Compare commits

...

63 Commits

Author SHA1 Message Date
Cody Henthorne
9359d56880 Bump version to 8.0.3 2026-02-23 11:42:37 -05:00
Cody Henthorne
3214200188 Update baseline profile. 2026-02-23 11:35:58 -05:00
Cody Henthorne
841ab7f983 Update translations and other static files. 2026-02-23 11:06:18 -05:00
Alex Hart
53b3728432 Update handling for early nav. 2026-02-23 11:13:42 -04:00
Alex Hart
cf9f98efc9 Fix bad behavior when rotating device with message details open. 2026-02-23 10:54:21 -04:00
Alex Hart
b5c666a1f4 Bump version to 8.0.2 2026-02-19 13:18:41 -04:00
Alex Hart
b1954a509c Update baseline profile. 2026-02-19 13:16:06 -04:00
Alex Hart
c2c91cfe42 Update translations and other static files. 2026-02-19 12:51:41 -04:00
Greyson Parrelli
cccbec5744 Only set HDR transcoder flags for HDR content. 2026-02-19 10:58:55 -05:00
Alex Hart
4c89b20fad Fix possible captcha race. 2026-02-19 11:14:14 -04:00
Greyson Parrelli
2328fa3e88 Route video GIF attachments to the GENERIC_TRANSCODE queue. 2026-02-19 09:57:23 -05:00
Greyson Parrelli
e19d4624c1 Fix video transcoding crash caused by premature codec API calls.
Move getParameterDescriptor and setParameters calls to after
configure/start, since they require the codec to be in the
Executing state. Always set KEY_COLOR_TRANSFER_REQUEST in the
format before configure as the primary tone-mapping mechanism.
2026-02-19 09:57:06 -05:00
Alex Hart
345f58ed48 Bump version to 8.0.1 2026-02-18 16:26:31 -04:00
Alex Hart
4c14ce3937 Update translations and other static files. 2026-02-18 16:17:34 -04:00
Alex Hart
82684c0169 Bump version to 8.0.0 2026-02-18 16:03:34 -04:00
Alex Hart
2607328255 Update translations and other static files. 2026-02-18 15:52:21 -04:00
Michelle Tang
484ce3a1da Turn on binary service writes. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
85d5f62301 Fix potential archive sync issue. 2026-02-18 15:48:16 -04:00
jeffrey-signal
b0571f8184 Fix edit group member label placeholder text. 2026-02-18 15:48:16 -04:00
Cody Henthorne
b80dd28b40 Defensively prevent gif playback in background. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
e0cf0808cf PIP moves opposite to finger in RTL mode.
Co-authored-by: Alex Hart <alex@signal.org>
2026-02-18 15:48:16 -04:00
Greyson Parrelli
ffdd5b62ae Remove now-unused colorInfo parsing from video transcoding. 2026-02-18 15:48:16 -04:00
jeffrey-signal
3b5376ef8b Fix mixed-directional text input behavior in recipient search field. 2026-02-18 15:48:16 -04:00
jeffrey-signal
cd57fb0d76 Fix member label pill overflow caused by excessive combining marks. 2026-02-18 15:48:16 -04:00
adel-signal
6986acd6f4 Update RingRTC to 2.65.0
Co-authored-by: emir-signal <emir@signal.org>
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-02-18 15:48:16 -04:00
Greyson Parrelli
2bc571ffd3 Possible fix for some transcoding issues. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
a8dddf33f8 Media needs to be tapped on twice for it to load during call.
Fixes #14581
Fixes #14592
2026-02-18 15:48:16 -04:00
Greyson Parrelli
46582a685b Fix weird screen transition when accepting an incoming call.
Co-authored-by: Alex Hart <alex@signal.org>
2026-02-18 15:48:16 -04:00
Greyson Parrelli
ad381783f7 Gifs stop or lower volume of other media playing.
Fixes #14297

Co-authored-by: Alex Hart <alex@signal.org>
2026-02-18 15:48:16 -04:00
jeffrey-signal
b81c1eb65c Fix crash when selecting a group to add a recipient. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
2c4d3b3ee4 Fix deleted attachment still visible under 'Shared media'. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
d1400928ce Slide in country picker vertically in regV5. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
49abece92b Includes quotes of your messages in 'notify for mentions' setting.
Fixes #14595
2026-02-18 15:48:16 -04:00
Greyson Parrelli
b48b1f031e Fix gradle format task. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
9cefe0bc04 Allow importing a backup as part of quickstart. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
ee73b0e229 Allow reading quickstart credentials from disk. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
ab0ce58812 Fix find-by-username text field using wrong background color.
The TextField was not explicitly setting container colors, causing it
to fall back to Material 3 defaults instead of the app's surfaceVariant
color. Added focusedContainerColor, unfocusedContainerColor, and
disabledContainerColor to match the pattern used throughout the rest
of the codebase.
2026-02-18 15:48:16 -04:00
Greyson Parrelli
333a206d36 Fix issue where emoji search bar got weird after tab switching. 2026-02-18 15:48:16 -04:00
Michelle Tang
86bb7666ea Fix unpinning all in note to self. 2026-02-18 15:48:16 -04:00
Cody Henthorne
58b5ebf39d Allow SendDeliveryReceiptJob to run during benchmarking tests. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
47947b85c7 Fix note to self media delete options. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
6910ba6d2e Ignore storageId in Recipient.hasSameContent().
Was potentially causing unnecessary UI churn.
2026-02-18 15:48:16 -04:00
Cody Henthorne
08254edae6 Add incoming group message benchmark tests. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
e67307a961 Add quickstart variant that launches with predefined credentials. 2026-02-18 15:48:16 -04:00
Cody Henthorne
9922621945 Add incoming individual message benchmark tests. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
c7476a2a07 Mute in lobby if accepting large group call. 2026-02-18 15:48:16 -04:00
Alex Hart
ac59528f5c Enable call vanity when joining a video call. 2026-02-18 15:48:16 -04:00
Alex Hart
97c9728c65 Resolve crash in blur container due to multiple measures. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
80d1694e6e Ensure data is updated when refreshing contacts during a search.
Fixes #14112
2026-02-18 15:48:16 -04:00
Greyson Parrelli
28c6e31c7d Removing a recipient does not immediately remove them from the contacts list. 2026-02-18 15:48:16 -04:00
Cody Henthorne
8836b2a570 Fix media send delayed after backgrounding app. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
786c2b888b Remove beta labeling from Signal Secure Backups. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
c91275c5da Add the country code picker to regV5. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
7b362460e7 Spruce up the welcome and permission screens in regV5. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
a1862c3420 Sticker from Google emoji keyboard renders as image attachment when forwarded. 2026-02-18 15:48:16 -04:00
jeffrey-signal
44ea9ccc59 Fix unique constraint violation when importing groups with duplicate recipient IDs. 2026-02-18 15:48:16 -04:00
andrew-signal
4c9cdf3b8f Refactor RefreshOwnProfileJob to Kotlin. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
4a6d4f197d Add 429 handling to various archive calls. 2026-02-18 15:48:16 -04:00
Greyson Parrelli
ae04749336 Add better logging for ovesized messages. 2026-02-18 15:48:16 -04:00
Cody Henthorne
caa743aba2 Fix foreground service start in background crash by starting service sooner for incoming group calls. 2026-02-18 15:48:16 -04:00
Alex Hart
a4469a4285 Fix issue with initial audio output setting.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-18 15:48:08 -04:00
Alex Hart
2771b31aab Clear stale user-selected audio device when it disconnects.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-18 15:48:06 -04:00
Greyson Parrelli
5c418a4260 Add RRP support to regV5. 2026-02-18 15:48:06 -04:00
391 changed files with 12829 additions and 19114 deletions

View File

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

View File

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

View File

@@ -81,8 +81,7 @@ class CallLinkTableTest {
roomId = CallLinkRoomId.fromBytes(roomId),
credentials = CallLinkCredentials(
linkKeyBytes = roomId,
adminPassBytes = null,
epochBytes = null
adminPassBytes = null
),
state = SignalCallLinkState(),
deletionTimestamp = 0L

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -125,7 +125,8 @@ fun CallScreen(
callRecipient = callRecipient,
isVideoCall = isRemoteVideoOffer,
callStatus = callScreenState.callStatus,
callScreenControlsListener = callScreenControlsListener
callScreenControlsListener = callScreenControlsListener,
localParticipant = localParticipant
)
return

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -104,6 +104,7 @@ class EmojiKeyboardPageFragment : Fragment(), EmojiEventListener, EmojiPageViewG
override fun onPageSelected() {
viewModel.refreshRecentEmoji()
appBarLayout.setExpanded(false, false)
}
private fun updateCategoryTab(key: String) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -72,7 +72,7 @@ fun TransferAccountNavHost(
modifier = modifier,
transitionSpec = TransitionSpecs.HorizontalSlide.transitionSpec,
popTransitionSpec = TransitionSpecs.HorizontalSlide.popTransitionSpec,
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitonSpec
predictivePopTransitionSpec = TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec
)
}

View File

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

View File

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

View File

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