Compare commits

...

65 Commits

Author SHA1 Message Date
Cody Henthorne fbbcadf09b Bump version to 8.12.2 2026-05-24 14:05:47 -04:00
Cody Henthorne 3fc6ac3871 Update translations and other static files. 2026-05-24 13:56:15 -04:00
Cody Henthorne 6a2ec01c52 Fix duplicate key crash in contact search lists. 2026-05-24 13:29:41 -04:00
Cody Henthorne c1b3fb6d1b Fix profile fetch crash when skipping debounce. 2026-05-24 10:32:58 -04:00
jeffrey-signal 792d86f4d8 Bump version to 8.12.1 2026-05-21 17:06:28 -04:00
jeffrey-signal 849856cde8 Update baseline profile. 2026-05-21 16:58:59 -04:00
jeffrey-signal 0646418d4d Update translations and other static files. 2026-05-21 16:36:58 -04:00
Alex Hart ed540a2f9e Update model key entry. 2026-05-21 16:57:14 -03:00
Cody Henthorne 00d86101f5 Fix crash when no recent recipients for profile refresh.
Fixes #14791
2026-05-21 15:38:37 -04:00
jeffrey-signal 86e49cd564 Bump version to 8.12.0 2026-05-21 11:12:07 -04:00
jeffrey-signal 21b7d64fcd Update baseline profile. 2026-05-21 10:50:28 -04:00
jeffrey-signal 42c0044096 Update translations and other static files. 2026-05-21 10:43:07 -04:00
Michelle Tang f2e8b83604 Update reg v5 UI for quick restore. 2026-05-21 10:38:23 -04:00
Michelle Tang 46b8ac6561 Update reg v5 UI for local backup v1 account. 2026-05-21 10:38:22 -04:00
Michelle Tang 9089cc393e Update reg v5 UI for locked account. 2026-05-21 10:38:22 -04:00
Alex Hart 2ea59bef68 Handle PniChangeNumber sync on linked devices. 2026-05-21 10:38:22 -04:00
Alex Hart 698fc38aed Migrate ContactSearch RV to MappingLazyColumn. 2026-05-21 10:38:22 -04:00
Michelle Tang 1d74b00b91 Update local backups reg v5 UI. 2026-05-21 10:38:21 -04:00
Greyson Parrelli ea861fff49 Remove unnecessary link test. 2026-05-21 10:38:21 -04:00
Cody Henthorne 3b93edcdaf Add verification code requested alert handling. 2026-05-21 10:38:21 -04:00
Cody Henthorne 6722a28f98 Fix broken linkify unit tests. 2026-05-21 10:38:21 -04:00
Cody Henthorne 16de2efa9e Fix non-instrumentation variants not being able to run unit tests. 2026-05-21 10:38:20 -04:00
Michelle Tang 4d0919c9a8 Fix minor safety number UI issues. 2026-05-21 10:38:20 -04:00
Greyson Parrelli 01e1cb4d67 Hide keyboard before navigating to chat settings. 2026-05-21 10:38:20 -04:00
Michelle Tang 9d1d5142da Turn on key transparency. 2026-05-21 10:38:20 -04:00
Alex Hart 49f0c2502b Add IncomingMessageObserver integration test infrastructure. 2026-05-21 10:38:19 -04:00
Michelle Tang 71ffc36e7f Update key transparency string. 2026-05-21 10:38:19 -04:00
Greyson Parrelli 5941ff814d Improve perf for backup info in debuglogs. 2026-05-21 10:38:19 -04:00
Cody Henthorne 1661f3b5f7 Improve profile fetch performance for large groups. 2026-05-21 10:38:18 -04:00
Cody Henthorne d682de08d2 Sort pinned chats like chat list for shortcut list. 2026-05-21 10:38:18 -04:00
Cody Henthorne 5321f8124a Add additional download checks to long message text attachments. 2026-05-21 10:38:18 -04:00
Michelle Tang 5052f22d44 Fix local restore crash from file selection. 2026-05-21 10:38:18 -04:00
Michelle Tang 9a8cb1785b Update reg v5 to use new scaffold. 2026-05-21 10:38:17 -04:00
Cody Henthorne a2065becdd fixup! Fix chat bubbles not rendering due to ConstraintLayout bug. 2026-05-21 10:38:17 -04:00
Greyson Parrelli e85637a58d Inline useBinaryId remote config. 2026-05-21 10:38:17 -04:00
Greyson Parrelli 73c3d141e3 Remove USE_STRING_ID build config. 2026-05-21 10:38:17 -04:00
jeffrey-signal eafba156ba RegistrationScaffold will automatically add top padding when there is no header. 2026-05-21 10:38:17 -04:00
Greyson Parrelli e6beafd612 Update video demo project to support batch transcoding. 2026-05-21 10:38:16 -04:00
Cody Henthorne a9649fd017 Enforce change number post registration delay. 2026-05-21 10:38:16 -04:00
adel-signal 4decae274b Update to RingRTC v2.69.1 2026-05-21 10:38:16 -04:00
gram-signal dbb83d86e3 Add remote config for requirePqRatio. 2026-05-21 10:38:15 -04:00
Greyson Parrelli 2aa27df95b Add SignalRestClient. 2026-05-21 10:38:15 -04:00
Cody Henthorne ec47b83f76 Add sync message encrypt local metric to send flows. 2026-05-21 10:38:15 -04:00
Cody Henthorne 6eea4ba937 Sync release note channel settings with storage service. 2026-05-21 10:38:15 -04:00
Cody Henthorne 9f608337f1 Add friendly toasts for forced remote config refresh. 2026-05-21 10:38:14 -04:00
jeffrey-signal 28edcdf62d Update regV5 permissions screen to use RegistrationScaffold. 2026-05-21 10:38:14 -04:00
jeffrey-signal 10d969ea35 Add two pane registration scaffold. 2026-05-21 10:38:14 -04:00
jeffrey-signal 38bac16640 Add separate window breakpoints for windows with large widths vs large heights. 2026-05-21 10:36:50 -04:00
jeffrey-signal 93077ac457 Bump version to 8.11.4 2026-05-21 10:24:18 -04:00
jeffrey-signal c069eb1b88 Update baseline profile. 2026-05-21 10:08:50 -04:00
jeffrey-signal e5cd18bf1e Update translations and other static files. 2026-05-21 10:02:16 -04:00
Alex Hart 9e8ae7e26a Only update desktop activity timestamp for user-initiated sync messages.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-20 12:42:52 -03:00
Cody Henthorne 00042b9579 Stop screen sharing when disabled from system UI. 2026-05-20 10:06:18 -04:00
Greyson Parrelli e750b81a31 Disable dnsjava hosts file parsing to fix NPE race condition. 2026-05-20 10:05:39 -04:00
Greyson Parrelli daec317f52 Don't auto-snooze donation megaphones. 2026-05-20 10:02:35 -04:00
Greyson Parrelli 112514c221 Remove persistent play services error notification.
Fixes #14786
2026-05-19 18:05:05 -04:00
Cody Henthorne f43db8ace0 Fix chat bubbles not rendering due to ConstraintLayout bug.
Resolves signalapp/Signal-Android#14774
2026-05-19 18:05:04 -04:00
jeffrey-signal 54df95727b Bump version to 8.11.3 2026-05-19 12:12:47 -04:00
jeffrey-signal 022b4d9508 Update baseline profile. 2026-05-19 11:58:37 -04:00
jeffrey-signal 7411e725ec Update translations and other static files. 2026-05-19 11:51:57 -04:00
Alex Hart 83a279f422 Fix display bug with donation type. 2026-05-19 11:42:12 -04:00
Michelle Tang 523066d093 Turn off key transparency. 2026-05-19 10:39:06 -04:00
Michelle Tang de27343c24 Update key transparency api. 2026-05-19 10:38:08 -04:00
Michelle Tang c36179293e Fix missing safety number dialog. 2026-05-18 14:13:59 -04:00
andrew-signal a79a91bafb Bump to libsignal v0.94.1 2026-05-18 14:09:27 -04:00
341 changed files with 24066 additions and 13712 deletions
+15 -4
View File
@@ -27,8 +27,8 @@ plugins {
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
val canonicalVersionCode = 1690
val canonicalVersionName = "8.11.2"
val canonicalVersionCode = 1695
val canonicalVersionName = "8.12.2"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -100,6 +100,9 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).conf
if (!isTestTask && (name.contains("Mocked") || name.contains("Benchmark"))) {
source("$projectDir/src/benchmarkShared/java")
}
if (isTestTask && name.contains("AndroidTest")) {
source("$projectDir/src/benchmarkShared/java")
}
}
wire {
@@ -168,6 +171,7 @@ android {
getByName("androidTest") {
java.srcDir("$projectDir/src/testShared")
java.srcDir("$projectDir/src/benchmarkShared/java")
}
}
@@ -272,7 +276,6 @@ android {
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\"")
buildConfigField("boolean", "TRACING_ENABLED", "false")
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "false")
buildConfigField("boolean", "USE_STRING_ID", "false")
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
@@ -288,7 +291,11 @@ android {
}
}
testInstrumentationRunner = "org.thoughtcrime.securesms.testing.SignalTestRunner"
testInstrumentationRunner = if (project.hasProperty("imoTests")) {
"org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverTestRunner"
} else {
"org.thoughtcrime.securesms.testing.SignalTestRunner"
}
testInstrumentationRunnerArguments["clearPackageData"] = "true"
}
@@ -346,6 +353,7 @@ android {
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Instrumentation\"")
buildConfigField("String", "STRIPE_BASE_URL", "\"http://127.0.0.1:8080/stripe\"")
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{ \"BVT/2gHqbrG1xzuIypLIOjFgMtihrMld1/5TGADL6Dhv\"}")
}
create("spinner") {
@@ -516,6 +524,9 @@ android {
androidComponents {
beforeVariants { variant ->
variant.enable = variant.name in selectableVariants
if (variant.enable) {
(variant as? com.android.build.api.variant.HasUnitTestBuilder)?.enableUnitTest = true
}
}
onVariants(selector().all()) { variant: com.android.build.api.variant.ApplicationVariant ->
// Rename APK to include version name
@@ -0,0 +1,36 @@
package org.thoughtcrime.securesms
import org.signal.core.util.logging.AndroidLogger
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverDependencyProvider
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverTestRunner
/**
* Application used when running `IncomingMessageObserver` instrumentation tests. Installs
* [IncomingMessageObserverDependencyProvider] so the websocket and job manager are replaced
* with test-friendly implementations. Selected by [IncomingMessageObserverTestRunner] when
* gradle is invoked with `-PimoTests`.
*/
class IncomingMessageObserverInstrumentationApplicationContext : ApplicationContext() {
override fun initializeAppDependencies() {
val default = ApplicationDependencyProvider(this)
AppDependencies.init(this, IncomingMessageObserverDependencyProvider(this, default))
AppDependencies.deadlockDetector.start()
}
override fun initializeLogging() {
Log.initialize({ true }, AndroidLogger)
SignalProtocolLoggerProvider.setProvider(CustomSignalProtocolLogger())
}
override fun beginJobLoop() = Unit
fun beginJobLoopForTests() {
super.beginJobLoop()
}
}
@@ -7,9 +7,9 @@ package org.thoughtcrime.securesms.database
import org.signal.core.util.Base64
import org.signal.core.util.Util
import org.signal.network.api.AttachmentUploadResult
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import kotlin.random.Random
@@ -10,11 +10,11 @@ import org.thoughtcrime.securesms.recipients.LiveRecipientCache
import org.whispersystems.signalservice.api.SignalServiceDataStore
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.account.AccountApi
import org.whispersystems.signalservice.api.attachment.AttachmentApi
import org.whispersystems.signalservice.api.donations.DonationsApi
import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.message.MessageApi
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.push.PushServiceSocket
/**
@@ -41,7 +41,7 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
return recipientCache
}
override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): ArchiveApi {
override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket, signalServiceConfiguration: SignalServiceConfiguration): ArchiveApi {
return mockk()
}
@@ -52,12 +52,11 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
override fun provideSignalServiceMessageSender(
protocolStore: SignalServiceDataStore,
pushServiceSocket: PushServiceSocket,
attachmentApi: AttachmentApi,
messageApi: MessageApi,
keysApi: KeysApi
): SignalServiceMessageSender {
if (signalServiceMessageSender == null) {
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, attachmentApi, messageApi, keysApi))
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, messageApi, keysApi))
}
return signalServiceMessageSender!!
}
@@ -25,6 +25,7 @@ import org.signal.core.util.Util
import org.signal.core.util.logging.Log
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.signal.network.api.AttachmentUploadResult
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.AttachmentTable
@@ -37,7 +38,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.MessageContentFuzzer.DeleteForMeSync
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.IdentityUtil
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import java.util.UUID
@@ -0,0 +1,353 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.messages
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isTrue
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.ServiceId
import org.signal.core.util.UuidUtil
import org.signal.core.util.orNull
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import org.thoughtcrime.securesms.crypto.PreKeyUtil
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.SyncMessage
import java.util.UUID
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class SyncMessageProcessorTest_synchronizePniChangeNumber {
@get:Rule
val harness = SignalActivityRule(createGroup = true)
private lateinit var messageHelper: MessageHelper
private val newPniUuid: UUID = UUID.randomUUID()
private val newPni: ServiceId.PNI = ServiceId.PNI.from(newPniUuid)
// 16-byte raw UUID — matches the actual wire format the server sends (per proto comment and
// iOS/Desktop behavior). Do NOT use `newPni.toByteString()` here — that produces libsignal's
// 17-byte ServiceIdBinary form, which is a different format.
private val newPniBytes: ByteString = UuidUtil.toByteArray(newPniUuid).toByteString()
private val newE164 = "+15555550199"
private val newPniIdentity: IdentityKeyPair = IdentityKeyPair.generate()
private val newSignedPreKey: SignedPreKeyRecord = PreKeyUtil.generateSignedPreKey(1234, newPniIdentity.privateKey)
private val newLastResortKyber: KyberPreKeyRecord = PreKeyUtil.generateLastResortKyberPreKey(5678, newPniIdentity.privateKey)
private val newRegistrationId = 4242
@Before
fun setUp() {
messageHelper = MessageHelper(harness)
SignalStore.account.deviceId = 2
}
@After
fun tearDown() {
messageHelper.tearDown()
}
@Test
fun appliesAllStateOnHappyPath() {
sendPniChangeNumber()
assertThat(SignalStore.account.e164).isEqualTo(newE164)
assertThat(SignalStore.account.pni).isEqualTo(newPni)
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(newRegistrationId)
assertThat(SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString())
.isEqualTo(newPniIdentity.publicKey.serialize().toByteString())
assertThat(SignalStore.account.pniPreKeys.activeSignedPreKeyId).isEqualTo(newSignedPreKey.id)
assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isTrue()
assertThat(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId).isEqualTo(newLastResortKyber.id)
assertThat(SignalStore.misc.forcePniSignedPreKeyRotation).isTrue()
val self = Recipient.self().fresh()
assertThat(self.requireE164()).isEqualTo(newE164)
assertThat(self.pni.orNull()).isEqualTo(newPni)
val pniProtocolStore = AppDependencies.protocolStore.pni()
val storedSigned = pniProtocolStore.loadSignedPreKey(newSignedPreKey.id)
assertThat(storedSigned.serialize().toByteString()).isEqualTo(newSignedPreKey.serialize().toByteString())
val storedKyber = pniProtocolStore.loadLastResortKyberPreKeys().firstOrNull { it.id == newLastResortKyber.id }
assertThat(storedKyber).isNotNull()
assertThat(storedKyber!!.serialize().toByteString()).isEqualTo(newLastResortKyber.serialize().toByteString())
// The IdentityTable cache is keyed by ServiceId string, not RecipientId — for self, that's
// separate ACI and PNI rows. We want the PNI row, so look it up by the new PNI directly.
val selfPniIdentity = pniProtocolStore.getIdentity(SignalProtocolAddress(newPni.toString(), SignalServiceAddress.DEFAULT_DEVICE_ID))
assertThat(selfPniIdentity).isNotNull()
assertThat(selfPniIdentity!!.publicKey.serialize().toByteString())
.isEqualTo(newPniIdentity.publicKey.serialize().toByteString())
}
@Test
fun appliesStateWhenLastResortKyberAbsent() {
val original = captureOriginalState()
sendPniChangeNumber(lastResortKyberPreKey = null)
assertThat(SignalStore.account.e164).isEqualTo(newE164)
assertThat(SignalStore.account.pni).isEqualTo(newPni)
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(newRegistrationId)
assertThat(SignalStore.account.pniPreKeys.activeSignedPreKeyId).isEqualTo(newSignedPreKey.id)
assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isTrue()
// No kyber was supplied, so kyber metadata should be unchanged.
assertThat(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId).isEqualTo(original.lastResortKyberPreKeyId)
assertThat(SignalStore.misc.forcePniSignedPreKeyRotation).isTrue()
}
@Test
fun bailsWhenPrimaryDevice() {
SignalStore.account.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID
val original = captureOriginalState()
sendPniChangeNumber()
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenSourceIsNotPrimaryDevice() {
val original = captureOriginalState()
sendPniChangeNumber(sourceDeviceId = 3)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenEnvelopePniMissing() {
val original = captureOriginalState()
sendPniChangeNumber(envelopePniBinary = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenIdentityKeyPairMissing() {
val original = captureOriginalState()
sendPniChangeNumber(identityKeyPair = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenSignedPreKeyMissing() {
val original = captureOriginalState()
sendPniChangeNumber(signedPreKey = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenRegistrationIdMissing() {
val original = captureOriginalState()
sendPniChangeNumber(registrationId = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenRegistrationIdZero() {
val original = captureOriginalState()
sendPniChangeNumber(registrationId = 0)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenNewE164Missing() {
val original = captureOriginalState()
sendPniChangeNumber(e164 = null)
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenNewE164Empty() {
val original = captureOriginalState()
sendPniChangeNumber(e164 = "")
assertOriginalStatePreserved(original)
}
@Test
fun bailsWhenNewE164NotValid() {
val original = captureOriginalState()
sendPniChangeNumber(e164 = "not a phone number")
assertOriginalStatePreserved(original)
}
@Test
fun bailsOnMalformedIdentityKeyPair() {
val original = captureOriginalState()
sendPniChangeNumber(identityKeyPair = malformedBytes())
assertOriginalStatePreserved(original)
}
@Test
fun bailsOnMalformedSignedPreKey() {
val original = captureOriginalState()
sendPniChangeNumber(signedPreKey = malformedBytes())
assertOriginalStatePreserved(original)
}
@Test
fun bailsOnMalformedLastResortKyber() {
val original = captureOriginalState()
sendPniChangeNumber(lastResortKyberPreKey = malformedBytes())
assertOriginalStatePreserved(original)
}
@Test
fun skipsRedeliveryWhenPniAlreadyMatches() {
sendPniChangeNumber()
val afterFirstApply = captureOriginalState()
val otherIdentity = IdentityKeyPair.generate()
val otherSignedPreKey = PreKeyUtil.generateSignedPreKey(9999, otherIdentity.privateKey)
sendPniChangeNumber(
identityKeyPair = otherIdentity.serialize().toByteString(),
signedPreKey = otherSignedPreKey.serialize().toByteString(),
e164 = "+15555550100",
timestamp = messageHelper.nextStartTime() + 1000
)
assertOriginalStatePreserved(afterFirstApply)
}
@Test
fun bailsWhenServerTimestampStale() {
sendPniChangeNumber()
val afterFirstApply = captureOriginalState()
val otherPniUuid = UUID.randomUUID()
val otherPniBytes = UuidUtil.toByteArray(otherPniUuid).toByteString()
sendPniChangeNumber(
envelopePniBinary = otherPniBytes,
e164 = "+15555550100",
timestamp = messageHelper.nextStartTime() - 100_000L
)
assertOriginalStatePreserved(afterFirstApply)
}
private fun captureOriginalState(): OriginalState {
val self = Recipient.self().fresh()
return OriginalState(
e164 = SignalStore.account.e164,
pni = SignalStore.account.pni,
pniRegistrationId = SignalStore.account.pniRegistrationId,
isSignedPreKeyRegistered = SignalStore.account.pniPreKeys.isSignedPreKeyRegistered,
activeSignedPreKeyId = SignalStore.account.pniPreKeys.activeSignedPreKeyId,
lastResortKyberPreKeyId = SignalStore.account.pniPreKeys.lastResortKyberPreKeyId,
pniIdentityPublicKey = SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString(),
selfE164 = self.e164.orNull(),
selfPni = self.pni.orNull(),
forcePniSignedPreKeyRotation = SignalStore.misc.forcePniSignedPreKeyRotation
)
}
private fun assertOriginalStatePreserved(original: OriginalState) {
assertThat(SignalStore.account.e164).isEqualTo(original.e164)
assertThat(SignalStore.account.pni).isEqualTo(original.pni)
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(original.pniRegistrationId)
assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isEqualTo(original.isSignedPreKeyRegistered)
assertThat(SignalStore.account.pniPreKeys.activeSignedPreKeyId).isEqualTo(original.activeSignedPreKeyId)
assertThat(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId).isEqualTo(original.lastResortKyberPreKeyId)
assertThat(SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString())
.isEqualTo(original.pniIdentityPublicKey)
assertThat(SignalStore.misc.forcePniSignedPreKeyRotation).isEqualTo(original.forcePniSignedPreKeyRotation)
val self = Recipient.self().fresh()
assertThat(self.e164.orNull()).isEqualTo(original.selfE164)
assertThat(self.pni.orNull()).isEqualTo(original.selfPni)
}
private data class OriginalState(
val e164: String?,
val pni: ServiceId.PNI?,
val pniRegistrationId: Int,
val isSignedPreKeyRegistered: Boolean,
val activeSignedPreKeyId: Int,
val lastResortKyberPreKeyId: Int,
val pniIdentityPublicKey: ByteString,
val selfE164: String?,
val selfPni: ServiceId.PNI?,
val forcePniSignedPreKeyRotation: Boolean
)
private fun malformedBytes(): ByteString = byteArrayOf(0x00, 0x01, 0x02).toByteString()
private fun sendPniChangeNumber(
identityKeyPair: ByteString? = newPniIdentity.serialize().toByteString(),
signedPreKey: ByteString? = newSignedPreKey.serialize().toByteString(),
lastResortKyberPreKey: ByteString? = newLastResortKyber.serialize().toByteString(),
registrationId: Int? = newRegistrationId,
e164: String? = newE164,
envelopePniBinary: ByteString? = newPniBytes,
sourceDeviceId: Int = SignalServiceAddress.DEFAULT_DEVICE_ID,
timestamp: Long = messageHelper.nextStartTime()
) {
val content = Content(
syncMessage = SyncMessage(
pniChangeNumber = SyncMessage.PniChangeNumber(
identityKeyPair = identityKeyPair,
signedPreKey = signedPreKey,
lastResortKyberPreKey = lastResortKyberPreKey,
registrationId = registrationId,
newE164 = e164
)
)
)
val envelope = MessageContentFuzzer.envelope(
timestamp = timestamp,
updatedPniBinary = envelopePniBinary
)
messageHelper.processor.process(
envelope = envelope,
content = content,
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, sourceDeviceId = sourceDeviceId),
serverDeliveredTimestamp = timestamp + 10
)
}
}
@@ -0,0 +1,27 @@
package org.thoughtcrime.securesms.messages.incomingmessageobserver
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertMessageReceived
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertNoMessageReceived
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule
@RunWith(AndroidJUnit4::class)
class DecryptionErrorTest {
@get:Rule
val rule = IncomingMessageObserverRule(peerCount = 2)
@Test
fun malformedEnvelope_dropsMessage_butPipelineRecovers() {
val peer = rule.peers[0]
rule.deliver { malformedEnvelope() from peer }
assertNoMessageReceived(from = peer, body = "subsequent")
rule.deliver { text("subsequent") from peer }
assertMessageReceived(from = peer, body = "subsequent")
}
}
@@ -0,0 +1,39 @@
package org.thoughtcrime.securesms.messages.incomingmessageobserver
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertGroupMessageReceived
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule
@RunWith(AndroidJUnit4::class)
class IncomingGroupMessageTest {
@get:Rule
val rule = IncomingMessageObserverRule(peerCount = 5)
@Test
fun deliveredGroupText_isPersistedInGroupThread() {
val group = rule.testGroup
rule.deliver { groupText("hello group", group = group) from rule.peers[0] }
assertGroupMessageReceived(from = rule.peers[0], group = group, body = "hello group")
}
@Test
fun multipleGroupMembers_messagesPersistedFromEach() {
val group = rule.testGroup
rule.deliver {
groupText("from peer 0", group = group) from rule.peers[0]
groupText("from peer 1", group = group) from rule.peers[1]
groupText("from peer 2", group = group) from rule.peers[2]
}
assertGroupMessageReceived(from = rule.peers[0], group = group, body = "from peer 0")
assertGroupMessageReceived(from = rule.peers[1], group = group, body = "from peer 1")
assertGroupMessageReceived(from = rule.peers[2], group = group, body = "from peer 2")
}
}
@@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.messages.incomingmessageobserver
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertMessageReceived
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule
@RunWith(AndroidJUnit4::class)
class IncomingTextMessageTest {
@get:Rule
val rule = IncomingMessageObserverRule(peerCount = 2)
@Test
fun deliveredOneToOneText_isPersisted() {
rule.deliver { text("hello world") from rule.peers[0] }
assertMessageReceived(from = rule.peers[0], body = "hello world")
}
}
@@ -41,11 +41,12 @@ object MessageContentFuzzer {
/**
* Create an [Envelope].
*/
fun envelope(timestamp: Long, serverGuid: UUID = UUID.randomUUID()): Envelope {
fun envelope(timestamp: Long, serverGuid: UUID = UUID.randomUUID(), updatedPniBinary: ByteString? = null): Envelope {
return Envelope.Builder()
.clientTimestamp(timestamp)
.serverTimestamp(timestamp + 5)
.serverGuidBinary(serverGuid.toByteArray().toByteString())
.also { if (updatedPniBinary != null) it.updatedPniBinary(updatedPniBinary) }
.build()
}
@@ -0,0 +1,71 @@
package org.thoughtcrime.securesms.testing.incomingmessageobserver
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isTrue
import org.signal.benchmark.setup.OtherClient
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.SignalServiceAddress
/**
* Reads database state produced by [IncomingMessageObserverRule]-driven tests. Import members
* individually (e.g. `import …IncomingMessageObserverAssertions.assertMessageReceived`) so test
* bodies stay terse.
*/
object IncomingMessageObserverAssertions {
fun OtherClient.recipientId(): RecipientId = Recipient.externalPush(SignalServiceAddress(serviceId, e164)).id
fun findIncomingMessage(from: OtherClient, body: String): MessageRecord? {
val threadId = SignalDatabase.threads.getThreadIdFor(from.recipientId()) ?: return null
return SignalDatabase.messages.getConversation(threadId).use { cursor ->
MessageTable.MmsReader(cursor).use { reader -> reader.firstOrNull { it.body == body } }
}
}
fun findIncomingGroupMessage(from: OtherClient, group: GroupHandle, body: String): MessageRecord? {
val threadId = SignalDatabase.threads.getThreadIdFor(group.recipientId) ?: return null
return SignalDatabase.messages.getConversation(threadId).use { cursor ->
MessageTable.MmsReader(cursor).use { reader ->
reader.firstOrNull { it.body == body && it.fromRecipient.id == from.recipientId() }
}
}
}
fun assertMessageReceived(from: OtherClient, body: String) {
val record = findIncomingMessage(from, body)
assertThat(record, "incoming message with body \"$body\" from ${from.serviceId} not found").isNotNull()
assertThat(record!!.fromRecipient.id, "incoming message sender mismatch for body \"$body\"").isEqualTo(from.recipientId())
}
fun assertGroupMessageReceived(from: OtherClient, group: GroupHandle, body: String) {
val record = findIncomingGroupMessage(from, group, body)
assertThat(record, "group message \"$body\" from ${from.serviceId} in ${group.groupId} not found").isNotNull()
}
fun assertNoMessageReceived(from: OtherClient, body: String) {
val record = findIncomingMessage(from, body)
assertThat(record == null, "expected no message with body \"$body\" from ${from.serviceId}, but found one").isTrue()
}
fun assertNoMessagesInThread(recipientId: RecipientId) {
val threadId = SignalDatabase.threads.getThreadIdFor(recipientId) ?: return
val count = SignalDatabase.messages.getConversation(threadId).use { cursor -> cursor.count }
assertThat(count, "expected thread for $recipientId to be empty, but message count was").isEqualTo(0)
}
fun assertDeliveryReceipt(outgoingMessageId: Long) {
val record = SignalDatabase.messages.getMessageRecord(outgoingMessageId)
assertThat(record.hasDeliveryReceipt(), "expected delivery receipt on outgoing message $outgoingMessageId, but none recorded").isTrue()
}
fun assertReadReceipt(outgoingMessageId: Long) {
val record = SignalDatabase.messages.getMessageRecord(outgoingMessageId)
assertThat(record.hasReadReceipt(), "expected read receipt on outgoing message $outgoingMessageId, but none recorded").isTrue()
}
}
@@ -0,0 +1,63 @@
package org.thoughtcrime.securesms.testing.incomingmessageobserver
import android.app.Application
import org.signal.benchmark.setup.NoOpJob
import org.signal.libsignal.net.Network
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.jobmanager.JobManager
import org.thoughtcrime.securesms.jobs.JobManagerFactories
import org.whispersystems.signalservice.api.util.UptimeSleepTimer
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
import java.util.function.Supplier
import kotlin.time.Duration.Companion.seconds
/**
* Dependency provider used by [org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext].
* Composes [InstrumentationApplicationDependencyProvider] (so existing mocks for the account /
* archive / donations / billing APIs are reused) and overrides:
*
* - the auth and unauth websocket factories with [BenchmarkWebSocketConnection], so tests can
* inject encrypted envelopes through the real ingest pipeline;
* - the job manager, swapping the startup network jobs handled by [NoOpJob.replaceFactories]
* to no-ops so they can't fire against unstubbed mocks during a test.
*/
class IncomingMessageObserverDependencyProvider(
private val application: Application,
default: ApplicationDependencyProvider
) : AppDependencies.Provider by InstrumentationApplicationDependencyProvider(application, default) {
override fun provideAuthWebSocket(
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
libSignalNetworkSupplier: Supplier<Network>
): SignalWebSocket.AuthenticatedWebSocket {
return SignalWebSocket.AuthenticatedWebSocket(
connectionFactory = { BenchmarkWebSocketConnection.createAuthInstance() },
canConnect = { true },
sleepTimer = UptimeSleepTimer(),
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
)
}
override fun provideUnauthWebSocket(
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
libSignalNetworkSupplier: Supplier<Network>
): SignalWebSocket.UnauthenticatedWebSocket {
return SignalWebSocket.UnauthenticatedWebSocket(
connectionFactory = { BenchmarkWebSocketConnection.createUnauthInstance() },
canConnect = { true },
sleepTimer = UptimeSleepTimer(),
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
)
}
override fun provideJobManager(configurationBuilder: JobManager.Configuration.Builder): JobManager {
val config = configurationBuilder
.setJobFactories(NoOpJob.replaceFactories(JobManagerFactories.getJobFactories(application)))
.build()
return JobManager(application, config)
}
}
@@ -0,0 +1,201 @@
package org.thoughtcrime.securesms.testing.incomingmessageobserver
import okio.ByteString.Companion.toByteString
import org.junit.Assume
import org.junit.rules.ExternalResource
import org.signal.benchmark.setup.Generator
import org.signal.benchmark.setup.Harness
import org.signal.benchmark.setup.OtherClient
import org.signal.benchmark.setup.TestUsers
import org.signal.core.util.logging.Log
import org.signal.network.websocket.WebSocketRequestMessage
import org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.MarkerJob
import org.thoughtcrime.securesms.jobs.PushProcessMessageJob
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
import java.util.concurrent.CopyOnWriteArraySet
import kotlin.random.Random
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* JUnit rule that drives [org.thoughtcrime.securesms.messages.IncomingMessageObserver] from
* instrumentation tests. Sets up self, registers [peerCount] simulated peers from
* [Harness.otherClients], establishes a Signal double-ratchet session with each, and exposes a
* small DSL for delivering encrypted envelopes through the real ingest pipeline:
*
* ```
* @get:Rule val rule = IncomingMessageObserverRule(peerCount = 2)
*
* @Test fun example() {
* rule.deliver { text("hi") from rule.peers[0] }
* rule.deliver { groupText("hi all", group = rule.testGroup) from rule.peers[0] }
* }
* ```
*
* Run with `-PimoTests`; tests are skipped under the default runner. Throws on drain timeout.
* Mutually exclusive with `SignalDatabaseRule` / `SignalActivityRule` — all three claim the
* local identity.
*/
class IncomingMessageObserverRule(
private val peerCount: Int = 2,
private val drainTimeout: Duration = 30.seconds
) : ExternalResource() {
lateinit var self: Recipient
private set
lateinit var peers: List<OtherClient>
private set
/** Lazily-created group. Touching this from a test triggers setup; tests that don't use groups pay nothing. */
val testGroup: GroupHandle by lazy {
val gid = TestUsers.setupGroup(withLabels = false)
GroupHandle(gid, Recipient.externalGroupExact(gid).id)
}
override fun before() {
Assume.assumeTrue(
"IncomingMessageObserverRule requires the IMO test runner — run with -PimoTests",
AppDependencies.application is IncomingMessageObserverInstrumentationApplicationContext
)
self = TestUsers.setupSelf()
TestUsers.setupTestClients(peerCount)
peers = Harness.otherClients.take(peerCount)
val app = AppDependencies.application as IncomingMessageObserverInstrumentationApplicationContext
app.beginJobLoopForTests()
// IncomingMessageObserver caches `canProcessMessages` from restoreDecisionState at thread
// construction. If it was built before setupSelf() flipped the state it will silently drop
// every message; reset network so a fresh observer is constructed.
AppDependencies.incomingMessageObserver.notifyRestoreDecisionMade()
AppDependencies.startNetwork()
forceObserverConstruction()
val handshakeEnvelopes = peers.map { client ->
client.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis()))
}
deliverEnvelopes(handshakeEnvelopes)
peers.forEach { it.completeSession() }
}
fun deliver(builder: DeliveryBuilder.() -> Unit) {
val collected = DeliveryBuilder().apply(builder).specs
if (collected.isEmpty()) return
deliverEnvelopes(collected.map { it.materialize() })
}
private fun forceObserverConstruction() {
AppDependencies.incomingMessageObserver
}
private fun deliverEnvelopes(envelopes: List<Envelope>) {
val jobManager = AppDependencies.jobManager
val seenQueues = CopyOnWriteArraySet<String>()
val queueListener = object : JobTracker.JobListener {
override fun onStateChanged(job: Job, jobState: JobTracker.JobState) {
job.parameters.queue?.let { queue ->
if (queue.startsWith(PushProcessMessageJob.QUEUE_PREFIX)) {
seenQueues += queue
}
}
}
}
jobManager.addListener({ job: Job -> job.parameters.queue?.startsWith(PushProcessMessageJob.QUEUE_PREFIX) == true }, queueListener)
try {
BenchmarkWebSocketConnection.addPendingMessages(envelopes.map { it.toWebSocketPayload() })
BenchmarkWebSocketConnection.addQueueEmptyMessage()
BenchmarkWebSocketConnection.releaseMessages()
val consumed = BenchmarkWebSocketConnection.awaitAllMessagesConsumed(drainTimeout.inWholeMilliseconds)
check(consumed) { "Timed out waiting for benchmark websocket to consume ${envelopes.size} envelope(s)" }
// PushProcessMessageJob enqueue happens on a background thread after the websocket marks
// messages consumed; this tick lets that settle before we snapshot the queues to wait on.
Thread.sleep(100)
val queuesToDrain = seenQueues.toSet()
Log.d(TAG, "Awaiting ${queuesToDrain.size} PushProcessMessageJob queue(s): $queuesToDrain")
for (queue in queuesToDrain) {
val state = jobManager.runSynchronously(MarkerJob(queue), drainTimeout.inWholeMilliseconds)
check(state.isPresent) { "Timed out waiting for queue $queue to drain" }
}
} finally {
jobManager.removeListener(queueListener)
}
}
companion object {
private val TAG = Log.tag(IncomingMessageObserverRule::class)
private fun Envelope.toWebSocketPayload(): WebSocketRequestMessage = WebSocketRequestMessage(
verb = "PUT",
path = "/api/v1/message",
id = Random.nextLong(),
headers = listOf("X-Signal-Timestamp: $serverTimestamp"),
body = encodeByteString()
)
}
}
/** Identifies the test group created by [IncomingMessageObserverRule]. Hold a reference to pass into the [DeliveryBuilder.groupText] DSL. */
data class GroupHandle(val groupId: GroupId.V2, val recipientId: RecipientId)
/**
* Receiver of the DSL passed to [IncomingMessageObserverRule.deliver]. Construct content with
* [text] / [groupText] / [deliveryReceipts] / [readReceipts] / [malformedEnvelope] and chain
* with the [from] infix to attach a sending peer. Each `from` adds the resulting envelope to
* the batch that will be delivered when the lambda returns.
*/
class DeliveryBuilder internal constructor() {
internal val specs = mutableListOf<EnvelopeSpec>()
fun text(body: String, timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Text(body, timestamp, group = null)
fun groupText(body: String, group: GroupHandle, timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Text(body, timestamp, group)
fun deliveryReceipts(targets: List<Long>, sentAt: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.DeliveryReceipt(targets, sentAt)
fun readReceipts(targets: List<Long>, sentAt: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.ReadReceipt(targets, sentAt)
fun malformedEnvelope(timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Malformed(timestamp)
infix fun EnvelopeContentSpec.from(peer: OtherClient) {
specs += EnvelopeSpec(this, peer)
}
}
/** Opaque envelope content returned by [DeliveryBuilder]. Tests never construct or inspect variants directly; the type only appears as a return / receiver of the DSL methods. */
sealed class EnvelopeContentSpec {
internal data class Text(val body: String, val timestamp: Long, val group: GroupHandle?) : EnvelopeContentSpec()
internal data class DeliveryReceipt(val targets: List<Long>, val sentAt: Long) : EnvelopeContentSpec()
internal data class ReadReceipt(val targets: List<Long>, val sentAt: Long) : EnvelopeContentSpec()
internal data class Malformed(val timestamp: Long) : EnvelopeContentSpec()
}
internal data class EnvelopeSpec(val content: EnvelopeContentSpec, val peer: OtherClient) {
fun materialize(): Envelope = when (val c = content) {
is EnvelopeContentSpec.Text ->
peer.encrypt(Generator.encryptedTextMessage(c.timestamp, c.body, c.group?.let { Harness.groupMasterKey }))
is EnvelopeContentSpec.DeliveryReceipt ->
peer.encrypt(Generator.encryptedDeliveryReceipt(c.sentAt, c.targets), c.sentAt)
is EnvelopeContentSpec.ReadReceipt ->
peer.encrypt(Generator.encryptedReadReceipt(c.sentAt, c.targets), c.sentAt)
is EnvelopeContentSpec.Malformed -> {
val valid = peer.encrypt(Generator.encryptedTextMessage(c.timestamp))
val original = valid.content ?: error("Encrypted envelope unexpectedly had no content")
val corrupted = original.toByteArray().also { it[it.size / 2] = (it[it.size / 2].toInt() xor 0x01).toByte() }
valid.copy(content = corrupted.toByteString())
}
}
}
@@ -0,0 +1,18 @@
package org.thoughtcrime.securesms.testing.incomingmessageobserver
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext
/**
* Test runner that swaps in [IncomingMessageObserverInstrumentationApplicationContext] so the
* `IncomingMessageObserver` test harness can drive a faked websocket. Selected automatically by
* the build when `-PimoTests` is set.
*/
@Suppress("unused")
class IncomingMessageObserverTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
return super.newApplication(cl, IncomingMessageObserverInstrumentationApplicationContext::class.java.name, context)
}
}
@@ -6,55 +6,13 @@
package org.thoughtcrime.securesms
import android.app.Application
import org.signal.benchmark.setup.NoOpJob
import org.signal.libsignal.net.Network
import org.thoughtcrime.securesms.database.JobDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.JobManager
import org.thoughtcrime.securesms.jobmanager.JobMigrator
import org.thoughtcrime.securesms.jobmanager.impl.FactoryJobPredicate
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob
import org.thoughtcrime.securesms.jobs.ArchiveBackupIdReservationJob
import org.thoughtcrime.securesms.jobs.AttachmentCompressionJob
import org.thoughtcrime.securesms.jobs.AttachmentUploadJob
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob
import org.thoughtcrime.securesms.jobs.FastJobStorage
import org.thoughtcrime.securesms.jobs.FontDownloaderJob
import org.thoughtcrime.securesms.jobs.GroupCallUpdateSendJob
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob
import org.thoughtcrime.securesms.jobs.IndividualSendJob
import org.thoughtcrime.securesms.jobs.JobManagerFactories
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
import org.thoughtcrime.securesms.jobs.MarkerJob
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
import org.thoughtcrime.securesms.jobs.PostRegistrationBackupRedemptionJob
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.jobs.PushGroupSendJob
import org.thoughtcrime.securesms.jobs.PushProcessMessageJob
import org.thoughtcrime.securesms.jobs.ReactionSendJob
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
import org.thoughtcrime.securesms.jobs.SendDeliveryReceiptJob
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
import org.thoughtcrime.securesms.jobs.StorageSyncJob
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
import org.thoughtcrime.securesms.jobs.TypingSendJob
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.util.UptimeSleepTimer
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
@@ -97,85 +55,11 @@ class BenchmarkApplicationContext : ApplicationContext() {
)
}
override fun provideJobManager(): JobManager {
val config = JobManager.Configuration.Builder()
.setJobFactories(filterJobFactories(JobManagerFactories.getJobFactories(application)))
.setConstraintFactories(JobManagerFactories.getConstraintFactories(application))
.setConstraintObservers(JobManagerFactories.getConstraintObservers(application))
.setJobStorage(FastJobStorage(JobDatabase.getInstance(application)))
.setJobMigrator(JobMigrator(TextSecurePreferences.getJobManagerVersion(application), JobManager.CURRENT_VERSION, JobManagerFactories.getJobMigrations(application)))
.addReservedJobRunner(FactoryJobPredicate(PushProcessMessageJob.KEY, MarkerJob.KEY))
.addReservedJobRunner(FactoryJobPredicate(AttachmentUploadJob.KEY, AttachmentCompressionJob.KEY))
.addReservedJobRunner(
FactoryJobPredicate(
IndividualSendJob.KEY,
PushGroupSendJob.KEY,
ReactionSendJob.KEY,
TypingSendJob.KEY,
GroupCallUpdateSendJob.KEY,
SendDeliveryReceiptJob.KEY
)
)
override fun provideJobManager(configurationBuilder: JobManager.Configuration.Builder): JobManager {
val config = configurationBuilder
.setJobFactories(NoOpJob.replaceFactories(JobManagerFactories.getJobFactories(application)))
.build()
return JobManager(application, config)
}
private fun filterJobFactories(jobFactories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> {
val blockedJobs = setOf(
AccountConsistencyWorkerJob.KEY,
ArchiveBackupIdReservationJob.KEY,
AvatarGroupsV2DownloadJob.KEY,
CreateReleaseChannelJob.KEY,
DirectoryRefreshJob.KEY,
DownloadLatestEmojiDataJob.KEY,
EmojiSearchIndexDownloadJob.KEY,
FontDownloaderJob.KEY,
GroupRingCleanupJob.KEY,
GroupV2UpdateSelfProfileKeyJob.KEY,
LinkedDeviceInactiveCheckJob.KEY,
MultiDeviceProfileKeyUpdateJob.KEY,
PostRegistrationBackupRedemptionJob.KEY,
PreKeysSyncJob.KEY,
ProfileUploadJob.KEY,
RefreshAttributesJob.KEY,
RefreshSvrCredentialsJob.KEY,
RequestGroupV2InfoJob.KEY,
ResetSvrGuessCountJob.KEY,
RestoreOptimizedMediaJob.KEY,
RetrieveProfileAvatarJob.KEY,
RetrieveProfileJob.KEY,
RetrieveRemoteAnnouncementsJob.KEY,
RotateCertificateJob.KEY,
StickerPackDownloadJob.KEY,
StorageSyncJob.KEY,
StoryOnboardingDownloadJob.KEY
)
return jobFactories.mapValues {
if (it.key in blockedJobs) {
NoOpJob.Factory()
} else {
it.value
}
}
}
}
private class NoOpJob(parameters: Parameters) : Job(parameters) {
companion object {
const val KEY = "NoOpJob"
}
override fun serialize(): ByteArray? = null
override fun getFactoryKey(): String = KEY
override fun run(): Result = Result.success()
override fun onFailure() = Unit
class Factory : Job.Factory<NoOpJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): NoOpJob {
return NoOpJob(parameters)
}
}
}
}
@@ -0,0 +1,84 @@
package org.signal.benchmark.setup
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob
import org.thoughtcrime.securesms.jobs.ArchiveBackupIdReservationJob
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob
import org.thoughtcrime.securesms.jobs.FontDownloaderJob
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
import org.thoughtcrime.securesms.jobs.PostRegistrationBackupRedemptionJob
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
import org.thoughtcrime.securesms.jobs.StorageSyncJob
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
/**
* A [Job] that does nothing and always succeeds. Test setups substitute this for jobs whose
* real implementations would hit the network at startup (and so would either generate noise
* against the [DeviceTransferBlockingInterceptor][org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor]
* or fail against unstubbed mocks). Use [replaceFactories] to apply the swap.
*/
class NoOpJob(parameters: Parameters) : Job(parameters) {
override fun serialize(): ByteArray? = null
override fun getFactoryKey(): String = KEY
override fun run(): Result = Result.success()
override fun onFailure() = Unit
class Factory : Job.Factory<NoOpJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): NoOpJob = NoOpJob(parameters)
}
companion object {
const val KEY = "NoOpJob"
private val STARTUP_NETWORK_JOB_KEYS: Set<String> = setOf(
AccountConsistencyWorkerJob.KEY,
ArchiveBackupIdReservationJob.KEY,
AvatarGroupsV2DownloadJob.KEY,
CreateReleaseChannelJob.KEY,
DirectoryRefreshJob.KEY,
DownloadLatestEmojiDataJob.KEY,
EmojiSearchIndexDownloadJob.KEY,
FontDownloaderJob.KEY,
GroupRingCleanupJob.KEY,
GroupV2UpdateSelfProfileKeyJob.KEY,
LinkedDeviceInactiveCheckJob.KEY,
MultiDeviceProfileKeyUpdateJob.KEY,
PostRegistrationBackupRedemptionJob.KEY,
PreKeysSyncJob.KEY,
ProfileUploadJob.KEY,
RefreshAttributesJob.KEY,
RefreshSvrCredentialsJob.KEY,
RequestGroupV2InfoJob.KEY,
ResetSvrGuessCountJob.KEY,
RestoreOptimizedMediaJob.KEY,
RetrieveProfileAvatarJob.KEY,
RetrieveProfileJob.KEY,
RetrieveRemoteAnnouncementsJob.KEY,
RotateCertificateJob.KEY,
StickerPackDownloadJob.KEY,
StorageSyncJob.KEY,
StoryOnboardingDownloadJob.KEY
)
fun replaceFactories(factories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> =
factories.mapValues { if (it.key in STARTUP_NETWORK_JOB_KEYS) Factory() else it.value }
}
}
@@ -68,6 +68,18 @@ class BenchmarkWebSocketConnection : WebSocketConnection {
fun addQueueEmptyMessage() {
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.addQueueEmptyMessage() }
}
fun awaitAllMessagesConsumed(timeoutMs: Long): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val activeInstances = synchronized(this) { authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).toList() }
if (activeInstances.isNotEmpty() && activeInstances.all { it.incomingRequests.isEmpty() && it.incomingSemaphore.availablePermits() == 0 }) {
return true
}
Thread.sleep(25)
}
return false
}
}
override val name: String = "bench-${System.identityHashCode(this)}"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -34,7 +34,6 @@ import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.ServiceUtil;
import java.io.IOException;
import java.lang.ref.WeakReference;
@@ -49,8 +48,7 @@ import java.util.function.Consumer;
*/
public abstract class ContactSelectionActivity extends PassphraseRequiredActivity
implements SwipeRefreshLayout.OnRefreshListener,
ContactSelectionListFragment.OnContactSelectedListener,
ContactSelectionListFragment.ScrollCallback
ContactSelectionListFragment.OnContactSelectedListener
{
private static final String TAG = Log.tag(ContactSelectionActivity.class);
@@ -136,17 +134,6 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
@Override
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {}
@Override
public void onBeginScroll() {
hideKeyboard();
}
private void hideKeyboard() {
ServiceUtil.getInputMethodManager(this)
.hideSoftInputFromWindow(toolbar.getWindowToken(), 0);
toolbar.clearFocus();
}
private static class RefreshDirectoryTask extends AsyncTask<Context, Void, Void> {
private final WeakReference<ContactSelectionActivity> activity;
@@ -1,16 +1,19 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.view.View
import android.widget.TextView
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.ContactSelectionListModels.FindByPhoneNumberModel
import org.thoughtcrime.securesms.ContactSelectionListModels.FindByUsernameModel
import org.thoughtcrime.securesms.ContactSelectionListModels.FindContactsBannerModel
import org.thoughtcrime.securesms.ContactSelectionListModels.FindContactsModel
import org.thoughtcrime.securesms.ContactSelectionListModels.InviteToSignalModel
import org.thoughtcrime.securesms.ContactSelectionListModels.MoreHeaderModel
import org.thoughtcrime.securesms.ContactSelectionListModels.NewGroupModel
import org.thoughtcrime.securesms.ContactSelectionListModels.RefreshContactsModel
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
class ContactSelectionListAdapter(
context: Context,
@@ -23,152 +26,19 @@ class ContactSelectionListAdapter(
) : ContactSearchAdapter(context, fixedContacts, displayOptions, onClickCallbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) {
init {
registerFactory(NewGroupModel::class.java, LayoutFactory({ NewGroupViewHolder(it, onClickCallbacks::onNewGroupClicked) }, R.layout.contact_selection_new_group_item))
registerFactory(InviteToSignalModel::class.java, LayoutFactory({ InviteToSignalViewHolder(it, onClickCallbacks::onInviteToSignalClicked) }, R.layout.contact_selection_invite_action_item))
registerFactory(FindContactsModel::class.java, LayoutFactory({ FindContactsViewHolder(it, onClickCallbacks::onFindContactsClicked) }, R.layout.contact_selection_find_contacts_item))
registerFactory(FindContactsBannerModel::class.java, LayoutFactory({ FindContactsBannerViewHolder(it, onClickCallbacks::onDismissFindContactsBannerClicked, onClickCallbacks::onFindContactsClicked) }, R.layout.contact_selection_find_contacts_banner_item))
registerFactory(RefreshContactsModel::class.java, LayoutFactory({ RefreshContactsViewHolder(it, onClickCallbacks::onRefreshContactsClicked) }, R.layout.contact_selection_refresh_action_item))
registerFactory(MoreHeaderModel::class.java, LayoutFactory({ MoreHeaderViewHolder(it) }, R.layout.contact_search_section_header))
registerFactory(EmptyModel::class.java, LayoutFactory({ EmptyViewHolder(it) }, R.layout.contact_selection_empty_state))
registerFactory(FindByUsernameModel::class.java, LayoutFactory({ FindByUsernameViewHolder(it, onClickCallbacks::onFindByUsernameClicked) }, R.layout.contact_selection_find_by_username_item))
registerFactory(FindByPhoneNumberModel::class.java, LayoutFactory({ FindByPhoneNumberViewHolder(it, onClickCallbacks::onFindByPhoneNumberClicked) }, R.layout.contact_selection_find_by_phone_number_item))
}
class NewGroupModel : MappingModel<NewGroupModel> {
override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true
override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true
}
class InviteToSignalModel : MappingModel<InviteToSignalModel> {
override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
}
class RefreshContactsModel : MappingModel<RefreshContactsModel> {
override fun areItemsTheSame(newItem: RefreshContactsModel): Boolean = true
override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true
}
class FindContactsModel : MappingModel<FindContactsModel> {
override fun areItemsTheSame(newItem: FindContactsModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsModel): Boolean = true
}
class FindContactsBannerModel : MappingModel<FindContactsBannerModel> {
override fun areItemsTheSame(newItem: FindContactsBannerModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsBannerModel): Boolean = true
}
class FindByUsernameModel : MappingModel<FindByUsernameModel> {
override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true
override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true
}
class FindByPhoneNumberModel : MappingModel<FindByPhoneNumberModel> {
override fun areItemsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
override fun areContentsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
}
class MoreHeaderModel : MappingModel<MoreHeaderModel> {
override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true
override fun areContentsTheSame(newItem: MoreHeaderModel): Boolean = true
}
private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<InviteToSignalModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: InviteToSignalModel) = Unit
}
private class NewGroupViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<NewGroupModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: NewGroupModel) = Unit
}
private class RefreshContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<RefreshContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: RefreshContactsModel) = Unit
}
private class FindContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsModel) = Unit
}
private class FindContactsBannerViewHolder(itemView: View, onDismissListener: () -> Unit, onClickListener: () -> Unit) : MappingViewHolder<FindContactsBannerModel>(itemView) {
init {
itemView.findViewById<MaterialButton>(R.id.no_thanks_button).setOnClickListener { onDismissListener() }
itemView.findViewById<MaterialButton>(R.id.allow_contacts_button).setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsBannerModel) = Unit
}
private class MoreHeaderViewHolder(itemView: View) : MappingViewHolder<MoreHeaderModel>(itemView) {
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
override fun bind(model: MoreHeaderModel) {
headerTextView.setText(R.string.contact_selection_activity__more)
}
}
private class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
private val emptyText: TextView = itemView.findViewById(R.id.search_no_results)
override fun bind(model: EmptyModel) {
emptyText.text = context.getString(R.string.SearchFragment_no_results, model.empty.query ?: "")
}
}
private class FindByPhoneNumberViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByPhoneNumberModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByPhoneNumberModel) = Unit
}
private class FindByUsernameViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByUsernameModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByUsernameModel) = Unit
ContactSelectionListModels.registerNewGroup(this, onClickCallbacks::onNewGroupClicked)
ContactSelectionListModels.registerInviteToSignal(this, onClickCallbacks::onInviteToSignalClicked)
ContactSelectionListModels.registerFindContacts(this, onClickCallbacks::onFindContactsClicked)
ContactSelectionListModels.registerFindContactsBanner(this, onClickCallbacks::onDismissFindContactsBannerClicked, onClickCallbacks::onFindContactsClicked)
ContactSelectionListModels.registerRefreshContacts(this, onClickCallbacks::onRefreshContactsClicked)
ContactSelectionListModels.registerMoreHeader(this)
ContactSelectionListModels.registerEmpty(this)
ContactSelectionListModels.registerFindByUsername(this, onClickCallbacks::onFindByUsernameClicked)
ContactSelectionListModels.registerFindByPhoneNumber(this, onClickCallbacks::onFindByPhoneNumberClicked)
}
class ArbitraryRepository : org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository {
enum class ArbitraryRow(val code: String) {
NEW_GROUP("new-group"),
INVITE_TO_SIGNAL("invite-to-signal"),
MORE_HEADING("more-heading"),
REFRESH_CONTACTS("refresh-contacts"),
FIND_CONTACTS("find-contacts"),
FIND_CONTACTS_BANNER("find-contacts-banner"),
FIND_BY_USERNAME("find-by-username"),
FIND_BY_PHONE_NUMBER("find-by-phone-number");
companion object {
fun fromCode(code: String) = entries.first { it.code == code }
}
}
override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int {
return section.types.size
}
@@ -179,15 +49,15 @@ class ContactSelectionListAdapter(
}
override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> {
return when (ArbitraryRow.fromCode(arbitrary.type)) {
ArbitraryRow.NEW_GROUP -> NewGroupModel()
ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
ArbitraryRow.MORE_HEADING -> MoreHeaderModel()
ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel()
ArbitraryRow.FIND_CONTACTS -> FindContactsModel()
ArbitraryRow.FIND_CONTACTS_BANNER -> FindContactsBannerModel()
ArbitraryRow.FIND_BY_PHONE_NUMBER -> FindByPhoneNumberModel()
ArbitraryRow.FIND_BY_USERNAME -> FindByUsernameModel()
return when (ContactSelectionListModels.ArbitraryRow.fromCode(arbitrary.type)) {
ContactSelectionListModels.ArbitraryRow.NEW_GROUP -> NewGroupModel()
ContactSelectionListModels.ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
ContactSelectionListModels.ArbitraryRow.MORE_HEADING -> MoreHeaderModel()
ContactSelectionListModels.ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel()
ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS -> FindContactsModel()
ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS_BANNER -> FindContactsBannerModel()
ContactSelectionListModels.ArbitraryRow.FIND_BY_PHONE_NUMBER -> FindByPhoneNumberModel()
ContactSelectionListModels.ArbitraryRow.FIND_BY_USERNAME -> FindByUsernameModel()
}
}
}
@@ -18,10 +18,8 @@ package org.thoughtcrime.securesms;
import android.Manifest;
import org.signal.core.ui.logging.LoggingFragment;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Rect;
import android.os.AsyncTask;
import android.os.Bundle;
import android.text.TextUtils;
@@ -38,27 +36,24 @@ import androidx.appcompat.app.AlertDialog;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import androidx.transition.AutoTransition;
import androidx.transition.TransitionManager;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.jetbrains.annotations.NotNull;
import org.signal.core.ui.logging.LoggingFragment;
import org.signal.core.ui.permissions.Permissions;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
import org.thoughtcrime.securesms.contacts.ContactChipViewModel;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.HeaderAction;
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.contacts.SelectedContacts;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
@@ -71,18 +66,19 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRep
import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.groups.SelectionLimits;
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.signal.core.ui.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameAciFetchResult;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UsernameUtil;
@@ -92,14 +88,13 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.stream.Collectors;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.disposables.Disposable;
import kotlin.Unit;
@@ -126,22 +121,17 @@ public final class ContactSelectionListFragment extends LoggingFragment {
private SwipeRefreshLayout swipeRefresh;
private String cursorFilter;
private ContactSearchView contactSearchView;
private RecyclerViewFastScroller fastScroller;
private RecyclerView chipRecycler;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
private MappingAdapter contactChipAdapter;
private ContactChipViewModel contactChipViewModel;
private LifecycleDisposable lifecycleDisposable;
private HeaderActionProvider headerActionProvider;
private TextView headerActionView;
private ContactSearchViewModel contactSearchViewModel;
@Nullable private RecyclerView innerRecyclerView;
@Nullable private LinearLayoutManager innerLayoutManager;
@Nullable private NewConversationCallback newConversationCallback;
@Nullable private FindByCallback findByCallback;
@Nullable private NewCallCallback newCallCallback;
@Nullable private ScrollCallback scrollCallback;
@Nullable private OnItemLongClickListener onItemLongClickListener;
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
private Set<RecipientId> currentSelection;
@@ -168,14 +158,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
setNewCallCallback((NewCallCallback) context);
}
if (getParentFragment() instanceof ScrollCallback) {
setScrollCallback((ScrollCallback) getParentFragment());
}
if (context instanceof ScrollCallback) {
setScrollCallback((ScrollCallback) context);
}
if (getParentFragment() instanceof OnContactSelectedListener) {
setOnContactSelectedListener((OnContactSelectedListener) getParentFragment());
}
@@ -221,10 +203,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
this.newCallCallback = callback;
}
public void setScrollCallback(@Nullable ScrollCallback callback) {
this.scrollCallback = callback;
}
public void setOnContactSelectedListener(@Nullable OnContactSelectedListener listener) {
this.onContactSelectedListener = listener;
}
@@ -259,10 +237,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
emptyText = view.findViewById(android.R.id.empty);
contactSearchView = view.findViewById(R.id.recycler_view);
swipeRefresh = view.findViewById(R.id.swipe_refresh);
fastScroller = view.findViewById(R.id.fast_scroller);
chipRecycler = view.findViewById(R.id.chipRecycler);
constraintLayout = view.findViewById(R.id.container);
headerActionView = view.findViewById(R.id.header_action);
contactChipViewModel = new ViewModelProvider(this).get(ContactChipViewModel.class);
contactChipAdapter = new MappingAdapter();
@@ -309,133 +285,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
)
).get(ContactSearchViewModel.class);
List<RecyclerView.OnScrollListener> scrollListeners = new ArrayList<>();
final HeaderAction headerAction;
if (headerActionProvider != null) {
headerAction = headerActionProvider.getHeaderAction();
headerActionView.setEnabled(true);
headerActionView.setText(headerAction.getLabel());
headerActionView.setCompoundDrawablesRelativeWithIntrinsicBounds(headerAction.getIcon(), 0, 0, 0);
headerActionView.setOnClickListener(v -> headerAction.getAction().run());
scrollListeners.add(new RecyclerView.OnScrollListener() {
private final Rect bounds = new Rect();
@Override
public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) {
if (hideLetterHeaders() || innerLayoutManager == null) {
return;
}
int firstPosition = innerLayoutManager.findFirstVisibleItemPosition();
if (firstPosition == 0) {
View firstChild = rv.getChildAt(0);
rv.getDecoratedBoundsWithMargins(firstChild, bounds);
headerActionView.setTranslationY(bounds.top);
}
}
});
} else {
headerActionView.setEnabled(false);
}
scrollListeners.add(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView rv, int newState) {
if (newState == RecyclerView.SCROLL_STATE_DRAGGING && scrollCallback != null) {
scrollCallback.onBeginScroll();
}
}
});
float contentBottomPaddingDp = fragmentArgs.getRecyclerPadBottom() != -1
? fragmentArgs.getRecyclerPadBottom() / getResources().getDisplayMetrics().density
: 0f;
ContactSearchAdapter.AdapterFactory adapterFactory =
(context, fc, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) ->
new ContactSelectionListAdapter(
context,
fc,
displayOptions,
new ContactSelectionListAdapter.OnContactSelectionClick() {
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
contactSearchViewModel.refresh();
}
@Override
public void onFindContactsClicked() {
requestContactPermissions();
}
@Override
public void onRefreshContactsClicked() {
if (onRefreshListener != null && !isRefreshing()) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
@Override
public void onNewGroupClicked() {
newConversationCallback.onNewGroup(false);
}
@Override
public void onFindByPhoneNumberClicked() {
findByCallback.onFindByPhoneNumber();
}
@Override
public void onFindByUsernameClicked() {
findByCallback.onFindByUsername();
}
@Override
public void onInviteToSignalClicked() {
if (newConversationCallback != null) {
newConversationCallback.onInvite();
}
if (newCallCallback != null) {
newCallCallback.onInvite();
}
}
@Override
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
callbacks.onExpandClicked(expand);
}
@Override
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
@Override
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
storyContextMenuCallbacks,
new CallButtonClickCallbacks()
);
contactSearchView.bind(
contactSearchViewModel,
getChildFragmentManager(),
@@ -452,25 +301,83 @@ public final class ContactSelectionListFragment extends LoggingFragment {
onLoadFinished(size);
}
},
Collections.singletonList(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders)),
contentBottomPaddingDp,
adapterFactory,
scrollListeners,
rv -> {
innerRecyclerView = rv;
innerLayoutManager = (LinearLayoutManager) rv.getLayoutManager();
rv.setItemAnimator(new DefaultItemAnimator() {
@Override
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
return true;
}
ContactSelectionListModels.composeEntries(
new ContactSelectionListModels.Callback() {
@Override
public void onNewGroupClicked() {
newConversationCallback.onNewGroup(false);
}
@Override
public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) {
contactSearchView.setAlpha(1f);
@Override
public void onInviteToSignalClicked() {
if (newConversationCallback != null) {
newConversationCallback.onInvite();
}
if (newCallCallback != null) {
newCallCallback.onInvite();
}
}
@Override
public void onFindContactsClicked() {
requestContactPermissions();
}
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
contactSearchViewModel.refresh();
}
@Override
public void onRefreshContactsClicked() {
if (onRefreshListener != null && !isRefreshing()) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
@Override
public void onFindByUsernameClicked() {
findByCallback.onFindByUsername();
}
@Override
public void onFindByPhoneNumberClicked() {
findByCallback.onFindByPhoneNumber();
}
}
});
}
),
new ContactSearchAdapter.ClickCallbacks() {
@Override
public void onStoryClicked(@NotNull View view, ContactSearchData.@NotNull Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NotNull View view, ContactSearchData.@NotNull KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(ContactSearchData.@NotNull Expand expand) {
contactSearchViewModel.expandSection(expand.getSectionKey());
}
@Override
public void onChatTypeClicked(@NotNull View view, ContactSearchData.@NotNull ChatTypeRow chatTypeRow, boolean isSelected) {
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
}
@Override
public void onUnknownRecipientClicked(@NotNull View view, ContactSearchData.@NotNull UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
null,
new CallButtonClickCallbacks()
);
return view;
@@ -595,32 +502,23 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public void reset() {
contactSearchViewModel.clearSelection();
contactSearchViewModel.refresh();
fastScroller.setVisibility(View.GONE);
headerActionView.setVisibility(View.GONE);
contactSearchViewModel.setFastScrollEnabled(false);
}
private void onLoadFinished(int count) {
if (resetPositionOnCommit && innerRecyclerView != null) {
if (resetPositionOnCommit) {
resetPositionOnCommit = false;
innerRecyclerView.scrollToPosition(0);
contactSearchViewModel.requestScrollPosition(0);
}
swipeRefresh.setVisibility(View.VISIBLE);
emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
boolean useFastScroller = count > 20;
if (useFastScroller && innerRecyclerView != null) {
fastScroller.setVisibility(View.VISIBLE);
fastScroller.setRecyclerView(innerRecyclerView);
if (useFastScroller) {
contactSearchViewModel.setFastScrollEnabled(true);
} else {
fastScroller.setRecyclerView(null);
fastScroller.setVisibility(View.GONE);
}
if (headerActionView.isEnabled() && !hasQueryFilter()) {
headerActionView.setVisibility(View.VISIBLE);
} else {
headerActionView.setVisibility(View.GONE);
contactSearchViewModel.setFastScrollEnabled(false);
}
}
@@ -790,8 +688,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public boolean onItemLongClick(View anchorView, ContactSearchKey item) {
if (onItemLongClickListener != null && innerRecyclerView != null) {
return onItemLongClickListener.onLongClick(anchorView, item, innerRecyclerView);
if (onItemLongClickListener != null) {
return onItemLongClickListener.onLongClick(anchorView, item, isDisplayingContextMenu -> contactSearchViewModel.setDisplayingContextMenu(isDisplayingContextMenu));
} else {
return false;
}
@@ -933,19 +831,19 @@ public final class ContactSelectionListFragment extends LoggingFragment {
!SignalStore.uiHints().getDismissedContactsPermissionBanner() &&
!hasQuery)
{
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
}
if (fragmentArgs.getEnableCreateNewGroup() && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.NEW_GROUP.getCode());
}
if (fragmentArgs.getEnableFindByUsername() && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_USERNAME.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_BY_USERNAME.getCode());
}
if (fragmentArgs.getEnableFindByPhoneNumber() && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
}
if (includeChatTypes && !hasQuery) {
@@ -967,10 +865,12 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
boolean hideHeader = newCallCallback != null || (newConversationCallback != null && !hasQuery);
HeaderAction sectionHeaderAction = (headerActionProvider != null && !hasQuery) ? headerActionProvider.getHeaderAction() : null;
builder.addSection(new ContactSearchConfiguration.Section.Individuals(
includeSelf ? new RecipientTable.IncludeSelfMode.IncludeWithRemap(getString(R.string.note_to_self)) : RecipientTable.IncludeSelfMode.Exclude.INSTANCE,
transportType,
!hideHeader,
sectionHeaderAction,
null,
!hideLetterHeaders(),
newConversationCallback != null ? ContactSearchSortOrder.RECENCY : ContactSearchSortOrder.NATURAL
@@ -1017,13 +917,13 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
private void addMoreSection(@NonNull ContactSearchConfiguration.Builder builder) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.MORE_HEADING.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.MORE_HEADING.getCode());
if (hasContactsPermissions(requireContext())) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.REFRESH_CONTACTS.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.REFRESH_CONTACTS.getCode());
} else if (SignalStore.uiHints().getDismissedContactsPermissionBanner()) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS.getCode());
}
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
}
private static @Nullable ContactSearchConfiguration.TransportType resolveTransportType(boolean includePushContacts, boolean includeSmsContacts) {
@@ -1113,15 +1013,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
void onInvite();
}
public interface ScrollCallback {
void onBeginScroll();
}
public interface HeaderActionProvider {
@NonNull HeaderAction getHeaderAction();
}
public interface OnItemLongClickListener {
boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView);
boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, Consumer<Boolean> setIsDisplayingContextMenu);
}
}
@@ -0,0 +1,299 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.contacts.paged.ContactSearchModels.EmptyModel
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProviderBuilder
/**
* Holds the [MappingModel]s and [MappingViewHolder]s used by [ContactSelectionListAdapter] on top of
* the base set in [org.thoughtcrime.securesms.contacts.paged.ContactSearchModels], along with helpers
* for registering them on a [MappingAdapter] (RecyclerView) or building a [MappingEntryProvider]
* (Compose).
*/
object ContactSelectionListModels {
fun registerNewGroup(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
NewGroupModel::class.java,
LayoutFactory({ NewGroupViewHolder(it, onClick) }, R.layout.contact_selection_new_group_item)
)
}
fun registerInviteToSignal(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
InviteToSignalModel::class.java,
LayoutFactory({ InviteToSignalViewHolder(it, onClick) }, R.layout.contact_selection_invite_action_item)
)
}
fun registerFindContacts(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindContactsModel::class.java,
LayoutFactory({ FindContactsViewHolder(it, onClick) }, R.layout.contact_selection_find_contacts_item)
)
}
fun registerFindContactsBanner(mappingAdapter: MappingAdapter, onDismiss: () -> Unit, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindContactsBannerModel::class.java,
LayoutFactory({ FindContactsBannerViewHolder(it, onDismiss, onClick) }, R.layout.contact_selection_find_contacts_banner_item)
)
}
fun registerRefreshContacts(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
RefreshContactsModel::class.java,
LayoutFactory({ RefreshContactsViewHolder(it, onClick) }, R.layout.contact_selection_refresh_action_item)
)
}
fun registerMoreHeader(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(
MoreHeaderModel::class.java,
LayoutFactory({ MoreHeaderViewHolder(it) }, R.layout.contact_search_section_header)
)
}
fun registerEmpty(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(
EmptyModel::class.java,
LayoutFactory({ EmptyViewHolder(it) }, R.layout.contact_selection_empty_state)
)
}
fun registerFindByUsername(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindByUsernameModel::class.java,
LayoutFactory({ FindByUsernameViewHolder(it, onClick) }, R.layout.contact_selection_find_by_username_item)
)
}
fun registerFindByPhoneNumber(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindByPhoneNumberModel::class.java,
LayoutFactory({ FindByPhoneNumberViewHolder(it, onClick) }, R.layout.contact_selection_find_by_phone_number_item)
)
}
/**
* Returns a [MappingEntryProvider] containing the same set of view holders registered by the
* adapter-side `register*` methods, suitable for use with a Compose `MappingLazyColumn`.
*/
@JvmStatic
fun composeEntries(
callback: Callback
): MappingEntryProvider<Any> {
return MappingEntryProviderBuilder<Any>().apply {
viewHolder<NewGroupModel> { context ->
LayoutFactory(
{ view -> NewGroupViewHolder(view, callback::onNewGroupClicked) },
R.layout.contact_selection_new_group_item
).createViewHolder(FrameLayout(context))
}
viewHolder<InviteToSignalModel> { context ->
LayoutFactory(
{ view -> InviteToSignalViewHolder(view, callback::onInviteToSignalClicked) },
R.layout.contact_selection_invite_action_item
).createViewHolder(FrameLayout(context))
}
viewHolder<FindContactsModel> { context ->
LayoutFactory(
{ view -> FindContactsViewHolder(view, callback::onFindContactsClicked) },
R.layout.contact_selection_find_contacts_item
).createViewHolder(FrameLayout(context))
}
viewHolder<FindContactsBannerModel> { context ->
LayoutFactory(
{ view -> FindContactsBannerViewHolder(view, callback::onDismissFindContactsBannerClicked, callback::onFindContactsClicked) },
R.layout.contact_selection_find_contacts_banner_item
).createViewHolder(FrameLayout(context))
}
viewHolder<RefreshContactsModel> { context ->
LayoutFactory(
{ view -> RefreshContactsViewHolder(view, callback::onRefreshContactsClicked) },
R.layout.contact_selection_refresh_action_item
).createViewHolder(FrameLayout(context))
}
viewHolder<MoreHeaderModel> { context ->
LayoutFactory(
{ view -> MoreHeaderViewHolder(view) },
R.layout.contact_search_section_header
).createViewHolder(FrameLayout(context))
}
viewHolder<EmptyModel> { context ->
LayoutFactory(
{ view -> EmptyViewHolder(view) },
R.layout.contact_selection_empty_state
).createViewHolder(FrameLayout(context))
}
viewHolder<FindByUsernameModel> { context ->
LayoutFactory(
{ view -> FindByUsernameViewHolder(view, callback::onFindByUsernameClicked) },
R.layout.contact_selection_find_by_username_item
).createViewHolder(FrameLayout(context))
}
viewHolder<FindByPhoneNumberModel> { context ->
LayoutFactory(
{ view -> FindByPhoneNumberViewHolder(view, callback::onFindByPhoneNumberClicked) },
R.layout.contact_selection_find_by_phone_number_item
).createViewHolder(FrameLayout(context))
}
}.build()
}
interface Callback {
fun onNewGroupClicked()
fun onInviteToSignalClicked()
fun onFindContactsClicked()
fun onDismissFindContactsBannerClicked()
fun onRefreshContactsClicked()
fun onFindByUsernameClicked()
fun onFindByPhoneNumberClicked()
}
enum class ArbitraryRow(val code: String) {
NEW_GROUP("new-group"),
INVITE_TO_SIGNAL("invite-to-signal"),
MORE_HEADING("more-heading"),
REFRESH_CONTACTS("refresh-contacts"),
FIND_CONTACTS("find-contacts"),
FIND_CONTACTS_BANNER("find-contacts-banner"),
FIND_BY_USERNAME("find-by-username"),
FIND_BY_PHONE_NUMBER("find-by-phone-number");
companion object {
fun fromCode(code: String) = entries.first { it.code == code }
}
}
class NewGroupModel : MappingModel<NewGroupModel> {
override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true
override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true
}
class InviteToSignalModel : MappingModel<InviteToSignalModel> {
override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
}
class RefreshContactsModel : MappingModel<RefreshContactsModel> {
override fun areItemsTheSame(newItem: RefreshContactsModel): Boolean = true
override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true
}
class FindContactsModel : MappingModel<FindContactsModel> {
override fun areItemsTheSame(newItem: FindContactsModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsModel): Boolean = true
}
class FindContactsBannerModel : MappingModel<FindContactsBannerModel> {
override fun areItemsTheSame(newItem: FindContactsBannerModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsBannerModel): Boolean = true
}
class FindByUsernameModel : MappingModel<FindByUsernameModel> {
override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true
override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true
}
class FindByPhoneNumberModel : MappingModel<FindByPhoneNumberModel> {
override fun areItemsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
override fun areContentsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
}
class MoreHeaderModel : MappingModel<MoreHeaderModel> {
override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true
override fun areContentsTheSame(newItem: MoreHeaderModel): Boolean = true
}
private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<InviteToSignalModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: InviteToSignalModel) = Unit
}
private class NewGroupViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<NewGroupModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: NewGroupModel) = Unit
}
private class RefreshContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<RefreshContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: RefreshContactsModel) = Unit
}
private class FindContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsModel) = Unit
}
private class FindContactsBannerViewHolder(itemView: View, onDismissListener: () -> Unit, onClickListener: () -> Unit) : MappingViewHolder<FindContactsBannerModel>(itemView) {
init {
itemView.findViewById<MaterialButton>(R.id.no_thanks_button).setOnClickListener { onDismissListener() }
itemView.findViewById<MaterialButton>(R.id.allow_contacts_button).setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsBannerModel) = Unit
}
private class MoreHeaderViewHolder(itemView: View) : MappingViewHolder<MoreHeaderModel>(itemView) {
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
override fun bind(model: MoreHeaderModel) {
headerTextView.setText(R.string.contact_selection_activity__more)
}
}
private class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
private val emptyText: TextView = itemView.findViewById(R.id.search_no_results)
override fun bind(model: EmptyModel) {
emptyText.text = context.getString(R.string.SearchFragment_no_results, model.empty.query ?: "")
}
}
private class FindByPhoneNumberViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByPhoneNumberModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByPhoneNumberModel) = Unit
}
private class FindByUsernameViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByUsernameModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByUsernameModel) = Unit
}
}
@@ -116,6 +116,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePay
import org.thoughtcrime.securesms.components.snackbars.LocalSnackbarStateConsumerRegistry
import org.thoughtcrime.securesms.components.snackbars.SnackbarHostKey
import org.thoughtcrime.securesms.components.snackbars.SnackbarState
import org.thoughtcrime.securesms.components.verificationrequested.VerificationCodeRequestedBottomSheet
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.conversation.ConversationIntents
@@ -195,6 +196,7 @@ import org.thoughtcrime.securesms.window.AppScaffoldNavigator
import org.thoughtcrime.securesms.window.NavigationType
import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDelegate
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import kotlin.time.Duration.Companion.minutes
import org.signal.core.ui.R as CoreUiR
class MainActivity :
@@ -357,6 +359,25 @@ class MainActivity :
}
}
}
launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
SignalStore
.account
.verificationCodeRequestedAtMsFlow
.filter { it > 0L }
.collect { requestedAt ->
val notificationThreshold = requestedAt + 10.minutes.inWholeMilliseconds
if (System.currentTimeMillis() < notificationThreshold) {
VerificationCodeRequestedBottomSheet.show(supportFragmentManager, requestedAt)
} else {
Log.i(TAG, "Verification code requested but is older than 10 minutes, not showing sheet")
}
SignalStore.account.verificationCodeRequestedAtMs = 0L
}
}
}
}
supportFragmentManager.setFragmentResultListener(
@@ -71,6 +71,7 @@ import org.signal.network.NetworkResult
import org.signal.network.StatusCodeErrorAction
import org.signal.network.api.SvrBApi
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.signal.network.rest.toNetworkResult
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
@@ -1628,19 +1629,6 @@ object BackupRepository {
}
}
fun getResumableMessagesBackupUploadSpec(backupFileSize: Long): NetworkResult<ResumableMessagesBackupUploadSpec> {
return initBackupAndFetchAuth()
.then { credential ->
SignalNetwork.archive.getMessageBackupUploadForm(SignalStore.account.requireAci(), credential.messageBackupAccess, backupFileSize)
.also { Log.i(TAG, "UploadFormResult: ${it::class.simpleName}") }
}
.then { form ->
SignalNetwork.archive.getBackupResumableUploadUrl(form)
.also { Log.i(TAG, "ResumableUploadUrlResult: ${it::class.simpleName}") }
.map { ResumableMessagesBackupUploadSpec(attachmentUploadForm = form, resumableUri = it) }
}
}
fun getMessageBackupUploadForm(backupFileSize: Long): NetworkResult<AttachmentUploadForm> {
return initBackupAndFetchAuth()
.then { credential ->
@@ -156,8 +156,7 @@ object AccountDataArchiveProcessor {
navigationBarSize = signalStore.settingsValues.useCompactNavigationBar.toRemoteNavigationBarSize()
).takeUnless { Environment.IS_INSTRUMENTATION && SignalStore.backup.importedEmptyAndroidSettings },
bioText = selfRecord.about ?: "",
bioEmoji = selfRecord.aboutEmoji ?: "",
keyTransparencyData = selfRecord.keyTransparencyData?.toByteString()
bioEmoji = selfRecord.aboutEmoji ?: ""
)
)
)
@@ -251,7 +250,7 @@ object AccountDataArchiveProcessor {
SignalStore.account.usernameLink = null
}
SignalDatabase.recipients.setKeyTransparencyData(Recipient.self().aci.get(), accountData.keyTransparencyData?.toByteArray())
SignalDatabase.recipients.clearSelfKeyTransparencyData()
SignalDatabase.runPostSuccessfulTransaction { ProfileUtil.handleSelfProfileKeyChange() }
@@ -86,6 +86,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
is AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment().setLaunchCheckoutFlow(appSettingsRoute.launchCheckoutFlow)
AppSettingsRoute.Invite -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
AppSettingsRoute.AccountRoute.Account -> AppSettingsFragmentDirections.actionDirectToAccountSettingsFragment()
else -> error("Unsupported start location: ${appSettingsRoute?.javaClass?.name}")
}
}
@@ -177,6 +178,9 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
@JvmStatic
fun changeNumber(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChangeNumberRoute.Start)
@JvmStatic
fun account(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.AccountRoute.Account)
@JvmStatic
fun subscriptions(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.DonationsRoute.Donations(directToCheckoutType = InAppPaymentType.RECURRING_DONATION))
@@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.registration.sms.ReceivedSmsEvent
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.visible
import kotlin.math.ceil
import kotlin.time.Duration.Companion.milliseconds
/**
@@ -168,7 +169,7 @@ class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_n
is ChangeNumberResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
is ChangeNumberResult.AuthorizationFailed -> presentIncorrectCodeDialog()
is ChangeNumberResult.AttemptsExhausted -> presentAccountLocked()
is ChangeNumberResult.RateLimited -> presentRateLimitedDialog()
is ChangeNumberResult.RateLimited -> presentRateLimitedDialog(result.timeRemaining)
else -> presentGenericError(result)
}
@@ -195,13 +196,25 @@ class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_n
)
}
private fun presentRateLimitedDialog() {
private fun presentRateLimitedDialog(retryAfterSeconds: Long = 0) {
binding.codeEntryLayout.keyboard.displayFailure().addListener(
object : AssertedSuccessListener<Boolean?>() {
override fun onSuccess(result: Boolean?) {
MaterialAlertDialogBuilder(requireContext()).apply {
setTitle(R.string.RegistrationActivity_too_many_attempts)
setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
if (retryAfterSeconds > 0) {
val minutes = ceil(retryAfterSeconds / 60.0).toInt().coerceAtLeast(1)
setMessage(
if (minutes >= 60) {
val hours = ceil(minutes / 60.0).toInt()
resources.getQuantityString(R.plurals.ChangeNumberEnterCodeFragment__too_many_attempts_try_again_in_hours, hours, hours)
} else {
resources.getQuantityString(R.plurals.ChangeNumberEnterCodeFragment__too_many_attempts_try_again_in_minutes, minutes, minutes)
}
)
} else {
setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
}
setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
binding.codeEntryLayout.callMeCountDown.visibility = View.VISIBLE
binding.codeEntryLayout.resendSmsCountDown.visibility = View.VISIBLE
@@ -23,7 +23,6 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
@@ -34,7 +33,10 @@ import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import kotlin.time.Duration.Companion.milliseconds
class ChangeNumberFragment : ComposeFragment() {
@@ -46,10 +48,25 @@ class ChangeNumberFragment : ComposeFragment() {
navController.popBackStack()
},
onContinueClick = {
navController.safeNavigate(ChangeNumberFragmentDirections.actionChangePhoneNumberFragmentToEnterPhoneNumberChangeFragment())
val remainingWaitSeconds = remainingPostRegistrationWaitSeconds()
if (remainingWaitSeconds > 0) {
ChangeNumberPostRegistrationWaitSheet.show(parentFragmentManager, remainingWaitSeconds)
} else {
navController.safeNavigate(ChangeNumberFragmentDirections.actionChangePhoneNumberFragmentToEnterPhoneNumberChangeFragment())
}
}
)
}
private fun remainingPostRegistrationWaitSeconds(): Long {
val registeredAt = SignalStore.account.registeredAtTimestamp
if (registeredAt <= 0) {
return 0
}
val waitingPeriodSeconds = RemoteConfig.changeNumberPostRegistrationWaitingPeriodSeconds
val elapsedSeconds = (System.currentTimeMillis() - registeredAt).milliseconds.inWholeSeconds
return (waitingPeriodSeconds - elapsedSeconds).coerceAtLeast(0)
}
}
@Composable
@@ -73,7 +90,7 @@ fun ChangeNumberScreen(
.padding(horizontal = 32.dp)
) {
Image(
painter = painterResource(id = R.drawable.change_number_hero_image),
painter = painterResource(id = R.drawable.change_number),
contentDescription = null,
modifier = Modifier
.padding(top = 20.dp)
@@ -83,7 +100,6 @@ fun ChangeNumberScreen(
text = stringResource(id = R.string.AccountSettingsFragment__change_phone_number),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 24.dp)
)
@@ -0,0 +1,151 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.os.Bundle
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import kotlin.math.ceil
/**
* Sheet shown when the user attempts to change their phone number before the
* post-registration waiting period has elapsed.
*/
class ChangeNumberPostRegistrationWaitSheet : ComposeBottomSheetDialogFragment() {
companion object {
private const val ARG_REMAINING_SECONDS = "arg.remaining_seconds"
@JvmStatic
fun show(fragmentManager: FragmentManager, remainingSeconds: Long) {
ChangeNumberPostRegistrationWaitSheet().apply {
arguments = Bundle().apply {
putLong(ARG_REMAINING_SECONDS, remainingSeconds)
}
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
private val remainingSeconds: Long
get() = requireArguments().getLong(ARG_REMAINING_SECONDS)
@Composable
override fun SheetContent() {
SheetContent(
remainingSeconds = remainingSeconds,
onDismiss = { dismissAllowingStateLoss() }
)
}
}
@Composable
private fun SheetContent(
remainingSeconds: Long,
onDismiss: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters(gutterSize = 36.dp)
) {
BottomSheets.Handle()
Image(
painter = painterResource(R.drawable.change_number_error),
contentDescription = null,
modifier = Modifier
.padding(top = 26.dp)
)
Text(
text = stringResource(R.string.ChangeNumberPostRegistrationWaitSheet__title),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = stringResource(R.string.ChangeNumberPostRegistrationWaitSheet__body),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp)
)
Text(
text = formatTryAgainIn(remainingSeconds),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp)
)
Buttons.LargeTonal(
onClick = onDismiss,
modifier = Modifier
.fillMaxWidth()
.padding(top = 32.dp, bottom = 24.dp, start = 12.dp, end = 12.dp)
) {
Text(stringResource(R.string.ChangeNumberPostRegistrationWaitSheet__ok))
}
}
}
@DayNightPreviews
@Composable
private fun SheetContentMinutesPreview() {
Previews.BottomSheetContentPreview {
SheetContent(
remainingSeconds = 25 * 60,
onDismiss = {}
)
}
}
@DayNightPreviews
@Composable
private fun SheetContentHoursPreview() {
Previews.BottomSheetContentPreview {
SheetContent(
remainingSeconds = 2 * 60 * 60,
onDismiss = {}
)
}
}
@Composable
private fun formatTryAgainIn(remainingSeconds: Long): String {
val minutes = ceil(remainingSeconds / 60.0).toInt().coerceAtLeast(1)
return if (minutes >= 60) {
val hours = ceil(minutes / 60.0).toInt()
pluralStringResource(R.plurals.ChangeNumberPostRegistrationWaitSheet__try_again_in_hours, hours, hours)
} else {
pluralStringResource(R.plurals.ChangeNumberPostRegistrationWaitSheet__try_again_in_minutes, minutes, minutes)
}
}
@@ -103,20 +103,6 @@ class ChangeNumberRepository(
@WorkerThread
fun changeLocalNumber(e164: String, pni: ServiceId.PNI) {
SignalDatabase.recipients.updateSelfE164(e164, pni)
AppDependencies.recipientCache.clear()
if (e164 != SignalStore.account.requireE164()) {
SignalDatabase.recipients.rotateStorageId(Recipient.self().fresh().id)
StorageSyncHelper.scheduleSyncForDataChange()
}
SignalStore.account.setE164(e164)
SignalStore.account.setPni(pni)
AppDependencies.resetProtocolStores()
AppDependencies.groupsV2Authorization.clear()
val metadata: PendingChangeNumberMetadata? = SignalStore.misc.pendingChangeNumberMetadata
if (metadata == null) {
Log.w(TAG, "No change number metadata, this shouldn't happen")
@@ -125,25 +111,32 @@ class ChangeNumberRepository(
val pniIdentityKeyPair = IdentityKeyPair(metadata.pniIdentityKeyPair.toByteArray())
val pniRegistrationId = metadata.pniRegistrationId
val pniSignedPreyKeyId = metadata.pniSignedPreKeyId
val pniSignedPreKeyId = metadata.pniSignedPreKeyId
val pniLastResortKyberPreKeyId = metadata.pniLastResortKyberPreKeyId
// Prekeys were generated and stored during createChangeNumberRequest; reload them so we can pass them through and reuse for the upload below.
val preResetPniStore = AppDependencies.protocolStore.pni()
val signedPreKey = preResetPniStore.loadSignedPreKey(pniSignedPreKeyId)
val lastResortKyberPreKey = preResetPniStore.loadLastResortKyberPreKeys().firstOrNull { it.id == pniLastResortKyberPreKeyId }
applyLocalNumberChange(
e164 = e164,
pni = pni,
pniIdentityKeyPair = pniIdentityKeyPair,
pniSignedPreKey = signedPreKey,
pniLastResortKyberPreKey = lastResortKyberPreKey,
pniRegistrationId = pniRegistrationId
)
AppDependencies.resetNetwork()
AppDependencies.startNetwork()
val pniProtocolStore = AppDependencies.protocolStore.pni()
val pniMetadataStore = SignalStore.account.pniPreKeys
SignalStore.account.pniRegistrationId = pniRegistrationId
SignalStore.account.setPniIdentityKeyAfterChangeNumber(pniIdentityKeyPair)
val signedPreKey = pniProtocolStore.loadSignedPreKey(pniSignedPreyKeyId)
val oneTimeEcPreKeys = PreKeyUtil.generateAndStoreOneTimeEcPreKeys(pniProtocolStore, pniMetadataStore)
val lastResortKyberPreKey = pniProtocolStore.loadLastResortKyberPreKeys().firstOrNull { it.id == pniLastResortKyberPreKeyId }
val oneTimeKyberPreKeys = PreKeyUtil.generateAndStoreOneTimeKyberPreKeys(pniProtocolStore, pniMetadataStore)
if (lastResortKyberPreKey == null) {
Log.w(TAG, "Last-resort kyber prekey is missing!")
}
pniMetadataStore.activeSignedPreKeyId = signedPreKey.id
Log.i(TAG, "Submitting prekeys with PNI identity key: ${pniIdentityKeyPair.publicKey.fingerprint}")
retryChangeLocalNumberNetworkOperation {
@@ -161,6 +154,61 @@ class ChangeNumberRepository(
pniMetadataStore.isSignedPreKeyRegistered = true
pniMetadataStore.lastResortKyberPreKeyId = pniLastResortKyberPreKeyId
SignalStore.misc.hasPniInitializedDevices = true
AppDependencies.jobManager.add(RefreshAttributesJob())
rotateCertificates()
SignalStore.misc.unlockChangeNumber()
}
/**
* Applies the local state for a successful number change: self recipient row, account values,
* PNI protocol store, and identity entry.
*
* Does NOT reset the network callers must do so before any subsequent traffic that needs to
* use the new PNI. Does NOT make any server requests and does NOT flag prekeys as registered
* server-side the caller is responsible for that once it can attest to server state.
*/
@WorkerThread
fun applyLocalNumberChange(
e164: String,
pni: ServiceId.PNI,
pniIdentityKeyPair: IdentityKeyPair,
pniSignedPreKey: SignedPreKeyRecord,
pniLastResortKyberPreKey: KyberPreKeyRecord?,
pniRegistrationId: Int
) {
SignalDatabase.recipients.updateSelfE164(e164, pni)
AppDependencies.recipientCache.clear()
if (e164 != SignalStore.account.requireE164()) {
SignalDatabase.recipients.rotateStorageId(Recipient.self().fresh().id)
StorageSyncHelper.scheduleSyncForDataChange()
}
SignalStore.account.setE164(e164)
SignalStore.account.setPni(pni)
AppDependencies.resetProtocolStores()
AppDependencies.groupsV2Authorization.clear()
val pniProtocolStore = AppDependencies.protocolStore.pni()
val pniMetadataStore = SignalStore.account.pniPreKeys
SignalStore.account.pniRegistrationId = pniRegistrationId
SignalStore.account.setPniIdentityKeyAfterChangeNumber(pniIdentityKeyPair)
PreKeyUtil.storeSignedPreKey(pniProtocolStore, pniMetadataStore, pniSignedPreKey)
pniMetadataStore.activeSignedPreKeyId = pniSignedPreKey.id
if (pniLastResortKyberPreKey != null) {
PreKeyUtil.storeLastResortKyberPreKey(pniProtocolStore, pniMetadataStore, pniLastResortKyberPreKey)
} else {
Log.w(TAG, "Last-resort kyber prekey is missing!")
}
pniProtocolStore.identities().saveIdentityWithoutSideEffects(
Recipient.self().id,
pni,
@@ -171,20 +219,8 @@ class ChangeNumberRepository(
true
)
SignalStore.misc.hasPniInitializedDevices = true
AppDependencies.groupsV2Authorization.clear()
Recipient.self().fresh()
StorageSyncHelper.scheduleSyncForDataChange()
AppDependencies.resetNetwork()
AppDependencies.startNetwork()
AppDependencies.jobManager.add(RefreshAttributesJob())
rotateCertificates()
SignalStore.misc.unlockChangeNumber()
}
@WorkerThread
@@ -4,15 +4,20 @@ import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.DialogInterface
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.EditText
import android.widget.Toast
import androidx.fragment.app.setFragmentResultListener
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.permissions.PermissionDeniedBottomSheet
import org.signal.core.ui.permissions.RationaleDialog
@@ -1092,15 +1097,26 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
private fun refreshRemoteValues() {
Toast.makeText(context, "Running remote config refresh, app will restart after completion.", Toast.LENGTH_LONG).show()
SignalExecutors.BOUNDED.execute {
val starterToast = Toast.makeText(context, "Running remote config refresh, app will restart after completion.", Toast.LENGTH_LONG).apply { show() }
lifecycleScope.launch(Dispatchers.IO) {
SignalStore.remoteConfig.eTag = ""
val result: Optional<JobTracker.JobState> = AppDependencies.jobManager.runSynchronously(RemoteConfigRefreshJob(), TimeUnit.SECONDS.toMillis(10))
if (result.isPresent && result.get() == JobTracker.JobState.SUCCESS) {
AppUtil.restart(requireContext())
} else {
Toast.makeText(context, "Failed to refresh config remote config.", Toast.LENGTH_SHORT).show()
withContext(Dispatchers.Main) {
starterToast.cancel()
if (result.isPresent && result.get() == JobTracker.JobState.SUCCESS) {
val toast = Toast.makeText(context, "Refresh successful. Restarting...", Toast.LENGTH_SHORT)
if (Build.VERSION.SDK_INT >= 30) {
toast.addCallback(object : Toast.Callback() {
override fun onToastHidden() {
AppUtil.restart(requireContext())
}
})
}
toast.show()
} else {
Toast.makeText(context, "Failed to refresh config remote config.", Toast.LENGTH_SHORT).show()
}
}
}
}
@@ -236,8 +236,8 @@ private fun TitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) {
when (inAppPayment.type) {
InAppPaymentType.UNKNOWN -> error("Unsupported type UNKNOWN")
InAppPaymentType.ONE_TIME_GIFT -> OneTimeGiftTitleAndSubtitle(inAppPayment)
InAppPaymentType.ONE_TIME_DONATION -> RecurringDonationTitleAndSubtitle(inAppPayment)
InAppPaymentType.RECURRING_DONATION -> OneTimeDonationTitleAndSubtitle(inAppPayment)
InAppPaymentType.ONE_TIME_DONATION -> OneTimeDonationTitleAndSubtitle(inAppPayment)
InAppPaymentType.RECURRING_DONATION -> RecurringDonationTitleAndSubtitle(inAppPayment)
InAppPaymentType.RECURRING_BACKUP -> error("This type is not supported")
}
}
@@ -0,0 +1,195 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.verificationrequested
import android.os.Bundle
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.DateUtils
import java.util.Locale
/**
* Sheet shown when the server has pushed a notification telling us a verification code was
* requested for the user's account.
*/
class VerificationCodeRequestedBottomSheet : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 1f
companion object {
private const val ARG_REQUESTED_AT = "requested_at"
@JvmStatic
fun show(fragmentManager: FragmentManager, requestedAtMs: Long) {
VerificationCodeRequestedBottomSheet().apply {
arguments = Bundle().apply { putLong(ARG_REQUESTED_AT, requestedAtMs) }
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
@Composable
override fun SheetContent() {
val context = LocalContext.current
val resources = LocalResources.current
val requestedAt = requireArguments().getLong(ARG_REQUESTED_AT)
val formattedTime = remember(requestedAt) {
val time = DateUtils.getOnlyTimeString(context, requestedAt)
val day = DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), requestedAt)
resources.getString(R.string.VerificationCodeRequestedBottomSheet__time_with_day, time, day)
}
val nestedScrollInterop = rememberNestedScrollInteropConnection()
val scrollModifier = Modifier.nestedScroll(nestedScrollInterop)
VerificationCodeRequestedContent(
formattedTime = formattedTime,
onSafetyTipsClicked = {
val fragmentManager = parentFragmentManager
dismissAllowingStateLoss()
VerificationCodeRequestedSafetyTipsBottomSheet.show(fragmentManager)
},
onOkClicked = { dismissAllowingStateLoss() },
modifier = scrollModifier
)
}
}
@Composable
private fun VerificationCodeRequestedContent(
formattedTime: String,
onSafetyTipsClicked: () -> Unit,
onOkClicked: () -> Unit,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier.fillMaxWidth()
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
BottomSheets.Handle()
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.weight(weight = 1f, fill = false)
.verticalScroll(state = scrollState)
.padding(horizontal = 36.dp)
.padding(bottom = 36.dp)
) {
Spacer(modifier = Modifier.height(26.dp))
Image(
painter = painterResource(id = R.drawable.verificationcode_alert_96),
contentDescription = null,
modifier = Modifier.size(96.dp)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(id = R.string.VerificationCodeRequestedBottomSheet__title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formattedTime,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(20.dp))
Text(
text = stringResource(id = R.string.VerificationCodeRequestedBottomSheet__body_1),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(id = R.string.VerificationCodeRequestedBottomSheet__body_2),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(32.dp))
Buttons.LargeTonal(
onClick = onSafetyTipsClicked,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
) {
Text(text = stringResource(id = R.string.VerificationCodeRequestedBottomSheet__safety_tips))
}
Spacer(modifier = Modifier.height(8.dp))
Buttons.LargeTonal(
onClick = onOkClicked,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
) {
Text(text = stringResource(id = R.string.VerificationCodeRequestedBottomSheet__ok))
}
}
}
}
@DayNightPreviews
@Composable
private fun VerificationCodeRequestedContentPreview() {
Previews.BottomSheetContentPreview {
VerificationCodeRequestedContent(
formattedTime = "3:25 PM Today",
onSafetyTipsClicked = {},
onOkClicked = {}
)
}
}
@@ -0,0 +1,182 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.verificationrequested
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
/**
* Sheet showing safety tips related to a verification code alert.
*/
class VerificationCodeRequestedSafetyTipsBottomSheet : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 1f
companion object {
@JvmStatic
fun show(fragmentManager: FragmentManager) {
VerificationCodeRequestedSafetyTipsBottomSheet().show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
@Composable
override fun SheetContent() {
val nestedScrollInterop = rememberNestedScrollInteropConnection()
val scrollModifier = Modifier.nestedScroll(nestedScrollInterop)
SafetyTipsContent(
onOpenAccountSettings = {
startActivity(AppSettingsActivity.account(requireContext()))
dismissAllowingStateLoss()
},
modifier = scrollModifier
)
}
}
@Composable
private fun SafetyTipsContent(
onOpenAccountSettings: () -> Unit,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier.fillMaxWidth()
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
BottomSheets.Handle()
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.weight(weight = 1f, fill = false)
.verticalScroll(state = scrollState)
.padding(horizontal = 36.dp)
.padding(bottom = 36.dp)
) {
Spacer(modifier = Modifier.height(26.dp))
Text(
text = stringResource(id = R.string.SafetyTipsBottomSheet__title),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(36.dp))
SafetyTipRow(
iconRes = R.drawable.safetytip_48_message,
titleRes = R.string.SafetyTipsBottomSheet__tip_1_title,
bodyRes = R.string.SafetyTipsBottomSheet__tip_1_body
)
Spacer(modifier = Modifier.height(40.dp))
SafetyTipRow(
iconRes = R.drawable.safetytip_48_pin,
titleRes = R.string.SafetyTipsBottomSheet__tip_2_title,
bodyRes = R.string.SafetyTipsBottomSheet__tip_2_body
)
Spacer(modifier = Modifier.height(40.dp))
SafetyTipRow(
iconRes = R.drawable.safetytip_48_lock,
titleRes = R.string.SafetyTipsBottomSheet__tip_3_title,
bodyRes = R.string.SafetyTipsBottomSheet__tip_3_body
)
Spacer(modifier = Modifier.height(40.dp))
Buttons.LargeTonal(
onClick = onOpenAccountSettings,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
) {
Text(text = stringResource(id = R.string.SafetyTipsBottomSheet__open_account_settings))
}
}
}
}
@Composable
private fun SafetyTipRow(
iconRes: Int,
titleRes: Int,
bodyRes: Int
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
Image(
painter = painterResource(id = iconRes),
contentDescription = null,
modifier = Modifier.size(48.dp)
)
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(id = titleRes),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(id = bodyRes),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@DayNightPreviews
@Composable
private fun SafetyTipsContentPreview() {
Previews.BottomSheetContentPreview {
SafetyTipsContent(onOpenAccountSettings = {})
}
}
@@ -9,36 +9,39 @@ import android.content.Context
import android.view.View
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.collections.immutable.persistentHashMapOf
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.FastScrollerState
import org.signal.core.ui.compose.LazyColumnFastScroller
import org.signal.core.ui.compose.LocalFragmentManager
import org.signal.core.ui.compose.Previews
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView.RecyclerViewReadyCallback
import org.thoughtcrime.securesms.components.emoji.Emojifier
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.stories.settings.custom.PrivateStorySettingsFragment
import org.thoughtcrime.securesms.stories.settings.my.MyStorySettingsFragment
import org.thoughtcrime.securesms.stories.settings.privacy.ChooseInitialMyStoryMembershipBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingLazyColumn
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingLazyListController
import org.thoughtcrime.securesms.util.adapter.mapping.compose.rememberMappingEntryProvider
import org.signal.core.ui.R as CoreUiR
/**
@@ -50,26 +53,16 @@ import org.signal.core.ui.R as CoreUiR
* 2. Via [ContactSearchView] in XML/View-based layouts [ContactSearchView] creates the ViewModel
* and delegates its `Content()` to this function.
*
* The [PagingMappingAdapter] is created internally via `remember` and re-created if
* [displayOptions] or [adapterFactory] change.
*
* @param viewModel Drives the list managed by the caller.
* @param mapStateToConfiguration Maps the current [ContactSearchState] to the active
* [ContactSearchConfiguration], re-evaluated whenever state changes.
* @param modifier Modifier applied to the composable root.
* @param displayOptions Controls checkbox and secondary-info visibility.
* @param callbacks Hooks for filtering and reacting to selection changes.
* @param storyFragmentManager [FragmentManager] used to show story-related dialogs.
* Pass `null` to disable story context menus and dialogs.
* @param onListCommitted Called after each list commit with the committed item count.
* @param itemDecorations [RecyclerView.ItemDecoration]s added to the internal list.
* @param contentBottomPadding Extra bottom padding so last items scroll above overlaid UI.
* Automatically disables `clipToPadding` when non-zero.
* @param adapterFactory Factory for the adapter swap for custom adapters (e.g.
* [ContactSelectionListAdapter]).
* @param scrollListeners [RecyclerView.OnScrollListener]s attached to the inner list.
* @param onRecyclerViewReady Called once with the inner [RecyclerView] after first composition.
* Useful for attaching fast-scrollers or custom item animators.
* @param additionalEntries Extra [MappingEntryProvider] entries layered on top of the base
* set from [ContactSearchModels.composeEntries]. The base set is
* always applied; on key collisions the base entry wins.
*/
@Composable
fun ContactSearch(
@@ -77,99 +70,151 @@ fun ContactSearch(
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
modifier: Modifier = Modifier,
displayOptions: ContactSearchAdapter.DisplayOptions = ContactSearchAdapter.DisplayOptions(),
callbacks: ContactSearchCallbacks = remember { ContactSearchCallbacks.Simple() },
storyFragmentManager: FragmentManager? = null,
onListCommitted: (Int) -> Unit = {},
itemDecorations: List<RecyclerView.ItemDecoration> = emptyList(),
contentBottomPadding: Dp = 0.dp,
adapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory,
scrollListeners: List<RecyclerView.OnScrollListener> = emptyList(),
onRecyclerViewReady: RecyclerViewReadyCallback? = null
additionalEntries: MappingEntryProvider<Any> = persistentHashMapOf(),
lazyListState: LazyListState = rememberLazyListState(),
callbacks: ContactSearchCallbacks = remember { ContactSearchCallbacks.Simple() },
clickCallbacks: ContactSearchAdapter.ClickCallbacks = rememberDefaultContactSearchItemClickCallbacks(viewModel, callbacks),
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks = rememberDefaultContactSearchItemLongClickCallbacks(),
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks = rememberDefaultContactSearchItemStoryContextMenuCallbacks(viewModel),
callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks = rememberDefaultContactSearchItemCallButtonClickCallbacks()
) {
val mappingModels by viewModel.mappingModels.collectAsStateWithLifecycle()
val controller by viewModel.controller.collectAsStateWithLifecycle()
val configState by viewModel.configurationState.collectAsStateWithLifecycle()
val totalCount by viewModel.totalCount.collectAsStateWithLifecycle()
val fastScrollerEnabled by viewModel.fastScrollerEnabled.collectAsStateWithLifecycle()
val isDisplayingContextMenu by viewModel.isDisplayingContextMenu.collectAsStateWithLifecycle()
val currentMapStateToConfiguration by rememberUpdatedState(mapStateToConfiguration)
val currentOnListCommitted by rememberUpdatedState(onListCommitted)
// Held as State references (not delegated) so click-callback lambdas captured inside
// remember() always read the latest value without recreating the adapter.
val currentCallbacks = rememberUpdatedState(callbacks)
val currentStoryFragmentManager = rememberUpdatedState(storyFragmentManager)
val context = LocalContext.current
val contextState = rememberUpdatedState(context)
val adapter = remember(viewModel.fixedContacts, displayOptions, adapterFactory) {
adapterFactory.create(
context = context,
fixedContacts = viewModel.fixedContacts,
displayOptions = displayOptions,
callbacks = DefaultClickCallbacks(viewModel, currentCallbacks, currentStoryFragmentManager),
longClickCallbacks = ContactSearchAdapter.LongClickCallbacksAdapter(),
storyContextMenuCallbacks = DefaultStoryContextMenuCallbacks(viewModel, currentStoryFragmentManager, contextState),
callButtonClickCallbacks = ContactSearchAdapter.EmptyCallButtonClickCallbacks
)
}
LaunchedEffect(mappingModels) {
adapter.submitList(mappingModels) {
currentOnListCommitted(mappingModels.size)
}
}
LaunchedEffect(controller) {
controller?.let { adapter.setPagingController(it) }
}
LaunchedEffect(configState) {
viewModel.setConfiguration(currentMapStateToConfiguration(configState))
}
val recyclerView = remember(context) {
RecyclerView(context).apply {
layoutManager = LinearLayoutManager(context)
LaunchedEffect(lazyListState) {
viewModel.scrollRequests.collect {
lazyListState.requestScrollToItem(0)
}
}
DisposableEffect(recyclerView, itemDecorations) {
itemDecorations.forEach { recyclerView.addItemDecoration(it) }
onDispose {
itemDecorations.forEach { recyclerView.removeItemDecoration(it) }
}
}
DisposableEffect(recyclerView, scrollListeners) {
scrollListeners.forEach { recyclerView.addOnScrollListener(it) }
onDispose {
scrollListeners.forEach { recyclerView.removeOnScrollListener(it) }
}
}
val bottomPaddingPx = with(LocalDensity.current) { contentBottomPadding.roundToPx() }
LaunchedEffect(recyclerView) {
onRecyclerViewReady?.onRecyclerViewReady(recyclerView)
}
AndroidView(
factory = { recyclerView },
update = { rv ->
if (rv.adapter !== adapter) {
rv.adapter = adapter
}
rv.setPadding(0, 0, 0, bottomPaddingPx)
rv.clipToPadding = bottomPaddingPx == 0
rv.clipChildren = bottomPaddingPx == 0
},
modifier = modifier.fillMaxSize()
val baseProvider = rememberContactSearchMappingEntryProvider(
fixedContacts = viewModel.fixedContacts,
displayOptions = displayOptions,
callbacks = clickCallbacks,
longClickCallbacks = longClickCallbacks,
storyContextMenuCallbacks = storyContextMenuCallbacks,
callButtonClickCallbacks = callButtonClickCallbacks
)
val provider = remember(baseProvider, additionalEntries) {
additionalEntries.putAll(baseProvider)
}
val mappingCtrl = remember {
MappingLazyListController(
entryProvider = provider
)
}
LaunchedEffect(controller) {
controller?.run {
mappingCtrl.pagingController = this
}
}
LaunchedEffect(mappingModels) {
mappingCtrl.items = mappingModels
currentOnListCommitted(mappingModels.size)
}
val fastScrollerState = remember(mappingModels, totalCount) {
FastScrollerState(items = mappingModels, totalCount = totalCount)
}
LazyColumnFastScroller(
enabled = fastScrollerEnabled,
userScrollEnabled = !isDisplayingContextMenu,
fastScrollerState = fastScrollerState,
lazyListState = lazyListState,
modifier = modifier.fillMaxSize(),
letterContent = {
Emojifier(text = it.toString()) { annotatedText, inlineContent ->
Text(
text = annotatedText,
inlineContent = inlineContent,
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
) {
MappingLazyColumn(
userScrollEnabled = !isDisplayingContextMenu,
controller = mappingCtrl,
lazyListState = it,
modifier = Modifier.fillMaxSize()
)
}
}
@Composable
private fun rememberContactSearchMappingEntryProvider(
fixedContacts: Set<ContactSearchKey>,
displayOptions: ContactSearchAdapter.DisplayOptions,
callbacks: ContactSearchAdapter.ClickCallbacks,
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks?,
callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
): MappingEntryProvider<Any> {
return rememberMappingEntryProvider {
// Subclass-registered models (Message, Thread, Empty, GroupWithMembers) and
// ArbitraryRepository-backed models are handled separately.
provider<Any>(
ContactSearchModels.composeEntries(
fixedContacts = fixedContacts,
displayOptions = displayOptions,
callbacks = callbacks,
longClickCallbacks = longClickCallbacks,
storyContextMenuCallbacks = storyContextMenuCallbacks,
callButtonClickCallbacks = callButtonClickCallbacks
)
)
}
}
@Composable
fun rememberDefaultContactSearchItemClickCallbacks(viewModel: ContactSearchViewModel, callbacks: ContactSearchCallbacks): ContactSearchAdapter.ClickCallbacks {
val fragmentManager = LocalFragmentManager.current
return remember(callbacks) {
DefaultClickCallbacks(viewModel, callbacks, fragmentManager)
}
}
@Composable
fun rememberDefaultContactSearchItemLongClickCallbacks(): ContactSearchAdapter.LongClickCallbacks {
return remember { ContactSearchAdapter.LongClickCallbacksAdapter() }
}
@Composable
fun rememberDefaultContactSearchItemStoryContextMenuCallbacks(viewModel: ContactSearchViewModel): ContactSearchAdapter.StoryContextMenuCallbacks {
val context = LocalContext.current
val fragmentManager = LocalFragmentManager.current
return remember { DefaultStoryContextMenuCallbacks(viewModel, fragmentManager, context) }
}
@Composable
fun rememberDefaultContactSearchItemCallButtonClickCallbacks(): ContactSearchAdapter.CallButtonClickCallbacks {
return remember { ContactSearchAdapter.EmptyCallButtonClickCallbacks }
}
private class DefaultClickCallbacks(
private val viewModel: ContactSearchViewModel,
private val callbacks: State<ContactSearchCallbacks>,
private val fragmentManager: State<FragmentManager?>
private val callbacks: ContactSearchCallbacks,
private val fragmentManager: FragmentManager?
) : ContactSearchAdapter.ClickCallbacks {
companion object {
@@ -179,7 +224,7 @@ private class DefaultClickCallbacks(
override fun onStoryClicked(view: View, story: ContactSearchData.Story, isSelected: Boolean) {
Log.d(TAG, "onStoryClicked()")
if (story.recipient.isMyStory && !SignalStore.story.userHasBeenNotifiedAboutStories) {
fragmentManager.value?.let { ChooseInitialMyStoryMembershipBottomSheetDialogFragment.show(it) }
fragmentManager?.let { ChooseInitialMyStoryMembershipBottomSheetDialogFragment.show(it) }
} else {
toggle(view, story, isSelected)
}
@@ -200,30 +245,30 @@ private class DefaultClickCallbacks(
if (isSelected) {
viewModel.setKeysNotSelected(setOf(chatTypeRow.contactSearchKey))
} else {
viewModel.setKeysSelected(callbacks.value.onBeforeContactsSelected(view, setOf(chatTypeRow.contactSearchKey)))
viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(view, setOf(chatTypeRow.contactSearchKey)))
}
}
private fun toggle(view: View, data: ContactSearchData, isSelected: Boolean) {
if (isSelected) {
Log.d(TAG, "toggle(OFF) ${data.contactSearchKey}")
callbacks.value.onContactDeselected(view, data.contactSearchKey)
callbacks.onContactDeselected(view, data.contactSearchKey)
viewModel.setKeysNotSelected(setOf(data.contactSearchKey))
} else {
Log.d(TAG, "toggle(ON) ${data.contactSearchKey}")
viewModel.setKeysSelected(callbacks.value.onBeforeContactsSelected(view, setOf(data.contactSearchKey)))
viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(view, setOf(data.contactSearchKey)))
}
}
}
private class DefaultStoryContextMenuCallbacks(
private val viewModel: ContactSearchViewModel,
private val fragmentManager: State<FragmentManager?>,
private val context: State<Context>
private val fragmentManager: FragmentManager?,
private val context: Context
) : ContactSearchAdapter.StoryContextMenuCallbacks {
override fun onOpenStorySettings(story: ContactSearchData.Story) {
val fm = fragmentManager.value ?: return
val fm = fragmentManager ?: return
if (story.recipient.isMyStory) {
MyStorySettingsFragment.createAsDialog().show(fm, null)
} else {
@@ -232,8 +277,8 @@ private class DefaultStoryContextMenuCallbacks(
}
override fun onRemoveGroupStory(story: ContactSearchData.Story, isSelected: Boolean) {
fragmentManager.value ?: return
MaterialAlertDialogBuilder(context.value)
fragmentManager ?: return
MaterialAlertDialogBuilder(context)
.setTitle(R.string.ContactSearchMediator__remove_group_story)
.setMessage(R.string.ContactSearchMediator__this_will_remove)
.setPositiveButton(R.string.ContactSearchMediator__remove) { _, _ -> viewModel.removeGroupStory(story) }
@@ -242,8 +287,8 @@ private class DefaultStoryContextMenuCallbacks(
}
override fun onDeletePrivateStory(story: ContactSearchData.Story, isSelected: Boolean) {
fragmentManager.value ?: return
val ctx = context.value
fragmentManager ?: return
val ctx = context
MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.ContactSearchMediator__delete_story)
.setMessage(ctx.getString(R.string.ContactSearchMediator__delete_the_custom, story.recipient.getDisplayName(ctx)))
@@ -1,47 +1,15 @@
package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.text.SpannableStringBuilder
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import org.signal.core.util.BreakIteratorCompat
import org.signal.core.util.requireDrawable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar
import org.thoughtcrime.securesms.avatar.view.AvatarView
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.FromTextView
import org.signal.core.ui.compose.FastScrollCharacterProvider
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller.FastScrollAdapter
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.thoughtcrime.securesms.util.visible
import org.signal.core.ui.R as CoreUiR
/**
* Default contact search adapter, using the models defined in `ContactSearchItems`
* Default contact search adapter. View holders, mapping models, and the helpers that register them
* live in [ContactSearchModels].
*/
@Suppress("LeakingThis")
open class ContactSearchAdapter(
@@ -55,12 +23,12 @@ open class ContactSearchAdapter(
) : PagingMappingAdapter<ContactSearchKey>(), FastScrollAdapter {
init {
registerStoryItems(this, displayOptions.displayCheckBox, onClickCallbacks::onStoryClicked, storyContextMenuCallbacks, displayOptions.displayStoryRing)
registerKnownRecipientItems(this, fixedContacts, displayOptions, onClickCallbacks::onKnownRecipientClicked, longClickCallbacks::onKnownRecipientLongClick, callButtonClickCallbacks)
registerHeaders(this)
registerExpands(this, onClickCallbacks::onExpandClicked)
registerChatTypeItems(this, onClickCallbacks::onChatTypeClicked)
registerFactory(UnknownRecipientModel::class.java, LayoutFactory({ UnknownRecipientViewHolder(it, onClickCallbacks::onUnknownRecipientClicked, displayOptions.displayCheckBox) }, R.layout.contact_search_unknown_item))
ContactSearchModels.registerStoryItems(this, displayOptions.displayCheckBox, onClickCallbacks::onStoryClicked, storyContextMenuCallbacks, displayOptions.displayStoryRing)
ContactSearchModels.registerKnownRecipientItems(this, fixedContacts, displayOptions, onClickCallbacks::onKnownRecipientClicked, longClickCallbacks::onKnownRecipientLongClick, callButtonClickCallbacks)
ContactSearchModels.registerHeaders(this)
ContactSearchModels.registerExpands(this, onClickCallbacks::onExpandClicked)
ContactSearchModels.registerChatTypeItems(this, onClickCallbacks::onChatTypeClicked)
ContactSearchModels.registerUnknownRecipientItems(this, onClickCallbacks::onUnknownRecipientClicked, displayOptions.displayCheckBox)
}
override fun getBubbleText(position: Int): CharSequence {
@@ -72,701 +40,6 @@ open class ContactSearchAdapter(
}
}
interface FastScrollCharacterProvider {
fun getFastScrollCharacter(context: Context): CharSequence
}
companion object {
fun registerStoryItems(
mappingAdapter: MappingAdapter,
displayCheckBox: Boolean = false,
storyListener: OnClickedCallback<ContactSearchData.Story>,
storyContextMenuCallbacks: StoryContextMenuCallbacks? = null,
showStoryRing: Boolean = false
) {
mappingAdapter.registerFactory(
StoryModel::class.java,
LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener, storyContextMenuCallbacks, showStoryRing) }, R.layout.contact_search_story_item)
)
}
fun registerKnownRecipientItems(
mappingAdapter: MappingAdapter,
fixedContacts: Set<ContactSearchKey>,
displayOptions: DisplayOptions,
recipientListener: OnClickedCallback<ContactSearchData.KnownRecipient>,
recipientLongClickCallback: OnLongClickedCallback<ContactSearchData.KnownRecipient>,
recipientCallButtonClickCallbacks: CallButtonClickCallbacks
) {
mappingAdapter.registerFactory(
RecipientModel::class.java,
LayoutFactory({
KnownRecipientViewHolder(it, fixedContacts, displayOptions, recipientListener, recipientLongClickCallback, recipientCallButtonClickCallbacks)
}, R.layout.contact_search_item)
)
}
fun registerHeaders(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(
HeaderModel::class.java,
LayoutFactory({ HeaderViewHolder(it) }, R.layout.contact_search_section_header)
)
}
fun registerExpands(mappingAdapter: MappingAdapter, expandListener: (ContactSearchData.Expand) -> Unit) {
mappingAdapter.registerFactory(
ExpandModel::class.java,
LayoutFactory({ ExpandViewHolder(it, expandListener) }, R.layout.contacts_expand_item)
)
}
fun registerChatTypeItems(mappingAdapter: MappingAdapter, chatTypeRowListener: OnClickedCallback<ContactSearchData.ChatTypeRow>) {
mappingAdapter.registerFactory(
ChatTypeModel::class.java,
LayoutFactory({ ChatTypeViewHolder(it, chatTypeRowListener) }, R.layout.contact_search_chat_type_item)
)
}
fun toMappingModelList(contactSearchData: List<ContactSearchData?>, selection: Set<ContactSearchKey>, arbitraryRepository: ArbitraryRepository?): MappingModelList {
return MappingModelList(
contactSearchData.filterNotNull().map {
when (it) {
is ContactSearchData.Story -> StoryModel(it, selection.contains(it.contactSearchKey), SignalStore.story.userHasBeenNotifiedAboutStories)
is ContactSearchData.KnownRecipient -> RecipientModel(it, selection.contains(it.contactSearchKey), it.shortSummary)
is ContactSearchData.Expand -> ExpandModel(it)
is ContactSearchData.Header -> HeaderModel(it)
is ContactSearchData.TestRow -> error("This row exists for testing only.")
is ContactSearchData.Arbitrary -> arbitraryRepository?.getMappingModel(it) ?: error("This row must be handled manually")
is ContactSearchData.Message -> MessageModel(it)
is ContactSearchData.Thread -> ThreadModel(it)
is ContactSearchData.Empty -> EmptyModel(it)
is ContactSearchData.GroupWithMembers -> GroupWithMembersModel(it)
is ContactSearchData.UnknownRecipient -> UnknownRecipientModel(it)
is ContactSearchData.ChatTypeRow -> ChatTypeModel(it, selection.contains(it.contactSearchKey))
}
}
)
}
}
/**
* Story Model
*/
class StoryModel(val story: ContactSearchData.Story, val isSelected: Boolean, val hasBeenNotified: Boolean) : MappingModel<StoryModel> {
override fun areItemsTheSame(newItem: StoryModel): Boolean {
return newItem.story == story
}
override fun areContentsTheSame(newItem: StoryModel): Boolean {
return story.recipient.hasSameContent(newItem.story.recipient) &&
isSelected == newItem.isSelected &&
hasBeenNotified == newItem.hasBeenNotified
}
override fun getChangePayload(newItem: StoryModel): Any? {
return if (story.recipient.hasSameContent(newItem.story.recipient) &&
hasBeenNotified == newItem.hasBeenNotified &&
newItem.isSelected != isSelected
) {
0
} else {
null
}
}
}
private class StoryViewHolder(
itemView: View,
val displayCheckBox: Boolean,
val onClick: OnClickedCallback<ContactSearchData.Story>,
private val storyContextMenuCallbacks: StoryContextMenuCallbacks?,
private val showStoryRing: Boolean = false
) : MappingViewHolder<StoryModel>(itemView) {
val avatar: AvatarView = itemView.findViewById(R.id.contact_photo_image)
val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge)
val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
val name: FromTextView = itemView.findViewById(R.id.name)
val number: TextView = itemView.findViewById(R.id.number)
val groupStoryIndicator: AppCompatImageView = itemView.findViewById(R.id.group_story_indicator)
var storyViewState: Observable<StoryViewState>? = null
var storyDisposable: Disposable? = null
override fun bind(model: StoryModel) {
itemView.setOnClickListener { onClick.onClicked(avatar, getData(model), isSelected(model)) }
bindLongPress(model)
bindCheckbox(model)
if (payload.isNotEmpty()) {
return
}
storyViewState = if (showStoryRing) StoryViewState.getForRecipientId(getRecipient(model).id) else null
avatar.setStoryRingFromState(StoryViewState.NONE)
groupStoryIndicator.isActivated = false
name.setText(getRecipient(model))
badge.setBadgeFromRecipient(getRecipient(model))
bindAvatar(model)
bindNumberField(model)
}
fun isSelected(model: StoryModel): Boolean = model.isSelected
fun getData(model: StoryModel): ContactSearchData.Story = model.story
fun getRecipient(model: StoryModel): Recipient = model.story.recipient
fun bindNumberField(model: StoryModel) {
number.visible = true
val count = if (model.story.recipient.isGroup) {
model.story.recipient.participantIds.size
} else {
model.story.count
}
if (model.story.recipient.isMyStory && !model.hasBeenNotified) {
number.setText(R.string.ContactSearchItems__tap_to_choose_your_viewers)
number.setSingleLine(false)
} else {
number.setSingleLine(true)
number.text = when {
model.story.recipient.isGroup -> context.resources.getQuantityString(R.plurals.ContactSearchItems__group_story_d_viewers, count, count)
model.story.recipient.isMyStory -> {
if (model.story.privacyMode == DistributionListPrivacyMode.ALL_EXCEPT) {
context.resources.getQuantityString(R.plurals.ContactSearchItems__my_story_s_dot_d_excluded, count, presentPrivacyMode(DistributionListPrivacyMode.ALL), count)
} else {
context.resources.getQuantityString(R.plurals.ContactSearchItems__my_story_s_dot_d_viewers, count, presentPrivacyMode(model.story.privacyMode), count)
}
}
else -> context.resources.getQuantityString(R.plurals.ContactSearchItems__custom_story_d_viewers, count, count)
}
}
}
fun bindCheckbox(model: StoryModel) {
checkbox.visible = displayCheckBox
checkbox.isChecked = isSelected(model)
}
fun bindAvatar(model: StoryModel) {
if (model.story.recipient.isMyStory) {
avatar.setFallbackAvatarProvider(MyStoryFallbackAvatarProvider)
avatar.displayProfileAvatar(Recipient.self())
} else {
avatar.setFallbackAvatarProvider(null)
avatar.displayChatAvatar(getRecipient(model))
}
groupStoryIndicator.visible = showStoryRing && model.story.recipient.isGroup
}
fun bindLongPress(model: StoryModel) {
if (storyContextMenuCallbacks == null) {
return
}
itemView.setOnLongClickListener {
val actions: List<ActionItem> = when {
model.story.recipient.isMyStory -> getMyStoryContextMenuActions(model, storyContextMenuCallbacks)
model.story.recipient.isGroup -> getGroupStoryContextMenuActions(model, storyContextMenuCallbacks)
model.story.recipient.isDistributionList -> getPrivateStoryContextMenuActions(model, storyContextMenuCallbacks)
else -> error("Unsupported story target. Not a group or distribution list.")
}
SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup)
.offsetX(context.resources.getDimensionPixelSize(R.dimen.dsl_settings_gutter))
.show(actions)
true
}
}
private fun getMyStoryContextMenuActions(model: StoryModel, callbacks: StoryContextMenuCallbacks): List<ActionItem> {
return listOf(
ActionItem(CoreUiR.drawable.symbol_settings_android_24, context.getString(R.string.ContactSearchItems__story_settings)) {
callbacks.onOpenStorySettings(model.story)
}
)
}
private fun getGroupStoryContextMenuActions(model: StoryModel, callbacks: StoryContextMenuCallbacks): List<ActionItem> {
return listOf(
ActionItem(R.drawable.symbol_minus_circle_24, context.getString(R.string.ContactSearchItems__remove_story)) {
callbacks.onRemoveGroupStory(model.story, model.isSelected)
}
)
}
private fun getPrivateStoryContextMenuActions(model: StoryModel, callbacks: StoryContextMenuCallbacks): List<ActionItem> {
return listOf(
ActionItem(CoreUiR.drawable.symbol_settings_android_24, context.getString(R.string.ContactSearchItems__story_settings)) {
callbacks.onOpenStorySettings(model.story)
},
ActionItem(CoreUiR.drawable.symbol_trash_24, context.getString(R.string.ContactSearchItems__delete_story), CoreUiR.color.signal_colorError) {
callbacks.onDeletePrivateStory(model.story, model.isSelected)
}
)
}
private fun presentPrivacyMode(privacyMode: DistributionListPrivacyMode): String {
return when (privacyMode) {
DistributionListPrivacyMode.ONLY_WITH -> context.getString(R.string.ContactSearchItems__only_share_with)
DistributionListPrivacyMode.ALL_EXCEPT -> context.getString(R.string.ChooseInitialMyStoryMembershipFragment__all_except)
DistributionListPrivacyMode.ALL -> context.getString(R.string.ChooseInitialMyStoryMembershipFragment__all_signal_connections)
}
}
private object MyStoryFallbackAvatarProvider : AvatarImageView.FallbackAvatarProvider {
override fun getFallbackAvatar(recipient: Recipient): FallbackAvatar {
if (recipient.isSelf) {
return FallbackAvatar.Resource.Person(recipient.avatarColor)
}
return super.getFallbackAvatar(recipient)
}
}
override fun onAttachedToWindow() {
storyDisposable = storyViewState?.observeOn(AndroidSchedulers.mainThread())?.subscribe {
avatar.setStoryRingFromState(it)
when (it) {
StoryViewState.UNVIEWED -> groupStoryIndicator.isActivated = true
else -> groupStoryIndicator.isActivated = false
}
}
}
override fun onDetachedFromWindow() {
storyDisposable?.dispose()
}
}
/**
* Recipient model
*/
class RecipientModel(
val knownRecipient: ContactSearchData.KnownRecipient,
val isSelected: Boolean,
val shortSummary: Boolean
) : MappingModel<RecipientModel>, FastScrollCharacterProvider {
override fun getFastScrollCharacter(context: Context): CharSequence {
val name = if (knownRecipient.recipient.isSelf) {
context.getString(R.string.note_to_self)
} else {
knownRecipient.recipient.getDisplayName(context)
}
val letter: CharSequence = BreakIteratorCompat.getInstance()
.apply { setText(name) }
.asSequence()
.map { charSequence -> charSequence.trim { it <= ' ' } }
.filter { it.isNotEmpty() }
.mapNotNull {
when {
EmojiUtil.isEmoji(it.toString()) -> it
Character.isLetterOrDigit(it[0]) -> it[0].uppercaseChar().toString()
else -> null
}
}
.firstOrNull() ?: "#"
return letter
}
override fun areItemsTheSame(newItem: RecipientModel): Boolean {
return newItem.knownRecipient == knownRecipient
}
override fun areContentsTheSame(newItem: RecipientModel): Boolean {
return knownRecipient.recipient.hasSameContent(newItem.knownRecipient.recipient) && isSelected == newItem.isSelected
}
override fun getChangePayload(newItem: RecipientModel): Any? {
return if (knownRecipient.recipient.hasSameContent(newItem.knownRecipient.recipient) && newItem.isSelected != isSelected) {
0
} else {
null
}
}
}
class UnknownRecipientModel(val data: ContactSearchData.UnknownRecipient) : MappingModel<UnknownRecipientModel> {
override fun areItemsTheSame(newItem: UnknownRecipientModel): Boolean = true
override fun areContentsTheSame(newItem: UnknownRecipientModel): Boolean = data == newItem.data
}
private class UnknownRecipientViewHolder(
itemView: View,
private val onClick: OnClickedCallback<ContactSearchData.UnknownRecipient>,
private val displayCheckBox: Boolean
) : MappingViewHolder<UnknownRecipientModel>(itemView) {
private val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
private val name: FromTextView = itemView.findViewById(R.id.name)
private val number: TextView = itemView.findViewById(R.id.number)
private val headerGroup: View = itemView.findViewById(R.id.contact_header)
private val headerText: TextView = itemView.findViewById(R.id.section_header)
override fun bind(model: UnknownRecipientModel) {
checkbox.visible = displayCheckBox
checkbox.isSelected = false
val nameText = when (model.data.mode) {
ContactSearchConfiguration.NewRowMode.NEW_CALL -> R.string.contact_selection_list__new_call
ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION -> -1
ContactSearchConfiguration.NewRowMode.BLOCK -> R.string.contact_selection_list__unknown_contact_block
ContactSearchConfiguration.NewRowMode.ADD_TO_GROUP -> R.string.contact_selection_list__unknown_contact_add_to_group
}
if (nameText > 0) {
name.setText(nameText)
number.text = model.data.query
number.visible = true
} else {
name.text = model.data.query
number.visible = false
}
if (model.data.mode == ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION) {
headerGroup.visible = true
headerText.setText(
if (model.data.sectionKey == ContactSearchConfiguration.SectionKey.PHONE_NUMBER) {
R.string.FindByActivity__find_by_phone_number
} else {
R.string.FindByActivity__find_by_username
}
)
} else {
headerGroup.visible = false
}
itemView.setOnClickListener {
onClick.onClicked(itemView, model.data, false)
}
}
}
private class KnownRecipientViewHolder(
itemView: View,
private val fixedContacts: Set<ContactSearchKey>,
displayOptions: DisplayOptions,
onClick: OnClickedCallback<ContactSearchData.KnownRecipient>,
private val onLongClick: OnLongClickedCallback<ContactSearchData.KnownRecipient>,
callButtonClickCallbacks: CallButtonClickCallbacks
) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, displayOptions, onClick, callButtonClickCallbacks), LetterHeaderDecoration.LetterHeaderItem {
private var headerLetter: String? = null
override fun isSelected(model: RecipientModel): Boolean = model.isSelected
override fun getData(model: RecipientModel): ContactSearchData.KnownRecipient = model.knownRecipient
override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient
override fun bindNumberField(model: RecipientModel) {
val recipient = getRecipient(model)
if (model.knownRecipient.sectionKey == ContactSearchConfiguration.SectionKey.GROUP_MEMBERS) {
number.text = model.knownRecipient.groupsInCommon.toDisplayText(context, displayGroupsLimit = 2)
number.visible = true
} else if (model.shortSummary && recipient.isGroup) {
val count = recipient.participantIds.size
number.text = context.resources.getQuantityString(R.plurals.ContactSearchItems__group_d_members, count, count)
number.visible = true
} else if (displayOptions.displaySecondaryInformation == DisplaySecondaryInformation.ALWAYS && recipient.combinedAboutAndEmoji != null) {
number.text = recipient.combinedAboutAndEmoji
number.visible = true
} else if (displayOptions.displaySecondaryInformation == DisplaySecondaryInformation.ALWAYS && recipient.hasE164) {
number.visible = false
} else {
super.bindNumberField(model)
}
headerLetter = model.knownRecipient.headerLetter
}
override fun bindCheckbox(model: RecipientModel) {
super.bindCheckbox(model)
if (fixedContacts.contains(model.knownRecipient.contactSearchKey)) {
checkbox.isChecked = true
}
checkbox.isEnabled = !fixedContacts.contains(model.knownRecipient.contactSearchKey)
}
override fun isEnabled(model: RecipientModel): Boolean {
return !fixedContacts.contains(model.knownRecipient.contactSearchKey)
}
override fun getHeaderLetter(): String? {
return headerLetter
}
override fun bindLongPress(model: RecipientModel) {
itemView.setOnLongClickListener { onLongClick.onLongClicked(itemView, model.knownRecipient) }
}
}
/**
* Base Recipient View Holder
*/
abstract class BaseRecipientViewHolder<T : MappingModel<T>, D : ContactSearchData>(
itemView: View,
val displayOptions: DisplayOptions,
val onClick: OnClickedCallback<D>,
val onCallButtonClickCallbacks: CallButtonClickCallbacks
) : MappingViewHolder<T>(itemView) {
protected val avatar: AvatarImageView = itemView.findViewById(R.id.contact_photo_image)
protected val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge)
protected val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
protected val name: FromTextView = itemView.findViewById(R.id.name)
protected val number: TextView = itemView.findViewById(R.id.number)
protected val label: TextView = itemView.findViewById(R.id.label)
private val startAudio: View = itemView.findViewById(R.id.start_audio)
private val startVideo: View = itemView.findViewById(R.id.start_video)
override fun bind(model: T) {
if (isEnabled(model)) {
itemView.setOnClickListener { onClick.onClicked(avatar, getData(model), isSelected(model)) }
bindLongPress(model)
} else {
itemView.setOnClickListener(null)
}
bindCheckbox(model)
if (payload.isNotEmpty()) {
return
}
val recipient = getRecipient(model)
val suffix: CharSequence? = if (recipient.isSystemContact && !recipient.showVerified) {
SpannableStringBuilder().apply {
val drawable = context.requireDrawable(R.drawable.symbol_person_circle_24).apply {
setTint(ContextCompat.getColor(context, CoreUiR.color.signal_colorOnSurface))
}
SpanUtil.appendCenteredImageSpan(this, drawable, 16, 16)
}
} else {
null
}
name.setText(recipient, suffix)
badge.setBadgeFromRecipient(getRecipient(model))
bindAvatar(model)
bindNumberField(model)
bindLabelField(model)
bindCallButtons(model)
}
protected open fun bindCheckbox(model: T) {
checkbox.visible = displayOptions.displayCheckBox
checkbox.isChecked = isSelected(model)
}
protected open fun isEnabled(model: T): Boolean = true
protected open fun bindAvatar(model: T) {
avatar.setAvatar(getRecipient(model))
}
protected open fun bindNumberField(model: T) {
number.visible = getRecipient(model).isGroup
if (getRecipient(model).isGroup) {
number.text = getRecipient(model).participantIds
.take(10)
.map { id ->
val recipient = Recipient.resolved(id)
RecipientDisplayName(
recipient = recipient,
displayName = if (recipient.isSelf) {
context.getString(R.string.ConversationTitleView_you)
} else {
recipient.getShortDisplayName(context)
}
)
}
.sortedWith(compareBy({ it.recipient.isUnregistered }, { it.recipient.isSelf }, { it.displayName }))
.joinToString(", ") { it.displayName }
}
}
protected open fun bindLabelField(model: T) {
label.visible = false
}
protected open fun bindLongPress(model: T) = Unit
private fun bindCallButtons(model: T) {
val recipient = getRecipient(model)
if (displayOptions.displayCallButtons && (recipient.isPushGroup || recipient.isRegistered)) {
startVideo.visible = true
startAudio.visible = !recipient.isPushGroup
startVideo.setOnClickListener {
onCallButtonClickCallbacks.onVideoCallButtonClicked(recipient)
}
startAudio.setOnClickListener {
onCallButtonClickCallbacks.onAudioCallButtonClicked(recipient)
}
} else {
startVideo.visible = false
startAudio.visible = false
}
}
abstract fun isSelected(model: T): Boolean
abstract fun getData(model: T): D
abstract fun getRecipient(model: T): Recipient
}
/**
* Mapping Model for section headers
*/
class HeaderModel(val header: ContactSearchData.Header) : MappingModel<HeaderModel> {
override fun areItemsTheSame(newItem: HeaderModel): Boolean {
return header.sectionKey == newItem.header.sectionKey
}
override fun areContentsTheSame(newItem: HeaderModel): Boolean {
return areItemsTheSame(newItem) &&
header.action?.icon == newItem.header.action?.icon &&
header.action?.label == newItem.header.action?.label
}
}
/**
* Mapping Model for messages
*/
class MessageModel(val message: ContactSearchData.Message) : MappingModel<MessageModel> {
override fun areItemsTheSame(newItem: MessageModel): Boolean = message.contactSearchKey == newItem.message.contactSearchKey
override fun areContentsTheSame(newItem: MessageModel): Boolean {
return message == newItem.message
}
}
/**
* Mapping Model for threads
*/
class ThreadModel(val thread: ContactSearchData.Thread) : MappingModel<ThreadModel> {
override fun areItemsTheSame(newItem: ThreadModel): Boolean = thread.contactSearchKey == newItem.thread.contactSearchKey
override fun areContentsTheSame(newItem: ThreadModel): Boolean {
return thread == newItem.thread
}
}
class EmptyModel(val empty: ContactSearchData.Empty) : MappingModel<EmptyModel> {
override fun areItemsTheSame(newItem: EmptyModel): Boolean = true
override fun areContentsTheSame(newItem: EmptyModel): Boolean = newItem.empty == empty
}
/**
* Mapping Model for [ContactSearchData.GroupWithMembers]
*/
class GroupWithMembersModel(val groupWithMembers: ContactSearchData.GroupWithMembers) : MappingModel<GroupWithMembersModel> {
override fun areContentsTheSame(newItem: GroupWithMembersModel): Boolean = newItem.groupWithMembers == groupWithMembers
override fun areItemsTheSame(newItem: GroupWithMembersModel): Boolean = newItem.groupWithMembers.contactSearchKey == groupWithMembers.contactSearchKey
}
/**
* View Holder for section headers
*/
private class HeaderViewHolder(itemView: View) : MappingViewHolder<HeaderModel>(itemView) {
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
private val headerActionView: MaterialButton = itemView.findViewById(R.id.section_header_action)
override fun bind(model: HeaderModel) {
headerTextView.setText(
when (model.header.sectionKey) {
ContactSearchConfiguration.SectionKey.STORIES -> R.string.ContactsCursorLoader_my_stories
ContactSearchConfiguration.SectionKey.RECENTS -> R.string.ContactsCursorLoader_recent_chats
ContactSearchConfiguration.SectionKey.INDIVIDUALS -> R.string.ContactsCursorLoader_contacts
ContactSearchConfiguration.SectionKey.GROUPS -> R.string.ContactsCursorLoader_groups
ContactSearchConfiguration.SectionKey.GROUP_MEMBERS -> R.string.ContactsCursorLoader_group_members
ContactSearchConfiguration.SectionKey.CHATS -> R.string.ContactsCursorLoader__chats
ContactSearchConfiguration.SectionKey.MESSAGES -> R.string.ContactsCursorLoader__messages
ContactSearchConfiguration.SectionKey.GROUPS_WITH_MEMBERS -> R.string.ContactsCursorLoader_group_members
ContactSearchConfiguration.SectionKey.CONTACTS_WITHOUT_THREADS -> R.string.ContactsCursorLoader_contacts
ContactSearchConfiguration.SectionKey.CHAT_TYPES -> R.string.ContactsCursorLoader__chat_types
else -> error("This section does not support HEADER")
}
)
if (model.header.action != null) {
headerActionView.visible = true
headerActionView.setIconResource(model.header.action.icon)
headerActionView.setText(model.header.action.label)
headerActionView.setOnClickListener { model.header.action.action.run() }
} else {
headerActionView.visible = false
}
}
}
/**
* Mapping Model for expandable content rows.
*/
class ExpandModel(val expand: ContactSearchData.Expand) : MappingModel<ExpandModel> {
override fun areItemsTheSame(newItem: ExpandModel): Boolean {
return expand.contactSearchKey == newItem.expand.contactSearchKey
}
override fun areContentsTheSame(newItem: ExpandModel): Boolean {
return areItemsTheSame(newItem)
}
}
/**
* View Holder for expandable content rows.
*/
private class ExpandViewHolder(itemView: View, private val expandListener: (ContactSearchData.Expand) -> Unit) : MappingViewHolder<ExpandModel>(itemView) {
override fun bind(model: ExpandModel) {
itemView.setOnClickListener { expandListener.invoke(model.expand) }
}
}
/**
* Mapping Model for chat types.
*/
class ChatTypeModel(val data: ContactSearchData.ChatTypeRow, val isSelected: Boolean) : MappingModel<ChatTypeModel> {
override fun areItemsTheSame(newItem: ChatTypeModel): Boolean = data == newItem.data
override fun areContentsTheSame(newItem: ChatTypeModel): Boolean = data == newItem.data && isSelected == newItem.isSelected
}
/**
* View Holder for chat types
*/
private class ChatTypeViewHolder(
itemView: View,
val onClick: OnClickedCallback<ContactSearchData.ChatTypeRow>
) : MappingViewHolder<ChatTypeModel>(itemView) {
val image: ImageView = itemView.findViewById(R.id.image)
val name: TextView = itemView.findViewById(R.id.name)
val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
override fun bind(model: ChatTypeModel) {
itemView.setOnClickListener { onClick.onClicked(itemView, model.data, model.isSelected) }
image.setImageResource(model.data.imageResId)
if (model.data.chatType == ChatType.INDIVIDUAL) {
name.text = context.getString(R.string.ChatFoldersFragment__one_on_one_chats)
}
if (model.data.chatType == ChatType.GROUPS) {
name.text = context.getString(R.string.ChatFoldersFragment__groups)
}
checkbox.isChecked = model.isSelected
}
}
interface StoryContextMenuCallbacks {
fun onOpenStorySettings(story: ContactSearchData.Story)
fun onRemoveGroupStory(story: ContactSearchData.Story, isSelected: Boolean)
@@ -857,5 +130,3 @@ open class ContactSearchAdapter(
}
}
}
private data class RecipientDisplayName(val recipient: Recipient, val displayName: String)
@@ -36,7 +36,7 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.RecipientSearchKey]
* Data: [ContactSearchData.Story]
* Model: [ContactSearchAdapter.StoryModel]
* Model: [ContactSearchModels.StoryModel]
*/
data class Stories(
val groupStories: Set<ContactSearchData.Story> = emptySet(),
@@ -50,7 +50,7 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.RecipientSearchKey]
* Data: [ContactSearchData.KnownRecipient]
* Model: [ContactSearchAdapter.RecipientModel]
* Model: [ContactSearchModels.RecipientModel]
*/
data class Recents(
val limit: Int = 25,
@@ -76,12 +76,13 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.RecipientSearchKey]
* Data: [ContactSearchData.KnownRecipient]
* Model: [ContactSearchAdapter.RecipientModel]
* Model: [ContactSearchModels.RecipientModel]
*/
data class Individuals(
val includeSelfMode: RecipientTable.IncludeSelfMode,
val transportType: TransportType,
override val includeHeader: Boolean,
override val headerAction: HeaderAction? = null,
override val expandConfig: ExpandConfig? = null,
val includeLetterHeaders: Boolean = false,
val pushSearchResultsSortOrder: ContactSearchSortOrder = ContactSearchSortOrder.NATURAL
@@ -92,7 +93,7 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.RecipientSearchKey]
* Data: [ContactSearchData.KnownRecipient]
* Model: [ContactSearchAdapter.RecipientModel]
* Model: [ContactSearchModels.RecipientModel]
*/
data class Groups(
val includeMms: Boolean = false,
@@ -126,7 +127,7 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.RecipientSearchKey]
* Data: [ContactSearchData.KnownRecipient]
* Model: [ContactSearchAdapter.RecipientModel]
* Model: [ContactSearchModels.RecipientModel]
*/
data class GroupMembers(
override val includeHeader: Boolean = true,
@@ -139,7 +140,7 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.GroupWithMembers]
* Data: [ContactSearchData.GroupWithMembers]
* Model: [ContactSearchAdapter.GroupWithMembersModel]
* Model: [ContactSearchModels.GroupWithMembersModel]
*/
data class GroupsWithMembers(
override val includeHeader: Boolean = true,
@@ -152,7 +153,7 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.Thread]
* Data: [ContactSearchData.Thread]
* Model: [ContactSearchAdapter.ThreadModel]
* Model: [ContactSearchModels.ThreadModel]
*/
data class Chats(
val isUnreadOnly: Boolean = false,
@@ -166,7 +167,7 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.Message]
* Data: [ContactSearchData.Message]
* Model: [ContactSearchAdapter.MessageModel]
* Model: [ContactSearchModels.MessageModel]
*/
data class Messages(
override val includeHeader: Boolean = true,
@@ -180,7 +181,7 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.RecipientSearchKey]
* Data: [ContactSearchData.KnownRecipient]
* Model: [ContactSearchAdapter.RecipientModel]
* Model: [ContactSearchModels.RecipientModel]
*/
data class ContactsWithoutThreads(
override val includeHeader: Boolean = true,
@@ -202,7 +203,7 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.ChatTypeSearchKey]
* Data: [ContactSearchData.ChatTypeRow]
* Model: [ContactSearchAdapter.ChatTypeModel]
* Model: [ContactSearchModels.ChatTypeModel]
*/
data class ChatTypes(
override val includeHeader: Boolean = true,
@@ -0,0 +1,871 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.text.SpannableStringBuilder
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.widget.AppCompatImageView
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import org.signal.core.ui.compose.FastScrollCharacterProvider
import org.signal.core.util.BreakIteratorCompat
import org.signal.core.util.requireDrawable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar
import org.thoughtcrime.securesms.avatar.view.AvatarView
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.FromTextView
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProviderBuilder
import org.thoughtcrime.securesms.util.visible
import org.signal.core.ui.R as CoreUiR
/**
* Holds the [MappingModel]s and [MappingViewHolder]s used by [ContactSearchAdapter], along with
* helpers for registering them on a [MappingAdapter] (RecyclerView) or building a
* [MappingEntryProvider] (Compose).
*/
object ContactSearchModels {
fun registerStoryItems(
mappingAdapter: MappingAdapter,
displayCheckBox: Boolean = false,
storyListener: ContactSearchAdapter.OnClickedCallback<ContactSearchData.Story>,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks? = null,
showStoryRing: Boolean = false
) {
mappingAdapter.registerFactory(
StoryModel::class.java,
LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener, storyContextMenuCallbacks, showStoryRing) }, R.layout.contact_search_story_item)
)
}
fun registerKnownRecipientItems(
mappingAdapter: MappingAdapter,
fixedContacts: Set<ContactSearchKey>,
displayOptions: ContactSearchAdapter.DisplayOptions,
recipientListener: ContactSearchAdapter.OnClickedCallback<ContactSearchData.KnownRecipient>,
recipientLongClickCallback: ContactSearchAdapter.OnLongClickedCallback<ContactSearchData.KnownRecipient>,
recipientCallButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
) {
mappingAdapter.registerFactory(
RecipientModel::class.java,
LayoutFactory({
KnownRecipientViewHolder(it, fixedContacts, displayOptions, recipientListener, recipientLongClickCallback, recipientCallButtonClickCallbacks)
}, R.layout.contact_search_item)
)
}
fun registerUnknownRecipientItems(
mappingAdapter: MappingAdapter,
onClick: ContactSearchAdapter.OnClickedCallback<ContactSearchData.UnknownRecipient>,
displayCheckBox: Boolean
) {
mappingAdapter.registerFactory(
UnknownRecipientModel::class.java,
LayoutFactory({ UnknownRecipientViewHolder(it, onClick, displayCheckBox) }, R.layout.contact_search_unknown_item)
)
}
fun registerHeaders(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(
HeaderModel::class.java,
LayoutFactory({ HeaderViewHolder(it) }, R.layout.contact_search_section_header)
)
}
fun registerExpands(mappingAdapter: MappingAdapter, expandListener: (ContactSearchData.Expand) -> Unit) {
mappingAdapter.registerFactory(
ExpandModel::class.java,
LayoutFactory({ ExpandViewHolder(it, expandListener) }, R.layout.contacts_expand_item)
)
}
fun registerChatTypeItems(mappingAdapter: MappingAdapter, chatTypeRowListener: ContactSearchAdapter.OnClickedCallback<ContactSearchData.ChatTypeRow>) {
mappingAdapter.registerFactory(
ChatTypeModel::class.java,
LayoutFactory({ ChatTypeViewHolder(it, chatTypeRowListener) }, R.layout.contact_search_chat_type_item)
)
}
/**
* Returns a [MappingEntryProvider] containing the same set of view holders registered by the
* adapter-side `register*` methods, suitable for use with a Compose `MappingLazyColumn`.
* Compose-side callers can merge this with their own provider via
* [MappingEntryProviderBuilder.provider].
*/
fun composeEntries(
fixedContacts: Set<ContactSearchKey>,
displayOptions: ContactSearchAdapter.DisplayOptions,
callbacks: ContactSearchAdapter.ClickCallbacks,
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks? = null,
callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
): MappingEntryProvider<Any> {
return MappingEntryProviderBuilder<Any>().apply {
viewHolder<StoryModel>(
key = { model -> "StoryModel:${model.story.recipient.id}:${model.story.privacyMode}" }
) { ctx ->
LayoutFactory(
{ view -> StoryViewHolder(view, displayOptions.displayCheckBox, callbacks::onStoryClicked, storyContextMenuCallbacks, displayOptions.displayStoryRing) },
R.layout.contact_search_story_item
).createViewHolder(FrameLayout(ctx))
}
entry<RecipientModel>(
key = { model -> "${model.knownRecipient.sectionKey}:${model.knownRecipient.recipient.id}" }
) { model ->
Column(modifier = Modifier.fillMaxWidth()) {
val letter = model.knownRecipient.headerLetter
if (letter != null) {
Text(
text = letter,
color = colorResource(R.color.signal_text_primary),
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
modifier = Modifier
.fillMaxWidth()
.padding(
start = dimensionResource(R.dimen.dsl_settings_gutter),
end = dimensionResource(R.dimen.dsl_settings_gutter),
top = 16.dp,
bottom = 12.dp
)
)
}
var viewHolder: MappingViewHolder<RecipientModel>? by remember { mutableStateOf(null) }
AndroidView(
factory = { ctx ->
val holder = LayoutFactory(
{ view -> KnownRecipientViewHolder(view, fixedContacts, displayOptions, callbacks::onKnownRecipientClicked, longClickCallbacks::onKnownRecipientLongClick, callButtonClickCallbacks) },
R.layout.contact_search_item
).createViewHolder(FrameLayout(ctx))
viewHolder = holder
holder.itemView
},
update = {
viewHolder?.bind(model)
}
)
}
}
viewHolder<UnknownRecipientModel>(
key = { model -> "Unknown:${model.data.sectionKey}:${model.data.mode}:${model.data.query}" }
) { ctx ->
LayoutFactory(
{ view -> UnknownRecipientViewHolder(view, callbacks::onUnknownRecipientClicked, displayOptions.displayCheckBox) },
R.layout.contact_search_unknown_item
).createViewHolder(FrameLayout(ctx))
}
viewHolder<HeaderModel>(
key = { model -> "HEADER${model.header.sectionKey}" }
) { ctx ->
LayoutFactory(
{ view -> HeaderViewHolder(view) },
R.layout.contact_search_section_header
).createViewHolder(FrameLayout(ctx))
}
viewHolder<ExpandModel>(
key = { model -> "EXPAND${model.expand.sectionKey}" }
) { ctx ->
LayoutFactory(
{ view -> ExpandViewHolder(view, callbacks::onExpandClicked) },
R.layout.contacts_expand_item
).createViewHolder(FrameLayout(ctx))
}
viewHolder<ChatTypeModel>(
key = { model -> "ChatType${model.data.chatType}" }
) { ctx ->
LayoutFactory(
{ view -> ChatTypeViewHolder(view, callbacks::onChatTypeClicked) },
R.layout.contact_search_chat_type_item
).createViewHolder(FrameLayout(ctx))
}
}.build()
}
fun toMappingModelList(contactSearchData: List<ContactSearchData?>, selection: Set<ContactSearchKey>, arbitraryRepository: ArbitraryRepository?): MappingModelList {
return MappingModelList(
contactSearchData.filterNotNull().map {
when (it) {
is ContactSearchData.Story -> StoryModel(it, selection.contains(it.contactSearchKey), SignalStore.story.userHasBeenNotifiedAboutStories)
is ContactSearchData.KnownRecipient -> RecipientModel(it, selection.contains(it.contactSearchKey), it.shortSummary)
is ContactSearchData.Expand -> ExpandModel(it)
is ContactSearchData.Header -> HeaderModel(it)
is ContactSearchData.TestRow -> error("This row exists for testing only.")
is ContactSearchData.Arbitrary -> arbitraryRepository?.getMappingModel(it) ?: error("This row must be handled manually")
is ContactSearchData.Message -> MessageModel(it)
is ContactSearchData.Thread -> ThreadModel(it)
is ContactSearchData.Empty -> EmptyModel(it)
is ContactSearchData.GroupWithMembers -> GroupWithMembersModel(it)
is ContactSearchData.UnknownRecipient -> UnknownRecipientModel(it)
is ContactSearchData.ChatTypeRow -> ChatTypeModel(it, selection.contains(it.contactSearchKey))
}
}
)
}
/**
* Story Model
*/
class StoryModel(val story: ContactSearchData.Story, val isSelected: Boolean, val hasBeenNotified: Boolean) : MappingModel<StoryModel> {
override fun areItemsTheSame(newItem: StoryModel): Boolean {
return newItem.story == story
}
override fun areContentsTheSame(newItem: StoryModel): Boolean {
return story.recipient.hasSameContent(newItem.story.recipient) &&
isSelected == newItem.isSelected &&
hasBeenNotified == newItem.hasBeenNotified
}
override fun getChangePayload(newItem: StoryModel): Any? {
return if (story.recipient.hasSameContent(newItem.story.recipient) &&
hasBeenNotified == newItem.hasBeenNotified &&
newItem.isSelected != isSelected
) {
0
} else {
null
}
}
}
private class StoryViewHolder(
itemView: View,
val displayCheckBox: Boolean,
val onClick: ContactSearchAdapter.OnClickedCallback<ContactSearchData.Story>,
private val storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks?,
private val showStoryRing: Boolean = false
) : MappingViewHolder<StoryModel>(itemView) {
val avatar: AvatarView = itemView.findViewById(R.id.contact_photo_image)
val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge)
val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
val name: FromTextView = itemView.findViewById(R.id.name)
val number: TextView = itemView.findViewById(R.id.number)
val groupStoryIndicator: AppCompatImageView = itemView.findViewById(R.id.group_story_indicator)
var storyViewState: Observable<StoryViewState>? = null
var storyDisposable: Disposable? = null
override fun bind(model: StoryModel) {
itemView.setOnClickListener { onClick.onClicked(avatar, getData(model), isSelected(model)) }
bindLongPress(model)
bindCheckbox(model)
if (payload.isNotEmpty()) {
return
}
storyViewState = if (showStoryRing) StoryViewState.getForRecipientId(getRecipient(model).id) else null
avatar.setStoryRingFromState(StoryViewState.NONE)
groupStoryIndicator.isActivated = false
name.setText(getRecipient(model))
badge.setBadgeFromRecipient(getRecipient(model))
bindAvatar(model)
bindNumberField(model)
}
fun isSelected(model: StoryModel): Boolean = model.isSelected
fun getData(model: StoryModel): ContactSearchData.Story = model.story
fun getRecipient(model: StoryModel): Recipient = model.story.recipient
fun bindNumberField(model: StoryModel) {
number.visible = true
val count = if (model.story.recipient.isGroup) {
model.story.recipient.participantIds.size
} else {
model.story.count
}
if (model.story.recipient.isMyStory && !model.hasBeenNotified) {
number.setText(R.string.ContactSearchItems__tap_to_choose_your_viewers)
number.setSingleLine(false)
} else {
number.setSingleLine(true)
number.text = when {
model.story.recipient.isGroup -> context.resources.getQuantityString(R.plurals.ContactSearchItems__group_story_d_viewers, count, count)
model.story.recipient.isMyStory -> {
if (model.story.privacyMode == DistributionListPrivacyMode.ALL_EXCEPT) {
context.resources.getQuantityString(R.plurals.ContactSearchItems__my_story_s_dot_d_excluded, count, presentPrivacyMode(DistributionListPrivacyMode.ALL), count)
} else {
context.resources.getQuantityString(R.plurals.ContactSearchItems__my_story_s_dot_d_viewers, count, presentPrivacyMode(model.story.privacyMode), count)
}
}
else -> context.resources.getQuantityString(R.plurals.ContactSearchItems__custom_story_d_viewers, count, count)
}
}
}
fun bindCheckbox(model: StoryModel) {
checkbox.visible = displayCheckBox
checkbox.isChecked = isSelected(model)
}
fun bindAvatar(model: StoryModel) {
if (model.story.recipient.isMyStory) {
avatar.setFallbackAvatarProvider(MyStoryFallbackAvatarProvider)
avatar.displayProfileAvatar(Recipient.self())
} else {
avatar.setFallbackAvatarProvider(null)
avatar.displayChatAvatar(getRecipient(model))
}
groupStoryIndicator.visible = showStoryRing && model.story.recipient.isGroup
}
fun bindLongPress(model: StoryModel) {
if (storyContextMenuCallbacks == null) {
return
}
itemView.setOnLongClickListener {
val actions: List<ActionItem> = when {
model.story.recipient.isMyStory -> getMyStoryContextMenuActions(model, storyContextMenuCallbacks)
model.story.recipient.isGroup -> getGroupStoryContextMenuActions(model, storyContextMenuCallbacks)
model.story.recipient.isDistributionList -> getPrivateStoryContextMenuActions(model, storyContextMenuCallbacks)
else -> error("Unsupported story target. Not a group or distribution list.")
}
SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup)
.offsetX(context.resources.getDimensionPixelSize(R.dimen.dsl_settings_gutter))
.show(actions)
true
}
}
private fun getMyStoryContextMenuActions(model: StoryModel, callbacks: ContactSearchAdapter.StoryContextMenuCallbacks): List<ActionItem> {
return listOf(
ActionItem(CoreUiR.drawable.symbol_settings_android_24, context.getString(R.string.ContactSearchItems__story_settings)) {
callbacks.onOpenStorySettings(model.story)
}
)
}
private fun getGroupStoryContextMenuActions(model: StoryModel, callbacks: ContactSearchAdapter.StoryContextMenuCallbacks): List<ActionItem> {
return listOf(
ActionItem(R.drawable.symbol_minus_circle_24, context.getString(R.string.ContactSearchItems__remove_story)) {
callbacks.onRemoveGroupStory(model.story, model.isSelected)
}
)
}
private fun getPrivateStoryContextMenuActions(model: StoryModel, callbacks: ContactSearchAdapter.StoryContextMenuCallbacks): List<ActionItem> {
return listOf(
ActionItem(CoreUiR.drawable.symbol_settings_android_24, context.getString(R.string.ContactSearchItems__story_settings)) {
callbacks.onOpenStorySettings(model.story)
},
ActionItem(CoreUiR.drawable.symbol_trash_24, context.getString(R.string.ContactSearchItems__delete_story), CoreUiR.color.signal_colorError) {
callbacks.onDeletePrivateStory(model.story, model.isSelected)
}
)
}
private fun presentPrivacyMode(privacyMode: DistributionListPrivacyMode): String {
return when (privacyMode) {
DistributionListPrivacyMode.ONLY_WITH -> context.getString(R.string.ContactSearchItems__only_share_with)
DistributionListPrivacyMode.ALL_EXCEPT -> context.getString(R.string.ChooseInitialMyStoryMembershipFragment__all_except)
DistributionListPrivacyMode.ALL -> context.getString(R.string.ChooseInitialMyStoryMembershipFragment__all_signal_connections)
}
}
private object MyStoryFallbackAvatarProvider : AvatarImageView.FallbackAvatarProvider {
override fun getFallbackAvatar(recipient: Recipient): FallbackAvatar {
if (recipient.isSelf) {
return FallbackAvatar.Resource.Person(recipient.avatarColor)
}
return super.getFallbackAvatar(recipient)
}
}
override fun onAttachedToWindow() {
storyDisposable = storyViewState?.observeOn(AndroidSchedulers.mainThread())?.subscribe {
avatar.setStoryRingFromState(it)
when (it) {
StoryViewState.UNVIEWED -> groupStoryIndicator.isActivated = true
else -> groupStoryIndicator.isActivated = false
}
}
}
override fun onDetachedFromWindow() {
storyDisposable?.dispose()
}
}
/**
* Recipient model
*/
class RecipientModel(
val knownRecipient: ContactSearchData.KnownRecipient,
val isSelected: Boolean,
val shortSummary: Boolean
) : MappingModel<RecipientModel>, FastScrollCharacterProvider {
override fun getFastScrollCharacter(context: Context): CharSequence {
val name = if (knownRecipient.recipient.isSelf) {
context.getString(R.string.note_to_self)
} else {
knownRecipient.recipient.getDisplayName(context)
}
val letter: CharSequence = BreakIteratorCompat.getInstance()
.apply { setText(name) }
.asSequence()
.map { charSequence -> charSequence.trim { it <= ' ' } }
.filter { it.isNotEmpty() }
.mapNotNull {
when {
EmojiUtil.isEmoji(it.toString()) -> it
Character.isLetterOrDigit(it[0]) -> it[0].uppercaseChar().toString()
else -> null
}
}
.firstOrNull() ?: "#"
return letter
}
override fun areItemsTheSame(newItem: RecipientModel): Boolean {
return newItem.knownRecipient == knownRecipient
}
override fun areContentsTheSame(newItem: RecipientModel): Boolean {
return knownRecipient.recipient.hasSameContent(newItem.knownRecipient.recipient) && isSelected == newItem.isSelected
}
override fun getChangePayload(newItem: RecipientModel): Any? {
return if (knownRecipient.recipient.hasSameContent(newItem.knownRecipient.recipient) && newItem.isSelected != isSelected) {
0
} else {
null
}
}
}
class UnknownRecipientModel(val data: ContactSearchData.UnknownRecipient) : MappingModel<UnknownRecipientModel> {
override fun areItemsTheSame(newItem: UnknownRecipientModel): Boolean = true
override fun areContentsTheSame(newItem: UnknownRecipientModel): Boolean = data == newItem.data
}
private class UnknownRecipientViewHolder(
itemView: View,
private val onClick: ContactSearchAdapter.OnClickedCallback<ContactSearchData.UnknownRecipient>,
private val displayCheckBox: Boolean
) : MappingViewHolder<UnknownRecipientModel>(itemView) {
private val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
private val name: FromTextView = itemView.findViewById(R.id.name)
private val number: TextView = itemView.findViewById(R.id.number)
private val headerGroup: View = itemView.findViewById(R.id.contact_header)
private val headerText: TextView = itemView.findViewById(R.id.section_header)
override fun bind(model: UnknownRecipientModel) {
checkbox.visible = displayCheckBox
checkbox.isSelected = false
val nameText = when (model.data.mode) {
ContactSearchConfiguration.NewRowMode.NEW_CALL -> R.string.contact_selection_list__new_call
ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION -> -1
ContactSearchConfiguration.NewRowMode.BLOCK -> R.string.contact_selection_list__unknown_contact_block
ContactSearchConfiguration.NewRowMode.ADD_TO_GROUP -> R.string.contact_selection_list__unknown_contact_add_to_group
}
if (nameText > 0) {
name.setText(nameText)
number.text = model.data.query
number.visible = true
} else {
name.text = model.data.query
number.visible = false
}
if (model.data.mode == ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION) {
headerGroup.visible = true
headerText.setText(
if (model.data.sectionKey == ContactSearchConfiguration.SectionKey.PHONE_NUMBER) {
R.string.FindByActivity__find_by_phone_number
} else {
R.string.FindByActivity__find_by_username
}
)
} else {
headerGroup.visible = false
}
itemView.setOnClickListener {
onClick.onClicked(itemView, model.data, false)
}
}
}
private class KnownRecipientViewHolder(
itemView: View,
private val fixedContacts: Set<ContactSearchKey>,
displayOptions: ContactSearchAdapter.DisplayOptions,
onClick: ContactSearchAdapter.OnClickedCallback<ContactSearchData.KnownRecipient>,
private val onLongClick: ContactSearchAdapter.OnLongClickedCallback<ContactSearchData.KnownRecipient>,
callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, displayOptions, onClick, callButtonClickCallbacks), LetterHeaderDecoration.LetterHeaderItem {
private var headerLetter: String? = null
override fun isSelected(model: RecipientModel): Boolean = model.isSelected
override fun getData(model: RecipientModel): ContactSearchData.KnownRecipient = model.knownRecipient
override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient
override fun bindNumberField(model: RecipientModel) {
val recipient = getRecipient(model)
if (model.knownRecipient.sectionKey == ContactSearchConfiguration.SectionKey.GROUP_MEMBERS) {
number.text = model.knownRecipient.groupsInCommon.toDisplayText(context, displayGroupsLimit = 2)
number.visible = true
} else if (model.shortSummary && recipient.isGroup) {
val count = recipient.participantIds.size
number.text = context.resources.getQuantityString(R.plurals.ContactSearchItems__group_d_members, count, count)
number.visible = true
} else if (displayOptions.displaySecondaryInformation == ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS && recipient.combinedAboutAndEmoji != null) {
number.text = recipient.combinedAboutAndEmoji
number.visible = true
} else if (displayOptions.displaySecondaryInformation == ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS && recipient.hasE164) {
number.visible = false
} else {
super.bindNumberField(model)
}
headerLetter = model.knownRecipient.headerLetter
}
override fun bindCheckbox(model: RecipientModel) {
super.bindCheckbox(model)
if (fixedContacts.contains(model.knownRecipient.contactSearchKey)) {
checkbox.isChecked = true
}
checkbox.isEnabled = !fixedContacts.contains(model.knownRecipient.contactSearchKey)
}
override fun isEnabled(model: RecipientModel): Boolean {
return !fixedContacts.contains(model.knownRecipient.contactSearchKey)
}
override fun getHeaderLetter(): String? {
return headerLetter
}
override fun bindLongPress(model: RecipientModel) {
itemView.setOnLongClickListener { onLongClick.onLongClicked(itemView, model.knownRecipient) }
}
}
/**
* Base Recipient View Holder
*/
abstract class BaseRecipientViewHolder<T : MappingModel<T>, D : ContactSearchData>(
itemView: View,
val displayOptions: ContactSearchAdapter.DisplayOptions,
val onClick: ContactSearchAdapter.OnClickedCallback<D>,
val onCallButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
) : MappingViewHolder<T>(itemView) {
protected val avatar: AvatarImageView = itemView.findViewById(R.id.contact_photo_image)
protected val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge)
protected val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
protected val name: FromTextView = itemView.findViewById(R.id.name)
protected val number: TextView = itemView.findViewById(R.id.number)
protected val label: TextView = itemView.findViewById(R.id.label)
private val startAudio: View = itemView.findViewById(R.id.start_audio)
private val startVideo: View = itemView.findViewById(R.id.start_video)
override fun bind(model: T) {
if (isEnabled(model)) {
itemView.setOnClickListener { onClick.onClicked(avatar, getData(model), isSelected(model)) }
bindLongPress(model)
} else {
itemView.setOnClickListener(null)
}
bindCheckbox(model)
if (payload.isNotEmpty()) {
return
}
val recipient = getRecipient(model)
val suffix: CharSequence? = if (recipient.isSystemContact && !recipient.showVerified) {
SpannableStringBuilder().apply {
val drawable = context.requireDrawable(R.drawable.symbol_person_circle_24).apply {
setTint(ContextCompat.getColor(context, CoreUiR.color.signal_colorOnSurface))
}
SpanUtil.appendCenteredImageSpan(this, drawable, 16, 16)
}
} else {
null
}
name.setText(recipient, suffix)
badge.setBadgeFromRecipient(getRecipient(model))
bindAvatar(model)
bindNumberField(model)
bindLabelField(model)
bindCallButtons(model)
}
protected open fun bindCheckbox(model: T) {
checkbox.visible = displayOptions.displayCheckBox
checkbox.isChecked = isSelected(model)
}
protected open fun isEnabled(model: T): Boolean = true
protected open fun bindAvatar(model: T) {
avatar.setAvatar(getRecipient(model))
}
protected open fun bindNumberField(model: T) {
number.visible = getRecipient(model).isGroup
if (getRecipient(model).isGroup) {
number.text = getRecipient(model).participantIds
.take(10)
.map { id ->
val recipient = Recipient.resolved(id)
RecipientDisplayName(
recipient = recipient,
displayName = if (recipient.isSelf) {
context.getString(R.string.ConversationTitleView_you)
} else {
recipient.getShortDisplayName(context)
}
)
}
.sortedWith(compareBy({ it.recipient.isUnregistered }, { it.recipient.isSelf }, { it.displayName }))
.joinToString(", ") { it.displayName }
}
}
protected open fun bindLabelField(model: T) {
label.visible = false
}
protected open fun bindLongPress(model: T) = Unit
private fun bindCallButtons(model: T) {
val recipient = getRecipient(model)
if (displayOptions.displayCallButtons && (recipient.isPushGroup || recipient.isRegistered)) {
startVideo.visible = true
startAudio.visible = !recipient.isPushGroup
startVideo.setOnClickListener {
onCallButtonClickCallbacks.onVideoCallButtonClicked(recipient)
}
startAudio.setOnClickListener {
onCallButtonClickCallbacks.onAudioCallButtonClicked(recipient)
}
} else {
startVideo.visible = false
startAudio.visible = false
}
}
abstract fun isSelected(model: T): Boolean
abstract fun getData(model: T): D
abstract fun getRecipient(model: T): Recipient
}
/**
* Mapping Model for section headers
*/
class HeaderModel(val header: ContactSearchData.Header) : MappingModel<HeaderModel> {
override fun areItemsTheSame(newItem: HeaderModel): Boolean {
return header.sectionKey == newItem.header.sectionKey
}
override fun areContentsTheSame(newItem: HeaderModel): Boolean {
return areItemsTheSame(newItem) &&
header.action?.icon == newItem.header.action?.icon &&
header.action?.label == newItem.header.action?.label
}
}
/**
* Mapping Model for messages
*/
class MessageModel(val message: ContactSearchData.Message) : MappingModel<MessageModel> {
override fun areItemsTheSame(newItem: MessageModel): Boolean = message.contactSearchKey == newItem.message.contactSearchKey
override fun areContentsTheSame(newItem: MessageModel): Boolean {
return message == newItem.message
}
}
/**
* Mapping Model for threads
*/
class ThreadModel(val thread: ContactSearchData.Thread) : MappingModel<ThreadModel> {
override fun areItemsTheSame(newItem: ThreadModel): Boolean = thread.contactSearchKey == newItem.thread.contactSearchKey
override fun areContentsTheSame(newItem: ThreadModel): Boolean {
return thread == newItem.thread
}
}
class EmptyModel(val empty: ContactSearchData.Empty) : MappingModel<EmptyModel> {
override fun areItemsTheSame(newItem: EmptyModel): Boolean = true
override fun areContentsTheSame(newItem: EmptyModel): Boolean = newItem.empty == empty
}
/**
* Mapping Model for [ContactSearchData.GroupWithMembers]
*/
class GroupWithMembersModel(val groupWithMembers: ContactSearchData.GroupWithMembers) : MappingModel<GroupWithMembersModel> {
override fun areContentsTheSame(newItem: GroupWithMembersModel): Boolean = newItem.groupWithMembers == groupWithMembers
override fun areItemsTheSame(newItem: GroupWithMembersModel): Boolean = newItem.groupWithMembers.contactSearchKey == groupWithMembers.contactSearchKey
}
/**
* View Holder for section headers
*/
private class HeaderViewHolder(itemView: View) : MappingViewHolder<HeaderModel>(itemView) {
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
private val headerActionView: MaterialButton = itemView.findViewById(R.id.section_header_action)
override fun bind(model: HeaderModel) {
headerTextView.setText(
when (model.header.sectionKey) {
ContactSearchConfiguration.SectionKey.STORIES -> R.string.ContactsCursorLoader_my_stories
ContactSearchConfiguration.SectionKey.RECENTS -> R.string.ContactsCursorLoader_recent_chats
ContactSearchConfiguration.SectionKey.INDIVIDUALS -> R.string.ContactsCursorLoader_contacts
ContactSearchConfiguration.SectionKey.GROUPS -> R.string.ContactsCursorLoader_groups
ContactSearchConfiguration.SectionKey.GROUP_MEMBERS -> R.string.ContactsCursorLoader_group_members
ContactSearchConfiguration.SectionKey.CHATS -> R.string.ContactsCursorLoader__chats
ContactSearchConfiguration.SectionKey.MESSAGES -> R.string.ContactsCursorLoader__messages
ContactSearchConfiguration.SectionKey.GROUPS_WITH_MEMBERS -> R.string.ContactsCursorLoader_group_members
ContactSearchConfiguration.SectionKey.CONTACTS_WITHOUT_THREADS -> R.string.ContactsCursorLoader_contacts
ContactSearchConfiguration.SectionKey.CHAT_TYPES -> R.string.ContactsCursorLoader__chat_types
else -> error("This section does not support HEADER")
}
)
if (model.header.action != null) {
headerActionView.visible = true
headerActionView.setIconResource(model.header.action.icon)
headerActionView.setText(model.header.action.label)
headerActionView.setOnClickListener { model.header.action.action.run() }
} else {
headerActionView.visible = false
}
}
}
/**
* Mapping Model for expandable content rows.
*/
class ExpandModel(val expand: ContactSearchData.Expand) : MappingModel<ExpandModel> {
override fun areItemsTheSame(newItem: ExpandModel): Boolean {
return expand.contactSearchKey == newItem.expand.contactSearchKey
}
override fun areContentsTheSame(newItem: ExpandModel): Boolean {
return areItemsTheSame(newItem)
}
}
/**
* View Holder for expandable content rows.
*/
private class ExpandViewHolder(itemView: View, private val expandListener: (ContactSearchData.Expand) -> Unit) : MappingViewHolder<ExpandModel>(itemView) {
override fun bind(model: ExpandModel) {
itemView.setOnClickListener { expandListener.invoke(model.expand) }
}
}
/**
* Mapping Model for chat types.
*/
class ChatTypeModel(val data: ContactSearchData.ChatTypeRow, val isSelected: Boolean) : MappingModel<ChatTypeModel> {
override fun areItemsTheSame(newItem: ChatTypeModel): Boolean = data == newItem.data
override fun areContentsTheSame(newItem: ChatTypeModel): Boolean = data == newItem.data && isSelected == newItem.isSelected
}
/**
* View Holder for chat types
*/
private class ChatTypeViewHolder(
itemView: View,
val onClick: ContactSearchAdapter.OnClickedCallback<ContactSearchData.ChatTypeRow>
) : MappingViewHolder<ChatTypeModel>(itemView) {
val image: ImageView = itemView.findViewById(R.id.image)
val name: TextView = itemView.findViewById(R.id.name)
val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
override fun bind(model: ChatTypeModel) {
itemView.setOnClickListener { onClick.onClicked(itemView, model.data, model.isSelected) }
image.setImageResource(model.data.imageResId)
if (model.data.chatType == ChatType.INDIVIDUAL) {
name.text = context.getString(R.string.ChatFoldersFragment__one_on_one_chats)
}
if (model.data.chatType == ChatType.GROUPS) {
name.text = context.getString(R.string.ChatFoldersFragment__groups)
}
checkbox.isChecked = model.isSelected
}
}
private data class RecipientDisplayName(val recipient: Recipient, val displayName: String)
}
@@ -7,16 +7,28 @@ package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.util.AttributeSet
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.collections.immutable.persistentHashMapOf
import kotlinx.coroutines.flow.filter
import org.signal.core.ui.compose.LocalFragmentManager
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
/**
* A Compose-compatible wrapper view for the ContactSearch framework.
@@ -33,25 +45,20 @@ class ContactSearchView : AbstractComposeView {
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
/**
* Called once with the inner [RecyclerView] after first composition.
* Java callers may implement this as a lambda: `rv -> fastScroller.setRecyclerView(rv)`.
*/
fun interface RecyclerViewReadyCallback {
fun onRecyclerViewReady(recyclerView: RecyclerView)
}
private var viewModel: ContactSearchViewModel? by mutableStateOf(null)
private var currentFragmentManager: FragmentManager? = null
private var currentDisplayOptions: ContactSearchAdapter.DisplayOptions? = null
private var currentMapStateToConfiguration: ((ContactSearchState) -> ContactSearchConfiguration)? = null
private var currentAdditionalEntries: MappingEntryProvider<Any> = persistentHashMapOf()
private var lazyListState: LazyListState? = null
private var currentCallbacks: ContactSearchCallbacks = ContactSearchCallbacks.Simple()
private var currentItemDecorations: List<RecyclerView.ItemDecoration> = emptyList()
private var currentContentBottomPadding: Dp = 0.dp
private var currentAdapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory
private var currentScrollListeners: List<RecyclerView.OnScrollListener> = emptyList()
private var recyclerView: RecyclerView? = null
private var currentOnRecyclerViewReady: RecyclerViewReadyCallback? = null
private var currentClickCallbacks: ContactSearchAdapter.ClickCallbacks? = null
private var currentLongClickCallbacks: ContactSearchAdapter.LongClickCallbacks? = null
private var currentStoryContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks? = null
private var currentCallButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks? = null
init {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
@@ -74,13 +81,9 @@ class ContactSearchView : AbstractComposeView {
* @param mapStateToConfiguration Maps the current [ContactSearchState] to the active
* [ContactSearchConfiguration], re-evaluated on every state change.
* @param callbacks Hooks for filtering and reacting to selection changes.
* @param itemDecorations [RecyclerView.ItemDecoration]s added to the internal list.
* @param contentBottomPaddingDp Extra bottom padding (in dp) so last items scroll above overlaid
* UI. Java callers pass a plain `float`.
* @param adapterFactory Factory for the adapter swap for custom adapters.
* @param scrollListeners [RecyclerView.OnScrollListener]s attached to the inner list.
* @param onRecyclerViewReady Called once with the inner [RecyclerView] after first composition.
* Useful for attaching fast-scrollers or custom item animators.
* @param additionalEntries Extra [MappingEntryProvider] entries layered on top of the base
* set from [ContactSearchModels.composeEntries]. The base set is
* always applied; on key collisions the base entry wins.
*/
fun bind(
viewModel: ContactSearchViewModel,
@@ -88,27 +91,40 @@ class ContactSearchView : AbstractComposeView {
displayOptions: ContactSearchAdapter.DisplayOptions,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
callbacks: ContactSearchCallbacks = ContactSearchCallbacks.Simple(),
itemDecorations: List<RecyclerView.ItemDecoration> = emptyList(),
contentBottomPaddingDp: Float = 0f,
adapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory,
scrollListeners: List<RecyclerView.OnScrollListener> = emptyList(),
onRecyclerViewReady: RecyclerViewReadyCallback? = null
additionalEntries: MappingEntryProvider<Any> = persistentHashMapOf(),
clickCallbacks: ContactSearchAdapter.ClickCallbacks? = null,
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks? = null,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks? = null,
callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks? = null
) {
check(this.viewModel == null) { "ContactSearchView.bind() may only be called once" }
currentFragmentManager = fragmentManager
currentDisplayOptions = displayOptions
currentMapStateToConfiguration = mapStateToConfiguration
currentCallbacks = callbacks
currentItemDecorations = itemDecorations
currentContentBottomPadding = contentBottomPaddingDp.dp
currentAdapterFactory = adapterFactory
currentScrollListeners = scrollListeners
currentOnRecyclerViewReady = onRecyclerViewReady
currentAdditionalEntries = additionalEntries
if (clickCallbacks != null) {
currentClickCallbacks = clickCallbacks
}
if (longClickCallbacks != null) {
currentLongClickCallbacks = longClickCallbacks
}
if (storyContextMenuCallbacks != null) {
currentStoryContextMenuCallbacks = storyContextMenuCallbacks
}
if (callButtonClickCallbacks != null) {
currentCallButtonClickCallbacks = callButtonClickCallbacks
}
this.viewModel = viewModel // triggers recomposition
}
override fun canScrollVertically(direction: Int): Boolean {
return recyclerView?.canScrollVertically(direction) ?: super.canScrollVertically(direction)
return lazyListState?.canScrollVertically(direction) ?: super.canScrollVertically(direction)
}
@Composable
@@ -117,21 +133,40 @@ class ContactSearchView : AbstractComposeView {
val displayOptions = currentDisplayOptions ?: return
val mapStateToConfiguration = currentMapStateToConfiguration ?: return
ContactSearch(
viewModel = vm,
mapStateToConfiguration = mapStateToConfiguration,
displayOptions = displayOptions,
callbacks = currentCallbacks,
storyFragmentManager = currentFragmentManager,
onListCommitted = { currentCallbacks.onAdapterListCommitted(it) },
itemDecorations = currentItemDecorations,
contentBottomPadding = currentContentBottomPadding,
adapterFactory = currentAdapterFactory,
scrollListeners = currentScrollListeners,
onRecyclerViewReady = RecyclerViewReadyCallback { recyclerView ->
this@ContactSearchView.recyclerView = recyclerView
currentOnRecyclerViewReady?.onRecyclerViewReady(recyclerView)
}
)
lazyListState = rememberLazyListState()
val view = LocalView.current
val context = LocalContext.current
LaunchedEffect(lazyListState) {
snapshotFlow { lazyListState!!.isScrollInProgress }
.filter { it }
.collect {
ViewUtil.hideKeyboard(context, view)
}
}
CompositionLocalProvider(LocalFragmentManager provides currentFragmentManager) {
ContactSearch(
viewModel = vm,
mapStateToConfiguration = mapStateToConfiguration,
displayOptions = displayOptions,
lazyListState = lazyListState ?: rememberLazyListState(),
callbacks = currentCallbacks,
onListCommitted = { currentCallbacks.onAdapterListCommitted(it) },
additionalEntries = currentAdditionalEntries,
clickCallbacks = currentClickCallbacks ?: rememberDefaultContactSearchItemClickCallbacks(vm, currentCallbacks),
longClickCallbacks = currentLongClickCallbacks ?: rememberDefaultContactSearchItemLongClickCallbacks(),
storyContextMenuCallbacks = currentStoryContextMenuCallbacks ?: rememberDefaultContactSearchItemStoryContextMenuCallbacks(vm),
callButtonClickCallbacks = currentCallButtonClickCallbacks ?: rememberDefaultContactSearchItemCallButtonClickCallbacks(),
modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection())
)
}
}
}
private fun LazyListState.canScrollVertically(direction: Int): Boolean {
return when {
direction < 0 -> canScrollBackward
else -> canScrollForward
}
}
@@ -13,7 +13,9 @@ import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.subjects.PublishSubject
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@@ -82,6 +84,13 @@ class ContactSearchViewModel(
private val internalSelectedContacts = MutableStateFlow<Set<ContactSearchKey>>(emptySet())
private val errorEvents = PublishSubject.create<ContactSearchError>()
private val rawQuery = MutableStateFlow<String?>(savedStateHandle[QUERY])
private val internalFastScrollerEnabled = MutableStateFlow(false)
private val internalDisplayingContextMenu = MutableStateFlow(false)
private val internalScrollRequests = MutableSharedFlow<ScrollRequest>(extraBufferCapacity = 1)
val fastScrollerEnabled: StateFlow<Boolean> = internalFastScrollerEnabled
val isDisplayingContextMenu: StateFlow<Boolean> = internalDisplayingContextMenu
val scrollRequests: SharedFlow<ScrollRequest> = internalScrollRequests
init {
viewModelScope.launch {
@@ -110,15 +119,30 @@ class ContactSearchViewModel(
/** Adapter-ready models combining [data] with [selectionState]. Suitable for direct submission to a [ContactSearchAdapter]. */
val mappingModels: StateFlow<MappingModelList> = combine(data, selectionState) { contactData, selection ->
ContactSearchAdapter.toMappingModelList(contactData, selection, arbitraryRepository)
ContactSearchModels.toMappingModelList(contactData, selection, arbitraryRepository)
}.stateIn(viewModelScope, SharingStarted.Eagerly, MappingModelList())
val errorEventsStream: Observable<ContactSearchError> = errorEvents
val internalTotalCount = MutableStateFlow(0)
val totalCount: StateFlow<Int> = internalTotalCount
override fun onCleared() {
disposables.clear()
}
fun setFastScrollEnabled(enabled: Boolean) {
internalFastScrollerEnabled.update { enabled }
}
fun setDisplayingContextMenu(isDisplayingContextMenu: Boolean) {
internalDisplayingContextMenu.update { isDisplayingContextMenu }
}
fun requestScrollPosition(position: Int) {
internalScrollRequests.tryEmit(ScrollRequest(position))
}
fun setConfiguration(contactSearchConfiguration: ContactSearchConfiguration) {
val pagedDataSource = ContactSearchPagedDataSource(
contactSearchConfiguration,
@@ -126,6 +150,7 @@ class ContactSearchViewModel(
searchRepository = searchRepository,
contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository
)
internalTotalCount.value = pagedDataSource.size()
pagedData.value = PagedData.createForStateFlow(pagedDataSource, pagingConfig)
}
@@ -227,6 +252,8 @@ class ContactSearchViewModel(
controller.value?.onDataInvalidated()
}
data class ScrollRequest(val position: Int)
class Factory(
private val selectionLimits: SelectionLimits,
private val isMultiSelect: Boolean = true,
@@ -157,8 +157,7 @@ class MultiselectForwardFragment :
Log.d(TAG, "onBeforeContactsSelected() Attempting to select: ${contactSearchKeys.map { it.toString() }}, Filtered selection: ${filtered.map { it.toString() } }")
return filtered
}
},
contentBottomPaddingDp = 44f
}
)
callback = findListener()!!
@@ -607,6 +607,8 @@ class ConversationFragment :
private var releaseNotesLayoutApplied: Boolean = false
private var releaseNotesWallpaperApplied: Boolean = false
private var applyToolbarPaddingRunnable: Runnable? = null
private val jumpAndPulseScrollStrategy = object : ScrollToPositionDelegate.ScrollStrategy {
override fun performScroll(recyclerView: RecyclerView, layoutManager: LinearLayoutManager, position: Int, smooth: Boolean) {
ScrollToPositionDelegate.JumpToPositionStrategy.performScroll(recyclerView, layoutManager, position, smooth)
@@ -761,19 +763,30 @@ class ConversationFragment :
split.second
}
binding.conversationItemRecycler.addOnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
binding.conversationItemRecycler.addOnLayoutChangeListener { _, left, top, right, bottom, _, _, _, _ ->
viewModel.onChatBoundsChanged(Rect(left, top, right, bottom))
}
binding.toolbar.addOnLayoutChangeListener { _, _, _, _, bottom, _, _, _, oldBottom ->
binding.conversationItemRecycler.padding(top = bottom)
if (bottom != oldBottom && ::conversationHeaderPositionDecoration.isInitialized) {
val newMargin = bottom + 16.dp
if (conversationHeaderPositionDecoration.toolbarMargin != newMargin) {
conversationHeaderPositionDecoration.toolbarMargin = newMargin
binding.conversationItemRecycler.invalidateItemDecorations()
// Bug: ConstraintLayout can provide a negative value for the toolbar causing RV layout problems
if (bottom < 0) return@addOnLayoutChangeListener
// Bug: LinearLayoutManger can get stuck and not layout children under Compose's AndroidFragment if updated too quickly.
val rv = binding.conversationItemRecycler
applyToolbarPaddingRunnable?.let { rv.removeCallbacks(it) }
val runnable = Runnable {
if (view == null) return@Runnable
rv.padding(top = bottom)
if (bottom != oldBottom && ::conversationHeaderPositionDecoration.isInitialized) {
val newMargin = bottom + 16.dp
if (conversationHeaderPositionDecoration.toolbarMargin != newMargin) {
conversationHeaderPositionDecoration.toolbarMargin = newMargin
rv.invalidateItemDecorations()
}
}
}
applyToolbarPaddingRunnable = runnable
rv.post(runnable)
}
binding.conversationItemRecycler.addItemDecoration(ChatColorsDrawable.ChatColorsItemDecoration)
@@ -4304,6 +4317,7 @@ class ConversationFragment :
override fun handleManageGroup() {
viewModel.recipientSnapshot?.let { recipient ->
container.hideKeyboard(composeText)
chatRouter.goToChatDetail(MainNavigationDetailLocation.Chats.ConversationSettings(recipient.id))
}
}
@@ -4341,6 +4355,7 @@ class ConversationFragment :
override fun handleConversationSettings() {
viewModel.recipientSnapshot?.let { recipient ->
if (!viewModel.hasMessageRequestState || recipient.isBlocked) {
container.hideKeyboard(composeText)
chatRouter.goToChatDetail(MainNavigationDetailLocation.Chats.ConversationSettings(recipient.id))
}
}
@@ -642,8 +642,8 @@ public class ConversationListFragment extends MainFragment implements Conversati
} else {
builder.arbitrary(
conversationFilterRequest.getSource() == ConversationFilterSource.DRAG
? ConversationListSearchAdapter.ChatFilterOptions.WITHOUT_TIP.getCode()
: ConversationListSearchAdapter.ChatFilterOptions.WITH_TIP.getCode()
? ConversationListSearchModels.ChatFilterOptions.WITHOUT_TIP.getCode()
: ConversationListSearchModels.ChatFilterOptions.WITH_TIP.getCode()
);
}
@@ -2,22 +2,18 @@ package org.thoughtcrime.securesms.conversationlist
import android.content.Context
import android.view.View
import android.widget.TextView
import androidx.core.os.bundleOf
import androidx.lifecycle.LifecycleOwner
import com.bumptech.glide.RequestManager
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.conversationlist.ConversationListSearchModels.ChatFilterEmptyMappingModel
import org.thoughtcrime.securesms.conversationlist.ConversationListSearchModels.ChatFilterMappingModel
import org.thoughtcrime.securesms.conversationlist.ConversationListSearchModels.ChatFilterOptions
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
import java.util.Locale
/**
* Adapter for ConversationList search. Adds factories to render ThreadModel and MessageModel using ConversationListItem,
@@ -35,177 +31,16 @@ class ConversationListSearchAdapter(
requestManager: RequestManager
) : ContactSearchAdapter(context, fixedContacts, displayOptions, onClickedCallbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks), TimestampPayloadSupport {
companion object {
private const val PAYLOAD_TIMESTAMP = 0
}
init {
registerFactory(
ThreadModel::class.java,
LayoutFactory({ ThreadViewHolder(onClickedCallbacks::onThreadClicked, onClickedCallbacks::onThreadLongClicked, lifecycleOwner, requestManager, it) }, R.layout.conversation_list_item_view)
)
registerFactory(
MessageModel::class.java,
LayoutFactory({ MessageViewHolder(onClickedCallbacks::onMessageClicked, lifecycleOwner, requestManager, it) }, R.layout.conversation_list_item_view)
)
registerFactory(
ChatFilterMappingModel::class.java,
LayoutFactory({ ChatFilterViewHolder(it, onClickedCallbacks::onClearFilterClicked) }, R.layout.conversation_list_item_clear_filter)
)
registerFactory(
ChatFilterEmptyMappingModel::class.java,
LayoutFactory({ ChatFilterViewHolder(it, onClickedCallbacks::onClearFilterClicked) }, R.layout.conversation_list_item_clear_filter_empty)
)
registerFactory(
EmptyModel::class.java,
LayoutFactory({ EmptyViewHolder(it) }, R.layout.conversation_list_empty_search_state)
)
registerFactory(
GroupWithMembersModel::class.java,
LayoutFactory({ GroupWithMembersViewHolder(onClickedCallbacks::onGroupWithMembersClicked, lifecycleOwner, requestManager, it) }, R.layout.conversation_list_item_view)
)
ConversationListSearchModels.registerThreads(this, onClickedCallbacks::onThreadClicked, onClickedCallbacks::onThreadLongClicked, lifecycleOwner, requestManager)
ConversationListSearchModels.registerMessages(this, onClickedCallbacks::onMessageClicked, lifecycleOwner, requestManager)
ConversationListSearchModels.registerGroupsWithMembers(this, onClickedCallbacks::onGroupWithMembersClicked, lifecycleOwner, requestManager)
ConversationListSearchModels.registerEmpty(this)
ConversationListSearchModels.registerChatFilters(this, onClickedCallbacks::onClearFilterClicked)
}
override fun notifyTimestampPayloadUpdate() {
notifyItemRangeChanged(0, itemCount, PAYLOAD_TIMESTAMP)
}
private abstract class ConversationListItemViewHolder<M : MappingModel<M>>(
itemView: View
) : MappingViewHolder<M>(itemView) {
private val conversationListItem: ConversationListItem = itemView as ConversationListItem
override fun bind(model: M) {
if (payload.contains(PAYLOAD_TIMESTAMP)) {
conversationListItem.updateTimestamp()
return
}
fullBind(model)
}
abstract fun fullBind(model: M)
}
private class EmptyViewHolder(
itemView: View
) : MappingViewHolder<EmptyModel>(itemView) {
private val noResults = itemView.findViewById<TextView>(R.id.search_no_results)
override fun bind(model: EmptyModel) {
if (payload.isNotEmpty()) {
return
}
noResults.text = context.getString(R.string.SearchFragment_no_results, model.empty.query ?: "")
}
}
private class ThreadViewHolder(
private val threadListener: OnClickedCallback<ContactSearchData.Thread>,
private val threadLongClickListener: (View, ContactSearchData.Thread) -> Boolean,
private val lifecycleOwner: LifecycleOwner,
private val requestManager: RequestManager,
itemView: View
) : ConversationListItemViewHolder<ThreadModel>(itemView) {
override fun fullBind(model: ThreadModel) {
itemView.setOnClickListener {
threadListener.onClicked(itemView, model.thread, false)
}
itemView.setOnLongClickListener {
threadLongClickListener(itemView, model.thread)
}
(itemView as ConversationListItem).bindThread(
lifecycleOwner,
model.thread.threadRecord,
requestManager,
Locale.getDefault(),
emptySet(),
ConversationSet(),
model.thread.query,
true,
false,
0
)
}
}
private class MessageViewHolder(
private val messageListener: OnClickedCallback<ContactSearchData.Message>,
private val lifecycleOwner: LifecycleOwner,
private val requestManager: RequestManager,
itemView: View
) : ConversationListItemViewHolder<MessageModel>(itemView) {
override fun fullBind(model: MessageModel) {
itemView.setOnClickListener {
messageListener.onClicked(itemView, model.message, false)
}
(itemView as ConversationListItem).bindMessage(
lifecycleOwner,
model.message.messageResult,
requestManager,
Locale.getDefault(),
model.message.query
)
}
}
private class GroupWithMembersViewHolder(
private val groupWithMembersListener: OnClickedCallback<ContactSearchData.GroupWithMembers>,
private val lifecycleOwner: LifecycleOwner,
private val requestManager: RequestManager,
itemView: View
) : ConversationListItemViewHolder<GroupWithMembersModel>(itemView) {
override fun fullBind(model: GroupWithMembersModel) {
itemView.setOnClickListener {
groupWithMembersListener.onClicked(itemView, model.groupWithMembers, false)
}
(itemView as ConversationListItem).bindGroupWithMembers(
lifecycleOwner,
model.groupWithMembers,
requestManager,
Locale.getDefault()
)
}
}
private open class BaseChatFilterMappingModel<T : BaseChatFilterMappingModel<T>>(val options: ChatFilterOptions) : MappingModel<T> {
override fun areItemsTheSame(newItem: T): Boolean = true
override fun areContentsTheSame(newItem: T): Boolean = options == newItem.options
}
private class ChatFilterMappingModel(options: ChatFilterOptions) : BaseChatFilterMappingModel<ChatFilterMappingModel>(options)
private class ChatFilterEmptyMappingModel(options: ChatFilterOptions) : BaseChatFilterMappingModel<ChatFilterEmptyMappingModel>(options)
private class ChatFilterViewHolder<T : BaseChatFilterMappingModel<T>>(itemView: View, listener: () -> Unit) : MappingViewHolder<T>(itemView) {
private val tip = itemView.findViewById<View>(R.id.clear_filter_tip)
init {
itemView.findViewById<View>(R.id.clear_filter).setOnClickListener { listener() }
}
override fun bind(model: T) {
tip.visible = model.options == ChatFilterOptions.WITH_TIP
}
}
enum class ChatFilterOptions(val code: String) {
WITH_TIP("with-tip"),
WITHOUT_TIP("without-tip");
companion object {
fun fromCode(code: String): ChatFilterOptions {
return entries.firstOrNull { it.code == code } ?: WITHOUT_TIP
}
}
notifyItemRangeChanged(0, itemCount, ConversationListSearchModels.PAYLOAD_TIMESTAMP)
}
class ChatFilterRepository : ArbitraryRepository {
@@ -0,0 +1,290 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversationlist
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import androidx.lifecycle.LifecycleOwner
import com.bumptech.glide.RequestManager
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
import org.thoughtcrime.securesms.contacts.paged.ContactSearchModels.EmptyModel
import org.thoughtcrime.securesms.contacts.paged.ContactSearchModels.GroupWithMembersModel
import org.thoughtcrime.securesms.contacts.paged.ContactSearchModels.MessageModel
import org.thoughtcrime.securesms.contacts.paged.ContactSearchModels.ThreadModel
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProviderBuilder
import org.thoughtcrime.securesms.util.visible
import java.util.Locale
/**
* Holds the [MappingModel]s and [MappingViewHolder]s used by [ConversationListSearchAdapter] on top of the
* base set in [org.thoughtcrime.securesms.contacts.paged.ContactSearchModels], along with helpers for
* registering them on a [MappingAdapter] (RecyclerView) or building a [MappingEntryProvider] (Compose).
*/
object ConversationListSearchModels {
const val PAYLOAD_TIMESTAMP = 0
fun registerThreads(
mappingAdapter: MappingAdapter,
onClicked: ContactSearchAdapter.OnClickedCallback<ContactSearchData.Thread>,
onLongClicked: (View, ContactSearchData.Thread) -> Boolean,
lifecycleOwner: LifecycleOwner,
requestManager: RequestManager
) {
mappingAdapter.registerFactory(
ThreadModel::class.java,
LayoutFactory({ ThreadViewHolder(onClicked, onLongClicked, lifecycleOwner, requestManager, it) }, R.layout.conversation_list_item_view)
)
}
fun registerMessages(
mappingAdapter: MappingAdapter,
onClicked: ContactSearchAdapter.OnClickedCallback<ContactSearchData.Message>,
lifecycleOwner: LifecycleOwner,
requestManager: RequestManager
) {
mappingAdapter.registerFactory(
MessageModel::class.java,
LayoutFactory({ MessageViewHolder(onClicked, lifecycleOwner, requestManager, it) }, R.layout.conversation_list_item_view)
)
}
fun registerGroupsWithMembers(
mappingAdapter: MappingAdapter,
onClicked: ContactSearchAdapter.OnClickedCallback<ContactSearchData.GroupWithMembers>,
lifecycleOwner: LifecycleOwner,
requestManager: RequestManager
) {
mappingAdapter.registerFactory(
GroupWithMembersModel::class.java,
LayoutFactory({ GroupWithMembersViewHolder(onClicked, lifecycleOwner, requestManager, it) }, R.layout.conversation_list_item_view)
)
}
fun registerEmpty(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(
EmptyModel::class.java,
LayoutFactory({ EmptyViewHolder(it) }, R.layout.conversation_list_empty_search_state)
)
}
fun registerChatFilters(mappingAdapter: MappingAdapter, onClearFilterClicked: () -> Unit) {
mappingAdapter.registerFactory(
ChatFilterMappingModel::class.java,
LayoutFactory({ ChatFilterViewHolder(it, onClearFilterClicked) }, R.layout.conversation_list_item_clear_filter)
)
mappingAdapter.registerFactory(
ChatFilterEmptyMappingModel::class.java,
LayoutFactory({ ChatFilterViewHolder(it, onClearFilterClicked) }, R.layout.conversation_list_item_clear_filter_empty)
)
}
/**
* Returns a [MappingEntryProvider] containing the same set of view holders registered by the
* adapter-side `register*` methods, suitable for use with a Compose `MappingLazyColumn`.
*/
fun composeEntries(
onThreadClicked: ContactSearchAdapter.OnClickedCallback<ContactSearchData.Thread>,
onThreadLongClicked: (View, ContactSearchData.Thread) -> Boolean,
onMessageClicked: ContactSearchAdapter.OnClickedCallback<ContactSearchData.Message>,
onGroupWithMembersClicked: ContactSearchAdapter.OnClickedCallback<ContactSearchData.GroupWithMembers>,
onClearFilterClicked: () -> Unit,
lifecycleOwner: LifecycleOwner,
requestManager: RequestManager
): MappingEntryProvider<Any> {
return MappingEntryProviderBuilder<Any>().apply {
viewHolder<ThreadModel>(
key = { model -> "Thread:${model.thread.contactSearchKey}" }
) { ctx ->
LayoutFactory(
{ view -> ThreadViewHolder(onThreadClicked, onThreadLongClicked, lifecycleOwner, requestManager, view) },
R.layout.conversation_list_item_view
).createViewHolder(FrameLayout(ctx))
}
viewHolder<MessageModel>(
key = { model -> "Message:${model.message.contactSearchKey}" }
) { ctx ->
LayoutFactory(
{ view -> MessageViewHolder(onMessageClicked, lifecycleOwner, requestManager, view) },
R.layout.conversation_list_item_view
).createViewHolder(FrameLayout(ctx))
}
viewHolder<GroupWithMembersModel>(
key = { model -> "GroupWithMembers:${model.groupWithMembers.contactSearchKey}" }
) { ctx ->
LayoutFactory(
{ view -> GroupWithMembersViewHolder(onGroupWithMembersClicked, lifecycleOwner, requestManager, view) },
R.layout.conversation_list_item_view
).createViewHolder(FrameLayout(ctx))
}
viewHolder<EmptyModel> { ctx ->
LayoutFactory(
{ view -> EmptyViewHolder(view) },
R.layout.conversation_list_empty_search_state
).createViewHolder(FrameLayout(ctx))
}
viewHolder<ChatFilterMappingModel> { ctx ->
LayoutFactory(
{ view -> ChatFilterViewHolder<ChatFilterMappingModel>(view, onClearFilterClicked) },
R.layout.conversation_list_item_clear_filter
).createViewHolder(FrameLayout(ctx))
}
viewHolder<ChatFilterEmptyMappingModel> { ctx ->
LayoutFactory(
{ view -> ChatFilterViewHolder<ChatFilterEmptyMappingModel>(view, onClearFilterClicked) },
R.layout.conversation_list_item_clear_filter_empty
).createViewHolder(FrameLayout(ctx))
}
}.build()
}
enum class ChatFilterOptions(val code: String) {
WITH_TIP("with-tip"),
WITHOUT_TIP("without-tip");
companion object {
fun fromCode(code: String): ChatFilterOptions {
return entries.firstOrNull { it.code == code } ?: WITHOUT_TIP
}
}
}
open class BaseChatFilterMappingModel<T : BaseChatFilterMappingModel<T>>(val options: ChatFilterOptions) : MappingModel<T> {
override fun areItemsTheSame(newItem: T): Boolean = true
override fun areContentsTheSame(newItem: T): Boolean = options == newItem.options
}
class ChatFilterMappingModel(options: ChatFilterOptions) : BaseChatFilterMappingModel<ChatFilterMappingModel>(options)
class ChatFilterEmptyMappingModel(options: ChatFilterOptions) : BaseChatFilterMappingModel<ChatFilterEmptyMappingModel>(options)
private abstract class ConversationListItemViewHolder<M : MappingModel<M>>(
itemView: View
) : MappingViewHolder<M>(itemView) {
private val conversationListItem: ConversationListItem = itemView as ConversationListItem
override fun bind(model: M) {
if (payload.contains(PAYLOAD_TIMESTAMP)) {
conversationListItem.updateTimestamp()
return
}
fullBind(model)
}
abstract fun fullBind(model: M)
}
private class EmptyViewHolder(
itemView: View
) : MappingViewHolder<EmptyModel>(itemView) {
private val noResults = itemView.findViewById<TextView>(R.id.search_no_results)
override fun bind(model: EmptyModel) {
if (payload.isNotEmpty()) {
return
}
noResults.text = context.getString(R.string.SearchFragment_no_results, model.empty.query ?: "")
}
}
private class ThreadViewHolder(
private val threadListener: ContactSearchAdapter.OnClickedCallback<ContactSearchData.Thread>,
private val threadLongClickListener: (View, ContactSearchData.Thread) -> Boolean,
private val lifecycleOwner: LifecycleOwner,
private val requestManager: RequestManager,
itemView: View
) : ConversationListItemViewHolder<ThreadModel>(itemView) {
override fun fullBind(model: ThreadModel) {
itemView.setOnClickListener {
threadListener.onClicked(itemView, model.thread, false)
}
itemView.setOnLongClickListener {
threadLongClickListener(itemView, model.thread)
}
(itemView as ConversationListItem).bindThread(
lifecycleOwner,
model.thread.threadRecord,
requestManager,
Locale.getDefault(),
emptySet(),
ConversationSet(),
model.thread.query,
true,
false,
0
)
}
}
private class MessageViewHolder(
private val messageListener: ContactSearchAdapter.OnClickedCallback<ContactSearchData.Message>,
private val lifecycleOwner: LifecycleOwner,
private val requestManager: RequestManager,
itemView: View
) : ConversationListItemViewHolder<MessageModel>(itemView) {
override fun fullBind(model: MessageModel) {
itemView.setOnClickListener {
messageListener.onClicked(itemView, model.message, false)
}
(itemView as ConversationListItem).bindMessage(
lifecycleOwner,
model.message.messageResult,
requestManager,
Locale.getDefault(),
model.message.query
)
}
}
private class GroupWithMembersViewHolder(
private val groupWithMembersListener: ContactSearchAdapter.OnClickedCallback<ContactSearchData.GroupWithMembers>,
private val lifecycleOwner: LifecycleOwner,
private val requestManager: RequestManager,
itemView: View
) : ConversationListItemViewHolder<GroupWithMembersModel>(itemView) {
override fun fullBind(model: GroupWithMembersModel) {
itemView.setOnClickListener {
groupWithMembersListener.onClicked(itemView, model.groupWithMembers, false)
}
(itemView as ConversationListItem).bindGroupWithMembers(
lifecycleOwner,
model.groupWithMembers,
requestManager,
Locale.getDefault()
)
}
}
private class ChatFilterViewHolder<T : BaseChatFilterMappingModel<T>>(itemView: View, listener: () -> Unit) : MappingViewHolder<T>(itemView) {
private val tip = itemView.findViewById<View>(R.id.clear_filter_tip)
init {
itemView.findViewById<View>(R.id.clear_filter).setOnClickListener { listener() }
}
override fun bind(model: T) {
tip.visible = model.options == ChatFilterOptions.WITH_TIP
}
}
}
@@ -75,6 +75,10 @@ public class SignalIdentityKeyStore implements IdentityKeyStore {
return baseStore.getIdentityRecord(recipientId);
}
public @NonNull Optional<IdentityRecord> getIdentityRecord(@NonNull Recipient recipient) {
return baseStore.getIdentityRecord(recipient);
}
public @NonNull IdentityRecordList getIdentityRecords(@NonNull List<Recipient> recipients) {
return baseStore.getIdentityRecords(recipients);
}
@@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.database.SessionTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.whispersystems.signalservice.api.SignalServiceSessionStore;
import org.whispersystems.signalservice.api.SignalSessionLock;
import org.signal.core.models.ServiceId;
@@ -76,7 +77,7 @@ public class TextSecureSessionStore implements SignalServiceSessionStore {
try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
SessionRecord sessionRecord = SignalDatabase.sessions().load(accountId, address);
return sessionRecord != null && sessionRecord.hasSenderChain(0.0);
return sessionRecord != null && sessionRecord.hasSenderChain(RemoteConfig.requirePqRatio());
}
}
@@ -188,6 +189,6 @@ public class TextSecureSessionStore implements SignalServiceSessionStore {
}
private static boolean isActive(@Nullable SessionRecord record) {
return record != null && record.hasSenderChain(0.0);
return record != null && record.hasSenderChain(RemoteConfig.requirePqRatio());
}
}
@@ -66,6 +66,7 @@ import org.signal.core.util.toInt
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.signal.glide.decryptableuri.DecryptableUri
import org.signal.network.api.AttachmentUploadResult
import org.thoughtcrime.securesms.attachments.ArchivedAttachment
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.AttachmentId
@@ -107,7 +108,6 @@ import org.thoughtcrime.securesms.util.ImageCompressionUtil
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.video.EncryptedMediaDataSource
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import java.io.ByteArrayInputStream
@@ -3557,38 +3557,41 @@ class AttachmentTable(
)
.readToSingleLong(0)
val archiveStatusMediaNameCounts: Map<ArchiveTransferState, Long> = ArchiveTransferState.entries.associateWith { state ->
val archiveStatusMediaNameCounts: Map<ArchiveTransferState, Long> = ArchiveTransferState.entries.associateWith { 0L }.toMutableMap().apply {
readableDatabase.query(
"""
SELECT COUNT(*) FROM (
SELECT DISTINCT $DATA_HASH_END, $REMOTE_KEY
SELECT $ARCHIVE_TRANSFER_STATE, COUNT(*) FROM (
SELECT DISTINCT $DATA_HASH_END, $REMOTE_KEY, $ARCHIVE_TRANSFER_STATE
FROM $TABLE_NAME LEFT JOIN ${MessageTable.TABLE_NAME} ON $TABLE_NAME.$MESSAGE_ID = ${MessageTable.TABLE_NAME}.${MessageTable.ID}
WHERE ${buildAttachmentsThatCanArchiveQuery(archiveTransferStateFilter = "$ARCHIVE_TRANSFER_STATE = ${state.value}")}
)
WHERE ${buildAttachmentsThatCanArchiveQuery(archiveTransferStateFilter = "1=1")}
) GROUP BY $ARCHIVE_TRANSFER_STATE
"""
)
.readToSingleLong(0)
).forEach { cursor ->
this[ArchiveTransferState.deserialize(cursor.getInt(0))] = cursor.getLong(1)
}
}
val uniqueEligibleMediaNamesWithThumbnailsCount =
readableDatabase.query("SELECT COUNT(*) FROM (SELECT DISTINCT $DATA_HASH_END, $REMOTE_KEY FROM $TABLE_NAME WHERE $DATA_HASH_END NOT NULL AND $REMOTE_KEY NOT NULL AND $THUMBNAIL_FILE NOT NULL AND $QUOTE = 0 AND $MESSAGE_ID != $WALLPAPER_MESSAGE_ID)")
.readToSingleLong(-1L)
val archiveStatusMediaNameThumbnailCounts: Map<ArchiveTransferState, Long> = ArchiveTransferState.entries.associateWith { state ->
val archiveStatusMediaNameThumbnailCounts: Map<ArchiveTransferState, Long> = ArchiveTransferState.entries.associateWith { 0L }.toMutableMap().apply {
readableDatabase.query(
"""
SELECT COUNT(*) FROM (
SELECT DISTINCT $DATA_HASH_END, $REMOTE_KEY
SELECT $ARCHIVE_THUMBNAIL_TRANSFER_STATE, COUNT(*) FROM (
SELECT DISTINCT $DATA_HASH_END, $REMOTE_KEY, $ARCHIVE_THUMBNAIL_TRANSFER_STATE
FROM $TABLE_NAME LEFT JOIN ${MessageTable.TABLE_NAME} ON $TABLE_NAME.$MESSAGE_ID = ${MessageTable.TABLE_NAME}.${MessageTable.ID}
WHERE
${buildAttachmentsThatCanArchiveQuery("$ARCHIVE_THUMBNAIL_TRANSFER_STATE = ${state.value}")} AND
WHERE
${buildAttachmentsThatCanArchiveQuery("1=1")} AND
$QUOTE = 0 AND
($CONTENT_TYPE LIKE 'image/%' OR $CONTENT_TYPE LIKE 'video/%') AND
$CONTENT_TYPE != 'image/svg+xml' AND
$MESSAGE_ID != $WALLPAPER_MESSAGE_ID
)
) GROUP BY $ARCHIVE_THUMBNAIL_TRANSFER_STATE
"""
)
.readToSingleLong(0)
).forEach { cursor ->
this[ArchiveTransferState.deserialize(cursor.getInt(0))] = cursor.getLong(1)
}
}
val pendingAttachmentUploadBytes = getPendingArchiveUploadBytes()
@@ -3599,9 +3602,9 @@ class AttachmentTable(
FROM (
SELECT DISTINCT $DATA_HASH_END, $REMOTE_KEY, $DATA_SIZE
FROM $TABLE_NAME
WHERE
$DATA_FILE NOT NULL AND
$DATA_HASH_END NOT NULL AND
WHERE
$DATA_FILE NOT NULL AND
$DATA_HASH_END NOT NULL AND
$REMOTE_KEY NOT NULL AND
$ARCHIVE_TRANSFER_STATE = ${ArchiveTransferState.FINISHED.value}
)
@@ -43,6 +43,7 @@ import org.signal.core.util.toInt
import org.signal.core.util.update
import org.signal.core.util.updateAll
import org.signal.core.util.withinTransaction
import org.signal.libsignal.net.KeyTransparency
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.InvalidKeyException
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
@@ -67,6 +68,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase.Companion.runPostSucce
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.sessions
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.threads
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.KeyTransparencyStore
import org.thoughtcrime.securesms.database.model.RecipientRecord
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList
@@ -77,6 +79,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchove
import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.KeyTransparencyApi
import org.thoughtcrime.securesms.groups.BadGroupIdException
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupId.V1
@@ -117,6 +120,7 @@ import java.util.LinkedList
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
import kotlin.math.max
import kotlin.time.Duration
open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper) {
@@ -759,6 +763,27 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
return (foundRecords + remappedRecords).associateBy { it.id }
}
/**
* Returns recipient records eligible for a profile fetch.
* - Must have a service id (ACI or PNI)
* - Last profile fetch must be before [debounceThreshold] if non-null
*/
fun getRecordsForProfileFetch(ids: Collection<RecipientId>, debounceThreshold: Duration?): List<RecipientRecord> {
if (ids.isEmpty()) {
return emptyList()
}
val prefix = "($ACI_COLUMN NOT NULL OR $PNI_COLUMN NOT NULL) AND ${if (debounceThreshold != null) " ($LAST_PROFILE_FETCH < ${debounceThreshold.inWholeMilliseconds}) AND " else ""}"
val idQuery = SqlUtil.buildFastCollectionQuery(ID, ids, prefix)
return readableDatabase
.select()
.from(TABLE_NAME)
.where(idQuery.where, idQuery.whereArgs)
.run()
.readToList { cursor -> RecipientTableCursorUtil.getRecord(context, cursor) }
}
fun getRecord(id: RecipientId): RecipientRecord {
val query = "$ID = ?"
val args = arrayOf(id.serialize())
@@ -2330,6 +2355,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
.values(NEEDS_PNI_SIGNATURE to 0)
.run()
Log.i(TAG, "Resetting KT data due to change number.")
KeyTransparencyApi.reset(aci = SignalStore.account.requireAci().libSignalAci, field = KeyTransparency.AccountDataField.E164, keyTransparencyStore = KeyTransparencyStore)
SignalDatabase.pendingPniSignatureMessages.deleteAll()
db.setTransactionSuccessful()
@@ -2363,6 +2391,11 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
rotateStorageId(id)
StorageSyncHelper.scheduleSyncForDataChange()
}
if (id == Recipient.self().id) {
Log.i(TAG, "Resetting KT data due to username change.")
KeyTransparencyApi.reset(aci = SignalStore.account.requireAci().libSignalAci, field = KeyTransparency.AccountDataField.USERNAME_HASH, keyTransparencyStore = KeyTransparencyStore)
}
}
}
@@ -2478,13 +2511,34 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
fun markUnregistered(id: RecipientId) {
val record = getRecord(id)
if (record.aci != null && record.pni != null) {
val needsSplit = record.aci != null && record.pni != null
if (record.registered == RegisteredState.NOT_REGISTERED && !needsSplit) {
return
}
if (needsSplit) {
markUnregisteredAndSplit(id, record)
} else {
markUnregisteredWithoutSplit(id)
}
}
fun markUnregistered(ids: Collection<RecipientId>) {
if (ids.isEmpty()) {
return
}
ids
.chunked(100)
.forEach { chunk ->
writableDatabase.withinTransaction {
for (id in chunk) {
markUnregistered(id)
}
}
}
}
/**
* Marks the user unregistered and also splits it into an ACI-only and PNI-only contact.
* This is to allow a new user to register the number with a new ACI.
@@ -3762,13 +3816,23 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
}
return Recipient.resolvedList(recipientsWithinInteractionThreshold)
.asSequence()
.filterNot { it.isSelf }
.filter { it.lastProfileFetchTime < lastProfileFetchThreshold }
.take(limit)
.map { it.id }
.toMutableList()
if (Recipient.isSelfSet) {
recipientsWithinInteractionThreshold.remove(Recipient.self().id)
}
if (recipientsWithinInteractionThreshold.isEmpty()) {
return emptyList()
}
val select = SqlUtil.buildFastCollectionQuery(ID, recipientsWithinInteractionThreshold, "$LAST_PROFILE_FETCH < $lastProfileFetchThreshold AND")
return readableDatabase
.select(ID)
.from(TABLE_NAME)
.where(select.where, select.whereArgs)
.limit(limit)
.run()
.readToList { RecipientId.from(it.requireLong(ID)) }
}
fun markProfilesFetched(ids: Collection<RecipientId>, time: Long) {
@@ -3779,11 +3843,6 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
db.update(TABLE_NAME, values, query.where, query.whereArgs)
}
}
// Invalidate recipient cache so that updated timestamps are reflected
ids.forEach { id ->
AppDependencies.databaseObserver.notifyRecipientChanged(id)
}
}
fun applyBlockedUpdate(blockedE164s: List<String>, blockedAcis: List<ACI>, blockedGroupIds: List<ByteArray?>) {
@@ -4017,6 +4076,11 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
fun rotateStorageId(recipientId: RecipientId, logFailure: Boolean = false) {
val selfId = Recipient.self().id
if (recipientId != selfId && recipientId == SignalStore.releaseChannel.releaseChannelRecipientId) {
// Release channel info is stored on the account record (self)
rotateStorageId(selfId)
}
val values = ContentValues(1).apply {
put(STORAGE_SERVICE_ID, Base64.encodeWithPadding(StorageSyncHelper.generateKey()))
}
@@ -4099,6 +4163,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
fun clearAllKeyTransparencyData() {
Log.i(TAG, "Clearing all key transparency data.")
writableDatabase
.update(TABLE_NAME)
.values(KEY_TRANSPARENCY_DATA to null)
@@ -4107,6 +4172,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
}
fun clearSelfKeyTransparencyData() {
Log.i(TAG, "Clearing self key transparency data.")
writableDatabase
.update(TABLE_NAME)
.values(KEY_TRANSPARENCY_DATA to null)
@@ -145,7 +145,6 @@ object RecipientTableCursorUtil {
signalProfileAvatar = cursor.requireString(RecipientTable.PROFILE_AVATAR),
profileAvatarFileDetails = AvatarHelper.getAvatarFileDetails(context, recipientId),
profileSharing = cursor.requireBoolean(RecipientTable.PROFILE_SHARING),
lastProfileFetch = cursor.requireLong(RecipientTable.LAST_PROFILE_FETCH),
notificationChannel = cursor.requireString(RecipientTable.NOTIFICATION_CHANNEL),
sealedSenderAccessMode = RecipientTable.SealedSenderAccessMode.fromMode(cursor.requireInt(RecipientTable.SEALED_SENDER_MODE)),
capabilities = readCapabilities(cursor),
@@ -1622,6 +1622,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
Log.w(TAG, "Failed to parse serviceId!")
null
}
} else if (pinned.releaseNotes != null) {
SignalStore.releaseChannel.releaseChannelRecipientId?.let { Recipient.resolved(it) }
} else if (pinned.legacyGroupId != null) {
try {
Recipient.externalGroupExact(GroupId.v1(pinned.legacyGroupId!!.toByteArray()))
@@ -1657,6 +1659,10 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
notifyConversationListListeners()
}
fun applyStorageSyncReleaseChannelUpdate(recipientId: RecipientId, archived: Boolean, forcedUnread: Boolean) {
applyStorageSyncUpdate(recipientId, archived, forcedUnread, isGroup = false)
}
private fun applyStorageSyncUpdate(recipientId: RecipientId, archived: Boolean, forcedUnread: Boolean, isGroup: Boolean) {
val values = ContentValues()
values.put(ARCHIVED, if (archived) 1 else 0)
@@ -2145,7 +2151,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
private fun createQuery(where: String, offset: Long, limit: Long, preferPinned: Boolean): String {
val orderBy = if (preferPinned) {
"$TABLE_NAME.$PINNED_ORDER DESC, $TABLE_NAME.$DATE DESC"
"CASE WHEN $TABLE_NAME.$PINNED_ORDER IS NULL THEN 1 ELSE 0 END, $TABLE_NAME.$PINNED_ORDER ASC, $TABLE_NAME.$DATE DESC"
} else {
"$TABLE_NAME.$DATE DESC"
}
@@ -59,7 +59,6 @@ data class RecipientRecord(
val profileAvatarFileDetails: ProfileAvatarFileDetails,
@get:JvmName("isProfileSharing")
val profileSharing: Boolean,
val lastProfileFetch: Long,
val notificationChannel: String?,
val sealedSenderAccessMode: SealedSenderAccessMode,
val capabilities: Capabilities,
@@ -17,6 +17,7 @@ import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
import org.signal.mediasend.MediaSendDependencies
import org.signal.network.api.ArchiveApi
import org.signal.network.api.AttachmentApi
import org.signal.network.api.CallingApi
import org.signal.network.api.CdsApi
import org.signal.network.api.CertificateApi
@@ -27,6 +28,7 @@ import org.signal.network.api.RateLimitChallengeApi
import org.signal.network.api.RemoteConfigApi
import org.signal.network.api.SvrBApi
import org.signal.network.api.UsernameApi
import org.signal.network.rest.SignalRestClient
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.components.TypingStatusRepository
import org.thoughtcrime.securesms.components.TypingStatusSender
@@ -63,7 +65,6 @@ import org.whispersystems.signalservice.api.SignalServiceDataStore
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.account.AccountApi
import org.whispersystems.signalservice.api.attachment.AttachmentApi
import org.whispersystems.signalservice.api.donations.DonationsApi
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations
import org.whispersystems.signalservice.api.keys.KeysApi
@@ -131,7 +132,7 @@ object AppDependencies {
@JvmStatic
val jobManager: JobManager by lazy {
provider.provideJobManager()
provider.provideJobManager(provider.provideJobManagerConfigurationBuilder())
}
@JvmStatic
@@ -348,6 +349,10 @@ object AppDependencies {
val pushServiceSocket: PushServiceSocket
get() = networkModule.pushServiceSocket
@JvmStatic
val signalRestClient: SignalRestClient
get() = networkModule.signalRestClient
@JvmStatic
val registrationApi: RegistrationApi
get() = networkModule.registrationApi
@@ -433,13 +438,15 @@ object AppDependencies {
interface Provider {
fun providePushServiceSocket(signalServiceConfiguration: SignalServiceConfiguration, groupsV2Operations: GroupsV2Operations): PushServiceSocket
fun provideSignalRestClient(signalServiceConfiguration: SignalServiceConfiguration): SignalRestClient
fun provideGroupsV2Operations(signalServiceConfiguration: SignalServiceConfiguration): GroupsV2Operations
fun provideSignalServiceAccountManager(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, accountApi: AccountApi, pushServiceSocket: PushServiceSocket, groupsV2Operations: GroupsV2Operations): SignalServiceAccountManager
fun provideSignalServiceMessageSender(protocolStore: SignalServiceDataStore, pushServiceSocket: PushServiceSocket, attachmentApi: AttachmentApi, messageApi: MessageApi, keysApi: KeysApi): SignalServiceMessageSender
fun provideSignalServiceMessageSender(protocolStore: SignalServiceDataStore, pushServiceSocket: PushServiceSocket, messageApi: MessageApi, keysApi: KeysApi): SignalServiceMessageSender
fun provideSignalServiceMessageReceiver(pushServiceSocket: PushServiceSocket): SignalServiceMessageReceiver
fun provideSignalServiceNetworkAccess(): SignalServiceNetworkAccess
fun provideRecipientCache(): LiveRecipientCache
fun provideJobManager(): JobManager
fun provideJobManager(configurationBuilder: JobManager.Configuration.Builder): JobManager
fun provideJobManagerConfigurationBuilder(): JobManager.Configuration.Builder
fun provideFrameRateTracker(): FrameRateTracker
fun provideMegaphoneRepository(): MegaphoneRepository
fun provideEarlyMessageCache(): EarlyMessageCache
@@ -471,7 +478,7 @@ object AppDependencies {
fun providePinnedMessageManager(): PinnedMessageManager
fun provideLibsignalNetwork(config: SignalServiceConfiguration): Network
fun provideBillingApi(): BillingApi
fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): ArchiveApi
fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket, signalServiceConfiguration: SignalServiceConfiguration): ArchiveApi
fun provideKeysApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): KeysApi
fun provideAttachmentApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, pushServiceSocket: PushServiceSocket): AttachmentApi
fun provideLinkDeviceApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): LinkDeviceApi
@@ -18,9 +18,12 @@ import org.signal.core.util.concurrent.DeadlockDetector;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.libsignal.net.Network;
import org.signal.libsignal.protocol.SignalProtocolAddress;
import org.signal.libsignal.zkgroup.GenericServerPublicParams;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
import org.signal.network.api.ArchiveApi;
import org.signal.network.rest.SignalRestClient;
import org.signal.network.api.CallingApi;
import org.signal.network.api.CdsApi;
import org.signal.network.api.CertificateApi;
@@ -104,7 +107,7 @@ import org.whispersystems.signalservice.api.SignalServiceDataStore;
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.account.AccountApi;
import org.whispersystems.signalservice.api.attachment.AttachmentApi;
import org.signal.network.api.AttachmentApi;
import org.whispersystems.signalservice.api.donations.DonationsApi;
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
@@ -154,6 +157,14 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
RemoteConfig.okHttpAutomaticRetry());
}
@Override
public @NonNull SignalRestClient provideSignalRestClient(@NonNull SignalServiceConfiguration signalServiceConfiguration) {
return new SignalRestClient(signalServiceConfiguration,
BuildConfig.SIGNAL_AGENT,
new DynamicCredentialsProvider(),
RemoteConfig.okHttpAutomaticRetry());
}
@Override
public @NonNull GroupsV2Operations provideGroupsV2Operations(@NonNull SignalServiceConfiguration signalServiceConfiguration) {
return new GroupsV2Operations(provideClientZkOperations(signalServiceConfiguration), RemoteConfig.groupLimits().getHardLimit());
@@ -167,13 +178,11 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
@Override
public @NonNull SignalServiceMessageSender provideSignalServiceMessageSender(@NonNull SignalServiceDataStore protocolStore,
@NonNull PushServiceSocket pushServiceSocket,
@NonNull AttachmentApi attachmentApi,
@NonNull MessageApi messageApi,
@NonNull KeysApi keysApi) {
return new SignalServiceMessageSender(pushServiceSocket,
protocolStore,
ReentrantSessionLock.INSTANCE,
attachmentApi,
messageApi,
keysApi,
Optional.of(new SecurityEventListener(context)),
@@ -181,8 +190,6 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
RemoteConfig.maxEnvelopeSizeBytes(),
RemoteConfig.maxIncrementalMacsPerEnvelope(),
RemoteConfig::useMessageSendRestFallback,
RemoteConfig.useBinaryId(),
BuildConfig.USE_STRING_ID,
new PreKeyRepository(
keysApi,
protocolStore.aci(),
@@ -209,25 +216,28 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
}
@Override
public @NonNull JobManager provideJobManager() {
JobManager.Configuration config = new JobManager.Configuration.Builder()
.setJobFactories(JobManagerFactories.getJobFactories(context))
.setConstraintFactories(JobManagerFactories.getConstraintFactories(context))
.setConstraintObservers(JobManagerFactories.getConstraintObservers(context))
.setJobStorage(new FastJobStorage(JobDatabase.getInstance(context)))
.setJobMigrator(new JobMigrator(TextSecurePreferences.getJobManagerVersion(context), JobManager.CURRENT_VERSION, JobManagerFactories.getJobMigrations(context)))
.addReservedJobRunner(new FactoryJobPredicate(PushProcessMessageJob.KEY, MarkerJob.KEY))
.addReservedJobRunner(new FactoryJobPredicate(AttachmentUploadJob.KEY, AttachmentCompressionJob.KEY))
.addReservedJobRunner(new FactoryJobPredicate(
IndividualSendJob.KEY,
PushGroupSendJob.KEY,
ReactionSendJob.KEY,
TypingSendJob.KEY,
GroupCallUpdateSendJob.KEY,
SendDeliveryReceiptJob.KEY
))
.build();
return new JobManager(context, config);
public @NonNull JobManager provideJobManager(@NonNull JobManager.Configuration.Builder configurationBuilder) {
return new JobManager(context, configurationBuilder.build());
}
@Override
public @NonNull JobManager.Configuration.Builder provideJobManagerConfigurationBuilder() {
return new JobManager.Configuration.Builder()
.setJobFactories(JobManagerFactories.getJobFactories(context))
.setConstraintFactories(JobManagerFactories.getConstraintFactories(context))
.setConstraintObservers(JobManagerFactories.getConstraintObservers(context))
.setJobStorage(new FastJobStorage(JobDatabase.getInstance(context)))
.setJobMigrator(new JobMigrator(TextSecurePreferences.getJobManagerVersion(context), JobManager.CURRENT_VERSION, JobManagerFactories.getJobMigrations(context)))
.addReservedJobRunner(new FactoryJobPredicate(PushProcessMessageJob.KEY, MarkerJob.KEY))
.addReservedJobRunner(new FactoryJobPredicate(AttachmentUploadJob.KEY, AttachmentCompressionJob.KEY))
.addReservedJobRunner(new FactoryJobPredicate(
IndividualSendJob.KEY,
PushGroupSendJob.KEY,
ReactionSendJob.KEY,
TypingSendJob.KEY,
GroupCallUpdateSendJob.KEY,
SendDeliveryReceiptJob.KEY
));
}
@Override
@@ -502,8 +512,12 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
}
@Override
public @NonNull ArchiveApi provideArchiveApi(@NonNull SignalWebSocket.AuthenticatedWebSocket authWebSocket, @NonNull SignalWebSocket.UnauthenticatedWebSocket unauthWebSocket, @NonNull PushServiceSocket pushServiceSocket) {
return new ArchiveApi(authWebSocket, unauthWebSocket, pushServiceSocket);
public @NonNull ArchiveApi provideArchiveApi(@NonNull SignalWebSocket.AuthenticatedWebSocket authWebSocket, @NonNull SignalWebSocket.UnauthenticatedWebSocket unauthWebSocket, @NonNull PushServiceSocket pushServiceSocket, @NonNull SignalServiceConfiguration signalServiceConfiguration) {
try {
return new ArchiveApi(authWebSocket, unauthWebSocket, pushServiceSocket, new GenericServerPublicParams(signalServiceConfiguration.getBackupServerPublicParams()));
} catch (InvalidInputException e) {
throw new RuntimeException(e);
}
}
@Override
@@ -1,6 +1,8 @@
package org.thoughtcrime.securesms.dependencies
import org.signal.core.util.logging.Log
import org.signal.libsignal.keytrans.KeyTransparencyException
import org.signal.libsignal.net.KeyTransparency
import org.signal.libsignal.net.KeyTransparency.CheckMode
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.protocol.IdentityKey
@@ -13,6 +15,18 @@ import org.whispersystems.signalservice.api.websocket.SignalWebSocket
*/
class KeyTransparencyApi(private val unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket) {
companion object {
val TAG = Log.tag(KeyTransparencyApi::class.java)
fun reset(aci: ServiceId.Aci, field: KeyTransparency.AccountDataField, keyTransparencyStore: KeyTransparencyStore) {
try {
KeyTransparency.resetField(aci, field, keyTransparencyStore)
} catch (e: IllegalArgumentException) {
Log.w(TAG, "Unexpected result when trying to reset KT", e)
}
}
}
suspend fun check(checkMode: CheckMode, aci: ServiceId.Aci, aciIdentityKey: IdentityKey, e164: String?, unidentifiedAccessKey: ByteArray?, usernameHash: ByteArray?, keyTransparencyStore: KeyTransparencyStore): RequestResult<Unit, KeyTransparencyException> {
return unauthWebSocket.runCatchingWithChatConnection { chatConnection ->
chatConnection.keyTransparencyClient().check(checkMode, aci, aciIdentityKey, e164, unidentifiedAccessKey, usernameHash, keyTransparencyStore)
@@ -17,6 +17,7 @@ import org.signal.core.util.resettableLazy
import org.signal.libsignal.net.Network
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
import org.signal.network.api.ArchiveApi
import org.signal.network.api.AttachmentApi
import org.signal.network.api.CallingApi
import org.signal.network.api.CdsApi
import org.signal.network.api.CertificateApi
@@ -27,6 +28,7 @@ import org.signal.network.api.RateLimitChallengeApi
import org.signal.network.api.RemoteConfigApi
import org.signal.network.api.SvrBApi
import org.signal.network.api.UsernameApi
import org.signal.network.rest.SignalRestClient
import org.thoughtcrime.securesms.crypto.storage.SignalServiceDataStoreImpl
import org.thoughtcrime.securesms.groups.GroupsV2Authorization
import org.thoughtcrime.securesms.groups.GroupsV2AuthorizationMemoryValueCache
@@ -40,7 +42,6 @@ import org.whispersystems.signalservice.api.SignalServiceAccountManager
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.account.AccountApi
import org.whispersystems.signalservice.api.attachment.AttachmentApi
import org.whispersystems.signalservice.api.donations.DonationsApi
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations
import org.whispersystems.signalservice.api.keys.KeysApi
@@ -90,7 +91,7 @@ class NetworkDependenciesModule(
val protocolStore: SignalServiceDataStoreImpl by _protocolStore
private val _signalServiceMessageSender = resettableLazy {
provider.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, attachmentApi, messageApi, keysApi)
provider.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, messageApi, keysApi)
}
val signalServiceMessageSender: SignalServiceMessageSender by _signalServiceMessageSender
@@ -102,6 +103,10 @@ class NetworkDependenciesModule(
provider.providePushServiceSocket(signalServiceNetworkAccess.getConfiguration(), groupsV2Operations)
}
val signalRestClient: SignalRestClient by lazy {
provider.provideSignalRestClient(signalServiceNetworkAccess.getConfiguration())
}
val signalServiceAccountManager: SignalServiceAccountManager by lazy {
provider.provideSignalServiceAccountManager(authWebSocket, accountApi, pushServiceSocket, groupsV2Operations)
}
@@ -150,7 +155,7 @@ class NetworkDependenciesModule(
}
val archiveApi: ArchiveApi by lazy {
provider.provideArchiveApi(authWebSocket, unauthWebSocket, pushServiceSocket)
provider.provideArchiveApi(authWebSocket, unauthWebSocket, pushServiceSocket, signalServiceNetworkAccess.getConfiguration())
}
val keysApi: KeysApi by lazy {
@@ -35,13 +35,16 @@ public class FcmReceiveService extends FirebaseMessagingService {
remoteMessage.getOriginalPriority(),
NetworkUtil.getNetworkStatus(this)));
String registrationChallenge = remoteMessage.getData().get("challenge");
String rateLimitChallenge = remoteMessage.getData().get("rateLimitChallenge");
String registrationChallenge = remoteMessage.getData().get("challenge");
String rateLimitChallenge = remoteMessage.getData().get("rateLimitChallenge");
String verificationCodeRequest = remoteMessage.getData().get("verificationCodeRequested");
if (registrationChallenge != null) {
handleRegistrationPushChallenge(registrationChallenge);
} else if (rateLimitChallenge != null) {
handleRateLimitPushChallenge(rateLimitChallenge);
} else if (verificationCodeRequest != null && SignalStore.account().isPrimaryDevice()) {
handleVerificationCodeRequested(verificationCodeRequest, remoteMessage.getSentTime());
} else {
handleReceivedNotification(AppDependencies.getApplication(), remoteMessage);
}
@@ -102,4 +105,20 @@ public class FcmReceiveService extends FirebaseMessagingService {
Log.d(TAG, "Got a rate limit push challenge.");
AppDependencies.getJobManager().add(new SubmitRateLimitPushChallengeJob(challenge));
}
private static void handleVerificationCodeRequested(String verificationCodeRequestJson, long sentTime) {
Log.i(TAG, "Got a verification code requested push.");
VerificationCodeRequestedPush verificationRequestedPush = VerificationCodeRequestedPush.fromJson(verificationCodeRequestJson);
long requestedAt;
if (verificationRequestedPush != null && verificationRequestedPush.getTimestamp() != null) {
requestedAt = verificationRequestedPush.getTimestamp();
} else {
Log.w(TAG, "Unable to parse requested at timestamp from server, using sent time instead");
requestedAt = sentTime;
}
SignalStore.account().setVerificationCodeRequestedAtMs(requestedAt);
}
}
@@ -0,0 +1,30 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.gcm
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.signal.core.util.logging.Log
@Serializable
data class VerificationCodeRequestedPush(val timestamp: Long?) {
companion object {
private val TAG = Log.tag(VerificationCodeRequestedPush::class)
private val json = Json { ignoreUnknownKeys = true }
@JvmStatic
fun fromJson(jsonString: String): VerificationCodeRequestedPush? {
return try {
json.decodeFromString(jsonString)
} catch (e: Throwable) {
Log.w(TAG, "Unable to parse verification code request", e)
null
}
}
}
}
@@ -9,6 +9,7 @@ import org.signal.core.util.Util
import org.signal.core.util.logging.Log
import org.signal.glide.decryptableuri.DecryptableUri
import org.signal.network.NetworkResult
import org.signal.network.api.AttachmentUploadResult
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.AttachmentUploadUtil
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
@@ -30,7 +31,6 @@ import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.util.ImageCompressionUtil
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream
@@ -38,6 +38,8 @@ import org.thoughtcrime.securesms.notifications.v2.ConversationId.Companion.forC
import org.thoughtcrime.securesms.s3.S3
import org.thoughtcrime.securesms.transport.RetryLaterException
import org.thoughtcrime.securesms.util.AttachmentUtil
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.MessageUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream.IntegrityCheck
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
@@ -301,6 +303,10 @@ class AttachmentDownloadJob private constructor(
throw MmsException("[$attachmentId] Attachment too large, failing download")
}
if (MediaUtil.isLongTextType(attachment.contentType) && attachment.size > MessageUtil.MAX_TOTAL_BODY_SIZE_BYTES) {
throw InvalidAttachmentException("[$attachmentId] Long-text attachment exceeds ${MessageUtil.MAX_TOTAL_BODY_SIZE_BYTES} byte cap, declared size: ${attachment.size}")
}
val pointer = createAttachmentPointer(attachment)
val progressListener = object : SignalServiceAttachment.ProgressListener {
@@ -16,6 +16,7 @@ import org.signal.core.util.readLength
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.net.RetryLaterException
import org.signal.libsignal.net.UploadTooLargeException
import org.signal.network.api.AttachmentUploadResult
import org.signal.protos.resumableuploads.ResumableUpload
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.Attachment
@@ -41,7 +42,6 @@ import org.thoughtcrime.securesms.transport.UndeliverableMessageException
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.MessageUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
@@ -16,31 +16,19 @@
*/
package org.thoughtcrime.securesms.jobs;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.graphics.BitmapFactory;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability;
import org.signal.core.util.PendingIntentFlags;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.PlayServicesProblemActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.gcm.FcmUtil;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.net.SignalNetwork;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.notifications.NotificationIds;
import org.thoughtcrime.securesms.transport.RetryLaterException;
import org.whispersystems.signalservice.api.NetworkResultUtil;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
@@ -88,25 +76,26 @@ public class FcmRefreshJob extends BaseJob {
int result = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context);
if (result != ConnectionResult.SUCCESS) {
notifyFcmFailure();
} else {
Optional<String> token = FcmUtil.getToken(context);
Log.w(TAG, "Play Services are unavailable. Skipping FCM refresh.");
return;
}
if (token.isPresent()) {
String oldToken = SignalStore.account().getFcmToken();
Optional<String> token = FcmUtil.getToken(context);
if (!token.get().equals(oldToken)) {
int oldLength = oldToken != null ? oldToken.length() : -1;
Log.i(TAG, "Token changed. oldLength: " + oldLength + " newLength: " + token.get().length());
} else {
Log.i(TAG, "Token didn't change.");
}
if (token.isPresent()) {
String oldToken = SignalStore.account().getFcmToken();
NetworkResultUtil.toBasicLegacy(SignalNetwork.account().setFcmToken(token.get()));
SignalStore.account().setFcmToken(token.get());
if (!token.get().equals(oldToken)) {
int oldLength = oldToken != null ? oldToken.length() : -1;
Log.i(TAG, "Token changed. oldLength: " + oldLength + " newLength: " + token.get().length());
} else {
throw new RetryLaterException(new IOException("Failed to retrieve a token."));
Log.i(TAG, "Token didn't change.");
}
NetworkResultUtil.toBasicLegacy(SignalNetwork.account().setFcmToken(token.get()));
SignalStore.account().setFcmToken(token.get());
} else {
throw new RetryLaterException(new IOException("Failed to retrieve a token."));
}
}
@@ -121,24 +110,6 @@ public class FcmRefreshJob extends BaseJob {
return true;
}
private void notifyFcmFailure() {
Intent intent = new Intent(context, PlayServicesProblemActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(context, 1122, intent, PendingIntentFlags.cancelCurrent());
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NotificationChannels.getInstance().FAILURES);
builder.setSmallIcon(R.drawable.ic_notification);
builder.setLargeIcon(BitmapFactory.decodeResource(context.getResources(),
R.drawable.symbol_error_triangle_fill_32));
builder.setContentTitle(context.getString(R.string.GcmRefreshJob_Permanent_Signal_communication_failure));
builder.setContentText(context.getString(R.string.GcmRefreshJob_Signal_was_unable_to_register_with_Google_Play_Services));
builder.setTicker(context.getString(R.string.GcmRefreshJob_Permanent_Signal_communication_failure));
builder.setVibrate(new long[] {0, 1000});
builder.setContentIntent(pendingIntent);
((NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE))
.notify(NotificationIds.FCM_FAILURE, builder.build());
}
public static final class Factory implements Job.Factory<FcmRefreshJob> {
@Override
public @NonNull FcmRefreshJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) {
@@ -409,6 +409,10 @@ class IndividualSendJob private constructor(parameters: Parameters, private val
SignalLocalMetrics.IndividualMessageSend.onMessageSent(messageId)
}
override fun onSyncMessageEncrypted() {
SignalLocalMetrics.IndividualMessageSend.onSyncMessageEncrypted(messageId)
}
override fun onSyncMessageSent() {
SignalLocalMetrics.IndividualMessageSend.onSyncMessageSent(messageId)
}
@@ -13,12 +13,12 @@ import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.conversation.colors.ChatColorsMapper;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.signal.network.service.CdnService;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
@@ -32,7 +32,6 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
@@ -150,7 +149,7 @@ public class MultiDeviceContactUpdateJob extends BaseJob {
Uri updateUri = null;
try {
DeviceContactsOutputStream out = new DeviceContactsOutputStream(writeDetails.outputStream, RemoteConfig.useBinaryId(), BuildConfig.USE_STRING_ID);
DeviceContactsOutputStream out = new DeviceContactsOutputStream(writeDetails.outputStream);
Recipient recipient = Recipient.resolved(recipientId);
if (recipient.getRegistered() == RecipientTable.RegisteredState.NOT_REGISTERED) {
@@ -215,7 +214,7 @@ public class MultiDeviceContactUpdateJob extends BaseJob {
Uri updateUri = null;
try {
DeviceContactsOutputStream out = new DeviceContactsOutputStream(writeDetails.outputStream, RemoteConfig.useBinaryId(), BuildConfig.USE_STRING_ID);
DeviceContactsOutputStream out = new DeviceContactsOutputStream(writeDetails.outputStream);
List<Recipient> recipients = SignalDatabase.recipients().getRecipientsForMultiDeviceSync();
Map<RecipientId, Integer> inboxPositions = SignalDatabase.threads().getInboxPositions();
Set<RecipientId> archived = SignalDatabase.threads().getArchivedRecipients();
@@ -286,11 +285,12 @@ public class MultiDeviceContactUpdateJob extends BaseJob {
{
if (length > 0) {
try {
CdnService cdnService = new CdnService(AppDependencies.getSignalRestClient(), AppDependencies.getAttachmentApi());
SignalServiceAttachmentStream.Builder attachmentStream = SignalServiceAttachment.newStreamBuilder()
.withStream(stream)
.withContentType("application/octet-stream")
.withLength(length)
.withResumableUploadSpec(messageSender.getResumableUploadSpec(AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(length))));
.withResumableUploadSpec(cdnService.getResumableUploadSpecBlocking(AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(length))));
messageSender.sendSyncMessage(SignalServiceSyncMessage.forContacts(new ContactsMessage(attachmentStream.build(), complete))
);
@@ -13,7 +13,6 @@ import org.signal.core.util.Base64
import org.signal.core.util.UuidUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.ThreadTable
@@ -378,16 +377,8 @@ class MultiDeviceDeleteSyncJob private constructor(
private fun Recipient.toDeleteSyncConversationId(): ConversationIdentifier? {
return when {
isGroup -> ConversationIdentifier(threadGroupId = requireGroupId().decodedId.toByteString())
hasAci -> if (BuildConfig.USE_STRING_ID) {
ConversationIdentifier(threadServiceId = requireAci().toString())
} else {
ConversationIdentifier(threadServiceIdBinary = requireAci().toByteString())
}
hasPni -> if (BuildConfig.USE_STRING_ID) {
ConversationIdentifier(threadServiceId = requirePni().toString())
} else {
ConversationIdentifier(threadServiceIdBinary = requirePni().toByteString())
}
hasAci -> ConversationIdentifier(threadServiceIdBinary = requireAci().toByteString())
hasPni -> ConversationIdentifier(threadServiceIdBinary = requirePni().toByteString())
hasE164 -> ConversationIdentifier(threadE164 = requireE164())
else -> null
}
@@ -395,11 +386,7 @@ class MultiDeviceDeleteSyncJob private constructor(
private fun DeleteSyncJobData.AddressableMessage.toDeleteSyncMessage(): AddressableMessage? {
val author: Recipient = Recipient.resolved(RecipientId.from(authorRecipientId))
val authorServiceId = if (BuildConfig.USE_STRING_ID) {
author.aci.orNull()?.toString() ?: author.pni.orNull()?.toString()
} else {
author.aci.orNull()?.toByteString() ?: author.pni.orNull()?.toByteString()
}
val authorServiceId: ByteString? = author.aci.orNull()?.toByteString() ?: author.pni.orNull()?.toByteString()
val authorE164: String? = if (authorServiceId == null) {
author.e164.orNull()
@@ -411,19 +398,11 @@ class MultiDeviceDeleteSyncJob private constructor(
Log.w(TAG, "Unable to send sync message without serviceId or e164 recipient: ${author.id}")
null
} else {
if (BuildConfig.USE_STRING_ID) {
AddressableMessage(
authorServiceId = authorServiceId as String?,
authorE164 = authorE164,
sentTimestamp = sentTimestamp
)
} else {
AddressableMessage(
authorServiceIdBinary = authorServiceId as ByteString?,
authorE164 = authorE164,
sentTimestamp = sentTimestamp
)
}
AddressableMessage(
authorServiceIdBinary = authorServiceId,
authorE164 = authorE164,
sentTimestamp = sentTimestamp
)
}
}
@@ -6,7 +6,7 @@ import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.thoughtcrime.securesms.BuildConfig;
import org.signal.network.service.CdnService;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobmanager.Job;
@@ -15,7 +15,6 @@ import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.net.NotPushRegisteredException;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
@@ -78,7 +77,7 @@ public class MultiDeviceProfileKeyUpdateJob extends BaseJob {
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DeviceContactsOutputStream out = new DeviceContactsOutputStream(baos, RemoteConfig.useBinaryId(), BuildConfig.USE_STRING_ID);
DeviceContactsOutputStream out = new DeviceContactsOutputStream(baos);
out.write(new DeviceContact(Optional.ofNullable(SignalStore.account().getAci()),
Optional.ofNullable(SignalStore.account().getE164()),
@@ -93,7 +92,8 @@ public class MultiDeviceProfileKeyUpdateJob extends BaseJob {
SignalServiceMessageSender messageSender = AppDependencies.getSignalServiceMessageSender();
long dataLength = baos.toByteArray().length;
long ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(dataLength));
ResumableUploadSpec uploadSpec = messageSender.getResumableUploadSpec(ciphertextLength);
CdnService cdnService = new CdnService(AppDependencies.getSignalRestClient(), AppDependencies.getAttachmentApi());
ResumableUploadSpec uploadSpec = cdnService.getResumableUploadSpecBlocking(ciphertextLength);
SignalServiceAttachmentStream attachmentStream = SignalServiceAttachment.newStreamBuilder()
.withStream(new ByteArrayInputStream(baos.toByteArray()))
.withContentType("application/octet-stream")
@@ -125,6 +125,11 @@ class PreKeysSyncJob private constructor(
return
}
val pniRotationOverride = SignalStore.misc.forcePniSignedPreKeyRotation
if (pniRotationOverride) {
warn(TAG, ServiceIdType.PNI, "Forced PNI prekey rotation pending after PniChangeNumber sync. Bypassing dedup/interval gating for PNI.")
}
val forceRotation = if (forceRotationRequested) {
warn(TAG, "Forced rotation was requested.")
warn(TAG, ServiceIdType.ACI, "Active Signed EC: ${SignalStore.account.aciPreKeys.activeSignedPreKeyId}, Last Resort Kyber: ${SignalStore.account.aciPreKeys.lastResortKyberPreKeyId}")
@@ -146,19 +151,26 @@ class PreKeysSyncJob private constructor(
false
}
if (forceRotation) {
warn(TAG, "Forcing prekey rotation.")
val forcePniRotation = forceRotation || pniRotationOverride
if (forcePniRotation) {
warn(TAG, "Forcing prekey rotation. ACI=$forceRotation PNI=$forcePniRotation")
} else if (forceRotationRequested) {
warn(TAG, "Forced prekey rotation was requested, but we already did a forced refresh ${System.currentTimeMillis() - SignalStore.misc.lastForcedPreKeyRefresh} ms ago. Ignoring.")
}
syncPreKeys(ServiceIdType.ACI, SignalStore.account.aci, AppDependencies.protocolStore.aci(), SignalStore.account.aciPreKeys, forceRotation)
syncPreKeys(ServiceIdType.PNI, SignalStore.account.pni, AppDependencies.protocolStore.pni(), SignalStore.account.pniPreKeys, forceRotation)
syncPreKeys(ServiceIdType.PNI, SignalStore.account.pni, AppDependencies.protocolStore.pni(), SignalStore.account.pniPreKeys, forcePniRotation)
SignalStore.misc.lastFullPrekeyRefreshTime = System.currentTimeMillis()
if (forceRotation) {
if (forcePniRotation) {
SignalStore.misc.lastForcedPreKeyRefresh = System.currentTimeMillis()
}
if (pniRotationOverride) {
// Cleared only after both syncPreKeys calls completed without throwing; a thrown upload leaves the flag set for the next attempt.
SignalStore.misc.forcePniSignedPreKeyRotation = false
}
}
private fun syncPreKeys(serviceIdType: ServiceIdType, serviceId: ServiceId?, protocolStore: SignalServiceAccountDataStore, metadataStore: PreKeyMetadataStore, forceRotation: Boolean) {
@@ -16,6 +16,7 @@ import org.signal.core.util.Util
import org.signal.core.util.logging.Log
import org.signal.libsignal.zkgroup.InvalidInputException
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
import org.signal.network.service.CdnService
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.TextSecureExpiredException
import org.thoughtcrime.securesms.attachments.Attachment
@@ -233,7 +234,7 @@ abstract class PushSendJob protected constructor(parameters: Parameters) : BaseJ
val inputStream = PartAuthority.getAttachmentStream(context, attachment.uri!!)
val ciphertextLength = getCiphertextLength(PaddingInputStream.getPaddedSize(attachment.size))
val uploadSpec = AppDependencies.signalServiceMessageSender.getResumableUploadSpec(ciphertextLength)
val uploadSpec = CdnService(AppDependencies.signalRestClient, AppDependencies.attachmentApi).getResumableUploadSpecBlocking(ciphertextLength)
return SignalServiceAttachment.newStreamBuilder()
.withStream(inputStream)
@@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.database.RecipientTable.Companion.maskCapabili
import org.thoughtcrime.securesms.database.RecipientTable.PhoneNumberSharingState
import org.thoughtcrime.securesms.database.RecipientTable.SealedSenderAccessMode
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.database.model.RecipientRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
@@ -32,6 +33,7 @@ import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.notifications.v2.ConversationId.Companion.forConversation
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientCreator
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientUtil
import org.thoughtcrime.securesms.storage.StorageSyncHelper
@@ -49,6 +51,7 @@ import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import org.whispersystems.signalservice.api.util.ExpiringProfileCredentialUtil
import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
/**
@@ -90,32 +93,25 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val
val stopwatch = Stopwatch("RetrieveProfile")
val recipients = recipientIds.map { Recipient.live(it).refresh().resolve() }
val debounceThreshold = if (skipDebounce) null else System.currentTimeMillis().milliseconds - PROFILE_FETCH_DEBOUNCE_TIME
val recipientsToFetch = SignalDatabase
.recipients
.getRecordsForProfileFetch(recipientIds, debounceThreshold)
.map { RecipientCreator.forRecord(context, it) }
RecipientUtil.ensureUuidsAreAvailable(
context,
recipients.filter { it.registered != RecipientTable.RegisteredState.NOT_REGISTERED }
)
stopwatch.split("resolve-ensure")
val currentTime = System.currentTimeMillis()
val debounceThreshold = currentTime - PROFILE_FETCH_DEBOUNCE_TIME_MS
val recipientsToFetch = recipients.filter { recipient ->
recipient.hasServiceId && (skipDebounce || recipient.lastProfileFetchTime < debounceThreshold)
}
stopwatch.split("resolve")
if (recipientsToFetch.isEmpty()) {
Log.i(TAG, "All ${recipients.size} recipients have been fetched recently (within ${PROFILE_FETCH_DEBOUNCE_TIME_MS}ms). Skipping network requests.")
Log.i(TAG, "All ${recipientIds.size} recipients have been fetched recently (within $PROFILE_FETCH_DEBOUNCE_TIME) or are not eligible. Skipping network requests.")
return
}
if (recipientsToFetch.size < recipients.size) {
Log.i(TAG, "Debouncing: Fetching ${recipientsToFetch.size} of ${recipients.size} recipients (${recipients.size - recipientsToFetch.size} were fetched recently)")
if (recipientsToFetch.size < recipientIds.size) {
Log.i(TAG, "Fetching ${recipientsToFetch.size} of ${recipientIds.size} recipients (${recipientIds.size - recipientsToFetch.size} were ineligible or fetched recently)")
}
val fetchingRecipientIds = recipientsToFetch.map { it.id }.toSet()
val recipientsById: Map<RecipientId, Recipient> = recipients.associateBy { it.id }
val recipientsById: Map<RecipientId, Recipient> = recipientsToFetch.associateBy { it.id }
val requests: List<ProfileFetchRequest<RecipientId>> = recipientsToFetch
.map { recipient ->
@@ -176,12 +172,6 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val
}
}
}
if (updatedProfiles.isNotEmpty()) {
StorageSyncHelper.scheduleSyncForDataChange()
}
if (avatarJobs.isNotEmpty()) {
AppDependencies.jobManager.addAll(avatarJobs)
}
stopwatch.split("process")
SignalDatabase.recipients.markProfilesFetched(successIds, System.currentTimeMillis())
@@ -193,9 +183,7 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val
}
if (response.unregistered.isNotEmpty()) {
Log.i(TAG, "Marking ${response.unregistered.size} users as unregistered.")
for (recipientId in response.unregistered) {
SignalDatabase.recipients.markUnregistered(recipientId)
}
SignalDatabase.recipients.markUnregistered(response.unregistered)
}
stopwatch.split("registered-update")
@@ -214,9 +202,17 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val
val keyCount = response.successes.mapNotNull { recipientsById[it.id] }.mapNotNull { it.profileKey }.count()
Log.d(TAG, "Started with ${recipients.size} recipient(s). Of those, ${recipientsToFetch.size} were outside the cache period. Found ${response.successes.size} profile(s), and had keys for $keyCount of them. Will retry ${response.retryableFailures.size}.")
Log.d(TAG, "Started with ${recipientIds.size} recipient(s). Of those, ${recipientsToFetch.size} were outside the cache period. Found ${response.successes.size} profile(s), and had keys for $keyCount of them. Will retry ${response.retryableFailures.size}.")
stopwatch.stop(TAG)
if (avatarJobs.isNotEmpty()) {
AppDependencies.jobManager.addAll(avatarJobs)
}
if (updatedProfiles.isNotEmpty()) {
StorageSyncHelper.scheduleSyncForDataChange()
}
recipientIds.clear()
recipientIds.addAll(response.retryableFailures)
@@ -353,11 +349,19 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val
}
val identityKey = IdentityKey(decode(identityKeyValue), 0)
if (!AppDependencies.protocolStore.aci().identities().getIdentityRecord(recipient.id).isPresent) {
val existingIdentityKey = AppDependencies.protocolStore.aci().identities().getIdentityRecord(recipient)
.map { (_, identityKey): IdentityRecord -> identityKey }
.orElse(null)
if (existingIdentityKey == null) {
Log.w(TAG, "Still first use for ${recipient.id}")
return
}
if (existingIdentityKey == identityKey) {
return
}
IdentityUtil.saveIdentity(recipient.requireServiceId().toString(), identityKey)
} catch (e: InvalidKeyException) {
Log.w(TAG, e)
@@ -544,7 +548,7 @@ class RetrieveProfileJob private constructor(parameters: Parameters, private val
private const val KEY_SKIP_DEBOUNCE = "skip_debounce"
private const val QUEUE_PREFIX = "RetrieveProfileJob_"
private val PROFILE_FETCH_DEBOUNCE_TIME_MS = 5.minutes.inWholeMilliseconds
private val PROFILE_FETCH_DEBOUNCE_TIME = 5.minutes
/**
* Submits the necessary job to refresh the profile of the requested recipient. Works for any
@@ -14,6 +14,7 @@ import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.signal.core.util.readLength
import org.signal.network.NetworkResult
import org.signal.network.api.AttachmentUploadResult
import org.signal.protos.resumableuploads.ResumableUpload
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.AttachmentId
@@ -35,7 +36,6 @@ import org.thoughtcrime.securesms.service.AttachmentProgressService
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.archive.ArchiveMediaUploadFormStatusCodes
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.preference.PreferenceManager
import kotlinx.coroutines.flow.Flow
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId.PNI
@@ -86,6 +87,8 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
private const val KEY_HAS_LINKED_DEVICES = "account.has_linked_devices"
private const val KEY_HAS_INACTIVE_PRIMARY_DEVICE_ALERT = "account.has_inactive_primary_device_alert"
private const val KEY_VERIFICATION_CODE_REQUESTED_AT = "account.verification_code_requested_at"
private const val KEY_ACCOUNT_ENTROPY_POOL = "account.account_entropy_pool"
private const val KEY_RESTORED_ACCOUNT_ENTROPY_KEY = "account.restored_account_entropy_pool"
private const val KEY_RESTORED_ACCOUNT_ENTROPY_KEY_FROM_PRIMARY = "account.restore_account_entropy_pool_primary"
@@ -434,7 +437,7 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
val isRegistered: Boolean
get() = getBoolean(KEY_IS_REGISTERED, false)
fun setRegistered(registered: Boolean) {
fun setRegistered(registered: Boolean, isAciChanged: Boolean = false) {
Log.i(TAG, "Setting push registered: $registered", Throwable())
val previous = isRegistered
@@ -451,7 +454,7 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
clearLocalCredentials()
}
if (!previous && registered) {
if (registered && (!previous || isAciChanged)) {
registeredAtTimestamp = System.currentTimeMillis()
} else if (!registered) {
registeredAtTimestamp = -1
@@ -562,6 +565,11 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
@get:JvmName("isMultiDevice")
var isMultiDevice by booleanValue(KEY_HAS_LINKED_DEVICES, false)
/** Server has indicated a verification code was requested for the account at this timestamp (ms since epoch) */
private val verificationCodeRequestedAtMsValue = longValue(KEY_VERIFICATION_CODE_REQUESTED_AT, 0)
var verificationCodeRequestedAtMs: Long by verificationCodeRequestedAtMsValue
val verificationCodeRequestedAtMsFlow: Flow<Long> by lazy { verificationCodeRequestedAtMsValue.toFlow() }
/** Do not alter. If you need to migrate more stuff, create a new method. */
private fun migrateFromSharedPrefsV1(context: Context) {
Log.i(TAG, "[V1] Migrating account values from shared prefs.")
@@ -32,6 +32,7 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto
private const val LAST_SERVER_TIME_OFFSET_UPDATE = "misc.last_server_time_offset_update"
private const val NEEDS_USERNAME_RESTORE = "misc.needs_username_restore"
private const val LAST_FORCED_PREKEY_REFRESH = "misc.last_forced_prekey_refresh"
private const val FORCE_PNI_SIGNED_PREKEY_ROTATION = "misc.force_pni_signed_prekey_rotation"
private const val LAST_CDS_FOREGROUND_SYNC = "misc.last_cds_foreground_sync"
private const val LINKED_DEVICE_LAST_ACTIVE_CHECK_TIME = "misc.linked_device.last_active_check_time"
private const val LEAST_ACTIVE_LINKED_DEVICE = "misc.linked_device.least_active"
@@ -51,6 +52,7 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto
private const val CAPTCHA_LAST_VIEWED_AT = "misc.captcha_last_viewed_at"
private const val CALLING_ASSETS_VERSION = "misc.calling_assets_version"
private const val LAST_SYNC_MESSAGE_SEEN_TIME_MS = "misc.last_sync_message_seen_time"
private const val LAST_APPLIED_PNI_CHANGE_SERVER_TIMESTAMP = "misc.last_applied_pni_change_server_timestamp"
}
public override fun onFirstEverAppLaunch() {
@@ -75,6 +77,17 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto
*/
var lastForcedPreKeyRefresh by longValue(LAST_FORCED_PREKEY_REFRESH, 0)
/**
* Bypasses the timeout in [org.thoughtcrime.securesms.jobs.PreKeysSyncJob] since otherwise we can hit a race.
*/
var forcePniSignedPreKeyRotation by booleanValue(FORCE_PNI_SIGNED_PREKEY_ROTATION, false)
/**
* Envelope serverTimestamp of the most recently applied PniChangeNumber sync. Used to reject
* stale replays a sync with serverTimestamp <= this value is treated as a replay and ignored.
*/
var lastAppliedPniChangeServerTimestamp by longValue(LAST_APPLIED_PNI_CHANGE_SERVER_TIMESTAMP, 0L)
/**
* The last time we completed a routine profile refresh.
*/
@@ -566,6 +566,7 @@ public final class SettingsValues extends SignalStoreValues {
}
public void setAutomaticVerificationEnabled(boolean enabled) {
Log.i(TAG, "Setting key transparency enabled to " + enabled);
putBoolean(AUTOMATIC_VERIFICATION_ENABLED, enabled);
}
@@ -33,6 +33,11 @@ final class LogSectionBadges implements LogSection {
InAppPaymentTable.InAppPayment latestRecurringDonation = SignalDatabase.inAppPayments().getLatestInAppPaymentByType(InAppPaymentType.RECURRING_DONATION);
if (latestRecurringDonation != null) {
InAppPaymentSubscriberRecord donationSubscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION);
boolean shouldCancel = donationSubscriber != null
? donationSubscriber.getRequiresCancel()
: SignalStore.inAppPayments().getShouldCancelSubscriptionBeforeNextSubscribeAttempt();
return new StringBuilder().append("Badge Count : ").append(Recipient.self().getBadges().size()).append("\n")
.append("ExpiredBadge : ").append(SignalStore.inAppPayments().getExpiredBadge() != null).append("\n")
.append("LastKeepAliveLaunchTime : ").append(SignalStore.inAppPayments().getLastKeepAliveLaunchTime()).append("\n")
@@ -44,7 +49,7 @@ final class LogSectionBadges implements LogSection {
.append("InAppPaymentData.Error : ").append(getError(latestRecurringDonation.getData())).append("\n")
.append("InAppPaymentData.Cancellation : ").append(getCancellation(latestRecurringDonation.getData())).append("\n")
.append("DisplayBadgesOnProfile : ").append(SignalStore.inAppPayments().getDisplayBadgesOnProfile()).append("\n")
.append("ShouldCancelBeforeNextAttempt : ").append(InAppPaymentsRepository.getShouldCancelSubscriptionBeforeNextSubscribeAttempt(InAppPaymentSubscriberRecord.Type.DONATION)).append("\n")
.append("ShouldCancelBeforeNextAttempt : ").append(shouldCancel).append("\n")
.append("IsUserManuallyCancelledDonation : ").append(SignalStore.inAppPayments().isDonationSubscriptionManuallyCancelled()).append("\n");
} else {
@@ -105,7 +105,12 @@ class LogSectionRemoteBackups : LogSection {
}
output.append("\n -- Attachment Stats\n")
output.append(SignalDatabase.attachments.debugGetAttachmentStats().prettyString())
val backupInProgress = SignalStore.backup.archiveUploadState?.state?.let { it != ArchiveUploadProgressState.State.None && it != ArchiveUploadProgressState.State.UserCanceled } ?: false
if (SignalStore.backup.hasBackupCreationError || backupInProgress) {
output.append(SignalDatabase.attachments.debugGetAttachmentStats().prettyString())
} else {
output.append("Skipped (last backup succeeded and no upload in progress)\n")
}
return output
}
@@ -354,6 +354,16 @@ public class SubmitDebugLogActivity extends BaseActivity {
private void initViewModel() {
viewModel.getMode().observe(this, this::presentMode);
viewModel.getEvents().observe(this, this::presentEvents);
viewModel.getSlowPrefixWarning().observe(this, this::presentSlowPrefixWarning);
}
private void presentSlowPrefixWarning(@NonNull Long durationMillis) {
int durationSeconds = (int) Math.round(durationMillis / 1000.0);
new MaterialAlertDialogBuilder(this)
.setTitle(R.string.SubmitDebugLogActivity_slow_log_title)
.setMessage(getString(R.string.SubmitDebugLogActivity_slow_log_message, durationSeconds))
.setPositiveButton(android.R.string.ok, null)
.show();
}
private void subscribeToLogLines() {
@@ -373,18 +373,28 @@ public class SubmitDebugLogRepository {
int maxTitleLength = SECTIONS.stream().reduce(0, (max, section) -> Math.max(max, section.getTitle().length()), Integer::sum);
List<LogLine> allLines = new ArrayList<>();
List<Future<List<LogLine>>> futures = new ArrayList<>(SECTIONS.size());
for (LogSection section : SECTIONS) {
List<LogLine> lines = getLinesForSection(context, section, maxTitleLength);
futures.add(SignalExecutors.BOUNDED.submit(() -> getLinesForSection(context, section, maxTitleLength)));
}
if (SECTIONS.indexOf(section) != SECTIONS.size() - 1) {
for (int i = 0; i < SECTION_SPACING; i++) {
lines.add(SimpleLogLine.EMPTY);
}
List<LogLine> allLines = new ArrayList<>();
for (int i = 0; i < futures.size(); i++) {
List<LogLine> lines;
try {
lines = futures.get(i).get();
} catch (InterruptedException | ExecutionException e) {
Log.w(TAG, "Failed to read section " + SECTIONS.get(i).getTitle(), e);
lines = new ArrayList<>();
}
allLines.addAll(lines);
if (i != futures.size() - 1) {
for (int j = 0; j < SECTION_SPACING; j++) {
allLines.add(SimpleLogLine.EMPTY);
}
}
}
List<LogLine> withIds = new ArrayList<>(allLines.size());
@@ -15,6 +15,7 @@ import org.signal.core.util.tracing.Tracer;
import org.signal.debuglogsviewer.DebugLogsViewer;
import org.thoughtcrime.securesms.database.LogDatabase;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import java.util.ArrayList;
@@ -28,20 +29,23 @@ public class SubmitDebugLogViewModel extends ViewModel {
private static final String TAG = Log.tag(SubmitDebugLogViewModel.class);
private static final int CHUNK_SIZE = 10_000;
private static final int CHUNK_SIZE = 10_000;
private static final long SLOW_PREFIX_THRESHOLD_MILLIS = 3_000L;
private final SubmitDebugLogRepository repo;
private final MutableLiveData<Mode> mode;
private final SingleLiveEvent<Event> event;
private final SingleLiveEvent<Long> slowPrefixWarning;
private final long firstViewTime;
private final byte[] trace;
private SubmitDebugLogViewModel() {
this.repo = new SubmitDebugLogRepository();
this.mode = new MutableLiveData<>();
this.trace = Tracer.getInstance().serialize();
this.firstViewTime = System.currentTimeMillis();
this.event = new SingleLiveEvent<>();
this.repo = new SubmitDebugLogRepository();
this.mode = new MutableLiveData<>();
this.trace = Tracer.getInstance().serialize();
this.firstViewTime = System.currentTimeMillis();
this.event = new SingleLiveEvent<>();
this.slowPrefixWarning = new SingleLiveEvent<>();
}
@NonNull Observable<List<String>> getLogLinesObservable() {
@@ -50,7 +54,13 @@ public class SubmitDebugLogViewModel extends ViewModel {
try {
mode.postValue(Mode.LOADING);
long prefixStartTime = System.currentTimeMillis();
repo.getPrefixLogLines(prefixLines -> {
long prefixDurationMillis = System.currentTimeMillis() - prefixStartTime;
if (prefixDurationMillis > SLOW_PREFIX_THRESHOLD_MILLIS && RemoteConfig.showSlowDebugLogWarning()) {
slowPrefixWarning.postValue(prefixDurationMillis);
}
try {
List<String> prefixStrings = new ArrayList<>();
for (LogLine line : prefixLines) {
@@ -142,6 +152,10 @@ public class SubmitDebugLogViewModel extends ViewModel {
return event;
}
@NonNull LiveData<Long> getSlowPrefixWarning() {
return slowPrefixWarning;
}
void onDiskSaveLocationReady(@Nullable Uri uri) {
if (uri == null) {
Log.w(TAG, "Null URI!");
@@ -16,7 +16,6 @@ import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowSizeClass
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.getWindowBreakpoint
import org.signal.core.ui.rememberIsSplitPane
@@ -79,7 +78,7 @@ data class MainContentLayoutData(
val isSplitPane = resources.rememberIsSplitPane()
return remember(windowSizeClass, mode, breakpoint, isSplitPane) {
val isLargeWindowSize = breakpoint == WindowBreakpoint.LARGE
val isLargeWindowSize = breakpoint.isLargeWindow
MainContentLayoutData(
shape = when {
@@ -103,8 +103,7 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(
)
)
}
},
contentBottomPaddingDp = 44f
}
)
viewLifecycleOwner.lifecycleScope.launch {
@@ -65,7 +65,10 @@ class MegaphoneRepository(private val context: Application) {
val currentTime = System.currentTimeMillis()
val next = Megaphones.getNextMegaphone(context, databaseCache)
if (next != null) {
val isDonateMegaphone = next?.event == Megaphones.Event.REMOTE_MEGAPHONE &&
RemoteMegaphoneRepository.getRemoteMegaphoneToShow()?.primaryActionId?.isDonateAction == true
if (next != null && !isDonateMegaphone) {
val record = getRecord(next.event)
if (record.lastVisible > 0 && currentTime - record.lastVisible > MAX_DISPLAY_DURATION) {
Log.i(TAG, "Auto-snoozing ${next.event} after being visible for ${currentTime - record.lastVisible}ms without interaction.")
@@ -942,6 +942,11 @@ public final class GroupSendUtil {
public void onSyncMessageSent() {
SignalLocalMetrics.GroupMessageSend.onSenderKeySyncSent(messageId);
}
@Override
public void onSyncMessageEncrypted() {
SignalLocalMetrics.GroupMessageSend.onSenderKeySyncEncrypted(messageId);
}
}
private static final class LegacyMetricEventListener implements LegacyGroupEvents {
@@ -964,6 +969,11 @@ public final class GroupSendUtil {
public void onSyncMessageSent() {
SignalLocalMetrics.GroupMessageSend.onLegacySyncFinished(messageId);
}
@Override
public void onSyncMessageEncrypted() {
SignalLocalMetrics.GroupMessageSend.onLegacySyncEncrypted(messageId);
}
}
/**
@@ -255,7 +255,10 @@ class IncomingMessageObserver(
val needsConnectionString = if (conclusion) "Needs Connection" else "Does Not Need Connection"
Log.d(TAG, "[$needsConnectionString] Network: $hasNetwork, Foreground: $appVisibleSnapshot, Time Since Last Interaction: $lastInteractionString, FCM: $fcmEnabled, WS Open or Keep-alives: $websocketAlreadyOpen, Registered: $registered, Unauthorized: $unauthorizedReceived, Proxy: $hasProxy, Force websocket: $forceWebsocket")
Log.d(
TAG,
"[$needsConnectionString] Network: $hasNetwork, Foreground: $appVisibleSnapshot, Time Since Last Interaction: $lastInteractionString, FCM: $fcmEnabled, WS Open or Keep-alives: $websocketAlreadyOpen, Registered: $registered, Unauthorized: $unauthorizedReceived, Proxy: $hasProxy, Force websocket: $forceWebsocket"
)
return conclusion
}
@@ -287,7 +290,7 @@ class IncomingMessageObserver(
}
@VisibleForTesting
fun processEnvelope(bufferedProtocolStore: BufferedProtocolStore, envelope: Envelope, serverDeliveredTimestamp: Long, batchCache: BatchCache): List<FollowUpOperation>? {
fun processEnvelope(bufferedProtocolStore: BufferedProtocolStore, envelope: Envelope, serverDeliveredTimestamp: Long, batchCache: BatchCache): ProcessingResult? {
return when (envelope.type) {
Envelope.Type.SERVER_DELIVERY_RECEIPT -> {
processReceipt(envelope)
@@ -299,9 +302,9 @@ class IncomingMessageObserver(
Envelope.Type.UNIDENTIFIED_SENDER,
Envelope.Type.PLAINTEXT_CONTENT -> {
SignalTrace.beginSection("IncomingMessageObserver#processMessage")
val followUps = processMessage(bufferedProtocolStore, envelope, serverDeliveredTimestamp, batchCache)
val result = processMessage(bufferedProtocolStore, envelope, serverDeliveredTimestamp, batchCache)
SignalTrace.endSection()
followUps
result
}
else -> {
@@ -311,56 +314,79 @@ class IncomingMessageObserver(
}
}
private fun processMessage(bufferedProtocolStore: BufferedProtocolStore, envelope: Envelope, serverDeliveredTimestamp: Long, batchCache: BatchCache): List<FollowUpOperation> {
private fun processMessage(bufferedProtocolStore: BufferedProtocolStore, envelope: Envelope, serverDeliveredTimestamp: Long, batchCache: BatchCache): ProcessingResult {
val localReceiveMetric = SignalLocalMetrics.MessageReceive.start()
SignalTrace.beginSection("IncomingMessageObserver#decryptMessage")
val result = MessageDecryptor.decrypt(context, bufferedProtocolStore, envelope, serverDeliveredTimestamp)
SignalTrace.endSection()
localReceiveMetric.onEnvelopeDecrypted()
var isNetworkResetRequired = false
SignalLocalMetrics.MessageLatency.onMessageReceived(envelope.serverTimestamp!!, serverDeliveredTimestamp, envelope.urgent!!)
when (result) {
is MessageDecryptor.Result.Success -> {
val job = PushProcessMessageJob.processOrDefer(messageContentProcessor, result, localReceiveMetric, batchCache)
isNetworkResetRequired = isNetworkResetRequired(result, bufferedProtocolStore.pni)
if (job != null) {
return result.followUpOperations + FollowUpOperation { job.asChain() }
}
}
is MessageDecryptor.Result.Error -> {
return result.followUpOperations + FollowUpOperation {
val jobs = mutableListOf<Job>()
if (result.errorMetadata.groupMasterKey != null) {
val groupId = result.errorMetadata.groupId!!
if (!SignalDatabase.groups.getGroup(groupId).isPresent) {
Log.w(TAG, "Decryption error in group, but group not found. Creating placeholder for groupId: $groupId")
SignalDatabase.groups.create(
groupMasterKey = result.errorMetadata.groupMasterKey!!,
groupState = DecryptedGroup(revision = GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION),
groupSendEndorsements = null
)
jobs += RequestGroupV2InfoJob(groupId)
}
}
jobs += PushProcessMessageErrorJob(
result.toMessageState(),
result.errorMetadata.toExceptionMetadata(),
result.envelope.clientTimestamp!!
return ProcessingResult(
followUpOperations = result.followUpOperations + FollowUpOperation { job.asChain() },
isNetworkResetRequired = isNetworkResetRequired
)
AppDependencies.jobManager.startChain(jobs)
}
}
is MessageDecryptor.Result.Error -> {
return ProcessingResult(
result.followUpOperations + FollowUpOperation {
val jobs = mutableListOf<Job>()
if (result.errorMetadata.groupMasterKey != null) {
val groupId = result.errorMetadata.groupId!!
if (!SignalDatabase.groups.getGroup(groupId).isPresent) {
Log.w(TAG, "Decryption error in group, but group not found. Creating placeholder for groupId: $groupId")
SignalDatabase.groups.create(
groupMasterKey = result.errorMetadata.groupMasterKey!!,
groupState = DecryptedGroup(revision = GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION),
groupSendEndorsements = null
)
jobs += RequestGroupV2InfoJob(groupId)
}
}
jobs += PushProcessMessageErrorJob(
result.toMessageState(),
result.errorMetadata.toExceptionMetadata(),
result.envelope.clientTimestamp!!
)
AppDependencies.jobManager.startChain(jobs)
}
)
}
is MessageDecryptor.Result.Ignore -> {
// No action needed
}
else -> {
throw AssertionError("Unexpected result! ${result.javaClass.simpleName}")
}
}
return result.followUpOperations
return ProcessingResult(
followUpOperations = result.followUpOperations,
isNetworkResetRequired = isNetworkResetRequired
)
}
/**
* True iff this envelope's PniChangeNumber sync actually changed our PNI within this batch.
* Comparing the batch-start PNI against the current value makes the check idempotent a
* redelivered envelope finds the PNI already applied and won't re-trigger a websocket reset.
*/
private fun isNetworkResetRequired(result: MessageDecryptor.Result.Success, pniAtBatchStart: ServiceId.PNI): Boolean {
return result.content.syncMessage?.pniChangeNumber != null && SignalStore.account.pni != pniAtBatchStart
}
private fun processReceipt(envelope: Envelope) {
@@ -527,16 +553,26 @@ class IncomingMessageObserver(
val allFollowUpOperations = mutableListOf<FollowUpOperation>()
val bufferedStore = BufferedProtocolStore.create()
val batchCache = ReusedBatchCache()
var processedCount = 0
var networkResetRequired = false
val committed = SignalDatabase.tryRunInTransaction {
batch.forEach { response ->
for (response in batch) {
SignalTrace.beginSection("IncomingMessageObserver#perMessageTransaction")
val followUps = processEnvelope(bufferedStore, response.envelope, response.serverDeliveredTimestamp, batchCache)
val result = processEnvelope(bufferedStore, response.envelope, response.serverDeliveredTimestamp, batchCache)
bufferedStore.flushToDisk()
SignalTrace.endSection()
if (followUps?.isNotEmpty() == true) {
allFollowUpOperations += followUps
if (result?.followUpOperations?.isNotEmpty() == true) {
allFollowUpOperations += result.followUpOperations
}
processedCount++
if (result?.isNetworkResetRequired == true) {
networkResetRequired = true
Log.w(TAG, "Self identity changed mid-batch after envelope $processedCount of ${batch.size}. Committing what we have; the remainder will be redelivered to the new connection.")
break
}
}
}
@@ -550,8 +586,13 @@ class IncomingMessageObserver(
AppDependencies.jobManager.addAllChains(jobs)
}
batch.forEach { response ->
authWebSocket.sendAck(response)
for (i in 0 until processedCount) {
sendAckSafely(batch[i], i, batch.size)
}
if (networkResetRequired) {
AppDependencies.resetNetwork()
AppDependencies.startNetwork()
}
}
@@ -565,26 +606,46 @@ class IncomingMessageObserver(
val bufferedStore = BufferedProtocolStore.create()
val batchCache = ReusedBatchCache()
batch.forEach { response ->
for ((index, response) in batch.withIndex()) {
SignalTrace.beginSection("IncomingMessageObserver#perMessageTransaction")
val followUpOperations = SignalDatabase.runInTransaction {
val followUps = processEnvelope(bufferedStore, response.envelope, response.serverDeliveredTimestamp, batchCache)
val results = SignalDatabase.runInTransaction {
val result = processEnvelope(bufferedStore, response.envelope, response.serverDeliveredTimestamp, batchCache)
bufferedStore.flushToDisk()
followUps
result
}
SignalTrace.endSection()
if (followUpOperations?.isNotEmpty() == true) {
val jobs = followUpOperations.mapNotNull { it.run() }
if (results?.followUpOperations?.isNotEmpty() == true) {
val jobs = results.followUpOperations.mapNotNull { it.run() }
AppDependencies.jobManager.addAllChains(jobs)
}
authWebSocket.sendAck(response)
sendAckSafely(response, index, batch.size)
if (results?.isNetworkResetRequired == true) {
Log.w(TAG, "Self identity changed mid-batch after envelope ${index + 1} of ${batch.size}. Stopping individual processing; the remainder will be redelivered to the new connection.")
AppDependencies.resetNetwork()
AppDependencies.startNetwork()
break
}
}
batchCache.flushAndClear()
}
/**
* Best-effort ack. Failures just mean the server will redeliver and for a redelivered
* PniChangeNumber sync, [isNetworkResetRequired] sees the PNI is already applied and won't
* re-trigger a reset, so we don't loop.
*/
private fun sendAckSafely(response: EnvelopeResponse, index: Int, size: Int) {
try {
authWebSocket.sendAck(response)
} catch (e: Exception) {
Log.w(TAG, "Failed to send ack for envelope $index of $size. The server will redeliver.", e)
}
}
override fun uncaughtException(t: Thread, e: Throwable) {
Log.w(TAG, "Uncaught exception in message thread!", e)
}
@@ -649,4 +710,9 @@ class IncomingMessageObserver(
}
}
}
data class ProcessingResult(
val followUpOperations: List<FollowUpOperation>,
val isNetworkResetRequired: Boolean = false
)
}
@@ -470,7 +470,6 @@ open class MessageContentProcessor(private val context: Context) {
content.syncMessage != null -> {
SignalStore.account.isMultiDevice = true
SignalStore.misc.lastSyncMessageSeenTimeMs = System.currentTimeMillis()
SyncMessageProcessor.process(
context,
@@ -39,7 +39,6 @@ import org.signal.libsignal.protocol.message.CiphertextMessage
import org.signal.libsignal.protocol.message.DecryptionErrorMessage
import org.signal.libsignal.protocol.message.SenderKeyDistributionMessage
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil
@@ -162,8 +161,7 @@ object MessageDecryptor {
val envelope = if (cipherResult?.metadata?.sourceServiceId != null) {
envelope.newBuilder()
.sourceServiceId(if (BuildConfig.USE_STRING_ID) cipherResult.metadata.sourceServiceId.toString() else null)
.sourceServiceIdBinary(if (RemoteConfig.useBinaryId) cipherResult.metadata.sourceServiceId.toByteString() else null)
.sourceServiceIdBinary(cipherResult.metadata.sourceServiceId.toByteString())
.sourceDeviceId(cipherResult.metadata.sourceDeviceId)
.build()
} else {

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