Compare commits

...

75 Commits

Author SHA1 Message Date
Greyson Parrelli 439760e773 Bump version to 8.10.1 2026-05-07 16:17:23 -04:00
Greyson Parrelli 7560896e2d Update baseline profile. 2026-05-07 16:16:42 -04:00
Greyson Parrelli fe18def67e Update translations and other static files. 2026-05-07 16:08:50 -04:00
Alex Hart 413962a093 Bypass single-pane scaffold for RTL.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-07 15:42:26 -04:00
Alex Hart e518eca9a1 Do not include SMS recipients in letter header query.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-07 16:26:13 -03:00
Greyson Parrelli b70322b5a6 Fix baseline profile build. 2026-05-07 09:09:30 -04:00
Cody Henthorne 047516c80b Fix missed update item wallpaper bubble background corner radius. 2026-05-06 14:13:41 -04:00
Greyson Parrelli 0a45b9b5e3 Bump version to 8.10.0 2026-05-06 13:21:37 -04:00
Greyson Parrelli 99b0061127 Update translations and other static files. 2026-05-06 13:21:07 -04:00
jeffrey-signal 7b11cc1676 Use GroupId navigation args for conversation settings instead of Parcelable. 2026-05-06 13:08:39 -04:00
jeffrey-signal 663e0a616e Fix message bubble caption exceeding the media width. 2026-05-06 13:08:39 -04:00
Alex Hart d05338cee0 Align sync message tracking with iOS. 2026-05-06 13:08:39 -04:00
Alex Hart ce294dbc0b Add mapping-based lazycolumn / lazyrow. 2026-05-06 13:08:39 -04:00
andrew-signal d0efd8d4b0 Bump to libsignal v0.93.2 2026-05-06 13:08:38 -04:00
Greyson Parrelli c8875b5ad1 Limit R8 threads to 1 to fix non-deterministic output. 2026-05-06 13:08:38 -04:00
jeffrey-signal 188458f772 Open chat tab conversation settings in the detail pane where available. 2026-05-06 13:08:38 -04:00
jeffrey-signal ed7fd10749 Split MainNavigationRouter into focused domain-specific routers. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 2ffbf09b1b Fix lint crash by switching static-ips to properties. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 799e57dbe9 Fix bug allowing concurrent execution of jobs in the same queue.
There was an issue where a higher priority job in the same queue would
become the new most eligible job, even if the current most eligible job
was actively running.
2026-05-06 13:08:38 -04:00
Greyson Parrelli 572c11ee6d Update to AGP 9.1.1 2026-05-06 13:08:38 -04:00
Alex Hart 4dd5a4ee53 Reduce Compose overhead on lower-end device.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-06 13:08:38 -04:00
Alex Hart 370fca3c89 Block screen recording during registration by applying FLAG_SECURE.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-06 13:08:38 -04:00
Cody Henthorne d91f130238 Update color and styling of release note update items. 2026-05-06 13:08:38 -04:00
Greyson Parrelli bb20432417 Fix verification message archive restore. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 8138ea5f8f Show error dialog when ChallengeRequired has empty challenges list in change number flow. 2026-05-06 13:08:38 -04:00
Greyson Parrelli f235aa0599 Fix audio message timestamp truncation when playback speed toggle is visible. 2026-05-06 13:08:38 -04:00
jeffrey-signal c7d719e983 Fix message text sometimes overlapping the footer text.
Resolves signalapp/Signal-Android#13580
2026-05-06 13:08:38 -04:00
adel-signal cf71d43a2f Update DRED duration remote config away from global 2026-05-06 13:08:38 -04:00
Greyson Parrelli 1e70e825a3 Skip session switchover events from non-ACI contacts during backup export. 2026-05-06 13:08:38 -04:00
Greyson Parrelli cce1979716 Catch ForegroundServiceStartNotAllowedException in AttachmentProgressService listener. 2026-05-06 13:08:38 -04:00
Greyson Parrelli ad7e9c0fd7 Skip unlock animation to reduce screen lock dismiss latency. 2026-05-06 13:08:38 -04:00
Greyson Parrelli bd3e1e8059 Catch IllegalArgumentException when setting precomputed text with stale params. 2026-05-06 13:08:38 -04:00
Greyson Parrelli adb9e2173f Reroute ISE to NoSessionException. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 958c6f451f Fix infinite spinner on RegistrationLockFragment when server rejects registration lock token. 2026-05-06 13:08:38 -04:00
Greyson Parrelli ab090236a1 Switch username scanner to use CameraScreen. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 23698dbc28 Switch linked device scanner to use CameraScreen. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 0542262c49 Improve error handling resiliance. 2026-05-06 13:08:38 -04:00
Greyson Parrelli e2d4ca9a4c Improve StorageSyncJob job data reliability. 2026-05-06 13:08:38 -04:00
Greyson Parrelli e54f3f501a Fix improper index usage on story queries. 2026-05-06 13:08:38 -04:00
Cody Henthorne 638d4997d1 Improve chat open performance when thread pool is saturated.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-06 13:08:38 -04:00
Jim Gustafson cbd05c4dff Update to RingRTC v2.68.1 2026-05-06 13:08:38 -04:00
andrew-signal ef396b5758 Bump libsignal to v0.93.1 2026-05-06 13:08:38 -04:00
Alex Hart 1d36ecafe1 Clean up back-pressed behavior which could result in an empty backstack and crash.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
Co-authored-by: jeffrey-signal <jeffrey@signal.org>
2026-05-06 13:08:38 -04:00
Alex Hart 07329c5b0d Migrate VerifyDisplayFragment to compose. 2026-05-06 13:08:38 -04:00
Alex Hart 7fc4ec3006 Migrate donation gateway sheet to compose. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 9e7477bbeb Ensure that story error query uses proper index. 2026-05-06 13:08:38 -04:00
Alex Hart c83054906b Upgrade compose bom.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-06 13:08:38 -04:00
Cody Henthorne 011dc3495f Fix FiatMoneyTests run on non-US locales. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 41b833e788 Improve deletion in all media screen. 2026-05-06 13:08:38 -04:00
Cody Henthorne e11f7225d3 Fix crash and subsequent retry after upload to archive fails length check. 2026-05-06 13:08:38 -04:00
Cody Henthorne bb261a3d85 Favor internal channel ids for recipients over externally created ones. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 116f702be6 Add Live Queries tab to Spinner. 2026-05-06 13:08:38 -04:00
Cody Henthorne 4d09776277 Improve db usage around ensuring custom notification channel stae. 2026-05-06 13:08:38 -04:00
jeffrey-signal f32184c27e Fix padding around incoming caller name. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 5fc037b324 Upgrade to SQLCipher 8.14.0 2026-05-06 13:08:38 -04:00
Cody Henthorne fc9d3e11e8 Only retrieve remote announcements during specific time window. 2026-05-06 13:08:38 -04:00
Cody Henthorne a951c7edfe Fix undownloaded voice note button UI bug. 2026-05-06 13:08:38 -04:00
Cody Henthorne 9d1714d452 Fix unique constraint crash when remapping recipients in name collision table. 2026-05-06 13:08:37 -04:00
Jordan Rose 9c2825f202 Consistently use core-util Hex utility class. 2026-05-06 13:08:37 -04:00
Greyson Parrelli a8969b34a4 Fix HUF currency formatting. 2026-05-06 13:08:37 -04:00
Cody Henthorne 1f59f3c2c4 Use correct wakelock for link device sync. 2026-05-06 13:08:37 -04:00
jeffrey-signal c6d91dce6e Convert ContextUtil to Kotlin. 2026-05-06 13:08:37 -04:00
jeffrey-signal 40c4633d41 Add utility method for resolving a FragmentActivity from Context. 2026-05-06 13:08:37 -04:00
Cody Henthorne edfe89683b Attempt to fix date headers overlaping scheduled messages. 2026-05-06 13:08:37 -04:00
Cody Henthorne cc3bedd154 Center release channel media bubbles in chat. 2026-05-06 13:08:37 -04:00
Cody Henthorne 56803a8850 Add internal preference to disable ANR induced crashing. 2026-05-06 13:08:37 -04:00
andrew-signal 2fdb712b38 Reattempt auth connection when network validated status changes. 2026-05-06 13:08:37 -04:00
Cody Henthorne 3d39045d1b Fix quickstart build failure. 2026-05-06 13:08:37 -04:00
Greyson Parrelli 90385b4e1c Fix flakey registration test. 2026-05-06 13:08:37 -04:00
Greyson Parrelli a02b66601c Remove support for END_SESSION message. 2026-05-06 13:08:37 -04:00
Alex Hart a83c57ff73 Use adaptive bitmap for dynamic shortcut icons to remove white border.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-06 13:08:37 -04:00
Alex Hart 3d063b38be Increase okhttp read/write timeouts for debug log uploads.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-06 13:08:37 -04:00
dependabot[bot] 03d20cb46a Update reproducible build dependencies. 2026-05-06 13:08:37 -04:00
Cody Henthorne 561186df90 Adjust auto-download checks. 2026-05-06 13:08:37 -04:00
Alex Hart fdcd21132c Update logic for processing qrs in CameraScreenViewModel. 2026-04-27 16:44:56 -04:00
388 changed files with 35308 additions and 24118 deletions
+143 -101
View File
@@ -1,7 +1,10 @@
@file:Suppress("UnstableApiUsage")
import com.android.build.api.dsl.ManagedVirtualDevice
import com.android.build.api.artifact.ArtifactTransformationRequest
import com.android.build.api.artifact.SingleArtifact
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
@@ -10,7 +13,6 @@ import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.ktlint)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlinx.serialization)
@@ -22,21 +24,22 @@ plugins {
id("licenses")
}
apply(from = "static-ips.gradle.kts")
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
val canonicalVersionCode = 1684
val canonicalVersionName = "8.9.1"
val canonicalVersionCode = 1686
val canonicalVersionName = "8.10.1"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
// We don't want versions to ever end in 0 so that they don't conflict with nightly versions
val possibleHotfixVersions = (0 until maxHotfixVersions).toList().filter { it % 10 != 0 }
val debugKeystorePropertiesProvider = providers.of(PropertiesFileValueSource::class.java) {
val debugKeystorePropertiesProvider: Provider<Properties> = providers.of(PropertiesFileValueSource::class.java) {
parameters.file.set(rootProject.layout.projectDirectory.file("keystore.debug.properties"))
}
val languagesProvider = providers.of(LanguageListValueSource::class.java) {
val languagesProvider: Provider<List<String>> = providers.of(LanguageListValueSource::class.java) {
parameters.resDir.set(layout.projectDirectory.dir("src/main/res"))
}
@@ -81,6 +84,24 @@ val selectableVariants = listOf(
"githubProdRelease"
)
// Wire 5.x iterates Android source sets and expects matching Kotlin source sets.
// AGP 9.0's built-in Kotlin doesn't create all source sets automatically.
val kotlinExt = extensions.getByName("kotlin") as KotlinAndroidProjectExtension
android.sourceSets.all {
kotlinExt.sourceSets.findByName(name) ?: kotlinExt.sourceSets.create(name)
}
// AGP 9.0's built-in Kotlin doesn't pick up extra java.srcDir entries from Android
// source sets, so add shared dirs directly to the relevant Kotlin compile tasks.
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).configureEach {
val isTestTask = name.contains("UnitTest") || name.contains("AndroidTest")
if (isTestTask) {
source("$projectDir/src/testShared")
}
if (!isTestTask && (name.contains("Mocked") || name.contains("Benchmark"))) {
source("$projectDir/src/benchmarkShared/java")
}
}
wire {
kotlin {
javaInterop = true
@@ -94,8 +115,6 @@ wire {
srcDir("${project.rootDir}/lib/libsignal-service/src/main/protowire")
srcDir("${project.rootDir}/lib/archive/src/main/protowire")
}
// Handled by libsignal
prune("signalservice.DecryptionErrorMessage")
}
ktlint {
@@ -106,7 +125,7 @@ android {
namespace = "org.thoughtcrime.securesms"
buildToolsVersion = libs.versions.buildTools.get()
compileSdkVersion = libs.versions.compileSdk.get()
compileSdkVersion(libs.versions.compileSdk.get())
ndkVersion = libs.versions.ndk.get()
flavorDimensions += listOf("distribution", "environment")
@@ -114,13 +133,7 @@ android {
android.bundle.language.enableSplit = false
kotlinOptions {
jvmTarget = libs.versions.kotlinJvmTarget.get()
freeCompilerArgs = listOf("-Xjvm-default=all")
suppressWarnings = true
}
debugKeystorePropertiesProvider.orNull?.let { properties ->
debugKeystorePropertiesProvider.get().takeIf { it.isNotEmpty() }?.let { properties ->
signingConfigs.getByName("debug").apply {
storeFile = file("${project.rootDir}/${properties.getProperty("storeFile")}")
storePassword = properties.getProperty("storePassword")
@@ -137,8 +150,8 @@ android {
}
managedDevices {
devices {
create<ManagedVirtualDevice>("pixel3api30") {
localDevices {
create("pixel3api30") {
device = "Pixel 3"
apiLevel = 30
systemImageSource = "google-atd"
@@ -195,10 +208,6 @@ android {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
defaultConfig {
if (currentHotfixVersion >= maxHotfixVersions) {
throw AssertionError("Hotfix version offset is too large!")
@@ -291,7 +300,7 @@ android {
isDefault = true
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android.txt"),
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard/proguard-firebase-messaging.pro",
"proguard/proguard-google-play-services.pro",
"proguard/proguard-jackson.pro",
@@ -308,6 +317,7 @@ android {
"proguard/proguard-retrolambda.pro",
"proguard/proguard-okhttp.pro",
"proguard/proguard-ez-vcard.pro",
"proguard/proguard-dnsjava.pro",
"proguard/proguard.cfg"
)
testProguardFiles(
@@ -480,70 +490,6 @@ android {
lintConfig = rootProject.file("lint.xml")
}
androidComponents {
beforeVariants { variant ->
variant.enable = variant.name in selectableVariants
}
onVariants(selector().all()) { variant: com.android.build.api.variant.ApplicationVariant ->
// Include the test-only library on debug builds.
if (variant.buildType != "instrumentation") {
variant.packaging.jniLibs.excludes.add("**/libsignal_jni_testing.so")
}
// Starting with minSdk 23, Android leaves native libraries uncompressed, which is fine for the Play Store, but not for our self-distributed APKs.
// This reverts it to the legacy behavior, compressing the native libraries, and drastically reducing the APK file size.
if (variant.name.contains("website", ignoreCase = true) || variant.name.contains("github", ignoreCase = true)) {
variant.packaging.jniLibs.useLegacyPackaging.set(true)
}
// Version overrides
if (variant.name.contains("nightly", ignoreCase = true)) {
var tag = getNightlyTagForCurrentCommit()
if (!tag.isNullOrEmpty()) {
if (tag.startsWith("v")) {
tag = tag.substring(1)
}
// We add a multiple of maxHotfixVersions to nightlies to ensure we're always at least that many versions ahead
val nightlyBuffer = (5 * maxHotfixVersions)
val nightlyVersionCode = (canonicalVersionCode * maxHotfixVersions) + (getNightlyBuildNumber(tag) * 10) + nightlyBuffer
variant.outputs.forEach { output ->
output.versionName.set("$tag | ${getLastCommitDateTimeUtc()}")
output.versionCode.set(nightlyVersionCode)
}
}
}
}
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 }
}
onVariants(selector().withBuildType("benchmark")) { variant ->
val taskProvider = tasks.register<CopyBenchmarkBackupTask>("copyBenchmarkBackup${variant.name.capitalize()}") {
if (benchmarkBackupFile != null) {
inputFile.set(File(benchmarkBackupFile))
}
}
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
}
}
val releaseDir = "$projectDir/src/release/java"
val debugDir = "$projectDir/src/debug/java"
@@ -565,15 +511,79 @@ android {
manifest.srcFile("$projectDir/src/benchmarkShared/AndroidManifest.xml")
}
}
}
applicationVariants.configureEach {
outputs.configureEach {
if (this is com.android.build.gradle.internal.api.BaseVariantOutputImpl) {
val fileVersionName = versionName.substringBefore(" |")
outputFileName = outputFileName.replace(".apk", "-$fileVersionName.apk")
androidComponents {
beforeVariants { variant ->
variant.enable = variant.name in selectableVariants
}
onVariants(selector().all()) { variant: com.android.build.api.variant.ApplicationVariant ->
// Rename APK to include version name
val renameTask = tasks.register<RenameApkTask>("renameApk${variant.name.replaceFirstChar { it.uppercase() }}")
val renameRequest = variant.artifacts.use(renameTask)
.wiredWithDirectories(RenameApkTask::apkFolder, RenameApkTask::outFolder)
.toTransformMany(SingleArtifact.APK)
renameTask.configure {
transformationRequest.set(renameRequest)
}
// Include the test-only library on debug builds.
if (variant.buildType != "instrumentation") {
variant.packaging.jniLibs.excludes.add("**/libsignal_jni_testing.so")
}
// Starting with minSdk 23, Android leaves native libraries uncompressed, which is fine for the Play Store, but not for our self-distributed APKs.
// This reverts it to the legacy behavior, compressing the native libraries, and drastically reducing the APK file size.
if (variant.name.contains("website", ignoreCase = true) || variant.name.contains("github", ignoreCase = true)) {
variant.packaging.jniLibs.useLegacyPackaging.set(true)
}
// Version overrides
if (variant.name.contains("nightly", ignoreCase = true)) {
var tag = getNightlyTagForCurrentCommit()
if (!tag.isNullOrEmpty()) {
if (tag.startsWith("v")) {
tag = tag.substring(1)
}
// We add a multiple of maxHotfixVersions to nightlies to ensure we're always at least that many versions ahead
val nightlyBuffer = (5 * maxHotfixVersions)
val nightlyVersionCode = (canonicalVersionCode * maxHotfixVersions) + (getNightlyBuildNumber(tag) * 10) + nightlyBuffer
variant.outputs.forEach { output ->
output.versionName.set("$tag | ${getLastCommitDateTimeUtc()}")
output.versionCode.set(nightlyVersionCode)
}
}
}
}
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 }
}
onVariants(selector().withBuildType("benchmark")) { variant ->
val taskProvider = tasks.register<CopyBenchmarkBackupTask>("copyBenchmarkBackup${variant.name.capitalize()}") {
if (benchmarkBackupFile != null) {
inputFile.set(File(benchmarkBackupFile))
}
}
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
}
}
baselineProfile {
@@ -590,6 +600,14 @@ baselineProfile {
dexLayoutOptimization = false
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.fromTarget(libs.versions.kotlinJvmTarget.get())
freeCompilerArgs.addAll("-Xjvm-default=all")
suppressWarnings = true
}
}
dependencies {
lintChecks(project(":lintchecks"))
ktlintRuleset(libs.ktlint.twitter.compose)
@@ -619,11 +637,7 @@ dependencies {
implementation(project(":lib:apng"))
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.appcompat) {
version {
strictly("1.6.1")
}
}
implementation(libs.androidx.appcompat)
implementation(libs.androidx.window.window)
implementation(libs.androidx.window.java)
implementation(libs.androidx.recyclerview)
@@ -743,6 +757,7 @@ dependencies {
}
testImplementation(testLibs.conscrypt.openjdk.uber)
testImplementation(testLibs.mockk)
testImplementation(testFixtures(project(":core:ui")))
testImplementation(testFixtures(project(":lib:libsignal-service")))
testImplementation(testLibs.espresso.core)
testImplementation(testLibs.kotlinx.coroutines.test)
@@ -857,7 +872,7 @@ abstract class LanguageListValueSource : ValueSource<List<String>, LanguageListV
}
}
abstract class PropertiesFileValueSource : ValueSource<Properties?, PropertiesFileValueSource.Params> {
abstract class PropertiesFileValueSource : ValueSource<Properties, PropertiesFileValueSource.Params> {
interface Params : ValueSourceParameters {
@get:InputFile
@get:Optional
@@ -865,9 +880,9 @@ abstract class PropertiesFileValueSource : ValueSource<Properties?, PropertiesFi
val file: RegularFileProperty
}
override fun obtain(): Properties? {
override fun obtain(): Properties {
val f: File = parameters.file.asFile.get()
if (!f.exists()) return null
if (!f.exists()) return Properties()
return Properties().apply {
f.inputStream().use { load(it) }
@@ -937,3 +952,30 @@ abstract class CopyBenchmarkBackupTask : DefaultTask() {
backupFile.copyTo(dest.resolve("backup.binproto"), overwrite = true)
}
}
abstract class RenameApkTask : DefaultTask() {
@get:InputFiles
abstract val apkFolder: DirectoryProperty
@get:OutputDirectory
abstract val outFolder: DirectoryProperty
@get:Internal
abstract val transformationRequest: Property<ArtifactTransformationRequest<RenameApkTask>>
@TaskAction
fun rename() {
transformationRequest.get().submit(this) { artifact ->
val originalFile = File(artifact.outputFile)
val versionName = artifact.versionName?.substringBefore(" |")
val newName = if (!versionName.isNullOrEmpty()) {
originalFile.name.replace(".apk", "-$versionName.apk")
} else {
originalFile.name
}
val newFile = File(outFolder.get().asFile, newName)
originalFile.copyTo(newFile, overwrite = true)
newFile
}
}
}
+4
View File
@@ -0,0 +1,4 @@
# dnsjava references desktop/server-only classes that are absent on Android.
-dontwarn com.sun.jna.**
-dontwarn javax.naming.**
-dontwarn lombok.Generated
@@ -74,7 +74,7 @@ class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPai
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, null)
if (!aciStore.containsSession(getAliceProtocolAddress())) {
val sessionBuilder = SignalSessionBuilder(sessionLock, SessionBuilder(aciStore, getAliceProtocolAddress()))
val sessionBuilder = SignalSessionBuilder(sessionLock, SessionBuilder(aciStore, getAliceProtocolAddress(), SignalProtocolAddress(serviceAddress.identifier, 1)))
sessionBuilder.process(getAlicePreKeyBundle())
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -321,7 +321,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
* This is so we can capture ANR's that happen on boot before the foreground event.
*/
private void startAnrDetector() {
AnrDetector.start(TimeUnit.SECONDS.toMillis(5), RemoteConfig::internalUser, (dumps) -> {
AnrDetector.start(TimeUnit.SECONDS.toMillis(5), () -> RemoteConfig.internalUser() && SignalStore.internal().getAnrDetectionCrashes(), (dumps) -> {
LogDatabase.getInstance(this).anrs().save(System.currentTimeMillis(), dumps);
return Unit.INSTANCE;
});
@@ -85,7 +85,6 @@ import kotlinx.coroutines.withContext
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.isSplitPane
import org.signal.core.ui.permissions.Permissions
import org.signal.core.ui.rememberIsSplitPane
import org.signal.core.util.Util
@@ -521,15 +520,14 @@ class MainActivity :
}.navigateToDetailLocation(location)
}
is MainNavigationDetailLocation.Chats -> {
if (location is MainNavigationDetailLocation.Chats.Conversation) {
chatNavGraphState.writeGraphicsLayerToBitmap()
}
is MainNavigationDetailLocation.Conversation -> {
chatNavGraphState.writeGraphicsLayerToBitmap()
chatsNavHostController.navigateToDetailLocation(location)
}
is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.CallLinkDetails -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
}
}
@@ -848,7 +846,7 @@ class MainActivity :
val detailLocation = extras.getParcelableCompat(KEY_DETAIL_LOCATION, MainNavigationDetailLocation::class.java)
if (detailLocation != null) {
mainNavigationViewModel.goTo(detailLocation)
goTo(detailLocation)
return
}
@@ -1034,7 +1032,7 @@ class MainActivity :
private fun handleConversationIntent(intent: Intent) {
if (ConversationIntents.isConversationIntent(intent)) {
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
intent.action = null
setIntent(intent)
}
@@ -50,7 +50,7 @@ public class MainNavigator {
.withStartingPosition(startingPosition)
.asIncognito(incognito)
.toConversationArgs())
.subscribe(args -> viewModel.goTo(new MainNavigationDetailLocation.Chats.Conversation(args)));
.subscribe(args -> viewModel.goTo(new MainNavigationDetailLocation.Conversation(args)));
lifecycleDisposable.add(disposable);
}
@@ -16,7 +16,6 @@
*/
package org.thoughtcrime.securesms;
import android.animation.Animator;
import android.app.KeyguardManager;
import android.content.Context;
import android.content.Intent;
@@ -49,7 +48,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.AnimatingToggle;
import org.thoughtcrime.securesms.crypto.InvalidPassphraseException;
import org.thoughtcrime.securesms.crypto.MasterSecret;
@@ -389,13 +387,7 @@ public class PassphrasePromptActivity extends PassphraseActivity {
Log.i(TAG, "onAuthenticationSucceeded");
lockScreenButton.setOnClickListener(null);
unlockView.addAnimatorListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
handleAuthenticated();
}
});
unlockView.playAnimation();
handleAuthenticated();
}
@Override
@@ -16,8 +16,8 @@ import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import org.signal.core.util.logging.Log
import org.signal.core.util.safeUnregisterReceiver
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioHandler
import java.util.concurrent.TimeUnit
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.update
import org.signal.core.util.bytes
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.signal.core.util.safeUnregisterReceiver
import org.signal.core.util.throttleLatest
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.attachments.AttachmentId
@@ -31,7 +32,6 @@ import org.thoughtcrime.securesms.jobmanager.impl.DiskSpaceNotLowConstraint
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobmanager.impl.WifiConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
@@ -203,6 +203,7 @@ object ArchiveRestoreProgress {
state.hasActivelyRestoredThisRun -> ArchiveRestoreProgressState.RestoreStatus.FINISHED
else -> ArchiveRestoreProgressState.RestoreStatus.NONE
}
else -> {
val availableBytes = SignalStore.backup.spaceAvailableOnDiskBytes
@@ -322,7 +322,7 @@ class ChatItemArchiveExporter(
}
MessageTypes.isSessionSwitchoverType(record.type) -> {
builder.updateMessage = record.toRemoteSessionSwitchoverUpdate(record.dateSent) ?: continue
builder.updateMessage = record.toRemoteSessionSwitchoverUpdate(record.dateSent)?.takeIf { builder.authorIsAciContact(exportState) } ?: continue
transformTimer.emit("sse")
}
@@ -886,8 +886,8 @@ class ChatItemArchiveImporter(
SimpleChatUpdate.Type.UNKNOWN -> typeWithoutBase
SimpleChatUpdate.Type.JOINED_SIGNAL -> MessageTypes.JOINED_TYPE or typeWithoutBase
SimpleChatUpdate.Type.IDENTITY_UPDATE -> MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT or typeWithoutBase
SimpleChatUpdate.Type.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT or typeWithoutBase
SimpleChatUpdate.Type.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT or typeWithoutBase
SimpleChatUpdate.Type.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT or typeWithoutBase or MessageTypes.BASE_SENT_TYPE
SimpleChatUpdate.Type.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT or typeWithoutBase or MessageTypes.BASE_SENT_TYPE
SimpleChatUpdate.Type.CHANGE_NUMBER -> MessageTypes.CHANGE_NUMBER_TYPE
SimpleChatUpdate.Type.RELEASE_CHANNEL_DONATION_REQUEST -> MessageTypes.RELEASE_CHANNEL_DONATION_REQUEST_TYPE
SimpleChatUpdate.Type.END_SESSION -> MessageTypes.END_SESSION_BIT or typeWithoutBase
@@ -333,9 +333,10 @@ private fun BackupFailedBody() {
append(stringResource(id = R.string.BackupAlertBottomSheet__an_error_occurred))
append(" ")
val link = stringResource(R.string.remote_backup_support_url)
withLink(
LinkAnnotation.Clickable(tag = "learn-more") {
CommunicationActions.openBrowserLink(context, context.getString(R.string.remote_backup_support_url))
CommunicationActions.openBrowserLink(context, link)
}
) {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
@@ -31,10 +31,11 @@ class NoRemoteStorageSpaceAvailableBottomSheet : ComposeBottomSheetDialogFragmen
@Composable
override fun SheetContent() {
val context = LocalContext.current
val supportUrl = stringResource(R.string.remote_backup_support_url)
NoRemoteStorageSpaceAvailableBottomSheetContent(
onLearnMoreClick = {
CommunicationActions.openBrowserLink(context, context.getString(R.string.remote_backup_support_url))
CommunicationActions.openBrowserLink(context, supportUrl)
},
onContactSupportClick = {
ContactSupportDialogFragment.create(
@@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString.Builder
@@ -37,7 +38,6 @@ import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.BackupValues
import org.thoughtcrime.securesms.util.DateUtils
import java.util.Locale
import kotlin.time.Duration.Companion.days
import org.signal.core.ui.R as CoreUiR
@@ -53,7 +53,7 @@ fun BackupCreateErrorRow(
onLearnMoreClick: () -> Unit = {}
) {
val context = LocalContext.current
val locale = Locale.getDefault()
val locale = LocalLocale.current
when (error) {
BackupValues.BackupCreationError.TRANSIENT -> {
@@ -82,7 +82,7 @@ fun BackupCreateErrorRow(
BackupValues.BackupCreationError.BACKUP_FILE_TOO_LARGE -> {
BackupAlertText {
if (lastMessageCutoffTime > 0) {
append(stringResource(R.string.BackupStatusRow__not_backing_up_old_messages, DateUtils.getDayPrecisionTimeString(context, locale, lastMessageCutoffTime)))
append(stringResource(R.string.BackupStatusRow__not_backing_up_old_messages, DateUtils.getDayPrecisionTimeString(context, locale.platformLocale, lastMessageCutoffTime)))
} else {
append(stringResource(R.string.BackupStatusRow__backup_file_too_large))
}
@@ -91,7 +91,7 @@ fun BackupCreateErrorRow(
BackupValues.BackupCreationError.NOT_ENOUGH_DISK_SPACE -> {
BackupAlertText {
append(stringResource(R.string.BackupStatusRow__not_enough_disk_space, DateUtils.getDayPrecisionTimeString(context, locale, lastMessageCutoffTime)))
append(stringResource(R.string.BackupStatusRow__not_enough_disk_space, DateUtils.getDayPrecisionTimeString(context, locale.platformLocale, lastMessageCutoffTime)))
}
}
}
@@ -43,6 +43,7 @@ import org.signal.core.models.AccountEntropyPool
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.fonts.MonoTypeface
import org.thoughtcrime.securesms.registration.ui.restore.BackupKeyVisualTransformation
import org.thoughtcrime.securesms.registration.ui.restore.attachBackupKeyAutoFillHelper
@@ -59,6 +60,8 @@ fun EnterKeyScreen(
captionContent: @Composable () -> Unit,
seeKeyButton: @Composable () -> Unit
) {
TemporaryScreenshotSecurity.bind()
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
@@ -59,6 +59,7 @@ import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.Util
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
import org.thoughtcrime.securesms.fonts.MonoTypeface
@@ -133,6 +134,8 @@ fun MessageBackupsKeyRecordScreen(
mode: MessageBackupsKeyRecordMode = MessageBackupsKeyRecordMode.Next(onNextClick = {}),
notifyKeyIsSameAsOnDeviceBackupKey: Boolean = false
) {
TemporaryScreenshotSecurity.bind()
val snackbarHostState = remember { SnackbarHostState() }
val backupKeyString = remember(backupKey) {
backupKey.chunked(4).joinToString(" ")
@@ -108,9 +108,10 @@ fun VerifyBackupPinScreen(
append(stringResource(id = R.string.VerifyBackupPinScreen__enter_the_backup_key_that_you_recorded))
append(" ")
val supportUrl = stringResource(R.string.remote_backup_support_url)
withLink(
LinkAnnotation.Clickable(tag = "learn-more") {
CommunicationActions.openBrowserLink(context, context.getString(R.string.remote_backup_support_url))
CommunicationActions.openBrowserLink(context, supportUrl)
}
) {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
@@ -10,8 +10,8 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.components.settings.models.SplashImage
@@ -16,9 +16,8 @@ import androidx.fragment.app.FragmentActivity
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.getParcelableExtraCompat
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.main.MainNavigationCallDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationRouter
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.viewModel
@@ -56,23 +55,17 @@ class CallLinkDetailsActivity : FragmentActivity() {
}
}
private inner class Router : MainNavigationRouter {
override fun goTo(location: MainNavigationDetailLocation) {
private inner class Router : MainNavigationCallDetailRouter {
override fun goToCallDetail(location: MainNavigationDetailLocation.Calls) {
when (location) {
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
EditCallLinkNameDialogFragment().apply {
arguments = bundleOf(EditCallLinkNameDialogFragment.ARG_NAME to viewModel.nameSnapshot)
}.show(supportFragmentManager, null)
}
is MainNavigationDetailLocation.Empty -> {
finishAfterTransition()
}
else -> error("Unsupported route $location")
}
}
override fun goTo(location: MainNavigationListLocation) = Unit
override fun exitDetailLocation() = finishAfterTransition()
}
}
@@ -46,8 +46,8 @@ import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.YouAreAlrea
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.SignalCallRow
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.main.MainNavigationCallDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationRouter
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
@@ -63,7 +63,7 @@ fun CallLinkDetailsScreen(
viewModel: CallLinkDetailsViewModel = viewModel {
CallLinkDetailsViewModel(roomId)
},
router: MainNavigationRouter = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalActivity.current as ComponentActivity) {
router: MainNavigationCallDetailRouter = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalActivity.current as ComponentActivity) {
error("Should already be created.")
}
) {
@@ -90,7 +90,7 @@ fun CallLinkDetailsScreen(
class DefaultCallLinkDetailsCallback(
private val activity: FragmentActivity,
private val viewModel: CallLinkDetailsViewModel,
private val router: MainNavigationRouter
private val router: MainNavigationCallDetailRouter
) : CallLinkDetailsCallback {
private val lifecycleDisposable = LifecycleDisposable()
@@ -113,7 +113,7 @@ class DefaultCallLinkDetailsCallback(
}
override fun onEditNameClicked() {
router.goTo(MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
router.goToCallDetail(MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
}
override fun onShareClicked() {
@@ -152,7 +152,7 @@ class DefaultCallLinkDetailsCallback(
viewModel.setDisplayRevocationDialog(false)
activity.lifecycleScope.launch {
if (viewModel.delete()) {
router.goTo(MainNavigationDetailLocation.Empty)
router.exitDetailLocation()
}
}
}
@@ -324,13 +324,16 @@ class CallLogAdapter(
return
}
presentRecipientDetails(model.call.peer, model.call.searchQuery)
presentRecipientDetails(model.call)
presentCallInfo(model.call, model.call.date)
presentCallType(model)
}
private fun presentRecipientDetails(recipient: Recipient, searchQuery: String?) {
binding.callRecipientAvatar.setAvatar(Glide.with(binding.callRecipientAvatar), recipient, true)
private fun presentRecipientDetails(call: CallLogRow.Call) {
val recipient = call.peer
val searchQuery = call.searchQuery
binding.callRecipientAvatar.setAvatar(Glide.with(binding.callRecipientAvatar), recipient, false)
binding.callRecipientAvatar.setOnClickListener { onCallClicked(call) }
binding.callRecipientBadge.setBadgeFromRecipient(recipient)
binding.callRecipientName.text = if (searchQuery != null) {
SearchUtil.getHighlightedSpan(
@@ -333,7 +333,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
viewModel.toggleSelected(callLogRow.id)
} else {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails(callLogRow.record.roomId))
mainNavigationViewModel.goTo(MainNavigationDetailLocation.CallLinkDetails(callLogRow.record.roomId))
}
}
@@ -25,7 +25,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
@@ -214,7 +213,8 @@ private fun UserMessagesHost(
onDismiss: (UserMessage) -> Unit,
snackbarHostState: SnackbarHostState
) {
val context = LocalContext.current
val youAreAlreadyInACall = stringResource(R.string.CommunicationActions__you_are_already_in_a_call)
val errorRetrievingContacts = stringResource(R.string.ContactSelectionListFragment_error_retrieving_contacts_check_your_network_connection)
when (userMessage) {
null -> {}
@@ -228,14 +228,14 @@ private fun UserMessagesHost(
is UserMessage.UserAlreadyInAnotherCall -> LaunchedEffect(userMessage) {
snackbarHostState.showSnackbar(
message = context.getString(R.string.CommunicationActions__you_are_already_in_a_call)
message = youAreAlreadyInACall
)
onDismiss(userMessage)
}
is UserMessage.ContactsRefreshFailed -> LaunchedEffect(userMessage) {
snackbarHostState.showSnackbar(
message = context.getString(R.string.ContactSelectionListFragment_error_retrieving_contacts_check_your_network_connection)
message = errorRetrievingContacts
)
onDismiss(userMessage)
}
@@ -63,6 +63,7 @@ public final class AudioView extends FrameLayout {
@NonNull private final AnimatingToggle controlToggle;
@NonNull private final View progressAndPlay;
@NonNull private final LottieAnimationView playPauseButton;
@NonNull private final View downloadContainer;
@NonNull private final ImageView downloadButton;
@Nullable private final ProgressWheel circleProgress;
@NonNull private final SeekBar seekBar;
@@ -121,13 +122,14 @@ public final class AudioView extends FrameLayout {
throw new IllegalStateException("Unsupported mode: " + mode);
}
this.controlToggle = findViewById(R.id.control_toggle);
this.playPauseButton = findViewById(R.id.play);
this.progressAndPlay = findViewById(R.id.progress_and_play);
this.downloadButton = findViewById(R.id.download);
this.circleProgress = findViewById(R.id.circle_progress);
this.seekBar = findViewById(R.id.seek);
this.duration = findViewById(R.id.duration);
this.controlToggle = findViewById(R.id.control_toggle);
this.playPauseButton = findViewById(R.id.play);
this.progressAndPlay = findViewById(R.id.progress_and_play);
this.downloadContainer = findViewById(R.id.download_container);
this.downloadButton = findViewById(R.id.download);
this.circleProgress = findViewById(R.id.circle_progress);
this.seekBar = findViewById(R.id.seek);
this.duration = findViewById(R.id.duration);
lottieDirection = REVERSE;
this.playPauseButton.setOnClickListener(new PlayPauseClickedListener());
@@ -168,6 +170,7 @@ public final class AudioView extends FrameLayout {
public void setProgressAndPlayBackgroundTint(@ColorInt int color) {
progressAndPlay.getBackground().setColorFilter(color, PorterDuff.Mode.SRC_IN);
downloadContainer.getBackground().setColorFilter(color, PorterDuff.Mode.SRC_IN);
}
public Observer<VoiceNotePlaybackState> getPlaybackStateObserver() {
@@ -195,7 +198,7 @@ public final class AudioView extends FrameLayout {
}
if (showControls && audio.isPendingDownload()) {
controlToggle.displayQuick(downloadButton);
controlToggle.displayQuick(downloadContainer);
seekBar.setEnabled(false);
downloadButton.setOnClickListener(new DownloadClickedListener(audio));
if (circleProgress != null) {
@@ -30,11 +30,12 @@ import com.bumptech.glide.request.target.SimpleTarget;
import com.bumptech.glide.request.target.Target;
import com.bumptech.glide.request.transition.Transition;
import org.signal.core.util.ContextUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar;
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatarDrawable;
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity;
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavigator;
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.SystemContactPhoto;
@@ -53,9 +54,7 @@ import java.util.List;
import java.util.Objects;
public final class AvatarImageView extends AppCompatImageView {
private static final int SIZE_LARGE = 1;
private static final int SIZE_SMALL = 2;
@SuppressWarnings("unused")
private static final String TAG = Log.tag(AvatarImageView.class);
@@ -156,10 +155,10 @@ public final class AvatarImageView extends AppCompatImageView {
public void setAvatar(@NonNull RequestManager requestManager, @Nullable Recipient recipient, boolean quickContactEnabled, boolean useSelfProfileAvatar, boolean useBlurGradient) {
setAvatar(requestManager, recipient, new AvatarOptions.Builder(this)
.withUseSelfProfileAvatar(useSelfProfileAvatar)
.withQuickContactEnabled(quickContactEnabled)
.withUseBlurGradient(useBlurGradient)
.build());
.withUseSelfProfileAvatar(useSelfProfileAvatar)
.withQuickContactEnabled(quickContactEnabled)
.withUseBlurGradient(useBlurGradient)
.build());
}
private void setAvatar(@Nullable Recipient recipient, @NonNull AvatarOptions avatarOptions) {
@@ -182,8 +181,8 @@ public final class AvatarImageView extends AppCompatImageView {
if (!photo.equals(recipientContactPhoto) || shouldBlur != blurred || !Objects.equals(chatColors, this.chatColors) || !Objects.equals(initials, this.initials)) {
requestManager.clear(this);
this.chatColors = chatColors;
this.initials = initials;
this.chatColors = chatColors;
this.initials = initials;
recipientContactPhoto = photo;
FallbackAvatarProvider activeFallbackPhotoProvider = this.fallbackAvatarProvider;
@@ -215,12 +214,12 @@ public final class AvatarImageView extends AppCompatImageView {
List<Transformation<Bitmap>> transforms = Collections.singletonList(new CircleCrop());
RequestBuilder<Drawable> request = requestManager.load(photo.contactPhoto)
.fallback(fallback)
.error(fallback)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.downsample(DownsampleStrategy.CENTER_INSIDE)
.transform(new MultiTransformation<>(transforms))
.addListener(redownloadRequestListener);
.fallback(fallback)
.error(fallback)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.downsample(DownsampleStrategy.CENTER_INSIDE)
.transform(new MultiTransformation<>(transforms))
.addListener(redownloadRequestListener);
if (wasUnblurred) {
blurred = shouldBlur;
@@ -260,17 +259,12 @@ public final class AvatarImageView extends AppCompatImageView {
private void setAvatarClickHandler(@NonNull final Recipient recipient, boolean quickContactEnabled) {
if (quickContactEnabled) {
super.setOnClickListener(v -> {
Context context = getContext();
FragmentActivity activity = ContextUtil.requireFragmentActivity(getContext());
if (recipient.isPushGroup()) {
context.startActivity(ConversationSettingsActivity.forGroup(context, recipient.requireGroupId().requirePush()),
ConversationSettingsActivity.createTransitionBundle(context, this));
ConversationSettingsNavigator.navigate(activity, recipient);
} else {
if (context instanceof FragmentActivity) {
RecipientBottomSheetDialogFragment.show(((FragmentActivity) context).getSupportFragmentManager(), recipient.getId(), null);
} else {
context.startActivity(ConversationSettingsActivity.forRecipient(context, recipient.getId()),
ConversationSettingsActivity.createTransitionBundle(context, this));
}
RecipientBottomSheetDialogFragment.show(activity.getSupportFragmentManager(), recipient.getId(), null);
}
});
} else {
@@ -283,13 +277,13 @@ public final class AvatarImageView extends AppCompatImageView {
Drawable fallback = new FallbackAvatarDrawable(getContext(), new FallbackAvatar.Resource.Group(color)).circleCrop();
Glide.with(this)
.load(avatarBytes)
.dontAnimate()
.fallback(fallback)
.error(fallback)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.circleCrop()
.into(this);
.load(avatarBytes)
.dontAnimate()
.fallback(fallback)
.error(fallback)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.circleCrop()
.into(this);
}
public void setNonAvatarImageResource(@DrawableRes int imageResource) {
@@ -308,7 +302,8 @@ public final class AvatarImageView extends AppCompatImageView {
}
}
private static class DefaultFallbackAvatarProvider implements FallbackAvatarProvider {}
private static class DefaultFallbackAvatarProvider implements FallbackAvatarProvider {
}
private static class RecipientContactPhoto {
@@ -254,7 +254,11 @@ public class ConversationItemFooter extends ConstraintLayout {
}
});
dateView.setMaxWidth(ViewUtil.dpToPx(32));
ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) dateView.getLayoutParams();
params.startToEnd = R.id.footer_audio_playback_speed_toggle;
params.constrainedWidth = true;
params.horizontalBias = 1f;
dateView.setLayoutParams(params);
}
private void hidePlaybackSpeedToggle() {
@@ -276,7 +280,11 @@ public class ConversationItemFooter extends ConstraintLayout {
}
});
dateView.setMaxWidth(Integer.MAX_VALUE);
ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) dateView.getLayoutParams();
params.startToEnd = ConstraintLayout.LayoutParams.UNSET;
params.constrainedWidth = false;
params.horizontalBias = 0.5f;
dateView.setLayoutParams(params);
}
private @NonNull Rect getPlaybackSpeedToggleTouchDelegateRect() {
@@ -9,10 +9,10 @@ import androidx.annotation.DrawableRes;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import org.signal.core.util.ContextUtil;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.SimpleEmojiTextView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SpanUtil;
@@ -5,18 +5,41 @@
package org.thoughtcrime.securesms.components
import android.view.Window
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.fragment.app.Fragment
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import org.thoughtcrime.securesms.util.TextSecurePreferences
import java.util.WeakHashMap
/**
* Applies temporary screenshot security for the given component lifecycle.
*
* Multiple callers can request security on the same window concurrently; the
* flag is only cleared once every caller has released its hold.
*/
object TemporaryScreenshotSecurity {
private val activeHolds = WeakHashMap<Window, Int>()
@Composable
fun bind() {
val activity = LocalActivity.current as? ComponentActivity ?: return
DisposableEffect(activity) {
acquire(activity)
onDispose {
release(activity)
}
}
}
@JvmStatic
fun bindToViewLifecycleOwner(fragment: Fragment) {
val observer = LifecycleObserver { fragment.requireActivity() }
@@ -31,21 +54,37 @@ object TemporaryScreenshotSecurity {
activity.lifecycle.addObserver(observer)
}
private fun acquire(activity: ComponentActivity) {
val window = activity.window
val previous = activeHolds[window] ?: 0
activeHolds[window] = previous + 1
if (previous == 0 && !TextSecurePreferences.isScreenSecurityEnabled(activity)) {
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
private fun release(activity: ComponentActivity) {
val window = activity.window
val next = ((activeHolds[window] ?: 0) - 1).coerceAtLeast(0)
if (next == 0) {
activeHolds.remove(window)
if (!TextSecurePreferences.isScreenSecurityEnabled(activity)) {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
} else {
activeHolds[window] = next
}
}
private class LifecycleObserver(
private val activityProvider: () -> ComponentActivity
) : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
val activity = activityProvider()
if (!TextSecurePreferences.isScreenSecurityEnabled(activity)) {
activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
acquire(activityProvider())
}
override fun onPause(owner: LifecycleOwner) {
val activity = activityProvider()
if (!TextSecurePreferences.isScreenSecurityEnabled(activity)) {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
release(activityProvider())
}
}
}
@@ -69,12 +69,14 @@ fun <Reason> SendSupportEmailEffect(
filterRes: ContactSupportCallbacks.StringForReason<Reason>,
hide: () -> Unit
) {
val subject = stringResource(subjectRes(contactSupportState.reason))
val helpDebugLog = stringResource(R.string.HelpFragment__debug_log)
val context = LocalContext.current
LaunchedEffect(contactSupportState.sendEmail) {
if (contactSupportState.sendEmail) {
val subject = context.getString(subjectRes(contactSupportState.reason))
val prefix = if (contactSupportState.debugLogUrl != null) {
"\n${context.getString(R.string.HelpFragment__debug_log)} ${contactSupportState.debugLogUrl}\n\n"
"\n$helpDebugLog ${contactSupportState.debugLogUrl}\n\n"
} else {
""
}
@@ -15,11 +15,11 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.LinearSmoothScroller;
import androidx.recyclerview.widget.RecyclerView;
import org.signal.core.util.ContextUtil;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiHeader;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiNoResultsModel;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
@@ -243,7 +243,11 @@ public class EmojiTextView extends AppCompatTextView {
return;
}
textView.setPrecomputedText(precomputedTextCompat);
try {
textView.setPrecomputedText(precomputedTextCompat);
} catch (IllegalArgumentException e) {
textView.setText(text, type);
}
if (textView.sizeChangeInProgress) {
textView.sizeChangeInProgress = false;
@@ -1,17 +1,15 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatImageButton;
import org.signal.core.util.ContextUtil;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
public class EmojiToggle extends AppCompatImageButton implements MediaKeyboard.MediaKeyboardListener {
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.ContextThemeWrapper;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import android.widget.FrameLayout;
@@ -15,6 +14,7 @@ import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import org.signal.core.util.ContextUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.InputAwareLayout.InputView;
@@ -29,12 +29,12 @@ public class MediaKeyboard extends FrameLayout implements InputView {
private static final String EMOJI_SEARCH = "emoji_search_fragment";
@Nullable private MediaKeyboardListener keyboardListener;
private boolean isInitialised;
private int latestKeyboardHeight;
private State keyboardState;
private KeyboardPagerFragment keyboardPagerFragment;
private FragmentManager fragmentManager;
private int mediaKeyboardTheme;
private boolean isInitialised;
private int latestKeyboardHeight;
private State keyboardState;
private KeyboardPagerFragment keyboardPagerFragment;
private FragmentManager fragmentManager;
private int mediaKeyboardTheme;
public MediaKeyboard(Context context) {
this(context, null);
@@ -175,7 +175,7 @@ public class MediaKeyboard extends FrameLayout implements InputView {
LayoutInflater.from(getContext()).inflate(R.layout.media_keyboard, this, true);
if (fragmentManager == null) {
FragmentActivity activity = resolveActivity(getContext());
FragmentActivity activity = ContextUtil.requireFragmentActivity(getContext());
fragmentManager = activity.getSupportFragmentManager();
}
@@ -188,25 +188,17 @@ public class MediaKeyboard extends FrameLayout implements InputView {
.replace(R.id.media_keyboard_fragment_container, keyboardPagerFragment, TAG)
.commitNowAllowingStateLoss();
keyboardState = State.NORMAL;
latestKeyboardHeight = -1;
isInitialised = true;
}
}
private static FragmentActivity resolveActivity(@Nullable Context context) {
if (context instanceof FragmentActivity) {
return (FragmentActivity) context;
} else if (context instanceof ContextThemeWrapper) {
return resolveActivity(((ContextThemeWrapper) context).getBaseContext());
} else {
throw new IllegalStateException("Could not locate FragmentActivity");
keyboardState = State.NORMAL;
latestKeyboardHeight = -1;
isInitialised = true;
}
}
public interface MediaKeyboardListener {
void onShown();
void onHidden();
void onKeyboardChanged(@NonNull KeyboardPage page);
}
@@ -11,9 +11,9 @@ import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.core.graphics.drawable.DrawableCompat;
import org.signal.core.util.ContextUtil;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.signal.core.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -31,6 +31,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
@@ -507,6 +508,7 @@ private fun ActiveBackupsRow(
style = MaterialTheme.typography.bodyLarge
)
val locale = LocalLocale.current.platformLocale
when (val type = backupState.messageBackupsType) {
is MessageBackupsType.Paid -> {
val body = if (backupState is BackupState.Canceled) {
@@ -514,13 +516,13 @@ private fun ActiveBackupsRow(
} else if (type.pricePerMonth.amount == BigDecimal.ZERO) {
stringResource(
R.string.BackupsSettingsFragment_renews_s,
DateUtils.formatDateWithYear(Locale.getDefault(), backupState.renewalTime.inWholeMilliseconds)
DateUtils.formatDateWithYear(locale, backupState.renewalTime.inWholeMilliseconds)
)
} else {
stringResource(
R.string.BackupsSettingsFragment_s_month_renews_s,
FiatMoneyUtil.format(LocalContext.current.resources, type.pricePerMonth),
DateUtils.formatDateWithYear(Locale.getDefault(), backupState.renewalTime.inWholeMilliseconds)
DateUtils.formatDateWithYear(locale, backupState.renewalTime.inWholeMilliseconds)
)
}
@@ -4,6 +4,7 @@
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.widget.Toast
@@ -176,6 +177,7 @@ class LocalBackupsFragment : ComposeFragment() {
}
}
@SuppressLint("LocalContextGetResourceValueCall")
@Composable
private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>): ActivityResultLauncher<Uri?> {
val context = LocalContext.current
@@ -54,6 +54,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
@@ -1051,9 +1052,10 @@ private fun BackupCard(
else -> error("Not supported here.")
}
val locale = LocalLocale.current.platformLocale
if (backupState.renewalTime > 0.seconds) {
Text(
text = stringResource(resource, DateUtils.formatDateWithYear(Locale.getDefault(), backupState.renewalTime.inWholeMilliseconds))
text = stringResource(resource, DateUtils.formatDateWithYear(locale, backupState.renewalTime.inWholeMilliseconds))
)
}
}
@@ -1873,8 +1875,10 @@ private fun ErrorCardPreview() {
@Composable
private fun PendingCardPreview() {
Previews.Preview {
val locale = LocalLocale.current.platformLocale
PendingCard(
price = FiatMoney(BigDecimal.TEN, Currency.getInstance(Locale.getDefault()))
price = FiatMoney(BigDecimal.TEN, Currency.getInstance(locale))
)
}
}
@@ -103,6 +103,10 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
is VerificationCodeRequestResult.ChallengeRequired -> {
Log.i(TAG, "Unable to request sms code due to challenges required: ${castResult.challenges.joinToString { it.key }}")
if (castResult.challenges.isEmpty()) {
Log.w(TAG, "Challenge required but no challenges listed, showing error.")
showErrorDialog(R.string.RegistrationActivity_sms_provider_error)
}
}
is VerificationCodeRequestResult.RateLimited -> {
@@ -27,6 +27,7 @@ import org.signal.core.util.bytes
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.util.AttachmentUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.webrtc.CallDataMode
import kotlin.math.abs
@@ -169,6 +170,21 @@ private fun DataAndStorageSettingsScreen(
)
}
item {
Rows.TextRow(
text = {
Text(
text = stringResource(
R.string.DataAndStorageSettingsFragment__voice_messages_and_stickers_under_size_are_always_auto_downloaded,
AttachmentUtil.SMALL_ATTACHMENT_SIZE.toUnitString()
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
item {
Dividers.Default()
}
@@ -207,12 +207,21 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
switchPref(
title = DSLSettingsText.from("Force split pane UI on phones."),
isEnabled = !state.forceSinglePane,
isChecked = state.forceSplitPane,
onClick = {
viewModel.setForceSplitPane(!state.forceSplitPane)
}
)
switchPref(
title = DSLSettingsText.from("Force single-pane on newer devices."),
isChecked = state.forceSinglePane,
onClick = {
viewModel.setForceSinglePane(!state.forceSinglePane)
}
)
clickPref(
title = DSLSettingsText.from("Display enable permission sheet"),
onClick = {
@@ -370,6 +379,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
switchPref(
title = DSLSettingsText.from("Enable ANR-induced crashing"),
isChecked = SignalStore.internal.anrDetectionCrashes,
onClick = {
SignalStore.internal.anrDetectionCrashes = !SignalStore.internal.anrDetectionCrashes
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Logging"))
@@ -31,6 +31,7 @@ data class InternalSettingsState(
val hasPendingOneTimeDonation: Boolean,
val hevcEncoding: Boolean,
val forceSplitPane: Boolean,
val forceSinglePane: Boolean,
val useNewMediaActivity: Boolean,
val disableInternalUser: Boolean
)
@@ -203,6 +203,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null,
hevcEncoding = SignalStore.internal.hevcEncoding,
forceSplitPane = SignalStore.internal.forceSplitPane,
forceSinglePane = SignalStore.internal.forceSinglePane,
useNewMediaActivity = SignalStore.internal.useNewMediaActivity,
disableInternalUser = RemoteConfig.internalUserDisabled
)
@@ -225,6 +226,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun setForceSinglePane(forceSinglePane: Boolean) {
SignalStore.internal.forceSinglePane = forceSinglePane
refresh()
}
class Factory(private val repository: InternalSettingsRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(InternalSettingsViewModel(repository)))
@@ -50,8 +50,8 @@ import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.util.Hex
import org.signal.core.util.Util
import org.signal.libsignal.protocol.util.Hex
import org.thoughtcrime.securesms.components.settings.app.internal.sqlite.InternalSqlitePlaygroundViewModel.QueryResult
class InternalSqlitePlaygroundFragment : ComposeFragment() {
@@ -34,9 +34,9 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
@@ -46,8 +46,7 @@ sealed interface GatewayOrderStrategy {
}
companion object {
fun getStrategy(): GatewayOrderStrategy {
val self = Recipient.self()
fun getStrategy(self: Recipient = Recipient.self()): GatewayOrderStrategy {
val e164 = self.e164.orNull() ?: return Default
return if (PhoneNumberUtil.getInstance().parse(e164, "").countryCode == 1) {
@@ -1,34 +1,28 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.concurrent.LifecycleDisposable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.core.util.dp
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.components.settings.app.subscription.models.IdealWeroButton
import org.thoughtcrime.securesms.components.settings.app.subscription.models.PayPalButton
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.viewModel
import org.signal.core.ui.R as CoreUiR
@@ -36,172 +30,56 @@ import org.signal.core.ui.R as CoreUiR
/**
* Entry point to capturing the necessary payment token to pay for a donation
*/
class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
private val lifecycleDisposable = LifecycleDisposable()
class GatewaySelectorBottomSheet : ComposeBottomSheetDialogFragment() {
private val args: GatewaySelectorBottomSheetArgs by navArgs()
override val peekHeightPercentage: Float = 1f
private val viewModel: GatewaySelectorViewModel by viewModel {
GatewaySelectorViewModel(args, requireListener<GooglePayComponent>().googlePayRepository)
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
BadgeDisplay112.register(adapter)
GooglePayButton.register(adapter)
PayPalButton.register(adapter)
IndeterminateLoadingCircle.register(adapter)
IdealWeroButton.register(adapter)
@Composable
override fun SheetContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += viewModel.state.subscribe { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
GatewaySelectorBottomSheetContent(state, onEvent = this::onEvent)
}
private fun getConfiguration(state: GatewaySelectorState): DSLConfiguration {
return when (state) {
GatewaySelectorState.Loading -> {
configure {
space(16.dp)
customPref(IndeterminateLoadingCircle)
space(16.dp)
private fun onEvent(event: GatewaySelectorBottomSheetEvent) {
when (event) {
GatewaySelectorBottomSheetEvent.GOOGLE_PAY_SELECTED -> {
setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.GOOGLE_PAY)
}
GatewaySelectorBottomSheetEvent.PAYPAL_SELECTED -> {
setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.PAYPAL)
}
GatewaySelectorBottomSheetEvent.SEPA_SELECTED -> {
if (viewModel.checkIsSepaPaymentValidAmount()) {
setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.SEPA_DEBIT)
} else {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(FAILURE_KEY to true, SEPA_EURO_MAX to viewModel.getSepaMaximum()))
}
}
is GatewaySelectorState.Ready -> {
configure {
customPref(
BadgeDisplay112.Model(
badge = state.inAppPayment.data.badge!!.let { Badges.fromDatabaseBadge(it) },
withDisplayText = false
)
)
space(12.dp)
GatewaySelectorBottomSheetEvent.IDEAL_SELECTED -> {
setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.IDEAL)
}
presentTitleAndSubtitle(requireContext(), state.inAppPayment)
space(16.dp)
state.gatewayOrderStrategy.orderedGateways.forEach { gateway ->
when (gateway) {
InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING -> error("Unsupported payment method.")
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> renderGooglePayButton(state)
InAppPaymentData.PaymentMethodType.PAYPAL -> renderPayPalButton(state)
InAppPaymentData.PaymentMethodType.CARD -> renderCreditCardButton(state)
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> renderSEPADebitButton(state)
InAppPaymentData.PaymentMethodType.IDEAL -> renderIDEALButton(state)
InAppPaymentData.PaymentMethodType.UNKNOWN -> error("Unsupported payment method.")
}
}
space(16.dp)
}
GatewaySelectorBottomSheetEvent.CREDIT_CARD_SELECTED -> {
setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.CARD)
}
}
}
private fun DSLConfiguration.renderGooglePayButton(state: GatewaySelectorState.Ready) {
if (state.isGooglePayAvailable) {
space(16.dp)
customPref(
GooglePayButton.Model(
isEnabled = true,
onClick = {
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.GOOGLE_PAY)
.subscribeBy {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
}
}
)
)
}
}
private fun DSLConfiguration.renderPayPalButton(state: GatewaySelectorState.Ready) {
if (state.isPayPalAvailable) {
space(16.dp)
customPref(
PayPalButton.Model(
onClick = {
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.PAYPAL)
.subscribeBy {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
}
},
isEnabled = true
)
)
}
}
private fun DSLConfiguration.renderCreditCardButton(state: GatewaySelectorState.Ready) {
if (state.isCreditCardAvailable) {
space(16.dp)
primaryButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__credit_or_debit_card),
icon = DSLSettingsIcon.from(R.drawable.credit_card, CoreUiR.color.signal_colorOnCustom),
disableOnClick = true,
onClick = {
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.CARD)
.subscribeBy {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
}
}
)
}
}
private fun DSLConfiguration.renderSEPADebitButton(state: GatewaySelectorState.Ready) {
if (state.isSEPADebitAvailable) {
space(16.dp)
tonalButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__bank_transfer),
icon = DSLSettingsIcon.from(R.drawable.bank_transfer),
disableOnClick = true,
onClick = {
val price = state.inAppPayment.data.amount!!.toFiatMoney()
if (state.sepaEuroMaximum != null &&
price.currency == CurrencyUtil.EURO &&
price.amount > state.sepaEuroMaximum.amount
) {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(FAILURE_KEY to true, SEPA_EURO_MAX to state.sepaEuroMaximum.amount))
} else {
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.SEPA_DEBIT)
.subscribeBy {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
}
}
}
)
}
}
private fun DSLConfiguration.renderIDEALButton(state: GatewaySelectorState.Ready) {
if (state.isIDEALAvailable) {
space(16.dp)
customPref(
IdealWeroButton.Model(
onClick = {
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.IDEAL)
.subscribeBy {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
}
}
)
)
private fun setPaymentMethodAndDismiss(type: InAppPaymentData.PaymentMethodType) {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
val inAppPayment = viewModel.updateInAppPaymentMethod(type)
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to inAppPayment))
}
}
@@ -0,0 +1,366 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.util.money.FiatMoney
import org.signal.donations.DonateWithGooglePayButton
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImage112
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toDecimalValue
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.IdealWeroButton
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.PayPalButton
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList
import org.thoughtcrime.securesms.database.model.databaseprotos.FiatValue
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.recipients.Recipient
import java.math.BigDecimal
import java.util.Currency
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun GatewaySelectorBottomSheetContent(
state: GatewaySelectorState,
onEvent: (GatewaySelectorBottomSheetEvent) -> Unit,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.testTag(GatewaySelectorTestTags.CONTAINER)
.verticalScroll(scrollState)
.horizontalGutters()
.fillMaxWidth()
) {
BottomSheets.Handle()
when (state) {
GatewaySelectorState.Loading -> Loading()
is GatewaySelectorState.Ready -> Ready(state, onEvent)
}
}
}
@Composable
private fun Loading() {
CircularProgressIndicator(
modifier = Modifier.padding(vertical = 16.dp)
)
}
@Composable
private fun Ready(state: GatewaySelectorState.Ready, onEvent: (GatewaySelectorBottomSheetEvent) -> Unit) {
BadgeImage112(
badge = state.inAppPayment.data.badge!!.let { Badges.fromDatabaseBadge(it) },
modifier = Modifier.size(112.dp)
)
Spacer(modifier = Modifier.size(12.dp))
TitleAndSubtitle(state.inAppPayment)
Spacer(modifier = Modifier.size(16.dp))
var isGatewaySelected by remember { mutableStateOf(false) }
val onGatewaySelected: (GatewaySelectorBottomSheetEvent) -> Unit = remember(onEvent) {
{
if (!isGatewaySelected) {
isGatewaySelected = true
onEvent(it)
}
}
}
state.gatewayOrderStrategy.orderedGateways.forEach {
when (it) {
InAppPaymentData.PaymentMethodType.UNKNOWN -> error("Unsupported payment method.")
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> {
if (state.isGooglePayAvailable) {
DonateWithGooglePayButton(
onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.GOOGLE_PAY_SELECTED) },
enabled = !isGatewaySelected,
modifier = Modifier
.testTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON)
.padding(top = 16.dp)
.fillMaxWidth()
.height(44.dp)
)
}
}
InAppPaymentData.PaymentMethodType.CARD -> {
if (state.isCreditCardAvailable) {
Buttons.LargePrimary(
onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.CREDIT_CARD_SELECTED) },
enabled = !isGatewaySelected,
modifier = Modifier
.testTag(GatewaySelectorTestTags.CREDIT_CARD_BUTTON)
.padding(top = 16.dp)
.fillMaxWidth()
.height(44.dp)
) {
Row(
horizontalArrangement = spacedBy(8.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.credit_card),
contentDescription = null
)
Text(
text = stringResource(R.string.GatewaySelectorBottomSheet__credit_or_debit_card)
)
}
}
}
}
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> {
if (state.isSEPADebitAvailable) {
Buttons.LargeTonal(
onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.SEPA_SELECTED) },
enabled = !isGatewaySelected,
modifier = Modifier
.testTag(GatewaySelectorTestTags.SEPA_BUTTON)
.padding(top = 16.dp)
.fillMaxWidth()
.height(44.dp)
) {
Row(
horizontalArrangement = spacedBy(8.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.bank_transfer),
contentDescription = null
)
Text(
text = stringResource(R.string.GatewaySelectorBottomSheet__bank_transfer)
)
}
}
}
}
InAppPaymentData.PaymentMethodType.IDEAL -> {
if (state.isIDEALAvailable) {
IdealWeroButton(
onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.IDEAL_SELECTED) },
enabled = !isGatewaySelected,
modifier = Modifier
.testTag(GatewaySelectorTestTags.IDEAL_BUTTON)
.padding(top = 16.dp)
.height(44.dp)
.fillMaxWidth()
)
}
}
InAppPaymentData.PaymentMethodType.PAYPAL -> {
if (state.isPayPalAvailable) {
PayPalButton(
enabled = !isGatewaySelected,
onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.PAYPAL_SELECTED) },
modifier = Modifier
.testTag(GatewaySelectorTestTags.PAYPAL_BUTTON)
.padding(top = 16.dp)
.height(44.dp)
.fillMaxWidth()
)
}
}
InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING -> error("Unsupported payment method.")
}
}
Spacer(modifier = Modifier.size(16.dp))
}
@DayNightPreviews
@Composable
private fun GatewaySelectorBottomSheetContentLoadingPreview() {
Previews.BottomSheetContentPreview {
GatewaySelectorBottomSheetContent(
state = GatewaySelectorState.Loading,
onEvent = {}
)
}
}
@Composable
private fun TitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) {
when (inAppPayment.type) {
InAppPaymentType.UNKNOWN -> error("Unsupported type UNKNOWN")
InAppPaymentType.ONE_TIME_GIFT -> OneTimeGiftTitleAndSubtitle(inAppPayment)
InAppPaymentType.ONE_TIME_DONATION -> RecurringDonationTitleAndSubtitle(inAppPayment)
InAppPaymentType.RECURRING_DONATION -> OneTimeDonationTitleAndSubtitle(inAppPayment)
InAppPaymentType.RECURRING_BACKUP -> error("This type is not supported")
}
}
@Composable
private fun RecurringDonationTitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) {
Text(
text = stringResource(R.string.GatewaySelectorBottomSheet__donate_s_month_to_signal, rememberFormattedAmount(inAppPayment)),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 6.dp)
)
Text(
text = stringResource(R.string.GatewaySelectorBottomSheet__get_a_s_badge, inAppPayment.data.badge!!.name),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@Composable
private fun OneTimeDonationTitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) {
Text(
text = stringResource(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, rememberFormattedAmount(inAppPayment)),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 6.dp)
)
Text(
text = pluralStringResource(R.plurals.GatewaySelectorBottomSheet__get_a_s_badge_for_d_days, 30, inAppPayment.data.badge!!.name, 30),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@Composable
private fun OneTimeGiftTitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) {
Text(
text = stringResource(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, rememberFormattedAmount(inAppPayment)),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 6.dp)
)
Text(
text = stringResource(R.string.GatewaySelectorBottomSheet__donate_for_a_friend),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@Composable
private fun rememberFormattedAmount(inAppPayment: InAppPaymentTable.InAppPayment): String {
val resources = LocalResources.current
return remember(inAppPayment.data.amount) {
FiatMoneyUtil.format(resources, inAppPayment.data.amount!!.toFiatMoney())
}
}
@DayNightPreviews
@Composable
private fun GatewaySelectorBottomSheetContentReadyOneTimeDonationPreview() {
Previews.BottomSheetContentPreview {
GatewaySelectorBottomSheetContent(
state = rememberGatewaySelectorBottomSheetContentPreviewState(InAppPaymentType.ONE_TIME_DONATION),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun GatewaySelectorBottomSheetContentReadyRecurringDonationPreview() {
Previews.BottomSheetContentPreview {
GatewaySelectorBottomSheetContent(
state = rememberGatewaySelectorBottomSheetContentPreviewState(InAppPaymentType.RECURRING_DONATION),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun GatewaySelectorBottomSheetContentReadyOneTimeGiftDonationPreview() {
Previews.BottomSheetContentPreview {
GatewaySelectorBottomSheetContent(
state = rememberGatewaySelectorBottomSheetContentPreviewState(InAppPaymentType.ONE_TIME_GIFT),
onEvent = {}
)
}
}
@Composable
@VisibleForTesting
fun rememberGatewaySelectorBottomSheetContentPreviewState(type: InAppPaymentType): GatewaySelectorState.Ready {
return remember {
GatewaySelectorState.Ready(
inAppPayment = InAppPaymentTable.InAppPayment(
id = InAppPaymentTable.InAppPaymentId(1),
type = type,
state = InAppPaymentTable.State.CREATED,
insertedAt = 1.milliseconds,
updatedAt = 1.milliseconds,
notified = true,
subscriberId = null,
endOfPeriod = 0.milliseconds,
data = InAppPaymentData(
badge = BadgeList.Badge(
name = type.name.lowercase()
),
amount = FiatValue(currencyCode = "USD", amount = BigDecimal.TEN.toDecimalValue())
)
),
gatewayOrderStrategy = GatewayOrderStrategy.getStrategy(
self = Recipient(
isResolving = false,
e164Value = "+15555555555"
)
),
isGooglePayAvailable = true,
isPayPalAvailable = true,
isCreditCardAvailable = true,
isSEPADebitAvailable = true,
isIDEALAvailable = true,
sepaEuroMaximum = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD"))
)
}
}
@@ -0,0 +1,14 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
enum class GatewaySelectorBottomSheetEvent {
GOOGLE_PAY_SELECTED,
PAYPAL_SELECTED,
SEPA_SELECTED,
IDEAL_SELECTED,
CREDIT_CARD_SELECTED
}
@@ -1,8 +1,9 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
import io.reactivex.rxjava3.core.Single
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.getAvailablePaymentMethods
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
@@ -35,8 +36,8 @@ object GatewaySelectorRepository {
}
}
fun setInAppPaymentMethodType(inAppPayment: InAppPaymentTable.InAppPayment, paymentMethodType: InAppPaymentData.PaymentMethodType): Single<InAppPaymentTable.InAppPayment> {
return Single.fromCallable {
suspend fun setInAppPaymentMethodType(inAppPayment: InAppPaymentTable.InAppPayment, paymentMethodType: InAppPaymentData.PaymentMethodType): InAppPaymentTable.InAppPayment {
return withContext(Dispatchers.IO) {
SignalDatabase.inAppPayments.update(
inAppPayment.copy(
data = inAppPayment.data.copy(
@@ -44,7 +45,9 @@ object GatewaySelectorRepository {
)
)
)
}.flatMap { InAppPaymentsRepository.requireInAppPayment(inAppPayment.id) }
SignalDatabase.inAppPayments.getById(inAppPayment.id) ?: throw Exception("Not found.")
}
}
data class GatewayConfiguration(
@@ -0,0 +1,15 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
object GatewaySelectorTestTags {
const val CONTAINER = "container"
const val GOOGLE_PAY_BUTTON = "google_pay_button"
const val PAYPAL_BUTTON = "paypal_button"
const val CREDIT_CARD_BUTTON = "credit_card_button"
const val SEPA_BUTTON = "sepa_button"
const val IDEAL_BUTTON = "ideal_button"
}
@@ -1,29 +1,33 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.rx.RxStore
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
import java.math.BigDecimal
class GatewaySelectorViewModel(
args: GatewaySelectorBottomSheetArgs,
repository: GooglePayRepository
) : ViewModel() {
private val store = RxStore<GatewaySelectorState>(GatewaySelectorState.Loading)
private val store = MutableStateFlow<GatewaySelectorState>(GatewaySelectorState.Loading)
private val disposables = CompositeDisposable()
val state = store.stateFlowable
val state = store.asStateFlow()
init {
val inAppPayment = InAppPaymentsRepository.requireInAppPayment(args.inAppPaymentId)
@@ -48,13 +52,28 @@ class GatewaySelectorViewModel(
}
override fun onCleared() {
store.dispose()
disposables.clear()
}
fun updateInAppPaymentMethod(inAppPaymentMethodType: InAppPaymentData.PaymentMethodType): Single<InAppPaymentTable.InAppPayment> {
val state = store.state as GatewaySelectorState.Ready
fun getSepaMaximum(): BigDecimal {
val state = store.value as GatewaySelectorState.Ready
return state.sepaEuroMaximum!!.amount
}
return GatewaySelectorRepository.setInAppPaymentMethodType(state.inAppPayment, inAppPaymentMethodType).observeOn(AndroidSchedulers.mainThread())
fun checkIsSepaPaymentValidAmount(): Boolean {
val state = store.value as GatewaySelectorState.Ready
val price = state.inAppPayment.data.amount!!.toFiatMoney()
return !(
state.sepaEuroMaximum != null &&
price.currency == CurrencyUtil.EURO &&
price.amount > state.sepaEuroMaximum.amount
)
}
suspend fun updateInAppPaymentMethod(inAppPaymentMethodType: InAppPaymentData.PaymentMethodType): InAppPaymentTable.InAppPayment {
val state = store.value as GatewaySelectorState.Ready
return GatewaySelectorRepository.setInAppPaymentMethodType(state.inAppPayment, inAppPaymentMethodType)
}
}
@@ -29,8 +29,8 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.database.InAppPaymentTable
@@ -1,32 +0,0 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.models
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
object GooglePayButton {
class Model(val onClick: () -> Unit, override val isEnabled: Boolean) : PreferenceModel<Model>(isEnabled = isEnabled) {
override fun areItemsTheSame(newItem: Model): Boolean = true
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val googlePayButton: View = findViewById(R.id.googlepay_button)
override fun bind(model: Model) {
googlePayButton.isEnabled = model.isEnabled
googlePayButton.setOnClickListener {
googlePayButton.isEnabled = false
model.onClick()
}
}
}
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.google_pay_button_pref))
}
}
@@ -1,28 +0,0 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.models
import org.thoughtcrime.securesms.databinding.PaypalButtonBinding
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
object PayPalButton {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, PaypalButtonBinding::inflate))
}
class Model(val onClick: () -> Unit, val isEnabled: Boolean) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = true
override fun areContentsTheSame(newItem: Model): Boolean = isEnabled == newItem.isEnabled
}
class ViewHolder(binding: PaypalButtonBinding) : BindingViewHolder<Model, PaypalButtonBinding>(binding) {
override fun bind(model: Model) {
binding.paypalButton.isEnabled = model.isEnabled
binding.paypalButton.setOnClickListener {
binding.paypalButton.isEnabled = false
model.onClick()
}
}
}
}
@@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.models
package org.thoughtcrime.securesms.components.settings.app.subscription.ui
import android.view.View
import android.widget.TextView
@@ -1,19 +1,11 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.models
package org.thoughtcrime.securesms.components.settings.app.subscription.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ButtonColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
@@ -21,49 +13,19 @@ 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.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.models.DSLComposePreference
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
/**
* DSL Ideal | Wero button for the payments gateway.
*/
object IdealWeroButton {
@Stable
class Model(val onClick: () -> Unit) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean = true
}
class ViewHolder(itemView: ComposeView) : DSLComposePreference.ViewHolder<Model>(itemView) {
@Composable
override fun Content(model: Model) {
IdealWeroButton(model)
}
}
fun register(adapter: MappingAdapter) {
DSLComposePreference.register(adapter) { ViewHolder(it) }
}
}
@Composable
private fun IdealWeroButton(model: IdealWeroButton.Model) {
var enabled by remember { mutableStateOf(true) }
fun IdealWeroButton(
onClick: () -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier
) {
Buttons.LargeTonal(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
onClick = {
enabled = false
model.onClick()
},
onClick = onClick,
enabled = enabled,
modifier = Modifier
.height(44.dp)
.horizontalGutters()
.fillMaxWidth(),
modifier = modifier,
colors = ButtonColors(
containerColor = colorResource(org.signal.core.ui.R.color.signal_light_colorPrimaryContainer),
contentColor = colorResource(org.signal.core.ui.R.color.signal_light_colorOnPrimaryContainer),
@@ -82,6 +44,6 @@ private fun IdealWeroButton(model: IdealWeroButton.Model) {
@Composable
private fun IdealWeroButtonPreview() {
Previews.Preview {
IdealWeroButton(model = remember { IdealWeroButton.Model(onClick = {}) })
IdealWeroButton(onClick = {}, enabled = true)
}
}
@@ -1,4 +1,4 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.models
package org.thoughtcrime.securesms.components.settings.app.subscription.ui
import android.view.View
import com.google.android.material.button.MaterialButton
@@ -0,0 +1,85 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
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.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
@Composable
fun PayPalButton(
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val overlayColor = colorResource(org.signal.core.ui.R.color.signal_light_colorTransparent3)
Buttons.LargeTonal(
onClick = onClick,
enabled = enabled,
contentPadding = PaddingValues.Zero,
modifier = modifier.drawWithContent {
drawContent()
if (!enabled) {
drawRoundRect(
color = overlayColor,
cornerRadius = CornerRadius(500f, 500f)
)
}
},
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFF6C757),
disabledContainerColor = Color(0xFFF6C757)
)
) {
Image(
imageVector = ImageVector.vectorResource(R.drawable.paypal),
contentDescription = stringResource(R.string.BackupsTypeSettingsFragment__paypal)
)
}
}
@DayNightPreviews
@Composable
fun PayPalButtonPreview() {
Previews.Preview {
PayPalButton(
enabled = true,
onClick = {},
modifier = Modifier
.horizontalGutters()
.fillMaxWidth()
.height(44.dp)
)
}
}
@DayNightPreviews
@Composable
fun PayPalButtonDisabledPreview() {
Previews.Preview {
PayPalButton(
enabled = false,
onClick = {},
modifier = Modifier
.horizontalGutters()
.fillMaxWidth()
.height(44.dp)
)
}
}
@@ -7,7 +7,6 @@ import android.app.Activity
import android.content.Intent
import android.graphics.Bitmap
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.animation.AnimatedVisibility
@@ -49,8 +48,7 @@ import androidx.core.app.ShareCompat
import androidx.core.app.TaskStackBuilder
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@@ -58,8 +56,10 @@ import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.PermissionStatus
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlinx.coroutines.CoroutineScope
import org.signal.camera.CameraScreenEvents
import org.signal.camera.CameraScreenState
import org.signal.camera.CameraScreenViewModel
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.DayNightPreviews
@@ -69,7 +69,6 @@ import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.permissions.Permissions
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
@@ -85,7 +84,6 @@ import java.util.UUID
class UsernameLinkSettingsFragment : ComposeFragment() {
private val viewModel: UsernameLinkSettingsViewModel by viewModels()
private val disposables: LifecycleDisposable = LifecycleDisposable()
private lateinit var galleryLauncher: ActivityResultLauncher<Unit>
@@ -115,21 +113,29 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
val linkCopiedEvent: UUID? by viewModel.linkCopiedEvent
val helpText = stringResource(id = R.string.UsernameLinkSettings_scan_this_qr_code)
val cameraViewModel: CameraScreenViewModel = viewModel { CameraScreenViewModel() }
val cameraState by cameraViewModel.state
val cameraPermissionState: PermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA) {
viewModel.onTabSelected(ActiveTab.Scan)
}
LaunchedEffect(cameraViewModel) {
cameraViewModel.qrCodeDetected.collect { data ->
viewModel.onQrCodeScanned(data)
}
}
MainScreen(
state = state,
navController = navController,
lifecycleOwner = viewLifecycleOwner,
disposables = disposables.disposables,
cameraState = cameraState,
cameraEmitter = cameraViewModel::onEvent,
cameraPermissionState = cameraPermissionState,
onCodeTabSelected = { viewModel.onTabSelected(ActiveTab.Code) },
onScanTabSelected = { viewModel.onTabSelected(ActiveTab.Scan) },
onUsernameLinkResetResultHandled = { viewModel.onUsernameLinkResetResultHandled() },
onShareBadge = { shareQrBadge(requireActivity(), viewModel.generateQrCodeImage(helpText)) },
onQrCodeScanned = { data -> viewModel.onQrCodeScanned(data) },
onQrResultHandled = { viewModel.onQrResultHandled() },
onOpenCameraClicked = { askCameraPermissions() },
onOpenGalleryClicked = { galleryLauncher.launch(Unit) },
@@ -143,10 +149,6 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
disposables.bindTo(viewLifecycleOwner)
}
override fun onResume() {
super.onResume()
viewModel.onResume()
@@ -167,14 +169,13 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
private fun MainScreen(
state: UsernameLinkSettingsState,
navController: NavController? = null,
lifecycleOwner: LifecycleOwner = previewLifecycleOwner,
disposables: CompositeDisposable = CompositeDisposable(),
cameraState: CameraScreenState = CameraScreenState(),
cameraEmitter: (CameraScreenEvents) -> Unit = {},
cameraPermissionState: PermissionState = previewPermissionState(),
onCodeTabSelected: () -> Unit = {},
onScanTabSelected: () -> Unit = {},
onUsernameLinkResetResultHandled: () -> Unit = {},
onShareBadge: () -> Unit = {},
onQrCodeScanned: (String) -> Unit = {},
onQrResultHandled: () -> Unit = {},
onOpenCameraClicked: () -> Unit = {},
onOpenGalleryClicked: () -> Unit = {},
@@ -238,10 +239,9 @@ private fun MainScreen(
exit = slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth })
) {
UsernameQrScanScreen(
lifecycleOwner = lifecycleOwner,
disposables = disposables,
qrScanResult = state.qrScanResult,
onQrCodeScanned = onQrCodeScanned,
cameraState = cameraState,
cameraEmitter = cameraEmitter,
onQrResultHandled = onQrResultHandled,
onOpenCameraClicked = onOpenCameraClicked,
onOpenGalleryClicked = onOpenGalleryClicked,
@@ -394,11 +394,6 @@ private fun previewPermissionState(): PermissionState {
}
}
private val previewLifecycleOwner: LifecycleOwner = object : LifecycleOwner {
override val lifecycle: Lifecycle
get() = throw UnsupportedOperationException("Only for tests")
}
private fun shareQrBadge(activity: Activity, badge: Bitmap?) {
if (badge == null) {
return
@@ -1,45 +1,50 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.LifecycleOwner
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.signal.camera.CameraCaptureMode
import org.signal.camera.CameraScreen
import org.signal.camera.CameraScreenEvents
import org.signal.camera.CameraScreenState
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.qr.QrScannerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
import org.thoughtcrime.securesms.qr.QrScanScreens
import org.thoughtcrime.securesms.qr.QrCrosshair
import org.thoughtcrime.securesms.recipients.Recipient
import java.util.concurrent.TimeUnit
/**
* A screen that allows you to scan a QR code to start a chat.
*/
@Composable
fun UsernameQrScanScreen(
lifecycleOwner: LifecycleOwner,
disposables: CompositeDisposable,
qrScanResult: QrScanResult?,
onQrCodeScanned: (String) -> Unit,
cameraState: CameraScreenState,
cameraEmitter: (CameraScreenEvents) -> Unit,
onQrResultHandled: () -> Unit,
onOpenCameraClicked: () -> Unit,
onOpenGalleryClicked: () -> Unit,
@@ -88,26 +93,49 @@ fun UsernameQrScanScreen(
modifier = Modifier
.fillMaxWidth()
.weight(1f, true)
.background(Color.Black)
) {
QrScanScreens.QrScanScreen(
factory = { context ->
val view = QrScannerView(context)
disposables += view.qrData.throttleFirst(3000, TimeUnit.MILLISECONDS).subscribe { data ->
onQrCodeScanned(data)
if (hasCameraPermission) {
CameraScreen(
state = cameraState,
emitter = cameraEmitter,
enableQrScanning = true,
captureMode = CameraCaptureMode.ImageOnly,
roundCorners = false,
fillViewport = true,
modifier = Modifier.fillMaxSize()
) {
QrCrosshair(modifier = Modifier.fillMaxSize())
}
} else {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.align(Alignment.Center)
.padding(48.dp)
) {
Text(
text = stringResource(R.string.CameraXFragment_to_scan_qr_code_allow_camera),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = Color.White
)
Buttons.MediumTonal(
colors = ButtonDefaults.filledTonalButtonColors(),
onClick = onOpenCameraClicked
) {
Text(stringResource(R.string.CameraXFragment_allow_access))
}
view
},
update = { view ->
view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXRemoteConfig.isBlocklisted())
},
hasPermission = hasCameraPermission,
onRequestPermissions = onOpenCameraClicked,
qrHeaderLabelString = ""
)
}
}
FloatingActionButton(
shape = CircleShape,
containerColor = SignalTheme.colors.colorSurface1,
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 24.dp),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 24.dp),
onClick = onOpenGalleryClicked
) {
Image(
@@ -143,3 +171,37 @@ private fun QrScanResultDialog(title: String? = null, message: String, onDismiss
onDismiss = onDismiss
)
}
@DayNightPreviews
@Composable
private fun UsernameQrScanScreenPreview() {
Previews.Preview {
UsernameQrScanScreen(
qrScanResult = null,
cameraState = CameraScreenState(),
cameraEmitter = {},
onQrResultHandled = {},
onOpenCameraClicked = {},
onOpenGalleryClicked = {},
onRecipientFound = {},
hasCameraPermission = true
)
}
}
@DayNightPreviews
@Composable
private fun UsernameQrScanScreenNoPermissionPreview() {
Previews.Preview {
UsernameQrScanScreen(
qrScanResult = null,
cameraState = CameraScreenState(),
cameraEmitter = {},
onQrResultHandled = {},
onOpenCameraClicked = {},
onOpenGalleryClicked = {},
onRecipientFound = {},
hasCameraPermission = false
)
}
}
@@ -24,22 +24,24 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.rememberPermissionState
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.signal.camera.CameraScreenEvents
import org.signal.camera.CameraScreenState
import org.signal.camera.CameraScreenViewModel
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.permissions.Permissions
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableExtraCompat
import org.signal.core.util.permissions.PermissionCompat
import org.thoughtcrime.securesms.R
@@ -57,7 +59,6 @@ class UsernameQrScannerActivity : AppCompatActivity() {
}
private val viewModel: UsernameQrScannerViewModel by viewModels()
private val disposables = LifecycleDisposable()
@SuppressLint("MissingSuperCall")
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
@@ -66,7 +67,6 @@ class UsernameQrScannerActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
disposables.bindTo(this)
val galleryLauncher = registerForActivityResult(QrImageSelectionActivity.Contract()) { uri ->
if (uri != null) {
@@ -86,14 +86,22 @@ class UsernameQrScannerActivity : AppCompatActivity() {
val cameraPermissionState: PermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
val state by viewModel.state
val cameraViewModel: CameraScreenViewModel = viewModel { CameraScreenViewModel() }
val cameraState by cameraViewModel.state
LaunchedEffect(cameraViewModel) {
cameraViewModel.qrCodeDetected.collect { url ->
viewModel.onQrScanned(url)
}
}
SignalTheme {
Content(
lifecycleOwner = this,
diposables = disposables.disposables,
state = state,
cameraState = cameraState,
cameraEmitter = cameraViewModel::onEvent,
galleryPermissionsState = galleryPermissionState,
cameraPermissionState = cameraPermissionState,
onQrScanned = { url -> viewModel.onQrScanned(url) },
onQrResultHandled = {
finish()
},
@@ -143,12 +151,11 @@ class UsernameQrScannerActivity : AppCompatActivity() {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Content(
lifecycleOwner: LifecycleOwner,
diposables: CompositeDisposable,
state: UsernameQrScannerViewModel.ScannerState,
cameraState: CameraScreenState,
cameraEmitter: (CameraScreenEvents) -> Unit,
galleryPermissionsState: MultiplePermissionsState,
cameraPermissionState: PermissionState,
onQrScanned: (String) -> Unit,
onQrResultHandled: () -> Unit,
onOpenCameraClicked: () -> Unit,
onOpenGalleryClicked: () -> Unit,
@@ -173,10 +180,9 @@ fun Content(
}
) { contentPadding ->
UsernameQrScanScreen(
lifecycleOwner = lifecycleOwner,
disposables = diposables,
qrScanResult = state.qrScanResult,
onQrCodeScanned = onQrScanned,
cameraState = cameraState,
cameraEmitter = cameraEmitter,
onQrResultHandled = onQrResultHandled,
onOpenCameraClicked = onOpenCameraClicked,
onOpenGalleryClicked = onOpenGalleryClicked,
@@ -1,12 +1,9 @@
package org.thoughtcrime.securesms.components.settings.conversation
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityOptionsCompat
import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
@@ -36,35 +33,6 @@ open class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSet
}
companion object {
@JvmStatic
fun createTransitionBundle(context: Context, avatar: View, windowContent: View): Bundle? {
return if (context is Activity) {
ActivityOptionsCompat.makeSceneTransitionAnimation(
context,
*arrayOf(
androidx.core.util.Pair.create(avatar, "avatar"),
androidx.core.util.Pair.create(windowContent, "window_content")
)
).toBundle()
} else {
null
}
}
@JvmStatic
fun createTransitionBundle(context: Context, avatar: View): Bundle? {
return if (context is Activity) {
ActivityOptionsCompat.makeSceneTransitionAnimation(
context,
avatar,
"avatar"
).toBundle()
} else {
null
}
}
@JvmStatic
fun forGroup(context: Context, groupId: GroupId): Intent {
val startBundle = ConversationSettingsFragmentArgs.Builder(null, groupId, null)
@@ -38,6 +38,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.concurrent.addTo
import org.signal.core.util.getParcelableArrayListExtraCompat
import org.signal.core.util.orNull
import org.signal.core.util.requireDrawable
import org.signal.core.util.requireParcelableCompat
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.AvatarPreviewActivity
@@ -91,8 +92,8 @@ import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescription
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationRouter
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
@@ -111,7 +112,6 @@ import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
import org.thoughtcrime.securesms.stories.viewer.AddToGroupStoryDelegate
import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.ExpirationUtil
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
@@ -147,30 +147,28 @@ class ConversationSettingsFragment :
private val alertDisabledTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary_50) }
private val colorizer = ColorizerV2()
private val blockIcon by lazy {
ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_block_24).apply {
requireContext().requireDrawable(R.drawable.symbol_block_24).apply {
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
}
}
private val leaveIcon by lazy {
ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_leave_24).apply {
requireContext().requireDrawable(R.drawable.symbol_leave_24).apply {
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
}
}
private val endGroupIcon by lazy {
ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_x_circle_24).apply {
requireContext().requireDrawable(R.drawable.symbol_x_circle_24).apply {
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
}
}
private val viewModel by viewModels<ConversationSettingsViewModel>(
factoryProducer = {
val groupId = args.groupId as? GroupId
ConversationSettingsViewModel.Factory(
recipientId = args.recipientId,
groupId = groupId,
groupId = args.groupId,
callMessageIds = args.callMessageIds ?: longArrayOf(),
repository = ConversationSettingsRepository(requireContext()),
messageRequestRepository = MessageRequestRepository(requireContext())
@@ -179,7 +177,7 @@ class ConversationSettingsFragment :
)
private var transitionCallback: TransitionCallback? = null
private var mainNavRouter: MainNavigationRouter? = null
private var chatRouter: MainNavigationChatDetailRouter? = null
private lateinit var toolbar: Toolbar
private lateinit var toolbarAvatarContainer: FrameLayout
@@ -196,7 +194,7 @@ class ConversationSettingsFragment :
override fun onAttach(context: Context) {
super.onAttach(context)
transitionCallback = context as? TransitionCallback
mainNavRouter = context as? MainNavigationRouter
chatRouter = context as? MainNavigationChatDetailRouter
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -253,9 +251,7 @@ class ConversationSettingsFragment :
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return if (item.itemId == R.id.action_edit) {
val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
val groupId = args.groupId as GroupId
startActivity(CreateProfileActivity.getIntentForGroupProfile(requireActivity(), requireNotNull(groupId)))
startActivity(CreateProfileActivity.getIntentForGroupProfile(requireActivity(), requireNotNull(args.groupId)))
true
} else {
super.onOptionsItemSelected(item)
@@ -263,8 +259,8 @@ class ConversationSettingsFragment :
}
private fun goToConversationList() {
if (mainNavRouter != null) {
mainNavRouter?.goTo(MainNavigationDetailLocation.Empty)
if (chatRouter != null) {
chatRouter?.exitDetailLocation()
} else {
startActivity(MainActivity.clearTopAndOpenDetail(requireContext(), MainNavigationDetailLocation.Empty))
}
@@ -914,7 +910,7 @@ class ConversationSettingsFragment :
icon = DSLSettingsIcon.from(R.drawable.ic_link_16),
isEnabled = state.recipient.isActiveGroup && !state.isDeprecatedOrUnregistered,
onClick = {
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToShareableGroupLinkFragment(groupState.groupId.requireV2().toString()))
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToShareableGroupLinkFragment(groupState.groupId))
}
)
@@ -0,0 +1,34 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation
import androidx.fragment.app.FragmentActivity
import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.recipients.Recipient
/**
* Routes to the conversation settings screen, handling split-pane vs. standalone activity automatically.
*/
object ConversationSettingsNavigator {
@JvmStatic
fun navigate(
activity: FragmentActivity,
recipient: Recipient
) {
if (activity is MainNavigationChatDetailRouter) {
activity.goToChatDetail(MainNavigationDetailLocation.Chats.ConversationSettings(recipient.id))
return
}
val intent = if (recipient.isPushGroup) {
ConversationSettingsActivity.forGroup(activity, recipient.requireGroupId())
} else {
ConversationSettingsActivity.forRecipient(activity, recipient.id)
}
activity.startActivity(intent)
}
}
@@ -100,15 +100,16 @@ class MuteUntilTimePickerBottomSheet : ComposeBottomSheetDialogFragment() {
}
val zonedDateTime = remember { ZonedDateTime.now() }
val timezoneDisclaimer = remember {
val zoneOffsetFormatter = DateTimeFormatter.ofPattern("OOOO")
val zoneNameFormatter = DateTimeFormatter.ofPattern("zzzz")
context.getString(
R.string.MuteUntilTimePickerBottomSheet__timezone_disclaimer,
zoneOffsetFormatter.format(zonedDateTime),
zoneNameFormatter.format(zonedDateTime)
)
}
val zoneOffset = remember { DateTimeFormatter.ofPattern("OOOO").format(zonedDateTime) }
val zoneName = remember { DateTimeFormatter.ofPattern("zzzz").format(zonedDateTime) }
val timezoneDisclaimer = stringResource(
R.string.MuteUntilTimePickerBottomSheet__timezone_disclaimer,
zoneOffset,
zoneName
)
val selectDateTitle = stringResource(R.string.MuteUntilTimePickerBottomSheet__select_date_title)
val selectTimeTitle = stringResource(R.string.MuteUntilTimePickerBottomSheet__select_time_title)
MuteUntilSheetContent(
dateText = dateText,
@@ -117,7 +118,7 @@ class MuteUntilTimePickerBottomSheet : ComposeBottomSheetDialogFragment() {
onDateClick = {
val local = LocalDateTime.now().atMidnight().atUTC().toMillis()
val datePicker = MaterialDatePicker.Builder.datePicker()
.setTitleText(context.getString(R.string.MuteUntilTimePickerBottomSheet__select_date_title))
.setTitleText(selectDateTitle)
.setSelection(selectedDate)
.setCalendarConstraints(CalendarConstraints.Builder().setStart(local).setValidator(DateValidatorPointForward.now()).build())
.build()
@@ -138,7 +139,7 @@ class MuteUntilTimePickerBottomSheet : ComposeBottomSheetDialogFragment() {
.setTimeFormat(timeFormat)
.setHour(selectedHour)
.setMinute(selectedMinute)
.setTitleText(context.getString(R.string.MuteUntilTimePickerBottomSheet__select_time_title))
.setTitleText(selectTimeTitle)
.build()
timePicker.addOnDismissListener {
@@ -9,7 +9,6 @@ import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.ui.GroupErrors
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@@ -24,10 +23,8 @@ class PermissionsSettingsFragment : DSLSettingsFragment(
private val viewModel: PermissionsSettingsViewModel by viewModels(
factoryProducer = {
val args = PermissionsSettingsFragmentArgs.fromBundle(requireArguments())
val groupId = requireNotNull(args.groupId as GroupId)
val repository = PermissionsSettingsRepository(requireContext())
PermissionsSettingsViewModel.Factory(groupId, repository)
PermissionsSettingsViewModel.Factory(args.groupId, repository)
}
)
@@ -6,6 +6,7 @@ import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import org.signal.core.util.requireDrawable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
@@ -13,7 +14,6 @@ import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelPillView
import org.thoughtcrime.securesms.groups.memberlabel.StyledMemberLabel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@@ -105,7 +105,7 @@ object RecipientPreference {
} else {
if (recipient.isSystemContact) {
SpannableStringBuilder(recipient.getDisplayName(context)).apply {
val drawable = ContextUtil.requireDrawable(context, R.drawable.symbol_person_circle_24).apply {
val drawable = context.requireDrawable(R.drawable.symbol_person_circle_24).apply {
setTint(ContextCompat.getColor(context, CoreUiR.color.signal_colorOnSurface))
}
SpanUtil.appendCenteredImageSpan(this, drawable, 16, 16)
@@ -30,10 +30,10 @@ import androidx.core.widget.ImageViewCompat
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import com.google.android.material.button.MaterialButton
import org.signal.core.util.dp
import org.signal.core.util.requireDrawable
import org.signal.libsignal.protocol.fingerprint.Fingerprint
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.qr.QrCodeUtil
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.visible
import java.nio.charset.Charset
@@ -205,7 +205,7 @@ class SafetyNumberQrView : ConstraintLayout {
private fun createVerifiedBitmap(width: Int, height: Int, @DrawableRes id: Int): Bitmap {
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val check = ContextUtil.requireDrawable(context, id).toBitmap()
val check = context.requireDrawable(id).toBitmap()
val offset = ((width - check.width) / 2).toFloat()
canvas.drawBitmap(check, offset, offset, null)
return bitmap
@@ -15,7 +15,7 @@ import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavigator
import org.thoughtcrime.securesms.components.webrtc.controls.CallInfoView
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -82,7 +82,7 @@ class CallInfoCallbacks(
}
override fun onContactDetails(callParticipant: CallParticipant) {
activity.startActivity(ConversationSettingsActivity.forRecipient(activity, callParticipant.recipient.id))
ConversationSettingsNavigator.navigate(activity, callParticipant.recipient)
}
override fun onViewSafetyNumber(callParticipant: CallParticipant) {
@@ -653,6 +653,10 @@ private fun InfoOverlay(
if (!renderInPip) {
Spacer(modifier = Modifier.size(12.dp))
val shortDisplayName = rememberRecipientField(recipient) { getShortDisplayName(context) }
val sIsBlocked = stringResource(R.string.CallParticipantView__s_is_blocked, shortDisplayName)
val canNotReceiveAudio = stringResource(R.string.CallParticipantView__cant_receive_audio_video_from_s, shortDisplayName)
// Use AndroidView for EmojiTextView
AndroidView(
factory = { ctx ->
@@ -670,15 +674,9 @@ private fun InfoOverlay(
},
update = { view ->
view.text = if (isBlocked) {
context.getString(
R.string.CallParticipantView__s_is_blocked,
recipient.getShortDisplayName(context)
)
sIsBlocked
} else {
context.getString(
R.string.CallParticipantView__cant_receive_audio_video_from_s,
recipient.getShortDisplayName(context)
)
canNotReceiveAudio
}
},
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
@@ -145,7 +145,7 @@ fun IncomingCallScreen(
MaterialTheme.typography.headlineMedium.copy(shadow = textShadow)
},
color = Color.White,
modifier = Modifier.padding(top = 16.dp)
modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp)
)
if (callStatus != null) {
@@ -14,6 +14,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import org.signal.core.util.BreakIteratorCompat
import org.signal.core.util.requireDrawable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar
import org.thoughtcrime.securesms.avatar.view.AvatarView
@@ -29,7 +30,6 @@ import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@@ -543,7 +543,7 @@ open class ContactSearchAdapter(
val recipient = getRecipient(model)
val suffix: CharSequence? = if (recipient.isSystemContact && !recipient.showVerified) {
SpannableStringBuilder().apply {
val drawable = ContextUtil.requireDrawable(context, R.drawable.symbol_person_circle_24).apply {
val drawable = context.requireDrawable(R.drawable.symbol_person_circle_24).apply {
setTint(ContextCompat.getColor(context, CoreUiR.color.signal_colorOnSurface))
}
SpanUtil.appendCenteredImageSpan(this, drawable, 16, 16)
@@ -244,14 +244,8 @@ class ContactSearchPagedDataSource(
return contactSearchPagedDataSourceRepository.querySignalContactLetterHeaders(
query = query,
includeSelfMode = section.includeSelfMode,
includePush = when (section.transportType) {
ContactSearchConfiguration.TransportType.PUSH, ContactSearchConfiguration.TransportType.ALL -> true
else -> false
},
includeSms = when (section.transportType) {
ContactSearchConfiguration.TransportType.SMS, ContactSearchConfiguration.TransportType.ALL -> true
else -> false
}
includePush = true,
includeSms = false
)
}
@@ -303,14 +303,10 @@ object ContactDiscovery {
}
if (NotificationChannels.supported()) {
SignalDatabase.recipients.getRecipientsWithNotificationChannels().use { reader ->
var recipient: Recipient? = reader.getNext()
while (recipient != null) {
NotificationChannels.getInstance().updateContactChannelName(recipient)
recipient = reader.getNext()
}
}
SignalDatabase
.recipients
.getRecipientsWithNotificationChannels()
.forEach { NotificationChannels.getInstance().updateContactChannelName(Recipient.resolved(it.id)) }
}
}
@@ -70,11 +70,13 @@ import com.google.common.collect.Sets;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.ui.util.ThemeUtil;
import org.signal.core.ui.view.Stub;
import org.signal.core.util.BidiUtil;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.StringUtil;
import org.signal.core.util.Util;
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;
@@ -147,14 +149,12 @@ import org.thoughtcrime.securesms.util.LongClickMovementMethod;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.PlaceholderURLSpan;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.ProjectionList;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SearchUtil;
import org.signal.core.ui.util.ThemeUtil;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.UrlClickHandler;
import org.signal.core.util.Util;
import org.thoughtcrime.securesms.util.VibrateUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.NullableStub;
@@ -291,6 +291,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private final ProjectionList colorizerProjections = new ProjectionList(3);
private boolean isBound = false;
private RelativeLayout.LayoutParams normalBubbleParams = null;
private final Runnable shrinkBubble = new Runnable() {
@Override
public void run() {
@@ -421,6 +423,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
this.displayMode = displayMode;
this.previousMessage = previousMessageRecord;
lastFooterDecisionLineWidth = -1;
lastFooterWasCollapsed = false;
setGutterSizes(messageRecord, groupThread);
setMessageShape(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setMediaAttributes(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, hasWallpaper, isMessageRequestAccepted, allowedToPlayInline);
@@ -597,7 +602,14 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
int collapsedTopMargin = -1 * (dateView.getMeasuredHeight() + ViewUtil.dpToPx(4));
if (bodyText.isSingleLine() && !messageRecord.isFailed()) {
int maxBubbleWidth = hasBigImageLinkPreview(messageRecord) || hasThumbnail(messageRecord) ? readDimen(R.dimen.media_bubble_max_width) : getMaxBubbleWidth();
int maxBubbleWidth = hasBigImageLinkPreview(messageRecord) || hasThumbnail(messageRecord) ? readDimen(R.dimen.media_bubble_max_width) : getMaxBubbleWidth();
if (hasThumbnail(messageRecord) || hasBigImageLinkPreview(messageRecord)) {
int thumbnailWidth = mediaThumbnailStub.resolved() ? mediaThumbnailStub.require().getMeasuredWidth() : 0;
if (thumbnailWidth > 0) {
maxBubbleWidth = Math.min(maxBubbleWidth, thumbnailWidth);
}
}
int bodyMargins = ViewUtil.getLeftMargin(bodyText) + ViewUtil.getRightMargin(bodyText);
int sizeWithMargins = bodyText.getMeasuredWidth() + TEXT_FOOTER_SPACING + footerWidth + bodyMargins;
int minSize = Math.min(maxBubbleWidth, Math.max(bodyText.getMeasuredWidth() + TEXT_FOOTER_SPACING + footerWidth + bodyMargins, bodyBubble.getMeasuredWidth()));
@@ -631,10 +643,12 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (lineWidthChangedSlightly) {
if (lastFooterWasCollapsed && ViewUtil.getTopMargin(footer) != collapsedTopMargin) {
ViewUtil.setTopMargin(footer, collapsedTopMargin, false);
ViewUtil.setBottomMargin(footer, collapsedBottomMargin, false);
updatingFooter = true;
needsMeasure = true;
if (requiredSpace - FOOTER_POSITION_THRESHOLD <= availableSpace) {
ViewUtil.setTopMargin(footer, collapsedTopMargin, false);
ViewUtil.setBottomMargin(footer, collapsedBottomMargin, false);
updatingFooter = true;
needsMeasure = true;
}
}
} else {
if (requiredSpace + FOOTER_POSITION_THRESHOLD <= availableSpace) {
@@ -658,7 +672,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
boolean lineWidthChangedSlightly = Math.abs(currentLineWidth - lastFooterDecisionLineWidth) <= FOOTER_POSITION_THRESHOLD;
if (lineWidthChangedSlightly && lastFooterWasCollapsed) {
shouldRevert = false;
int currentRequiredSpace = currentLineWidth + TEXT_FOOTER_SPACING + footer.getMeasuredWidth();
if (currentRequiredSpace - FOOTER_POSITION_THRESHOLD <= bodyText.getMeasuredWidth()) {
shouldRevert = false;
}
}
}
@@ -697,6 +714,17 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
if (!isViewOnceMessage(messageRecord) && (hasThumbnail(messageRecord) || hasBigImageLinkPreview(messageRecord))) {
int thumbnailWidth = mediaThumbnailStub.require().getMeasuredWidth();
if (thumbnailWidth > 0 && bodyBubble.getMeasuredWidth() > thumbnailWidth) {
bodyBubble.getLayoutParams().width = thumbnailWidth;
updatingFooter = false;
lastFooterDecisionLineWidth = -1;
lastFooterWasCollapsed = false;
needsMeasure = true;
}
}
if (needsMeasure) {
if (measureCalls < MAX_MEASURE_CALLS) {
measureCalls++;
@@ -1862,7 +1890,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private void setGutterSizes(@NonNull MessageRecord current, boolean isGroupThread) {
if (isGroupThread && current.isOutgoing()) {
if (isReleaseNotes) {
int gutter = readDimen(R.dimen.conversation_individual_right_gutter);
ViewUtil.setPaddingStart(this, gutter);
ViewUtil.setPaddingEnd(this, gutter);
} else if (isGroupThread && current.isOutgoing()) {
ViewUtil.setPaddingStart(this, readDimen(R.dimen.conversation_group_left_gutter));
ViewUtil.setPaddingEnd(this, readDimen(R.dimen.conversation_individual_right_gutter));
} else if (current.isOutgoing()) {
@@ -1872,6 +1904,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
ViewUtil.setPaddingStart(this, readDimen(R.dimen.conversation_individual_received_left_gutter));
ViewUtil.setPaddingEnd(this, readDimen(R.dimen.conversation_individual_right_gutter));
}
if (isReleaseNotes && normalBubbleParams == null) {
RelativeLayout.LayoutParams bubbleParams = (RelativeLayout.LayoutParams) bodyBubble.getLayoutParams();
normalBubbleParams = new RelativeLayout.LayoutParams(bubbleParams);
bubbleParams.addRule(RelativeLayout.CENTER_HORIZONTAL);
bubbleParams.setMarginStart(0);
bodyBubble.setLayoutParams(bubbleParams);
} else if (normalBubbleParams != null && !isReleaseNotes) {
bodyBubble.setLayoutParams(normalBubbleParams);
normalBubbleParams = null;
}
}
private void setReactions(@NonNull MessageRecord current) {
@@ -17,12 +17,12 @@ import androidx.core.content.ContextCompat;
import com.bumptech.glide.RequestManager;
import org.signal.core.util.ContextUtil;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.avatar.view.AvatarView;
import org.thoughtcrime.securesms.badges.BadgeImageView;
import org.thoughtcrime.securesms.database.model.StoryViewState;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -221,13 +221,16 @@ public final class ConversationUpdateItem extends FrameLayout
observeDisplayBody(lifecycleOwner, spannableMessage);
observeDisplayBodyWithTimer(lifecycleOwner);
boolean donationRequest = conversationMessage.getMessageRecord().isReleaseChannelDonationRequest();
present(conversationMessage, nextMessageRecord, conversationRecipient, isMessageRequestAccepted);
presentTimer(updateDescription);
presentBackground(shouldCollapse(messageRecord, previousMessageRecord),
shouldCollapse(messageRecord, nextMessageRecord),
hasWallpaper);
hasWallpaper,
donationRequest);
presentActionButton(hasWallpaper, conversationMessage.getMessageRecord().isReleaseChannelDonationRequest());
presentActionButton(hasWallpaper, donationRequest);
presentCollapsedHead(conversationMessage.getMessageRecord().getCollapsedState());
updateSelectedState();
@@ -785,7 +788,7 @@ public final class ConversationUpdateItem extends FrameLayout
(messageRecord.isGroupV2JoinRequest(toBlock.requireServiceId()) && previousMessageRecord.map(m -> m.isCollapsedGroupV2JoinUpdate(toBlock.requireServiceId())).orElse(false));
}
private void presentBackground(boolean collapseAbove, boolean collapseBelow, boolean hasWallpaper) {
private void presentBackground(boolean collapseAbove, boolean collapseBelow, boolean hasWallpaper, boolean isDonationRequest) {
int marginDefault = getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_update_vertical_margin);
int marginCollapsed = 0;
int paddingDefault = getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_update_vertical_padding);
@@ -843,7 +846,11 @@ public final class ConversationUpdateItem extends FrameLayout
ViewUtil.updateLayoutParams(background, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
if (hasWallpaper) {
background.setBackgroundResource(R.drawable.conversation_update_wallpaper_background_singular);
if (isDonationRequest) {
background.setBackgroundResource(R.drawable.conversation_update_release_note_background);
} else {
background.setBackgroundResource(R.drawable.conversation_update_wallpaper_background_singular);
}
} else {
background.setBackground(null);
}
@@ -852,8 +859,8 @@ public final class ConversationUpdateItem extends FrameLayout
private void presentActionButton(boolean hasWallpaper, boolean isBoostRequest) {
if (isBoostRequest) {
actionButton.setBackgroundTintList(ColorStateList.valueOf(ContextCompat.getColor(getContext(), org.signal.core.ui.R.color.signal_colorSecondaryContainer)));
actionButton.setTextColor(ColorStateList.valueOf(ContextCompat.getColor(getContext(), org.signal.core.ui.R.color.signal_colorOnSecondaryContainer)));
actionButton.setBackgroundTintList(ColorStateList.valueOf(ContextCompat.getColor(getContext(), R.color.release_notes_cta_background)));
actionButton.setTextColor(ColorStateList.valueOf(ContextCompat.getColor(getContext(), org.signal.core.ui.R.color.signal_colorOnSurface)));
} else if (hasWallpaper) {
actionButton.setBackgroundTintList(AppCompatResources.getColorStateList(getContext(), R.color.conversation_update_item_button_background_wallpaper));
actionButton.setTextColor(AppCompatResources.getColorStateList(getContext(), R.color.conversation_update_item_button_text_color_wallpaper));
@@ -29,6 +29,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
@@ -53,6 +54,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.ui.RecipientLookupFailureMessage
import org.thoughtcrime.securesms.recipients.ui.RecipientPicker
import org.thoughtcrime.securesms.recipients.ui.RecipientPicker.DisplayMode
import org.thoughtcrime.securesms.recipients.ui.RecipientPickerCallbacks
import org.thoughtcrime.securesms.recipients.ui.RecipientPickerScaffold
import org.thoughtcrime.securesms.recipients.ui.RecipientSelection
@@ -310,6 +312,7 @@ private fun NewConversationRecipientPicker(
searchQuery = uiState.searchQuery,
isRefreshing = uiState.isRefreshingContacts,
shouldResetContactsList = uiState.shouldResetContactsList,
displayModes = setOf(DisplayMode.PUSH, DisplayMode.ACTIVE_GROUPS, DisplayMode.INACTIVE_GROUPS, DisplayMode.SELF),
callbacks = remember(callbacks) {
RecipientPickerCallbacks(
listActions = callbacks,
@@ -333,20 +336,21 @@ private fun UserMessagesHost(
snackbarHostState: SnackbarHostState
) {
val context = LocalContext.current
val resources = LocalResources.current
when (userMessage) {
null -> {}
is UserMessage.Info.RecipientRemoved -> LaunchedEffect(userMessage) {
snackbarHostState.showSnackbar(
message = context.getString(R.string.NewConversationActivity__s_has_been_removed, userMessage.recipient.getDisplayName(context))
message = resources.getString(R.string.NewConversationActivity__s_has_been_removed, userMessage.recipient.getDisplayName(context))
)
onDismiss(userMessage)
}
is UserMessage.Info.RecipientBlocked -> LaunchedEffect(userMessage) {
snackbarHostState.showSnackbar(
message = context.getString(R.string.NewConversationActivity__s_has_been_blocked, userMessage.recipient.getDisplayName(context))
message = resources.getString(R.string.NewConversationActivity__s_has_been_blocked, userMessage.recipient.getDisplayName(context))
)
onDismiss(userMessage)
}
@@ -358,18 +362,24 @@ private fun UserMessagesHost(
)
}
is UserMessage.Info.UserAlreadyInAnotherCall -> LaunchedEffect(userMessage) {
snackbarHostState.showSnackbar(
message = context.getString(R.string.CommunicationActions__you_are_already_in_a_call)
)
onDismiss(userMessage)
is UserMessage.Info.UserAlreadyInAnotherCall -> {
val youAreAlreadyInACall = stringResource(R.string.CommunicationActions__you_are_already_in_a_call)
LaunchedEffect(userMessage) {
snackbarHostState.showSnackbar(
message = youAreAlreadyInACall
)
onDismiss(userMessage)
}
}
is UserMessage.Info.ContactsRefreshFailed -> LaunchedEffect(userMessage) {
snackbarHostState.showSnackbar(
message = context.getString(R.string.ContactSelectionListFragment_error_retrieving_contacts_check_your_network_connection)
)
onDismiss(userMessage)
is UserMessage.Info.ContactsRefreshFailed -> {
val errorRetrievingContacts = stringResource(R.string.ContactSelectionListFragment_error_retrieving_contacts_check_your_network_connection)
LaunchedEffect(userMessage) {
snackbarHostState.showSnackbar(
message = errorRetrievingContacts
)
onDismiss(userMessage)
}
}
is UserMessage.Prompt.ConfirmRemoveRecipient -> Dialogs.SimpleAlertDialog(
@@ -100,6 +100,8 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment
setCondensedMode(ConversationItemDisplayMode.Condensed(ConversationItemDisplayMode.MessageMode.SCHEDULED))
}
val stickyHeaders = StickyHeaderDecoration(messageAdapter, false, false, ConversationAdapter.HEADER_TYPE_INLINE_DATE)
val list: RecyclerView = view.findViewById<RecyclerView>(R.id.scheduled_list).apply {
layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true)
adapter = messageAdapter
@@ -107,7 +109,7 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment
doOnNextLayout {
// Adding this without waiting for a layout pass would result in an indeterminate amount of padding added to the top of the view
addItemDecoration(StickyHeaderDecoration(messageAdapter, false, false, ConversationAdapter.HEADER_TYPE_INLINE_DATE))
addItemDecoration(stickyHeaders)
}
}
@@ -127,6 +129,9 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment
} else if (!list.canScrollVertically(1)) {
list.layoutManager?.scrollToPosition(0)
}
stickyHeaders.invalidateCache()
list.invalidateItemDecorations()
}
recyclerViewColorizer.setChatColors(conversationRecipient.chatColors)
}
@@ -12,11 +12,11 @@ import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import org.signal.core.util.ContextUtil;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.FromTextView;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.AlwaysChangedDiffUtil;
@@ -8,8 +8,10 @@ import android.view.Window
import androidx.activity.viewModels
import androidx.core.app.ActivityCompat
import androidx.lifecycle.enableSavedStateHandles
import androidx.lifecycle.lifecycleScope
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import kotlinx.coroutines.launch
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.Log.tag
import org.thoughtcrime.securesms.MainActivity
@@ -17,10 +19,14 @@ import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayRepository
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavHostFragment
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.jobs.ConversationShortcutUpdateJob
import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment
import org.thoughtcrime.securesms.util.ConfigurationUtil
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
@@ -29,12 +35,11 @@ import java.util.concurrent.TimeUnit
/**
* Wrapper activity for ConversationFragment.
*/
open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, GooglePayComponent {
open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, GooglePayComponent, MainNavigationChatDetailRouter {
companion object {
private val TAG = tag(ConversationActivity::class.java)
private const val STATE_WATERMARK = "share_data_watermark"
private const val MESSAGE_DETAILS_FRAGMENT_TAG = "MessageDetailsFragment"
}
private val theme = DynamicNoActionBarTheme()
@@ -137,6 +142,32 @@ open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaCo
.commitNowAllowingStateLoss()
}
override fun exitDetailLocation() {
if (!supportFragmentManager.popBackStackImmediate()) {
finish()
}
}
override fun goToChatDetail(location: MainNavigationDetailLocation.Chats) {
when (location) {
is MainNavigationDetailLocation.Chats.ConversationSettings -> {
lifecycleScope.launch {
val args = ConversationSettingsNavHostFragment.createArgs(location.recipientId)
supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, ConversationSettingsNavHostFragment::class.java, args)
.addToBackStack(null)
.commit()
}
}
is MainNavigationDetailLocation.Chats.MessageDetails -> {
MessageDetailsFragment.create(location.messageId, location.recipientId)
.show(supportFragmentManager, MESSAGE_DETAILS_FRAGMENT_TAG)
}
}
}
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return motionEventRelay.offer(ev) || super.dispatchTouchEvent(ev)
}
@@ -12,6 +12,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.core.view.children
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.LifecycleOwner
import androidx.media3.common.MediaItem
import androidx.recyclerview.widget.RecyclerView
@@ -22,7 +23,7 @@ import org.signal.core.util.toOptional
import org.thoughtcrime.securesms.BindableConversationItem
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.Unbindable
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavigator
import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener
import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge
import org.thoughtcrime.securesms.conversation.ConversationHeaderCallbacks
@@ -580,7 +581,7 @@ class ConversationAdapterV2(
override fun onGroupSettingsClicked() {
val recipient = conversationBanner.recipientInfo?.recipient ?: return
context.startActivity(ConversationSettingsActivity.forGroup(context, recipient.requireGroupId()))
ConversationSettingsNavigator.navigate(context as FragmentActivity, recipient)
}
override fun onShowGroupDescriptionClicked(groupName: String, description: String, linkifyWebLinks: Boolean) {
@@ -129,6 +129,7 @@ import org.signal.core.util.concurrent.addTo
import org.signal.core.util.dp
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.signal.core.util.requireDrawable
import org.signal.core.util.requireParcelableCompat
import org.signal.core.util.setActionItemTint
import org.signal.donations.InAppPaymentType
@@ -171,7 +172,6 @@ import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
import org.thoughtcrime.securesms.components.snackbars.makeSnackbar
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft
@@ -292,16 +292,15 @@ 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.MainNavigationChatDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationRouter
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.mediapreview.MediaPreviewV2Activity
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment
import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository
import org.thoughtcrime.securesms.mms.AttachmentManager
import org.thoughtcrime.securesms.mms.AudioSlide
@@ -349,7 +348,6 @@ import org.thoughtcrime.securesms.stories.StoryViewerArgs
import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity
import org.thoughtcrime.securesms.util.BubbleUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.Debouncer
@@ -582,7 +580,7 @@ class ConversationFragment :
private lateinit var conversationItemDecorations: ConversationItemDecorations
private lateinit var optionsMenuCallback: ConversationOptionsMenuCallback
private var mainNavRouter: MainNavigationRouter? = null
private lateinit var chatRouter: MainNavigationChatDetailRouter
private var animationsAllowed = false
private var pinnedShortcutReceiver: BroadcastReceiver? = null
@@ -661,7 +659,7 @@ class ConversationFragment :
override fun onAttach(context: Context) {
super.onAttach(context)
mainNavRouter = context as? MainNavigationRouter
chatRouter = context as MainNavigationChatDetailRouter
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -1820,7 +1818,7 @@ class ConversationFragment :
private fun presentNavigationIconForBubble() {
binding.toolbar.navigationIcon = DrawableUtil.tint(
ContextUtil.requireDrawable(requireContext(), R.drawable.ic_notification),
requireContext().requireDrawable(R.drawable.ic_notification),
ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)
)
@@ -3092,7 +3090,7 @@ class ConversationFragment :
private fun handleDisplayDetails(conversationMessage: ConversationMessage) {
val recipientSnapshot = viewModel.recipientSnapshot ?: return
navigateTo(MainNavigationDetailLocation.Chats.MessageDetails(recipientSnapshot.id, MessageId(conversationMessage.messageRecord.id)))
chatRouter.goToChatDetail(MainNavigationDetailLocation.Chats.MessageDetails(recipientSnapshot.id, MessageId(conversationMessage.messageRecord.id)))
}
private fun handleDeleteMessages(messageParts: Set<MultiselectPart>) {
@@ -3632,7 +3630,7 @@ class ConversationFragment :
} else if (messageRecord.hasFailedWithNetworkFailures()) {
ConversationDialogs.displayMessageCouldNotBeSentDialog(requireContext(), messageRecord)
} else {
navigateTo(MainNavigationDetailLocation.Chats.MessageDetails(recipientId, MessageId(messageRecord.id)))
chatRouter.goToChatDetail(MainNavigationDetailLocation.Chats.MessageDetails(recipientId, MessageId(messageRecord.id)))
}
}
@@ -4306,7 +4304,7 @@ class ConversationFragment :
override fun handleManageGroup() {
viewModel.recipientSnapshot?.let { recipient ->
navigateTo(MainNavigationDetailLocation.Chats.ConversationSettings(recipient.id))
chatRouter.goToChatDetail(MainNavigationDetailLocation.Chats.ConversationSettings(recipient.id))
}
}
@@ -4343,7 +4341,7 @@ class ConversationFragment :
override fun handleConversationSettings() {
viewModel.recipientSnapshot?.let { recipient ->
if (!viewModel.hasMessageRequestState || recipient.isBlocked) {
navigateTo(MainNavigationDetailLocation.Chats.ConversationSettings(recipient.id))
chatRouter.goToChatDetail(MainNavigationDetailLocation.Chats.ConversationSettings(recipient.id))
}
}
}
@@ -4410,50 +4408,6 @@ class ConversationFragment :
}
}
/**
* Routes to the appropriate destination based on the current window configuration.
*
* In split-pane mode, delegates to the [MainNavigationRouter] to display content in the detail pane. Otherwise, opens the destination as a standalone screen.
*/
private fun navigateTo(location: MainNavigationDetailLocation.Chats) {
val router = mainNavRouter
if (router != null && resources.isSplitPane()) {
router.goTo(location)
} else {
when (location) {
is MainNavigationDetailLocation.Chats.MessageDetails -> navigateToMessageDetailsStandalone(location)
is MainNavigationDetailLocation.Chats.ConversationSettings -> navigateToConversationSettingsStandalone(viewModel.recipientSnapshot!!)
is MainNavigationDetailLocation.Chats.Conversation -> error("ConversationFragment shouldn't navigate to another conversation - use the main navigation infrastructure instead.")
}
}
}
/**
* Opens message details as a standalone (single-pane) screen. Use [navigateTo] as the entry point.
*/
private fun navigateToMessageDetailsStandalone(location: MainNavigationDetailLocation.Chats.MessageDetails) {
MessageDetailsFragment.create(location.messageId, location.recipientId)
.show(requireActivity().supportFragmentManager, MESSAGE_DETAILS_TAG)
}
/**
* Opens conversation settings as a standalone (single-pane) screen.
*/
private fun navigateToConversationSettingsStandalone(recipient: Recipient) {
val intent = if (recipient.isPushGroup) {
ConversationSettingsActivity.forGroup(requireContext(), recipient.requireGroupId())
} else {
ConversationSettingsActivity.forRecipient(requireContext(), recipient.id)
}
val bundle = ConversationSettingsActivity.createTransitionBundle(
requireActivity(),
binding.conversationTitleView.root.findViewById(R.id.contact_photo_image),
binding.toolbar
)
requireActivity().startActivity(intent, bundle)
}
private inner class OnReactionsSelectedListener : ConversationReactionOverlay.OnReactionSelectedListener {
override fun onReactionSelected(messageRecord: MessageRecord, emoji: String?) {
reactionDelegate.hide()
@@ -37,7 +37,7 @@ fun Fragment.listenToEventBusWhileResumed(
.collectLatest {
if (!resources.isSplitPane()) {
when (it) {
is MainNavigationDetailLocation.Chats.Conversation -> unsubscribe()
is MainNavigationDetailLocation.Conversation -> unsubscribe()
MainNavigationDetailLocation.Empty -> subscribe()
else -> Unit
}
@@ -47,6 +47,7 @@ import com.bumptech.glide.RequestManager;
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.makeramen.roundedimageview.RoundedDrawable;
import org.signal.core.util.ContextUtil;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.StringUtil;
import org.signal.core.util.logging.Log;
@@ -72,14 +73,12 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.database.model.UpdateDescription;
import org.thoughtcrime.securesms.fonts.SignalSymbols.Glyph;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.glide.targets.GlideLiveDataTarget;
import org.signal.glide.decryptableuri.DecryptableUri;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.search.MessageResult;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
@@ -485,7 +485,7 @@ class JobDatabase(
/** Should only be used for debugging! */
fun debugResetBackoffInterval() {
writableDatabase.update(Jobs.TABLE_NAME, contentValuesOf(Jobs.NEXT_BACKOFF_INTERVAL to 0), null, null)
writableDatabase.update(Jobs.TABLE_NAME, contentValuesOf(Jobs.NEXT_BACKOFF_INTERVAL to 0, Jobs.INITIAL_DELAY to 0), null, null)
}
private fun JobSpec.toContentValues(): ContentValues {
@@ -314,6 +314,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
private const val INDEX_DATE_SENT_FROM_TO_THREAD = "message_date_sent_from_to_thread_index"
private const val INDEX_THREAD_COUNT = "message_thread_count_index"
private const val INDEX_THREAD_UNREAD_COUNT = "message_thread_unread_count_index"
private const val INDEX_STORY_TYPE = "message_story_type_index"
private const val INDEX_ARCHIVED_STORY = "message_story_archived_index"
@JvmField
val CREATE_INDEXS = arrayOf(
@@ -322,7 +324,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
"CREATE INDEX IF NOT EXISTS $INDEX_DATE_SENT_FROM_TO_THREAD ON $TABLE_NAME ($DATE_SENT, $FROM_RECIPIENT_ID, $TO_RECIPIENT_ID, $THREAD_ID)",
"CREATE INDEX IF NOT EXISTS message_date_server_index ON $TABLE_NAME ($DATE_SERVER)",
"CREATE INDEX IF NOT EXISTS message_reactions_unread_index ON $TABLE_NAME ($REACTIONS_UNREAD);",
"CREATE INDEX IF NOT EXISTS message_story_type_index ON $TABLE_NAME ($STORY_TYPE);",
"CREATE INDEX IF NOT EXISTS $INDEX_STORY_TYPE ON $TABLE_NAME ($STORY_TYPE);",
"CREATE INDEX IF NOT EXISTS message_parent_story_id_index ON $TABLE_NAME ($PARENT_STORY_ID);",
"CREATE INDEX IF NOT EXISTS $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID ON $TABLE_NAME ($THREAD_ID, $DATE_RECEIVED, $STORY_TYPE, $PARENT_STORY_ID, $SCHEDULED_DATE, $LATEST_REVISION_ID);",
"CREATE INDEX IF NOT EXISTS message_quote_id_quote_author_scheduled_date_latest_revision_id_index ON $TABLE_NAME ($QUOTE_ID, $QUOTE_AUTHOR, $SCHEDULED_DATE, $LATEST_REVISION_ID);",
@@ -341,7 +343,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
"CREATE INDEX IF NOT EXISTS message_pinned_until_index ON $TABLE_NAME ($PINNED_UNTIL)",
"CREATE INDEX IF NOT EXISTS message_pinned_at_index ON $TABLE_NAME ($PINNED_AT)",
"CREATE INDEX IF NOT EXISTS message_deleted_by_index ON $TABLE_NAME ($DELETED_BY)",
"CREATE INDEX IF NOT EXISTS message_story_archived_index ON $TABLE_NAME ($STORY_ARCHIVED, $STORY_TYPE, $DATE_SENT) WHERE $STORY_TYPE > 0 AND $STORY_ARCHIVED > 0",
"CREATE INDEX IF NOT EXISTS $INDEX_ARCHIVED_STORY ON $TABLE_NAME ($STORY_ARCHIVED, $STORY_TYPE, $DATE_SENT) WHERE $STORY_TYPE > 0 AND $STORY_ARCHIVED > 0",
"CREATE INDEX IF NOT EXISTS message_starred_index ON $TABLE_NAME ($STARRED) WHERE $STARRED > 0",
"CREATE INDEX IF NOT EXISTS message_collapsed_state_index ON $TABLE_NAME ($COLLAPSED_STATE)",
"CREATE INDEX IF NOT EXISTS message_collapsed_head_id_index ON $TABLE_NAME ($COLLAPSED_HEAD_ID)"
@@ -1385,10 +1387,6 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
database.endTransaction()
}
fun ensureMigration() {
databaseHelper.signalWritableDatabase
}
fun isViewOnce(messageId: Long): Boolean {
return readableDatabase
.exists(TABLE_NAME)
@@ -1422,24 +1420,17 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
whereArgs = buildArgs(threadId)
}
return MmsReader(queryMessages(where, whereArgs))
return MmsReader(queryMessages(where, whereArgs, index = INDEX_STORY_TYPE))
}
fun getAllOutgoingStories(reverse: Boolean, limit: Int): Reader {
val where = "$IS_STORY_CLAUSE AND ($outgoingTypeClause)"
return MmsReader(queryMessages(where, null, reverse, limit.toLong()))
return MmsReader(queryMessages(where, null, reverse, limit.toLong(), index = INDEX_STORY_TYPE))
}
fun markAllIncomingStoriesRead(): List<MarkedMessageInfo> {
val where = "$IS_STORY_CLAUSE AND NOT ($outgoingTypeClause) AND $READ = 0"
val markedMessageInfos = setMessagesRead(where, null)
notifyConversationListListeners()
return markedMessageInfos
}
fun markAllCallEventsRead(): List<MarkedMessageInfo> {
val where = "$IS_CALL_TYPE_CLAUSE AND $READ = 0"
val markedMessageInfos = setMessagesRead(where, null)
val markedMessageInfos = setMessagesRead(where, null, index = INDEX_STORY_TYPE)
notifyConversationListListeners()
return markedMessageInfos
}
@@ -1448,28 +1439,18 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val where = "$IS_STORY_CLAUSE AND ($outgoingTypeClause) AND $NOTIFIED = 0 AND ($TYPE & ${MessageTypes.BASE_TYPE_MASK}) = ${MessageTypes.BASE_SENT_FAILED_TYPE}"
writableDatabase
.update("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID")
.update("$TABLE_NAME INDEXED BY $INDEX_STORY_TYPE")
.values(NOTIFIED to 1)
.where(where)
.run()
notifyConversationListListeners()
}
fun markOnboardingStoryRead() {
val recipientId = SignalStore.releaseChannel.releaseChannelRecipientId ?: return
val where = "$IS_STORY_CLAUSE AND NOT ($outgoingTypeClause) AND $READ = 0 AND $FROM_RECIPIENT_ID = ?"
val markedMessageInfos = setMessagesRead(where, buildArgs(recipientId))
if (markedMessageInfos.isNotEmpty()) {
notifyConversationListListeners()
}
}
fun getAllStoriesFor(recipientId: RecipientId, limit: Int): Reader {
val threadId = threads.getThreadIdIfExistsFor(recipientId)
val where = "$IS_STORY_CLAUSE AND $THREAD_ID = ?"
val whereArgs = buildArgs(threadId)
val cursor = queryMessages(where, whereArgs, false, limit.toLong())
val cursor = queryMessages(where, whereArgs, false, limit.toLong(), index = INDEX_STORY_TYPE)
return MmsReader(cursor)
}
@@ -1477,21 +1458,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val threadId = threads.getThreadIdIfExistsFor(recipientId)
val query = "$IS_STORY_CLAUSE AND NOT ($outgoingTypeClause) AND $THREAD_ID = ? AND $VIEWED_COLUMN = ?"
val args = buildArgs(threadId, 0)
return MmsReader(queryMessages(query, args, false, limit.toLong()))
}
fun getUnreadMissedCallCount(): Long {
return readableDatabase
.select("COUNT(*)")
.from(TABLE_NAME)
.where(
"($TYPE = ? OR $TYPE = ?) AND $READ = ?",
MessageTypes.MISSED_AUDIO_CALL_TYPE,
MessageTypes.MISSED_VIDEO_CALL_TYPE,
0
)
.run()
.readToSingleLong(0L)
return MmsReader(queryMessages(query, args, false, limit.toLong(), index = INDEX_STORY_TYPE))
}
fun getParentStoryIdForGroupReply(messageId: Long): GroupReply? {
@@ -1538,7 +1505,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
@VisibleForTesting
fun getStoryViewState(threadId: Long): StoryViewState {
val hasStories = readableDatabase
.exists(TABLE_NAME)
.exists("$TABLE_NAME INDEXED BY $INDEX_STORY_TYPE")
.where("$IS_STORY_CLAUSE AND $THREAD_ID = ?", threadId)
.run()
@@ -1547,7 +1514,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
val hasUnviewedStories = readableDatabase
.exists(TABLE_NAME)
.exists("$TABLE_NAME INDEXED BY $INDEX_STORY_TYPE")
.where("$IS_STORY_CLAUSE AND $THREAD_ID = ? AND $VIEWED_COLUMN = ? AND NOT ($outgoingTypeClause)", threadId, 0)
.run()
@@ -1580,7 +1547,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun getUnreadStoryThreadRecipientIds(): List<RecipientId> {
val query = """
SELECT DISTINCT ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID}
FROM $TABLE_NAME
FROM $TABLE_NAME INDEXED BY $INDEX_STORY_TYPE
JOIN ${ThreadTable.TABLE_NAME} ON $TABLE_NAME.$THREAD_ID = ${ThreadTable.TABLE_NAME}.${ThreadTable.ID}
WHERE
$IS_STORY_CLAUSE AND
@@ -1596,7 +1563,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun hasFailedOutgoingStory(): Boolean {
val where = "$IS_STORY_CLAUSE AND ($outgoingTypeClause) AND $NOTIFIED = 0 AND ($TYPE & ${MessageTypes.BASE_TYPE_MASK}) = ${MessageTypes.BASE_SENT_FAILED_TYPE}"
return readableDatabase.exists(TABLE_NAME).where(where).run()
return readableDatabase
.exists("$TABLE_NAME INDEXED BY $INDEX_STORY_TYPE")
.where(where)
.run()
}
fun getOrderedStoryRecipientsAndIds(isOutgoingOnly: Boolean): List<StoryResult> {
@@ -1610,7 +1580,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
$TABLE_NAME.$DATE_SENT,
$RECEIPT_TIMESTAMP,
($outgoingTypeClause) = 0 AND $VIEWED_COLUMN = 0 AS is_unread
FROM $TABLE_NAME
FROM $TABLE_NAME INDEXED BY $INDEX_STORY_TYPE
JOIN ${ThreadTable.TABLE_NAME} ON $TABLE_NAME.$THREAD_ID = ${ThreadTable.TABLE_NAME}.${ThreadTable.ID}
WHERE
$STORY_TYPE > 0 AND
@@ -1700,7 +1670,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val sharedArgs = buildArgs(timestamp, releaseChannelThreadId)
val deleteStoryRepliesQuery = """
DELETE FROM $TABLE_NAME
DELETE FROM $TABLE_NAME INDEXED BY $INDEX_STORY_TYPE
WHERE
$PARENT_STORY_ID > 0 AND
$PARENT_STORY_ID IN (
@@ -1774,7 +1744,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val outgoingFilter = "($outgoingTypeClause)"
return writableDatabase
.update(TABLE_NAME)
.update("$TABLE_NAME INDEXED BY $INDEX_STORY_TYPE")
.values(STORY_ARCHIVED to 1)
.where("$IS_STORY_CLAUSE AND $DATE_SENT < ? AND $THREAD_ID != ? AND $outgoingFilter", timestamp, releaseChannelThreadId)
.run()
@@ -1782,21 +1752,23 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun getArchiveScreenStoriesCount(includeActive: Boolean): Int {
val storyClause = if (includeActive) "$STORY_TYPE > 0 AND $DELETED_BY IS NULL" else IS_ARCHIVED_STORY_CLAUSE
val index = if (includeActive) INDEX_STORY_TYPE else INDEX_ARCHIVED_STORY
val where = "$storyClause AND ($outgoingTypeClause)"
return readableDatabase.select("COUNT(*)").from(TABLE_NAME).where(where).run().readToSingleInt()
return readableDatabase.select("COUNT(*)").from("$TABLE_NAME INDEXED BY $index").where(where).run().readToSingleInt()
}
fun getArchiveScreenStoriesPage(includeActive: Boolean, sortNewest: Boolean, offset: Int, limit: Int): Reader {
val storyClause = if (includeActive) "$STORY_TYPE > 0 AND $DELETED_BY IS NULL" else IS_ARCHIVED_STORY_CLAUSE
val index = if (includeActive) INDEX_STORY_TYPE else INDEX_ARCHIVED_STORY
val where = "$storyClause AND ($outgoingTypeClause)"
val order = if (sortNewest) "$TABLE_NAME.$DATE_SENT DESC" else "$TABLE_NAME.$DATE_SENT ASC"
return MmsReader(queryMessages(where, null, orderBy = order, limit = limit.toLong(), offset = offset.toLong()))
return MmsReader(queryMessages(where, null, orderBy = order, limit = limit.toLong(), offset = offset.toLong(), index = index))
}
fun getOldestArchivedStorySentTimestamp(): Long? {
return readableDatabase
.select(DATE_SENT)
.from(TABLE_NAME)
.from("$TABLE_NAME INDEXED BY $INDEX_ARCHIVED_STORY")
.where(IS_ARCHIVED_STORY_CLAUSE)
.limit(1)
.orderBy("$DATE_SENT ASC")
@@ -1810,7 +1782,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val args = buildArgs(timestamp)
val deletedCount = db.select(ID)
.from(TABLE_NAME)
.from("$TABLE_NAME INDEXED BY $INDEX_ARCHIVED_STORY")
.where(where, args)
.run()
.use { cursor ->
@@ -1834,17 +1806,17 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun deleteStoriesForRecipient(recipientId: RecipientId): Int {
return writableDatabase.withinTransaction { db ->
val threadId = threads.getThreadIdFor(recipientId) ?: return@withinTransaction 0
val storesInRecipientThread = "$IS_STORY_CLAUSE AND $THREAD_ID = ?"
val storiesInRecipientThread = "$IS_STORY_CLAUSE AND $THREAD_ID = ?"
val sharedArgs = buildArgs(threadId)
val deleteStoryRepliesQuery = """
DELETE FROM $TABLE_NAME
DELETE FROM $TABLE_NAME INDEXED BY $INDEX_STORY_TYPE
WHERE
$PARENT_STORY_ID > 0 AND
$PARENT_STORY_ID IN (
SELECT $ID
FROM $TABLE_NAME
WHERE $storesInRecipientThread
WHERE $storiesInRecipientThread
)
"""
@@ -1858,7 +1830,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
ABS($PARENT_STORY_ID) IN (
SELECT $ID
FROM $TABLE_NAME
WHERE $storesInRecipientThread
WHERE $storiesInRecipientThread
)
"""
@@ -1869,7 +1841,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
ABS($PARENT_STORY_ID) IN (
SELECT $ID
FROM $TABLE_NAME
WHERE $storesInRecipientThread
WHERE $storiesInRecipientThread
)
"""
@@ -1883,8 +1855,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
AppDependencies.databaseObserver.notifyStoryObservers(recipientId)
val deletedStoryCount = db.select(ID)
.from(TABLE_NAME)
.where(storesInRecipientThread, sharedArgs)
.from("$TABLE_NAME INDEXED BY $INDEX_STORY_TYPE")
.where(storiesInRecipientThread, sharedArgs)
.run()
.use { cursor ->
while (cursor.moveToNext()) {
@@ -2126,13 +2098,19 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
/**
* Note: [reverse] and [orderBy] are mutually exclusive. If you want the order to be reversed, explicitly use 'ASC' or 'DESC'
*/
private fun queryMessages(where: String, arguments: Array<String>?, reverse: Boolean = false, limit: Long = 0, offset: Long = 0, orderBy: String = ""): Cursor {
private fun queryMessages(where: String, arguments: Array<String>?, reverse: Boolean = false, limit: Long = 0, offset: Long = 0, orderBy: String = "", index: String? = null): Cursor {
val database = databaseHelper.signalReadableDatabase
val indexString = if (index != null) {
" INDEXED BY $index"
} else {
""
}
var rawQueryString = """
SELECT
${Util.join(MMS_PROJECTION, ",")}
FROM
$TABLE_NAME
$TABLE_NAME$indexString
WHERE
$where
""".toSingleLine()
@@ -2620,11 +2598,11 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return setMessagesRead("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND ($READ = 0 OR ($REACTIONS_UNREAD = 1 AND ($outgoingTypeClause)) OR ($VOTES_UNREAD = 1 AND ($outgoingTypeClause)))", null)
}
private fun setMessagesRead(where: String, arguments: Array<String>?): List<MarkedMessageInfo> {
private fun setMessagesRead(where: String, arguments: Array<String>?, index: String = INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID): List<MarkedMessageInfo> {
val releaseChannelId = SignalStore.releaseChannel.releaseChannelRecipientId
return writableDatabase.rawQuery(
"""
UPDATE $TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID
UPDATE $TABLE_NAME INDEXED BY $index
SET $READ = 1, $REACTIONS_UNREAD = 0, $REACTIONS_LAST_SEEN = ${System.currentTimeMillis()}, $VOTES_UNREAD = 0, $VOTES_LAST_SEEN = ${System.currentTimeMillis()}
WHERE $where
RETURNING $ID, $FROM_RECIPIENT_ID, $DATE_SENT, $DATE_RECEIVED, $TYPE, $EXPIRES_IN, $EXPIRE_STARTED, $THREAD_ID, $STORY_TYPE
@@ -3246,8 +3224,6 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
if (message.isSecure) {
type = type or (MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT)
} else if (message.isEndSession) {
type = type or MessageTypes.END_SESSION_BIT
}
if (message.isIdentityVerified) {
@@ -4381,18 +4357,24 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
threads
.forEach { threadId ->
SignalDatabase.threads.update(threadId, unarchive = false)
notifyConversationListeners(threadId)
}
flushBulkDeleteNotifications(threads)
return unhandled
}
/**
* Helper to notify various database observers after doing deletions via [deleteMessage] with notifying disabled.
*/
fun flushBulkDeleteNotifications(touchedThreadIds: Set<Long>) {
touchedThreadIds.forEach { threadId ->
SignalDatabase.threads.update(threadId, unarchive = false)
notifyConversationListeners(threadId)
}
notifyConversationListListeners()
notifyStickerListeners()
notifyStickerPackListeners()
OptimizeMessageSearchIndexJob.enqueue()
return unhandled
}
private fun getMessagesInThreadAfterInclusive(threadId: Long, timestamp: Long, limit: Long): List<MessageRecord> {
@@ -280,13 +280,11 @@ class NameCollisionTables(
}
override fun remapRecipient(fromId: RecipientId, toId: RecipientId) {
val count = writableDatabase
.update(NameCollisionMembershipTable.TABLE_NAME)
.values(NameCollisionMembershipTable.RECIPIENT_ID to toId.serialize())
.where("${NameCollisionMembershipTable.RECIPIENT_ID} = ?", fromId)
.run()
Log.d(TAG, "Remapped $fromId to $toId. count: $count")
writableDatabase.execSQL(
"UPDATE OR REPLACE ${NameCollisionMembershipTable.TABLE_NAME} SET ${NameCollisionMembershipTable.RECIPIENT_ID} = ? WHERE ${NameCollisionMembershipTable.RECIPIENT_ID} = ?",
arrayOf(toId.serialize(), fromId.serialize())
)
Log.d(TAG, "Remapped $fromId to $toId")
}
private fun handleNameCollisions(
@@ -469,7 +467,7 @@ class NameCollisionTables(
SELECT ${NameCollisionMembershipTable.COLLISION_ID}
FROM ${NameCollisionMembershipTable.TABLE_NAME}
GROUP BY ${NameCollisionMembershipTable.COLLISION_ID}
HAVING COUNT($ID) >= 2
HAVING COUNT(*) >= 2
)
""".trimIndent()
)
@@ -731,9 +731,13 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
return RecipientReader(cursor)
}
fun getRecipientsWithNotificationChannels(): RecipientReader {
val cursor = readableDatabase.query(TABLE_NAME, ID_PROJECTION, "$NOTIFICATION_CHANNEL NOT NULL", null, null, null, null)
return RecipientReader(cursor)
fun getRecipientsWithNotificationChannels(): List<RecipientNotificationData> {
return readableDatabase
.select(ID, NOTIFICATION_CHANNEL)
.from(TABLE_NAME)
.where("$NOTIFICATION_CHANNEL NOT NULL")
.run()
.readToList { RecipientNotificationData(RecipientId.from(it.requireLong(ID)), it.requireNonNullString(NOTIFICATION_CHANNEL)) }
}
fun getExistingRecords(ids: Collection<RecipientId>): Map<RecipientId, RecipientRecord> {
@@ -5027,4 +5031,6 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
val expiringProfileKeyCredential: Pair<ProfileKey, ExpiringProfileKeyCredential>? = null,
val clearUsername: Boolean = false
)
data class RecipientNotificationData(val id: RecipientId, val channel: String)
}
@@ -29,11 +29,10 @@ data class InAppPaymentReceiptRecord(
@JvmStatic
fun createForSubscription(subscription: ActiveSubscription.Subscription): InAppPaymentReceiptRecord {
val activeCurrency = Currency.getInstance(subscription.currency)
val activeAmount = subscription.amount.movePointLeft(activeCurrency.defaultFractionDigits)
return InAppPaymentReceiptRecord(
id = -1L,
amount = FiatMoney(activeAmount, activeCurrency),
amount = FiatMoney.fromSignalNetworkAmount(subscription.amount, activeCurrency),
timestamp = System.currentTimeMillis(),
subscriptionLevel = subscription.level,
type = if (subscription.level == SubscriptionsConfiguration.BACKUPS_LEVEL) Type.RECURRING_BACKUP else Type.RECURRING_DONATION
@@ -6,8 +6,6 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.util.Consumer;
import com.mobilecoin.lib.util.Hex;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
@@ -22,6 +20,8 @@ import okio.Okio;
import okio.Sink;
import okio.Source;
import org.signal.core.util.Hex;
/**
* Helper for downloading Emoji files via {@link EmojiRemote}.
*/
@@ -93,7 +93,7 @@ public class EmojiDownloader {
savedMd5 = EmojiFiles.getMd5(context, version, name.getUuid());
}
if (!Arrays.equals(savedMd5, Hex.toByteArray(responseMD5))) {
if (!Arrays.equals(savedMd5, Hex.fromStringCondensed(responseMD5))) {
EmojiFiles.delete(context, version, name.getUuid());
throw new IOException("MD5 Mismatch.");
}
@@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.transport.RetryLaterException
import org.thoughtcrime.securesms.util.AttachmentUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream.IntegrityCheck
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
@@ -46,6 +47,7 @@ import org.whispersystems.signalservice.api.push.exceptions.MissingConfiguration
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
import org.whispersystems.signalservice.api.push.exceptions.RangeException
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import java.io.File
import java.io.IOException
import java.util.Optional
@@ -140,14 +142,16 @@ class AttachmentDownloadJob private constructor(
}
}
constructor(messageId: Long, attachmentId: AttachmentId, forceDownload: Boolean) : this(
constructor(messageId: Long, attachmentId: AttachmentId, forceDownload: Boolean) : this(messageId, attachmentId, forceDownload, forceDownload, forceDownload)
constructor(messageId: Long, attachmentId: AttachmentId, forceDownload: Boolean, skipInCallConstraint: Boolean, isHighPriority: Boolean) : this(
Parameters.Builder()
.setQueue(constructQueueString(attachmentId))
.addConstraint(NetworkConstraint.KEY)
.maybeApplyNotInCallConstraint(forceDownload)
.maybeApplyNotInCallConstraint(skipInCallConstraint)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.setQueuePriority(if (forceDownload) Parameters.PRIORITY_HIGH else Parameters.PRIORITY_DEFAULT)
.setQueuePriority(if (isHighPriority) Parameters.PRIORITY_HIGH else Parameters.PRIORITY_DEFAULT)
.build(),
messageId,
attachmentId,
@@ -314,12 +318,20 @@ class AttachmentDownloadJob private constructor(
throw InvalidAttachmentException("Attachment has no integrity check!")
}
if (attachment.size <= 0) {
Log.w(TAG, "[$attachmentId] Attachment has no declared size!")
throw InvalidAttachmentException("Attachment has no declared size!")
}
val expectedCiphertextSize = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(attachment.size))
val downloadLimit: Long = minOf(expectedCiphertextSize, maxReceiveSize)
val decryptingStream = AppDependencies
.signalServiceMessageReceiver
.retrieveAttachment(
pointer,
attachmentFile,
maxReceiveSize,
downloadLimit,
IntegrityCheck.forEncryptedDigestAndPlaintextHash(attachment.remoteDigest, attachment.dataHash),
progressListener
)
@@ -470,8 +482,8 @@ class AttachmentDownloadJob private constructor(
}
}
private fun Parameters.Builder.maybeApplyNotInCallConstraint(forceDownload: Boolean): Parameters.Builder {
if (forceDownload) {
private fun Parameters.Builder.maybeApplyNotInCallConstraint(skipConstraint: Boolean): Parameters.Builder {
if (skipConstraint) {
return this
}
return this.addConstraint(NotInCallConstraint.KEY)
@@ -12,6 +12,7 @@ import org.signal.core.util.Util
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.inRoundedDays
import org.signal.core.util.logging.Log
import org.signal.core.util.readLength
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.net.RetryLaterException
import org.signal.libsignal.net.UploadTooLargeException
@@ -50,6 +51,7 @@ import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvali
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
import java.io.IOException
import java.net.ProtocolException
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
@@ -272,6 +274,22 @@ class AttachmentUploadJob private constructor(
resetProgressListeners(databaseAttachment)
throw e
} catch (e: IOException) {
if (e is ProtocolException || e.cause is ProtocolException) {
Log.w(TAG, "[$attachmentId] Length may be incorrect. Recalculating.", e)
val actualLength = SignalDatabase.attachments.getAttachmentStream(attachmentId, 0).use { it.readLength() }
if (actualLength != databaseAttachment.size) {
Log.w(TAG, "[$attachmentId] Length was incorrect! Will update. Previous: ${databaseAttachment.size}, Newly-Calculated: $actualLength")
SignalDatabase.attachments.updateAttachmentLength(attachmentId, actualLength)
uploadSpec = null
} else {
Log.i(TAG, "[$attachmentId] Length was correct. No action needed. Will retry.")
}
}
resetProgressListeners(databaseAttachment)
throw e
}
}
@@ -492,6 +492,10 @@ class FastJobStorage(private val jobDatabase: JobDatabase) : JobStorage {
private fun placeJobInEligibleList(jobCandidate: MinimalJobSpec) {
val existingJobInQueue = jobCandidate.queueKey?.let { mostEligibleJobForQueue[it] }
if (existingJobInQueue != null) {
if (existingJobInQueue.isRunning) {
return
}
if (jobCandidate.globalPriority < existingJobInQueue.globalPriority) {
return
}
@@ -283,7 +283,6 @@ class IndividualSendJob private constructor(parameters: Parameters, private val
.withPreviews(previews)
.withGiftBadge(giftBadge)
.asExpirationUpdate(message.isExpirationUpdate)
.asEndSessionMessage(message.isEndSession)
.withPayment(payment)
.withBodyRanges(bodyRanges)
.withPollCreate(pollCreate)
@@ -35,8 +35,12 @@ import org.whispersystems.signalservice.internal.ServiceResponse
import java.io.IOException
import java.lang.Integer.max
import java.security.MessageDigest
import java.time.LocalTime
import java.time.ZonedDateTime
import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
/**
* Retrieves and processes release channel messages.
@@ -52,6 +56,9 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool
private val TAG = Log.tag(RetrieveRemoteAnnouncementsJob::class.java)
private val RETRIEVE_FREQUENCY = TimeUnit.DAYS.toMillis(3)
private val WINDOW_START = LocalTime.of(9, 0)
private val WINDOW_END = LocalTime.of(21, 0)
@JvmStatic
@JvmOverloads
fun enqueue(force: Boolean = false) {
@@ -65,13 +72,18 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool
return
}
val windowDelay = calculateTimeToNextWindow()
if (windowDelay > Duration.ZERO) {
Log.i(TAG, "Outside window, delaying ${windowDelay.inWholeMinutes} minutes")
}
val job = RetrieveRemoteAnnouncementsJob(
force,
Parameters.Builder()
.setQueue("RetrieveReleaseChannelJob")
.setMaxInstancesForFactory(1)
.setMaxAttempts(3)
.addConstraint(NetworkConstraint.KEY)
.setInitialDelay(windowDelay.inWholeMilliseconds)
.build()
)
@@ -80,6 +92,23 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool
.then(job)
.enqueue()
}
private fun calculateTimeToNextWindow(): Duration {
val now = ZonedDateTime.now()
val time = now.toLocalTime()
if (time.isAfter(WINDOW_START) && time.isBefore(WINDOW_END)) {
return Duration.ZERO
}
val next9am = if (time.isBefore(WINDOW_START)) {
now.with(WINDOW_START)
} else {
now.plusDays(1).with(WINDOW_START)
}
return (next9am.toInstant().toEpochMilli() - System.currentTimeMillis()).milliseconds
}
}
override fun serialize(): ByteArray? = JsonJobData.Builder().putBoolean(KEY_FORCE, force).serialize()
@@ -106,6 +135,13 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool
return
}
val delay = calculateTimeToNextWindow()
if (delay > Duration.ZERO) {
Log.i(TAG, "Outside window, re-enqueuing")
enqueue(force = force)
return
}
val manifestMd5: ByteArray? = S3.getObjectMD5(MANIFEST)
if (manifestMd5 == null) {
@@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@@ -97,7 +98,7 @@ public final class SendRetryReceiptJob extends BaseJob {
@Override
protected boolean onShouldRetry(@NonNull Exception e) {
return e instanceof PushNetworkException;
return e instanceof PushNetworkException || e instanceof RateLimitException;
}
@Override
@@ -632,8 +632,13 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
class Factory : Job.Factory<StorageSyncJob?> {
override fun create(parameters: Parameters, serializedData: ByteArray?): StorageSyncJob {
val data = serializedData?.let { StorageSyncJobData.ADAPTER.decode(it) } ?: StorageSyncJobData()
return StorageSyncJob(parameters, data.localManifestOutOfDate)
return try {
val data = serializedData?.let { StorageSyncJobData.ADAPTER.decode(it) } ?: StorageSyncJobData()
StorageSyncJob(parameters, localManifestOutOfDate = data.localManifestOutOfDate)
} catch (e: IOException) {
Log.w(TAG, "Error deserializing StorageSyncJob", e)
StorageSyncJob(parameters, localManifestOutOfDate = false)
}
}
}
}

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