mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-09 03:39:07 +01:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9359d56880 | ||
|
|
3214200188 | ||
|
|
841ab7f983 | ||
|
|
53b3728432 | ||
|
|
cf9f98efc9 | ||
|
|
b5c666a1f4 | ||
|
|
b1954a509c | ||
|
|
c2c91cfe42 | ||
|
|
cccbec5744 | ||
|
|
4c89b20fad | ||
|
|
2328fa3e88 | ||
|
|
e19d4624c1 | ||
|
|
345f58ed48 | ||
|
|
4c14ce3937 | ||
|
|
82684c0169 | ||
|
|
2607328255 | ||
|
|
484ce3a1da | ||
|
|
85d5f62301 | ||
|
|
b0571f8184 | ||
|
|
b80dd28b40 | ||
|
|
e0cf0808cf | ||
|
|
ffdd5b62ae | ||
|
|
3b5376ef8b | ||
|
|
cd57fb0d76 | ||
|
|
6986acd6f4 | ||
|
|
2bc571ffd3 | ||
|
|
a8dddf33f8 | ||
|
|
46582a685b | ||
|
|
ad381783f7 | ||
|
|
b81c1eb65c | ||
|
|
2c4d3b3ee4 | ||
|
|
d1400928ce | ||
|
|
49abece92b | ||
|
|
b48b1f031e | ||
|
|
9cefe0bc04 | ||
|
|
ee73b0e229 | ||
|
|
ab0ce58812 | ||
|
|
333a206d36 | ||
|
|
86bb7666ea | ||
|
|
58b5ebf39d | ||
|
|
47947b85c7 | ||
|
|
6910ba6d2e | ||
|
|
08254edae6 | ||
|
|
e67307a961 | ||
|
|
9922621945 | ||
|
|
c7476a2a07 | ||
|
|
ac59528f5c | ||
|
|
97c9728c65 | ||
|
|
80d1694e6e | ||
|
|
28c6e31c7d | ||
|
|
8836b2a570 | ||
|
|
786c2b888b | ||
|
|
c91275c5da | ||
|
|
7b362460e7 | ||
|
|
a1862c3420 | ||
|
|
44ea9ccc59 | ||
|
|
4c9cdf3b8f | ||
|
|
4a6d4f197d | ||
|
|
ae04749336 | ||
|
|
caa743aba2 | ||
|
|
a4469a4285 | ||
|
|
2771b31aab | ||
|
|
5c418a4260 |
@@ -1,17 +1,7 @@
|
||||
@file:Suppress("UnstableApiUsage")
|
||||
|
||||
import com.android.build.api.dsl.ManagedVirtualDevice
|
||||
import org.gradle.api.file.DirectoryProperty
|
||||
import org.gradle.api.file.RegularFileProperty
|
||||
import org.gradle.api.provider.ValueSource
|
||||
import org.gradle.api.provider.ValueSourceParameters
|
||||
import org.gradle.api.tasks.InputDirectory
|
||||
import org.gradle.api.tasks.InputFile
|
||||
import org.gradle.api.tasks.Optional
|
||||
import org.gradle.api.tasks.PathSensitive
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
|
||||
import java.io.File
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
@@ -30,8 +20,8 @@ plugins {
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
|
||||
val canonicalVersionCode = 1651
|
||||
val canonicalVersionName = "7.74.3"
|
||||
val canonicalVersionCode = 1655
|
||||
val canonicalVersionName = "8.0.3"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
@@ -50,6 +40,14 @@ val languagesForBuildConfigProvider = languagesProvider.map { languages ->
|
||||
languages.joinToString(separator = ", ") { language -> "\"$language\"" }
|
||||
}
|
||||
|
||||
val localPropertiesFile = File(rootProject.projectDir, "local.properties")
|
||||
val localProperties: Properties? = if (localPropertiesFile.exists()) {
|
||||
Properties().apply { localPropertiesFile.inputStream().use { load(it) } }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val quickstartCredentialsDir: String? = localProperties?.getProperty("quickstart.credentials.dir")
|
||||
|
||||
val selectableVariants = listOf(
|
||||
"nightlyBackupRelease",
|
||||
"nightlyBackupSpinner",
|
||||
@@ -72,6 +70,8 @@ val selectableVariants = listOf(
|
||||
"playStagingPerf",
|
||||
"playStagingInstrumentation",
|
||||
"playStagingRelease",
|
||||
"playProdQuickstart",
|
||||
"playStagingQuickstart",
|
||||
"websiteProdSpinner",
|
||||
"websiteProdRelease",
|
||||
"githubProdSpinner",
|
||||
@@ -259,7 +259,7 @@ 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", "true")
|
||||
buildConfigField("boolean", "USE_STRING_ID", "false")
|
||||
|
||||
ndk {
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
@@ -358,8 +358,13 @@ android {
|
||||
isDebuggable = false
|
||||
isMinifyEnabled = true
|
||||
matchingFallbacks += "debug"
|
||||
applicationIdSuffix = ".benchmark"
|
||||
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Benchmark\"")
|
||||
buildConfigField("boolean", "TRACING_ENABLED", "true")
|
||||
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{ \"BVT/2gHqbrG1xzuIypLIOjFgMtihrMld1/5TGADL6Dhv\"}")
|
||||
|
||||
manifestPlaceholders["applicationClass"] = "org.thoughtcrime.securesms.BenchmarkApplicationContext"
|
||||
}
|
||||
|
||||
create("mocked") {
|
||||
@@ -370,6 +375,8 @@ android {
|
||||
matchingFallbacks += "debug"
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Benchmark\"")
|
||||
buildConfigField("boolean", "TRACING_ENABLED", "true")
|
||||
|
||||
manifestPlaceholders["applicationClass"] = "org.thoughtcrime.securesms.ApplicationContext"
|
||||
}
|
||||
|
||||
create("canary") {
|
||||
@@ -379,6 +386,14 @@ android {
|
||||
matchingFallbacks += "debug"
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Canary\"")
|
||||
}
|
||||
|
||||
create("quickstart") {
|
||||
initWith(getByName("debug"))
|
||||
isDefault = false
|
||||
isMinifyEnabled = false
|
||||
matchingFallbacks += "debug"
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Quickstart\"")
|
||||
}
|
||||
}
|
||||
|
||||
productFlavors {
|
||||
@@ -445,7 +460,6 @@ android {
|
||||
buildConfigField("String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\"")
|
||||
buildConfigField("org.signal.libsignal.net.Network.Environment", "LIBSIGNAL_NET_ENV", "org.signal.libsignal.net.Network.Environment.STAGING")
|
||||
buildConfigField("int", "LIBSIGNAL_LOG_LEVEL", "org.signal.libsignal.protocol.logging.SignalProtocolLogger.DEBUG")
|
||||
buildConfigField("boolean", "USE_STRING_ID", "false")
|
||||
|
||||
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\"")
|
||||
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\"")
|
||||
@@ -508,6 +522,24 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onVariants(selector().withBuildType("quickstart")) { variant ->
|
||||
val environment = variant.flavorName?.let { name ->
|
||||
when {
|
||||
name.contains("staging", ignoreCase = true) -> "staging"
|
||||
name.contains("prod", ignoreCase = true) -> "prod"
|
||||
else -> "prod"
|
||||
}
|
||||
} ?: "prod"
|
||||
|
||||
val taskProvider = tasks.register<CopyQuickstartCredentialsTask>("copyQuickstartCredentials${variant.name.capitalize()}") {
|
||||
if (quickstartCredentialsDir != null) {
|
||||
inputDir.set(File(quickstartCredentialsDir))
|
||||
}
|
||||
filePrefix.set("${environment}_")
|
||||
}
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
|
||||
}
|
||||
}
|
||||
|
||||
val releaseDir = "$projectDir/src/release/java"
|
||||
@@ -578,6 +610,7 @@ dependencies {
|
||||
implementation(project(":core:models"))
|
||||
implementation(project(":core:models-jvm"))
|
||||
implementation(project(":feature:camera"))
|
||||
implementation(project(":feature:registration"))
|
||||
|
||||
implementation(libs.androidx.fragment.ktx)
|
||||
implementation(libs.androidx.appcompat) {
|
||||
@@ -832,3 +865,38 @@ abstract class PropertiesFileValueSource : ValueSource<Properties?, PropertiesFi
|
||||
fun String.capitalize(): String {
|
||||
return this.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
abstract class CopyQuickstartCredentialsTask : DefaultTask() {
|
||||
@get:InputDirectory
|
||||
@get:Optional
|
||||
abstract val inputDir: DirectoryProperty
|
||||
|
||||
@get:Input
|
||||
abstract val filePrefix: Property<String>
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val outputDir: DirectoryProperty
|
||||
|
||||
@TaskAction
|
||||
fun copy() {
|
||||
if (!inputDir.isPresent) {
|
||||
throw GradleException("quickstart.credentials.dir is not set in local.properties. This is required for quickstart builds.")
|
||||
}
|
||||
|
||||
val prefix = filePrefix.get()
|
||||
val candidates = inputDir.get().asFile.listFiles()
|
||||
?.filter { it.extension == "json" && it.name.startsWith(prefix) }
|
||||
?: emptyList()
|
||||
|
||||
if (candidates.isEmpty()) {
|
||||
throw GradleException("No credential files matching '$prefix*.json' found in ${inputDir.get().asFile}. Add files like '${prefix}account1.json' to your credentials directory.")
|
||||
}
|
||||
|
||||
val chosen = candidates.random()
|
||||
logger.lifecycle("Selected quickstart credential: ${chosen.name}")
|
||||
|
||||
val dest = outputDir.get().asFile.resolve("quickstart")
|
||||
dest.mkdirs()
|
||||
chosen.copyTo(dest.resolve(chosen.name), overwrite = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import com.bumptech.glide.RequestManager
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.signal.ringrtc.CallLinkEpoch
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState
|
||||
import org.thoughtcrime.securesms.contactshare.Contact
|
||||
@@ -329,7 +328,7 @@ class V2ConversationItemShapeTest {
|
||||
|
||||
override fun onShowGroupDescriptionClicked(groupName: String, description: String, shouldLinkifyWebLinks: Boolean) = Unit
|
||||
|
||||
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey, callLinkEpoch: CallLinkEpoch?) = Unit
|
||||
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey) = Unit
|
||||
|
||||
override fun onItemClick(item: MultiselectPart?) = Unit
|
||||
|
||||
|
||||
@@ -81,8 +81,7 @@ class CallLinkTableTest {
|
||||
roomId = CallLinkRoomId.fromBytes(roomId),
|
||||
credentials = CallLinkCredentials(
|
||||
linkKeyBytes = roomId,
|
||||
adminPassBytes = null,
|
||||
epochBytes = null
|
||||
adminPassBytes = null
|
||||
),
|
||||
state = SignalCallLinkState(),
|
||||
deletionTimestamp = 0L
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import android.app.Application
|
||||
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.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.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
|
||||
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
|
||||
import java.util.function.Supplier
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class BenchmarkApplicationContext : ApplicationContext() {
|
||||
|
||||
override fun initializeAppDependencies() {
|
||||
AppDependencies.init(this, BenchmarkDependencyProvider(this, ApplicationDependencyProvider(this)))
|
||||
|
||||
DeviceTransferBlockingInterceptor.getInstance().blockNetwork()
|
||||
}
|
||||
|
||||
override fun onForeground() = Unit
|
||||
|
||||
class BenchmarkDependencyProvider(val application: Application, private val default: ApplicationDependencyProvider) : AppDependencies.Provider by 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(): 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
|
||||
)
|
||||
)
|
||||
.build()
|
||||
return JobManager(application, config)
|
||||
}
|
||||
|
||||
private fun filterJobFactories(jobFactories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> {
|
||||
val blockedJobs = setOf(
|
||||
AccountConsistencyWorkerJob.KEY,
|
||||
ArchiveBackupIdReservationJob.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,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application>
|
||||
<application
|
||||
android:name="${applicationClass}"
|
||||
tools:replace="name">
|
||||
<profileable android:shell="true" />
|
||||
|
||||
<activity android:name="org.signal.benchmark.BenchmarkSetupActivity"
|
||||
<activity
|
||||
android:name="org.signal.benchmark.BenchmarkSetupActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:exported="true"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
android:windowSoftInputMode="stateHidden" />
|
||||
|
||||
<receiver
|
||||
android:name="org.signal.benchmark.BenchmarkCommandReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="org.signal.benchmark.action.COMMAND" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.benchmark
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.signal.benchmark.setup.Generator
|
||||
import org.signal.benchmark.setup.Harness
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* A BroadcastReceiver that accepts commands sent from the benchmark app to perform
|
||||
* background operations on the client.
|
||||
*/
|
||||
class BenchmarkCommandReceiver : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(BenchmarkCommandReceiver::class)
|
||||
|
||||
const val ACTION_COMMAND = "org.signal.benchmark.action.COMMAND"
|
||||
const val EXTRA_COMMAND = "command"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != ACTION_COMMAND) {
|
||||
Log.w(TAG, "Ignoring unknown action: ${intent.action}")
|
||||
return
|
||||
}
|
||||
|
||||
val command = intent.getStringExtra(EXTRA_COMMAND)
|
||||
Log.i(TAG, "Received command: $command")
|
||||
|
||||
when (command) {
|
||||
"individual-send" -> handlePrepareIndividualSend()
|
||||
"group-send" -> handlePrepareGroupSend()
|
||||
"release-messages" -> {
|
||||
BenchmarkWebSocketConnection.authInstance.startWholeBatchTrace = true
|
||||
BenchmarkWebSocketConnection.authInstance.releaseMessages()
|
||||
}
|
||||
else -> Log.w(TAG, "Unknown command: $command")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePrepareIndividualSend() {
|
||||
val client = Harness.otherClients[0]
|
||||
|
||||
// Send message from Bob to Self
|
||||
val encryptedEnvelope = client.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis()))
|
||||
|
||||
runBlocking {
|
||||
launch(Dispatchers.IO) {
|
||||
BenchmarkWebSocketConnection.authInstance.run {
|
||||
Log.i(TAG, "Sending initial message form Bob to establish session.")
|
||||
addPendingMessages(listOf(encryptedEnvelope.toWebSocketPayload()))
|
||||
releaseMessages()
|
||||
|
||||
// Sleep briefly to let the message be processed.
|
||||
ThreadUtil.sleep(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Have Bob generate N messages that will be received by Alice
|
||||
val messageCount = 100
|
||||
val envelopes = client.generateInboundEnvelopes(messageCount)
|
||||
|
||||
val messages = envelopes.map { e -> e.toWebSocketPayload() }
|
||||
|
||||
BenchmarkWebSocketConnection.authInstance.addPendingMessages(messages)
|
||||
BenchmarkWebSocketConnection.authInstance.addQueueEmptyMessage()
|
||||
}
|
||||
|
||||
private fun handlePrepareGroupSend() {
|
||||
val clients = Harness.otherClients.take(5)
|
||||
|
||||
// Send message from others to Self in the group
|
||||
val encryptedEnvelopes = clients.map { it.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis(), groupMasterKey = Harness.groupMasterKey)) }
|
||||
|
||||
runBlocking {
|
||||
launch(Dispatchers.IO) {
|
||||
BenchmarkWebSocketConnection.authInstance.run {
|
||||
Log.i(TAG, "Sending initial group messages from client to establish sessions.")
|
||||
addPendingMessages(encryptedEnvelopes.map { it.toWebSocketPayload() })
|
||||
releaseMessages()
|
||||
|
||||
// Sleep briefly to let the messages be processed.
|
||||
ThreadUtil.sleep(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Have clients generate N group messages that will be received by Alice
|
||||
clients.forEach { client ->
|
||||
val messageCount = 100
|
||||
val envelopes = client.generateInboundGroupEnvelopes(messageCount, Harness.groupMasterKey)
|
||||
|
||||
val messages = envelopes.map { e -> e.toWebSocketPayload() }
|
||||
|
||||
BenchmarkWebSocketConnection.authInstance.addPendingMessages(messages)
|
||||
}
|
||||
BenchmarkWebSocketConnection.authInstance.addQueueEmptyMessage()
|
||||
}
|
||||
|
||||
private fun Envelope.toWebSocketPayload(): WebSocketRequestMessage {
|
||||
return WebSocketRequestMessage(
|
||||
verb = "PUT",
|
||||
path = "/api/v1/message",
|
||||
id = Random.nextLong(),
|
||||
headers = listOf("X-Signal-Timestamp: ${this.timestamp}"),
|
||||
body = this.encodeByteString()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ class BenchmarkSetupActivity : BaseActivity() {
|
||||
when (intent.extras!!.getString("setup-type")) {
|
||||
"cold-start" -> setupColdStart()
|
||||
"conversation-open" -> setupConversationOpen()
|
||||
"message-send" -> setupMessageSend()
|
||||
"group-message-send" -> setupGroupMessageSend()
|
||||
}
|
||||
|
||||
val textView: TextView = TextView(this).apply {
|
||||
@@ -56,4 +58,14 @@ class BenchmarkSetupActivity : BaseActivity() {
|
||||
SignalDatabase.threads.update(SignalDatabase.threads.getOrCreateThreadIdFor(recipient = recipient), true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupMessageSend() {
|
||||
TestUsers.setupSelf()
|
||||
TestUsers.setupTestClients(1)
|
||||
}
|
||||
|
||||
private fun setupGroupMessageSend() {
|
||||
TestUsers.setupSelf()
|
||||
TestUsers.setupGroup()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.benchmark.setup
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.toByteArray
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
|
||||
import org.whispersystems.signalservice.api.crypto.ContentHint
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.push.GroupContextV2
|
||||
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
object Generator {
|
||||
|
||||
fun encryptedTextMessage(
|
||||
now: Long,
|
||||
message: String = "Test message",
|
||||
groupMasterKey: GroupMasterKey? = null
|
||||
): EnvelopeContent {
|
||||
val content = Content.Builder().apply {
|
||||
dataMessage(
|
||||
DataMessage.Builder().buildWith {
|
||||
body = message
|
||||
timestamp = now
|
||||
if (groupMasterKey != null) {
|
||||
groupV2 = GroupContextV2.Builder().buildWith {
|
||||
masterKey = groupMasterKey.serialize().toByteString()
|
||||
revision = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
return EnvelopeContent.encrypted(content.build(), ContentHint.RESENDABLE, Optional.empty())
|
||||
}
|
||||
|
||||
fun OutgoingPushMessage.toEnvelope(timestamp: Long, destination: ServiceId): Envelope {
|
||||
val serverGuid = UUID.randomUUID()
|
||||
return Envelope.Builder()
|
||||
.type(Envelope.Type.fromValue(this.type))
|
||||
.sourceDevice(1)
|
||||
.timestamp(timestamp)
|
||||
.serverTimestamp(timestamp + 1)
|
||||
.destinationServiceId(destination.toString())
|
||||
.destinationServiceIdBinary(destination.toByteString())
|
||||
.serverGuid(serverGuid.toString())
|
||||
.serverGuidBinary(serverGuid.toByteArray().toByteString())
|
||||
.content(Base64.decode(this.content).toByteString())
|
||||
.urgent(true)
|
||||
.story(false)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.benchmark.setup
|
||||
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.UuidUtil
|
||||
import org.signal.libsignal.metadata.certificate.SenderCertificate
|
||||
import org.signal.libsignal.metadata.certificate.ServerCertificate
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.ECPrivateKey
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration
|
||||
|
||||
object Harness {
|
||||
const val SELF_E164 = "+15555559999"
|
||||
val SELF_ACI = ACI.from(UuidUtil.parseOrThrow("d81b9a54-0ec9-43aa-a73f-7e99280ad53e"))
|
||||
|
||||
private val OTHERS_IDENTITY_KEY = IdentityKeyPair(Base64.decode("CiEFbAw403SCGPB+tjqfk+jrH7r9ma1P2hcujqydHRYVzzISIGiWYdWYBBdBzDdF06wgEm+HKcc6ETuWB7Jnvk7Wjw1u"))
|
||||
private val OTHERS_PROFILE_KEY = ProfileKey(Base64.decode("aJJ/A7GBCSnU9HJ1DdMWcKMMeXQKRUguTlAbtlfo/ik"))
|
||||
|
||||
val groupMasterKey = GroupMasterKey(Hex.fromStringCondensed("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))
|
||||
|
||||
val trustRoot = ECKeyPair(
|
||||
ECPublicKey(Base64.decode("BVT/2gHqbrG1xzuIypLIOjFgMtihrMld1/5TGADL6Dhv")),
|
||||
ECPrivateKey(Base64.decode("2B1zU7JQdPol/XWiom4pQXrSrHFeO8jzZ1u7wfrtY3o"))
|
||||
)
|
||||
|
||||
val otherClients: List<OtherClient> by lazy {
|
||||
val random = Random(4242)
|
||||
buildList {
|
||||
(0 until 1000).forEach { i ->
|
||||
val aci = ACI.from(UUID(random.nextLong(), random.nextLong()))
|
||||
val e164 = "+1555555%04d".format(i)
|
||||
val identityKey = OTHERS_IDENTITY_KEY
|
||||
val profileKey = OTHERS_PROFILE_KEY
|
||||
|
||||
add(OtherClient(aci, e164, identityKey, profileKey))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createCertificateFor(uuid: UUID, e164: String?, deviceId: Int, identityKey: ECPublicKey, expires: Duration): SenderCertificate {
|
||||
val serverKey: ECKeyPair = ECKeyPair.generate()
|
||||
val serverCertificate = ServerCertificate(trustRoot.privateKey, 1, serverKey.publicKey)
|
||||
return serverCertificate.issue(serverKey.privateKey, uuid.toString(), Optional.ofNullable(e164), deviceId, identityKey, expires.inWholeMilliseconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.benchmark.setup
|
||||
|
||||
import org.signal.benchmark.setup.Generator.toEnvelope
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.SessionBuilder
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
import org.signal.libsignal.protocol.groups.state.SenderKeyRecord
|
||||
import org.signal.libsignal.protocol.state.IdentityKeyStore
|
||||
import org.signal.libsignal.protocol.state.IdentityKeyStore.IdentityChange
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.PreKeyBundle
|
||||
import org.signal.libsignal.protocol.state.PreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.SessionRecord
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||
import org.signal.libsignal.protocol.util.KeyHelper
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
|
||||
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
|
||||
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher
|
||||
import org.whispersystems.signalservice.api.crypto.SignalSessionBuilder
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* This is a "fake" client that can start a session with the running app's user, referred to as Alice in this
|
||||
* code.
|
||||
*/
|
||||
class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPair: IdentityKeyPair, val profileKey: ProfileKey) {
|
||||
|
||||
private val serviceAddress = SignalServiceAddress(serviceId, e164)
|
||||
private val registrationId = KeyHelper.generateRegistrationId(false)
|
||||
private val aciStore = BobSignalServiceAccountDataStore(registrationId, identityKeyPair)
|
||||
private val senderCertificate = Harness.createCertificateFor(serviceId.rawUuid, e164, 1, identityKeyPair.publicKey.publicKey, System.currentTimeMillis().milliseconds + 30.days)
|
||||
private val sessionLock = object : SignalSessionLock {
|
||||
private val lock = ReentrantLock()
|
||||
|
||||
override fun acquire(): SignalSessionLock.Lock {
|
||||
lock.lock()
|
||||
return SignalSessionLock.Lock { lock.unlock() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Inspired by SignalServiceMessageSender#getEncryptedMessage */
|
||||
fun encrypt(envelopeContent: EnvelopeContent): Envelope {
|
||||
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, null)
|
||||
|
||||
if (!aciStore.containsSession(getAliceProtocolAddress())) {
|
||||
val sessionBuilder = SignalSessionBuilder(sessionLock, SessionBuilder(aciStore, getAliceProtocolAddress()))
|
||||
sessionBuilder.process(getAlicePreKeyBundle())
|
||||
}
|
||||
|
||||
return cipher.encrypt(getAliceProtocolAddress(), getAliceUnidentifiedAccess(), envelopeContent)
|
||||
.toEnvelope(envelopeContent.content.get().dataMessage!!.timestamp!!, getAliceServiceId())
|
||||
}
|
||||
|
||||
fun generateInboundEnvelopes(count: Int): List<Envelope> {
|
||||
val envelopes = ArrayList<Envelope>(count)
|
||||
var now = System.currentTimeMillis()
|
||||
for (i in 0 until count) {
|
||||
envelopes += encrypt(Generator.encryptedTextMessage(now))
|
||||
now += 3
|
||||
}
|
||||
|
||||
return envelopes
|
||||
}
|
||||
|
||||
fun generateInboundGroupEnvelopes(count: Int, groupMasterKey: GroupMasterKey): List<Envelope> {
|
||||
val envelopes = ArrayList<Envelope>(count)
|
||||
var now = System.currentTimeMillis()
|
||||
for (i in 0 until count) {
|
||||
envelopes += encrypt(Generator.encryptedTextMessage(now, groupMasterKey = groupMasterKey))
|
||||
now += 3
|
||||
}
|
||||
|
||||
return envelopes
|
||||
}
|
||||
|
||||
private fun getAliceServiceId(): ServiceId {
|
||||
return SignalStore.account.requireAci()
|
||||
}
|
||||
|
||||
private fun getAlicePreKeyBundle(): PreKeyBundle {
|
||||
val aliceSignedPreKeyRecord = SignalDatabase.signedPreKeys.getAll(getAliceServiceId()).first()
|
||||
|
||||
val aliceSignedKyberPreKeyRecord = SignalDatabase.kyberPreKeys.getAllLastResort(getAliceServiceId()).first().record
|
||||
|
||||
return PreKeyBundle(
|
||||
registrationId = SignalStore.account.registrationId,
|
||||
deviceId = 1,
|
||||
preKeyId = PreKeyBundle.NULL_PRE_KEY_ID,
|
||||
preKeyPublic = null,
|
||||
signedPreKeyId = aliceSignedPreKeyRecord.id,
|
||||
signedPreKeyPublic = aliceSignedPreKeyRecord.keyPair.publicKey,
|
||||
signedPreKeySignature = aliceSignedPreKeyRecord.signature,
|
||||
identityKey = getAlicePublicKey(),
|
||||
kyberPreKeyId = aliceSignedKyberPreKeyRecord.id,
|
||||
kyberPreKeyPublic = aliceSignedKyberPreKeyRecord.keyPair.publicKey,
|
||||
kyberPreKeySignature = aliceSignedKyberPreKeyRecord.signature
|
||||
)
|
||||
}
|
||||
|
||||
private fun getAliceProtocolAddress(): SignalProtocolAddress {
|
||||
return SignalProtocolAddress(SignalStore.account.requireAci().toString(), 1)
|
||||
}
|
||||
|
||||
private fun getAlicePublicKey(): IdentityKey {
|
||||
return SignalStore.account.aciIdentityKey.publicKey
|
||||
}
|
||||
|
||||
private fun getAliceProfileKey(): ProfileKey {
|
||||
return ProfileKeyUtil.getSelfProfileKey()
|
||||
}
|
||||
|
||||
private fun getAliceUnidentifiedAccess(): SealedSenderAccess? {
|
||||
val theirProfileKey = getAliceProfileKey()
|
||||
val themUnidentifiedAccessKey = UnidentifiedAccess(UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey), senderCertificate.serialized, false)
|
||||
|
||||
return SealedSenderAccess.forIndividual(themUnidentifiedAccessKey)
|
||||
}
|
||||
|
||||
private class BobSignalServiceAccountDataStore(private val registrationId: Int, private val identityKeyPair: IdentityKeyPair) : SignalServiceAccountDataStore {
|
||||
private var aliceSessionRecord: SessionRecord? = null
|
||||
|
||||
override fun getIdentityKeyPair(): IdentityKeyPair = identityKeyPair
|
||||
|
||||
override fun getLocalRegistrationId(): Int = registrationId
|
||||
override fun isTrustedIdentity(address: SignalProtocolAddress?, identityKey: IdentityKey?, direction: IdentityKeyStore.Direction?): Boolean = true
|
||||
override fun loadSession(address: SignalProtocolAddress?): SessionRecord = aliceSessionRecord ?: SessionRecord()
|
||||
override fun saveIdentity(address: SignalProtocolAddress?, identityKey: IdentityKey?): IdentityChange = IdentityChange.NEW_OR_UNCHANGED
|
||||
override fun storeSession(address: SignalProtocolAddress?, record: SessionRecord?) {
|
||||
aliceSessionRecord = record
|
||||
}
|
||||
override fun getSubDeviceSessions(name: String?): List<Int> = emptyList()
|
||||
override fun containsSession(address: SignalProtocolAddress?): Boolean = aliceSessionRecord != null
|
||||
override fun getIdentity(address: SignalProtocolAddress?): IdentityKey = SignalStore.account.aciIdentityKey.publicKey
|
||||
override fun loadPreKey(preKeyId: Int): PreKeyRecord = throw UnsupportedOperationException()
|
||||
override fun storePreKey(preKeyId: Int, record: PreKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun containsPreKey(preKeyId: Int): Boolean = throw UnsupportedOperationException()
|
||||
override fun removePreKey(preKeyId: Int) = throw UnsupportedOperationException()
|
||||
override fun loadExistingSessions(addresses: MutableList<SignalProtocolAddress>?): MutableList<SessionRecord> = throw UnsupportedOperationException()
|
||||
override fun deleteSession(address: SignalProtocolAddress?) = throw UnsupportedOperationException()
|
||||
override fun deleteAllSessions(name: String?) = throw UnsupportedOperationException()
|
||||
override fun loadSignedPreKey(signedPreKeyId: Int): SignedPreKeyRecord = throw UnsupportedOperationException()
|
||||
override fun loadSignedPreKeys(): MutableList<SignedPreKeyRecord> = throw UnsupportedOperationException()
|
||||
override fun storeSignedPreKey(signedPreKeyId: Int, record: SignedPreKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun containsSignedPreKey(signedPreKeyId: Int): Boolean = throw UnsupportedOperationException()
|
||||
override fun removeSignedPreKey(signedPreKeyId: Int) = throw UnsupportedOperationException()
|
||||
override fun loadKyberPreKey(kyberPreKeyId: Int): KyberPreKeyRecord = throw UnsupportedOperationException()
|
||||
override fun loadKyberPreKeys(): MutableList<KyberPreKeyRecord> = throw UnsupportedOperationException()
|
||||
override fun storeKyberPreKey(kyberPreKeyId: Int, record: KyberPreKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun containsKyberPreKey(kyberPreKeyId: Int): Boolean = throw UnsupportedOperationException()
|
||||
override fun markKyberPreKeyUsed(kyberPreKeyId: Int, signedPreKeyId: Int, baseKey: ECPublicKey) = throw UnsupportedOperationException()
|
||||
override fun deleteAllStaleOneTimeEcPreKeys(threshold: Long, minCount: Int) = throw UnsupportedOperationException()
|
||||
override fun markAllOneTimeEcPreKeysStaleIfNecessary(staleTime: Long) = throw UnsupportedOperationException()
|
||||
override fun storeSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?, record: SenderKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun loadSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?): SenderKeyRecord = throw UnsupportedOperationException()
|
||||
override fun archiveSession(address: SignalProtocolAddress?) = throw UnsupportedOperationException()
|
||||
override fun getAllAddressesWithActiveSessions(addressNames: MutableList<String>?): MutableMap<SignalProtocolAddress, SessionRecord> = throw UnsupportedOperationException()
|
||||
override fun getSenderKeySharedWith(distributionId: DistributionId?): MutableSet<SignalProtocolAddress> = throw UnsupportedOperationException()
|
||||
override fun markSenderKeySharedWith(distributionId: DistributionId?, addresses: MutableCollection<SignalProtocolAddress>?) = throw UnsupportedOperationException()
|
||||
override fun clearSenderKeySharedWith(addresses: MutableCollection<SignalProtocolAddress>?) = throw UnsupportedOperationException()
|
||||
override fun storeLastResortKyberPreKey(kyberPreKeyId: Int, kyberPreKeyRecord: KyberPreKeyRecord) = throw UnsupportedOperationException()
|
||||
override fun removeKyberPreKey(kyberPreKeyId: Int) = throw UnsupportedOperationException()
|
||||
override fun markAllOneTimeKyberPreKeysStaleIfNecessary(staleTime: Long) = throw UnsupportedOperationException()
|
||||
override fun deleteAllStaleOneTimeKyberPreKeys(threshold: Long, minCount: Int) = throw UnsupportedOperationException()
|
||||
override fun loadLastResortKyberPreKeys(): List<KyberPreKeyRecord> = throw UnsupportedOperationException()
|
||||
override fun isMultiDevice(): Boolean = throw UnsupportedOperationException()
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,24 @@ import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.preference.PreferenceManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okio.ByteString
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.util.Util
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.signal.storageservice.storage.protos.groups.AccessControl
|
||||
import org.signal.storageservice.storage.protos.groups.Member
|
||||
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup
|
||||
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember
|
||||
import org.signal.storageservice.storage.protos.groups.local.DecryptedTimer
|
||||
import org.signal.storageservice.storage.protos.groups.local.EnabledState
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
|
||||
import org.thoughtcrime.securesms.crypto.PreKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.CertificateType
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.Skipped
|
||||
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
|
||||
@@ -28,11 +37,12 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object TestUsers {
|
||||
|
||||
private var generatedOthers: Int = 0
|
||||
private val TEST_E164 = "+15555550101"
|
||||
private var generatedOthers: Int = 1
|
||||
|
||||
fun setupSelf(): Recipient {
|
||||
val application: Application = AppDependencies.application
|
||||
@@ -50,19 +60,19 @@ object TestUsers {
|
||||
runBlocking {
|
||||
val registrationData = RegistrationData(
|
||||
code = "123123",
|
||||
e164 = TEST_E164,
|
||||
e164 = Harness.SELF_E164,
|
||||
password = Util.getSecret(18),
|
||||
registrationId = RegistrationRepository.getRegistrationId(),
|
||||
profileKey = RegistrationRepository.getProfileKey(TEST_E164),
|
||||
profileKey = RegistrationRepository.getProfileKey(Harness.SELF_E164),
|
||||
fcmToken = null,
|
||||
pniRegistrationId = RegistrationRepository.getPniRegistrationId(),
|
||||
recoveryPassword = "asdfasdfasdfasdf"
|
||||
)
|
||||
val remoteResult = AccountRegistrationResult(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
uuid = Harness.SELF_ACI.toString(),
|
||||
pni = UUID.randomUUID().toString(),
|
||||
storageCapable = false,
|
||||
number = TEST_E164,
|
||||
number = Harness.SELF_E164,
|
||||
masterKey = null,
|
||||
pin = null,
|
||||
aciPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.aciPreKeys),
|
||||
@@ -78,6 +88,31 @@ object TestUsers {
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson"))
|
||||
TextSecurePreferences.setPromptedOptimizeDoze(application, true)
|
||||
TextSecurePreferences.setRatingEnabled(application, false)
|
||||
|
||||
PreKeyUtil.generateAndStoreSignedPreKey(AppDependencies.protocolStore.aci(), SignalStore.account.aciPreKeys)
|
||||
PreKeyUtil.generateAndStoreOneTimeEcPreKeys(AppDependencies.protocolStore.aci(), SignalStore.account.aciPreKeys)
|
||||
PreKeyUtil.generateAndStoreOneTimeKyberPreKeys(AppDependencies.protocolStore.aci(), SignalStore.account.aciPreKeys)
|
||||
|
||||
val aliceSenderCertificate = Harness.createCertificateFor(
|
||||
uuid = Harness.SELF_ACI.rawUuid,
|
||||
e164 = Harness.SELF_E164,
|
||||
deviceId = 1,
|
||||
identityKey = SignalStore.account.aciIdentityKey.publicKey.publicKey,
|
||||
expires = System.currentTimeMillis().milliseconds + 30.days
|
||||
)
|
||||
|
||||
val aliceSenderCertificate2 = Harness.createCertificateFor(
|
||||
uuid = Harness.SELF_ACI.rawUuid,
|
||||
e164 = null,
|
||||
deviceId = 1,
|
||||
identityKey = SignalStore.account.aciIdentityKey.publicKey.publicKey,
|
||||
expires = System.currentTimeMillis().milliseconds + 30.days
|
||||
)
|
||||
|
||||
SignalStore.certificate.setUnidentifiedAccessCertificate(CertificateType.ACI_AND_E164, aliceSenderCertificate.serialized)
|
||||
SignalStore.certificate.setUnidentifiedAccessCertificate(CertificateType.ACI_ONLY, aliceSenderCertificate2.serialized)
|
||||
|
||||
return Recipient.self()
|
||||
}
|
||||
|
||||
@@ -111,4 +146,71 @@ object TestUsers {
|
||||
|
||||
return others
|
||||
}
|
||||
|
||||
fun setupTestClients(othersCount: Int): List<RecipientId> {
|
||||
val others = mutableListOf<RecipientId>()
|
||||
synchronized(this) {
|
||||
for (i in 0 until othersCount) {
|
||||
val otherClient = Harness.otherClients[i]
|
||||
|
||||
val recipientId = RecipientId.from(SignalServiceAddress(otherClient.serviceId, otherClient.e164))
|
||||
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
|
||||
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, otherClient.profileKey)
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
SignalDatabase.recipients.markRegistered(recipientId, otherClient.serviceId)
|
||||
AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(otherClient.serviceId.toString(), 1), otherClient.identityKeyPair.publicKey)
|
||||
|
||||
others += recipientId
|
||||
}
|
||||
|
||||
generatedOthers += othersCount
|
||||
}
|
||||
|
||||
return others
|
||||
}
|
||||
|
||||
fun setupGroup() {
|
||||
val members = setupTestClients(5)
|
||||
val self = Recipient.self()
|
||||
|
||||
val fullMembers = buildList {
|
||||
add(member(aci = self.requireAci()))
|
||||
addAll(members.map { member(aci = Recipient.resolved(it).requireAci()) })
|
||||
}
|
||||
|
||||
val group = DecryptedGroup(
|
||||
title = "Title",
|
||||
avatar = "",
|
||||
disappearingMessagesTimer = DecryptedTimer(),
|
||||
accessControl = AccessControl(),
|
||||
revision = 1,
|
||||
members = fullMembers,
|
||||
pendingMembers = emptyList(),
|
||||
requestingMembers = emptyList(),
|
||||
inviteLinkPassword = ByteString.EMPTY,
|
||||
description = "Description",
|
||||
isAnnouncementGroup = EnabledState.DISABLED,
|
||||
bannedMembers = emptyList(),
|
||||
isPlaceholderGroup = false
|
||||
)
|
||||
|
||||
val groupId = SignalDatabase.groups.create(
|
||||
groupMasterKey = Harness.groupMasterKey,
|
||||
groupState = group,
|
||||
groupSendEndorsements = null
|
||||
)
|
||||
|
||||
SignalDatabase.recipients.setProfileSharing(Recipient.externalGroupExact(groupId!!).id, true)
|
||||
}
|
||||
|
||||
private fun member(aci: ACI, role: Member.Role = Member.Role.DEFAULT, joinedAt: Int = 0, labelEmoji: String = "", labelString: String = ""): DecryptedMember {
|
||||
return DecryptedMember(
|
||||
role = role,
|
||||
aciBytes = aci.toByteString(),
|
||||
joinedAtRevision = joinedAt,
|
||||
labelEmoji = labelEmoji,
|
||||
labelString = labelString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.internal.websocket
|
||||
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.thoughtcrime.securesms.util.SignalTrace
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
|
||||
import org.whispersystems.signalservice.internal.push.SendMessageResponse
|
||||
import java.net.SocketException
|
||||
import java.util.LinkedList
|
||||
import java.util.Optional
|
||||
import java.util.concurrent.Semaphore
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
/**
|
||||
* A [WebSocketConnection] that provides a way to add "incoming" WebSocket payloads
|
||||
* and have client code pull them off the "wire" as they would a normal socket.
|
||||
*
|
||||
* Add messages with [addPendingMessages] and then can release them to the requestor via
|
||||
* [releaseMessages].
|
||||
*/
|
||||
class BenchmarkWebSocketConnection : WebSocketConnection {
|
||||
|
||||
companion object {
|
||||
lateinit var authInstance: BenchmarkWebSocketConnection
|
||||
private set
|
||||
|
||||
@Synchronized
|
||||
fun createAuthInstance(): WebSocketConnection {
|
||||
authInstance = BenchmarkWebSocketConnection()
|
||||
return authInstance
|
||||
}
|
||||
|
||||
lateinit var unauthInstance: BenchmarkWebSocketConnection
|
||||
private set
|
||||
|
||||
@Synchronized
|
||||
fun createUnauthInstance(): WebSocketConnection {
|
||||
unauthInstance = BenchmarkWebSocketConnection()
|
||||
return unauthInstance
|
||||
}
|
||||
}
|
||||
|
||||
override val name: String = "bench-${System.identityHashCode(this)}"
|
||||
|
||||
private val state = BehaviorSubject.create<WebSocketConnectionState>()
|
||||
|
||||
private val incomingRequests = LinkedList<WebSocketRequestMessage>()
|
||||
private val incomingSemaphore = Semaphore(0)
|
||||
|
||||
var startWholeBatchTrace = false
|
||||
|
||||
@Volatile
|
||||
private var isShutdown = false
|
||||
|
||||
override fun connect(): Observable<WebSocketConnectionState> {
|
||||
state.onNext(WebSocketConnectionState.CONNECTED)
|
||||
return state
|
||||
}
|
||||
|
||||
override fun isDead(): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
state.onNext(WebSocketConnectionState.DISCONNECTED)
|
||||
|
||||
// Signal shutdown
|
||||
isShutdown = true
|
||||
|
||||
val queuedThreads = incomingSemaphore.queueLength
|
||||
if (queuedThreads > 0) {
|
||||
incomingSemaphore.release(queuedThreads)
|
||||
}
|
||||
}
|
||||
|
||||
override fun readRequest(timeoutMillis: Long): WebSocketRequestMessage {
|
||||
if (incomingSemaphore.tryAcquire(1, 10, TimeUnit.SECONDS)) {
|
||||
// Check if we were woken up due to shutdown
|
||||
if (isShutdown) {
|
||||
throw SocketException("WebSocket connection closed")
|
||||
}
|
||||
return getNextRequest()
|
||||
}
|
||||
|
||||
throw TimeoutException("Timeout exceeded")
|
||||
}
|
||||
|
||||
override fun readRequestIfAvailable(): Optional<WebSocketRequestMessage> {
|
||||
return if (incomingSemaphore.tryAcquire()) {
|
||||
Optional.of(getNextRequest())
|
||||
} else {
|
||||
Optional.empty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNextRequest(): WebSocketRequestMessage {
|
||||
if (startWholeBatchTrace) {
|
||||
startWholeBatchTrace = false
|
||||
SignalTrace.beginSection("IncomingMessageObserver#totalProcessing")
|
||||
}
|
||||
|
||||
return incomingRequests.removeFirst()
|
||||
}
|
||||
|
||||
override fun sendResponse(response: WebSocketResponseMessage) = Unit
|
||||
|
||||
fun addPendingMessages(messages: List<WebSocketRequestMessage>) {
|
||||
incomingRequests.addAll(messages)
|
||||
}
|
||||
|
||||
fun releaseMessages() {
|
||||
incomingSemaphore.release(incomingRequests.size)
|
||||
}
|
||||
|
||||
override fun sendRequest(
|
||||
request: WebSocketRequestMessage,
|
||||
timeoutSeconds: Long
|
||||
): Single<WebsocketResponse> {
|
||||
if (request.verb != null && request.path != null) {
|
||||
if (request.verb == "PUT" && request.path!!.startsWith("/v1/messages/")) {
|
||||
return Single.just(WebsocketResponse(200, SendMessageResponse().toJson(), emptyList<String>(), true))
|
||||
}
|
||||
}
|
||||
|
||||
return Single.error(okio.IOException("fake timeout"))
|
||||
}
|
||||
|
||||
override fun sendKeepAlive() = Unit
|
||||
|
||||
fun addQueueEmptyMessage() {
|
||||
addPendingMessages(
|
||||
listOf(
|
||||
WebSocketRequestMessage(
|
||||
verb = "PUT",
|
||||
path = "/api/v1/queue/empty"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Any.toJson(): String {
|
||||
return JsonUtils.toJson(this)
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.ringrtc.CallLinkEpoch
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
@@ -295,7 +294,7 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey, callLinkEpoch: CallLinkEpoch?) {
|
||||
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,6 @@ import androidx.lifecycle.Observer;
|
||||
|
||||
import com.bumptech.glide.RequestManager;
|
||||
|
||||
import org.signal.ringrtc.CallLinkEpoch;
|
||||
import org.signal.ringrtc.CallLinkRootKey;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
@@ -30,8 +29,8 @@ import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory;
|
||||
import org.thoughtcrime.securesms.polls.PollRecord;
|
||||
import org.thoughtcrime.securesms.polls.PollOption;
|
||||
import org.thoughtcrime.securesms.polls.PollRecord;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
@@ -136,7 +135,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args);
|
||||
void onEditedIndicatorClicked(@NonNull ConversationMessage conversationMessage);
|
||||
void onShowGroupDescriptionClicked(@NonNull String groupName, @NonNull String description, boolean shouldLinkifyWebLinks);
|
||||
void onJoinCallLink(@NonNull CallLinkRootKey callLinkRootKey, @Nullable CallLinkEpoch callLinkEpoch);
|
||||
void onJoinCallLink(@NonNull CallLinkRootKey callLinkRootKey);
|
||||
void onShowSafetyTips(boolean forGroup);
|
||||
void onReportSpamLearnMoreClicked();
|
||||
void onMessageRequestAcceptOptionsClicked();
|
||||
|
||||
@@ -342,6 +342,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
|
||||
.collect(java.util.stream.Collectors.toSet()),
|
||||
selectionLimit,
|
||||
isMulti,
|
||||
new ContactSearchAdapter.DisplayOptions(
|
||||
isMulti,
|
||||
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
|
||||
@@ -558,6 +559,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
public void onDataRefreshed() {
|
||||
this.resetPositionOnCommit = true;
|
||||
swipeRefresh.setRefreshing(false);
|
||||
contactSearchMediator.refresh();
|
||||
}
|
||||
|
||||
public boolean hasQueryFilter() {
|
||||
@@ -574,6 +576,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
|
||||
public void reset() {
|
||||
contactSearchMediator.clearSelection();
|
||||
contactSearchMediator.refresh();
|
||||
fastScroller.setVisibility(View.GONE);
|
||||
headerActionView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,6 @@ import org.signal.mediasend.MediaSendActivityContract
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFragment
|
||||
import org.thoughtcrime.securesms.calls.new.NewCallActivity
|
||||
@@ -140,6 +139,7 @@ import org.thoughtcrime.securesms.main.MainContentLayoutData
|
||||
import org.thoughtcrime.securesms.main.MainMegaphoneState
|
||||
import org.thoughtcrime.securesms.main.MainNavigationBar
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocationEffect
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationRail
|
||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel
|
||||
@@ -156,7 +156,6 @@ import org.thoughtcrime.securesms.main.chatNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.main.navigateToDetailLocation
|
||||
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
|
||||
import org.thoughtcrime.securesms.main.rememberFocusRequester
|
||||
import org.thoughtcrime.securesms.main.rememberMainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
|
||||
@@ -447,7 +446,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
|
||||
val chatNavGraphState = ChatNavGraphState.remember(windowSizeClass)
|
||||
val mutableInteractionSource = remember { MutableInteractionSource() }
|
||||
val mainNavigationDetailLocation by rememberMainNavigationDetailLocation(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
|
||||
MainNavigationDetailLocationEffect(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
|
||||
|
||||
val chatsNavHostController = rememberDetailNavHostController(
|
||||
onRequestFocus = rememberFocusRequester(
|
||||
@@ -477,25 +476,33 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
storiesNavGraphBuilder()
|
||||
}
|
||||
|
||||
LaunchedEffect(mainNavigationDetailLocation) {
|
||||
mainNavigationViewModel.clearEarlyDetailLocation()
|
||||
when (mainNavigationDetailLocation) {
|
||||
is MainNavigationDetailLocation.Empty -> {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController
|
||||
MainNavigationListLocation.CALLS -> callsNavHostController
|
||||
MainNavigationListLocation.STORIES -> storiesNavHostController
|
||||
}.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
suspend fun navigateToLocation(location: MainNavigationDetailLocation) {
|
||||
when (location) {
|
||||
is MainNavigationDetailLocation.Empty -> {
|
||||
when (mainNavigationState.currentListLocation) {
|
||||
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController
|
||||
MainNavigationListLocation.CALLS -> callsNavHostController
|
||||
MainNavigationListLocation.STORIES -> storiesNavHostController
|
||||
}.navigateToDetailLocation(location)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Chats -> {
|
||||
chatNavGraphState.writeGraphicsLayerToBitmap()
|
||||
chatsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
}
|
||||
is MainNavigationDetailLocation.Chats -> {
|
||||
if (location is MainNavigationDetailLocation.Chats.Conversation) {
|
||||
chatNavGraphState.writeGraphicsLayerToBitmap()
|
||||
}
|
||||
chatsNavHostController.navigateToDetailLocation(location)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
|
||||
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
|
||||
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
|
||||
}
|
||||
}
|
||||
|
||||
mainNavigationViewModel.earlyNavigationDetailLocationRequested?.let { navigateToLocation(it) }
|
||||
mainNavigationViewModel.clearEarlyDetailLocation()
|
||||
|
||||
mainNavigationViewModel.detailLocation.collect { navigateToLocation(it) }
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -752,27 +759,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
val coroutine = rememberCoroutineScope()
|
||||
|
||||
return remember(scaffoldNavigator, coroutine) {
|
||||
mainNavigationViewModel.wrapNavigator(coroutine, scaffoldNavigator) { detailLocation ->
|
||||
when (detailLocation) {
|
||||
is MainNavigationDetailLocation.Chats.Conversation -> {
|
||||
startActivity(
|
||||
ConversationIntents.createBuilderSync(this, detailLocation.conversationArgs.recipientId, detailLocation.conversationArgs.threadId)
|
||||
.withArgs(detailLocation.conversationArgs)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails -> {
|
||||
startActivity(CallLinkDetailsActivity.createIntent(this, detailLocation.callLinkRoomId))
|
||||
}
|
||||
|
||||
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
|
||||
error("Unexpected subroute EditCallLinkName.")
|
||||
}
|
||||
|
||||
MainNavigationDetailLocation.Empty -> Unit
|
||||
}
|
||||
}
|
||||
mainNavigationViewModel.wrapNavigator(coroutine, scaffoldNavigator)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ class CallLinkArchiveExporter(private val cursor: Cursor) : Iterator<ArchiveReci
|
||||
id = callLink.recipientId.toLong(),
|
||||
callLink = CallLink(
|
||||
rootKey = callLink.credentials!!.linkKeyBytes.toByteString(),
|
||||
epoch = callLink.credentials.epochBytes?.takeIf { it.size == 4 }?.toByteString(),
|
||||
adminKey = callLink.credentials.adminPassBytes?.toByteString()?.nullIfEmpty(),
|
||||
name = callLink.state.name,
|
||||
expirationMs = expirationTime.takeIf { it != Long.MAX_VALUE }?.clampToValidBackupRange() ?: 0,
|
||||
|
||||
@@ -42,11 +42,7 @@ object CallLinkArchiveImporter {
|
||||
CallLinkTable.CallLink(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = CallLinkRoomId.fromCallLinkRootKey(rootKey),
|
||||
credentials = CallLinkCredentials(
|
||||
callLink.rootKey.toByteArray(),
|
||||
callLink.epoch?.toByteArray(),
|
||||
callLink.adminKey?.toByteArray()
|
||||
),
|
||||
credentials = CallLinkCredentials(callLink.rootKey.toByteArray(), callLink.adminKey?.toByteArray()),
|
||||
state = SignalCallLinkState(
|
||||
name = callLink.name,
|
||||
restrictions = callLink.restrictions.toLocal(),
|
||||
|
||||
@@ -33,8 +33,6 @@ import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.compose.BetaHeader
|
||||
import org.thoughtcrime.securesms.components.compose.TextWithBetaLabel
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
@@ -65,10 +63,6 @@ fun MessageBackupsEducationScreen(
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) {
|
||||
item {
|
||||
BetaHeader()
|
||||
}
|
||||
|
||||
item {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.image_signal_backups),
|
||||
@@ -80,9 +74,9 @@ fun MessageBackupsEducationScreen(
|
||||
}
|
||||
|
||||
item {
|
||||
TextWithBetaLabel(
|
||||
Text(
|
||||
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__signal_backups),
|
||||
textStyle = MaterialTheme.typography.headlineMedium,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 15.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ package org.thoughtcrime.securesms.calls.links
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.ringrtc.CallException
|
||||
import org.signal.ringrtc.CallLinkEpoch
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
@@ -23,7 +22,6 @@ import java.net.URLDecoder
|
||||
*/
|
||||
object CallLinks {
|
||||
private const val ROOT_KEY = "key"
|
||||
private const val EPOCH = "epoch"
|
||||
private const val LEGACY_HTTPS_LINK_PREFIX = "https://signal.link/call#key="
|
||||
private const val LEGACY_SGNL_LINK_PREFIX = "sgnl://signal.link/call#key="
|
||||
private const val HTTPS_LINK_PREFIX = "https://signal.link/call/#key="
|
||||
@@ -31,13 +29,7 @@ object CallLinks {
|
||||
|
||||
private val TAG = Log.tag(CallLinks::class.java)
|
||||
|
||||
fun url(rootKeyBytes: ByteArray, epochBytes: ByteArray?): String {
|
||||
return if (epochBytes == null) {
|
||||
"$HTTPS_LINK_PREFIX${CallLinkRootKey(rootKeyBytes)}"
|
||||
} else {
|
||||
"$HTTPS_LINK_PREFIX${CallLinkRootKey(rootKeyBytes)}&epoch=${CallLinkEpoch.fromBytes(epochBytes)}"
|
||||
}
|
||||
}
|
||||
fun url(rootKeyBytes: ByteArray): String = "$HTTPS_LINK_PREFIX${CallLinkRootKey(rootKeyBytes)}"
|
||||
|
||||
fun watchCallLink(roomId: CallLinkRoomId): Observable<CallLinkTable.CallLink> {
|
||||
return Observable.create { emitter ->
|
||||
@@ -78,13 +70,8 @@ object CallLinks {
|
||||
return url.split("#").last().startsWith("key=")
|
||||
}
|
||||
|
||||
data class CallLinkParseResult(
|
||||
val rootKey: CallLinkRootKey,
|
||||
val epoch: CallLinkEpoch?
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun parseUrl(url: String): CallLinkParseResult? {
|
||||
fun parseUrl(url: String): CallLinkRootKey? {
|
||||
if (!isPrefixedCallLink(url)) {
|
||||
Log.w(TAG, "Invalid url prefix.")
|
||||
return null
|
||||
@@ -132,13 +119,9 @@ object CallLinks {
|
||||
}
|
||||
|
||||
return try {
|
||||
val epoch = fragmentQuery[EPOCH]?.let { s -> CallLinkEpoch(s) }
|
||||
CallLinkParseResult(
|
||||
rootKey = CallLinkRootKey(key),
|
||||
epoch = epoch
|
||||
)
|
||||
return CallLinkRootKey(key)
|
||||
} catch (e: CallException) {
|
||||
Log.w(TAG, "Invalid root key or epoch found in fragment query string.")
|
||||
Log.w(TAG, "Invalid root key found in fragment query string.")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ import org.signal.core.ui.R as CoreUiR
|
||||
@Composable
|
||||
private fun SignalCallRowPreview() {
|
||||
val callLink = remember {
|
||||
val credentials = CallLinkCredentials(byteArrayOf(1, 2, 3, 4), byteArrayOf(0, 1, 2, 3), byteArrayOf(5, 6, 7, 8))
|
||||
val credentials = CallLinkCredentials(byteArrayOf(1, 2, 3, 4), byteArrayOf(5, 6, 7, 8))
|
||||
CallLinkTable.CallLink(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = CallLinkRoomId.fromBytes(byteArrayOf(1, 3, 5, 7)),
|
||||
@@ -97,7 +97,7 @@ fun SignalCallRow(
|
||||
"https://signal.call.example.com"
|
||||
} else {
|
||||
remember(callLink.credentials) {
|
||||
callLink.credentials?.let { CallLinks.url(it.linkKeyBytes, it.epochBytes) } ?: ""
|
||||
callLink.credentials?.let { CallLinks.url(it.linkKeyBytes) } ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
|
||||
startActivity(
|
||||
ShareActivity.sendSimpleText(
|
||||
requireContext(),
|
||||
getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.linkKeyBytes, viewModel.epochBytes))
|
||||
getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.linkKeyBytes))
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -176,7 +176,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
|
||||
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
|
||||
when (it) {
|
||||
is EnsureCallLinkCreatedResult.Success -> {
|
||||
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.linkKeyBytes, viewModel.epochBytes))
|
||||
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.linkKeyBytes))
|
||||
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
|
||||
is EnsureCallLinkCreatedResult.Success -> {
|
||||
val mimeType = Intent.normalizeMimeType("text/plain")
|
||||
val shareIntent = ShareCompat.IntentBuilder(requireContext())
|
||||
.setText(CallLinks.url(viewModel.linkKeyBytes, viewModel.epochBytes))
|
||||
.setText(CallLinks.url(viewModel.linkKeyBytes))
|
||||
.setType(mimeType)
|
||||
.createChooserIntent()
|
||||
|
||||
|
||||
@@ -52,9 +52,6 @@ class CreateCallLinkViewModel(
|
||||
val linkKeyBytes: ByteArray
|
||||
get() = callLink.value.credentials!!.linkKeyBytes
|
||||
|
||||
val epochBytes: ByteArray?
|
||||
get() = callLink.value.credentials!!.epochBytes
|
||||
|
||||
private val internalShowAlreadyInACall = MutableStateFlow(false)
|
||||
val showAlreadyInACall: StateFlow<Boolean> = internalShowAlreadyInACall
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ class DefaultCallLinkDetailsCallback(
|
||||
override fun onShareClicked() {
|
||||
val mimeType = Intent.normalizeMimeType("text/plain")
|
||||
val shareIntent = ShareCompat.IntentBuilder(activity)
|
||||
.setText(CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
|
||||
.setText(CallLinks.url(viewModel.rootKeySnapshot))
|
||||
.setType(mimeType)
|
||||
.createChooserIntent()
|
||||
|
||||
@@ -131,7 +131,7 @@ class DefaultCallLinkDetailsCallback(
|
||||
}
|
||||
|
||||
override fun onCopyClicked() {
|
||||
Util.copyToClipboard(activity, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
|
||||
Util.copyToClipboard(activity, CallLinks.url(viewModel.rootKeySnapshot))
|
||||
Toast.makeText(activity, R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ class DefaultCallLinkDetailsCallback(
|
||||
activity.startActivity(
|
||||
ShareActivity.sendSimpleText(
|
||||
activity,
|
||||
activity.getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
|
||||
activity.getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.rootKeySnapshot))
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -324,7 +324,6 @@ private fun CallLinkDetailsScreenPreview() {
|
||||
val callLink = remember {
|
||||
val credentials = CallLinkCredentials(
|
||||
byteArrayOf(1, 2, 3, 4),
|
||||
byteArrayOf(0, 1, 2, 3),
|
||||
byteArrayOf(3, 4, 5, 6)
|
||||
)
|
||||
CallLinkTable.CallLink(
|
||||
|
||||
@@ -48,9 +48,6 @@ class CallLinkDetailsViewModel(
|
||||
val rootKeySnapshot: ByteArray
|
||||
get() = state.value.callLink?.credentials?.linkKeyBytes ?: error("Call link not loaded yet.")
|
||||
|
||||
val epochSnapshot: ByteArray?
|
||||
get() = state.value.callLink?.credentials?.epochBytes
|
||||
|
||||
private val recipientSubject = BehaviorSubject.create<Recipient>()
|
||||
val recipientSnapshot: Recipient?
|
||||
get() = recipientSubject.value
|
||||
|
||||
@@ -66,6 +66,7 @@ public class ComposeText extends EmojiEditText {
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
private SpoilerRendererDelegate spoilerRendererDelegate;
|
||||
private MentionValidatorWatcher mentionValidatorWatcher;
|
||||
private MessageSendType lastMessageSendType;
|
||||
|
||||
@Nullable private InputPanel.MediaListener mediaListener;
|
||||
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
|
||||
@@ -221,6 +222,11 @@ public class ComposeText extends EmojiEditText {
|
||||
}
|
||||
|
||||
public void setMessageSendType(MessageSendType messageSendType) {
|
||||
if (messageSendType.equals(lastMessageSendType)) {
|
||||
return;
|
||||
}
|
||||
lastMessageSendType = messageSendType;
|
||||
|
||||
int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND;
|
||||
int inputType = getInputType();
|
||||
|
||||
|
||||
@@ -47,7 +47,10 @@ public abstract class FullScreenDialogFragment extends DialogFragment {
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
WindowUtil.initializeScreenshotSecurity(requireContext(), requireDialog().getWindow());
|
||||
|
||||
if (getShowsDialog()) {
|
||||
WindowUtil.initializeScreenshotSecurity(requireContext(), requireDialog().getWindow());
|
||||
}
|
||||
}
|
||||
|
||||
protected void onNavigateUp() {
|
||||
|
||||
@@ -172,11 +172,11 @@ public class LinkPreviewView extends FrameLayout {
|
||||
spinner.setVisibility(GONE);
|
||||
noPreview.setVisibility(GONE);
|
||||
|
||||
CallLinks.CallLinkParseResult callLinkParseResult = CallLinks.isCallLink(linkPreview.getUrl()) ? CallLinks.parseUrl(linkPreview.getUrl()) : null;
|
||||
CallLinkRootKey callLinkRootKey = CallLinks.isCallLink(linkPreview.getUrl()) ? CallLinks.parseUrl(linkPreview.getUrl()) : null;
|
||||
if (!Util.isEmpty(linkPreview.getTitle())) {
|
||||
title.setText(linkPreview.getTitle());
|
||||
title.setVisibility(VISIBLE);
|
||||
} else if (callLinkParseResult != null) {
|
||||
} else if (callLinkRootKey != null) {
|
||||
title.setText(R.string.Recipient_signal_call);
|
||||
title.setVisibility(VISIBLE);
|
||||
} else {
|
||||
@@ -186,7 +186,7 @@ public class LinkPreviewView extends FrameLayout {
|
||||
if (showDescription && !Util.isEmpty(linkPreview.getDescription())) {
|
||||
description.setText(linkPreview.getDescription());
|
||||
description.setVisibility(VISIBLE);
|
||||
} else if (callLinkParseResult != null) {
|
||||
} else if (callLinkRootKey != null) {
|
||||
description.setText(R.string.LinkPreviewView__use_this_link_to_join_a_signal_call);
|
||||
description.setVisibility(VISIBLE);
|
||||
} else {
|
||||
@@ -221,14 +221,14 @@ public class LinkPreviewView extends FrameLayout {
|
||||
thumbnail.get().setImageResource(requestManager, new ImageSlide(linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION && !scheduleMessageMode, false);
|
||||
thumbnail.get().showSecondaryText(false);
|
||||
thumbnail.get().setOutlineEnabled(true);
|
||||
} else if (callLinkParseResult != null) {
|
||||
} else if (callLinkRootKey != null) {
|
||||
thumbnail.setVisibility(VISIBLE);
|
||||
thumbnailState.applyState(thumbnail);
|
||||
thumbnail.get().setImageDrawable(
|
||||
requestManager,
|
||||
new FallbackAvatarDrawable(
|
||||
getContext(),
|
||||
new FallbackAvatar.Resource.CallLink(AvatarColorHash.forCallLink(callLinkParseResult.getRootKey().getKeyBytes()))
|
||||
new FallbackAvatar.Resource.CallLink(AvatarColorHash.forCallLink(callLinkRootKey.getKeyBytes()))
|
||||
).circleCrop()
|
||||
);
|
||||
thumbnail.get().showSecondaryText(false);
|
||||
@@ -272,7 +272,7 @@ public class LinkPreviewView extends FrameLayout {
|
||||
thumbnailState.applyState(thumbnail);
|
||||
}
|
||||
|
||||
private @StringRes static int getLinkPreviewErrorString(@Nullable LinkPreviewRepository.Error customError) {
|
||||
private @StringRes static int getLinkPreviewErrorString(@Nullable LinkPreviewRepository.Error customError) {
|
||||
return customError == LinkPreviewRepository.Error.GROUP_LINK_INACTIVE ? R.string.LinkPreviewView_this_group_link_is_not_active
|
||||
: R.string.LinkPreviewView_no_link_preview_available;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.view.View
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.signal.core.ui.initializeScreenshotSecurity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.fragments.findListener
|
||||
|
||||
@@ -57,6 +58,11 @@ abstract class WrapperDialogFragment : DialogFragment(R.layout.fragment_containe
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
dialog?.window?.initializeScreenshotSecurity()
|
||||
}
|
||||
|
||||
open fun onHandleBackPressed() {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.compose
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Adds a 'Beta' label next to [text] to indicate a feature is in development
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun TextWithBetaLabel(
|
||||
text: String,
|
||||
textStyle: TextStyle = TextStyle.Default,
|
||||
enabled: Boolean = true,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
FlowRow(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = textStyle,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.alpha(if (enabled) 1f else Rows.DISABLED_ALPHA)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.Beta__beta_title).uppercase(),
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
.padding(vertical = 6.dp)
|
||||
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(28.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
.alpha(if (enabled) 1f else Rows.DISABLED_ALPHA)
|
||||
.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 'Beta' header to indicate a feature is currently in development
|
||||
*/
|
||||
@Composable
|
||||
fun BetaHeader(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.background(
|
||||
color = SignalTheme.colors.colorSurface2,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = SignalIcons.Info.imageVector,
|
||||
contentDescription = stringResource(id = R.string.Beta__info),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.Beta__this_is_beta),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun BetaLabelPreview() {
|
||||
Previews.Preview {
|
||||
TextWithBetaLabel("Signal Backups")
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun BetaLabelDisabledPreview() {
|
||||
Previews.Preview {
|
||||
TextWithBetaLabel("Signal Backups", enabled = false)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(locale = "de")
|
||||
@Composable
|
||||
fun LongTextBetaLabelPreview() {
|
||||
Previews.Preview {
|
||||
Scaffold {
|
||||
TextWithBetaLabel(
|
||||
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__signal_backups),
|
||||
textStyle = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalGutters()
|
||||
.padding(it)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun BetaHeaderPreview() {
|
||||
Previews.Preview {
|
||||
BetaHeader()
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,6 @@ import org.thoughtcrime.securesms.banner.banners.UnauthorizedBanner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Importance
|
||||
import org.thoughtcrime.securesms.components.compose.TextWithBetaLabel
|
||||
import org.thoughtcrime.securesms.components.emoji.Emojifier
|
||||
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRoute
|
||||
import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRouter
|
||||
@@ -415,10 +414,9 @@ private fun AppSettingsContent(
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
TextWithBetaLabel(
|
||||
Text(
|
||||
text = stringResource(R.string.preferences_chats__backups),
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
enabled = isRegisteredAndUpToDate
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
|
||||
@@ -53,7 +53,6 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.components.compose.TextWithBetaLabel
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
@@ -285,9 +284,9 @@ private fun NeverEnabledBackupsRow(
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
TextWithBetaLabel(
|
||||
Text(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
|
||||
textStyle = MaterialTheme.typography.bodyLarge
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
@@ -331,9 +330,9 @@ private fun InactiveBackupsRow(
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
Column {
|
||||
TextWithBetaLabel(
|
||||
Text(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
|
||||
textStyle = MaterialTheme.typography.bodyLarge
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
@@ -377,9 +376,9 @@ private fun NotFoundBackupRow(
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
TextWithBetaLabel(
|
||||
Text(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
|
||||
textStyle = MaterialTheme.typography.bodyLarge
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
@@ -412,9 +411,9 @@ private fun PendingBackupRow(
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
TextWithBetaLabel(
|
||||
Text(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
|
||||
textStyle = MaterialTheme.typography.bodyLarge
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
@@ -463,9 +462,9 @@ private fun LocalStoreBackupRow(
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
TextWithBetaLabel(
|
||||
Text(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
|
||||
textStyle = MaterialTheme.typography.bodyLarge
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
val tierText = when (backupState.tier) {
|
||||
@@ -508,9 +507,9 @@ private fun ActiveBackupsRow(
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
TextWithBetaLabel(
|
||||
Text(
|
||||
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
|
||||
textStyle = MaterialTheme.typography.bodyLarge
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
when (val type = backupState.messageBackupsType) {
|
||||
|
||||
@@ -101,7 +101,6 @@ import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusRow
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.status.RestoreType
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.billing.launchManageBackupsSubscription
|
||||
import org.thoughtcrime.securesms.components.compose.BetaHeader
|
||||
import org.thoughtcrime.securesms.components.compose.BiometricsAuthentication
|
||||
import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
@@ -419,10 +418,6 @@ private fun RemoteBackupsSettingsContent(
|
||||
modifier = Modifier
|
||||
.padding(it)
|
||||
) {
|
||||
item {
|
||||
BetaHeader(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
|
||||
if (state.isOutOfStorageSpace) {
|
||||
item {
|
||||
OutOfStorageSpaceBlock(
|
||||
|
||||
@@ -65,6 +65,7 @@ import org.thoughtcrime.securesms.megaphone.Megaphones
|
||||
import org.thoughtcrime.securesms.payments.DataExportUtil
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.registration.data.QuickstartCredentialExporter
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
@@ -164,6 +165,16 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Export quickstart credentials"),
|
||||
summary = DSLSettingsText.from("Export registration credentials to a JSON file for quickstart builds."),
|
||||
onClick = {
|
||||
exportQuickstartCredentials()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Unregister"),
|
||||
summary = DSLSettingsText.from("This will unregister your account without deleting it."),
|
||||
@@ -1144,6 +1155,21 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
}
|
||||
|
||||
private fun exportQuickstartCredentials() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Export quickstart credentials?")
|
||||
.setMessage("This will export your account's private keys and credentials to an unencrypted file on disk. This is very dangerous! Only use it with test accounts.")
|
||||
.setPositiveButton("Export") { _, _ ->
|
||||
SimpleTask.run({
|
||||
QuickstartCredentialExporter.export(requireContext())
|
||||
}) { file ->
|
||||
Toast.makeText(requireContext(), "Exported to ${file.absolutePath}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun promptUserForSentTimestamp() {
|
||||
val input = EditText(requireContext()).apply {
|
||||
inputType = android.text.InputType.TYPE_CLASS_NUMBER
|
||||
|
||||
@@ -30,26 +30,28 @@ class CheckoutNavHostFragment : NavHostFragment() {
|
||||
get() = requireArguments().getSerializableCompat(ARG_TYPE, InAppPaymentType::class.java)!!
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
if (savedInstanceState == null) {
|
||||
val navGraph = navController.navInflater.inflate(R.navigation.checkout)
|
||||
navGraph.setStartDestination(
|
||||
when (inAppPaymentType) {
|
||||
InAppPaymentType.UNKNOWN -> error("Unsupported start destination")
|
||||
InAppPaymentType.ONE_TIME_GIFT -> R.id.giftFlowStartFragment
|
||||
InAppPaymentType.ONE_TIME_DONATION, InAppPaymentType.RECURRING_DONATION -> R.id.donateToSignalFragment
|
||||
InAppPaymentType.RECURRING_BACKUP -> error("Unsupported start destination")
|
||||
}
|
||||
)
|
||||
val navGraph = navController.navInflater.inflate(R.navigation.checkout)
|
||||
navGraph.setStartDestination(
|
||||
when (inAppPaymentType) {
|
||||
InAppPaymentType.UNKNOWN -> error("Unsupported start destination")
|
||||
InAppPaymentType.ONE_TIME_GIFT -> R.id.giftFlowStartFragment
|
||||
InAppPaymentType.ONE_TIME_DONATION, InAppPaymentType.RECURRING_DONATION -> R.id.donateToSignalFragment
|
||||
InAppPaymentType.RECURRING_BACKUP -> error("Unsupported start destination")
|
||||
}
|
||||
)
|
||||
|
||||
val startBundle = when (inAppPaymentType) {
|
||||
val startBundle = if (savedInstanceState == null) {
|
||||
when (inAppPaymentType) {
|
||||
InAppPaymentType.UNKNOWN -> error("Unknown payment type")
|
||||
InAppPaymentType.ONE_TIME_GIFT, InAppPaymentType.RECURRING_BACKUP -> null
|
||||
InAppPaymentType.ONE_TIME_DONATION, InAppPaymentType.RECURRING_DONATION -> DonateToSignalFragmentArgs.Builder(inAppPaymentType).build().toBundle()
|
||||
}
|
||||
|
||||
navController.setGraph(navGraph, startBundle)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
navController.setGraph(navGraph, startBundle)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,10 +659,11 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
)
|
||||
)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__see_all),
|
||||
onClick = {
|
||||
startActivity(MediaOverviewActivity.forThread(requireContext(), state.threadId))
|
||||
startActivityForResult(MediaOverviewActivity.forThread(requireContext(), state.threadId), REQUEST_CODE_RETURN_FROM_MEDIA)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -197,6 +197,7 @@ data class CallParticipantsState(
|
||||
|
||||
companion object {
|
||||
const val SMALL_GROUP_MAX = 6
|
||||
const val PRE_JOIN_MUTE_THRESHOLD = 8
|
||||
|
||||
@JvmField
|
||||
val MAX_OUTGOING_GROUP_RING_DURATION = TimeUnit.MINUTES.toMillis(1)
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
@@ -28,6 +29,15 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
|
||||
|
||||
companion object {
|
||||
const val TAG = "WebRtcAudioPicker31"
|
||||
|
||||
private fun WebRtcAudioOutput.toSignalAudioDevice(): SignalAudioManager.AudioDevice {
|
||||
return when (this) {
|
||||
WebRtcAudioOutput.HANDSET -> SignalAudioManager.AudioDevice.EARPIECE
|
||||
WebRtcAudioOutput.SPEAKER -> SignalAudioManager.AudioDevice.SPEAKER_PHONE
|
||||
WebRtcAudioOutput.BLUETOOTH_HEADSET -> SignalAudioManager.AudioDevice.BLUETOOTH
|
||||
WebRtcAudioOutput.WIRED_HEADSET -> SignalAudioManager.AudioDevice.WIRED_HEADSET
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showPicker(fragmentActivity: FragmentActivity, threshold: Int, onDismiss: (DialogInterface) -> Unit): DialogInterface? {
|
||||
@@ -60,20 +70,20 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
|
||||
|
||||
val am = AppDependencies.androidCallAudioManager
|
||||
if (am.availableCommunicationDevices.isEmpty()) {
|
||||
Toast.makeText(context, R.string.WebRtcAudioOutputToggleButton_no_eligible_audio_i_o_detected, Toast.LENGTH_LONG).show()
|
||||
LaunchedEffect(Unit) {
|
||||
Toast.makeText(context, R.string.WebRtcAudioOutputToggleButton_no_eligible_audio_i_o_detected, Toast.LENGTH_LONG).show()
|
||||
stateUpdater.hidePicker()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val devices: List<AudioOutputOption> = am.availableCommunicationDevices.map { AudioOutputOption(it.toFriendlyName(context).toString(), AudioDeviceMapping.fromPlatformType(it.type), it.id) }.distinctBy { it.deviceType.name }.filterNot { it.deviceType == SignalAudioManager.AudioDevice.NONE }
|
||||
val currentDeviceId = am.communicationDevice?.id ?: -1
|
||||
if (devices.size < threshold) {
|
||||
Log.d(TAG, "Only found $devices devices, not showing picker.")
|
||||
if (devices.isEmpty()) return
|
||||
|
||||
val index = devices.indexOfFirst { it.deviceId == currentDeviceId }
|
||||
if (index == -1) return
|
||||
|
||||
onAudioDeviceSelected(devices[(index + 1) % devices.size])
|
||||
LaunchedEffect(Unit) {
|
||||
Log.d(TAG, "Only found $devices devices, not showing picker.")
|
||||
cycleToNextDevice()
|
||||
}
|
||||
return
|
||||
} else {
|
||||
Log.d(TAG, "Found $devices devices, showing picker.")
|
||||
@@ -124,6 +134,37 @@ class WebRtcAudioPicker31(private val audioOutputChangedListener: OnAudioOutputC
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycles to the next audio device without showing a picker.
|
||||
* Uses the system device list to resolve the actual device ID, falling back to
|
||||
* type-based lookup from app-tracked state when the current communication device is unknown.
|
||||
*/
|
||||
fun cycleToNextDevice() {
|
||||
val am = AppDependencies.androidCallAudioManager
|
||||
val devices: List<AudioOutputOption> = am.availableCommunicationDevices
|
||||
.map { AudioOutputOption("", AudioDeviceMapping.fromPlatformType(it.type), it.id) }
|
||||
.distinctBy { it.deviceType.name }
|
||||
.filterNot { it.deviceType == SignalAudioManager.AudioDevice.NONE }
|
||||
|
||||
if (devices.isEmpty()) {
|
||||
Log.w(TAG, "cycleToNextDevice: no available communication devices")
|
||||
return
|
||||
}
|
||||
|
||||
val currentDeviceId = am.communicationDevice?.id ?: -1
|
||||
val index = devices.indexOfFirst { it.deviceId == currentDeviceId }
|
||||
|
||||
if (index != -1) {
|
||||
onAudioDeviceSelected(devices[(index + 1) % devices.size])
|
||||
} else {
|
||||
val nextOutput = outputState.peekNext()
|
||||
val targetDeviceType = nextOutput.toSignalAudioDevice()
|
||||
val targetDevice = devices.firstOrNull { it.deviceType == targetDeviceType } ?: devices.first()
|
||||
Log.d(TAG, "cycleToNextDevice: communicationDevice unknown, selecting ${targetDevice.deviceType} by type")
|
||||
onAudioDeviceSelected(targetDevice)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioOutputOption.toWebRtcAudioOutput(): WebRtcAudioOutput {
|
||||
return when (this.deviceType) {
|
||||
SignalAudioManager.AudioDevice.WIRED_HEADSET -> WebRtcAudioOutput.WIRED_HEADSET
|
||||
|
||||
@@ -34,8 +34,6 @@ class ControlsAndInfoViewModel(
|
||||
|
||||
val rootKeySnapshot: ByteArray
|
||||
get() = state.value.callLink?.credentials?.linkKeyBytes ?: error("Call link not loaded yet.")
|
||||
val epochSnapshot: ByteArray?
|
||||
get() = state.value.callLink?.credentials?.epochBytes
|
||||
|
||||
fun setRecipient(recipient: Recipient) {
|
||||
if (recipient.isCallLink && callRecipientId != recipient.id) {
|
||||
|
||||
@@ -185,11 +185,13 @@ class AudioOutputPickerController(
|
||||
|
||||
val isLegacy = Build.VERSION.SDK_INT < 31
|
||||
if (!willDisplayPicker) {
|
||||
if (isLegacy) {
|
||||
LaunchedEffect(Unit) {
|
||||
displaySheet = false
|
||||
onSelectedDeviceChanged(WebRtcAudioDevice(outputState.peekNext(), null))
|
||||
} else {
|
||||
newApiController!!.Picker(threshold = SHOW_PICKER_THRESHOLD)
|
||||
if (isLegacy) {
|
||||
onSelectedDeviceChanged(WebRtcAudioDevice(outputState.peekNext(), null))
|
||||
} else {
|
||||
newApiController!!.cycleToNextDevice()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,18 +35,6 @@ import org.thoughtcrime.securesms.events.CallParticipant
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Enum identifying each slot in the BlurrableContentLayer.
|
||||
* Used as subcomposition keys to ensure each slot is only composed once.
|
||||
*/
|
||||
private enum class BlurrableContentSlot {
|
||||
BARS,
|
||||
GRID,
|
||||
REACTIONS,
|
||||
OVERFLOW,
|
||||
AUDIO_INDICATOR
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CallElementsLayout(
|
||||
callGridSlot: @Composable () -> Unit,
|
||||
@@ -84,8 +72,6 @@ fun CallElementsLayout(
|
||||
}
|
||||
|
||||
SubcomposeLayout(modifier = modifier) { constraints ->
|
||||
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
|
||||
|
||||
// Holder to capture measurements from BlurrableContentLayer
|
||||
var measuredBarsHeightPx = 0
|
||||
var measuredBarsWidthPx = 0
|
||||
@@ -133,8 +119,8 @@ fun CallElementsLayout(
|
||||
|
||||
/**
|
||||
* A layer that contains content which can be blurred when the local participant video is focused.
|
||||
* All slots are subcomposed here ONCE to avoid duplicate subcomposition that would cause
|
||||
* IllegalArgumentException when slots contain SubcomposeLayout (like BoxWithConstraints).
|
||||
* Uses a single multi-content Layout pass to avoid re-subcomposing slots that contain
|
||||
* SubcomposeLayout (like BoxWithConstraints), which can trigger duplicate key crashes.
|
||||
*
|
||||
* @param onMeasured Callback invoked during measurement with (barsHeight, barsWidth) to report
|
||||
* dimensions needed by the parent layout for PipLayer positioning.
|
||||
@@ -156,17 +142,26 @@ private fun BlurrableContentLayer(
|
||||
isBlurred = isFocused,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
SubcomposeLayout(modifier = Modifier.fillMaxSize()) { constraints ->
|
||||
Layout(
|
||||
contents = listOf(
|
||||
{ callOverflowSlot() },
|
||||
{ callGridSlot() },
|
||||
{ barsSlot() },
|
||||
{ reactionsSlot() },
|
||||
{ audioIndicatorSlot() }
|
||||
),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) { measurables, constraints ->
|
||||
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
|
||||
|
||||
val overflowPlaceables = subcompose(BlurrableContentSlot.OVERFLOW, callOverflowSlot)
|
||||
.map { it.measure(looseConstraints) }
|
||||
val (overflowMeasurables, gridMeasurables, barsMeasurables, reactionsMeasurables, audioIndicatorMeasurables) = measurables
|
||||
|
||||
val overflowPlaceables = overflowMeasurables.map { it.measure(looseConstraints) }
|
||||
val constrainedHeightOffset = if (isPortrait) overflowPlaceables.maxOfOrNull { it.height } ?: 0 else 0
|
||||
val constrainedWidthOffset = if (isPortrait) 0 else overflowPlaceables.maxOfOrNull { it.width } ?: 0
|
||||
|
||||
val nonOverflowConstraints = looseConstraints.offset(horizontal = -constrainedWidthOffset, vertical = -constrainedHeightOffset)
|
||||
val gridPlaceables = subcompose(BlurrableContentSlot.GRID, callGridSlot)
|
||||
.map { it.measure(nonOverflowConstraints) }
|
||||
val gridPlaceables = gridMeasurables.map { it.measure(nonOverflowConstraints) }
|
||||
|
||||
val barConstraints = if (bottomInsetPx > constrainedHeightOffset) {
|
||||
looseConstraints.offset(-constrainedWidthOffset, -bottomInsetPx)
|
||||
@@ -178,9 +173,7 @@ private fun BlurrableContentLayer(
|
||||
val barsMaxWidth = minOf(barConstraints.maxWidth, bottomSheetWidthPx)
|
||||
val barsConstrainedToSheet = barConstraints.copy(maxWidth = barsMaxWidth)
|
||||
|
||||
val barsPlaceables = subcompose(BlurrableContentSlot.BARS, barsSlot)
|
||||
.map { it.measure(barsConstrainedToSheet) }
|
||||
|
||||
val barsPlaceables = barsMeasurables.map { it.measure(barsConstrainedToSheet) }
|
||||
val barsHeightPx = barsPlaceables.sumOf { it.height }
|
||||
val barsWidthPx = barsPlaceables.maxOfOrNull { it.width } ?: 0
|
||||
|
||||
@@ -188,17 +181,17 @@ private fun BlurrableContentLayer(
|
||||
onMeasured(barsHeightPx, barsWidthPx)
|
||||
|
||||
val reactionsConstraints = barConstraints.offset(vertical = -barsHeightPx)
|
||||
val reactionsPlaceables = subcompose(BlurrableContentSlot.REACTIONS, reactionsSlot)
|
||||
.map { it.measure(reactionsConstraints) }
|
||||
val reactionsPlaceables = reactionsMeasurables.map { it.measure(reactionsConstraints) }
|
||||
|
||||
val audioIndicatorPlaceables = subcompose(BlurrableContentSlot.AUDIO_INDICATOR, audioIndicatorSlot)
|
||||
.map { it.measure(looseConstraints) }
|
||||
val audioIndicatorPlaceables = audioIndicatorMeasurables.map { it.measure(looseConstraints) }
|
||||
|
||||
layout(looseConstraints.maxWidth, looseConstraints.maxHeight) {
|
||||
overflowPlaceables.forEach {
|
||||
if (isPortrait) {
|
||||
if (isPortrait) {
|
||||
overflowPlaceables.forEach {
|
||||
it.place(0, looseConstraints.maxHeight - it.height)
|
||||
} else {
|
||||
}
|
||||
} else {
|
||||
overflowPlaceables.forEach {
|
||||
it.place(looseConstraints.maxWidth - it.width, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ sealed interface CallEvent {
|
||||
data class ShowGroupCallSafetyNumberChange(val identityRecords: List<IdentityRecord>) : CallEvent
|
||||
data object SwitchToSpeaker : CallEvent
|
||||
data object ShowSwipeToSpeakerHint : CallEvent
|
||||
data object ShowLargeGroupAutoMuteToast : CallEvent
|
||||
data class ShowRemoteMuteToast(private val muted: Recipient, private val mutedBy: Recipient) : CallEvent {
|
||||
fun getDescription(context: Context): String {
|
||||
return if (muted.isSelf && mutedBy.isSelf) {
|
||||
|
||||
@@ -31,7 +31,7 @@ class CallInfoCallbacks(
|
||||
override fun onShareLinkClicked() {
|
||||
val mimeType = Intent.normalizeMimeType("text/plain")
|
||||
val shareIntent = ShareCompat.IntentBuilder(activity)
|
||||
.setText(CallLinks.url(controlsAndInfoViewModel.rootKeySnapshot, controlsAndInfoViewModel.epochSnapshot))
|
||||
.setText(CallLinks.url(controlsAndInfoViewModel.rootKeySnapshot))
|
||||
.setType(mimeType)
|
||||
.createChooserIntent()
|
||||
|
||||
|
||||
@@ -125,7 +125,8 @@ fun CallScreen(
|
||||
callRecipient = callRecipient,
|
||||
isVideoCall = isRemoteVideoOffer,
|
||||
callStatus = callScreenState.callStatus,
|
||||
callScreenControlsListener = callScreenControlsListener
|
||||
callScreenControlsListener = callScreenControlsListener,
|
||||
localParticipant = localParticipant
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
@@ -40,7 +40,9 @@ import org.signal.core.ui.compose.Previews
|
||||
import org.signal.glide.compose.GlideImage
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.AvatarImage
|
||||
import org.thoughtcrime.securesms.events.CallParticipant
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.ringrtc.CameraState
|
||||
|
||||
private val textShadow = Shadow(
|
||||
color = Color(0f, 0f, 0f, 0.25f),
|
||||
@@ -52,7 +54,8 @@ fun IncomingCallScreen(
|
||||
callRecipient: Recipient,
|
||||
callStatus: String?,
|
||||
isVideoCall: Boolean,
|
||||
callScreenControlsListener: CallScreenControlsListener
|
||||
callScreenControlsListener: CallScreenControlsListener,
|
||||
localParticipant: CallParticipant = CallParticipant.EMPTY
|
||||
) {
|
||||
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
val callTypePadding = remember(isLandscape) {
|
||||
@@ -62,24 +65,37 @@ fun IncomingCallScreen(
|
||||
PaddingValues(top = 22.dp, bottom = 30.dp)
|
||||
}
|
||||
}
|
||||
val showLocalVideo = localParticipant.isVideoEnabled
|
||||
|
||||
Scaffold { contentPadding ->
|
||||
|
||||
GlideImage(
|
||||
model = callRecipient.contactPhoto,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.blur(
|
||||
radiusX = 25.dp,
|
||||
radiusY = 25.dp,
|
||||
edgeTreatment = BlurredEdgeTreatment.Rectangle
|
||||
)
|
||||
)
|
||||
if (showLocalVideo) {
|
||||
RemoteParticipantContent(
|
||||
participant = localParticipant,
|
||||
renderInPip = false,
|
||||
raiseHandAllowed = false,
|
||||
mirrorVideo = localParticipant.cameraDirection == CameraState.Direction.FRONT,
|
||||
showAudioIndicator = false,
|
||||
onInfoMoreInfoClick = null,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
GlideImage(
|
||||
model = callRecipient.contactPhoto,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.blur(
|
||||
radiusX = 25.dp,
|
||||
radiusY = 25.dp,
|
||||
edgeTreatment = BlurredEdgeTreatment.Rectangle
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = Color.Black.copy(alpha = 0.4f))
|
||||
.background(color = Color.Black.copy(alpha = if (showLocalVideo) 0.2f else 0.4f))
|
||||
) {}
|
||||
|
||||
CallScreenTopAppBar(
|
||||
|
||||
@@ -17,8 +17,8 @@ import androidx.compose.foundation.gestures.rememberDraggable2DState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.absoluteOffset
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -34,9 +34,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.NightPreview
|
||||
@@ -90,40 +92,50 @@ fun PictureInPicture(
|
||||
modifier = modifier
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val maxHeight = constraints.maxHeight
|
||||
val maxWidth = constraints.maxWidth
|
||||
val targetContentWidth = with(density) { state.targetSize.width.toPx().roundToInt() }
|
||||
val targetContentHeight = with(density) { state.targetSize.height.toPx().roundToInt() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
// In RTL mode, BoxWithConstraints places children at (maxWidth - childWidth, 0)
|
||||
// by default (TopStart alignment). Since we use absoluteOffset, we need to
|
||||
// compensate for this base position so that our corner offsets remain correct.
|
||||
val baseOffsetX = if (layoutDirection == LayoutDirection.Rtl) {
|
||||
maxWidth - targetContentWidth
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
var isDragging by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val topLeft = remember {
|
||||
IntOffset(0, 0)
|
||||
val topLeft = remember(baseOffsetX) {
|
||||
IntOffset(-baseOffsetX, 0)
|
||||
}
|
||||
|
||||
val topRight = remember(maxWidth, targetContentWidth) {
|
||||
IntOffset(maxWidth - targetContentWidth, 0)
|
||||
val topRight = remember(maxWidth, targetContentWidth, baseOffsetX) {
|
||||
IntOffset(maxWidth - targetContentWidth - baseOffsetX, 0)
|
||||
}
|
||||
|
||||
val bottomLeft = remember(maxHeight, targetContentHeight) {
|
||||
IntOffset(0, maxHeight - targetContentHeight)
|
||||
val bottomLeft = remember(maxHeight, targetContentHeight, baseOffsetX) {
|
||||
IntOffset(-baseOffsetX, maxHeight - targetContentHeight)
|
||||
}
|
||||
|
||||
val bottomRight = remember(maxWidth, maxHeight, targetContentWidth, targetContentHeight) {
|
||||
IntOffset(maxWidth - targetContentWidth, maxHeight - targetContentHeight)
|
||||
val bottomRight = remember(maxWidth, maxHeight, targetContentWidth, targetContentHeight, baseOffsetX) {
|
||||
IntOffset(maxWidth - targetContentWidth - baseOffsetX, maxHeight - targetContentHeight)
|
||||
}
|
||||
|
||||
val centerOffset = remember(maxWidth, maxHeight, targetContentWidth, targetContentHeight) {
|
||||
val centerOffset = remember(maxWidth, maxHeight, targetContentWidth, targetContentHeight, baseOffsetX) {
|
||||
IntOffset(
|
||||
(maxWidth - targetContentWidth) / 2,
|
||||
(maxWidth - targetContentWidth) / 2 - baseOffsetX,
|
||||
(maxHeight - targetContentHeight) / 2
|
||||
)
|
||||
}
|
||||
|
||||
val initialOffset = remember(maxWidth, maxHeight, targetContentWidth, targetContentHeight) {
|
||||
val initialOffset = remember(maxWidth, maxHeight, targetContentWidth, targetContentHeight, baseOffsetX) {
|
||||
getDesiredCornerOffset(state.corner, topLeft, topRight, bottomLeft, bottomRight)
|
||||
}
|
||||
|
||||
@@ -132,7 +144,7 @@ fun PictureInPicture(
|
||||
}
|
||||
|
||||
// Animate position when focused state changes or when constraints/corner changes
|
||||
LaunchedEffect(maxWidth, maxHeight, targetContentWidth, targetContentHeight, state.corner, isFocused) {
|
||||
LaunchedEffect(maxWidth, maxHeight, targetContentWidth, targetContentHeight, state.corner, isFocused, baseOffsetX) {
|
||||
if (!isDragging) {
|
||||
val targetOffset = if (isFocused) {
|
||||
centerOffset
|
||||
@@ -151,7 +163,7 @@ fun PictureInPicture(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(state.contentSize)
|
||||
.offset {
|
||||
.absoluteOffset {
|
||||
offsetAnimatable.value
|
||||
}
|
||||
.draggable2D(
|
||||
|
||||
@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.components.webrtc.v2
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.KeyguardManager
|
||||
import android.app.PictureInPictureParams
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -117,6 +118,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
|
||||
private var enterPipOnResume: Boolean = false
|
||||
private var lastProcessedIntentTimestamp = 0L
|
||||
private var previousEvent: WebRtcViewModel? = null
|
||||
private var answeredFromNotification: Boolean = false
|
||||
private var ephemeralStateDisposable = Disposable.empty()
|
||||
private val callPermissionsDialogController = CallPermissionsDialogController()
|
||||
|
||||
@@ -249,6 +251,8 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
|
||||
if (SignalStore.rateLimit.needsRecaptcha()) {
|
||||
RecaptchaProofBottomSheetFragment.show(supportFragmentManager)
|
||||
}
|
||||
|
||||
updateIncomingRingingVanity()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
@@ -263,6 +267,8 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
|
||||
Log.i(TAG, "onPause")
|
||||
super.onPause()
|
||||
|
||||
disableIncomingRingingVanity()
|
||||
|
||||
if (!isInPipMode() || isFinishing) {
|
||||
EventBus.getDefault().unregister(this)
|
||||
}
|
||||
@@ -388,7 +394,15 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
|
||||
viewModel.setRecipient(event.recipient)
|
||||
callScreen.setRecipient(event.recipient)
|
||||
event.isRemoteVideoOffer
|
||||
callScreen.setWebRtcCallState(event.state)
|
||||
|
||||
if (answeredFromNotification && event.state == WebRtcViewModel.State.CALL_INCOMING) {
|
||||
Log.d(TAG, "Suppressing CALL_INCOMING UI state because call was already answered from notification")
|
||||
} else {
|
||||
if (event.state != WebRtcViewModel.State.CALL_INCOMING) {
|
||||
answeredFromNotification = false
|
||||
}
|
||||
callScreen.setWebRtcCallState(event.state)
|
||||
}
|
||||
|
||||
if (event.state != previousCallState) {
|
||||
setTurnScreenOnForCallState(event.state)
|
||||
@@ -403,6 +417,9 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
|
||||
recreate()
|
||||
return
|
||||
}
|
||||
if (previousCallState != WebRtcViewModel.State.CALL_INCOMING) {
|
||||
updateIncomingRingingVanity()
|
||||
}
|
||||
}
|
||||
|
||||
WebRtcViewModel.State.CALL_OUTGOING -> handleOutgoingCall(event)
|
||||
@@ -679,8 +696,18 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
|
||||
|
||||
private fun processIntent(callIntent: CallIntent) {
|
||||
when (callIntent.action) {
|
||||
CallIntent.Action.ANSWER_AUDIO -> handleAnswerWithAudio()
|
||||
CallIntent.Action.ANSWER_VIDEO -> handleAnswerWithVideo()
|
||||
CallIntent.Action.ANSWER_AUDIO -> {
|
||||
handleAnswerWithAudio()
|
||||
if (!callIntent.isStartedFromFullScreen) {
|
||||
answeredFromNotification = true
|
||||
}
|
||||
}
|
||||
CallIntent.Action.ANSWER_VIDEO -> {
|
||||
handleAnswerWithVideo()
|
||||
if (!callIntent.isStartedFromFullScreen) {
|
||||
answeredFromNotification = true
|
||||
}
|
||||
}
|
||||
CallIntent.Action.DENY -> handleDenyCall()
|
||||
CallIntent.Action.END_CALL -> handleEndCall()
|
||||
else -> Unit
|
||||
@@ -952,6 +979,10 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
|
||||
is CallEvent.SwitchToSpeaker -> callScreen.switchToSpeakerView()
|
||||
is CallEvent.ShowSwipeToSpeakerHint -> callScreen.showSpeakerViewHint()
|
||||
is CallEvent.ShowRemoteMuteToast -> callScreen.showRemoteMuteToast(event.getDescription(this))
|
||||
is CallEvent.ShowLargeGroupAutoMuteToast -> {
|
||||
callScreen.onCallStateUpdate(CallControlsChange.MIC_OFF)
|
||||
callScreen.showRemoteMuteToast(getString(R.string.WebRtcCallView__youve_been_muted_large_group))
|
||||
}
|
||||
is CallEvent.ShowVideoTooltip -> {
|
||||
if (isInPipMode()) return
|
||||
|
||||
@@ -1051,6 +1082,24 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateIncomingRingingVanity() {
|
||||
val event = previousEvent ?: return
|
||||
if (event.state != WebRtcViewModel.State.CALL_INCOMING) return
|
||||
|
||||
val keyguardManager = getSystemService(KeyguardManager::class.java)
|
||||
val shouldEnable = keyguardManager == null || !keyguardManager.isKeyguardLocked
|
||||
|
||||
Log.i(TAG, "updateIncomingRingingVanity(): shouldEnable=$shouldEnable, keyguardLocked=${keyguardManager?.isKeyguardLocked}")
|
||||
AppDependencies.signalCallManager.setIncomingRingingVanity(shouldEnable)
|
||||
}
|
||||
|
||||
private fun disableIncomingRingingVanity() {
|
||||
val event = previousEvent ?: return
|
||||
if (event.state == WebRtcViewModel.State.CALL_INCOMING) {
|
||||
AppDependencies.signalCallManager.setIncomingRingingVanity(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeScreenshotSecurity() {
|
||||
if (TextSecurePreferences.isScreenSecurityEnabled(this)) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
|
||||
@@ -101,6 +101,7 @@ class WebRtcCallViewModel : ViewModel() {
|
||||
private var previousParticipantList = Collections.emptyList<CallParticipant>()
|
||||
private var switchOnFirstScreenShare = true
|
||||
private var showScreenShareTip = true
|
||||
private var hasShownAutoMuteToast = false
|
||||
|
||||
var isCallStarting = false
|
||||
private set
|
||||
@@ -313,6 +314,7 @@ class WebRtcCallViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
val wasMicrophoneEnabled = internalMicrophoneEnabled.value
|
||||
internalMicrophoneEnabled.value = localParticipant.isMicrophoneEnabled
|
||||
isAudioDeviceChangePending.value = webRtcViewModel.isAudioDeviceChangePending
|
||||
|
||||
@@ -320,6 +322,16 @@ class WebRtcCallViewModel : ViewModel() {
|
||||
remoteMutedBy.update { null }
|
||||
}
|
||||
|
||||
if (!hasShownAutoMuteToast &&
|
||||
wasMicrophoneEnabled &&
|
||||
!localParticipant.isMicrophoneEnabled &&
|
||||
webRtcViewModel.state == WebRtcViewModel.State.CALL_PRE_JOIN &&
|
||||
webRtcViewModel.remoteDevicesCount.orElse(0L) >= CallParticipantsState.PRE_JOIN_MUTE_THRESHOLD
|
||||
) {
|
||||
hasShownAutoMuteToast = true
|
||||
emitEvent(CallEvent.ShowLargeGroupAutoMuteToast)
|
||||
}
|
||||
|
||||
val state: CallParticipantsState = participantsState.value!!
|
||||
val wasScreenSharing: Boolean = state.focusedParticipant.isScreenSharing
|
||||
val newState: CallParticipantsState = CallParticipantsState.update(state, webRtcViewModel, enableVideo)
|
||||
|
||||
@@ -43,6 +43,7 @@ class ContactSearchMediator(
|
||||
private val fragment: Fragment,
|
||||
private val fixedContacts: Set<ContactSearchKey> = setOf(),
|
||||
selectionLimits: SelectionLimits,
|
||||
private val isMultiSelect: Boolean = true,
|
||||
displayOptions: ContactSearchAdapter.DisplayOptions,
|
||||
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
|
||||
private val callbacks: Callbacks = SimpleCallbacks(),
|
||||
@@ -61,6 +62,7 @@ class ContactSearchMediator(
|
||||
fragment,
|
||||
ContactSearchViewModel.Factory(
|
||||
selectionLimits = selectionLimits,
|
||||
isMultiSelect = isMultiSelect,
|
||||
repository = ContactSearchRepository(),
|
||||
performSafetyNumberChecks = performSafetyNumberChecks,
|
||||
arbitraryRepository = arbitraryRepository,
|
||||
|
||||
@@ -33,6 +33,7 @@ import org.whispersystems.signalservice.api.util.Preconditions
|
||||
class ContactSearchViewModel(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val selectionLimits: SelectionLimits,
|
||||
private val isMultiSelect: Boolean,
|
||||
private val contactSearchRepository: ContactSearchRepository,
|
||||
private val performSafetyNumberChecks: Boolean,
|
||||
private val arbitraryRepository: ArbitraryRepository?,
|
||||
@@ -116,7 +117,11 @@ class ContactSearchViewModel(
|
||||
safetyNumberRepository.batchSafetyNumberCheck(newSelectionEntries)
|
||||
}
|
||||
|
||||
internalSelectedContacts.update { it + newSelectionEntries }
|
||||
if (!isMultiSelect && newSelectionEntries.isNotEmpty()) {
|
||||
internalSelectedContacts.update { newSelectionEntries.toSet() }
|
||||
} else {
|
||||
internalSelectedContacts.update { it + newSelectionEntries }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +177,7 @@ class ContactSearchViewModel(
|
||||
|
||||
class Factory(
|
||||
private val selectionLimits: SelectionLimits,
|
||||
private val isMultiSelect: Boolean = true,
|
||||
private val repository: ContactSearchRepository,
|
||||
private val performSafetyNumberChecks: Boolean,
|
||||
private val arbitraryRepository: ArbitraryRepository?,
|
||||
@@ -183,6 +189,7 @@ class ContactSearchViewModel(
|
||||
ContactSearchViewModel(
|
||||
savedStateHandle = handle,
|
||||
selectionLimits = selectionLimits,
|
||||
isMultiSelect = isMultiSelect,
|
||||
contactSearchRepository = repository,
|
||||
performSafetyNumberChecks = performSafetyNumberChecks,
|
||||
arbitraryRepository = arbitraryRepository,
|
||||
|
||||
@@ -74,6 +74,7 @@ import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.core.util.StringUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.core.ui.view.Stub;
|
||||
import org.signal.ringrtc.CallLinkRootKey;
|
||||
import org.thoughtcrime.securesms.BindableConversationItem;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
@@ -1234,14 +1235,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
//noinspection ConstantConditions
|
||||
LinkPreview linkPreview = ((MmsMessageRecord) messageRecord).getLinkPreviews().get(0);
|
||||
|
||||
CallLinks.CallLinkParseResult callLinkParseResult = CallLinks.isCallLink(linkPreview.getUrl()) ? CallLinks.parseUrl(linkPreview.getUrl()) : null;
|
||||
if (callLinkParseResult != null) {
|
||||
CallLinkRootKey callLinkRootKey = CallLinks.isCallLink(linkPreview.getUrl()) ? CallLinks.parseUrl(linkPreview.getUrl()) : null;
|
||||
if (callLinkRootKey != null) {
|
||||
joinCallLinkStub.setVisibility(View.VISIBLE);
|
||||
joinCallLinkStub.get().setTextColor(ContextCompat.getColor(context, messageRecord.isOutgoing() ? org.signal.core.ui.R.color.signal_light_colorOnPrimary : org.signal.core.ui.R.color.signal_colorOnPrimaryContainer));
|
||||
joinCallLinkStub.get().setBackgroundColor(ContextCompat.getColor(context, messageRecord.isOutgoing() ? org.signal.core.ui.R.color.signal_light_colorTransparent2 : org.signal.core.ui.R.color.signal_colorOnPrimary));
|
||||
joinCallLinkStub.get().setOnClickListener(v -> {
|
||||
if (eventListener != null) {
|
||||
eventListener.onJoinCallLink(callLinkParseResult.getRootKey(), callLinkParseResult.getEpoch());
|
||||
eventListener.onJoinCallLink(callLinkRootKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,7 +27,9 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.IconButtons.IconButton
|
||||
@@ -67,6 +69,7 @@ fun RecipientSearchBar(
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = { Text(hint) },
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(textDirection = TextDirection.ContentOrLtr),
|
||||
shape = SearchBarDefaults.inputFieldShape,
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
|
||||
@@ -123,16 +123,17 @@ class MultiselectForwardFragment :
|
||||
|
||||
contactSearchRecycler = view.findViewById(R.id.contact_selection_list)
|
||||
contactSearchMediator = ContactSearchMediator(
|
||||
this,
|
||||
emptySet(),
|
||||
RemoteConfig.shareSelectionLimit,
|
||||
ContactSearchAdapter.DisplayOptions(
|
||||
fragment = this,
|
||||
fixedContacts = emptySet(),
|
||||
selectionLimits = RemoteConfig.shareSelectionLimit,
|
||||
isMultiSelect = !args.selectSingleRecipient,
|
||||
displayOptions = ContactSearchAdapter.DisplayOptions(
|
||||
displayCheckBox = !args.selectSingleRecipient,
|
||||
displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER,
|
||||
displayStoryRing = true
|
||||
),
|
||||
this::getConfiguration,
|
||||
object : ContactSearchMediator.SimpleCallbacks() {
|
||||
mapStateToConfiguration = this::getConfiguration,
|
||||
callbacks = object : ContactSearchMediator.SimpleCallbacks() {
|
||||
override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey> {
|
||||
val filtered: Set<ContactSearchKey> = filterContacts(view, contactSearchKeys)
|
||||
Log.d(TAG, "onBeforeContactsSelected() Attempting to select: ${contactSearchKeys.map { it.toString() }}, Filtered selection: ${filtered.map { it.toString() } }")
|
||||
|
||||
@@ -57,6 +57,7 @@ import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.doOnPreDraw
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentResultListener
|
||||
import androidx.fragment.app.activityViewModels
|
||||
@@ -121,7 +122,6 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.core.util.setActionItemTint
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.ringrtc.CallLinkEpoch
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
import org.thoughtcrime.securesms.BlockUnblockDialog
|
||||
import org.thoughtcrime.securesms.GroupMembersDialog
|
||||
@@ -280,6 +280,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModelV2
|
||||
import org.thoughtcrime.securesms.longmessage.LongMessageFragment
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel
|
||||
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
|
||||
@@ -413,6 +414,7 @@ class ConversationFragment :
|
||||
private const val ACTION_PINNED_SHORTCUT = "action_pinned_shortcut"
|
||||
private const val SAVED_STATE_IS_SEARCH_REQUESTED = "is_search_requested"
|
||||
private const val EMOJI_SEARCH_FRAGMENT_TAG = "EmojiSearchFragment"
|
||||
private const val MESSAGE_DETAILS_TAG = "MessageDetailsFragment"
|
||||
|
||||
private const val SCROLL_HEADER_ANIMATION_DURATION: Long = 100L
|
||||
private const val SCROLL_HEADER_CLOSE_DELAY: Long = SCROLL_HEADER_ANIMATION_DURATION * 4
|
||||
@@ -630,6 +632,7 @@ class ConversationFragment :
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
SignalLocalMetrics.ConversationOpen.start()
|
||||
registerForResults()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
@@ -683,8 +686,6 @@ class ConversationFragment :
|
||||
|
||||
SpoilerAnnotation.resetRevealedSpoilers()
|
||||
|
||||
registerForResults()
|
||||
|
||||
inputPanel.setMediaListener(InputPanelMediaListener())
|
||||
|
||||
binding.conversationItemRecycler.addOnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
|
||||
@@ -782,6 +783,10 @@ class ConversationFragment :
|
||||
}
|
||||
keyboardEvents = null
|
||||
|
||||
if (!requireActivity().isChangingConfigurations) {
|
||||
(requireActivity().supportFragmentManager.findFragmentByTag(MESSAGE_DETAILS_TAG) as? DialogFragment)?.dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
super.onDestroyView()
|
||||
if (pinnedShortcutReceiver != null) {
|
||||
requireActivity().unregisterReceiver(pinnedShortcutReceiver)
|
||||
@@ -2795,7 +2800,11 @@ class ConversationFragment :
|
||||
|
||||
private fun handleDisplayDetails(conversationMessage: ConversationMessage) {
|
||||
val recipientSnapshot = viewModel.recipientSnapshot ?: return
|
||||
MessageDetailsFragment.create(conversationMessage.messageRecord, recipientSnapshot.id).show(childFragmentManager, null)
|
||||
if (requireActivity() is MainActivity) {
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.MessageDetails(recipientSnapshot.id, conversationMessage.messageRecord.id))
|
||||
} else {
|
||||
MessageDetailsFragment.create(conversationMessage.messageRecord, recipientSnapshot.id).show(requireActivity().supportFragmentManager, MESSAGE_DETAILS_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDeleteMessages(messageParts: Set<MultiselectPart>) {
|
||||
@@ -3311,8 +3320,10 @@ class ConversationFragment :
|
||||
.show(childFragmentManager)
|
||||
} else if (messageRecord.hasFailedWithNetworkFailures()) {
|
||||
ConversationDialogs.displayMessageCouldNotBeSentDialog(requireContext(), messageRecord)
|
||||
} else if (requireActivity() is MainActivity) {
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.MessageDetails(recipientId, messageRecord.id))
|
||||
} else {
|
||||
MessageDetailsFragment.create(messageRecord, recipientId).show(childFragmentManager, null)
|
||||
MessageDetailsFragment.create(messageRecord, recipientId).show(requireActivity().supportFragmentManager, MESSAGE_DETAILS_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3741,8 +3752,8 @@ class ConversationFragment :
|
||||
GroupDescriptionDialog.show(childFragmentManager, groupName, description, shouldLinkifyWebLinks)
|
||||
}
|
||||
|
||||
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey, callLinkEpoch: CallLinkEpoch?) {
|
||||
CommunicationActions.startVideoCall(this@ConversationFragment, callLinkRootKey, callLinkEpoch) {
|
||||
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey) {
|
||||
CommunicationActions.startVideoCall(this@ConversationFragment, callLinkRootKey) {
|
||||
YouAreAlreadyInACallSnackbar.show(requireView())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +285,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
|
||||
contactSearchMediator = new ContactSearchMediator(this,
|
||||
Collections.emptySet(),
|
||||
SelectionLimits.NO_LIMITS,
|
||||
false,
|
||||
new ContactSearchAdapter.DisplayOptions(
|
||||
false,
|
||||
ContactSearchAdapter.DisplaySecondaryInformation.NEVER,
|
||||
|
||||
@@ -21,21 +21,18 @@ import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.signal.ringrtc.CallLinkEpoch
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
import org.signal.ringrtc.CallLinkState.Restrictions
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogRow
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColorHash
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.CallLinkUpdateSendJob
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
|
||||
import org.whispersystems.signalservice.api.storage.StorageId
|
||||
import org.whispersystems.signalservice.internal.push.SyncMessage
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
@@ -50,7 +47,6 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
|
||||
const val TABLE_NAME = "call_link"
|
||||
const val ID = "_id"
|
||||
const val ROOT_KEY = "root_key"
|
||||
const val EPOCH = "epoch"
|
||||
const val ROOM_ID = "room_id"
|
||||
const val ADMIN_KEY = "admin_key"
|
||||
const val NAME = "name"
|
||||
@@ -72,8 +68,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
|
||||
$REVOKED INTEGER NOT NULL,
|
||||
$EXPIRATION INTEGER NOT NULL,
|
||||
$RECIPIENT_ID INTEGER UNIQUE REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE,
|
||||
$DELETION_TIMESTAMP INTEGER DEFAULT 0 NOT NULL,
|
||||
$EPOCH BLOB DEFAULT NULL
|
||||
$DELETION_TIMESTAMP INTEGER DEFAULT 0 NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -133,7 +128,6 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
|
||||
.values(
|
||||
contentValuesOf(
|
||||
ROOT_KEY to credentials.linkKeyBytes,
|
||||
EPOCH to credentials.epochBytes,
|
||||
ADMIN_KEY to credentials.adminPassBytes
|
||||
)
|
||||
)
|
||||
@@ -193,10 +187,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
|
||||
.readToSingleObject { CallLinkDeserializer.deserialize(it) }
|
||||
}
|
||||
|
||||
fun getOrCreateCallLinkByRootKey(
|
||||
callLinkRootKey: CallLinkRootKey,
|
||||
callLinkEpoch: CallLinkEpoch?
|
||||
): CallLink {
|
||||
fun getOrCreateCallLinkByRootKey(callLinkRootKey: CallLinkRootKey): CallLink {
|
||||
val roomId = CallLinkRoomId.fromBytes(callLinkRootKey.deriveRoomId())
|
||||
val callLink = getCallLinkByRoomId(roomId)
|
||||
return if (callLink == null) {
|
||||
@@ -205,7 +196,6 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
|
||||
roomId = roomId,
|
||||
credentials = CallLinkCredentials(
|
||||
linkKeyBytes = callLinkRootKey.keyBytes,
|
||||
epochBytes = callLinkEpoch?.bytes,
|
||||
adminPassBytes = null
|
||||
),
|
||||
state = SignalCallLinkState(),
|
||||
@@ -215,33 +205,12 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
|
||||
insertCallLink(link)
|
||||
return getCallLinkByRoomId(roomId)!!
|
||||
} else {
|
||||
if (callLink.credentials?.epoch != callLinkEpoch) {
|
||||
val modifiedCallLink = overwriteEpoch(callLink, callLinkEpoch)
|
||||
AppDependencies.jobManager.add(
|
||||
CallLinkUpdateSendJob(
|
||||
callLink.credentials!!.roomId,
|
||||
SyncMessage.CallLinkUpdate.Type.UPDATE
|
||||
)
|
||||
)
|
||||
modifiedCallLink
|
||||
} else {
|
||||
callLink
|
||||
}
|
||||
callLink
|
||||
}
|
||||
}
|
||||
|
||||
private fun overwriteEpoch(callLink: CallLink, callLinkEpoch: CallLinkEpoch?): CallLink {
|
||||
val modifiedCallLink = callLink.copy(
|
||||
deletionTimestamp = 0,
|
||||
credentials = callLink.credentials!!.copy(epochBytes = callLinkEpoch?.bytes)
|
||||
)
|
||||
updateCallLinkCredentials(modifiedCallLink.roomId, modifiedCallLink.credentials!!)
|
||||
return modifiedCallLink
|
||||
}
|
||||
|
||||
fun insertOrUpdateCallLinkByRootKey(
|
||||
callLinkRootKey: CallLinkRootKey,
|
||||
callLinkEpoch: CallLinkEpoch?,
|
||||
adminPassKey: ByteArray?,
|
||||
deletionTimestamp: Long = 0L,
|
||||
storageId: StorageId? = null
|
||||
@@ -256,7 +225,6 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
|
||||
roomId = roomId,
|
||||
credentials = CallLinkCredentials(
|
||||
linkKeyBytes = callLinkRootKey.keyBytes,
|
||||
epochBytes = callLinkEpoch?.bytes,
|
||||
adminPassBytes = adminPassKey
|
||||
),
|
||||
state = SignalCallLinkState(),
|
||||
@@ -283,8 +251,7 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
|
||||
writableDatabase.update(TABLE_NAME)
|
||||
.values(
|
||||
ADMIN_KEY to adminPassKey,
|
||||
ROOT_KEY to callLinkRootKey.keyBytes,
|
||||
EPOCH to callLinkEpoch?.bytes
|
||||
ROOT_KEY to callLinkRootKey.keyBytes
|
||||
)
|
||||
.where("$ROOM_ID = ?", callLink.roomId.serialize())
|
||||
.run()
|
||||
@@ -495,7 +462,6 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
|
||||
RECIPIENT_ID to data.recipientId.takeIf { it != RecipientId.UNKNOWN }?.toLong(),
|
||||
ROOM_ID to data.roomId.serialize(),
|
||||
ROOT_KEY to data.credentials?.linkKeyBytes,
|
||||
EPOCH to data.credentials?.epochBytes,
|
||||
ADMIN_KEY to data.credentials?.adminPassBytes
|
||||
).apply {
|
||||
putAll(data.state.serialize())
|
||||
@@ -519,7 +485,6 @@ class CallLinkTable(context: Context, databaseHelper: SignalDatabase) : Database
|
||||
credentials = data.requireBlob(ROOT_KEY)?.let { linkKey ->
|
||||
CallLinkCredentials(
|
||||
linkKeyBytes = linkKey,
|
||||
epochBytes = data.requireBlob(EPOCH),
|
||||
adminPassBytes = data.requireBlob(ADMIN_KEY)
|
||||
)
|
||||
},
|
||||
|
||||
@@ -659,7 +659,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
|
||||
val membershipValues = mutableListOf<ContentValues>()
|
||||
val groupRecipientId = recipients.getOrInsertFromGroupId(groupId)
|
||||
val members: List<RecipientId> = memberCollection.toSet().sorted()
|
||||
var groupMembers: List<RecipientId> = members
|
||||
var groupMembers: Collection<RecipientId> = members
|
||||
|
||||
val values = ContentValues()
|
||||
|
||||
@@ -815,7 +815,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
|
||||
val groupMembers = getV2GroupMembers(decryptedGroup, true)
|
||||
var groupSendEndorsementRecords: GroupSendEndorsementRecords? = receivedGroupSendEndorsements?.toGroupSendEndorsementRecords() ?: getGroupSendEndorsements(groupId)
|
||||
|
||||
val addedMembers: List<RecipientId> = if (existingGroup.isPresent && existingGroup.get().isV2Group) {
|
||||
val addedMembers: Collection<RecipientId> = if (existingGroup.isPresent && existingGroup.get().isV2Group) {
|
||||
val change = GroupChangeReconstruct.reconstructGroupChange(existingGroup.get().requireV2GroupProperties().decryptedGroup, decryptedGroup)
|
||||
val removed: List<ServiceId> = DecryptedGroupUtil.removedMembersServiceIdList(change)
|
||||
|
||||
@@ -1420,7 +1420,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
|
||||
.toMutableList()
|
||||
}
|
||||
|
||||
private fun getV2GroupMembers(decryptedGroup: DecryptedGroup, shouldRetry: Boolean): List<RecipientId> {
|
||||
private fun getV2GroupMembers(decryptedGroup: DecryptedGroup, shouldRetry: Boolean): Set<RecipientId> {
|
||||
val ids: List<RecipientId> = decryptedGroup.members.toAciList().toRecipientIds()
|
||||
|
||||
return if (RemappedRecords.getInstance().areAnyRemapped(ids)) {
|
||||
@@ -1433,7 +1433,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
|
||||
throw IllegalStateException("Remapped records in group membership!")
|
||||
}
|
||||
} else {
|
||||
ids
|
||||
ids.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3877,7 +3877,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
}
|
||||
}
|
||||
|
||||
fun setHasGroupsInCommon(recipientIds: List<RecipientId?>) {
|
||||
fun setHasGroupsInCommon(recipientIds: Collection<RecipientId>) {
|
||||
if (recipientIds.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V297_AddPinnedMessa
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V298_DoNotBackupReleaseNotes
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V299_AddAttachmentMetadataTable
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V300_AddKeyTransparencyColumn
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V301_RemoveCallLinkEpoch
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
|
||||
|
||||
/**
|
||||
@@ -314,10 +315,11 @@ object SignalDatabaseMigrations {
|
||||
297 to V297_AddPinnedMessageColumns,
|
||||
298 to V298_DoNotBackupReleaseNotes,
|
||||
299 to V299_AddAttachmentMetadataTable,
|
||||
300 to V300_AddKeyTransparencyColumn
|
||||
300 to V300_AddKeyTransparencyColumn,
|
||||
301 to V301_RemoveCallLinkEpoch
|
||||
)
|
||||
|
||||
const val DATABASE_VERSION = 300
|
||||
const val DATABASE_VERSION = 301
|
||||
|
||||
@JvmStatic
|
||||
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database.helpers.migration
|
||||
|
||||
import android.app.Application
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* We planned to introduce CallLink epochs as a first-class field to clients.
|
||||
* Now, we plan to introduce epochs as an internal detail in CallLink root keys.
|
||||
* Epochs were never enabled in production so no clients should have them.
|
||||
*/
|
||||
object V301_RemoveCallLinkEpoch : SignalDatabaseMigration {
|
||||
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
db.execSQL("ALTER TABLE call_link DROP COLUMN epoch")
|
||||
}
|
||||
}
|
||||
@@ -159,12 +159,22 @@ public final class GiphyMp4ProjectionPlayerHolder implements Player.Listener, De
|
||||
|
||||
@Override
|
||||
public void onPause(@NonNull LifecycleOwner owner) {
|
||||
if (player.getExoPlayer() != null) {
|
||||
player.getExoPlayer().stop();
|
||||
player.getExoPlayer().clearMediaItems();
|
||||
player.getExoPlayer().removeListener(this);
|
||||
AppDependencies.getExoPlayerPool().pool(player.getExoPlayer());
|
||||
returnPlayerToPool();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStop(@NonNull LifecycleOwner owner) {
|
||||
returnPlayerToPool();
|
||||
}
|
||||
|
||||
private void returnPlayerToPool() {
|
||||
ExoPlayer exoPlayer = player.getExoPlayer();
|
||||
if (exoPlayer != null) {
|
||||
exoPlayer.stop();
|
||||
exoPlayer.clearMediaItems();
|
||||
exoPlayer.removeListener(this);
|
||||
player.setExoPlayer(null);
|
||||
AppDependencies.getExoPlayerPool().pool(exoPlayer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -70,11 +71,14 @@ fun MemberLabelPill(
|
||||
modifier: Modifier = defaultModifier,
|
||||
textStyle: TextStyle = defaultTextStyle()
|
||||
) {
|
||||
val shape = RoundedCornerShape(percent = 50)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(
|
||||
color = backgroundColor,
|
||||
shape = RoundedCornerShape(percent = 50)
|
||||
shape = shape
|
||||
)
|
||||
.then(modifier),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
|
||||
@@ -212,8 +212,16 @@ class ArchiveThumbnailUploadJob private constructor(
|
||||
}
|
||||
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
Log.w(TAG, "Failed to get an upload spec with status code ${specResult.code}")
|
||||
return Result.retry(defaultBackoff())
|
||||
return when (specResult.code) {
|
||||
429 -> {
|
||||
Log.w(TAG, "Rate limited when getting upload spec.")
|
||||
Result.retry(specResult.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Failed to get an upload spec with status code ${specResult.code}")
|
||||
Result.retry(defaultBackoff())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,8 +269,16 @@ class ArchiveThumbnailUploadJob private constructor(
|
||||
}
|
||||
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
Log.w(TAG, "Hit a status code error of ${result.code} when trying to archive thumbnail for $attachmentId")
|
||||
Result.retry(defaultBackoff())
|
||||
when (result.code) {
|
||||
429 -> {
|
||||
Log.w(TAG, "Rate limited when trying to archive thumbnail for $attachmentId")
|
||||
Result.retry(result.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Hit a status code error of ${result.code} when trying to archive thumbnail for $attachmentId")
|
||||
Result.retry(defaultBackoff())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.ApplicationError -> Result.fatalFailure(RuntimeException(result.throwable))
|
||||
|
||||
@@ -74,7 +74,7 @@ public final class AttachmentCompressionJob extends BaseJob {
|
||||
int mmsSubscriptionId)
|
||||
{
|
||||
return new AttachmentCompressionJob(databaseAttachment.attachmentId,
|
||||
MediaUtil.isVideo(databaseAttachment) && MediaConstraints.isVideoTranscodeAvailable(),
|
||||
MediaUtil.isVideo(databaseAttachment) && !databaseAttachment.videoGif && MediaConstraints.isVideoTranscodeAvailable(),
|
||||
mms,
|
||||
mmsSubscriptionId);
|
||||
}
|
||||
|
||||
@@ -116,9 +116,19 @@ class AttachmentDownloadJob private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
AttachmentTable.TRANSFER_PROGRESS_STARTED,
|
||||
AttachmentTable.TRANSFER_PROGRESS_STARTED -> {
|
||||
Log.i(TAG, "${databaseAttachment.attachmentId} is in started state, enqueueing force download in case existing job is constraint-blocked")
|
||||
val downloadJob = AttachmentDownloadJob(
|
||||
messageId = databaseAttachment.mmsId,
|
||||
attachmentId = databaseAttachment.attachmentId,
|
||||
forceDownload = true
|
||||
)
|
||||
AppDependencies.jobManager.add(downloadJob)
|
||||
downloadJob.id
|
||||
}
|
||||
|
||||
AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE -> {
|
||||
Log.d(TAG, "${databaseAttachment.attachmentId} is downloading or permanently failed, transferState: $transferState")
|
||||
Log.d(TAG, "${databaseAttachment.attachmentId} is permanently failed, transferState: $transferState")
|
||||
null
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,12 @@ class BackupMessagesJob private constructor(
|
||||
val auth = when (val result = BackupRepository.getSvrBAuth()) {
|
||||
is NetworkResult.Success -> result.result
|
||||
is NetworkResult.NetworkError -> return Result.retry(defaultBackoff()).logW(TAG, "Network error when getting SVRB auth.", result.getCause(), true)
|
||||
is NetworkResult.StatusCodeError -> return Result.retry(defaultBackoff()).logW(TAG, "Status code error when getting SVRB auth.", result.getCause(), true)
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
return when (result.code) {
|
||||
429 -> Result.retry(result.retryAfter()?.inWholeMilliseconds ?: defaultBackoff()).logW(TAG, "Rate limited when getting SVRB auth.", result.getCause(), true)
|
||||
else -> Result.retry(defaultBackoff()).logW(TAG, "Status code error when getting SVRB auth.", result.getCause(), true)
|
||||
}
|
||||
}
|
||||
is NetworkResult.ApplicationError -> throw result.throwable
|
||||
}
|
||||
|
||||
@@ -183,7 +188,10 @@ class BackupMessagesJob private constructor(
|
||||
Log.i(TAG, "[svrb-restore] No backup data found, continuing.", true)
|
||||
null
|
||||
} else {
|
||||
return Result.retry(defaultBackoff()).logW(TAG, "[svrb-restore] Status code error when getting remote forward secrecy metadata.", result.getCause(), true)
|
||||
return when (result.code) {
|
||||
429 -> Result.retry(result.retryAfter()?.inWholeMilliseconds ?: defaultBackoff()).logW(TAG, "[svrb-restore] Rate limited when getting remote forward secrecy metadata.", result.getCause(), true)
|
||||
else -> Result.retry(defaultBackoff()).logW(TAG, "[svrb-restore] Status code error when getting remote forward secrecy metadata.", result.getCause(), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
is NetworkResult.ApplicationError -> {
|
||||
@@ -311,6 +319,10 @@ class BackupMessagesJob private constructor(
|
||||
return Result.failure()
|
||||
}
|
||||
}
|
||||
429 -> {
|
||||
Log.i(TAG, "Rate limited when getting upload spec.", result.getCause(), true)
|
||||
return Result.retry(result.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
|
||||
}
|
||||
else -> {
|
||||
Log.i(TAG, "Status code failure", result.getCause(), true)
|
||||
return Result.retry(defaultBackoff())
|
||||
@@ -359,14 +371,21 @@ class BackupMessagesJob private constructor(
|
||||
}
|
||||
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
Log.i(TAG, "Status code failure", uploadResult.getCause(), true)
|
||||
when (uploadResult.code) {
|
||||
400 -> {
|
||||
Log.w(TAG, "400 likely means bad resumable state. Resetting the upload spec before retrying.", true)
|
||||
resumableMessagesBackupUploadSpec = null
|
||||
return Result.retry(defaultBackoff())
|
||||
}
|
||||
429 -> {
|
||||
Log.w(TAG, "Rate limited when uploading backup file.", uploadResult.getCause(), true)
|
||||
return Result.retry(uploadResult.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
|
||||
}
|
||||
else -> {
|
||||
Log.i(TAG, "Status code failure (${uploadResult.code})", uploadResult.getCause(), true)
|
||||
return Result.retry(defaultBackoff())
|
||||
}
|
||||
}
|
||||
return Result.retry(defaultBackoff())
|
||||
}
|
||||
|
||||
is NetworkResult.ApplicationError -> throw uploadResult.throwable
|
||||
|
||||
@@ -81,8 +81,20 @@ class BackupRefreshJob private constructor(
|
||||
SignalStore.backup.lastCheckInSnoozeMillis = 0
|
||||
Result.success()
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Failed to refresh backup with server.", result.getCause())
|
||||
is NetworkResult.NetworkError -> {
|
||||
Log.w(TAG, "Network error when refreshing backup.", result.getCause())
|
||||
Result.retry(defaultBackoff())
|
||||
}
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
Log.w(TAG, "Status code error (${result.code}) when refreshing backup.", result.getCause())
|
||||
if (result.code == 429) {
|
||||
Result.retry(result.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
|
||||
} else {
|
||||
Result.failure()
|
||||
}
|
||||
}
|
||||
is NetworkResult.ApplicationError -> {
|
||||
Log.w(TAG, "Application error when refreshing backup.", result.throwable)
|
||||
Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ class CallLinkUpdateSendJob private constructor(
|
||||
val callLinkUpdate = CallLinkUpdate(
|
||||
rootKey = callLink.credentials.linkKeyBytes.toByteString(),
|
||||
adminPasskey = callLink.credentials.adminPassBytes?.toByteString(),
|
||||
epoch = callLink.credentials.epochBytes?.toByteString(),
|
||||
type = callLinkUpdateType
|
||||
)
|
||||
|
||||
|
||||
@@ -202,6 +202,10 @@ class CopyAttachmentToArchiveJob private constructor(private val attachmentId: A
|
||||
|
||||
Result.retry(defaultBackoff())
|
||||
}
|
||||
429 -> {
|
||||
Log.w(TAG, "[$attachmentId]$mediaIdLog Rate limit exceeded. Retrying.")
|
||||
Result.retry(archiveResult.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "[$attachmentId]$mediaIdLog Got back a non-2xx status code: ${archiveResult.code}. Retrying.")
|
||||
Result.retry(defaultBackoff())
|
||||
|
||||
@@ -48,7 +48,6 @@ class RefreshCallLinkDetailsJob private constructor(
|
||||
val manager: SignalCallLinkManager = AppDependencies.signalCallManager.callLinkManager
|
||||
val credentials = CallLinkCredentials(
|
||||
linkKeyBytes = callLinkUpdate.rootKey!!.toByteArray(),
|
||||
epochBytes = callLinkUpdate.epoch?.toByteArray(),
|
||||
adminPassBytes = callLinkUpdate.adminPasskey?.toByteArray()
|
||||
)
|
||||
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.core.util.Base64;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
import org.signal.libsignal.usernames.Username;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository;
|
||||
import org.thoughtcrime.securesms.badges.Badges;
|
||||
import org.thoughtcrime.securesms.badges.models.Badge;
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository;
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
||||
import org.thoughtcrime.securesms.database.RecipientTable;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork;
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName;
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.signal.core.util.Util;
|
||||
import org.whispersystems.signalservice.api.NetworkResultUtil;
|
||||
import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException;
|
||||
import org.whispersystems.signalservice.api.crypto.ProfileCipher;
|
||||
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
|
||||
import org.whispersystems.signalservice.api.util.ExpiringProfileCredentialUtil;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
import org.whispersystems.signalservice.internal.push.WhoAmIResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
|
||||
/**
|
||||
* Refreshes the profile of the local user. Different from {@link RetrieveProfileJob} in that we
|
||||
* have to sometimes look at/set different data stores, and we will *always* do the fetch regardless
|
||||
* of caching.
|
||||
*/
|
||||
public class RefreshOwnProfileJob extends BaseJob {
|
||||
|
||||
public static final String KEY = "RefreshOwnProfileJob";
|
||||
|
||||
private static final String TAG = Log.tag(RefreshOwnProfileJob.class);
|
||||
|
||||
private static final String SUBSCRIPTION_QUEUE = ProfileUploadJob.QUEUE + "_Subscription";
|
||||
private static final String BOOST_QUEUE = ProfileUploadJob.QUEUE + "_Boost";
|
||||
|
||||
public RefreshOwnProfileJob() {
|
||||
this(ProfileUploadJob.QUEUE);
|
||||
}
|
||||
|
||||
private RefreshOwnProfileJob(@NonNull String queue) {
|
||||
this(new Parameters.Builder()
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setQueue(queue)
|
||||
.setMaxInstancesForFactory(1)
|
||||
.setMaxAttempts(10)
|
||||
.build());
|
||||
}
|
||||
|
||||
public static @NonNull RefreshOwnProfileJob forSubscription() {
|
||||
return new RefreshOwnProfileJob(SUBSCRIPTION_QUEUE);
|
||||
}
|
||||
|
||||
public static @NonNull RefreshOwnProfileJob forBoost() {
|
||||
return new RefreshOwnProfileJob(BOOST_QUEUE);
|
||||
}
|
||||
|
||||
|
||||
private RefreshOwnProfileJob(@NonNull Parameters parameters) {
|
||||
super(parameters);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable byte[] serialize() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRun() throws Exception {
|
||||
if (!SignalStore.account().isRegistered() || TextUtils.isEmpty(SignalStore.account().getE164())) {
|
||||
Log.w(TAG, "Not yet registered!");
|
||||
return;
|
||||
}
|
||||
|
||||
if ((SignalStore.svr().hasPin() || SignalStore.account().restoredAccountEntropyPool()) && !SignalStore.svr().hasOptedOut() && SignalStore.storageService().getLastSyncTime() == 0) {
|
||||
Log.i(TAG, "Registered with PIN or AEP but haven't completed storage sync yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SignalStore.registration().hasUploadedProfile() && SignalStore.account().isPrimaryDevice()) {
|
||||
Log.i(TAG, "Registered but haven't uploaded profile yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
Recipient self = Recipient.self();
|
||||
|
||||
ProfileAndCredential profileAndCredential;
|
||||
try {
|
||||
profileAndCredential = ProfileUtil.retrieveProfileSync(context, self, getRequestType(self), false);
|
||||
} catch (IllegalStateException e) {
|
||||
Log.w(TAG, "Unexpected exception result from profile fetch. Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
SignalServiceProfile profile = profileAndCredential.getProfile();
|
||||
|
||||
if (Util.isEmpty(profile.getName()) &&
|
||||
Util.isEmpty(profile.getAvatar()) &&
|
||||
Util.isEmpty(profile.getAbout()) &&
|
||||
Util.isEmpty(profile.getAboutEmoji()))
|
||||
{
|
||||
Log.w(TAG, "The profile we retrieved was empty! Ignoring it.");
|
||||
|
||||
if (!self.getProfileName().isEmpty()) {
|
||||
Log.w(TAG, "We have a name locally. Scheduling a profile upload.");
|
||||
AppDependencies.getJobManager().add(new ProfileUploadJob());
|
||||
} else {
|
||||
Log.w(TAG, "We don't have a name locally, either!");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setProfileName(profile.getName());
|
||||
setProfileAbout(profile.getAbout(), profile.getAboutEmoji());
|
||||
setProfileAvatar(profile.getAvatar());
|
||||
setProfileCapabilities(profile.getCapabilities());
|
||||
setProfileBadges(profile.getBadges());
|
||||
ensureUnidentifiedAccessCorrect(profile.getUnidentifiedAccess(), profile.isUnrestrictedUnidentifiedAccess());
|
||||
ensurePhoneNumberSharingIsCorrect(profile.getPhoneNumberSharing());
|
||||
|
||||
profileAndCredential.getExpiringProfileKeyCredential()
|
||||
.ifPresent(expiringProfileKeyCredential -> setExpiringProfileKeyCredential(self, ProfileKeyUtil.getSelfProfileKey(), expiringProfileKeyCredential));
|
||||
|
||||
SignalStore.registration().setHasDownloadedProfile(true);
|
||||
|
||||
StoryOnboardingDownloadJob.Companion.enqueueIfNeeded();
|
||||
|
||||
checkUsernameIsInSync();
|
||||
}
|
||||
|
||||
private void setExpiringProfileKeyCredential(@NonNull Recipient recipient,
|
||||
@NonNull ProfileKey recipientProfileKey,
|
||||
@NonNull ExpiringProfileKeyCredential credential)
|
||||
{
|
||||
RecipientTable recipientTable = SignalDatabase.recipients();
|
||||
recipientTable.setProfileKeyCredential(recipient.getId(), recipientProfileKey, credential);
|
||||
}
|
||||
|
||||
private static SignalServiceProfile.RequestType getRequestType(@NonNull Recipient recipient) {
|
||||
return ExpiringProfileCredentialUtil.isValid(recipient.getExpiringProfileKeyCredential()) ? SignalServiceProfile.RequestType.PROFILE
|
||||
: SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onShouldRetry(@NonNull Exception e) {
|
||||
return e instanceof PushNetworkException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() { }
|
||||
|
||||
private void setProfileName(@Nullable String encryptedName) {
|
||||
try {
|
||||
ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey();
|
||||
String plaintextName = ProfileUtil.decryptString(profileKey, encryptedName);
|
||||
ProfileName profileName = ProfileName.fromSerialized(plaintextName);
|
||||
|
||||
if (!profileName.isEmpty()) {
|
||||
Log.d(TAG, "Saving non-empty name.");
|
||||
SignalDatabase.recipients().setProfileName(Recipient.self().getId(), profileName);
|
||||
} else {
|
||||
Log.w(TAG, "Ignoring empty name.");
|
||||
}
|
||||
|
||||
} catch (InvalidCiphertextException | IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void setProfileAbout(@Nullable String encryptedAbout, @Nullable String encryptedEmoji) {
|
||||
try {
|
||||
ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey();
|
||||
String plaintextAbout = ProfileUtil.decryptString(profileKey, encryptedAbout);
|
||||
String plaintextEmoji = ProfileUtil.decryptString(profileKey, encryptedEmoji);
|
||||
|
||||
Log.d(TAG, "Saving " + (!Util.isEmpty(plaintextAbout) ? "non-" : "") + "empty about.");
|
||||
Log.d(TAG, "Saving " + (!Util.isEmpty(plaintextEmoji) ? "non-" : "") + "empty emoji.");
|
||||
|
||||
SignalDatabase.recipients().setAbout(Recipient.self().getId(), plaintextAbout, plaintextEmoji);
|
||||
} catch (InvalidCiphertextException | IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void setProfileAvatar(@Nullable String avatar) {
|
||||
Log.d(TAG, "Saving " + (!Util.isEmpty(avatar) ? "non-" : "") + "empty avatar.");
|
||||
AppDependencies.getJobManager().add(new RetrieveProfileAvatarJob(Recipient.self(), avatar));
|
||||
}
|
||||
|
||||
private void setProfileCapabilities(@Nullable SignalServiceProfile.Capabilities capabilities) {
|
||||
if (capabilities == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Recipient selfSnapshot = Recipient.self();
|
||||
|
||||
SignalDatabase.recipients().setCapabilities(Recipient.self().getId(), capabilities);
|
||||
}
|
||||
|
||||
private void ensureUnidentifiedAccessCorrect(@Nullable String unidentifiedAccessVerifier, boolean universalUnidentifiedAccess) {
|
||||
if (unidentifiedAccessVerifier == null) {
|
||||
Log.w(TAG, "No unidentified access is set remotely! Refreshing attributes.");
|
||||
AppDependencies.getJobManager().add(new RefreshAttributesJob());
|
||||
return;
|
||||
}
|
||||
|
||||
if (TextSecurePreferences.isUniversalUnidentifiedAccess(context) != universalUnidentifiedAccess) {
|
||||
Log.w(TAG, "The universal access flag doesn't match our local value (local: " + TextSecurePreferences.isUniversalUnidentifiedAccess(context) + ", remote: " + universalUnidentifiedAccess + ")! Refreshing attributes.");
|
||||
AppDependencies.getJobManager().add(new RefreshAttributesJob());
|
||||
return;
|
||||
}
|
||||
|
||||
ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey();
|
||||
ProfileCipher cipher = new ProfileCipher(profileKey);
|
||||
|
||||
boolean verified;
|
||||
try {
|
||||
verified = cipher.verifyUnidentifiedAccess(Base64.decode(unidentifiedAccessVerifier));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed to decode unidentified access!", e);
|
||||
verified = false;
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
Log.w(TAG, "Unidentified access failed to verify! Refreshing attributes.");
|
||||
AppDependencies.getJobManager().add(new RefreshAttributesJob());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks to make sure that our phone number sharing setting matches what's on our profile. If there's a mismatch, we first sync with storage service
|
||||
* (to limit race conditions between devices) and then upload our profile.
|
||||
*/
|
||||
private void ensurePhoneNumberSharingIsCorrect(@Nullable String phoneNumberSharingCiphertext) {
|
||||
if (phoneNumberSharingCiphertext == null) {
|
||||
Log.w(TAG, "No phone number sharing is set remotely! Syncing with storage service, then uploading our profile.");
|
||||
syncWithStorageServiceThenUploadProfile();
|
||||
return;
|
||||
}
|
||||
|
||||
ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey();
|
||||
ProfileCipher cipher = new ProfileCipher(profileKey);
|
||||
|
||||
try {
|
||||
RecipientTable.PhoneNumberSharingState remotePhoneNumberSharing = cipher.decryptBoolean(Base64.decode(phoneNumberSharingCiphertext))
|
||||
.map(value -> value ? RecipientTable.PhoneNumberSharingState.ENABLED : RecipientTable.PhoneNumberSharingState.DISABLED)
|
||||
.orElse(RecipientTable.PhoneNumberSharingState.UNKNOWN);
|
||||
|
||||
if (remotePhoneNumberSharing == RecipientTable.PhoneNumberSharingState.UNKNOWN || remotePhoneNumberSharing.getEnabled() != SignalStore.phoneNumberPrivacy().isPhoneNumberSharingEnabled()) {
|
||||
Log.w(TAG, "Phone number sharing setting did not match! Syncing with storage service, then uploading our profile.");
|
||||
syncWithStorageServiceThenUploadProfile();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed to decode phone number sharing! Syncing with storage service, then uploading our profile.", e);
|
||||
syncWithStorageServiceThenUploadProfile();
|
||||
} catch (InvalidCiphertextException e) {
|
||||
Log.w(TAG, "Failed to decrypt phone number sharing! Syncing with storage service, then uploading our profile.", e);
|
||||
syncWithStorageServiceThenUploadProfile();
|
||||
}
|
||||
}
|
||||
|
||||
private void syncWithStorageServiceThenUploadProfile() {
|
||||
AppDependencies.getJobManager()
|
||||
.startChain(StorageSyncJob.forRemoteChange())
|
||||
.then(new ProfileUploadJob())
|
||||
.enqueue();
|
||||
}
|
||||
|
||||
private static void checkUsernameIsInSync() {
|
||||
boolean validated = false;
|
||||
|
||||
try {
|
||||
String localUsername = SignalStore.account().getUsername();
|
||||
|
||||
WhoAmIResponse whoAmIResponse = AppDependencies.getSignalServiceAccountManager().getWhoAmI();
|
||||
String remoteUsernameHash = whoAmIResponse.getUsernameHash();
|
||||
String localUsernameHash = localUsername != null ? Base64.encodeUrlSafeWithoutPadding(new Username(localUsername).getHash()) : null;
|
||||
|
||||
if (TextUtils.isEmpty(localUsernameHash) && TextUtils.isEmpty(remoteUsernameHash)) {
|
||||
Log.d(TAG, "Local and remote username hash are both empty. Considering validated.");
|
||||
UsernameRepository.onUsernameConsistencyValidated();
|
||||
} else if (!Objects.equals(localUsernameHash, remoteUsernameHash)) {
|
||||
Log.w(TAG, "Local username hash does not match server username hash. Local hash: " + (TextUtils.isEmpty(localUsername) ? "empty" : "present") + ", Remote hash: " + (TextUtils.isEmpty(remoteUsernameHash) ? "empty" : "present"));
|
||||
UsernameRepository.onUsernameMismatchDetected();
|
||||
return;
|
||||
} else {
|
||||
Log.d(TAG, "Username validated.");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed perform synchronization check during username phase.", e);
|
||||
} catch (BaseUsernameException e) {
|
||||
Log.w(TAG, "Our local username data is invalid!", e);
|
||||
UsernameRepository.onUsernameMismatchDetected();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
UsernameLinkComponents localUsernameLink = SignalStore.account().getUsernameLink();
|
||||
|
||||
if (localUsernameLink != null) {
|
||||
byte[] remoteEncryptedUsername = NetworkResultUtil.toBasicLegacy(SignalNetwork.username().getEncryptedUsernameFromLinkServerId(localUsernameLink.getServerId()));
|
||||
Username.UsernameLink combinedLink = new Username.UsernameLink(localUsernameLink.getEntropy(), remoteEncryptedUsername);
|
||||
Username remoteUsername = Username.fromLink(combinedLink);
|
||||
|
||||
if (!remoteUsername.getUsername().equals(SignalStore.account().getUsername())) {
|
||||
Log.w(TAG, "The remote username decrypted ok, but the decrypted username did not match our local username!");
|
||||
UsernameRepository.onUsernameLinkMismatchDetected();
|
||||
} else {
|
||||
Log.d(TAG, "Username link validated.");
|
||||
}
|
||||
|
||||
validated = true;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed perform synchronization check during the username link phase.", e);
|
||||
} catch (BaseUsernameException e) {
|
||||
Log.w(TAG, "Failed to decrypt username link using the remote encrypted username and our local entropy!", e);
|
||||
UsernameRepository.onUsernameLinkMismatchDetected();
|
||||
}
|
||||
|
||||
if (validated) {
|
||||
UsernameRepository.onUsernameConsistencyValidated();
|
||||
}
|
||||
}
|
||||
|
||||
private void setProfileBadges(@Nullable List<SignalServiceProfile.Badge> badges) throws IOException {
|
||||
if (badges == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<String> localDonorBadgeIds = Recipient.self()
|
||||
.getBadges()
|
||||
.stream()
|
||||
.filter(badge -> badge.getCategory() == Badge.Category.Donor)
|
||||
.map(Badge::getId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Set<String> remoteDonorBadgeIds = badges.stream()
|
||||
.filter(badge -> Objects.equals(badge.getCategory(), Badge.Category.Donor.getCode()))
|
||||
.map(SignalServiceProfile.Badge::getId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
boolean remoteHasSubscriptionBadges = remoteDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isSubscription);
|
||||
boolean localHasSubscriptionBadges = localDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isSubscription);
|
||||
boolean remoteHasBoostBadges = remoteDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isBoost);
|
||||
boolean localHasBoostBadges = localDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isBoost);
|
||||
boolean remoteHasGiftBadges = remoteDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isGift);
|
||||
boolean localHasGiftBadges = localDonorBadgeIds.stream().anyMatch(RefreshOwnProfileJob::isGift);
|
||||
|
||||
if (!remoteHasSubscriptionBadges && localHasSubscriptionBadges) {
|
||||
Badge mostRecentExpiration = Recipient.self()
|
||||
.getBadges()
|
||||
.stream()
|
||||
.filter(badge -> badge.getCategory() == Badge.Category.Donor)
|
||||
.filter(badge -> isSubscription(badge.getId()))
|
||||
.max(Comparator.comparingLong(Badge::getExpirationTimestamp))
|
||||
.get();
|
||||
|
||||
Log.d(TAG, "Marking subscription badge as expired, should notify next time the conversation list is open.", true);
|
||||
SignalStore.inAppPayments().setExpiredBadge(mostRecentExpiration);
|
||||
|
||||
if (!InAppPaymentsRepository.isUserManuallyCancelled(InAppPaymentSubscriberRecord.Type.DONATION)) {
|
||||
Log.d(TAG, "Detected an unexpected subscription expiry.", true);
|
||||
InAppPaymentSubscriberRecord subscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION);
|
||||
|
||||
boolean isDueToPaymentFailure = false;
|
||||
if (subscriber != null) {
|
||||
ServiceResponse<ActiveSubscription> response = AppDependencies.getDonationsService()
|
||||
.getSubscription(subscriber.getSubscriberId());
|
||||
|
||||
if (response.getResult().isPresent()) {
|
||||
ActiveSubscription activeSubscription = response.getResult().get();
|
||||
if (activeSubscription.isFailedPayment()) {
|
||||
Log.d(TAG, "Unexpected expiry due to payment failure.", true);
|
||||
isDueToPaymentFailure = true;
|
||||
}
|
||||
|
||||
if (activeSubscription.getChargeFailure() != null) {
|
||||
Log.d(TAG, "Active payment contains a charge failure: " + activeSubscription.getChargeFailure().getCode(), true);
|
||||
}
|
||||
}
|
||||
|
||||
InAppPaymentsRepository.setShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriber, true);
|
||||
}
|
||||
|
||||
if (!isDueToPaymentFailure) {
|
||||
Log.d(TAG, "Unexpected expiry due to inactivity.", true);
|
||||
}
|
||||
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue();
|
||||
}
|
||||
} else if (!remoteHasBoostBadges && localHasBoostBadges) {
|
||||
Badge mostRecentExpiration = Recipient.self()
|
||||
.getBadges()
|
||||
.stream()
|
||||
.filter(badge -> badge.getCategory() == Badge.Category.Donor)
|
||||
.filter(badge -> isBoost(badge.getId()))
|
||||
.max(Comparator.comparingLong(Badge::getExpirationTimestamp))
|
||||
.get();
|
||||
|
||||
Log.d(TAG, "Marking boost badge as expired, should notify next time the conversation list is open.", true);
|
||||
SignalStore.inAppPayments().setExpiredBadge(mostRecentExpiration);
|
||||
} else {
|
||||
Badge badge = SignalStore.inAppPayments().getExpiredBadge();
|
||||
|
||||
if (badge != null && badge.isSubscription() && remoteHasSubscriptionBadges) {
|
||||
Log.d(TAG, "Remote has subscription badges. Clearing local expired subscription badge.", true);
|
||||
SignalStore.inAppPayments().setExpiredBadge(null);
|
||||
} else if (badge != null && badge.isBoost() && remoteHasBoostBadges) {
|
||||
Log.d(TAG, "Remote has boost badges. Clearing local expired boost badge.", true);
|
||||
SignalStore.inAppPayments().setExpiredBadge(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!remoteHasGiftBadges && localHasGiftBadges) {
|
||||
Badge mostRecentExpiration = Recipient.self()
|
||||
.getBadges()
|
||||
.stream()
|
||||
.filter(badge -> badge.getCategory() == Badge.Category.Donor)
|
||||
.filter(badge -> isGift(badge.getId()))
|
||||
.max(Comparator.comparingLong(Badge::getExpirationTimestamp))
|
||||
.get();
|
||||
|
||||
Log.d(TAG, "Marking gift badge as expired, should notify next time the manage donations screen is open.", true);
|
||||
SignalStore.inAppPayments().setExpiredGiftBadge(mostRecentExpiration);
|
||||
} else if (remoteHasGiftBadges) {
|
||||
Log.d(TAG, "We have remote gift badges. Clearing local expired gift badge.", true);
|
||||
SignalStore.inAppPayments().setExpiredGiftBadge(null);
|
||||
}
|
||||
|
||||
boolean userHasVisibleBadges = badges.stream().anyMatch(SignalServiceProfile.Badge::isVisible);
|
||||
boolean userHasInvisibleBadges = badges.stream().anyMatch(b -> !b.isVisible());
|
||||
|
||||
List<Badge> appBadges = badges.stream().map(Badges::fromServiceBadge).collect(Collectors.toList());
|
||||
|
||||
if (userHasVisibleBadges && userHasInvisibleBadges) {
|
||||
boolean displayBadgesOnProfile = SignalStore.inAppPayments().getDisplayBadgesOnProfile();
|
||||
Log.d(TAG, "Detected mixed visibility of badges. Telling the server to mark them all " +
|
||||
(displayBadgesOnProfile ? "" : "not") +
|
||||
" visible.", true);
|
||||
|
||||
BadgeRepository badgeRepository = new BadgeRepository(context);
|
||||
List<Badge> updatedBadges = badgeRepository.setVisibilityForAllBadgesSync(displayBadgesOnProfile, appBadges);
|
||||
SignalDatabase.recipients().setBadges(Recipient.self().getId(), updatedBadges);
|
||||
} else {
|
||||
SignalDatabase.recipients().setBadges(Recipient.self().getId(), appBadges);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isSubscription(String badgeId) {
|
||||
return !isBoost(badgeId) && !isGift(badgeId);
|
||||
}
|
||||
|
||||
private static boolean isBoost(String badgeId) {
|
||||
return Objects.equals(badgeId, Badge.BOOST_BADGE_ID);
|
||||
}
|
||||
|
||||
private static boolean isGift(String badgeId) {
|
||||
return Objects.equals(badgeId, Badge.GIFT_BADGE_ID);
|
||||
}
|
||||
|
||||
public static final class Factory implements Job.Factory<RefreshOwnProfileJob> {
|
||||
|
||||
@Override
|
||||
public @NonNull RefreshOwnProfileJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) {
|
||||
return new RefreshOwnProfileJob(parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import android.text.TextUtils
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.Util
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.usernames.BaseUsernameException
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.database.RecipientTable.PhoneNumberSharingState
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.NetworkResultUtil
|
||||
import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException
|
||||
import org.whispersystems.signalservice.api.crypto.ProfileCipher
|
||||
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
|
||||
import org.whispersystems.signalservice.api.util.ExpiringProfileCredentialUtil
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Refreshes the profile of the local user. Different from [RetrieveProfileJob] in that we
|
||||
* have to sometimes look at/set different data stores, and we will *always* do the fetch regardless
|
||||
* of caching.
|
||||
*/
|
||||
class RefreshOwnProfileJob private constructor(parameters: Parameters) : BaseJob(parameters) {
|
||||
|
||||
companion object {
|
||||
const val KEY: String = "RefreshOwnProfileJob"
|
||||
|
||||
private val TAG = Log.tag(RefreshOwnProfileJob::class.java)
|
||||
|
||||
private val SUBSCRIPTION_QUEUE = ProfileUploadJob.QUEUE + "_Subscription"
|
||||
private val BOOST_QUEUE = ProfileUploadJob.QUEUE + "_Boost"
|
||||
|
||||
fun forSubscription(): RefreshOwnProfileJob {
|
||||
return RefreshOwnProfileJob(SUBSCRIPTION_QUEUE)
|
||||
}
|
||||
|
||||
fun forBoost(): RefreshOwnProfileJob {
|
||||
return RefreshOwnProfileJob(BOOST_QUEUE)
|
||||
}
|
||||
}
|
||||
|
||||
constructor() : this(ProfileUploadJob.QUEUE)
|
||||
|
||||
private constructor(queue: String) : this(
|
||||
Parameters.Builder()
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setQueue(queue)
|
||||
.setMaxInstancesForFactory(1)
|
||||
.setMaxAttempts(10)
|
||||
.build()
|
||||
)
|
||||
|
||||
override fun serialize(): ByteArray? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun getFactoryKey(): String {
|
||||
return KEY
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
override fun onRun() {
|
||||
if (!SignalStore.account.isRegistered || SignalStore.account.e164.isNullOrEmpty()) {
|
||||
Log.w(TAG, "Not yet registered!")
|
||||
return
|
||||
}
|
||||
|
||||
if ((SignalStore.svr.hasPin() || SignalStore.account.restoredAccountEntropyPool) && !SignalStore.svr.hasOptedOut() && SignalStore.storageService.lastSyncTime == 0L) {
|
||||
Log.i(TAG, "Registered with PIN or AEP but haven't completed storage sync yet.")
|
||||
return
|
||||
}
|
||||
|
||||
if (!SignalStore.registration.hasUploadedProfile && SignalStore.account.isPrimaryDevice) {
|
||||
Log.i(TAG, "Registered but haven't uploaded profile yet.")
|
||||
return
|
||||
}
|
||||
|
||||
val self = Recipient.self()
|
||||
|
||||
val profileAndCredential: ProfileAndCredential
|
||||
try {
|
||||
profileAndCredential = ProfileUtil.retrieveProfileSync(context, self, getRequestType(self), false)
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.w(TAG, "Unexpected exception result from profile fetch. Skipping.")
|
||||
return
|
||||
}
|
||||
|
||||
val profile = profileAndCredential.getProfile()
|
||||
|
||||
if (Util.isEmpty(profile.getName()) &&
|
||||
Util.isEmpty(profile.getAvatar()) &&
|
||||
Util.isEmpty(profile.getAbout()) &&
|
||||
Util.isEmpty(profile.getAboutEmoji())
|
||||
) {
|
||||
Log.w(TAG, "The profile we retrieved was empty! Ignoring it.")
|
||||
|
||||
if (!self.profileName.isEmpty()) {
|
||||
Log.w(TAG, "We have a name locally. Scheduling a profile upload.")
|
||||
AppDependencies.jobManager.add(ProfileUploadJob())
|
||||
} else {
|
||||
Log.w(TAG, "We don't have a name locally, either!")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setProfileName(profile.getName())
|
||||
setProfileAbout(profile.getAbout(), profile.getAboutEmoji())
|
||||
setProfileAvatar(profile.getAvatar())
|
||||
setProfileCapabilities(profile.getCapabilities())
|
||||
setProfileBadges(profile.getBadges())
|
||||
ensureUnidentifiedAccessCorrect(profile.getUnidentifiedAccess(), profile.isUnrestrictedUnidentifiedAccess())
|
||||
ensurePhoneNumberSharingIsCorrect(profile.getPhoneNumberSharing())
|
||||
|
||||
profileAndCredential.getExpiringProfileKeyCredential()
|
||||
.ifPresent { setExpiringProfileKeyCredential(self, ProfileKeyUtil.getSelfProfileKey(), it) }
|
||||
|
||||
SignalStore.registration.hasDownloadedProfile = true
|
||||
|
||||
StoryOnboardingDownloadJob.enqueueIfNeeded()
|
||||
|
||||
checkUsernameIsInSync()
|
||||
}
|
||||
|
||||
override fun onShouldRetry(e: Exception): Boolean {
|
||||
return e is PushNetworkException
|
||||
}
|
||||
|
||||
override fun onFailure() = Unit
|
||||
|
||||
private fun setExpiringProfileKeyCredential(
|
||||
recipient: Recipient,
|
||||
recipientProfileKey: ProfileKey,
|
||||
credential: ExpiringProfileKeyCredential
|
||||
) {
|
||||
SignalDatabase.recipients.setProfileKeyCredential(recipient.id, recipientProfileKey, credential)
|
||||
}
|
||||
|
||||
private fun setProfileName(encryptedName: String?) {
|
||||
try {
|
||||
val profileKey = ProfileKeyUtil.getSelfProfileKey()
|
||||
val plaintextName = ProfileUtil.decryptString(profileKey, encryptedName)
|
||||
val profileName = ProfileName.fromSerialized(plaintextName)
|
||||
|
||||
if (!profileName.isEmpty()) {
|
||||
Log.d(TAG, "Saving non-empty name.")
|
||||
SignalDatabase.recipients.setProfileName(Recipient.self().id, profileName)
|
||||
} else {
|
||||
Log.w(TAG, "Ignoring empty name.")
|
||||
}
|
||||
} catch (e: InvalidCiphertextException) {
|
||||
Log.w(TAG, e)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setProfileAbout(encryptedAbout: String?, encryptedEmoji: String?) {
|
||||
try {
|
||||
val profileKey = ProfileKeyUtil.getSelfProfileKey()
|
||||
val plaintextAbout = ProfileUtil.decryptString(profileKey, encryptedAbout)
|
||||
val plaintextEmoji = ProfileUtil.decryptString(profileKey, encryptedEmoji)
|
||||
|
||||
Log.d(TAG, "Saving " + (if (!Util.isEmpty(plaintextAbout)) "non-" else "") + "empty about.")
|
||||
Log.d(TAG, "Saving " + (if (!Util.isEmpty(plaintextEmoji)) "non-" else "") + "empty emoji.")
|
||||
|
||||
SignalDatabase.recipients.setAbout(Recipient.self().id, plaintextAbout, plaintextEmoji)
|
||||
} catch (e: InvalidCiphertextException) {
|
||||
Log.w(TAG, e)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setProfileAvatar(avatar: String?) {
|
||||
Log.d(TAG, "Saving " + (if (!Util.isEmpty(avatar)) "non-" else "") + "empty avatar.")
|
||||
AppDependencies.jobManager.add(RetrieveProfileAvatarJob(Recipient.self(), avatar))
|
||||
}
|
||||
|
||||
private fun setProfileCapabilities(capabilities: SignalServiceProfile.Capabilities?) {
|
||||
if (capabilities == null) {
|
||||
return
|
||||
}
|
||||
|
||||
SignalDatabase.recipients.setCapabilities(Recipient.self().id, capabilities)
|
||||
}
|
||||
|
||||
private fun ensureUnidentifiedAccessCorrect(unidentifiedAccessVerifier: String?, universalUnidentifiedAccess: Boolean) {
|
||||
if (unidentifiedAccessVerifier == null) {
|
||||
Log.w(TAG, "No unidentified access is set remotely! Refreshing attributes.")
|
||||
AppDependencies.jobManager.add(RefreshAttributesJob())
|
||||
return
|
||||
}
|
||||
|
||||
if (TextSecurePreferences.isUniversalUnidentifiedAccess(context) != universalUnidentifiedAccess) {
|
||||
Log.w(TAG, "The universal access flag doesn't match our local value (local: " + TextSecurePreferences.isUniversalUnidentifiedAccess(context) + ", remote: " + universalUnidentifiedAccess + ")! Refreshing attributes.")
|
||||
AppDependencies.jobManager.add(RefreshAttributesJob())
|
||||
return
|
||||
}
|
||||
|
||||
val profileKey = ProfileKeyUtil.getSelfProfileKey()
|
||||
val cipher = ProfileCipher(profileKey)
|
||||
|
||||
val verified = try {
|
||||
cipher.verifyUnidentifiedAccess(Base64.decode(unidentifiedAccessVerifier))
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to decode unidentified access!", e)
|
||||
false
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
Log.w(TAG, "Unidentified access failed to verify! Refreshing attributes.")
|
||||
AppDependencies.jobManager.add(RefreshAttributesJob())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks to make sure that our phone number sharing setting matches what's on our profile. If there's a mismatch, we first sync with storage service
|
||||
* (to limit race conditions between devices) and then upload our profile.
|
||||
*/
|
||||
private fun ensurePhoneNumberSharingIsCorrect(phoneNumberSharingCiphertext: String?) {
|
||||
if (phoneNumberSharingCiphertext == null) {
|
||||
Log.w(TAG, "No phone number sharing is set remotely! Syncing with storage service, then uploading our profile.")
|
||||
syncWithStorageServiceThenUploadProfile()
|
||||
return
|
||||
}
|
||||
|
||||
val profileKey = ProfileKeyUtil.getSelfProfileKey()
|
||||
val cipher = ProfileCipher(profileKey)
|
||||
|
||||
try {
|
||||
val remotePhoneNumberSharing = cipher.decryptBoolean(Base64.decode(phoneNumberSharingCiphertext))
|
||||
.map { value -> if (value) PhoneNumberSharingState.ENABLED else PhoneNumberSharingState.DISABLED }
|
||||
.orElse(PhoneNumberSharingState.UNKNOWN)
|
||||
|
||||
if (remotePhoneNumberSharing == PhoneNumberSharingState.UNKNOWN || remotePhoneNumberSharing.enabled != SignalStore.phoneNumberPrivacy.isPhoneNumberSharingEnabled()) {
|
||||
Log.w(TAG, "Phone number sharing setting did not match! Syncing with storage service, then uploading our profile.")
|
||||
syncWithStorageServiceThenUploadProfile()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to decode phone number sharing! Syncing with storage service, then uploading our profile.", e)
|
||||
syncWithStorageServiceThenUploadProfile()
|
||||
} catch (e: InvalidCiphertextException) {
|
||||
Log.w(TAG, "Failed to decrypt phone number sharing! Syncing with storage service, then uploading our profile.", e)
|
||||
syncWithStorageServiceThenUploadProfile()
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncWithStorageServiceThenUploadProfile() {
|
||||
AppDependencies.jobManager
|
||||
.startChain(StorageSyncJob.forRemoteChange())
|
||||
.then(ProfileUploadJob())
|
||||
.enqueue()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun setProfileBadges(badges: List<SignalServiceProfile.Badge>?) {
|
||||
if (badges == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val localDonorBadgeIds = Recipient.self()
|
||||
.badges
|
||||
.filter { it.category == Badge.Category.Donor }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
|
||||
val remoteDonorBadgeIds = badges
|
||||
.filter { it.getCategory() == Badge.Category.Donor.code }
|
||||
.map { it.getId() }
|
||||
.toSet()
|
||||
|
||||
val remoteHasSubscriptionBadges = remoteDonorBadgeIds.any { isSubscription(it) }
|
||||
val localHasSubscriptionBadges = localDonorBadgeIds.any { isSubscription(it) }
|
||||
val remoteHasBoostBadges = remoteDonorBadgeIds.any { isBoost(it) }
|
||||
val localHasBoostBadges = localDonorBadgeIds.any { isBoost(it) }
|
||||
val remoteHasGiftBadges = remoteDonorBadgeIds.any { isGift(it) }
|
||||
val localHasGiftBadges = localDonorBadgeIds.any { isGift(it) }
|
||||
|
||||
if (!remoteHasSubscriptionBadges && localHasSubscriptionBadges) {
|
||||
val mostRecentExpiration = Recipient.self()
|
||||
.badges
|
||||
.filter { it.category == Badge.Category.Donor }
|
||||
.filter { isSubscription(it.id) }
|
||||
.maxByOrNull { it.expirationTimestamp }
|
||||
?: throw NoSuchElementException("No value present")
|
||||
|
||||
Log.d(TAG, "Marking subscription badge as expired, should notify next time the conversation list is open.", true)
|
||||
SignalStore.inAppPayments.setExpiredBadge(mostRecentExpiration)
|
||||
|
||||
if (!InAppPaymentsRepository.isUserManuallyCancelled(InAppPaymentSubscriberRecord.Type.DONATION)) {
|
||||
Log.d(TAG, "Detected an unexpected subscription expiry.", true)
|
||||
val subscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
|
||||
var isDueToPaymentFailure = false
|
||||
if (subscriber != null) {
|
||||
val response = AppDependencies.donationsService
|
||||
.getSubscription(subscriber.subscriberId)
|
||||
|
||||
if (response.getResult().isPresent()) {
|
||||
val activeSubscription = response.getResult().get()
|
||||
if (activeSubscription.isFailedPayment()) {
|
||||
Log.d(TAG, "Unexpected expiry due to payment failure.", true)
|
||||
isDueToPaymentFailure = true
|
||||
}
|
||||
|
||||
if (activeSubscription.getChargeFailure() != null) {
|
||||
Log.d(TAG, "Active payment contains a charge failure: " + activeSubscription.getChargeFailure().getCode(), true)
|
||||
}
|
||||
}
|
||||
|
||||
InAppPaymentsRepository.setShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriber, true)
|
||||
}
|
||||
|
||||
if (!isDueToPaymentFailure) {
|
||||
Log.d(TAG, "Unexpected expiry due to inactivity.", true)
|
||||
}
|
||||
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue()
|
||||
}
|
||||
} else if (!remoteHasBoostBadges && localHasBoostBadges) {
|
||||
val mostRecentExpiration = Recipient.self()
|
||||
.badges
|
||||
.filter { it.category == Badge.Category.Donor }
|
||||
.filter { isBoost(it.id) }
|
||||
.maxByOrNull { it.expirationTimestamp }
|
||||
?: throw NoSuchElementException("No value present")
|
||||
|
||||
Log.d(TAG, "Marking boost badge as expired, should notify next time the conversation list is open.", true)
|
||||
SignalStore.inAppPayments.setExpiredBadge(mostRecentExpiration)
|
||||
} else {
|
||||
val badge = SignalStore.inAppPayments.getExpiredBadge()
|
||||
|
||||
if (badge != null && badge.isSubscription() && remoteHasSubscriptionBadges) {
|
||||
Log.d(TAG, "Remote has subscription badges. Clearing local expired subscription badge.", true)
|
||||
SignalStore.inAppPayments.setExpiredBadge(null)
|
||||
} else if (badge != null && badge.isBoost() && remoteHasBoostBadges) {
|
||||
Log.d(TAG, "Remote has boost badges. Clearing local expired boost badge.", true)
|
||||
SignalStore.inAppPayments.setExpiredBadge(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!remoteHasGiftBadges && localHasGiftBadges) {
|
||||
val mostRecentExpiration = Recipient.self()
|
||||
.badges
|
||||
.filter { it.category == Badge.Category.Donor }
|
||||
.filter { isGift(it.id) }
|
||||
.maxByOrNull { it.expirationTimestamp }
|
||||
?: throw NoSuchElementException("No value present")
|
||||
|
||||
Log.d(TAG, "Marking gift badge as expired, should notify next time the manage donations screen is open.", true)
|
||||
SignalStore.inAppPayments.setExpiredGiftBadge(mostRecentExpiration)
|
||||
} else if (remoteHasGiftBadges) {
|
||||
Log.d(TAG, "We have remote gift badges. Clearing local expired gift badge.", true)
|
||||
SignalStore.inAppPayments.setExpiredGiftBadge(null)
|
||||
}
|
||||
|
||||
val userHasVisibleBadges = badges.any { it.isVisible() }
|
||||
val userHasInvisibleBadges = badges.any { !it.isVisible() }
|
||||
|
||||
val appBadges = badges.map { Badges.fromServiceBadge(it) }
|
||||
|
||||
if (userHasVisibleBadges && userHasInvisibleBadges) {
|
||||
val displayBadgesOnProfile = SignalStore.inAppPayments.getDisplayBadgesOnProfile()
|
||||
Log.d(
|
||||
TAG,
|
||||
"Detected mixed visibility of badges. Telling the server to mark them all " +
|
||||
(if (displayBadgesOnProfile) "" else "not") +
|
||||
" visible.",
|
||||
true
|
||||
)
|
||||
|
||||
val badgeRepository = BadgeRepository(context)
|
||||
val updatedBadges = badgeRepository.setVisibilityForAllBadgesSync(displayBadgesOnProfile, appBadges)
|
||||
SignalDatabase.recipients.setBadges(Recipient.self().id, updatedBadges)
|
||||
} else {
|
||||
SignalDatabase.recipients.setBadges(Recipient.self().id, appBadges)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkUsernameIsInSync() {
|
||||
var validated = false
|
||||
|
||||
try {
|
||||
val localUsername = SignalStore.account.username
|
||||
|
||||
val whoAmIResponse = AppDependencies.signalServiceAccountManager.getWhoAmI()
|
||||
val remoteUsernameHash = whoAmIResponse.usernameHash
|
||||
val localUsernameHash = if (localUsername != null) Base64.encodeUrlSafeWithoutPadding(Username(localUsername).getHash()) else null
|
||||
|
||||
if (TextUtils.isEmpty(localUsernameHash) && TextUtils.isEmpty(remoteUsernameHash)) {
|
||||
Log.d(TAG, "Local and remote username hash are both empty. Considering validated.")
|
||||
UsernameRepository.onUsernameConsistencyValidated()
|
||||
} else if (localUsernameHash != remoteUsernameHash) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Local username hash does not match server username hash. Local hash: " + (if (TextUtils.isEmpty(localUsername)) "empty" else "present") + ", Remote hash: " + (if (TextUtils.isEmpty(remoteUsernameHash)) "empty" else "present")
|
||||
)
|
||||
UsernameRepository.onUsernameMismatchDetected()
|
||||
return
|
||||
} else {
|
||||
Log.d(TAG, "Username validated.")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed perform synchronization check during username phase.", e)
|
||||
} catch (e: BaseUsernameException) {
|
||||
Log.w(TAG, "Our local username data is invalid!", e)
|
||||
UsernameRepository.onUsernameMismatchDetected()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val localUsernameLink = SignalStore.account.usernameLink
|
||||
|
||||
if (localUsernameLink != null) {
|
||||
val remoteEncryptedUsername = NetworkResultUtil.toBasicLegacy<ByteArray>(SignalNetwork.username.getEncryptedUsernameFromLinkServerId(localUsernameLink.serverId))
|
||||
val combinedLink = Username.UsernameLink(localUsernameLink.entropy, remoteEncryptedUsername)
|
||||
val remoteUsername = Username.fromLink(combinedLink)
|
||||
|
||||
if (remoteUsername.getUsername() != SignalStore.account.username) {
|
||||
Log.w(TAG, "The remote username decrypted ok, but the decrypted username did not match our local username!")
|
||||
UsernameRepository.onUsernameLinkMismatchDetected()
|
||||
} else {
|
||||
Log.d(TAG, "Username link validated.")
|
||||
}
|
||||
|
||||
validated = true
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed perform synchronization check during the username link phase.", e)
|
||||
} catch (e: BaseUsernameException) {
|
||||
Log.w(TAG, "Failed to decrypt username link using the remote encrypted username and our local entropy!", e)
|
||||
UsernameRepository.onUsernameLinkMismatchDetected()
|
||||
}
|
||||
|
||||
if (validated) {
|
||||
UsernameRepository.onUsernameConsistencyValidated()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRequestType(recipient: Recipient): SignalServiceProfile.RequestType {
|
||||
return if (ExpiringProfileCredentialUtil.isValid(recipient.expiringProfileKeyCredential))
|
||||
SignalServiceProfile.RequestType.PROFILE
|
||||
else
|
||||
SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL
|
||||
}
|
||||
|
||||
private fun isSubscription(badgeId: String?): Boolean {
|
||||
return !isBoost(badgeId) && !isGift(badgeId)
|
||||
}
|
||||
|
||||
private fun isBoost(badgeId: String?): Boolean {
|
||||
return badgeId == Badge.BOOST_BADGE_ID
|
||||
}
|
||||
|
||||
private fun isGift(badgeId: String?): Boolean {
|
||||
return badgeId == Badge.GIFT_BADGE_ID
|
||||
}
|
||||
|
||||
class Factory : Job.Factory<RefreshOwnProfileJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): RefreshOwnProfileJob {
|
||||
return RefreshOwnProfileJob(parameters)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.jobs
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.groups.GroupAccessControl
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
@@ -114,7 +115,7 @@ class UnpinMessageJob(
|
||||
|
||||
val targetSentTimestamp = message.dateSent
|
||||
|
||||
val recipients = Recipient.resolvedList(recipientIds.filter { it != Recipient.self().id.toLong() }.map { RecipientId.from(it) })
|
||||
val recipients = Recipient.resolvedList(recipientIds.map { RecipientId.from(it) })
|
||||
val registered = RecipientUtil.getEligibleForSending(recipients)
|
||||
val unregistered = recipients - registered.toSet()
|
||||
val completions: List<Recipient> = deliver(conversationRecipient, registered, message.threadId, targetAuthor, targetSentTimestamp)
|
||||
@@ -148,11 +149,14 @@ class UnpinMessageJob(
|
||||
|
||||
val dataMessage = dataMessageBuilder.build()
|
||||
|
||||
val nonSelfRecipients = destinations.filterNot { it.isSelf }
|
||||
val includeSelf = destinations.size != nonSelfRecipients.size
|
||||
|
||||
val results = GroupSendUtil.sendResendableDataMessage(
|
||||
context,
|
||||
conversationRecipient.groupId.map { obj: GroupId -> obj.requireV2() }.orElse(null),
|
||||
null,
|
||||
destinations,
|
||||
nonSelfRecipients,
|
||||
false,
|
||||
ContentHint.RESENDABLE,
|
||||
MessageId(messageId),
|
||||
@@ -163,6 +167,10 @@ class UnpinMessageJob(
|
||||
null
|
||||
)
|
||||
|
||||
if (includeSelf) {
|
||||
results.add(AppDependencies.signalServiceMessageSender.sendSyncMessage(dataMessage))
|
||||
}
|
||||
|
||||
val result = GroupSendJobHelper.getCompletedSends(destinations, results)
|
||||
|
||||
for (unregistered in result.unregistered) {
|
||||
|
||||
@@ -364,10 +364,13 @@ class UploadAttachmentToArchiveJob private constructor(
|
||||
when (ArchiveMediaUploadFormStatusCodes.from(uploadSpec.code)) {
|
||||
ArchiveMediaUploadFormStatusCodes.BadArguments,
|
||||
ArchiveMediaUploadFormStatusCodes.InvalidPresentationOrSignature,
|
||||
ArchiveMediaUploadFormStatusCodes.InsufficientPermissions,
|
||||
ArchiveMediaUploadFormStatusCodes.RateLimited -> {
|
||||
ArchiveMediaUploadFormStatusCodes.InsufficientPermissions -> {
|
||||
return null to Result.retry(defaultBackoff())
|
||||
}
|
||||
ArchiveMediaUploadFormStatusCodes.RateLimited -> {
|
||||
Log.w(TAG, "[$attachmentId] Rate limited when getting upload form.")
|
||||
return null to Result.retry(uploadSpec.retryAfter()?.inWholeMilliseconds ?: defaultBackoff())
|
||||
}
|
||||
|
||||
ArchiveMediaUploadFormStatusCodes.Unknown -> {
|
||||
return null to Result.retry(defaultBackoff())
|
||||
|
||||
@@ -104,6 +104,7 @@ class EmojiKeyboardPageFragment : Fragment(), EmojiEventListener, EmojiPageViewG
|
||||
|
||||
override fun onPageSelected() {
|
||||
viewModel.refreshRecentEmoji()
|
||||
appBarLayout.setExpanded(false, false)
|
||||
}
|
||||
|
||||
private fun updateCategoryTab(key: String) {
|
||||
|
||||
@@ -19,7 +19,6 @@ import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.protocol.InvalidMessageException;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.ringrtc.CallLinkEpoch;
|
||||
import org.signal.ringrtc.CallLinkRootKey;
|
||||
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupJoinInfo;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
@@ -328,20 +327,15 @@ public class LinkPreviewRepository {
|
||||
@NonNull String callLinkUrl,
|
||||
@NonNull Callback callback) {
|
||||
|
||||
CallLinks.CallLinkParseResult linkParseResult = CallLinks.parseUrl(callLinkUrl);
|
||||
if (linkParseResult == null) {
|
||||
CallLinkRootKey callLinkRootKey = CallLinks.parseUrl(callLinkUrl);
|
||||
if (callLinkRootKey == null) {
|
||||
callback.onError(Error.PREVIEW_NOT_AVAILABLE);
|
||||
return () -> { };
|
||||
}
|
||||
|
||||
CallLinkEpoch epoch = linkParseResult.getEpoch();
|
||||
byte[] epochBytes = epoch != null ? epoch.getBytes() : null;
|
||||
|
||||
Disposable disposable = AppDependencies.getSignalCallManager()
|
||||
.getCallLinkManager()
|
||||
.readCallLink(new CallLinkCredentials(linkParseResult.getRootKey().getKeyBytes(),
|
||||
epochBytes,
|
||||
null))
|
||||
.readCallLink(new CallLinkCredentials(callLinkRootKey.getKeyBytes(), null))
|
||||
.observeOn(Schedulers.io())
|
||||
.subscribe(
|
||||
result -> {
|
||||
|
||||
@@ -13,6 +13,8 @@ import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -50,6 +52,8 @@ import org.thoughtcrime.securesms.compose.FragmentBackPressedState
|
||||
import org.thoughtcrime.securesms.conversation.ConversationArgs
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
|
||||
import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.serialization.JsonSerializableNavType
|
||||
import org.thoughtcrime.securesms.window.AppScaffoldAnimationDefaults
|
||||
import org.thoughtcrime.securesms.window.AppScaffoldAnimationState
|
||||
@@ -73,8 +77,8 @@ fun NavGraphBuilder.chatNavGraphBuilder(
|
||||
val context = LocalContext.current
|
||||
|
||||
// Because it can take a long time to load content, we use a "fake" chat list image to delay displaying
|
||||
// the fragment and prevent pop-in
|
||||
var shouldDisplayFragment by remember { mutableStateOf(false) }
|
||||
// the fragment and prevent pop-in. When there's no bitmap (e.g. returning from a sub-route), skip the animation.
|
||||
var shouldDisplayFragment by remember { mutableStateOf(chatNavGraphState.chatBitmap == null) }
|
||||
val transition: Transition<Boolean> = updateTransition(shouldDisplayFragment)
|
||||
val bitmap = chatNavGraphState.chatBitmap
|
||||
|
||||
@@ -126,16 +130,42 @@ fun NavGraphBuilder.chatNavGraphBuilder(
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
fragment.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
fragment.didFirstFrameRender.collectLatest {
|
||||
shouldDisplayFragment = it
|
||||
if (!it) {
|
||||
delay(150.milliseconds)
|
||||
shouldDisplayFragment = true
|
||||
if (!shouldDisplayFragment) {
|
||||
shouldDisplayFragment = it
|
||||
if (!it) {
|
||||
delay(150.milliseconds)
|
||||
shouldDisplayFragment = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composable<MainNavigationDetailLocation.Chats.MessageDetails>(
|
||||
typeMap = mapOf(
|
||||
typeOf<RecipientId>() to JsonSerializableNavType(RecipientId.serializer())
|
||||
)
|
||||
) { navBackStackEntry ->
|
||||
val context = LocalContext.current
|
||||
val route = navBackStackEntry.toRoute<MainNavigationDetailLocation.Chats.MessageDetails>()
|
||||
val fragmentState = key(route) { rememberFragmentState() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
(context as? MainNavigator.NavigatorProvider)?.onFirstRender()
|
||||
}
|
||||
|
||||
AndroidFragment(
|
||||
clazz = MessageDetailsFragment::class.java,
|
||||
fragmentState = fragmentState,
|
||||
arguments = MessageDetailsFragment.args(route.recipientId, route.messageId),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -17,7 +17,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -65,10 +64,10 @@ fun EmptyDetailScreen() {
|
||||
* utilizing collectAsStateWithLifecycle. Then the latest value is remembered as a saveable using the default [MainNavigationDetailLocation.Saver]
|
||||
*/
|
||||
@Composable
|
||||
fun rememberMainNavigationDetailLocation(
|
||||
fun MainNavigationDetailLocationEffect(
|
||||
mainNavigationViewModel: MainNavigationViewModel,
|
||||
onWillFocusPrimary: suspend () -> Unit = {}
|
||||
): State<MainNavigationDetailLocation> {
|
||||
) {
|
||||
val state = rememberSaveable(
|
||||
stateSaver = MainNavigationDetailLocation.Saver(
|
||||
mainNavigationViewModel.earlyNavigationDetailLocationRequested
|
||||
@@ -84,7 +83,9 @@ fun rememberMainNavigationDetailLocation(
|
||||
if (it == MainNavigationDetailLocation.Empty) {
|
||||
ThreePaneScaffoldRole.Secondary
|
||||
} else {
|
||||
onWillFocusPrimary()
|
||||
if (it.isContentRoot) {
|
||||
onWillFocusPrimary()
|
||||
}
|
||||
ThreePaneScaffoldRole.Primary
|
||||
}
|
||||
)
|
||||
@@ -93,8 +94,6 @@ fun rememberMainNavigationDetailLocation(
|
||||
state.value = it
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -147,6 +146,7 @@ fun rememberDetailNavHostController(
|
||||
|
||||
fun NavHostController.navigateToDetailLocation(location: MainNavigationDetailLocation) {
|
||||
navigate(location) {
|
||||
launchSingleTop = true
|
||||
if (location.isContentRoot) {
|
||||
popUpTo(graph.id) { inclusive = true }
|
||||
}
|
||||
|
||||
@@ -65,6 +65,13 @@ sealed class MainNavigationDetailLocation : Parcelable {
|
||||
@IgnoredOnParcel
|
||||
override val controllerKey: RecipientId = conversationArgs.recipientId
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MessageDetails(val recipientId: RecipientId, val messageId: Long) : Chats() {
|
||||
@Transient
|
||||
@IgnoredOnParcel
|
||||
override val controllerKey: RecipientId = recipientId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,7 +66,6 @@ class MainNavigationViewModel(
|
||||
|
||||
private var navigator: AppScaffoldNavigator<Any>? = null
|
||||
private var navigatorScope: CoroutineScope? = null
|
||||
private var goToLegacyDetailLocation: ((MainNavigationDetailLocation) -> Unit)? = null
|
||||
|
||||
private val internalDetailLocation = MutableSharedFlow<MainNavigationDetailLocation>()
|
||||
val detailLocation: SharedFlow<MainNavigationDetailLocation> = internalDetailLocation
|
||||
@@ -159,8 +158,7 @@ class MainNavigationViewModel(
|
||||
* Sets the navigator on the view-model. This wraps the given navigator in our own delegating implementation
|
||||
* such that we can react to navigateTo/Back signals and maintain proper state for internalDetailLocation.
|
||||
*/
|
||||
fun wrapNavigator(composeScope: CoroutineScope, threePaneScaffoldNavigator: ThreePaneScaffoldNavigator<Any>, goToLegacyDetailLocation: (MainNavigationDetailLocation) -> Unit): AppScaffoldNavigator<Any> {
|
||||
this.goToLegacyDetailLocation = goToLegacyDetailLocation
|
||||
fun wrapNavigator(composeScope: CoroutineScope, threePaneScaffoldNavigator: ThreePaneScaffoldNavigator<Any>): AppScaffoldNavigator<Any> {
|
||||
this.navigatorScope = composeScope
|
||||
this.navigator = Nav(threePaneScaffoldNavigator)
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectFor
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
import org.thoughtcrime.securesms.database.MediaTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.databinding.FragmentMediaPreviewV2Binding
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.mediapreview.caption.ExpandingCaptionView
|
||||
@@ -613,13 +614,23 @@ class MediaPreviewV2Fragment :
|
||||
return
|
||||
}
|
||||
|
||||
val messageRecord = SignalDatabase.messages.getMessageRecord(attachment.mmsId)
|
||||
val isNoteToSelf = messageRecord.isOutgoing && messageRecord.toRecipient.isSelf
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext()).apply {
|
||||
setIcon(R.drawable.symbol_error_triangle_fill_24)
|
||||
setTitle(R.string.MediaPreviewActivity_media_delete_confirmation_title)
|
||||
setMessage(R.string.MediaPreviewActivity_media_delete_confirmation_message)
|
||||
setCancelable(true)
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
setPositiveButton(R.string.ConversationFragment_delete_for_me) { _, _ ->
|
||||
|
||||
val deleteButtonLabel = if (isNoteToSelf) {
|
||||
R.string.ConversationFragment_delete
|
||||
} else {
|
||||
R.string.ConversationFragment_delete_for_me
|
||||
}
|
||||
|
||||
setPositiveButton(deleteButtonLabel) { _, _ ->
|
||||
lifecycleDisposable += viewModel.localDelete(requireContext(), attachment)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy(
|
||||
@@ -634,7 +645,7 @@ class MediaPreviewV2Fragment :
|
||||
)
|
||||
}
|
||||
|
||||
if (canRemotelyDelete(attachment)) {
|
||||
if (canRemotelyDelete(attachment, messageRecord) && !isNoteToSelf) {
|
||||
setNeutralButton(R.string.ConversationFragment_delete_for_everyone) { _, _ ->
|
||||
lifecycleDisposable += viewModel.remoteDelete(attachment)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
@@ -653,10 +664,10 @@ class MediaPreviewV2Fragment :
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun canRemotelyDelete(attachment: DatabaseAttachment): Boolean {
|
||||
private fun canRemotelyDelete(attachment: DatabaseAttachment, messageRecord: MessageRecord): Boolean {
|
||||
val mmsId = attachment.mmsId
|
||||
val attachmentCount = SignalDatabase.attachments.getAttachmentsForMessage(mmsId).size
|
||||
return attachmentCount <= 1 && MessageConstraintsUtil.isValidRemoteDeleteSend(listOf(SignalDatabase.messages.getMessageRecord(mmsId)), System.currentTimeMillis())
|
||||
return attachmentCount <= 1 && MessageConstraintsUtil.isValidRemoteDeleteSend(listOf(messageRecord), System.currentTimeMillis())
|
||||
}
|
||||
|
||||
private fun editMediaItem(currentItem: MediaTable.MediaRecord) {
|
||||
|
||||
@@ -449,7 +449,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
|
||||
.setInterpolator(MediaAnimations.interpolator)
|
||||
.alpha(1f)
|
||||
|
||||
disposables += sharedViewModel
|
||||
sharedViewModel
|
||||
.send(selection.filterIsInstance<ContactSearchKey.RecipientSearchKey>(), scheduledSendTime)
|
||||
.subscribe(
|
||||
{ result ->
|
||||
|
||||
@@ -66,6 +66,7 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(
|
||||
mediator = ContactSearchMediator(
|
||||
fragment = this,
|
||||
selectionLimits = RemoteConfig.shareSelectionLimit,
|
||||
isMultiSelect = true,
|
||||
displayOptions = ContactSearchAdapter.DisplayOptions(
|
||||
displayCheckBox = true,
|
||||
displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER
|
||||
|
||||
@@ -457,7 +457,7 @@ public final class Megaphones {
|
||||
public static @NonNull Megaphone buildTurnOnSignalBackupsMegaphone() {
|
||||
return new Megaphone.Builder(Event.TURN_ON_SIGNAL_BACKUPS, Megaphone.Style.BASIC)
|
||||
.setImage(R.drawable.backups_megaphone_image)
|
||||
.setTitle(R.string.TurnOnSignalBackups__title_beta)
|
||||
.setTitle(R.string.TurnOnSignalBackups__title)
|
||||
.setBody(R.string.TurnOnSignalBackups__body)
|
||||
.setActionButton(R.string.TurnOnSignalBackups__turn_on, (megaphone, controller) -> {
|
||||
Intent intent = AppSettingsActivity.remoteBackups(controller.getMegaphoneActivity());
|
||||
@@ -580,7 +580,7 @@ public final class Megaphones {
|
||||
}
|
||||
|
||||
private static boolean shouldShowTurnOnBackupsMegaphone(@NonNull Context context) {
|
||||
if (!RemoteConfig.backupsBetaMegaphone()) {
|
||||
if (!RemoteConfig.backupsMegaphone()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
package org.thoughtcrime.securesms.messagedetails
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestManager
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.ringrtc.CallLinkEpoch
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FullScreenDialogFragment
|
||||
import org.thoughtcrime.securesms.components.WrapperDialogFragment
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState
|
||||
import org.thoughtcrime.securesms.contactshare.Contact
|
||||
@@ -47,7 +49,7 @@ import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
|
||||
class MessageDetailsFragment : FullScreenDialogFragment(), MessageDetailsAdapter.Callbacks {
|
||||
class MessageDetailsFragment : Fragment(), MessageDetailsAdapter.Callbacks {
|
||||
private lateinit var requestManager: RequestManager
|
||||
private lateinit var viewModel: MessageDetailsViewModel
|
||||
private lateinit var adapter: MessageDetailsAdapter
|
||||
@@ -56,9 +58,16 @@ class MessageDetailsFragment : FullScreenDialogFragment(), MessageDetailsAdapter
|
||||
|
||||
private fun getVoiceNoteMediaController() = requireListener<VoiceNoteMediaControllerOwner>().voiceNoteMediaController
|
||||
|
||||
override fun getTitle() = R.string.AndroidManifest__message_details
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = inflater.inflate(R.layout.full_screen_dialog_fragment, container, false)
|
||||
inflater.inflate(R.layout.message_details_fragment, view.findViewById(R.id.full_screen_dialog_content), true)
|
||||
|
||||
override fun getDialogLayoutResource() = R.layout.message_details_fragment
|
||||
val toolbar: Toolbar = view.findViewById(R.id.full_screen_dialog_toolbar)
|
||||
toolbar.setTitle(R.string.AndroidManifest__message_details)
|
||||
toolbar.setNavigationOnClickListener { requireActivity().onBackPressedDispatcher.onBackPressed() }
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
requestManager = Glide.with(this)
|
||||
@@ -68,16 +77,6 @@ class MessageDetailsFragment : FullScreenDialogFragment(), MessageDetailsAdapter
|
||||
initializeVideoPlayer(view)
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
super.onDismiss(dialog)
|
||||
|
||||
if (activity is Callback) {
|
||||
(activity as Callback?)!!.onMessageDetailsFragmentDismissed()
|
||||
} else if (parentFragment is Callback) {
|
||||
(parentFragment as Callback?)!!.onMessageDetailsFragmentDismissed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeList(view: View) {
|
||||
val list = view.findViewById<RecyclerView>(R.id.message_details_list)
|
||||
val toolbarShadow = view.findViewById<View>(R.id.toolbar_shadow)
|
||||
@@ -97,20 +96,20 @@ class MessageDetailsFragment : FullScreenDialogFragment(), MessageDetailsAdapter
|
||||
val factory = MessageDetailsViewModel.Factory(recipientId, messageId)
|
||||
|
||||
viewModel = ViewModelProvider(this, factory)[MessageDetailsViewModel::class.java]
|
||||
viewModel.messageDetails.observe(this) { details: MessageDetails? ->
|
||||
viewModel.messageDetails.observe(viewLifecycleOwner) { details: MessageDetails? ->
|
||||
if (details == null) {
|
||||
dismissAllowingStateLoss()
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
} else {
|
||||
adapter.submitList(convertToRows(details))
|
||||
}
|
||||
}
|
||||
viewModel.recipient.observe(this) { recipient: Recipient -> recyclerViewColorizer.setChatColors(recipient.chatColors) }
|
||||
viewModel.recipient.observe(viewLifecycleOwner) { recipient: Recipient -> recyclerViewColorizer.setChatColors(recipient.chatColors) }
|
||||
}
|
||||
|
||||
private fun initializeVideoPlayer(view: View) {
|
||||
val videoContainer = view.findViewById<FrameLayout>(R.id.video_container)
|
||||
val recyclerView = view.findViewById<RecyclerView>(R.id.message_details_list)
|
||||
val holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(requireContext(), lifecycle, videoContainer, 1)
|
||||
val holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(requireContext(), viewLifecycleOwner.lifecycle, videoContainer, 1)
|
||||
val callback = GiphyMp4ProjectionRecycler(holders)
|
||||
|
||||
GiphyMp4PlaybackController.attach(recyclerView, callback, 1)
|
||||
@@ -362,7 +361,7 @@ class MessageDetailsFragment : FullScreenDialogFragment(), MessageDetailsAdapter
|
||||
Log.w(TAG, "Not yet implemented!", Exception())
|
||||
}
|
||||
|
||||
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey, callLinkEpoch: CallLinkEpoch?) {
|
||||
override fun onJoinCallLink(callLinkRootKey: CallLinkRootKey) {
|
||||
Log.w(TAG, "Not yet implemented!", Exception())
|
||||
}
|
||||
|
||||
@@ -414,8 +413,12 @@ class MessageDetailsFragment : FullScreenDialogFragment(), MessageDetailsAdapter
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onMessageDetailsFragmentDismissed()
|
||||
class Dialog : WrapperDialogFragment() {
|
||||
override fun getWrappedFragment(): Fragment {
|
||||
return MessageDetailsFragment().apply {
|
||||
arguments = this@Dialog.requireArguments()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -423,16 +426,17 @@ class MessageDetailsFragment : FullScreenDialogFragment(), MessageDetailsAdapter
|
||||
private const val MESSAGE_ID_EXTRA = "message_id"
|
||||
private const val RECIPIENT_EXTRA = "recipient_id"
|
||||
|
||||
fun create(message: MessageRecord, recipientId: RecipientId): DialogFragment {
|
||||
val dialogFragment: DialogFragment = MessageDetailsFragment()
|
||||
val args = Bundle()
|
||||
fun args(recipientId: RecipientId, messageId: Long): Bundle {
|
||||
return bundleOf(
|
||||
MESSAGE_ID_EXTRA to messageId,
|
||||
RECIPIENT_EXTRA to recipientId
|
||||
)
|
||||
}
|
||||
|
||||
args.putLong(MESSAGE_ID_EXTRA, message.id)
|
||||
args.putParcelable(RECIPIENT_EXTRA, recipientId)
|
||||
|
||||
dialogFragment.arguments = args
|
||||
|
||||
return dialogFragment
|
||||
fun create(message: MessageRecord, recipientId: RecipientId): Dialog {
|
||||
return Dialog().apply {
|
||||
arguments = args(recipientId, message.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.AlarmSleepTimer
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics
|
||||
import org.thoughtcrime.securesms.util.SignalTrace
|
||||
import org.thoughtcrime.securesms.util.asChain
|
||||
import org.whispersystems.signalservice.api.util.SleepTimer
|
||||
import org.whispersystems.signalservice.api.util.UptimeSleepTimer
|
||||
@@ -117,7 +118,7 @@ class IncomingMessageObserver(
|
||||
}
|
||||
)
|
||||
|
||||
private val messageContentProcessor = MessageContentProcessor(context)
|
||||
private val messageContentProcessor = MessageContentProcessor.create(context)
|
||||
|
||||
private var appVisible = false
|
||||
private var lastInteractionTime: Long = System.currentTimeMillis()
|
||||
@@ -278,7 +279,9 @@ class IncomingMessageObserver(
|
||||
fun processEnvelope(bufferedProtocolStore: BufferedProtocolStore, envelope: Envelope, serverDeliveredTimestamp: Long): List<FollowUpOperation>? {
|
||||
return when (envelope.type) {
|
||||
Envelope.Type.SERVER_DELIVERY_RECEIPT -> {
|
||||
SignalTrace.beginSection("IncomingMessageObserver#processReceipt")
|
||||
processReceipt(envelope)
|
||||
SignalTrace.endSection()
|
||||
null
|
||||
}
|
||||
|
||||
@@ -286,7 +289,10 @@ class IncomingMessageObserver(
|
||||
Envelope.Type.CIPHERTEXT,
|
||||
Envelope.Type.UNIDENTIFIED_SENDER,
|
||||
Envelope.Type.PLAINTEXT_CONTENT -> {
|
||||
processMessage(bufferedProtocolStore, envelope, serverDeliveredTimestamp)
|
||||
SignalTrace.beginSection("IncomingMessageObserver#processMessage")
|
||||
val followUps = processMessage(bufferedProtocolStore, envelope, serverDeliveredTimestamp)
|
||||
SignalTrace.endSection()
|
||||
followUps
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -298,7 +304,9 @@ class IncomingMessageObserver(
|
||||
|
||||
private fun processMessage(bufferedProtocolStore: BufferedProtocolStore, envelope: Envelope, serverDeliveredTimestamp: Long): List<FollowUpOperation> {
|
||||
val localReceiveMetric = SignalLocalMetrics.MessageReceive.start()
|
||||
SignalTrace.beginSection("IncomingMessageObserver#decryptMessage")
|
||||
val result = MessageDecryptor.decrypt(context, bufferedProtocolStore, envelope, serverDeliveredTimestamp)
|
||||
SignalTrace.endSection()
|
||||
localReceiveMetric.onEnvelopeDecrypted()
|
||||
|
||||
SignalLocalMetrics.MessageLatency.onMessageReceived(envelope.serverTimestamp!!, serverDeliveredTimestamp, envelope.urgent!!)
|
||||
@@ -425,15 +433,13 @@ class IncomingMessageObserver(
|
||||
GroupsV2ProcessingLock.acquireGroupProcessingLock().use {
|
||||
ReentrantSessionLock.INSTANCE.acquire().use {
|
||||
batch.forEach { response ->
|
||||
Log.d(TAG, "Beginning database transaction...")
|
||||
val followUpOperations = SignalDatabase.runInTransaction { db ->
|
||||
val followUps: List<FollowUpOperation>? = processEnvelope(bufferedStore, response.envelope, response.serverDeliveredTimestamp)
|
||||
bufferedStore.flushToDisk()
|
||||
followUps
|
||||
}
|
||||
Log.d(TAG, "Ended database transaction.")
|
||||
|
||||
if (followUpOperations != null) {
|
||||
if (followUpOperations?.isNotEmpty() == true) {
|
||||
Log.d(TAG, "Running ${followUpOperations.size} follow-up operations...")
|
||||
val jobs = followUpOperations.mapNotNull { it.run() }
|
||||
AppDependencies.jobManager.addAllChains(jobs)
|
||||
@@ -451,6 +457,7 @@ class IncomingMessageObserver(
|
||||
SignalLocalMetrics.PushWebsocketFetch.onProcessedBatch()
|
||||
|
||||
if (!hasMore && !decryptionDrained) {
|
||||
SignalTrace.endSection()
|
||||
Log.i(TAG, "Decryptions newly-drained.")
|
||||
decryptionDrained = true
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.EarlyMessageCacheEntry
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics
|
||||
import org.thoughtcrime.securesms.util.SignalTrace
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
@@ -326,7 +327,9 @@ open class MessageContentProcessor(private val context: Context) {
|
||||
open fun process(envelope: Envelope, content: Content, metadata: EnvelopeMetadata, serverDeliveredTimestamp: Long, processingEarlyContent: Boolean = false, localMetric: SignalLocalMetrics.MessageReceive? = null) {
|
||||
val senderRecipient = Recipient.externalPush(SignalServiceAddress(metadata.sourceServiceId, metadata.sourceE164))
|
||||
|
||||
SignalTrace.beginSection("MessageContentProcessor#handleMessage")
|
||||
handleMessage(senderRecipient, envelope, content, metadata, serverDeliveredTimestamp, processingEarlyContent, localMetric)
|
||||
SignalTrace.endSection()
|
||||
|
||||
val earlyCacheEntries: List<EarlyMessageCacheEntry>? = AppDependencies
|
||||
.earlyMessageCache
|
||||
|
||||
@@ -1391,7 +1391,6 @@ object SyncMessageProcessor {
|
||||
roomId,
|
||||
CallLinkCredentials(
|
||||
callLinkUpdate.rootKey!!.toByteArray(),
|
||||
callLinkUpdate.epoch?.toByteArray(),
|
||||
callLinkUpdate.adminPasskey?.toByteArray()
|
||||
)
|
||||
)
|
||||
@@ -1403,7 +1402,6 @@ object SyncMessageProcessor {
|
||||
roomId = roomId,
|
||||
credentials = CallLinkCredentials(
|
||||
linkKeyBytes = callLinkRootKey.keyBytes,
|
||||
epochBytes = callLinkUpdate.epoch?.toByteArray(),
|
||||
adminPassBytes = callLinkUpdate.adminPasskey?.toByteArray()
|
||||
),
|
||||
state = SignalCallLinkState(),
|
||||
|
||||
@@ -220,7 +220,7 @@ class MessageNotification(threadRecipient: Recipient, record: MessageRecord) : N
|
||||
override val timestamp: Long = record.timestamp
|
||||
override val authorRecipient: Recipient = record.fromRecipient.resolve()
|
||||
override val isNewNotification: Boolean = notifiedTimestamp == 0L && !record.isEditMessage
|
||||
val hasSelfMention = record.hasSelfMention()
|
||||
val hasSelfMention = record.hasSelfMention() || (record is MmsMessageRecord && record.quote?.author == Recipient.self().id)
|
||||
|
||||
private var thumbnailInfo: ThumbnailInfo = NotificationThumbnails.getWithoutModifying(this)
|
||||
|
||||
|
||||
@@ -167,13 +167,13 @@ object NotificationStateProvider {
|
||||
isUnreadMessage &&
|
||||
!messageRecord.isOutgoing &&
|
||||
isGroupStoryReply &&
|
||||
(isParentStorySentBySelf || messageRecord.hasSelfMention() || (hasSelfRepliedToStory && !messageRecord.isStoryReaction()))
|
||||
(isParentStorySentBySelf || messageRecord.hasSelfMentionOrQuoteOfSelf() || (hasSelfRepliedToStory && !messageRecord.isStoryReaction()))
|
||||
|
||||
fun includeMessage(notificationProfile: NotificationProfile?): MessageInclusion {
|
||||
return if (isUnreadIncoming || stickyThread || isNotifiableGroupStoryMessage || isIncomingMissedCall) {
|
||||
if (threadRecipient.isMuted && (threadRecipient.isDoNotNotifyMentions || !messageRecord.hasSelfMention())) {
|
||||
if (threadRecipient.isMuted && (threadRecipient.isDoNotNotifyMentions || !messageRecord.hasSelfMentionOrQuoteOfSelf())) {
|
||||
MessageInclusion.MUTE_FILTERED
|
||||
} else if (notificationProfile != null && !notificationProfile.isRecipientAllowed(threadRecipient.id) && !(notificationProfile.allowAllMentions && messageRecord.hasSelfMention())) {
|
||||
} else if (notificationProfile != null && !notificationProfile.isRecipientAllowed(threadRecipient.id) && !(notificationProfile.allowAllMentions && messageRecord.hasSelfMentionOrQuoteOfSelf())) {
|
||||
MessageInclusion.PROFILE_FILTERED
|
||||
} else {
|
||||
MessageInclusion.INCLUDE
|
||||
@@ -209,6 +209,10 @@ object NotificationStateProvider {
|
||||
|
||||
private val Recipient.isDoNotNotifyMentions: Boolean
|
||||
get() = mentionSetting == RecipientTable.MentionSetting.DO_NOT_NOTIFY
|
||||
|
||||
private fun MessageRecord.hasSelfMentionOrQuoteOfSelf(): Boolean {
|
||||
return hasSelfMention() || (this is MmsMessageRecord && quote?.author == Recipient.self().id)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class MessageInclusion {
|
||||
|
||||
@@ -809,7 +809,6 @@ class Recipient(
|
||||
profileAvatar == other.profileAvatar &&
|
||||
notificationChannelValue == other.notificationChannelValue &&
|
||||
sealedSenderAccessModeValue == other.sealedSenderAccessModeValue &&
|
||||
storageId.contentEquals(other.storageId) &&
|
||||
mentionSetting == other.mentionSetting &&
|
||||
wallpaperValue == other.wallpaperValue &&
|
||||
chatColorsValue == other.chatColorsValue &&
|
||||
|
||||
@@ -353,6 +353,9 @@ private fun Content(
|
||||
),
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Exports current account registration credentials to a JSON file
|
||||
* that can be used with quickstart builds.
|
||||
*/
|
||||
object QuickstartCredentialExporter {
|
||||
|
||||
private val TAG = Log.tag(QuickstartCredentialExporter::class.java)
|
||||
|
||||
private val json = Json { prettyPrint = true }
|
||||
|
||||
fun export(context: Context): File {
|
||||
val aci = SignalStore.account.requireAci()
|
||||
val pni = SignalStore.account.requirePni()
|
||||
val e164 = SignalStore.account.requireE164()
|
||||
val servicePassword = SignalStore.account.servicePassword ?: error("No service password")
|
||||
|
||||
val aciIdentityKeyPair = SignalStore.account.aciIdentityKey
|
||||
val pniIdentityKeyPair = SignalStore.account.pniIdentityKey
|
||||
|
||||
val aciSignedPreKey = AppDependencies.protocolStore.aci().loadSignedPreKey(SignalStore.account.aciPreKeys.activeSignedPreKeyId)
|
||||
val aciLastResortKyberPreKey = AppDependencies.protocolStore.aci().loadKyberPreKey(SignalStore.account.aciPreKeys.lastResortKyberPreKeyId)
|
||||
val pniSignedPreKey = AppDependencies.protocolStore.pni().loadSignedPreKey(SignalStore.account.pniPreKeys.activeSignedPreKeyId)
|
||||
val pniLastResortKyberPreKey = AppDependencies.protocolStore.pni().loadKyberPreKey(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId)
|
||||
|
||||
val self = Recipient.self()
|
||||
val profileKey = self.profileKey ?: error("No profile key")
|
||||
val profileName = self.profileName
|
||||
|
||||
val credentials = QuickstartCredentials(
|
||||
aci = aci.toString(),
|
||||
pni = pni.toString(),
|
||||
e164 = e164,
|
||||
servicePassword = servicePassword,
|
||||
aciIdentityKeyPair = Base64.encodeToString(aciIdentityKeyPair.serialize(), Base64.NO_WRAP),
|
||||
pniIdentityKeyPair = Base64.encodeToString(pniIdentityKeyPair.serialize(), Base64.NO_WRAP),
|
||||
aciSignedPreKey = Base64.encodeToString(aciSignedPreKey.serialize(), Base64.NO_WRAP),
|
||||
aciLastResortKyberPreKey = Base64.encodeToString(aciLastResortKyberPreKey.serialize(), Base64.NO_WRAP),
|
||||
pniSignedPreKey = Base64.encodeToString(pniSignedPreKey.serialize(), Base64.NO_WRAP),
|
||||
pniLastResortKyberPreKey = Base64.encodeToString(pniLastResortKyberPreKey.serialize(), Base64.NO_WRAP),
|
||||
profileKey = Base64.encodeToString(profileKey, Base64.NO_WRAP),
|
||||
registrationId = SignalStore.account.registrationId,
|
||||
pniRegistrationId = SignalStore.account.pniRegistrationId,
|
||||
profileGivenName = profileName.givenName,
|
||||
profileFamilyName = profileName.familyName,
|
||||
accountEntropyPool = SignalStore.account.accountEntropyPool.value
|
||||
)
|
||||
|
||||
val outputDir = context.getExternalFilesDir(null) ?: error("No external files directory")
|
||||
val outputFile = File(outputDir, "quickstart-credentials.json")
|
||||
outputFile.writeText(json.encodeToString(credentials))
|
||||
|
||||
Log.i(TAG, "Exported quickstart credentials to ${outputFile.absolutePath}")
|
||||
return outputFile
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.data
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* JSON-serializable bundle of registration credentials for quickstart builds.
|
||||
* All byte arrays are base64-encoded strings.
|
||||
*/
|
||||
@Serializable
|
||||
data class QuickstartCredentials(
|
||||
val version: Int = 1,
|
||||
val aci: String,
|
||||
val pni: String,
|
||||
val e164: String,
|
||||
val servicePassword: String,
|
||||
val aciIdentityKeyPair: String,
|
||||
val pniIdentityKeyPair: String,
|
||||
val aciSignedPreKey: String,
|
||||
val aciLastResortKyberPreKey: String,
|
||||
val pniSignedPreKey: String,
|
||||
val pniLastResortKyberPreKey: String,
|
||||
val profileKey: String,
|
||||
val registrationId: Int,
|
||||
val pniRegistrationId: Int,
|
||||
val profileGivenName: String,
|
||||
val profileFamilyName: String,
|
||||
val accountEntropyPool: String? = null
|
||||
)
|
||||
@@ -72,7 +72,7 @@ fun TransferAccountNavHost(
|
||||
modifier = modifier,
|
||||
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
|
||||
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
|
||||
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitonSpec
|
||||
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -458,8 +458,8 @@ class RegistrationViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun submitCaptchaToken(context: Context) {
|
||||
val e164 = getCurrentE164() ?: throw IllegalStateException("Can't submit captcha token if no phone number is set!")
|
||||
val captchaToken = store.value.captchaToken ?: throw IllegalStateException("Can't submit captcha token if no captcha token is set!")
|
||||
val e164 = getCurrentE164() ?: return clearChallengesAndBail { Log.w(TAG, "Phone number was null when trying to submit captcha token.") }
|
||||
val captchaToken = store.value.captchaToken ?: return bail { Log.w(TAG, "Captcha token was null when trying to submit captcha token.") }
|
||||
|
||||
store.update {
|
||||
it.copy(captchaToken = null, challengeInProgress = true, inProgress = true)
|
||||
@@ -486,7 +486,7 @@ class RegistrationViewModel : ViewModel() {
|
||||
fun requestAndSubmitPushToken(context: Context) {
|
||||
Log.v(TAG, "validatePushToken()")
|
||||
|
||||
val e164 = getCurrentE164() ?: throw IllegalStateException("Can't submit captcha token if no phone number is set!")
|
||||
val e164 = getCurrentE164() ?: return clearChallengesAndBail { Log.w(TAG, "Phone number was null when trying to submit push token.") }
|
||||
|
||||
viewModelScope.launch {
|
||||
Log.d(TAG, "Getting session in order to perform push token verification…")
|
||||
@@ -1063,6 +1063,22 @@ class RegistrationViewModel : ViewModel() {
|
||||
setInProgress(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Like [bail], but also clears challenge state. This is needed when challenge handling fails due to missing phone number,
|
||||
* since otherwise the stale challenges would re-trigger the observer on every config change.
|
||||
*/
|
||||
private fun clearChallengesAndBail(logMessage: () -> Unit) {
|
||||
logMessage()
|
||||
store.update {
|
||||
it.copy(
|
||||
inProgress = false,
|
||||
challengesRequested = emptyList(),
|
||||
challengeInProgress = false,
|
||||
captchaToken = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun registerWithBackupKey(context: Context, backupKey: String, e164: String?, pin: String?, aciIdentityKeyPair: IdentityKeyPair?, pniIdentityKeyPair: IdentityKeyPair?) {
|
||||
setInProgress(true)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.signal.registration.R
|
||||
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
|
||||
|
||||
/**
|
||||
|
||||
@@ -600,6 +600,7 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
private fun updateEnabledControls(showProgress: Boolean, isReRegister: Boolean) {
|
||||
binding.countryCode.isEnabled = !showProgress
|
||||
binding.number.isEnabled = !showProgress
|
||||
countryPickerView.isEnabled = !showProgress
|
||||
binding.cancelButton.visible = !showProgress && isReRegister
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user