Compare commits

...

82 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
Cody Henthorne 1043851423 Bump version to 8.9.1 2026-04-27 16:40:08 -04:00
Cody Henthorne 9bcbacc3d8 Update translations and other static files. 2026-04-27 16:40:08 -04:00
Cody Henthorne c2d7ee6926 Update release notes chat styling. 2026-04-27 16:39:59 -04:00
jeffrey-signal ceecacb47e Fix pull-to-refresh triggering when attempting to scroll up in contact search.
Resolves signalapp/Signal-Android#14742
2026-04-27 16:09:56 -04:00
Greyson Parrelli f4986273e4 Fix quote resolution failing for sent sync messages. 2026-04-27 13:34:52 -04:00
Greyson Parrelli 5f60adbe69 Update emoji_data.json 2026-04-27 13:34:52 -04:00
Alex Hart db6efeaf3d Revert "Migrate VerifyScanFragment to compose."
This reverts commit 9fa587b7e4.
2026-04-23 16:29:35 -03:00
412 changed files with 36237 additions and 24650 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 = 1683
val canonicalVersionName = "8.9.0"
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 one or more lines are too long
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;
@@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.components
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.Shader
import android.graphics.drawable.Drawable
/**
* Draws [bitmap] as a repeating tiled pattern rotated by [rotationDegrees].
*/
class RotatedTiledDrawable(
private val bitmap: Bitmap,
private val rotationDegrees: Float
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = android.graphics.BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
}
override fun onBoundsChange(bounds: android.graphics.Rect) {
paint.shader.setLocalMatrix(
Matrix().apply { setRotate(rotationDegrees, bounds.exactCenterX(), bounds.exactCenterY()) }
)
}
override fun draw(canvas: Canvas) {
canvas.drawRect(bounds, paint)
}
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
invalidateSelf()
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
invalidateSelf()
}
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
}
@@ -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"))
@@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
import org.thoughtcrime.securesms.database.model.addButton
import org.thoughtcrime.securesms.database.model.addLink
import org.thoughtcrime.securesms.database.model.addStyle
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -48,9 +49,12 @@ class InternalSettingsRepository(context: Context) {
val title = "Release Note Title"
val bodyText = "Release note body. Aren't I awesome?"
val body = "$title\n\n$bodyText"
val linkUrl = "https://signal.org"
val body = "$title\n\n$bodyText\n\n$linkUrl"
val linkStart = body.length - linkUrl.length
val bodyRangeList = BodyRangeList.Builder()
.addStyle(BodyRangeList.BodyRange.Style.BOLD, 0, title.length)
.addLink(linkUrl, linkStart, linkUrl.length)
bodyRangeList.addButton("Call to Action Text", callToAction, body.lastIndex, 0)
@@ -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)
}
)
@@ -44,7 +44,7 @@ object BioTextPreference {
override fun getSubhead1Text(context: Context): String? {
return if (recipient.isReleaseNotes) {
context.getString(R.string.ReleaseNotes__signal_release_notes_and_news)
null
} else {
recipient.combinedAboutAndEmoji
}
@@ -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
)
}
@@ -41,10 +41,6 @@ class ContactSearchView : AbstractComposeView {
fun onRecyclerViewReady(recyclerView: RecyclerView)
}
init {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
}
private var viewModel: ContactSearchViewModel? by mutableStateOf(null)
private var currentFragmentManager: FragmentManager? = null
private var currentDisplayOptions: ContactSearchAdapter.DisplayOptions? = null
@@ -54,8 +50,13 @@ class ContactSearchView : AbstractComposeView {
private var currentContentBottomPadding: Dp = 0.dp
private var currentAdapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory
private var currentScrollListeners: List<RecyclerView.OnScrollListener> = emptyList()
private var recyclerView: RecyclerView? = null
private var currentOnRecyclerViewReady: RecyclerViewReadyCallback? = null
init {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
}
/**
* Configures and activates the contact search. Must be called exactly once from the host
* fragment's `onViewCreated`. The [viewModel] must be created and held by the caller so it
@@ -106,6 +107,10 @@ class ContactSearchView : AbstractComposeView {
this.viewModel = viewModel // triggers recomposition
}
override fun canScrollVertically(direction: Int): Boolean {
return recyclerView?.canScrollVertically(direction) ?: super.canScrollVertically(direction)
}
@Composable
override fun Content() {
val vm = viewModel ?: return
@@ -123,7 +128,10 @@ class ContactSearchView : AbstractComposeView {
contentBottomPadding = currentContentBottomPadding,
adapterFactory = currentAdapterFactory,
scrollListeners = currentScrollListeners,
onRecyclerViewReady = currentOnRecyclerViewReady
onRecyclerViewReady = RecyclerViewReadyCallback { recyclerView ->
this@ContactSearchView.recyclerView = recyclerView
currentOnRecyclerViewReady?.onRecyclerViewReady(recyclerView)
}
)
}
}
@@ -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)) }
}
}
@@ -46,6 +46,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
@@ -209,7 +210,12 @@ private fun ConversationHeaderContent(
.padding(top = AvatarOverlapAbove)
.width(277.dp)
.then(
if (hasWallpaper) {
if (isReleaseNotes) {
Modifier
.clip(BorderShape)
.background(colorResource(R.color.release_notes_header_background))
.border(width = 2.dp, color = colorResource(R.color.release_notes_header_border), shape = BorderShape)
} else if (hasWallpaper) {
Modifier
.clip(BorderShape)
.background(if (isSystemInDarkTheme()) SignalTheme.colors.colorTransparentInverse5 else SignalTheme.colors.colorTransparent5)
@@ -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;
@@ -204,6 +204,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private Optional<MessageRecord> nextMessageRecord;
private Locale locale;
private boolean groupThread;
private boolean isReleaseNotes;
private LiveRecipient author;
private RequestManager requestManager;
private Optional<MessageRecord> previousMessage;
@@ -290,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() {
@@ -412,6 +415,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
this.batchSelected = batchSelected;
this.conversationRecipient = conversationRecipient.live();
this.groupThread = conversationRecipient.isGroup();
this.isReleaseNotes = conversationRecipient.isReleaseNotes();
this.author = messageRecord.getFromRecipient().live();
this.canPlayContent = false;
this.mediaItem = null;
@@ -419,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);
@@ -595,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()));
@@ -629,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) {
@@ -656,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;
}
}
}
@@ -695,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++;
@@ -772,6 +802,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private @ColorInt int getDefaultBubbleColor(boolean hasWallpaper) {
if (isReleaseNotes) {
return ContextCompat.getColor(context, R.color.release_notes_bubble);
}
return hasWallpaper ? defaultBubbleColorForWallpaper : defaultBubbleColor;
}
@@ -919,9 +952,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
footer.setOnlyShowSendingStatus(messageRecord.isRemoteDelete(), messageRecord);
} else {
bodyBubble.getBackground().setColorFilter(getDefaultBubbleColor(hasWallpaper), PorterDuff.Mode.SRC_IN);
footer.setTextColor(colorizer.getIncomingFooterTextColor(context, hasWallpaper));
footer.setIconColor(colorizer.getIncomingFooterIconColor(context, hasWallpaper));
footer.setRevealDotColor(colorizer.getIncomingFooterIconColor(context, hasWallpaper));
if (isReleaseNotes) {
int releaseNotesTextColor = ContextCompat.getColor(context, R.color.release_notes_bubble_text);
bodyText.setTextColor(releaseNotesTextColor);
bodyText.setLinkTextColor(releaseNotesTextColor);
footer.setTextColor(releaseNotesTextColor);
footer.setIconColor(releaseNotesTextColor);
footer.setRevealDotColor(releaseNotesTextColor);
} else {
footer.setTextColor(colorizer.getIncomingFooterTextColor(context, hasWallpaper));
footer.setIconColor(colorizer.getIncomingFooterIconColor(context, hasWallpaper));
footer.setRevealDotColor(colorizer.getIncomingFooterIconColor(context, hasWallpaper));
}
footer.setOnlyShowSendingStatus(false, messageRecord);
}
@@ -1718,8 +1760,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
int end = messageBody.getSpanEnd(placeholder);
URLSpan span = new InterceptableLongClickCopyLinkSpan(placeholder.getValue(),
urlClickListener,
ContextCompat.getColor(getContext(), R.color.signal_accent_primary),
false);
ContextCompat.getColor(getContext(), isReleaseNotes ? R.color.release_notes_bubble_text : R.color.signal_accent_primary),
isReleaseNotes);
messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
@@ -1848,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()) {
@@ -1858,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) {
@@ -167,6 +167,14 @@ internal object ConversationOptionsMenu {
if (recipient.isReleaseNotes) {
hideMenuItem(menu, R.id.menu_add_shortcut)
menu.findItem(R.id.menu_mute_notifications)?.apply {
setIcon(R.drawable.symbol_bell_24)
setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
}
menu.findItem(R.id.menu_unmute_notifications)?.apply {
setIcon(R.drawable.symbol_bell_slash_24)
setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
}
}
if (!SignalStore.labs.individualChatPlaintextExport) {
@@ -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;
@@ -119,7 +119,7 @@ public class ConversationTitleView extends ConstraintLayout {
if (recipient != null && recipient.isBlocked()) {
startDrawable = ContextUtil.requireDrawable(getContext(), R.drawable.symbol_block_16);
startDrawable.setBounds(0, 0, ViewUtil.dpToPx(18), ViewUtil.dpToPx(18));
} else if (recipient != null && recipient.isMuted()) {
} else if (recipient != null && recipient.isMuted() && !recipient.isReleaseNotes()) {
startDrawable = ContextUtil.requireDrawable(getContext(), R.drawable.ic_bell_disabled_16);
startDrawable.setBounds(0, 0, ViewUtil.dpToPx(18), ViewUtil.dpToPx(18));
}
@@ -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) {
@@ -18,6 +18,7 @@ import android.content.Intent
import android.content.IntentFilter
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Rect
@@ -42,6 +43,7 @@ import android.view.WindowManager
import android.view.animation.AnimationUtils
import android.view.inputmethod.EditorInfo
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import android.widget.TextView.OnEditorActionListener
import android.widget.Toast
@@ -49,7 +51,9 @@ import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.MainThread
import androidx.annotation.StringRes
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.SearchView
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat
@@ -59,6 +63,7 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.doOnPreDraw
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentResultListener
@@ -124,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
@@ -149,6 +155,7 @@ import org.thoughtcrime.securesms.components.InputAwareConstraintLayout
import org.thoughtcrime.securesms.components.InputPanel
import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
import org.thoughtcrime.securesms.components.RotatedTiledDrawable
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
import org.thoughtcrime.securesms.components.SendButton
import org.thoughtcrime.securesms.components.SignalProgressDialog
@@ -165,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
@@ -184,7 +190,6 @@ import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
import org.thoughtcrime.securesms.conversation.ConversationData
import org.thoughtcrime.securesms.conversation.ConversationHeaderView
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.ConversationIntents.ConversationScreenType
import org.thoughtcrime.securesms.conversation.ConversationItem
@@ -287,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
@@ -344,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
@@ -573,11 +576,11 @@ class ConversationFragment :
private lateinit var attachmentManager: AttachmentManager
private lateinit var multiselectItemDecoration: MultiselectItemDecoration
private lateinit var openableGiftItemDecoration: OpenableGiftItemDecoration
private lateinit var threadHeaderMarginDecoration: ThreadHeaderMarginDecoration
private lateinit var conversationHeaderPositionDecoration: ConversationHeaderPositionDecoration
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
@@ -601,6 +604,8 @@ class ConversationFragment :
private var firstPinRender: Boolean = true
private var skipNextBackPressHandling: Boolean = false
private var collapsibleEventScrollPosition: CollapsibleEventScrollPosition? = null
private var releaseNotesLayoutApplied: Boolean = false
private var releaseNotesWallpaperApplied: Boolean = false
private val jumpAndPulseScrollStrategy = object : ScrollToPositionDelegate.ScrollStrategy {
override fun performScroll(recyclerView: RecyclerView, layoutManager: LinearLayoutManager, position: Int, smooth: Boolean) {
@@ -654,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?) {
@@ -694,6 +699,7 @@ class ConversationFragment :
requireActivity(),
binding.toolbarBackground,
viewModel::wallpaperSnapshot,
{ viewModel.recipientSnapshot?.isReleaseNotes == true },
viewLifecycleOwner,
incognito = args.isIncognito
)
@@ -761,10 +767,10 @@ class ConversationFragment :
binding.toolbar.addOnLayoutChangeListener { _, _, _, _, bottom, _, _, _, oldBottom ->
binding.conversationItemRecycler.padding(top = bottom)
if (bottom != oldBottom && ::threadHeaderMarginDecoration.isInitialized) {
if (bottom != oldBottom && ::conversationHeaderPositionDecoration.isInitialized) {
val newMargin = bottom + 16.dp
if (threadHeaderMarginDecoration.toolbarMargin != newMargin) {
threadHeaderMarginDecoration.toolbarMargin = newMargin
if (conversationHeaderPositionDecoration.toolbarMargin != newMargin) {
conversationHeaderPositionDecoration.toolbarMargin = newMargin
binding.conversationItemRecycler.invalidateItemDecorations()
}
}
@@ -1562,6 +1568,10 @@ class ConversationFragment :
presentConversationTitle(inputReadyState.conversationRecipient)
val disabledInputView = binding.conversationDisabledInput
val isReleaseNotes = inputReadyState.conversationRecipient.isReleaseNotes
if (isReleaseNotes) {
applyReleaseNotesLayout()
}
var inputDisabled = true
when {
@@ -1572,22 +1582,42 @@ class ConversationFragment :
inputReadyState.isRequestingMember == true -> disabledInputView.showAsRequestingMember()
inputReadyState.isActiveGroup == false -> disabledInputView.showAsNoLongerAMember()
inputReadyState.isAnnouncementGroup == true && inputReadyState.isAdmin == false -> disabledInputView.showAsAnnouncementGroupAdminsOnly()
inputReadyState.conversationRecipient.isReleaseNotes -> disabledInputView.showAsReleaseNotesChannel(inputReadyState.conversationRecipient)
isReleaseNotes -> Unit
inputReadyState.shouldShowInviteToSignal() -> disabledInputView.showAsInviteToSignal(requireContext(), inputReadyState.conversationRecipient, inputReadyState.threadContainsSms)
else -> inputDisabled = false
}
inputPanel.setHideForMessageRequestState(inputDisabled)
if (inputDisabled) {
if (inputDisabled && !isReleaseNotes) {
binding.navBar.setBackgroundColor(disabledInputView.color)
} else {
} else if (!inputDisabled) {
disabledInputView.clear()
}
composeText.setMessageSendType(MessageSendType.SignalMessageSendType)
}
private fun applyReleaseNotesLayout() {
if (releaseNotesLayoutApplied) {
return
}
releaseNotesLayoutApplied = true
binding.conversationReleaseNotesFloatingLabel.visible = true
binding.conversationDisabledInput.visible = false
val navBarInset = ViewCompat.getRootWindowInsets(binding.root)?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
binding.conversationItemRecycler.updatePadding(bottom = ViewUtil.dpToPx(72) + navBarInset)
binding.navBar.setBackgroundColor(Color.TRANSPARENT)
ConstraintSet().apply {
clone(binding.root)
connect(binding.conversationItemRecyclerFrame.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM)
applyTo(binding.root)
}
}
private fun presentIdentityRecordsState(identityRecordsState: IdentityRecordsState) {
binding.conversationTitleView.root.setVerified(identityRecordsState.isVerified)
@@ -1713,16 +1743,13 @@ class ConversationFragment :
}
private fun onRecipientChanged(recipient: Recipient) {
presentWallpaper(recipient.wallpaper)
presentWallpaper(recipient)
presentConversationTitle(recipient)
presentChatColors(recipient.chatColors)
invalidateOptionsMenu()
updateMessageRequestAcceptedState(!viewModel.hasMessageRequestState)
recyclerViewColorizer.setChatColors(recipient.chatColors)
if (adapter.onHasWallpaperChanged(hasWallpaper = recipient.wallpaper != null)) {
conversationItemDecorations.hasWallpaper = recipient.wallpaper != null
}
}
@MainThread
@@ -1791,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)
)
@@ -1842,17 +1869,19 @@ class ConversationFragment :
}
}
private fun presentWallpaper(chatWallpaper: ChatWallpaper?) {
if (chatWallpaper != null) {
chatWallpaper.loadInto(binding.conversationWallpaper)
ChatWallpaperDimLevelUtil.applyDimLevelForNightMode(binding.conversationWallpaperDim, chatWallpaper)
private fun presentWallpaper(recipient: Recipient) {
val chatWallpaper = recipient.wallpaper
if (recipient.isReleaseNotes) {
applyReleaseNotesWallpaper()
} else {
binding.conversationWallpaperDim.visible = false
applyChatWallpaper(chatWallpaper)
}
val wallpaperEnabled = chatWallpaper != null || recipient.isReleaseNotes
val toolbarTint = ContextCompat.getColor(
requireContext(),
if (chatWallpaper != null) {
if (wallpaperEnabled) {
CoreUiR.color.signal_colorNeutralInverse
} else {
CoreUiR.color.signal_colorOnSurface
@@ -1863,7 +1892,6 @@ class ConversationFragment :
binding.toolbar.setActionItemTint(toolbarTint)
binding.toolbar.navigationIcon?.setTint(toolbarTint)
val wallpaperEnabled = chatWallpaper != null
binding.conversationWallpaper.visible = wallpaperEnabled
binding.scrollToBottom.setWallpaperEnabled(wallpaperEnabled)
binding.scrollToMention.setWallpaperEnabled(wallpaperEnabled)
@@ -1872,6 +1900,7 @@ class ConversationFragment :
val stateChanged = adapter.onHasWallpaperChanged(wallpaperEnabled)
conversationItemDecorations.hasWallpaper = wallpaperEnabled
conversationItemDecorations.isReleaseNotes = recipient.isReleaseNotes
if (stateChanged) {
binding.conversationItemRecycler.invalidateItemDecorations()
}
@@ -1888,12 +1917,39 @@ class ConversationFragment :
)
if (!inputPanel.isHidden) {
setNavBarBackgroundColor(chatWallpaper)
setNavBarBackgroundColor(wallpaperEnabled)
}
}
private fun setNavBarBackgroundColor(chatWallpaper: ChatWallpaper?) {
val navColor = if (chatWallpaper != null) {
private fun applyReleaseNotesWallpaper() {
if (releaseNotesWallpaperApplied) {
return
}
releaseNotesWallpaperApplied = true
val tinted = DrawableUtil.tint(
AppCompatResources.getDrawable(requireContext(), R.drawable.release_chat_background)!!,
ContextCompat.getColor(requireContext(), R.color.release_notes_background_pattern)
)
val bitmap = DrawableUtil.toBitmap(tinted, tinted.intrinsicWidth, tinted.intrinsicHeight)
binding.conversationWallpaper.scaleType = ImageView.ScaleType.MATRIX
binding.conversationWallpaper.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.release_notes_background))
binding.conversationWallpaper.setImageDrawable(RotatedTiledDrawable(bitmap, -45f))
binding.conversationWallpaperDim.visible = false
}
private fun applyChatWallpaper(chatWallpaper: ChatWallpaper?) {
if (chatWallpaper != null) {
chatWallpaper.loadInto(binding.conversationWallpaper)
ChatWallpaperDimLevelUtil.applyDimLevelForNightMode(binding.conversationWallpaperDim, chatWallpaper)
} else {
binding.conversationWallpaperDim.visible = false
}
}
private fun setNavBarBackgroundColor(hasWallpaper: Boolean) {
val navColor = if (hasWallpaper) {
R.color.conversation_navigation_wallpaper
} else {
CoreUiR.color.signal_colorBackground
@@ -2237,12 +2293,11 @@ class ConversationFragment :
}
)
threadHeaderMarginDecoration = ThreadHeaderMarginDecoration()
conversationHeaderPositionDecoration = ConversationHeaderPositionDecoration()
val statusBarInset = ViewCompat.getRootWindowInsets(binding.root)?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0
threadHeaderMarginDecoration.toolbarMargin = statusBarInset + resources.getDimensionPixelSize(R.dimen.signal_m3_toolbar_height) + 16.dp
binding.conversationItemRecycler.addItemDecoration(threadHeaderMarginDecoration)
binding.conversationItemRecycler.addItemDecoration(ConversationHeaderPositionDecoration())
conversationHeaderPositionDecoration.toolbarMargin = statusBarInset + resources.getDimensionPixelSize(R.dimen.signal_m3_toolbar_height) + 16.dp
binding.conversationItemRecycler.addItemDecoration(conversationHeaderPositionDecoration)
conversationItemDecorations = ConversationItemDecorations(hasWallpaper = args.hasWallpaper)
binding.conversationItemRecycler.addItemDecoration(conversationItemDecorations, 0)
@@ -3035,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>) {
@@ -3330,7 +3385,8 @@ class ConversationFragment :
private fun presentComposeDivider() {
val isAtBottom = isScrolledToBottom()
if (isAtBottom && !wasAtBottom) {
val suppress = viewModel.recipientSnapshot?.isReleaseNotes == true
if ((isAtBottom && !wasAtBottom) || suppress) {
ViewUtil.fadeOut(binding.composeDivider, 50, View.INVISIBLE)
} else if (wasAtBottom && !isAtBottom) {
ViewUtil.fadeIn(binding.composeDivider, 500)
@@ -3574,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)))
}
}
@@ -4248,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))
}
}
@@ -4285,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))
}
}
}
@@ -4352,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()
@@ -4734,10 +4746,6 @@ class ConversationFragment :
launchIntent = this@ConversationFragment::startActivity
)
}
override fun onUnmuteReleaseNotesChannel() {
viewModel.muteConversation(0L)
}
}
//endregion
@@ -5126,7 +5134,7 @@ class ConversationFragment :
}
override fun onInputHidden() {
setNavBarBackgroundColor(viewModel.wallpaperSnapshot)
setNavBarBackgroundColor(viewModel.wallpaperSnapshot != null || viewModel.recipientSnapshot?.isReleaseNotes == true)
viewModel.setIsMediaKeyboardShowing(false)
}
@@ -5248,17 +5256,6 @@ class ConversationFragment :
}
}
private inner class ThreadHeaderMarginDecoration : RecyclerView.ItemDecoration() {
var toolbarMargin: Int = 0
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
super.getItemOffsets(outRect, view, parent, state)
if (view is ConversationHeaderView) {
outRect.top = toolbarMargin
}
}
}
private inner class VoiceMessageRecordingSessionCallbacks : VoiceMessageRecordingDelegate.SessionCallback {
override fun onSessionWillBegin() {
getVoiceNoteMediaController().pausePlayback()
@@ -7,35 +7,45 @@ package org.thoughtcrime.securesms.conversation.v2
import android.graphics.Canvas
import android.graphics.Rect
import android.view.View
import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.conversation.ConversationHeaderView
import kotlin.math.min
/**
* Adjusts the Conversation's recycler view translationY so that the conversation header
* is pinned to the top of the visible area when content is too short to
* fill the screen.
* Reserves space above the [ConversationHeaderView] for the toolbar and adjusts the conversation RecyclerView's translationY so the header is pinned below the
* toolbar when content is short enough to fit the viewport. The toolbar margin is only contributed when a translation is actually going to be applied; when
* content overflows, no margin is added and no translation is applied.
*/
class ConversationHeaderPositionDecoration : RecyclerView.ItemDecoration() {
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
if (parent.childCount == 0 || parent.canScrollVertically(-1) || parent.canScrollVertically(1)) {
parent.translationY = 0f
} else {
val threadHeaderView: ConversationHeaderView = parent.children
.filterIsInstance<ConversationHeaderView>()
.firstOrNull() ?: run {
parent.translationY = 0f
return
}
private val bounds = Rect()
// A decorator adds the margin for the toolbar, margin is the difference of the bounds "height" and the view height
val bounds = Rect()
parent.getDecoratedBoundsWithMargins(threadHeaderView, bounds)
val toolbarMargin = bounds.bottom - bounds.top - threadHeaderView.height
var toolbarMargin: Int = 0
val childTop: Int = threadHeaderView.top - toolbarMargin
parent.translationY = min(0, -childTop).toFloat()
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
super.getItemOffsets(outRect, view, parent, state)
if (view is ConversationHeaderView && !parent.canScrollVertically(1)) {
outRect.top = toolbarMargin
}
}
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
if (parent.canScrollVertically(1)) {
parent.translationY = 0f
return
}
val threadHeaderView: ConversationHeaderView = parent.children
.filterIsInstance<ConversationHeaderView>()
.firstOrNull() ?: run {
parent.translationY = 0f
return
}
parent.getDecoratedBoundsWithMargins(threadHeaderView, bounds)
val margin = bounds.bottom - bounds.top - threadHeaderView.height
val childTop: Int = threadHeaderView.top - margin
parent.translationY = min(0, -childTop).toFloat()
}
}
@@ -59,6 +59,12 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch
unreadViewHolder?.updateForWallpaper()
}
var isReleaseNotes: Boolean = false
set(value) {
field = value
headerCache.values.forEach { it.updateForWallpaper() }
}
var selfRecipientId: RecipientId? = null
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
@@ -307,7 +313,10 @@ class ConversationItemDecorations(hasWallpaper: Boolean = false, private val sch
}
fun updateForWallpaper() {
if (hasWallpaper) {
if (isReleaseNotes) {
date.setBackgroundResource(R.drawable.release_notes_date_header_background)
date.setTextColor(ContextCompat.getColor(itemView.context, CoreUiR.color.signal_colorOnSurfaceVariant))
} else if (hasWallpaper) {
date.setBackgroundResource(R.drawable.wallpaper_bubble_background_18)
date.setTextColor(ContextCompat.getColor(itemView.context, CoreUiR.color.signal_colorNeutralInverse))
} else {
@@ -16,6 +16,7 @@ class ConversationToolbarOnScrollHelper(
activity: FragmentActivity,
toolbarBackground: View,
private val wallpaperProvider: () -> ChatWallpaper?,
private val releaseNotesProvider: () -> Boolean,
lifecycleOwner: LifecycleOwner,
private val incognito: Boolean = false
) : Material3OnScrollHelper(
@@ -25,10 +26,18 @@ class ConversationToolbarOnScrollHelper(
setStatusBarColor = {}
) {
override val activeColorSet: ColorSet
get() = if (incognito) ColorSet(R.color.conversation_toolbar_color_incognito) else ColorSet(getActiveToolbarColor(wallpaperProvider() != null))
get() = when {
incognito -> ColorSet(R.color.conversation_toolbar_color_incognito)
releaseNotesProvider() -> ColorSet(R.color.release_notes_toolbar_scrolled)
else -> ColorSet(getActiveToolbarColor(wallpaperProvider() != null))
}
override val inactiveColorSet: ColorSet
get() = if (incognito) ColorSet(R.color.conversation_toolbar_color_incognito) else ColorSet(getInactiveToolbarColor(wallpaperProvider() != null))
get() = when {
incognito -> ColorSet(R.color.conversation_toolbar_color_incognito)
releaseNotesProvider() -> ColorSet(R.color.release_notes_toolbar_transparent)
else -> ColorSet(getInactiveToolbarColor(wallpaperProvider() != null))
}
@ColorRes
private fun getActiveToolbarColor(hasWallpaper: Boolean): Int {
@@ -48,7 +48,6 @@ class DisabledInputView @JvmOverloads constructor(
private var requestingGroup: View? = null
private var announcementGroupOnly: TextView? = null
private var inviteToSignal: View? = null
private var releaseNoteChannel: View? = null
private var incognitoView: View? = null
private var currentView: View? = null
@@ -187,21 +186,6 @@ class DisabledInputView @JvmOverloads constructor(
)
}
fun showAsReleaseNotesChannel(recipient: Recipient) {
releaseNoteChannel = show(
existingView = releaseNoteChannel,
create = { inflater.inflate(R.layout.conversation_activity_unmute, this, false) },
bind = {
if (recipient.isMuted) {
visible = true
findViewById<View>(R.id.conversation_activity_unmute_button).setOnClickListener { listener?.onUnmuteReleaseNotesChannel() }
} else {
visible = false
}
}
)
}
fun setWallpaperEnabled(wallpaperEnabled: Boolean) {
color = ContextCompat.getColor(context, if (wallpaperEnabled) R.color.wallpaper_bubble_color else CoreUiR.color.signal_colorBackground)
setBackgroundColor(color)
@@ -272,7 +256,6 @@ class DisabledInputView @JvmOverloads constructor(
fun onBlockClicked()
fun onUnblockClicked()
fun onInviteToSignal(recipient: Recipient)
fun onUnmuteReleaseNotesChannel()
fun onReportSpamClicked()
}
}
@@ -444,14 +444,21 @@ open class V2ConversationItemTextOnlyViewHolder<Model : MappingModel<Model>>(
V2ConversationItemUtils.linkifyUrlLinks(messageBody, conversationContext.selectedItems.isEmpty(), conversationContext.clickListener::onUrlClicked)
if (conversationMessage.hasStyleLinks()) {
val isReleaseNotes = conversationMessage.threadRecipient.isReleaseNotes
val linkColor = if (isReleaseNotes) {
themeDelegate.getBodyTextColor(conversationMessage)
} else {
ContextCompat.getColor(getContext(), R.color.signal_accent_primary)
}
val underline = isReleaseNotes
messageBody.getSpans(0, messageBody.length, PlaceholderURLSpan::class.java).forEach { placeholder ->
val start = messageBody.getSpanStart(placeholder)
val end = messageBody.getSpanEnd(placeholder)
val span: URLSpan = InterceptableLongClickCopyLinkSpan(
placeholder.value,
conversationContext.clickListener::onUrlClicked,
ContextCompat.getColor(getContext(), R.color.signal_accent_primary),
false
linkColor,
underline
)
messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
@@ -44,6 +44,10 @@ class V2ConversationItemTheme(
return conversationContext.getColorizer().getIncomingFooterTextColor(context, conversationContext.hasWallpaper())
}
if (!conversationMessage.messageRecord.isOutgoing && conversationMessage.threadRecipient.isReleaseNotes) {
return ContextCompat.getColor(context, R.color.release_notes_bubble_text)
}
return getColor(
conversationMessage,
conversationContext.getColorizer()::getOutgoingFooterTextColor,
@@ -55,6 +59,9 @@ class V2ConversationItemTheme(
fun getBodyTextColor(
conversationMessage: ConversationMessage
): Int {
if (!conversationMessage.messageRecord.isOutgoing && conversationMessage.threadRecipient.isReleaseNotes) {
return ContextCompat.getColor(context, R.color.release_notes_bubble_text)
}
return getColor(
conversationMessage,
conversationContext.getColorizer()::getOutgoingBodyTextColor,
@@ -79,6 +86,8 @@ class V2ConversationItemTheme(
): Int {
return if (conversationMessage.messageRecord.isOutgoing) {
Color.TRANSPARENT
} else if (conversationMessage.threadRecipient.isReleaseNotes) {
ContextCompat.getColor(context, R.color.release_notes_bubble)
} else {
if (conversationContext.hasWallpaper()) {
ContextCompat.getColor(context, R.color.conversation_item_recv_bubble_color_wallpaper)
@@ -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;

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