mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-06-12 02:06:06 +01:00
Compare commits
48 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 |
+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 = 1692
|
||||
val canonicalVersionName = "8.11.4"
|
||||
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
|
||||
|
||||
+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 ->
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-23
@@ -45,7 +45,6 @@ import org.signal.core.ui.compose.Texts
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
/**
|
||||
@@ -299,31 +298,29 @@ private fun AdvancedPrivacySettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (RemoteConfig.internalUser) {
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
val label = buildAnnotatedString {
|
||||
append(stringResource(R.string.preferences_automatic_key_verification_body))
|
||||
append(" ")
|
||||
withLink(
|
||||
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
|
||||
callbacks.onAutomaticVerificationLearnMoreClick()
|
||||
})
|
||||
) {
|
||||
append(stringResource(R.string.LearnMoreTextView_learn_more))
|
||||
}
|
||||
item {
|
||||
val label = buildAnnotatedString {
|
||||
append(stringResource(R.string.preferences_automatic_key_verification_body))
|
||||
append(" ")
|
||||
withLink(
|
||||
LinkAnnotation.Clickable("learn-more", linkInteractionListener = {
|
||||
callbacks.onAutomaticVerificationLearnMoreClick()
|
||||
})
|
||||
) {
|
||||
append(stringResource(R.string.LearnMoreTextView_learn_more))
|
||||
}
|
||||
|
||||
Rows.ToggleRow(
|
||||
checked = state.allowAutomaticKeyVerification,
|
||||
text = AnnotatedString(stringResource(R.string.preferences_automatic_key_verification)),
|
||||
label = label,
|
||||
onCheckChanged = callbacks::onAllowAutomaticVerificationChanged
|
||||
)
|
||||
}
|
||||
|
||||
Rows.ToggleRow(
|
||||
checked = state.allowAutomaticKeyVerification,
|
||||
text = AnnotatedString(stringResource(R.string.preferences_automatic_key_verification)),
|
||||
label = label,
|
||||
onCheckChanged = callbacks::onAllowAutomaticVerificationChanged
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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()!!
|
||||
|
||||
+19
-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)
|
||||
@@ -766,17 +768,25 @@ class ConversationFragment :
|
||||
}
|
||||
|
||||
binding.toolbar.addOnLayoutChangeListener { _, _, _, _, bottom, _, _, _, oldBottom ->
|
||||
// Bug: ConstraintLayout's solver can transiently place the toolbar at a negative position during the very first layout, preventing future RV layouts
|
||||
// Bug: ConstraintLayout can provide a negative value for the toolbar causing RV layout problems
|
||||
if (bottom < 0) return@addOnLayoutChangeListener
|
||||
|
||||
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: 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)
|
||||
@@ -4307,6 +4317,7 @@ class ConversationFragment :
|
||||
|
||||
override fun handleManageGroup() {
|
||||
viewModel.recipientSnapshot?.let { recipient ->
|
||||
container.hideKeyboard(composeText)
|
||||
chatRouter.goToChatDetail(MainNavigationDetailLocation.Chats.ConversationSettings(recipient.id))
|
||||
}
|
||||
}
|
||||
@@ -4344,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}
|
||||
)
|
||||
|
||||
@@ -120,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) {
|
||||
|
||||
@@ -762,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())
|
||||
@@ -2489,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.
|
||||
@@ -3773,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) {
|
||||
@@ -3790,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?>) {
|
||||
@@ -4028,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()))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+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
|
||||
|
||||
@@ -81,10 +81,7 @@ class CheckKeyTransparencyJob private constructor(
|
||||
}
|
||||
|
||||
private fun canRunJob(): Boolean {
|
||||
return if (!RemoteConfig.internalUser) {
|
||||
Log.i(TAG, "Remote config is not on. Exiting.")
|
||||
false
|
||||
} else if (!SignalStore.account.isRegistered) {
|
||||
return if (!SignalStore.account.isRegistered) {
|
||||
Log.i(TAG, "Account not registered. Exiting.")
|
||||
false
|
||||
} else if (!SignalStore.settings.automaticVerificationEnabled) {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,8 +15,12 @@ import org.signal.core.util.UuidUtil
|
||||
import org.signal.core.util.isNotEmpty
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.InvalidKeyException
|
||||
import org.signal.libsignal.protocol.ServiceId.Pni
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||
import org.signal.ringrtc.CallException
|
||||
import org.signal.ringrtc.CallId
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
@@ -24,6 +28,7 @@ import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.attachments.TombstoneAttachment
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
|
||||
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberRepository
|
||||
import org.thoughtcrime.securesms.contactshare.Contact
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
@@ -66,6 +71,7 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceContactSyncJob
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceKeysUpdateJob
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceStickerPackSyncJob
|
||||
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
|
||||
import org.thoughtcrime.securesms.jobs.PushProcessEarlyMessagesJob
|
||||
import org.thoughtcrime.securesms.jobs.RefreshCallLinkDetailsJob
|
||||
import org.thoughtcrime.securesms.jobs.RefreshDonationSubscriptionStatusJob
|
||||
@@ -175,6 +181,7 @@ object SyncMessageProcessor {
|
||||
syncMessage.outgoingPayment != null -> handleSynchronizeOutgoingPayment(syncMessage.outgoingPayment!!, envelope.clientTimestamp!!)
|
||||
syncMessage.contacts != null -> handleSynchronizeContacts(syncMessage.contacts!!, envelope.clientTimestamp!!)
|
||||
syncMessage.keys != null -> handleSynchronizeKeys(syncMessage.keys!!, envelope.clientTimestamp!!)
|
||||
syncMessage.pniChangeNumber != null -> handleSynchronizePniChangeNumber(envelope, metadata, syncMessage.pniChangeNumber!!)
|
||||
syncMessage.callEvent != null -> handleSynchronizeCallEvent(syncMessage.callEvent!!, envelope.clientTimestamp!!)
|
||||
syncMessage.callLinkUpdate != null -> handleSynchronizeCallLink(syncMessage.callLinkUpdate!!, envelope.clientTimestamp!!)
|
||||
syncMessage.callLogEvent != null -> handleSynchronizeCallLogEvent(syncMessage.callLogEvent!!, envelope.clientTimestamp!!)
|
||||
@@ -1750,6 +1757,99 @@ object SyncMessageProcessor {
|
||||
MultiDeviceAttachmentBackfillUpdateJob.enqueue(request.targetMessage!!, request.targetConversation!!, messageId)
|
||||
}
|
||||
|
||||
private fun handleSynchronizePniChangeNumber(envelope: Envelope, metadata: EnvelopeMetadata, pniChangeNumber: SyncMessage.PniChangeNumber) {
|
||||
val timestamp = envelope.clientTimestamp!!
|
||||
|
||||
if (SignalStore.account.isPrimaryDevice) {
|
||||
warn(timestamp, "Received a PniChangeNumber sync message on the primary device. Bailing.")
|
||||
return
|
||||
}
|
||||
|
||||
if (metadata.sourceDeviceId != SignalServiceAddress.DEFAULT_DEVICE_ID) {
|
||||
warn(timestamp, "Received a PniChangeNumber sync message from a non-primary device (${metadata.sourceDeviceId}). Bailing.")
|
||||
return
|
||||
}
|
||||
|
||||
if (SignalStore.account.aci == null) {
|
||||
warn(timestamp, "Received a PniChangeNumber sync message but no local ACI is set. Bailing.")
|
||||
return
|
||||
}
|
||||
|
||||
val envelopeServerTimestamp = envelope.serverTimestamp ?: 0L
|
||||
val lastAppliedServerTimestamp = SignalStore.misc.lastAppliedPniChangeServerTimestamp
|
||||
if (envelopeServerTimestamp <= lastAppliedServerTimestamp) {
|
||||
warn(timestamp, "PniChangeNumber sync serverTimestamp ($envelopeServerTimestamp) is not newer than the last applied ($lastAppliedServerTimestamp). Treating as a replay and bailing.")
|
||||
return
|
||||
}
|
||||
|
||||
// updatedPniBinary is a raw 16-byte UUID per the proto contract instead of a 17-byte service-id array.
|
||||
val pni = if (envelope.updatedPniBinary != null) {
|
||||
val updatedPniUuid = UuidUtil.parseOrNull(envelope.updatedPniBinary!!.toByteArray())
|
||||
if (updatedPniUuid == null) {
|
||||
warn(timestamp, "Could not parse updatedPniBinary as a UUID. Bailing.")
|
||||
return
|
||||
}
|
||||
Pni(updatedPniUuid)
|
||||
} else if (envelope.updatedPni != null) {
|
||||
Pni.parseFromString(envelope.updatedPni)
|
||||
} else {
|
||||
warn(timestamp, "Neither updatedPni or updatedPniBinary were present on the envelope. Bailing.")
|
||||
return
|
||||
}
|
||||
|
||||
if (SignalStore.account.pni == PNI(pni)) {
|
||||
log(timestamp, "PniChangeNumber sync already applied locally. Skipping.")
|
||||
return
|
||||
}
|
||||
|
||||
val identityKeyPairBytes = pniChangeNumber.identityKeyPair
|
||||
val signedPreKeyBytes = pniChangeNumber.signedPreKey
|
||||
val registrationId = pniChangeNumber.registrationId
|
||||
val newE164 = pniChangeNumber.newE164
|
||||
|
||||
if (identityKeyPairBytes == null || signedPreKeyBytes == null || registrationId == null || registrationId <= 0 || newE164.isNullOrEmpty() || !SignalE164Util.isPotentialE164(newE164)) {
|
||||
warn(timestamp, "PniChangeNumber sync message is missing or has an invalid required field. Bailing.")
|
||||
return
|
||||
}
|
||||
|
||||
val pniIdentityKeyPair: IdentityKeyPair
|
||||
val pniSignedPreKey: SignedPreKeyRecord
|
||||
val pniLastResortKyberPreKey: KyberPreKeyRecord?
|
||||
try {
|
||||
pniIdentityKeyPair = IdentityKeyPair(identityKeyPairBytes.toByteArray())
|
||||
pniSignedPreKey = SignedPreKeyRecord(signedPreKeyBytes.toByteArray())
|
||||
pniLastResortKyberPreKey = pniChangeNumber.lastResortKyberPreKey?.let { KyberPreKeyRecord(it.toByteArray()) }
|
||||
} catch (e: Exception) {
|
||||
warn(timestamp, "Failed to deserialize PniChangeNumber sync message. Bailing.", e)
|
||||
return
|
||||
}
|
||||
|
||||
log(timestamp, "Applying PniChangeNumber sync message.")
|
||||
|
||||
ChangeNumberRepository().applyLocalNumberChange(
|
||||
e164 = newE164,
|
||||
pni = PNI(pni),
|
||||
pniIdentityKeyPair = pniIdentityKeyPair,
|
||||
pniSignedPreKey = pniSignedPreKey,
|
||||
pniLastResortKyberPreKey = pniLastResortKyberPreKey,
|
||||
pniRegistrationId = registrationId
|
||||
)
|
||||
|
||||
SignalStore.misc.lastAppliedPniChangeServerTimestamp = envelopeServerTimestamp
|
||||
|
||||
// The primary already submitted these per-device prekeys to the server as part of the
|
||||
// change-number request, so they are registered server-side from this device's perspective.
|
||||
val pniMetadataStore = SignalStore.account.pniPreKeys
|
||||
pniMetadataStore.isSignedPreKeyRegistered = true
|
||||
if (pniLastResortKyberPreKey != null) {
|
||||
pniMetadataStore.lastResortKyberPreKeyId = pniLastResortKyberPreKey.id
|
||||
}
|
||||
|
||||
// Rotate the primary-generated keys as soon as possible so we don't rely on them long-term.
|
||||
SignalStore.misc.forcePniSignedPreKeyRotation = true
|
||||
AppDependencies.jobManager.add(PreKeysSyncJob.create(forceRotationRequested = true))
|
||||
}
|
||||
|
||||
private fun handleSynchronizedPollCreate(
|
||||
envelope: Envelope,
|
||||
message: DataMessage,
|
||||
|
||||
+5
-1
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.messages.protocol
|
||||
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.core.models.ServiceId.PNI
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
@@ -13,9 +14,12 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
*/
|
||||
class BufferedProtocolStore private constructor(
|
||||
private val aciStore: Pair<ServiceId, BufferedSignalServiceAccountDataStore>,
|
||||
private val pniStore: Pair<ServiceId, BufferedSignalServiceAccountDataStore>
|
||||
private val pniStore: Pair<PNI, BufferedSignalServiceAccountDataStore>
|
||||
) {
|
||||
|
||||
/** The PNI captured when this batch's store was created. Does not refresh if [SignalStore.account.pni] later changes mid-batch. */
|
||||
val pni: PNI get() = pniStore.first
|
||||
|
||||
fun get(serviceId: ServiceId): BufferedSignalServiceAccountDataStore {
|
||||
return when (serviceId) {
|
||||
aciStore.first -> aciStore.second
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import org.signal.libsignal.protocol.NoSessionException
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.signal.libsignal.protocol.state.SessionRecord
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore
|
||||
import org.whispersystems.signalservice.api.SignalServiceSessionStore
|
||||
import kotlin.jvm.Throws
|
||||
@@ -76,7 +77,7 @@ class BufferedSessionStore(private val selfServiceId: ServiceId) : SignalService
|
||||
|
||||
if (fromDatabase != null) {
|
||||
store[address] = fromDatabase
|
||||
return fromDatabase.hasSenderChain(0.0)
|
||||
return fromDatabase.hasSenderChain(RemoteConfig.requirePqRatio)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -199,9 +199,10 @@ public class ApplicationMigrations {
|
||||
static final int COLLAPSED_EVENTS = 155;
|
||||
static final int COLLAPSED_EVENTS_2 = 156;
|
||||
static final int KEY_TRANSPARENCY = 157;
|
||||
static final int RELEASE_NOTES_CHAT_SYNC = 158;
|
||||
}
|
||||
|
||||
public static final int CURRENT_VERSION = 157;
|
||||
public static final int CURRENT_VERSION = 158;
|
||||
|
||||
/**
|
||||
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
|
||||
@@ -924,6 +925,10 @@ public class ApplicationMigrations {
|
||||
jobs.put(Version.KEY_TRANSPARENCY, new ResetKeyTransparencyMigrationJob());
|
||||
}
|
||||
|
||||
if (lastSeenVersion < Version.RELEASE_NOTES_CHAT_SYNC) {
|
||||
jobs.put(Version.RELEASE_NOTES_CHAT_SYNC, new AccountRecordMigrationJob());
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package org.thoughtcrime.securesms.net
|
||||
|
||||
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
|
||||
@@ -19,7 +20,6 @@ import org.signal.network.api.UsernameApi
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.KeyTransparencyApi
|
||||
import org.whispersystems.signalservice.api.account.AccountApi
|
||||
import org.whispersystems.signalservice.api.attachment.AttachmentApi
|
||||
import org.whispersystems.signalservice.api.keys.KeysApi
|
||||
import org.whispersystems.signalservice.api.message.MessageApi
|
||||
import org.whispersystems.signalservice.api.profiles.ProfileApi
|
||||
|
||||
+1
-6
@@ -32,7 +32,7 @@ import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
|
||||
public class PaymentRecipientSelectionFragment extends LoggingFragment implements ContactSelectionListFragment.OnContactSelectedListener, ContactSelectionListFragment.ScrollCallback {
|
||||
public class PaymentRecipientSelectionFragment extends LoggingFragment implements ContactSelectionListFragment.OnContactSelectedListener {
|
||||
|
||||
private Toolbar toolbar;
|
||||
private ContactFilterView contactFilterView;
|
||||
@@ -90,11 +90,6 @@ public class PaymentRecipientSelectionFragment extends LoggingFragment implement
|
||||
public void onSelectionChanged() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeginScroll() {
|
||||
hideKeyboard();
|
||||
}
|
||||
|
||||
private void hideKeyboard() {
|
||||
ViewUtil.hideKeyboard(requireContext(), toolbar);
|
||||
toolbar.clearFocus();
|
||||
|
||||
@@ -105,7 +105,6 @@ class Recipient(
|
||||
val profileAvatarFileDetails: ProfileAvatarFileDetails = ProfileAvatarFileDetails.NO_DETAILS,
|
||||
val isProfileSharing: Boolean = false,
|
||||
val hiddenState: HiddenState = HiddenState.NOT_HIDDEN,
|
||||
val lastProfileFetchTime: Long = 0,
|
||||
private val notificationChannelValue: String? = null,
|
||||
private val sealedSenderAccessModeValue: SealedSenderAccessMode = SealedSenderAccessMode.UNKNOWN,
|
||||
private val capabilities: RecipientRecord.Capabilities = RecipientRecord.Capabilities.UNKNOWN,
|
||||
|
||||
@@ -178,7 +178,6 @@ object RecipientCreator {
|
||||
profileAvatarFileDetails = record.profileAvatarFileDetails,
|
||||
isProfileSharing = record.profileSharing,
|
||||
hiddenState = record.hiddenState,
|
||||
lastProfileFetchTime = record.lastProfileFetch,
|
||||
isSelf = isSelf,
|
||||
notificationChannelValue = record.notificationChannel,
|
||||
sealedSenderAccessModeValue = record.sealedSenderAccessMode,
|
||||
|
||||
@@ -32,7 +32,6 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.compose.rememberFragmentState
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -295,22 +294,21 @@ private fun ContactSelectionListFragment.setUpCallbacks(
|
||||
}
|
||||
})
|
||||
|
||||
fragment.setOnItemLongClickListener { anchorView, contactSearchKey, recyclerView ->
|
||||
fragment.setOnItemLongClickListener { anchorView, contactSearchKey, setIsDisplayingContextMenu ->
|
||||
if (callbacks.contextMenu != null) {
|
||||
coroutineScope.launch { showItemContextMenu(anchorView, contactSearchKey, recyclerView, callbacks.contextMenu) }
|
||||
coroutineScope.launch { showItemContextMenu(anchorView, contactSearchKey, setIsDisplayingContextMenu, callbacks.contextMenu) }
|
||||
true
|
||||
}
|
||||
return@setOnItemLongClickListener false
|
||||
}
|
||||
|
||||
fragment.setOnRefreshListener { callbacks.refresh?.onRefresh() }
|
||||
fragment.setScrollCallback { clearFocus() }
|
||||
}
|
||||
|
||||
private suspend fun showItemContextMenu(
|
||||
anchorView: View,
|
||||
contactSearchKey: ContactSearchKey,
|
||||
recyclerView: RecyclerView,
|
||||
setIsDisplayingContextMenu: Consumer<Boolean>,
|
||||
callbacks: RecipientPickerCallbacks.ContextMenu
|
||||
) {
|
||||
val context = anchorView.context
|
||||
@@ -373,10 +371,10 @@ private suspend fun showItemContextMenu(
|
||||
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
|
||||
.offsetX(DimensionUnit.DP.toPixels(12f).toInt())
|
||||
.offsetY(DimensionUnit.DP.toPixels(12f).toInt())
|
||||
.onDismiss { recyclerView.suppressLayout(false) }
|
||||
.onDismiss { setIsDisplayingContextMenu.accept(false) }
|
||||
.show(actions)
|
||||
|
||||
recyclerView.suppressLayout(true)
|
||||
setIsDisplayingContextMenu.accept(true)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
|
||||
+2
-1
@@ -184,6 +184,7 @@ object RegistrationRepository {
|
||||
val aci: ACI = ACI.parseOrThrow(data.aci)
|
||||
val pni: PNI = PNI.parseOrThrow(data.pni)
|
||||
val hasPin: Boolean = data.hasPin
|
||||
val isAciChanged: Boolean = SignalStore.account.aci != aci
|
||||
|
||||
SignalStore.account.setAci(aci)
|
||||
SignalStore.account.setPni(pni)
|
||||
@@ -232,7 +233,7 @@ object RegistrationRepository {
|
||||
}
|
||||
|
||||
SignalStore.account.setServicePassword(data.servicePassword)
|
||||
SignalStore.account.setRegistered(true)
|
||||
SignalStore.account.setRegistered(registered = true, isAciChanged = isAciChanged)
|
||||
TextSecurePreferences.setPromptedPushRegistration(context, true)
|
||||
TextSecurePreferences.setUnauthorizedReceived(context, false)
|
||||
NotificationManagerCompat.from(context).cancel(NotificationIds.UNREGISTERED_NOTIFICATION_ID)
|
||||
|
||||
@@ -140,6 +140,10 @@ class AccountRecordProcessor(
|
||||
backupTier = local.proto.backupTier ?: remote.proto.backupTier
|
||||
automaticKeyVerificationDisabled = remote.proto.automaticKeyVerificationDisabled
|
||||
hasSeenAdminDeleteEducationDialog = remote.proto.hasSeenAdminDeleteEducationDialog
|
||||
releaseNotesChatArchived = remote.proto.releaseNotesChatArchived
|
||||
releaseNotesChatMutedUntilTimestamp = remote.proto.releaseNotesChatMutedUntilTimestamp
|
||||
releaseNotesChatBlocked = remote.proto.releaseNotesChatBlocked
|
||||
releaseNotesChatMarkedUnread = remote.proto.releaseNotesChatMarkedUnread
|
||||
|
||||
safeSetPayments(payments?.enabled == true, payments?.entropy?.toByteArray())
|
||||
safeSetSubscriber(donationSubscriberId, donationSubscriberCurrencyCode)
|
||||
|
||||
@@ -18,7 +18,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient.Companion.trustedPush
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncModels.localToRemoteRecord
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.storage.SignalContactRecord
|
||||
import org.whispersystems.signalservice.api.storage.StorageId
|
||||
import org.whispersystems.signalservice.api.storage.signalAci
|
||||
@@ -238,8 +237,8 @@ class ContactRecordProcessor(
|
||||
pniSignatureVerified = remote.proto.pniSignatureVerified || local.proto.pniSignatureVerified
|
||||
note = remote.proto.note.nullIfBlank() ?: ""
|
||||
avatarColor = if (SignalStore.account.isPrimaryDevice) local.proto.avatarColor else remote.proto.avatarColor
|
||||
aciBinary = if (RemoteConfig.useBinaryId) local.proto.aciBinary.nullIfEmpty() ?: remote.proto.aciBinary else ByteString.EMPTY
|
||||
pniBinary = if (RemoteConfig.useBinaryId) mergedPni?.toByteStringWithoutPrefix() ?: byteArrayOf().toByteString() else ByteString.EMPTY
|
||||
aciBinary = local.proto.aciBinary.nullIfEmpty() ?: remote.proto.aciBinary
|
||||
pniBinary = mergedPni?.toByteStringWithoutPrefix() ?: byteArrayOf().toByteString()
|
||||
}.build().toSignalContactRecord(StorageId.forContact(keyGenerator.generate()))
|
||||
|
||||
val matchesRemote = doParamsMatch(remote, merged)
|
||||
|
||||
@@ -139,6 +139,8 @@ object StorageSyncHelper {
|
||||
|
||||
val storageId = selfRecord?.storageId ?: self.storageId
|
||||
|
||||
val releaseChannelRecord: RecipientRecord? = SignalStore.releaseChannel.releaseChannelRecipientId?.let { SignalDatabase.recipients.getRecordForSync(it) }
|
||||
|
||||
val accountRecord = SignalAccountRecord.newBuilder(selfRecord?.syncExtras?.storageProto).apply {
|
||||
profileKey = self.profileKey?.toByteString() ?: ByteString.EMPTY
|
||||
givenName = self.profileName.givenName
|
||||
@@ -197,6 +199,13 @@ object StorageSyncHelper {
|
||||
safeSetPayments(SignalStore.payments.mobileCoinPaymentsEnabled(), Optional.ofNullable(SignalStore.payments.paymentsEntropy).map { obj: Entropy -> obj.bytes }.orElse(null))
|
||||
automaticKeyVerificationDisabled = !SignalStore.settings.automaticVerificationEnabled
|
||||
hasSeenAdminDeleteEducationDialog = SignalStore.uiHints.hasSeenAdminDeleteEducationDialog()
|
||||
|
||||
if (releaseChannelRecord != null) {
|
||||
releaseNotesChatArchived = releaseChannelRecord.syncExtras.isArchived == true
|
||||
releaseNotesChatMutedUntilTimestamp = releaseChannelRecord.muteUntil
|
||||
releaseNotesChatBlocked = releaseChannelRecord.isBlocked == true
|
||||
releaseNotesChatMarkedUnread = releaseChannelRecord.syncExtras.isForcedUnread == true
|
||||
}
|
||||
}
|
||||
|
||||
return accountRecord.toSignalAccountRecord(StorageId.forAccount(storageId)).toSignalStorageRecord()
|
||||
@@ -308,6 +317,13 @@ object StorageSyncHelper {
|
||||
SignalStore.misc.usernameQrCodeColorScheme = StorageSyncModels.remoteToLocalUsernameColor(update.new.proto.usernameLink!!.color)
|
||||
}
|
||||
|
||||
SignalStore.releaseChannel.releaseChannelRecipientId?.let { releaseChannelId ->
|
||||
SignalDatabase.recipients.setBlocked(releaseChannelId, update.new.proto.releaseNotesChatBlocked)
|
||||
SignalDatabase.recipients.setMuted(releaseChannelId, update.new.proto.releaseNotesChatMutedUntilTimestamp)
|
||||
SignalDatabase.threads.applyStorageSyncReleaseChannelUpdate(releaseChannelId, update.new.proto.releaseNotesChatArchived, update.new.proto.releaseNotesChatMarkedUnread)
|
||||
Recipient.live(releaseChannelId).refresh()
|
||||
}
|
||||
|
||||
if (update.new.proto.notificationProfileManualOverride != null) {
|
||||
if (update.new.proto.notificationProfileManualOverride!!.enabled != null) {
|
||||
Log.i(TAG, "Found a remote enabled notification override")
|
||||
|
||||
@@ -9,7 +9,6 @@ import org.signal.core.util.isNullOrEmpty
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
@@ -28,10 +27,10 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.groups.BadGroupIdException
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
|
||||
import org.whispersystems.signalservice.api.storage.SignalCallLinkRecord
|
||||
@@ -123,20 +122,24 @@ object StorageSyncModels {
|
||||
|
||||
@JvmStatic
|
||||
fun localToRemotePinnedConversations(records: List<RecipientRecord>): List<AccountRecord.PinnedConversation> {
|
||||
val releaseChannelId = SignalStore.releaseChannel.releaseChannelRecipientId
|
||||
return records
|
||||
.filter { it.recipientType == RecipientType.GV1 || it.recipientType == RecipientType.GV2 || it.registered == RecipientTable.RegisteredState.REGISTERED }
|
||||
.map { localToRemotePinnedConversation(it) }
|
||||
.filter { it.recipientType == RecipientType.GV1 || it.recipientType == RecipientType.GV2 || it.registered == RecipientTable.RegisteredState.REGISTERED || it.id == releaseChannelId }
|
||||
.map { localToRemotePinnedConversation(it, releaseChannelId) }
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private fun localToRemotePinnedConversation(settings: RecipientRecord): AccountRecord.PinnedConversation {
|
||||
private fun localToRemotePinnedConversation(settings: RecipientRecord, releaseChannelId: RecipientId?): AccountRecord.PinnedConversation {
|
||||
if (settings.id == releaseChannelId) {
|
||||
return AccountRecord.PinnedConversation(releaseNotes = AccountRecord.PinnedConversation.ReleaseNotes())
|
||||
}
|
||||
return when (settings.recipientType) {
|
||||
RecipientType.INDIVIDUAL -> {
|
||||
AccountRecord.PinnedConversation(
|
||||
contact = AccountRecord.PinnedConversation.Contact(
|
||||
serviceId = settings.serviceId?.toString().takeIf { BuildConfig.USE_STRING_ID } ?: "",
|
||||
serviceId = "",
|
||||
e164 = settings.e164 ?: "",
|
||||
serviceIdBinary = settings.serviceId?.toByteString().takeIf { RemoteConfig.useBinaryId } ?: ByteString.EMPTY
|
||||
serviceIdBinary = settings.serviceId?.toByteString() ?: ByteString.EMPTY
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -189,9 +192,9 @@ object StorageSyncModels {
|
||||
}
|
||||
|
||||
return SignalContactRecord.newBuilder(recipient.syncExtras.storageProto).apply {
|
||||
aci = recipient.aci?.toString().takeIf { BuildConfig.USE_STRING_ID } ?: ""
|
||||
aci = ""
|
||||
e164 = recipient.e164 ?: ""
|
||||
pni = recipient.pni?.toStringWithoutPrefix().takeIf { BuildConfig.USE_STRING_ID } ?: ""
|
||||
pni = ""
|
||||
profileKey = recipient.profileKey?.toByteString() ?: ByteString.EMPTY
|
||||
givenName = recipient.signalProfileName.givenName
|
||||
familyName = recipient.signalProfileName.familyName
|
||||
@@ -213,8 +216,8 @@ object StorageSyncModels {
|
||||
nickname = recipient.nickname.takeUnless { it.isEmpty }?.let { ContactRecord.Name(given = it.givenName, family = it.familyName) }
|
||||
note = recipient.note ?: ""
|
||||
avatarColor = localToRemoteAvatarColor(recipient.avatarColor)
|
||||
aciBinary = recipient.aci?.toByteString()?.takeIf { RemoteConfig.useBinaryId } ?: ByteString.EMPTY
|
||||
pniBinary = recipient.pni?.toByteStringWithoutPrefix()?.takeIf { RemoteConfig.useBinaryId } ?: ByteString.EMPTY
|
||||
aciBinary = recipient.aci?.toByteString() ?: ByteString.EMPTY
|
||||
pniBinary = recipient.pni?.toByteStringWithoutPrefix() ?: ByteString.EMPTY
|
||||
}.build().toSignalContactRecord(StorageId.forContact(rawStorageId))
|
||||
}
|
||||
|
||||
@@ -299,22 +302,11 @@ object StorageSyncModels {
|
||||
return SignalStoryDistributionListRecord.newBuilder(recipient.syncExtras.storageProto).apply {
|
||||
identifier = UuidUtil.toByteArray(record.distributionId.asUuid()).toByteString()
|
||||
name = record.name
|
||||
recipientServiceIds = if (BuildConfig.USE_STRING_ID) {
|
||||
record.getMembersToSync()
|
||||
.map { Recipient.resolved(it) }
|
||||
.filter { it.hasServiceId }
|
||||
.map { it.requireServiceId().toString() }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
recipientServiceIdsBinary = if (RemoteConfig.useBinaryId) {
|
||||
record.getMembersToSync()
|
||||
.map { Recipient.resolved(it) }
|
||||
.filter { it.hasServiceId }
|
||||
.map { it.requireServiceId().toByteString() }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
recipientServiceIds = emptyList()
|
||||
recipientServiceIdsBinary = record.getMembersToSync()
|
||||
.map { Recipient.resolved(it) }
|
||||
.filter { it.hasServiceId }
|
||||
.map { it.requireServiceId().toByteString() }
|
||||
allowsReplies = record.allowsReplies
|
||||
isBlockList = record.privacyMode.isBlockList
|
||||
}.build().toSignalStoryDistributionListRecord(StorageId.forStoryDistributionList(rawStorageId))
|
||||
@@ -516,9 +508,9 @@ object StorageSyncModels {
|
||||
RecipientType.INDIVIDUAL -> {
|
||||
RemoteRecipient(
|
||||
contact = RemoteRecipient.Contact(
|
||||
serviceId = recipient.serviceId?.toString().takeIf { BuildConfig.USE_STRING_ID } ?: "",
|
||||
serviceId = "",
|
||||
e164 = recipient.e164 ?: "",
|
||||
serviceIdBinary = recipient.serviceId?.toByteString().takeIf { RemoteConfig.useBinaryId } ?: ByteString.EMPTY
|
||||
serviceIdBinary = recipient.serviceId?.toByteString() ?: ByteString.EMPTY
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+1
-3
@@ -8,7 +8,6 @@ import androidx.fragment.app.viewModels
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.components.WrapperDialogFragment
|
||||
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository
|
||||
@@ -47,8 +46,7 @@ class ViewAllSignalConnectionsFragment : Fragment(R.layout.view_all_signal_conne
|
||||
displayCheckBox = false,
|
||||
displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER
|
||||
),
|
||||
mapStateToConfiguration = { getConfiguration() },
|
||||
itemDecorations = listOf(LetterHeaderDecoration(requireContext()) { false })
|
||||
mapStateToConfiguration = { getConfiguration() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -16,8 +16,8 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchModels
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseGroupStoryBottomSheet
|
||||
@@ -68,7 +68,7 @@ class StoriesPrivacySettingsFragment :
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
ContactSearchAdapter.registerStoryItems(
|
||||
ContactSearchModels.registerStoryItems(
|
||||
mappingAdapter = middle as PagingMappingAdapter<ContactSearchKey>,
|
||||
storyListener = { _, story, _ ->
|
||||
when {
|
||||
@@ -142,7 +142,7 @@ class StoriesPrivacySettingsFragment :
|
||||
private fun getMiddleConfiguration(state: StoriesPrivacySettingsState): DSLConfiguration {
|
||||
return if (state.areStoriesEnabled) {
|
||||
configure {
|
||||
ContactSearchAdapter.toMappingModelList(
|
||||
ContactSearchModels.toMappingModelList(
|
||||
state.storyContactItems,
|
||||
emptySet(),
|
||||
null
|
||||
|
||||
@@ -73,7 +73,7 @@ object AttachmentUtil {
|
||||
val contentType = attachment.contentType
|
||||
|
||||
return when {
|
||||
MediaUtil.isLongTextType(contentType) -> true
|
||||
MediaUtil.isLongTextType(contentType) -> attachment.size <= MessageUtil.MAX_TOTAL_BODY_SIZE_BYTES
|
||||
attachment.isSticker -> ciphertextSize <= SMALL_ATTACHMENT_SIZE || allowedForType(allowedTypes, "image", "sticker")
|
||||
attachment.voiceNote -> ciphertextSize <= SMALL_ATTACHMENT_SIZE || allowedForType(allowedTypes, "audio", "voice message")
|
||||
attachment.videoGif -> allowedForType(allowedTypes, "image", "video gif")
|
||||
|
||||
@@ -369,6 +369,13 @@ object RemoteConfig {
|
||||
}
|
||||
}
|
||||
|
||||
private fun Any?.asDouble(defaultValue: Double): Double {
|
||||
return when (this) {
|
||||
is String -> this.toDoubleOrNull() ?: defaultValue
|
||||
else -> defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T : String?> Any?.asString(defaultValue: T): T {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return when (this) {
|
||||
@@ -486,6 +493,23 @@ object RemoteConfig {
|
||||
)
|
||||
}
|
||||
|
||||
private fun remoteDouble(
|
||||
key: String,
|
||||
defaultValue: Double,
|
||||
hotSwappable: Boolean,
|
||||
active: Boolean = true,
|
||||
onChangeListener: OnFlagChange? = null
|
||||
): Config<Double> {
|
||||
return remoteValue(
|
||||
key = key,
|
||||
hotSwappable = hotSwappable,
|
||||
sticky = false,
|
||||
active = active,
|
||||
onChangeListener = onChangeListener,
|
||||
transformer = { it.asDouble(defaultValue) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun remoteDuration(
|
||||
key: String,
|
||||
defaultValue: Duration,
|
||||
@@ -650,6 +674,15 @@ object RemoteConfig {
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
/** Whether to surface a warning dialog when debug log prefix generation exceeds a threshold. */
|
||||
@JvmStatic
|
||||
@get:JvmName("showSlowDebugLogWarning")
|
||||
val showSlowDebugLogWarning: Boolean by remoteBoolean(
|
||||
key = "android.showSlowDebugLogWarning",
|
||||
defaultValue = false,
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
/** How often we allow an automatic session reset. */
|
||||
@JvmStatic
|
||||
@get:JvmName("automaticSessionResetIntervalSeconds")
|
||||
@@ -1184,15 +1217,6 @@ object RemoteConfig {
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
/** Whether or not to send over binary service ids (alongside string service ids). */
|
||||
@JvmStatic
|
||||
@get:JvmName("useBinaryId")
|
||||
val useBinaryId: Boolean by remoteBoolean(
|
||||
key = "android.useBinaryServiceId.2",
|
||||
defaultValue = true,
|
||||
hotSwappable = false
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
@get:JvmName("backupsMegaphone")
|
||||
val backupsMegaphone: Boolean by remoteBoolean(
|
||||
@@ -1387,5 +1411,27 @@ object RemoteConfig {
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
/** Seconds after registration during which change-number is blocked. */
|
||||
@JvmStatic
|
||||
@get:JvmName("changeNumberPostRegistrationWaitingPeriodSeconds")
|
||||
val changeNumberPostRegistrationWaitingPeriodSeconds: Long by remoteLong(
|
||||
key = "global.changeNumber.postRegistrationWaitingPeriodSeconds",
|
||||
defaultValue = 3600,
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
/**
|
||||
* A ratio between 0 and 1, where 0 means that a session is never archived due
|
||||
* to a lack of PQ, and 1 means that a session is always archived due to a
|
||||
* lack of PQ.
|
||||
*/
|
||||
@JvmStatic
|
||||
@get:JvmName("requirePqRatio")
|
||||
val requirePqRatio: Double by remoteDouble(
|
||||
key = "android.requirePqRatio",
|
||||
defaultValue = 0.0,
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ public final class SignalLocalMetrics {
|
||||
private static final String SPLIT_JOB_PRE_NETWORK = "job-pre-network";
|
||||
private static final String SPLIT_ENCRYPT = "encrypt";
|
||||
private static final String SPLIT_NETWORK_MAIN = "network-main";
|
||||
private static final String SPLIT_SYNC_ENCRYPT = "sync-encrypt";
|
||||
private static final String SPLIT_NETWORK_SYNC = "network-sync";
|
||||
private static final String SPLIT_JOB_POST_NETWORK = "job-post-network";
|
||||
private static final String SPLIT_UI_UPDATE = "ui-update";
|
||||
@@ -173,6 +174,10 @@ public final class SignalLocalMetrics {
|
||||
split(messageId, SPLIT_NETWORK_MAIN);
|
||||
}
|
||||
|
||||
public static void onSyncMessageEncrypted(long messageId) {
|
||||
split(messageId, SPLIT_SYNC_ENCRYPT);
|
||||
}
|
||||
|
||||
public static void onSyncMessageSent(long messageId) {
|
||||
split(messageId, SPLIT_NETWORK_SYNC);
|
||||
}
|
||||
@@ -318,9 +323,11 @@ public final class SignalLocalMetrics {
|
||||
private static final String SPLIT_SENDER_KEY_SHARED = "sk-shared";
|
||||
private static final String SPLIT_ENCRYPTION = "encryption";
|
||||
private static final String SPLIT_NETWORK_SENDER_KEY = "network-sk";
|
||||
private static final String SPLIT_SENDER_KEY_SYNC_ENCRYPT = "sk-sync-encrypt";
|
||||
private static final String SPLIT_NETWORK_SENDER_KEY_SYNC = "network-sk-sync";
|
||||
private static final String SPLIT_MSL_SENDER_KEY = "msl-sk";
|
||||
private static final String SPLIT_NETWORK_LEGACY = "network-legacy";
|
||||
private static final String SPLIT_LEGACY_SYNC_ENCRYPT = "legacy-sync-encrypt";
|
||||
private static final String SPLIT_NETWORK_LEGACY_SYNC = "network-legacy-sync";
|
||||
private static final String SPLIT_JOB_POST_NETWORK = "job-post-network";
|
||||
private static final String SPLIT_UI_UPDATE = "ui-update";
|
||||
@@ -367,6 +374,10 @@ public final class SignalLocalMetrics {
|
||||
split(messageId, SPLIT_NETWORK_SENDER_KEY);
|
||||
}
|
||||
|
||||
public static void onSenderKeySyncEncrypted(long messageId) {
|
||||
split(messageId, SPLIT_SENDER_KEY_SYNC_ENCRYPT);
|
||||
}
|
||||
|
||||
public static void onSenderKeySyncSent(long messageId) {
|
||||
split(messageId, SPLIT_NETWORK_SENDER_KEY_SYNC);
|
||||
}
|
||||
@@ -379,6 +390,10 @@ public final class SignalLocalMetrics {
|
||||
split(messageId, SPLIT_NETWORK_LEGACY);
|
||||
}
|
||||
|
||||
public static void onLegacySyncEncrypted(long messageId) {
|
||||
split(messageId, SPLIT_LEGACY_SYNC_ENCRYPT);
|
||||
}
|
||||
|
||||
public static void onLegacySyncFinished(long messageId) {
|
||||
split(messageId, SPLIT_NETWORK_LEGACY_SYNC);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user