mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-06-10 01:06:06 +01:00
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbbcadf09b | |||
| 3fc6ac3871 | |||
| 6a2ec01c52 | |||
| c1b3fb6d1b | |||
| 792d86f4d8 | |||
| 849856cde8 | |||
| 0646418d4d | |||
| ed540a2f9e | |||
| 00d86101f5 | |||
| 86e49cd564 | |||
| 21b7d64fcd | |||
| 42c0044096 | |||
| f2e8b83604 | |||
| 46b8ac6561 | |||
| 9089cc393e | |||
| 2ea59bef68 | |||
| 698fc38aed | |||
| 1d74b00b91 | |||
| ea861fff49 | |||
| 3b93edcdaf | |||
| 6722a28f98 | |||
| 16de2efa9e | |||
| 4d0919c9a8 | |||
| 01e1cb4d67 | |||
| 9d1d5142da | |||
| 49f0c2502b | |||
| 71ffc36e7f | |||
| 5941ff814d | |||
| 1661f3b5f7 | |||
| d682de08d2 | |||
| 5321f8124a | |||
| 5052f22d44 | |||
| 9a8cb1785b | |||
| a2065becdd | |||
| e85637a58d | |||
| 73c3d141e3 | |||
| eafba156ba | |||
| e6beafd612 | |||
| a9649fd017 | |||
| 4decae274b | |||
| dbb83d86e3 | |||
| 2aa27df95b | |||
| ec47b83f76 | |||
| 6eea4ba937 | |||
| 9f608337f1 | |||
| 28edcdf62d | |||
| 10d969ea35 | |||
| 38bac16640 | |||
| 93077ac457 | |||
| c069eb1b88 | |||
| e5cd18bf1e | |||
| 9e8ae7e26a | |||
| 00042b9579 | |||
| e750b81a31 | |||
| daec317f52 | |||
| 112514c221 | |||
| f43db8ace0 | |||
| 54df95727b | |||
| 022b4d9508 | |||
| 7411e725ec | |||
| 83a279f422 | |||
| 523066d093 | |||
| de27343c24 | |||
| c36179293e | |||
| a79a91bafb |
+15
-4
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+36
@@ -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()
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+3
-4
@@ -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!!
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+353
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+27
@@ -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")
|
||||
}
|
||||
}
|
||||
+39
@@ -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")
|
||||
}
|
||||
}
|
||||
+22
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+71
@@ -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()
|
||||
}
|
||||
}
|
||||
+63
@@ -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)
|
||||
}
|
||||
}
|
||||
+201
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -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 }
|
||||
}
|
||||
}
|
||||
+12
@@ -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 ->
|
||||
|
||||
+2
-3
@@ -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() }
|
||||
|
||||
|
||||
+4
@@ -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))
|
||||
|
||||
|
||||
+16
-3
@@ -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
|
||||
|
||||
+20
-4
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
+151
@@ -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)
|
||||
}
|
||||
}
|
||||
+73
-37
@@ -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
|
||||
|
||||
+22
-6
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+195
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+182
@@ -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)))
|
||||
|
||||
+9
-738
@@ -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)
|
||||
|
||||
+11
-10
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+28
-1
@@ -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,
|
||||
|
||||
+1
-2
@@ -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()!!
|
||||
|
||||
+22
-7
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+9
-174
@@ -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 {
|
||||
|
||||
+290
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -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);
|
||||
}
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+40
-26
@@ -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)
|
||||
|
||||
+8
-3
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -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() {
|
||||
|
||||
+17
-7
@@ -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());
|
||||
|
||||
+20
-6
@@ -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 {
|
||||
|
||||
+1
-2
@@ -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
Reference in New Issue
Block a user