Compare commits

...

148 Commits

Author SHA1 Message Date
jeffrey-signal
f8737995fa Bump version to 8.2.2 2026-03-09 12:17:17 -04:00
jeffrey-signal
1bbefea857 Update baseline profile. 2026-03-09 12:13:20 -04:00
jeffrey-signal
143630c41b Update translations and other static files. 2026-03-09 12:05:25 -04:00
Michelle Tang
577eaa1eae Avoid dropping column in message table. 2026-03-09 10:45:27 -04:00
Greyson Parrelli
316b071c81 Bump version to 8.2.1 2026-03-06 16:34:51 -05:00
Greyson Parrelli
5a6f55c0a8 Update baseline profile. 2026-03-06 16:34:32 -05:00
Greyson Parrelli
e008a50acc Update translations and other static files. 2026-03-06 16:18:53 -05:00
Michelle Tang
41c3913482 Update notification on admin delete. 2026-03-06 13:39:53 -05:00
Greyson Parrelli
803ff76678 Bump version to 8.2.0 2026-03-04 14:15:38 -05:00
Greyson Parrelli
309081437a Update baseline profile. 2026-03-04 14:15:38 -05:00
Greyson Parrelli
5f152b73c2 Update translations and other static files. 2026-03-04 14:03:32 -05:00
Greyson Parrelli
f8d3336a1e Add internal setting to disable internal user. 2026-03-04 13:55:39 -05:00
jeffrey-signal
dc1fdffe6a Warn user when their member label will show instead of their about text. 2026-03-04 13:55:39 -05:00
Greyson Parrelli
622d9c909f Fix unarchive actions from conversation search.
Fixes #14640
2026-03-04 13:55:39 -05:00
Michelle Tang
4e3ef19c1f Rotate receive for admin delete. 2026-03-04 13:55:39 -05:00
Greyson Parrelli
b054a30fa7 Add support for remote muting call participants. 2026-03-04 13:55:39 -05:00
jeffrey-signal
7266c24354 Show the entire member label on recipient details sheet. 2026-03-04 13:55:39 -05:00
jeffrey-signal
5ec2877bcc Fix member label disappearing after a new group member is added. 2026-03-04 13:55:39 -05:00
jeffrey-signal
0d93446c7d Fix member label emoji picker button not respecting use system emoji preference. 2026-03-04 13:55:39 -05:00
Michelle Tang
1e395ab416 Use global config for admin delete timer. 2026-03-04 13:55:39 -05:00
Michelle Tang
0acb5ac7cd Update admin delete string. 2026-03-04 13:55:39 -05:00
jeffrey-signal
3b18b5d2b7 Fix member label text size. 2026-03-04 13:55:39 -05:00
jeffrey-signal
16e63a061d Allow any group member to set member labels. 2026-03-04 13:55:39 -05:00
Michelle Tang
a6c8b940c9 Consolidate admin delete into one string. 2026-03-04 13:55:39 -05:00
Michelle Tang
74d9e3248b Add pending and failed states for admin delete. 2026-03-04 13:55:39 -05:00
andrew-signal
3af8b6050c Bump to libsignal v0.88.0. 2026-03-04 13:55:39 -05:00
Greyson Parrelli
da966753a1 Guard against invalid authors in directionless messages in archive export. 2026-03-04 13:55:39 -05:00
Cody Henthorne
0ad4b3f73e Skip optimize media when backup subscription is pending cancelation. 2026-03-04 13:55:39 -05:00
Greyson Parrelli
e8d072d4be Only set optimized storage in archive if on paid tier. 2026-03-04 13:55:39 -05:00
Greyson Parrelli
b0eed4a095 Filter out unmappable body ranges during archive export. 2026-03-04 13:55:39 -05:00
Greyson Parrelli
ba720efe61 Skip quotes with authors that lack ACI and E164 during archive export. 2026-03-04 13:55:39 -05:00
Cody Henthorne
e23d575460 Fix incorrect transaction batching during conversation delete. 2026-03-04 13:55:39 -05:00
Greyson Parrelli
7fbcd17759 Add some megaphones to encourage users to try backups. 2026-03-04 13:55:39 -05:00
Greyson Parrelli
a95ebb2158 Add improved notification settings when muted. 2026-03-04 13:55:39 -05:00
Greyson Parrelli
8a36425cac Remove broken legacy color migration.
Fixes #14228
Resolves #14518
2026-03-04 13:45:24 -05:00
Michelle Tang
4261ed39dc Fix message details crash on recipient tap. 2026-03-04 13:45:24 -05:00
jeffrey-signal
ca37a884fd Delete unused GroupMembersDialog. 2026-03-04 13:45:24 -05:00
Alex Hart
9fbb7683bc Fix RTL text direction not enforced when text starts with LTR characters.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-04 13:45:24 -05:00
Jim Gustafson
42e275ef0a Update to RingRTC v2.65.3 2026-03-04 13:45:24 -05:00
Greyson Parrelli
19ece12e93 Remove deprecated backup flavor. 2026-03-04 13:45:24 -05:00
Michelle Tang
3ef0d3e4a3 Skip pins of deleted messages. 2026-03-04 13:45:24 -05:00
andrew-signal
602ea46b8b Bump to libsignal v0.87.5. 2026-03-04 13:45:24 -05:00
Alex Hart
95c0bc6052 Update internal and local backup access. 2026-03-04 13:45:24 -05:00
Alex Hart
bd4ce1788c Fix ANR when backup deletion hangs. 2026-03-04 13:45:24 -05:00
Alex Hart
20d16a8433 Show immediate progress feedback when creating a local backup.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-04 13:45:24 -05:00
Alex Hart
db4c11cd53 Use user-friendly display path for local backup folder.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-03-04 13:45:24 -05:00
Greyson Parrelli
f439e1f8e3 Add additional upload validation to UploadAttachmentToArchiveJob. 2026-03-04 13:45:24 -05:00
Cody Henthorne
080b1aab83 Fix unable to restore username after device transfer. 2026-03-04 13:45:24 -05:00
Cody Henthorne
61ba2ac97a Improve message processing performance. 2026-03-04 13:45:23 -05:00
Alex Hart
7eebb38eda Add post-registration restore for backups v2 as well as error messaging. 2026-03-04 13:45:23 -05:00
Greyson Parrelli
43e7d65af5 Bump version to 8.1.3 2026-03-04 13:41:59 -05:00
Greyson Parrelli
386d8bb312 Update translations and other static files. 2026-03-04 13:41:32 -05:00
Michelle Tang
3fbd72092c Use batch inserting migration instead. 2026-03-02 17:30:54 -05:00
Greyson Parrelli
4e5b15cd88 Never notify for quotes in muted 1:1 chats. 2026-03-02 13:58:02 -05:00
Greyson Parrelli
8b2aeba3bd Bump version to 8.1.2 2026-02-27 22:44:39 -05:00
Greyson Parrelli
1d2334b920 Update translations and other static files. 2026-02-27 22:44:11 -05:00
jeffrey-signal
38a234ae66 Fix crash after inviting group members. 2026-02-27 22:29:06 -05:00
jeffrey-signal
2c1226dc02 Fix groups v1 migration suggestions dialog crash. 2026-02-27 22:27:58 -05:00
Greyson Parrelli
1df8ef6464 Fix backup import issue when we dedude messages with edits. 2026-02-27 16:25:30 -05:00
Alex Hart
f8d40bf86d Revert "Don't show 'Payment Pending' during backup subscription keep-alive flows."
This reverts commit e87aa22d32.
2026-02-27 17:02:15 -04:00
Alex Hart
58ab03b4e3 Fix crash when enabling vanity camera before capturer initialization. 2026-02-27 16:35:39 -04:00
Michelle Tang
0bf54e6b45 Fix network crash when unpinning. 2026-02-27 15:19:48 -05:00
jeffrey-signal
8fca0c69ac Bump version to 8.1.1 2026-02-26 21:51:58 -05:00
jeffrey-signal
70eb4ca2a1 Update baseline profile. 2026-02-26 21:29:00 -05:00
jeffrey-signal
9d9e30725e Update translations and other static files. 2026-02-26 21:20:51 -05:00
jeffrey-signal
ff9585ec7d Show member labels on the admin sheet. 2026-02-26 20:00:36 -05:00
Greyson Parrelli
a418c2750a Fix mute icons theming. 2026-02-26 13:54:11 -05:00
jeffrey-signal
9581994050 Handle network and permissions errors when saving group member label. 2026-02-26 10:34:16 -05:00
jeffrey-signal
316d0e67c5 Enforce member label emoji and text constraints. 2026-02-26 08:32:32 -05:00
Cody Henthorne
503bf04ec5 Bump version to 8.1.0 2026-02-25 20:01:23 -05:00
Cody Henthorne
d6b76936dd Update baseline profile. 2026-02-25 19:55:25 -05:00
Cody Henthorne
c53d16717b Update translations and other static files. 2026-02-25 19:46:16 -05:00
jeffrey-signal
2c747daa50 Disable member label button for users without permission to edit. 2026-02-25 19:38:12 -05:00
jeffrey-signal
0b2d3edcce Add member labels education sheet. 2026-02-25 19:38:12 -05:00
jeffrey-signal
955bcde062 Rotate send member labels flag. 2026-02-25 19:38:12 -05:00
Greyson Parrelli
a91aa72fb4 Guard against missing integrity check in CopyAttachmentToArchiveJob.
Add a check for hadIntegrityCheckPerformed() before attempting to copy
an attachment to the archive. If the attachment's download has failed
(transferState == FAILED), requireMediaName() would throw an
IllegalArgumentException because the integrity check was never
completed. The fix resets the archive transfer state to NONE and skips,
allowing a future successful download to re-trigger archiving.
2026-02-25 19:38:12 -05:00
Alex Hart
163ece75b2 Remove note about call links if there are no call links selected. 2026-02-25 19:38:12 -05:00
Alex Hart
a8fb5f2598 Prevent EmojiTextView measurement oscillation on size changes. 2026-02-25 19:38:12 -05:00
Alex Hart
3a62ad67e1 Fix out-of-sync audio selection.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-25 19:38:12 -05:00
Greyson Parrelli
48f4e1ddc6 Rotate the android.cameraXModelBlockList and android.cameraXMixedModelBlockList flags. 2026-02-25 19:38:12 -05:00
Greyson Parrelli
c37bb96aab Only bind camera use cases that the device supports.
The new camera implementation always bound all four CameraX use cases
(preview, image capture, video capture, and image analysis) regardless
of device capabilities. On devices with LEGACY camera hardware level,
this causes image capture to fail with "Capture request failed with
reason ERROR" because the hardware cannot handle that many simultaneous
use cases.

This change makes video capture and QR scanning use case binding
conditional based on CameraXModePolicy, which already determines device
capabilities. Video capture is only bound when the device supports mixed
mode (image + video simultaneously). QR scanning analysis is only bound
when explicitly requested.
2026-02-25 19:38:12 -05:00
jeffrey-signal
a2057e20d2 Rename UiCallbacks interfaces to avoid redeclaration errors. 2026-02-25 19:38:12 -05:00
Greyson Parrelli
577e05eb51 Make sure we transcode non-H264 video. 2026-02-25 19:38:12 -05:00
Greyson Parrelli
65a30cf2a7 Mark attachment 404's as permanent failures. 2026-02-25 19:38:12 -05:00
Greyson Parrelli
121f0c6134 Add custom mute until option. 2026-02-25 19:38:12 -05:00
jeffrey-signal
7d1897a9d2 Add ability to set group member label from conversation settings. 2026-02-25 19:38:12 -05:00
Alex Hart
415dbd1b61 Fix issue with joining video call from lock screen.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-25 19:38:12 -05:00
Alex Hart
cfc1c35203 Eliminate unnecessary utilization of SubcomposeLayout which was causing a calling crash.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-25 19:38:12 -05:00
Alex Hart
911d7f3be8 Fix crash occurring when user rapidly enters and leaves a call.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-25 19:38:12 -05:00
Cody Henthorne
c06944da13 Add receipt processing benchmark tests. 2026-02-25 19:38:12 -05:00
Alex Hart
b6dd4a3579 Fix formatting in EditMessageRevisionTest. 2026-02-25 09:49:49 -04:00
Greyson Parrelli
b057e145c5 Ensure usernames are unique regardless of casing. 2026-02-25 00:34:22 -05:00
Greyson Parrelli
772ad3b929 Show gallery button on camera screen when camera permission is denied. 2026-02-24 23:46:42 -05:00
Michelle Tang
46681868d3 Put new deleting UI behind remote config. 2026-02-24 18:21:51 -05:00
Michelle Tang
75795bd7d5 Update incoming delete message strings. 2026-02-24 18:10:16 -05:00
Greyson Parrelli
1908723fbe Prevent potential ISE in MediaPreviewV2Fragment. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
549992c08a Fix potention NPE on video recording failures. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
845704b9fe Map UNKNOWN group member role to DEFAULT during backup export. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
ba03ca5e0c Drop quotes with unexported authors during backup export. 2026-02-24 16:50:01 -05:00
Cody Henthorne
92a9f12b58 Fix notification not being dismissed for read edited message. 2026-02-24 16:50:01 -05:00
Cody Henthorne
3437ac63bb Fix group recipient being created without a group record. 2026-02-24 16:50:01 -05:00
jeffrey-signal
d798a35c38 Member labels padding, margin, and styling fixes. 2026-02-24 16:50:01 -05:00
Alex Hart
01b56995d9 Add distinctUntilChanged to speaker hint flow to prevent repeated popups.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-24 16:50:01 -05:00
Greyson Parrelli
3f190efb4e Validate profile keys before writing them to backup exports. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
bb6b149c2e Fix potential validation error with mentions. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
65b96fff16 Delete some dead testing code. 2026-02-24 16:50:01 -05:00
jeffrey-signal
0b8e8a7b2f Separate v1 and v2 colorizer implementations. 2026-02-24 16:50:01 -05:00
jeffrey-signal
a8a6fec19d Show preview on edit member label screen. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
a3fce4c149 Filter hidden recipients from contact-joined notifications. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
85265412da Skip trigger drop/recreate in deleteMessagesInThread when there are no messages to delete.
The deleteMessagesInThread method unconditionally drops and recreates FTS
and MSL triggers in every call, even when there are no messages matching
the delete criteria. Each trigger drop/create cycle changes the database
schema cookie, causing SQLITE_SCHEMA errors for concurrent reader
connections.
2026-02-24 16:50:01 -05:00
andrew-signal
e636a94de0 Fix bug where we constantly cycled network stack when on network with PAC proxy. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
08509f6693 Fix bug where video dimensions aren't always correct in chat view. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
d28fc98cfd Add ability to use volume buttons to capture image/video. 2026-02-24 16:50:01 -05:00
Michelle Tang
f584ef1d72 Add network constraint to admin delete job. 2026-02-24 16:50:01 -05:00
Alex Hart
67a6df57c8 Allow user to cancel in-flight keep-alive donation. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
fadbb0adc5 Enable change animations in the conversation list. 2026-02-24 16:50:01 -05:00
Michelle Tang
58774033b7 Prioritize regular delete first. 2026-02-24 16:50:01 -05:00
Cody Henthorne
66f0470960 Improve incoming group message processing. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
68137cb66f Add internal config to schedule a message after the weekend. 2026-02-24 16:50:01 -05:00
Alex Hart
4d6cacdb3d Fix call controls flickering when starting a video call.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-24 16:50:01 -05:00
Alex Hart
cf862af3ca Increase bank transfer minimum name limit.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-24 16:50:01 -05:00
Alex Hart
a8d106a292 Disable audio focus for video GIF playback in media send flow.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-02-24 16:50:01 -05:00
Michelle Tang
6155140de4 Default to allowing multiple votes. 2026-02-24 16:50:01 -05:00
Alex Hart
a4637248e8 Set megaphone snooze for backups to 2 weeks. 2026-02-24 16:50:01 -05:00
Alex Hart
8c4470a27e Add logline for full-screen intent support. 2026-02-24 16:50:01 -05:00
Michelle Tang
071fbfd916 Add support for admin delete. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
1968438ebb Improve video transcoding error logging. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
7b31383b88 Improve video encoder/decoder fallback logic. 2026-02-24 16:50:01 -05:00
Cody Henthorne
093a79045d Fix incorrect sender key state for mismatch/stale devices. 2026-02-24 16:50:01 -05:00
Cody Henthorne
e4928b0084 Fix long database transaction when syncing system contact information. 2026-02-24 16:50:01 -05:00
jeffrey-signal
03420cf501 Prevent autofill framework from treating message input as a credential field. 2026-02-24 16:50:01 -05:00
Cody Henthorne
541b4674a8 Add remote_backups cta action for release notes. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
6e108a03d1 Improve video transcode test error detection. 2026-02-24 16:50:01 -05:00
Alex Hart
c9dd332abd Pre-Registration Restoration from Local Unified Backup. 2026-02-24 16:50:01 -05:00
jeffrey-signal
7e605fb6de Fix member label emoji ignoring use system emoji preference. 2026-02-24 16:50:01 -05:00
andrew-signal
fa2b0aedb0 Bump to libsignal v0.87.4 2026-02-24 16:50:01 -05:00
andrew-signal
402f49edd9 Replace usages of old getEncryptedUsernameFromLinkServerId for libsignal's lookUpUsernameLink. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
caf2e555dd Fix more HDR transcoding errors. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
32dc36d937 Fix various transcoding issues on samsung devices. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
771d49bfa8 Add an instrumentation test for video transcoding. 2026-02-24 16:50:01 -05:00
Greyson Parrelli
70dc78601a Exclude time from apk output filename. 2026-02-24 16:50:01 -05:00
Cody Henthorne
b4d781ddbb Reduce calls to sleep for WebSocket keep alives. 2026-02-24 16:50:01 -05:00
jeffrey-signal
9c29601b55 Consolidate about sheet state into a single object. 2026-02-24 16:50:01 -05:00
jeffrey-signal
28c37cb3ac Add ability to edit member label from the about you sheet. 2026-02-24 16:50:01 -05:00
DivyaKhunt07
bd121e47c8 Fix bubble desired height calculation. 2026-02-24 16:50:00 -05:00
Greyson Parrelli
7428e1e2ea Improve UI for regV5 verification code submission. 2026-02-24 16:50:00 -05:00
Greyson Parrelli
376cb926b0 Give a more readable in-app version name to the nightly. 2026-02-24 16:50:00 -05:00
Greyson Parrelli
4ed0056d2a Preserve user zoom level when starting video recording.
Remove the unconditional zoom reset to 1x at the start of video
recording so that any pinch-to-zoom the user applied before recording
is maintained.
2026-02-24 16:50:00 -05:00
506 changed files with 46685 additions and 19713 deletions

1
.gitignore vendored
View File

@@ -33,3 +33,4 @@ maps.key
kls_database.db
.kotlin
lefthook-local.yml
sample-videos/

View File

@@ -2,6 +2,10 @@
import com.android.build.api.dsl.ManagedVirtualDevice
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.Properties
plugins {
@@ -20,8 +24,8 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1656
val canonicalVersionName = "8.0.4"
val canonicalVersionCode = 1663
val canonicalVersionName = "8.2.2"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -49,8 +53,6 @@ val localProperties: Properties? = if (localPropertiesFile.exists()) {
val quickstartCredentialsDir: String? = localProperties?.getProperty("quickstart.credentials.dir")
val selectableVariants = listOf(
"nightlyBackupRelease",
"nightlyBackupSpinner",
"nightlyProdSpinner",
"nightlyProdPerf",
"nightlyProdRelease",
@@ -464,17 +466,6 @@ android {
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\"")
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\"")
}
create("backup") {
initWith(getByName("staging"))
dimension = "environment"
applicationIdSuffix = ".backup"
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Backup\"")
}
}
lint {
@@ -516,7 +507,7 @@ android {
val nightlyVersionCode = (canonicalVersionCode * maxHotfixVersions) + (getNightlyBuildNumber(tag) * 10) + nightlyBuffer
variant.outputs.forEach { output ->
output.versionName.set(tag)
output.versionName.set("$tag | ${getLastCommitDateTimeUtc()}")
output.versionCode.set(nightlyVersionCode)
}
}
@@ -567,7 +558,8 @@ android {
applicationVariants.configureEach {
outputs.configureEach {
if (this is com.android.build.gradle.internal.api.BaseVariantOutputImpl) {
outputFileName = outputFileName.replace(".apk", "-$versionName.apk")
val fileVersionName = versionName.substringBefore(" |")
outputFileName = outputFileName.replace(".apk", "-$fileVersionName.apk")
}
}
}
@@ -806,6 +798,16 @@ fun getNightlyBuildNumber(tag: String?): Int {
return match?.groupValues?.get(1)?.toIntOrNull() ?: 0
}
fun getLastCommitDateTimeUtc(): String {
val timestamp = providers.exec {
commandLine("git", "log", "-1", "--pretty=format:%ct")
}.standardOutput.asText.get().trim().toLong()
val instant = Instant.ofEpochSecond(timestamp)
val formatter = DateTimeFormatter.ofPattern("MMM d '@' HH:mm 'UTC'", Locale.US)
.withZone(ZoneOffset.UTC)
return formatter.format(instant)
}
fun getMapsKey(): String {
return providers
.gradleProperty("mapsKey")

View File

@@ -71,6 +71,11 @@ class ArchiveImportExportTests {
runTests { it.startsWith("chat_folder_") }
}
// @Test
fun chatItemAdminDelete() {
runTests { it.startsWith("chat_item_admin_deleted_") }
}
// @Test
fun chatItemContactMessage() {
runTests { it.startsWith("chat_item_contact_message_") }

View File

@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.conversation.ConversationItem
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.database.FakeMessageRecords
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord
@@ -208,7 +209,7 @@ class V2ConversationItemShapeTest {
private val nextMessage: MessageRecord? = null
) : V2ConversationContext {
private val colorizer = Colorizer()
private val colorizer = ColorizerV2()
override val lifecycleOwner: LifecycleOwner = object : LifecycleOwner {
override val lifecycle: Lifecycle = LifecycleRegistry(this)

View File

@@ -0,0 +1,230 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.models.ServiceId.ACI
import org.signal.core.util.CursorUtil
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class EditMessageRevisionTest {
@get:Rule
val databaseRule = SignalDatabaseRule()
private lateinit var senderId: RecipientId
private var threadId: Long = 0
@Before
fun setUp() {
val senderAci = ACI.from(UUID.randomUUID())
senderId = SignalDatabase.recipients.getOrInsertFromServiceId(senderAci)
threadId = SignalDatabase.threads.getOrCreateThreadIdFor(senderId, false, ThreadTable.DistributionTypes.DEFAULT)
}
@Test
fun singleEditSetsLatestRevisionIdOnOriginal() {
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
val editId = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(editId)
assertThat(getLatestRevisionId(editId)).isNull()
}
@Test
fun singleEditOnlyLatestRevisionAppearsInNotificationState() {
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
val editId = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
val notificationIds = getNotificationStateMessageIds()
assertEquals(listOf(editId), notificationIds)
}
@Test
fun multiEditSetsLatestRevisionIdOnAllPreviousRevisions() {
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
val edit1Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(edit1Id)
assertThat(getLatestRevisionId(edit1Id)).isNull()
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(edit2Id)
assertThat(getLatestRevisionId(edit1Id)).isNotNull().isEqualTo(edit2Id)
}
@Test
fun multiEditOnlyLatestRevisionAppearsInNotificationState() {
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
val notificationIds = getNotificationStateMessageIds()
assertEquals("Only the latest revision should appear in notification state", listOf(edit2Id), notificationIds)
}
@Test
fun readSyncThenMultipleEditsDoNotCreateOrphanedUnreadRevisions() {
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
markAsRead(originalId)
assertEquals("No notifications after read sync", 0, getNotificationStateMessageIds().size)
insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
val notificationIds = getNotificationStateMessageIds()
assertEquals(
"No notifications should appear after edits to a message that was already read via sync",
emptyList<Long>(),
notificationIds
)
}
@Test
fun readSyncOnLatestRevisionThenSecondEditDoesNotCreateOrphanedNotification() {
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
val edit1Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
// Read sync updates the latestRevisionId (edit1), not the original
markAsRead(edit1Id)
assertEquals("No notifications after read sync on edited message", 0, getNotificationStateMessageIds().size)
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
val notificationIds = getNotificationStateMessageIds()
assertEquals(
"Only the latest revision or no revisions should appear depending on read state",
notificationIds.filter { it != edit2Id },
emptyList<Long>()
)
}
@Test
fun tripleEditCorrectlyChainsAllRevisions() {
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
val edit1Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
val edit3Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1003)
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(edit3Id)
assertThat(getLatestRevisionId(edit1Id)).isNotNull().isEqualTo(edit3Id)
assertThat(getLatestRevisionId(edit2Id)).isNotNull().isEqualTo(edit3Id)
assertThat(getLatestRevisionId(edit3Id)).isNull()
assertEquals(listOf(edit3Id), getNotificationStateMessageIds())
}
@Test
fun multiEditWithReadSyncBetweenEditsNotificationDismissedAndStaysDismissed() {
val originalId = insertOriginalMessage(sentTimeMillis = 1000)
assertEquals("Original unread message should be in notification state", 1, getNotificationStateMessageIds().size)
markAsReadAndNotified(originalId)
assertEquals("No notifications after read sync", 0, getNotificationStateMessageIds().size)
insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1001)
assertEquals("No notifications after first edit (original was read)", 0, getNotificationStateMessageIds().size)
val edit2Id = insertEdit(originalSentTimestamp = 1000, editSentTimeMillis = 1002)
val notificationIds = getNotificationStateMessageIds()
assertEquals(
"No notifications should appear - message was read via sync before edits arrived",
emptyList<Long>(),
notificationIds
)
// Verify revision chain integrity
assertThat(getLatestRevisionId(originalId)).isNotNull().isEqualTo(edit2Id)
val edit1Id = edit2Id - 1 // edit1 was inserted right before edit2
assertThat(getLatestRevisionId(edit1Id)).isNotNull().isEqualTo(edit2Id)
assertThat(getLatestRevisionId(edit2Id)).isNull()
}
private fun insertOriginalMessage(sentTimeMillis: Long): Long {
val message = IncomingMessage(
type = MessageType.NORMAL,
from = senderId,
sentTimeMillis = sentTimeMillis,
serverTimeMillis = sentTimeMillis,
receivedTimeMillis = System.currentTimeMillis(),
body = "original message"
)
return SignalDatabase.messages.insertMessageInbox(message, threadId).get().messageId
}
/**
* The target is always retrieved via [MessageTable.getMessageFor] using the original sent
* timestamp — this matches what [EditMessageProcessor] does and means targetMessage.id
* is always the original message's row ID.
*/
private fun insertEdit(originalSentTimestamp: Long, editSentTimeMillis: Long): Long {
val targetMessage = SignalDatabase.messages.getMessageFor(originalSentTimestamp, senderId) as MmsMessageRecord
val editMessage = IncomingMessage(
type = MessageType.NORMAL,
from = senderId,
sentTimeMillis = editSentTimeMillis,
serverTimeMillis = editSentTimeMillis,
receivedTimeMillis = System.currentTimeMillis(),
body = "edited at $editSentTimeMillis"
)
return SignalDatabase.messages.insertEditMessageInbox(editMessage, targetMessage).get().messageId
}
private fun getLatestRevisionId(messageId: Long): Long? {
return SignalDatabase.rawDatabase
.query(MessageTable.TABLE_NAME, arrayOf(MessageTable.LATEST_REVISION_ID), "${MessageTable.ID} = ?", arrayOf(messageId.toString()), null, null, null)
.use { cursor ->
if (cursor.moveToFirst()) {
val idx = cursor.getColumnIndexOrThrow(MessageTable.LATEST_REVISION_ID)
if (cursor.isNull(idx)) null else cursor.getLong(idx)
} else {
null
}
}
}
private fun getNotificationStateMessageIds(): List<Long> {
return SignalDatabase.messages.getMessagesForNotificationState(emptyList()).use { cursor ->
val ids = mutableListOf<Long>()
while (cursor.moveToNext()) {
ids.add(CursorUtil.requireLong(cursor, MessageTable.ID))
}
ids
}
}
private fun markAsRead(messageId: Long) {
SignalDatabase.rawDatabase.execSQL(
"UPDATE ${MessageTable.TABLE_NAME} SET ${MessageTable.READ} = 1 WHERE ${MessageTable.ID} = ?",
arrayOf(messageId)
)
}
private fun markAsReadAndNotified(messageId: Long) {
SignalDatabase.rawDatabase.execSQL(
"UPDATE ${MessageTable.TABLE_NAME} SET ${MessageTable.READ} = 1, ${MessageTable.NOTIFIED} = 1 WHERE ${MessageTable.ID} = ?",
arrayOf(messageId)
)
}
}

View File

@@ -190,7 +190,7 @@ class StorySendTableTest {
@Test
fun getRemoteDeleteRecipients_overlapWithPreviousDeletes() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
SignalDatabase.messages.markAsRemoteDelete(messageId1)
SignalDatabase.messages.markAsDeleteBySelf(messageId1)
storySends.insert(messageId2, recipients6to15, 200, true, distributionId2)
@@ -287,7 +287,7 @@ class StorySendTableTest {
fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetFullSentStorySyncManifestForStory2_thenIExpectNonNullResult() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
SignalDatabase.messages.markAsRemoteDelete(messageId1)
SignalDatabase.messages.markAsDeleteBySelf(messageId1)
val manifest = storySends.getFullSentStorySyncManifest(messageId2, 200)!!

View File

@@ -1,188 +0,0 @@
package org.thoughtcrime.securesms.messages
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import okio.ByteString
import org.junit.After
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.AliceClient
import org.thoughtcrime.securesms.testing.BobClient
import org.thoughtcrime.securesms.testing.Entry
import org.thoughtcrime.securesms.testing.FakeClientHelpers
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.awaitFor
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.websocket.WebSocketMessage
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import java.util.regex.Pattern
import kotlin.random.Random
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import android.util.Log as AndroidLog
/**
* Sends N messages from Bob to Alice to track performance of Alice's processing of messages.
*/
@Ignore("Ignore test in normal testing as it's a performance test with no assertions")
@RunWith(AndroidJUnit4::class)
class MessageProcessingPerformanceTest {
companion object {
private val TAG = Log.tag(MessageProcessingPerformanceTest::class.java)
private val TIMING_TAG = "TIMING_$TAG".substring(0..23)
private val DECRYPTION_TIME_PATTERN = Pattern.compile("^Decrypted (?<count>\\d+) envelopes in (?<duration>\\d+) ms.*$")
}
@get:Rule
val harness = SignalActivityRule()
private val trustRoot: ECKeyPair = ECKeyPair.generate()
@Before
fun setup() {
mockkStatic(SealedSenderAccessUtil::class)
every { SealedSenderAccessUtil.getCertificateValidator() } returns FakeClientHelpers.noOpCertificateValidator
mockkObject(MessageContentProcessor)
every { MessageContentProcessor.create(harness.application) } returns TimingMessageContentProcessor(harness.application)
}
@After
fun after() {
unmockkStatic(SealedSenderAccessUtil::class)
unmockkStatic(MessageContentProcessor::class)
}
@Test
fun testPerformance() {
val aliceClient = AliceClient(
serviceId = harness.self.requireServiceId(),
e164 = harness.self.requireE164(),
trustRoot = trustRoot
)
val bob = Recipient.resolved(harness.others[0])
val bobClient = BobClient(
serviceId = bob.requireServiceId(),
e164 = bob.requireE164(),
identityKeyPair = harness.othersKeys[0],
trustRoot = trustRoot,
profileKey = ProfileKey(bob.profileKey)
)
// Send the initial messages to get past the prekey phase
establishSession(aliceClient, bobClient, bob)
// Have Bob generate N messages that will be received by Alice
val messageCount = 100
val envelopes = generateInboundEnvelopes(bobClient, messageCount)
val firstTimestamp = envelopes.first().timestamp
val lastTimestamp = envelopes.last().timestamp ?: 0
// Inject the envelopes into the websocket
// TODO: mock websocket messages
// Wait until they've all been fully decrypted + processed
harness
.inMemoryLogger
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(lastTimestamp))
.awaitFor(1.minutes)
harness.inMemoryLogger.flush()
// Process logs for timing data
val entries = harness.inMemoryLogger.entries()
// Calculate decryption average
val totalDecryptDuration: Long = entries
.mapNotNull { entry -> entry.message?.let { DECRYPTION_TIME_PATTERN.matcher(it) } }
.filter { it.matches() }
.drop(1) // Ignore the first message, which represents the prekey exchange
.sumOf { it.group("duration")!!.toLong() }
AndroidLog.w(TAG, "Decryption: Average runtime: ${totalDecryptDuration.toFloat() / messageCount.toFloat()}ms")
// Calculate MessageContentProcessor
val takeLast: List<Entry> = entries.filter { it.tag == TimingMessageContentProcessor.TAG }.drop(2)
val iterator = takeLast.iterator()
var processCount = 0L
var processDuration = 0L
while (iterator.hasNext()) {
val start = iterator.next()
val end = iterator.next()
processCount++
processDuration += end.timestamp - start.timestamp
}
AndroidLog.w(TAG, "MessageContentProcessor.process: Average runtime: ${processDuration.toFloat() / processCount.toFloat()}ms")
// Calculate messages per second from "retrieving" first message post session initialization to processing last message
val start = entries.first { it.message == "Retrieved envelope! $firstTimestamp" }
val end = entries.first { it.message == TimingMessageContentProcessor.endTag(lastTimestamp) }
val duration = (end.timestamp - start.timestamp).toFloat() / 1000f
val messagePerSecond = messageCount.toFloat() / duration
AndroidLog.w(TAG, "Processing $messageCount messages took ${duration}s or ${messagePerSecond}m/s")
}
private fun establishSession(aliceClient: AliceClient, bobClient: BobClient, bob: Recipient) {
// Send message from Bob to Alice (self)
val firstPreKeyMessageTimestamp = System.currentTimeMillis()
val encryptedEnvelope = bobClient.encrypt(firstPreKeyMessageTimestamp)
val aliceProcessFirstMessageLatch = harness
.inMemoryLogger
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(firstPreKeyMessageTimestamp))
Thread { aliceClient.process(encryptedEnvelope, System.currentTimeMillis()) }.start()
aliceProcessFirstMessageLatch.awaitFor(15.seconds)
// Send message from Alice to Bob
val aliceNow = System.currentTimeMillis()
bobClient.decrypt(aliceClient.encrypt(aliceNow, bob), aliceNow)
}
private fun generateInboundEnvelopes(bobClient: BobClient, count: Int): List<Envelope> {
val envelopes = ArrayList<Envelope>(count)
var now = System.currentTimeMillis()
for (i in 0..count) {
envelopes += bobClient.encrypt(now)
now += 3
}
return envelopes
}
private fun webSocketTombstone(): ByteString {
return WebSocketMessage(request = WebSocketRequestMessage(verb = "PUT", path = "/api/v1/queue/empty")).encodeByteString()
}
private fun Envelope.toWebSocketPayload(): ByteString {
return WebSocketMessage(
type = WebSocketMessage.Type.REQUEST,
request = WebSocketRequestMessage(
verb = "PUT",
path = "/api/v1/message",
id = Random(System.currentTimeMillis()).nextLong(),
headers = listOf("X-Signal-Timestamp: ${this.timestamp}"),
body = this.encodeByteString()
)
).encodeByteString()
}
}

View File

@@ -1,28 +0,0 @@
package org.thoughtcrime.securesms.messages
import android.content.Context
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.testing.LogPredicate
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.Envelope
class TimingMessageContentProcessor(context: Context) : MessageContentProcessor(context) {
companion object {
val TAG = Log.tag(TimingMessageContentProcessor::class.java)
fun endTagPredicate(timestamp: Long): LogPredicate = { entry ->
entry.tag == TAG && entry.message == endTag(timestamp)
}
private fun startTag(timestamp: Long) = "$timestamp start"
fun endTag(timestamp: Long) = "$timestamp end"
}
override fun process(envelope: Envelope, content: Content, metadata: EnvelopeMetadata, serverDeliveredTimestamp: Long, processingEarlyContent: Boolean, localMetric: SignalLocalMetrics.MessageReceive?) {
Log.d(TAG, startTag(envelope.timestamp!!))
super.process(envelope, content, metadata, serverDeliveredTimestamp, processingEarlyContent, localMetric)
Log.d(TAG, endTag(envelope.timestamp!!))
}
}

View File

@@ -1,58 +0,0 @@
package org.thoughtcrime.securesms.testing
import org.signal.core.models.ServiceId
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.messages.protocol.BufferedProtocolStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.FakeClientHelpers.toEnvelope
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.Envelope
/**
* Welcome to Alice's Client.
*
* Alice represent the Android instrumentation test user. Unlike [BobClient] much less is needed here
* as it can make use of the standard Signal Android App infrastructure.
*/
class AliceClient(val serviceId: ServiceId, val e164: String, val trustRoot: ECKeyPair) {
companion object {
val TAG = Log.tag(AliceClient::class.java)
}
private val aliceSenderCertificate = FakeClientHelpers.createCertificateFor(
trustRoot = trustRoot,
uuid = serviceId.rawUuid,
e164 = e164,
deviceId = 1,
identityKey = SignalStore.account.aciIdentityKey.publicKey.publicKey,
expires = 31337
)
fun process(envelope: Envelope, serverDeliveredTimestamp: Long) {
val start = System.currentTimeMillis()
val bufferedStore = BufferedProtocolStore.create()
AppDependencies.incomingMessageObserver
.processEnvelope(bufferedStore, envelope, serverDeliveredTimestamp)
?.mapNotNull { it.run() }
?.forEach { it.enqueue() }
bufferedStore.flushToDisk()
val end = System.currentTimeMillis()
Log.d(TAG, "${end - start}")
}
fun encrypt(now: Long, destination: Recipient): Envelope {
return AppDependencies.signalServiceMessageSender.getEncryptedMessage(
SignalServiceAddress(destination.requireServiceId(), destination.requireE164()),
FakeClientHelpers.getSealedSenderAccess(ProfileKey(destination.profileKey), aliceSenderCertificate),
1,
FakeClientHelpers.encryptedTextMessage(now),
false
).toEnvelope(now, destination.requireServiceId())
}
}

View File

@@ -1,195 +0,0 @@
package org.thoughtcrime.securesms.testing
import org.signal.core.models.ServiceId
import org.signal.core.util.readToSingleInt
import org.signal.core.util.select
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.SessionBuilder
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.libsignal.protocol.groups.state.SenderKeyRecord
import org.signal.libsignal.protocol.state.IdentityKeyStore
import org.signal.libsignal.protocol.state.IdentityKeyStore.IdentityChange
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.signal.libsignal.protocol.state.PreKeyBundle
import org.signal.libsignal.protocol.state.PreKeyRecord
import org.signal.libsignal.protocol.state.SessionRecord
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import org.signal.libsignal.protocol.util.KeyHelper
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil
import org.thoughtcrime.securesms.database.KyberPreKeyTable
import org.thoughtcrime.securesms.database.OneTimePreKeyTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.SignedPreKeyTable
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.FakeClientHelpers.toEnvelope
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore
import org.whispersystems.signalservice.api.SignalSessionLock
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher
import org.whispersystems.signalservice.api.crypto.SignalSessionBuilder
import org.whispersystems.signalservice.api.push.DistributionId
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.Envelope
import java.util.UUID
import java.util.concurrent.locks.ReentrantLock
/**
* Welcome to Bob's Client.
*
* Bob is a "fake" client that can start a session with the Android instrumentation test user (Alice).
*
* Bob can create a new session using a prekey bundle created from Alice's prekeys, send a message, decrypt
* a return message from Alice, and that'll start a standard Signal session with normal keys/ratcheting.
*/
class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair: IdentityKeyPair, val trustRoot: ECKeyPair, val profileKey: ProfileKey) {
private val serviceAddress = SignalServiceAddress(serviceId, e164)
private val registrationId = KeyHelper.generateRegistrationId(false)
private val aciStore = BobSignalServiceAccountDataStore(registrationId, identityKeyPair)
private val senderCertificate = FakeClientHelpers.createCertificateFor(trustRoot, serviceId.rawUuid, e164, 1, identityKeyPair.publicKey.publicKey, 31337)
private val sessionLock = object : SignalSessionLock {
private val lock = ReentrantLock()
override fun acquire(): SignalSessionLock.Lock {
lock.lock()
return SignalSessionLock.Lock { lock.unlock() }
}
}
/** Inspired by SignalServiceMessageSender#getEncryptedMessage */
fun encrypt(now: Long): Envelope {
val envelopeContent = FakeClientHelpers.encryptedTextMessage(now)
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, null)
if (!aciStore.containsSession(getAliceProtocolAddress())) {
val sessionBuilder = SignalSessionBuilder(sessionLock, SessionBuilder(aciStore, getAliceProtocolAddress()))
sessionBuilder.process(getAlicePreKeyBundle())
}
return cipher.encrypt(getAliceProtocolAddress(), getAliceUnidentifiedAccess(), envelopeContent)
.toEnvelope(envelopeContent.content.get().dataMessage!!.timestamp!!, getAliceServiceId())
}
fun decrypt(envelope: Envelope, serverDeliveredTimestamp: Long) {
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, SealedSenderAccessUtil.getCertificateValidator())
cipher.decrypt(envelope, serverDeliveredTimestamp)
}
private fun getAliceServiceId(): ServiceId {
return SignalStore.account.requireAci()
}
private fun getAlicePreKeyBundle(): PreKeyBundle {
val selfPreKeyId = SignalDatabase.rawDatabase
.select(OneTimePreKeyTable.KEY_ID)
.from(OneTimePreKeyTable.TABLE_NAME)
.where("${OneTimePreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
.run()
.readToSingleInt(-1)
val selfPreKeyRecord = SignalDatabase.oneTimePreKeys.get(getAliceServiceId(), selfPreKeyId)!!
val selfSignedPreKeyId = SignalDatabase.rawDatabase
.select(SignedPreKeyTable.KEY_ID)
.from(SignedPreKeyTable.TABLE_NAME)
.where("${SignedPreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
.run()
.readToSingleInt(-1)
val selfSignedPreKeyRecord = SignalDatabase.signedPreKeys.get(getAliceServiceId(), selfSignedPreKeyId)!!
val selfSignedKyberPreKeyId = SignalDatabase.rawDatabase
.select(KyberPreKeyTable.KEY_ID)
.from(KyberPreKeyTable.TABLE_NAME)
.where("${KyberPreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
.run()
.readToSingleInt(-1)
val selfSignedKyberPreKeyRecord = SignalDatabase.kyberPreKeys.get(getAliceServiceId(), selfSignedKyberPreKeyId)!!.record
return PreKeyBundle(
SignalStore.account.registrationId,
1,
selfPreKeyId,
selfPreKeyRecord.keyPair.publicKey,
selfSignedPreKeyId,
selfSignedPreKeyRecord.keyPair.publicKey,
selfSignedPreKeyRecord.signature,
getAlicePublicKey(),
selfSignedKyberPreKeyId,
selfSignedKyberPreKeyRecord.keyPair.publicKey,
selfSignedKyberPreKeyRecord.signature
)
}
private fun getAliceProtocolAddress(): SignalProtocolAddress {
return SignalProtocolAddress(SignalStore.account.requireAci().toString(), 1)
}
private fun getAlicePublicKey(): IdentityKey {
return SignalStore.account.aciIdentityKey.publicKey
}
private fun getAliceProfileKey(): ProfileKey {
return ProfileKeyUtil.getSelfProfileKey()
}
private fun getAliceUnidentifiedAccess(): SealedSenderAccess? {
return FakeClientHelpers.getSealedSenderAccess(getAliceProfileKey(), senderCertificate)
}
private class BobSignalServiceAccountDataStore(private val registrationId: Int, private val identityKeyPair: IdentityKeyPair) : SignalServiceAccountDataStore {
private var aliceSessionRecord: SessionRecord? = null
override fun getIdentityKeyPair(): IdentityKeyPair = identityKeyPair
override fun getLocalRegistrationId(): Int = registrationId
override fun isTrustedIdentity(address: SignalProtocolAddress?, identityKey: IdentityKey?, direction: IdentityKeyStore.Direction?): Boolean = true
override fun loadSession(address: SignalProtocolAddress?): SessionRecord = aliceSessionRecord ?: SessionRecord()
override fun saveIdentity(address: SignalProtocolAddress?, identityKey: IdentityKey?): IdentityKeyStore.IdentityChange = IdentityChange.NEW_OR_UNCHANGED
override fun storeSession(address: SignalProtocolAddress?, record: SessionRecord?) {
aliceSessionRecord = record
}
override fun getSubDeviceSessions(name: String?): List<Int> = emptyList()
override fun containsSession(address: SignalProtocolAddress?): Boolean = aliceSessionRecord != null
override fun getIdentity(address: SignalProtocolAddress?): IdentityKey = SignalStore.account.aciIdentityKey.publicKey
override fun loadPreKey(preKeyId: Int): PreKeyRecord = throw UnsupportedOperationException()
override fun storePreKey(preKeyId: Int, record: PreKeyRecord?) = throw UnsupportedOperationException()
override fun containsPreKey(preKeyId: Int): Boolean = throw UnsupportedOperationException()
override fun removePreKey(preKeyId: Int) = throw UnsupportedOperationException()
override fun loadExistingSessions(addresses: MutableList<SignalProtocolAddress>?): MutableList<SessionRecord> = throw UnsupportedOperationException()
override fun deleteSession(address: SignalProtocolAddress?) = throw UnsupportedOperationException()
override fun deleteAllSessions(name: String?) = throw UnsupportedOperationException()
override fun loadSignedPreKey(signedPreKeyId: Int): SignedPreKeyRecord = throw UnsupportedOperationException()
override fun loadSignedPreKeys(): MutableList<SignedPreKeyRecord> = throw UnsupportedOperationException()
override fun storeSignedPreKey(signedPreKeyId: Int, record: SignedPreKeyRecord?) = throw UnsupportedOperationException()
override fun containsSignedPreKey(signedPreKeyId: Int): Boolean = throw UnsupportedOperationException()
override fun removeSignedPreKey(signedPreKeyId: Int) = throw UnsupportedOperationException()
override fun loadKyberPreKey(kyberPreKeyId: Int): KyberPreKeyRecord = throw UnsupportedOperationException()
override fun loadKyberPreKeys(): MutableList<KyberPreKeyRecord> = throw UnsupportedOperationException()
override fun storeKyberPreKey(kyberPreKeyId: Int, record: KyberPreKeyRecord?) = throw UnsupportedOperationException()
override fun containsKyberPreKey(kyberPreKeyId: Int): Boolean = throw UnsupportedOperationException()
override fun markKyberPreKeyUsed(kyberPreKeyId: Int, signedPreKeyId: Int, baseKey: ECPublicKey) = throw UnsupportedOperationException()
override fun deleteAllStaleOneTimeEcPreKeys(threshold: Long, minCount: Int) = throw UnsupportedOperationException()
override fun markAllOneTimeEcPreKeysStaleIfNecessary(staleTime: Long) = throw UnsupportedOperationException()
override fun storeSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?, record: SenderKeyRecord?) = throw UnsupportedOperationException()
override fun loadSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?): SenderKeyRecord = throw UnsupportedOperationException()
override fun archiveSession(address: SignalProtocolAddress?) = throw UnsupportedOperationException()
override fun getAllAddressesWithActiveSessions(addressNames: MutableList<String>?): MutableMap<SignalProtocolAddress, SessionRecord> = throw UnsupportedOperationException()
override fun getSenderKeySharedWith(distributionId: DistributionId?): MutableSet<SignalProtocolAddress> = throw UnsupportedOperationException()
override fun markSenderKeySharedWith(distributionId: DistributionId?, addresses: MutableCollection<SignalProtocolAddress>?) = throw UnsupportedOperationException()
override fun clearSenderKeySharedWith(addresses: MutableCollection<SignalProtocolAddress>?) = throw UnsupportedOperationException()
override fun storeLastResortKyberPreKey(kyberPreKeyId: Int, kyberPreKeyRecord: KyberPreKeyRecord) = throw UnsupportedOperationException()
override fun removeKyberPreKey(kyberPreKeyId: Int) = throw UnsupportedOperationException()
override fun markAllOneTimeKyberPreKeysStaleIfNecessary(staleTime: Long) = throw UnsupportedOperationException()
override fun deleteAllStaleOneTimeKyberPreKeys(threshold: Long, minCount: Int) = throw UnsupportedOperationException()
override fun loadLastResortKyberPreKeys(): List<KyberPreKeyRecord> = throw UnsupportedOperationException()
override fun isMultiDevice(): Boolean = throw UnsupportedOperationException()
}
}

View File

@@ -1,71 +0,0 @@
package org.thoughtcrime.securesms.testing
import okio.ByteString.Companion.toByteString
import org.signal.core.models.ServiceId
import org.signal.core.util.Base64
import org.signal.core.util.toByteArray
import org.signal.libsignal.metadata.certificate.CertificateValidator
import org.signal.libsignal.metadata.certificate.SenderCertificate
import org.signal.libsignal.metadata.certificate.ServerCertificate
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
import org.whispersystems.signalservice.api.crypto.ContentHint
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.DataMessage
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
import java.util.Optional
import java.util.UUID
object FakeClientHelpers {
val noOpCertificateValidator = object : CertificateValidator(ECKeyPair.generate().publicKey) {
override fun validate(certificate: SenderCertificate, validationTime: Long) = Unit
}
fun createCertificateFor(trustRoot: ECKeyPair, uuid: UUID, e164: String, deviceId: Int, identityKey: ECPublicKey, expires: Long): SenderCertificate {
val serverKey: ECKeyPair = ECKeyPair.generate()
val serverCertificate = ServerCertificate(trustRoot.privateKey, 1, serverKey.publicKey)
return serverCertificate.issue(serverKey.privateKey, uuid.toString(), Optional.of(e164), deviceId, identityKey, expires)
}
fun getSealedSenderAccess(theirProfileKey: ProfileKey, senderCertificate: SenderCertificate): SealedSenderAccess? {
val themUnidentifiedAccessKey = UnidentifiedAccess(UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey), senderCertificate.serialized, false)
return SealedSenderAccess.forIndividual(themUnidentifiedAccessKey)
}
fun encryptedTextMessage(now: Long, message: String = "Test body message"): EnvelopeContent {
val content = Content.Builder().apply {
dataMessage(
DataMessage.Builder().buildWith {
body = message
timestamp = now
}
)
}
return EnvelopeContent.encrypted(content.build(), ContentHint.RESENDABLE, Optional.empty())
}
fun OutgoingPushMessage.toEnvelope(timestamp: Long, destination: ServiceId): Envelope {
val serverGuid = UUID.randomUUID()
return Envelope.Builder()
.type(Envelope.Type.fromValue(this.type))
.sourceDevice(1)
.timestamp(timestamp)
.serverTimestamp(timestamp + 1)
.destinationServiceId(destination.toString())
.destinationServiceIdBinary(destination.toByteString())
.serverGuid(serverGuid.toString())
.serverGuidBinary(serverGuid.toByteArray().toByteString())
.content(Base64.decode(this.content).toByteString())
.urgent(true)
.story(false)
.build()
}
}

View File

@@ -13,8 +13,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.signal.benchmark.setup.Generator
import org.signal.benchmark.setup.Harness
import org.signal.benchmark.setup.OtherClient
import org.signal.core.util.ThreadUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.TestDbUtils
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
@@ -45,9 +50,18 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
when (command) {
"individual-send" -> handlePrepareIndividualSend()
"group-send" -> handlePrepareGroupSend()
"group-delivery-receipt" -> handlePrepareGroupReceipts { client, timestamps -> client.generateInboundDeliveryReceipts(timestamps) }
"group-read-receipt" -> handlePrepareGroupReceipts { client, timestamps -> client.generateInboundReadReceipts(timestamps) }
"release-messages" -> {
BenchmarkWebSocketConnection.authInstance.startWholeBatchTrace = true
BenchmarkWebSocketConnection.authInstance.releaseMessages()
BenchmarkWebSocketConnection.startWholeBatchTrace()
BenchmarkWebSocketConnection.releaseMessages()
}
"delete-thread" -> {
val pendingResult = goAsync()
Thread {
handleDeleteThread()
pendingResult.finish()
}.start()
}
else -> Log.w(TAG, "Unknown command: $command")
}
@@ -61,25 +75,23 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
runBlocking {
launch(Dispatchers.IO) {
BenchmarkWebSocketConnection.authInstance.run {
Log.i(TAG, "Sending initial message form Bob to establish session.")
addPendingMessages(listOf(encryptedEnvelope.toWebSocketPayload()))
releaseMessages()
Log.i(TAG, "Sending initial message form Bob to establish session.")
BenchmarkWebSocketConnection.addPendingMessages(listOf(encryptedEnvelope.toWebSocketPayload()))
BenchmarkWebSocketConnection.releaseMessages()
// Sleep briefly to let the message be processed.
ThreadUtil.sleep(100)
}
// Sleep briefly to let the message be processed.
ThreadUtil.sleep(1000)
}
}
// Have Bob generate N messages that will be received by Alice
val messageCount = 100
val messageCount = 500
val envelopes = client.generateInboundEnvelopes(messageCount)
val messages = envelopes.map { e -> e.toWebSocketPayload() }
BenchmarkWebSocketConnection.authInstance.addPendingMessages(messages)
BenchmarkWebSocketConnection.authInstance.addQueueEmptyMessage()
BenchmarkWebSocketConnection.addPendingMessages(messages)
BenchmarkWebSocketConnection.addQueueEmptyMessage()
}
private fun handlePrepareGroupSend() {
@@ -90,27 +102,92 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
runBlocking {
launch(Dispatchers.IO) {
BenchmarkWebSocketConnection.authInstance.run {
Log.i(TAG, "Sending initial group messages from client to establish sessions.")
addPendingMessages(encryptedEnvelopes.map { it.toWebSocketPayload() })
releaseMessages()
Log.i(TAG, "Sending initial group messages from client to establish sessions.")
BenchmarkWebSocketConnection.addPendingMessages(encryptedEnvelopes.map { it.toWebSocketPayload() })
BenchmarkWebSocketConnection.releaseMessages()
// Sleep briefly to let the messages be processed.
ThreadUtil.sleep(1000)
}
// Sleep briefly to let the messages be processed.
ThreadUtil.sleep(1000)
}
}
// Have clients generate N group messages that will be received by Alice
clients.forEach { client ->
val allClientMessages = clients.map { client ->
val messageCount = 100
val envelopes = client.generateInboundGroupEnvelopes(messageCount, Harness.groupMasterKey)
val messages = envelopes.map { e -> e.toWebSocketPayload() }
BenchmarkWebSocketConnection.authInstance.addPendingMessages(messages)
envelopes.map { e -> e.toWebSocketPayload() }
}
BenchmarkWebSocketConnection.authInstance.addQueueEmptyMessage()
BenchmarkWebSocketConnection.addPendingMessages(interleave(allClientMessages))
BenchmarkWebSocketConnection.addQueueEmptyMessage()
}
private fun handlePrepareGroupReceipts(generateReceipts: (OtherClient, List<Long>) -> List<Envelope>) {
val clients = Harness.otherClients.take(5)
establishGroupSessions(clients)
val timestamps = getOutgoingGroupMessageTimestamps()
Log.i(TAG, "Found ${timestamps.size} outgoing message timestamps for receipts")
val allClientEnvelopes = clients.map { client ->
generateReceipts(client, timestamps).map { it.toWebSocketPayload() }
}
BenchmarkWebSocketConnection.addPendingMessages(interleave(allClientEnvelopes))
BenchmarkWebSocketConnection.addQueueEmptyMessage()
}
private fun establishGroupSessions(clients: List<OtherClient>) {
val encryptedEnvelopes = clients.map { it.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis(), groupMasterKey = Harness.groupMasterKey)) }
runBlocking {
launch(Dispatchers.IO) {
Log.i(TAG, "Sending initial group messages from clients to establish sessions.")
BenchmarkWebSocketConnection.addPendingMessages(encryptedEnvelopes.map { it.toWebSocketPayload() })
BenchmarkWebSocketConnection.releaseMessages()
ThreadUtil.sleep(1000)
}
}
}
private fun handleDeleteThread() {
val threadId = SignalDatabase.threads.getRecentConversationList(1, false, false).use { cursor ->
if (cursor.moveToFirst()) {
cursor.getLong(cursor.getColumnIndexOrThrow("_id"))
} else {
Log.w(TAG, "No active threads found for deletion benchmark")
return
}
}
Log.i(TAG, "Deleting thread $threadId")
SignalDatabase.threads.deleteConversation(threadId, syncThreadDelete = false)
Log.i(TAG, "Thread $threadId deleted")
}
private fun getOutgoingGroupMessageTimestamps(): List<Long> {
val groupId = GroupId.v2(Harness.groupMasterKey)
val groupRecipient = Recipient.externalGroupExact(groupId)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val selfId = Recipient.self().id.toLong()
return TestDbUtils.getOutgoingMessageTimestamps(threadId, selfId)
}
/**
* Interleaves lists so that items from different lists alternate:
* [[a1, a2], [b1, b2], [c1, c2]] -> [a1, b1, c1, a2, b2, c2]
*/
private fun <T> interleave(lists: List<List<T>>): List<T> {
val result = mutableListOf<T>()
val maxSize = lists.maxOf { it.size }
for (i in 0 until maxSize) {
for (list in lists) {
if (i < list.size) {
result += list[i]
}
}
}
return result
}
private fun Envelope.toWebSocketPayload(): WebSocketRequestMessage {

View File

@@ -1,28 +1,51 @@
package org.signal.benchmark
import android.os.Bundle
import android.widget.TextView
import androidx.activity.compose.setContent
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.signal.benchmark.setup.TestMessages
import org.signal.benchmark.setup.TestUsers
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.TestDbUtils
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.TextSecurePreferences
class BenchmarkSetupActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
when (intent.extras!!.getString("setup-type")) {
"cold-start" -> setupColdStart()
"conversation-open" -> setupConversationOpen()
"message-send" -> setupMessageSend()
"group-message-send" -> setupGroupMessageSend()
var setupComplete by mutableStateOf(false)
setContent {
if (setupComplete) {
Text("done")
} else {
CircularProgressIndicator()
}
}
val textView: TextView = TextView(this).apply {
text = "done"
lifecycleScope.launch(Dispatchers.IO) {
when (intent.extras!!.getString("setup-type")) {
"cold-start" -> setupColdStart()
"conversation-open" -> setupConversationOpen()
"message-send" -> setupMessageSend()
"group-message-send" -> setupGroupMessageSend()
"group-delivery-receipt" -> setupGroupReceipt(includeMsl = true)
"group-read-receipt" -> setupGroupReceipt(enableReadReceipts = true)
"thread-delete" -> setupThreadDelete()
"thread-delete-group" -> setupThreadDeleteGroup()
}
setupComplete = true
}
setContentView(textView)
}
private fun setupColdStart() {
@@ -68,4 +91,99 @@ class BenchmarkSetupActivity : BaseActivity() {
TestUsers.setupSelf()
TestUsers.setupGroup()
}
private fun setupThreadDelete() {
TestUsers.setupSelf()
val recipientIds = TestUsers.setupTestRecipients(2)
val recipient = Recipient.resolved(recipientIds[0])
val reactionAuthor = recipientIds[1]
val messagesToAdd = 20_000
val generator = TestMessages.TimestampGenerator(System.currentTimeMillis() - (messagesToAdd * 2000L) - 60_000L)
for (i in 0 until messagesToAdd) {
val timestamp = generator.nextTimestamp()
when {
i % 20 == 0 -> TestMessages.insertIncomingVoiceMessage(other = recipient, timestamp = timestamp)
i % 4 == 0 -> TestMessages.insertIncomingImageMessage(other = recipient, attachmentCount = 1, timestamp = timestamp)
else -> TestMessages.insertIncomingTextMessage(other = recipient, body = "Message $i", timestamp = timestamp)
}
}
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient = recipient)
TestDbUtils.insertReactionsForThread(threadId, reactionAuthor, moduloFilter = 5)
SignalDatabase.threads.update(threadId, true)
}
private fun setupThreadDeleteGroup() {
TestUsers.setupSelf()
val groupId = TestUsers.setupGroup()
val groupRecipient = Recipient.externalGroupExact(groupId)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val selfId = Recipient.self().id
val memberRecipientIds = SignalDatabase.groups.getGroup(groupId).get().members.filter { it != selfId }
val messagesToAdd = 20_000
val generator = TestMessages.TimestampGenerator(System.currentTimeMillis() - (messagesToAdd * 2000L) - 60_000L)
for (i in 0 until messagesToAdd) {
val timestamp = generator.nextTimestamp()
when {
i % 4 == 0 -> TestMessages.insertOutgoingImageMessage(other = groupRecipient, attachmentCount = 1, timestamp = timestamp)
else -> {
val message = OutgoingMessage(
recipient = groupRecipient,
body = "Message $i",
timestamp = timestamp,
isSecure = true
)
val insert = SignalDatabase.messages.insertMessageOutbox(message, threadId, false, null)
SignalDatabase.messages.markAsSent(insert.messageId, true)
}
}
}
TestDbUtils.insertGroupReceiptsForThread(threadId, memberRecipientIds)
TestDbUtils.insertReactionsForThread(threadId, memberRecipientIds[0], moduloFilter = 5)
TestDbUtils.insertMentionsForThread(threadId, memberRecipientIds[0], moduloFilter = 10)
SignalDatabase.threads.update(threadId, true)
}
private fun setupGroupReceipt(includeMsl: Boolean = false, enableReadReceipts: Boolean = false) {
TestUsers.setupSelf()
val groupId = TestUsers.setupGroup()
val groupRecipient = Recipient.externalGroupExact(groupId)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val messageIds = mutableListOf<Long>()
val timestamps = mutableListOf<Long>()
val baseTimestamp = 2_000_000L
for (i in 0 until 100) {
val timestamp = baseTimestamp + i
val message = OutgoingMessage(
recipient = groupRecipient,
body = "Outgoing message $i",
timestamp = timestamp,
isSecure = true
)
val insert = SignalDatabase.messages.insertMessageOutbox(message, threadId, false, null)
SignalDatabase.messages.markAsSent(insert.messageId, true)
messageIds += insert.messageId
timestamps += timestamp
}
if (includeMsl) {
val selfId = Recipient.self().id
val memberRecipientIds = SignalDatabase.groups.getGroup(groupId).get().members.filter { it != selfId }
TestDbUtils.insertMessageSendLogEntries(messageIds, timestamps, memberRecipientIds)
}
if (enableReadReceipts) {
TextSecurePreferences.setReadReceiptsEnabled(this, true)
}
}
}

View File

@@ -18,6 +18,7 @@ import org.whispersystems.signalservice.internal.push.DataMessage
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.push.GroupContextV2
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
import org.whispersystems.signalservice.internal.push.ReceiptMessage
import java.util.Optional
import java.util.UUID
@@ -45,6 +46,26 @@ object Generator {
return EnvelopeContent.encrypted(content.build(), ContentHint.RESENDABLE, Optional.empty())
}
fun encryptedDeliveryReceipt(now: Long, timestamps: List<Long>): EnvelopeContent {
return encryptedReceipt(ReceiptMessage.Type.DELIVERY, timestamps)
}
fun encryptedReadReceipt(now: Long, timestamps: List<Long>): EnvelopeContent {
return encryptedReceipt(ReceiptMessage.Type.READ, timestamps)
}
private fun encryptedReceipt(type: ReceiptMessage.Type, timestamps: List<Long>): EnvelopeContent {
val content = Content.Builder().apply {
receiptMessage(
ReceiptMessage.Builder().buildWith {
this.type = type
timestamp = timestamps
}
)
}
return EnvelopeContent.encrypted(content.build(), ContentHint.IMPLICIT, Optional.empty())
}
fun OutgoingPushMessage.toEnvelope(timestamp: Long, destination: ServiceId): Envelope {
val serverGuid = UUID.randomUUID()
return Envelope.Builder()

View File

@@ -62,6 +62,10 @@ class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPai
/** Inspired by SignalServiceMessageSender#getEncryptedMessage */
fun encrypt(envelopeContent: EnvelopeContent): Envelope {
return encrypt(envelopeContent, envelopeContent.content.get().dataMessage!!.timestamp!!)
}
fun encrypt(envelopeContent: EnvelopeContent, timestamp: Long): Envelope {
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, null)
if (!aciStore.containsSession(getAliceProtocolAddress())) {
@@ -70,7 +74,7 @@ class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPai
}
return cipher.encrypt(getAliceProtocolAddress(), getAliceUnidentifiedAccess(), envelopeContent)
.toEnvelope(envelopeContent.content.get().dataMessage!!.timestamp!!, getAliceServiceId())
.toEnvelope(timestamp, getAliceServiceId())
}
fun generateInboundEnvelopes(count: Int): List<Envelope> {
@@ -84,6 +88,24 @@ class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPai
return envelopes
}
fun generateInboundDeliveryReceipts(messageTimestamps: List<Long>): List<Envelope> {
return generateInboundReceipts(messageTimestamps, Generator::encryptedDeliveryReceipt)
}
fun generateInboundReadReceipts(messageTimestamps: List<Long>): List<Envelope> {
return generateInboundReceipts(messageTimestamps, Generator::encryptedReadReceipt)
}
private fun generateInboundReceipts(messageTimestamps: List<Long>, receiptFactory: (Long, List<Long>) -> EnvelopeContent): List<Envelope> {
val envelopes = ArrayList<Envelope>(messageTimestamps.size)
var now = System.currentTimeMillis()
for (messageTimestamp in messageTimestamps) {
envelopes += encrypt(receiptFactory(now, listOf(messageTimestamp)), now)
now += 3
}
return envelopes
}
fun generateInboundGroupEnvelopes(count: Int, groupMasterKey: GroupMasterKey): List<Envelope> {
val envelopes = ArrayList<Envelope>(count)
var now = System.currentTimeMillis()

View File

@@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.keyvalue.CertificateType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.Skipped
@@ -170,7 +171,7 @@ object TestUsers {
return others
}
fun setupGroup() {
fun setupGroup(): GroupId.V2 {
val members = setupTestClients(5)
val self = Recipient.self()
@@ -202,6 +203,8 @@ object TestUsers {
)
SignalDatabase.recipients.setProfileSharing(Recipient.externalGroupExact(groupId!!).id, true)
return groupId
}
private fun member(aci: ACI, role: Member.Role = Member.Role.DEFAULT, joinedAt: Int = 0, labelEmoji: String = "", labelString: String = ""): DecryptedMember {

View File

@@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.database
import android.content.ContentValues
import org.signal.core.util.SqlUtil.buildArgs
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.internal.push.Content
object TestDbUtils {
@@ -11,4 +13,114 @@ object TestDbUtils {
contentValues.put(MessageTable.DATE_RECEIVED, timestamp)
val rowsUpdated = database.update(MessageTable.TABLE_NAME, contentValues, DatabaseTable.ID_WHERE, buildArgs(messageId))
}
/**
* Bulk-inserts a reaction on every Nth message (by _id modulo) in the given thread.
*/
fun insertReactionsForThread(threadId: Long, authorId: RecipientId, moduloFilter: Int) {
val db = SignalDatabase.messages.databaseHelper.signalWritableDatabase
db.execSQL(
"""
INSERT INTO reaction (message_id, author_id, emoji, date_sent, date_received)
SELECT ${MessageTable.ID}, ?, '👍', ${MessageTable.DATE_SENT}, ${MessageTable.DATE_RECEIVED}
FROM ${MessageTable.TABLE_NAME}
WHERE ${MessageTable.THREAD_ID} = ? AND ${MessageTable.ID} % ? = 0
""".trimIndent(),
arrayOf(authorId.toLong().toString(), threadId.toString(), moduloFilter.toString())
)
}
/**
* Bulk-inserts group receipt rows for every message in the given thread, one row per member.
*/
fun insertGroupReceiptsForThread(threadId: Long, memberRecipientIds: List<RecipientId>) {
val db = SignalDatabase.messages.databaseHelper.signalWritableDatabase
db.beginTransaction()
try {
for (recipientId in memberRecipientIds) {
db.execSQL(
"""
INSERT INTO group_receipts (mms_id, address, status, timestamp)
SELECT ${MessageTable.ID}, ?, 2, ${MessageTable.DATE_SENT}
FROM ${MessageTable.TABLE_NAME}
WHERE ${MessageTable.THREAD_ID} = ?
""".trimIndent(),
arrayOf(recipientId.toLong().toString(), threadId.toString())
)
}
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
/**
* Bulk-inserts a mention on every Nth message (by _id modulo) in the given thread.
*/
fun insertMentionsForThread(threadId: Long, mentionedRecipientId: RecipientId, moduloFilter: Int) {
val db = SignalDatabase.messages.databaseHelper.signalWritableDatabase
db.execSQL(
"""
INSERT INTO mention (thread_id, message_id, recipient_id, range_start, range_length)
SELECT ${MessageTable.THREAD_ID}, ${MessageTable.ID}, ?, 0, 5
FROM ${MessageTable.TABLE_NAME}
WHERE ${MessageTable.THREAD_ID} = ? AND ${MessageTable.ID} % ? = 0
""".trimIndent(),
arrayOf(mentionedRecipientId.toLong().toString(), threadId.toString(), moduloFilter.toString())
)
}
fun getOutgoingMessageTimestamps(threadId: Long, selfRecipientId: Long): List<Long> {
val timestamps = mutableListOf<Long>()
SignalDatabase.messages.databaseHelper.signalReadableDatabase.query(
MessageTable.TABLE_NAME,
arrayOf(MessageTable.DATE_SENT),
"${MessageTable.THREAD_ID} = ? AND ${MessageTable.FROM_RECIPIENT_ID} = ?",
arrayOf(threadId.toString(), selfRecipientId.toString()),
null,
null,
"${MessageTable.DATE_SENT} ASC"
).use { cursor ->
while (cursor.moveToNext()) {
timestamps += cursor.getLong(0)
}
}
return timestamps
}
fun insertMessageSendLogEntries(messageIds: List<Long>, timestamps: List<Long>, recipientIds: List<RecipientId>) {
val db = SignalDatabase.messages.databaseHelper.signalWritableDatabase
val dummyContent = Content.Builder().build().encode()
db.beginTransaction()
try {
for (i in messageIds.indices) {
val payloadValues = ContentValues().apply {
put("date_sent", timestamps[i])
put("content", dummyContent)
put("content_hint", 0)
put("urgent", 1)
}
val payloadId = db.insert("msl_payload", null, payloadValues)
val messageValues = ContentValues().apply {
put("payload_id", payloadId)
put("message_id", messageIds[i])
}
db.insert("msl_message", null, messageValues)
for (recipientId in recipientIds) {
val recipientValues = ContentValues().apply {
put("payload_id", payloadId)
put("recipient_id", recipientId.toLong())
put("device", 1)
}
db.insert("msl_recipient", null, recipientValues)
}
}
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}
}

View File

@@ -29,23 +29,42 @@ import java.util.concurrent.TimeoutException
class BenchmarkWebSocketConnection : WebSocketConnection {
companion object {
lateinit var authInstance: BenchmarkWebSocketConnection
private set
private val authInstances = mutableListOf<BenchmarkWebSocketConnection>()
private val unauthInstances = mutableListOf<BenchmarkWebSocketConnection>()
@Synchronized
fun createAuthInstance(): WebSocketConnection {
authInstance = BenchmarkWebSocketConnection()
val authInstance = BenchmarkWebSocketConnection()
authInstances += authInstance
return authInstance
}
lateinit var unauthInstance: BenchmarkWebSocketConnection
private set
@Synchronized
fun createUnauthInstance(): WebSocketConnection {
unauthInstance = BenchmarkWebSocketConnection()
val unauthInstance = BenchmarkWebSocketConnection()
unauthInstances += unauthInstance
return unauthInstance
}
@Synchronized
fun startWholeBatchTrace() {
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.startWholeBatchTrace = true }
}
@Synchronized
fun releaseMessages() {
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.releaseMessages() }
}
@Synchronized
fun addPendingMessages(messages: List<WebSocketRequestMessage>) {
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.addPendingMessages(messages) }
}
@Synchronized
fun addQueueEmptyMessage() {
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.addQueueEmptyMessage() }
}
}
override val name: String = "bench-${System.identityHashCode(this)}"
@@ -58,7 +77,8 @@ class BenchmarkWebSocketConnection : WebSocketConnection {
var startWholeBatchTrace = false
@Volatile
private var isShutdown = false
var isShutdown = false
private set
override fun connect(): Observable<WebSocketConnectionState> {
state.onNext(WebSocketConnectionState.CONNECTED)

View File

@@ -105,7 +105,6 @@ class ConversationElementGenerator {
false,
emptyList(),
false,
false,
now,
true,
now,
@@ -122,6 +121,7 @@ class ConversationElementGenerator {
0,
false,
0,
null,
null
)

View File

@@ -30,7 +30,7 @@ import org.thoughtcrime.securesms.conversation.ConversationItem
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.conversation.v2.ConversationAdapterV2
@@ -67,7 +67,7 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
requestManager = Glide.with(this),
clickListener = ClickListener(),
hasWallpaper = springboardViewModel.hasWallpaper.value,
colorizer = Colorizer(),
colorizer = ColorizerV2(),
startExpirationTimeout = {},
chatColorsDataProvider = { ChatColorsDrawable.ChatColorsData(null, null) },
displayDialogFragment = {}

View File

@@ -752,7 +752,7 @@
android:theme="@style/Signal.DayNight.NoActionBar" />
<activity
android:name=".registration.ui.restore.local.InternalNewLocalRestoreActivity"
android:name=".registration.ui.restore.local.RestoreLocalBackupActivity"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar" />
@@ -933,6 +933,11 @@
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar" />
<activity
android:name=".groups.memberlabel.MemberLabelActivity"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar" />
<!-- ======================================= -->
<!-- Activity Aliases -->
<!-- ======================================= -->

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,59 +0,0 @@
package org.thoughtcrime.securesms;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.LiveData;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.thoughtcrime.securesms.groups.LiveGroup;
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
import org.thoughtcrime.securesms.groups.ui.GroupMemberListView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment;
import java.util.List;
public final class GroupMembersDialog {
private final FragmentActivity fragmentActivity;
private final Recipient groupRecipient;
public GroupMembersDialog(@NonNull FragmentActivity activity,
@NonNull Recipient groupRecipient)
{
this.fragmentActivity = activity;
this.groupRecipient = groupRecipient;
}
public void display() {
AlertDialog dialog = new MaterialAlertDialogBuilder(fragmentActivity)
.setTitle(R.string.ConversationActivity_group_members)
.setIcon(R.drawable.ic_group_24)
.setCancelable(true)
.setView(R.layout.dialog_group_members)
.setPositiveButton(android.R.string.ok, null)
.show();
GroupMemberListView memberListView = dialog.findViewById(R.id.list_members);
memberListView.initializeAdapter(fragmentActivity);
LiveGroup liveGroup = new LiveGroup(groupRecipient.requireGroupId());
LiveData<List<GroupMemberEntry.FullMember>> fullMembers = liveGroup.getFullMembers();
//noinspection ConstantConditions
fullMembers.observe(fragmentActivity, memberListView::setMembers);
dialog.setOnDismissListener(d -> fullMembers.removeObservers(fragmentActivity));
memberListView.setRecipientClickListener(recipient -> {
dialog.dismiss();
contactClick(recipient);
});
}
private void contactClick(@NonNull Recipient recipient) {
RecipientBottomSheetDialogFragment.show(fragmentActivity.getSupportFragmentManager(), recipient.getId(), groupRecipient.requireGroupId());
}
}

View File

@@ -1,65 +0,0 @@
package org.thoughtcrime.securesms;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import java.util.concurrent.TimeUnit;
public class MuteDialog extends AlertDialog {
protected MuteDialog(Context context) {
super(context);
}
protected MuteDialog(Context context, boolean cancelable, OnCancelListener cancelListener) {
super(context, cancelable, cancelListener);
}
protected MuteDialog(Context context, int theme) {
super(context, theme);
}
public static void show(final Context context, final @NonNull MuteSelectionListener listener) {
show(context, listener, null);
}
public static void show(final Context context, final @NonNull MuteSelectionListener listener, @Nullable Runnable cancelListener) {
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(context);
builder.setTitle(R.string.MuteDialog_mute_notifications);
builder.setItems(R.array.mute_durations, (dialog, which) -> {
final long muteUntil;
switch (which) {
case 0: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
case 1: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(8); break;
case 2: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1); break;
case 3: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7); break;
case 4: muteUntil = Long.MAX_VALUE; break;
default: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
}
listener.onMuted(muteUntil);
});
if (cancelListener != null) {
builder.setOnCancelListener(dialog -> {
cancelListener.run();
dialog.dismiss();
});
}
builder.show();
}
public interface MuteSelectionListener {
public void onMuted(long until);
}
}

View File

@@ -0,0 +1,73 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.components.settings.conversation.MuteUntilTimePickerBottomSheet
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
object MuteDialog {
private const val MUTE_UNTIL: Long = -1L
private data class MuteOption(
@DrawableRes val iconRes: Int,
val title: String,
val duration: Long
)
@JvmStatic
fun show(context: Context, fragmentManager: FragmentManager, lifecycleOwner: LifecycleOwner, action: MuteSelectionListener) {
fragmentManager.setFragmentResultListener(MuteUntilTimePickerBottomSheet.REQUEST_KEY, lifecycleOwner) { _, bundle ->
action.onMuted(bundle.getLong(MuteUntilTimePickerBottomSheet.RESULT_TIMESTAMP))
}
val options = listOf(
MuteOption(R.drawable.ic_daytime_24, context.getString(R.string.arrays__mute_for_one_hour), 1.hours.inWholeMilliseconds),
MuteOption(R.drawable.ic_nighttime_26, context.getString(R.string.arrays__mute_for_eight_hours), 8.hours.inWholeMilliseconds),
MuteOption(R.drawable.symbol_calendar_one, context.getString(R.string.arrays__mute_for_one_day), 1.days.inWholeMilliseconds),
MuteOption(R.drawable.symbol_calendar_week, context.getString(R.string.arrays__mute_for_seven_days), 7.days.inWholeMilliseconds),
MuteOption(R.drawable.symbol_calendar_24, context.getString(R.string.MuteDialog__mute_until), MUTE_UNTIL),
MuteOption(R.drawable.symbol_bell_slash_24, context.getString(R.string.arrays__always), Long.MAX_VALUE)
)
val adapter = object : BaseAdapter() {
override fun getCount(): Int = options.size
override fun getItem(position: Int): MuteOption = options[position]
override fun getItemId(position: Int): Long = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(context).inflate(R.layout.mute_dialog_item, parent, false)
val option = options[position]
view.findViewById<ImageView>(R.id.mute_dialog_icon).setImageResource(option.iconRes)
view.findViewById<TextView>(R.id.mute_dialog_title).text = option.title
return view
}
}
MaterialAlertDialogBuilder(context)
.setTitle(R.string.MuteDialog_mute_notifications)
.setAdapter(adapter) { _, which ->
val option = options[which]
when (option.duration) {
MUTE_UNTIL -> MuteUntilTimePickerBottomSheet.show(fragmentManager)
Long.MAX_VALUE -> action.onMuted(Long.MAX_VALUE)
else -> action.onMuted(System.currentTimeMillis() + option.duration)
}
}
.show()
}
fun interface MuteSelectionListener {
fun onMuted(until: Long)
}
}

View File

@@ -153,7 +153,7 @@ abstract class Attachment(
* Denotes whether the media for the given attachment is no longer available for download.
*/
val isMediaNoLongerAvailableForDownload: Boolean
get() = isPermanentlyFailed && uploadTimestamp.milliseconds > 30.days
get() = isPermanentlyFailed && (System.currentTimeMillis().milliseconds - uploadTimestamp.milliseconds) > 30.days
val isSticker: Boolean
get() = stickerLocator != null

View File

@@ -155,6 +155,10 @@ object ExportSkips {
return log(sentTimestamp, "An incoming message author did not have an aci or e164.")
}
fun directionlessMessageAuthorDoesNotHaveAciOrE164(sentTimestamp: Long): String {
return log(sentTimestamp, "A directionlessmessage author did not have an aci or e164.")
}
fun outgoingMessageToReleaseNotesChat(sentTimestamp: Long): String {
return log(sentTimestamp, "An outgoing message was sent to the release notes chat.")
}
@@ -214,6 +218,14 @@ object ExportOddities {
return log(0, "Distribution list had self as a member. Removing it.")
}
fun quoteAuthorNotFound(sentTimestamp: Long): String {
return log(sentTimestamp, "Quote author was not found in the exported recipients. Removing the quote.")
}
fun quoteAuthorHasNoAciOrE164(sentTimestamp: Long): String {
return log(sentTimestamp, "Quote author has neither an ACI nor an E164. Removing the quote.")
}
fun emptyQuote(sentTimestamp: Long): String {
return log(sentTimestamp, "Quote had no text or attachments. Removing it.")
}
@@ -283,6 +295,10 @@ object ImportSkips {
return log(0, "Missing recipient for chat $chatId")
}
fun missingAdminDeleteRecipient(sentTimestamp: Long, chatId: Long): String {
return log(sentTimestamp, "Missing admin delete recipient for chat $chatId")
}
private fun log(sentTimestamp: Long, message: String): String {
return "[SKIP][$sentTimestamp] $message"
}

View File

@@ -44,7 +44,6 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
${MessageTable.FROM_RECIPIENT_ID},
${MessageTable.TO_RECIPIENT_ID},
${MessageTable.EXPIRE_STARTED},
${MessageTable.REMOTE_DELETED},
${MessageTable.UNIDENTIFIED},
${MessageTable.LINK_PREVIEWS},
${MessageTable.SHARED_CONTACTS},
@@ -68,7 +67,8 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
${MessageTable.VIEW_ONCE},
${MessageTable.PINNED_UNTIL},
${MessageTable.PINNING_MESSAGE_ID},
${MessageTable.PINNED_AT}
${MessageTable.PINNED_AT},
${MessageTable.DELETED_BY}
)
WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1
""".trimMargin()
@@ -136,7 +136,6 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
MessageTable.TO_RECIPIENT_ID,
EXPIRES_IN,
MessageTable.EXPIRE_STARTED,
MessageTable.REMOTE_DELETED,
MessageTable.UNIDENTIFIED,
MessageTable.LINK_PREVIEWS,
MessageTable.SHARED_CONTACTS,
@@ -161,7 +160,8 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, self
PARENT_STORY_ID,
MessageTable.PINNED_UNTIL,
MessageTable.PINNING_MESSAGE_ID,
MessageTable.PINNED_AT
MessageTable.PINNED_AT,
MessageTable.DELETED_BY
)
.from("${MessageTable.TABLE_NAME} INDEXED BY $dateReceivedIndex")
.where("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND ($EXPIRES_IN == 0 OR $EXPIRES_IN > ${1.days.inWholeMilliseconds}) AND $DATE_RECEIVED >= $lastSeenReceivedTime $cutoffQuery")

View File

@@ -62,7 +62,7 @@ class ChatArchiveExporter(private val cursor: Cursor, private val db: SignalData
expireTimerVersion = cursor.requireInt(RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION),
muteUntilMs = cursor.requireLong(RecipientTable.MUTE_UNTIL).takeIf { it > 0 },
markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.FORCED_UNREAD,
dontNotifyForMentionsIfMuted = RecipientTable.MentionSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING),
dontNotifyForMentionsIfMuted = RecipientTable.NotificationSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING),
style = ChatStyleConverter.constructRemoteChatStyle(
db = db,
chatColors = chatColors,

View File

@@ -19,7 +19,6 @@ import org.signal.core.util.UuidUtil
import org.signal.core.util.bytes
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.emptyIfNull
import org.signal.core.util.isEmpty
import org.signal.core.util.isNotEmpty
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.kibiBytes
@@ -42,6 +41,7 @@ import org.thoughtcrime.securesms.backup.v2.BackupMode
import org.thoughtcrime.securesms.backup.v2.ExportOddities
import org.thoughtcrime.securesms.backup.v2.ExportSkips
import org.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.proto.AdminDeletedMessage
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
import org.thoughtcrime.securesms.backup.v2.proto.ContactAttachment
@@ -193,11 +193,16 @@ class ChatItemArchiveExporter(
}
when {
record.remoteDeleted -> {
record.deletedBy == record.fromRecipientId -> {
builder.remoteDeletedMessage = RemoteDeletedMessage()
transformTimer.emit("remote-delete")
}
record.deletedBy != null -> {
builder.adminDeletedMessage = AdminDeletedMessage(adminId = record.deletedBy)
transformTimer.emit("admin-delete")
}
MessageTypes.isJoinedType(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.JOINED_SIGNAL)
transformTimer.emit("simple-update")
@@ -564,7 +569,7 @@ private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: Recipien
}
val direction = when {
record.type.isDirectionlessType() && !record.remoteDeleted -> {
record.type.isDirectionlessType() && record.deletedBy == null -> {
Direction.DIRECTIONLESS
}
MessageTypes.isOutgoingMessageType(record.type) || record.fromRecipientId == selfRecipientId.toLong() -> {
@@ -1169,6 +1174,16 @@ private fun BackupMessageRecord.toRemoteQuote(exportState: ExportState, attachme
return null
}
if (!exportState.recipientIds.contains(this.quoteAuthor)) {
Log.w(TAG, ExportOddities.quoteAuthorNotFound(this.dateSent))
return null
}
if (exportState.recipientIdToAci[this.quoteAuthor] == null && exportState.recipientIdToE164[this.quoteAuthor] == null) {
Log.w(TAG, ExportOddities.quoteAuthorHasNoAciOrE164(this.dateSent))
return null
}
val localType = QuoteModel.Type.fromCode(this.quoteType)
val remoteType = when (localType) {
QuoteModel.Type.NORMAL -> {
@@ -1360,11 +1375,12 @@ private fun FailureReason?.toRemote(): PaymentNotification.TransactionDetails.Fa
}
private fun List<Mention>.toRemoteBodyRanges(exportState: ExportState): List<BackupBodyRange> {
return this.map {
return this.mapNotNull {
val aci = exportState.recipientIdToAci[it.recipientId.toLong()] ?: return@mapNotNull null
BackupBodyRange(
start = it.start,
length = it.length,
mentionAci = exportState.recipientIdToAci[it.recipientId.toLong()]
mentionAci = aci
)
}
}
@@ -1377,16 +1393,16 @@ private fun ByteArray.toRemoteBodyRanges(dateSent: Long): List<BackupBodyRange>
return emptyList()
}
return decoded.ranges.map { range ->
return decoded.ranges.mapNotNull { range ->
val mention = range.mentionUuid?.let { UuidUtil.parseOrNull(it) }?.toByteArray()?.toByteString()?.takeIf { it.isNotEmpty() }
val style = if (mention == null) {
range.style?.toRemote() ?: BackupBodyRange.Style.NONE
range.style?.toRemote()
} else {
null
}
if (mention == null && style == null) {
return emptyList()
return@mapNotNull null
}
BackupBodyRange(
@@ -1662,7 +1678,8 @@ private fun ChatItem.validateChatItem(exportState: ExportState, selfRecipientId:
this.giftBadge == null &&
this.viewOnceMessage == null &&
this.directStoryReplyMessage == null &&
this.poll == null
this.poll == null &&
this.adminDeletedMessage == null
) {
Log.w(TAG, ExportSkips.emptyChatItem(this.dateSent))
return null
@@ -1688,6 +1705,11 @@ private fun ChatItem.validateChatItem(exportState: ExportState, selfRecipientId:
return null
}
if (this.directionless != null && this.authorId != selfRecipientId.toLong() && exportState.recipientIdToAci[this.authorId] == null && exportState.recipientIdToE164[this.authorId] == null) {
Log.w(TAG, ExportSkips.directionlessMessageAuthorDoesNotHaveAciOrE164(this.dateSent))
return null
}
if (this.outgoing != null && exportState.releaseNoteRecipientId != null && exportState.threadIdToRecipientId[this.chatId] == exportState.releaseNoteRecipientId) {
Log.w(TAG, ExportSkips.outgoingMessageToReleaseNotesChat(this.dateSent))
return null
@@ -1805,7 +1827,6 @@ private fun Cursor.toBackupMessageRecord(pastIds: Set<Long>, backupStartTime: Lo
toRecipientId = this.requireLong(MessageTable.TO_RECIPIENT_ID),
expiresIn = expiresIn,
expireStarted = expireStarted,
remoteDeleted = this.requireBoolean(MessageTable.REMOTE_DELETED),
sealedSender = this.requireBoolean(MessageTable.UNIDENTIFIED),
linkPreview = this.requireString(MessageTable.LINK_PREVIEWS),
sharedContacts = this.requireString(MessageTable.SHARED_CONTACTS),
@@ -1830,6 +1851,7 @@ private fun Cursor.toBackupMessageRecord(pastIds: Set<Long>, backupStartTime: Lo
parentStoryId = this.requireLong(MessageTable.PARENT_STORY_ID),
pinnedAt = this.requireLong(MessageTable.PINNED_AT),
pinnedUntil = this.requireLong(MessageTable.PINNED_UNTIL),
deletedBy = this.requireLongOrNull(MessageTable.DELETED_BY),
messageExtrasSize = messageExtras?.size ?: 0
)
}
@@ -1847,7 +1869,6 @@ private class BackupMessageRecord(
val toRecipientId: Long,
val expiresIn: Long,
val expireStarted: Long,
val remoteDeleted: Boolean,
val sealedSender: Boolean,
val linkPreview: String?,
val sharedContacts: String?,
@@ -1872,6 +1893,7 @@ private class BackupMessageRecord(
val viewOnce: Boolean,
val pinnedAt: Long,
val pinnedUntil: Long,
val deletedBy: Long?,
private val messageExtrasSize: Int
) {
val estimatedSizeInBytes: Int = (body?.length ?: 0) +

View File

@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.backup.v2.util.clampToValidBackupRange
import org.thoughtcrime.securesms.backup.v2.util.isValidUsername
import org.thoughtcrime.securesms.backup.v2.util.toRemote
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.IdentityTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.RecipientTableCursorUtil
@@ -75,7 +76,7 @@ class ContactArchiveExporter(private val cursor: Cursor, private val selfId: Lon
.e164(cursor.requireString(RecipientTable.E164)?.e164ToLong())
.blocked(cursor.requireBoolean(RecipientTable.BLOCKED))
.visibility(Recipient.HiddenState.deserialize(cursor.requireInt(RecipientTable.HIDDEN)).toRemote())
.profileKey(cursor.requireString(RecipientTable.PROFILE_KEY)?.let { Base64.decode(it) }?.toByteString())
.profileKey(cursor.requireString(RecipientTable.PROFILE_KEY)?.let { ProfileKeyUtil.profileKeyOrNull(it)?.serialize()?.toByteString() })
.profileSharing(cursor.requireBoolean(RecipientTable.PROFILE_SHARING))
.profileGivenName(cursor.requireString(RecipientTable.PROFILE_GIVEN_NAME))
.profileFamilyName(cursor.requireString(RecipientTable.PROFILE_FAMILY_NAME))

View File

@@ -116,7 +116,7 @@ private fun AccessControl.toRemote(): Group.AccessControl {
private fun Member.Role.toRemote(): Group.Member.Role {
return when (this) {
Member.Role.UNKNOWN -> Group.Member.Role.UNKNOWN
Member.Role.UNKNOWN -> Group.Member.Role.DEFAULT
Member.Role.DEFAULT -> Group.Member.Role.DEFAULT
Member.Role.ADMINISTRATOR -> Group.Member.Role.ADMINISTRATOR
}

View File

@@ -60,7 +60,7 @@ object ChatArchiveImporter {
.update(
RecipientTable.TABLE_NAME,
contentValuesOf(
RecipientTable.MENTION_SETTING to (if (chat.dontNotifyForMentionsIfMuted) RecipientTable.MentionSetting.DO_NOT_NOTIFY.id else RecipientTable.MentionSetting.ALWAYS_NOTIFY.id),
RecipientTable.MENTION_SETTING to (if (chat.dontNotifyForMentionsIfMuted) RecipientTable.NotificationSetting.DO_NOT_NOTIFY.id else RecipientTable.NotificationSetting.ALWAYS_NOTIFY.id),
RecipientTable.MUTE_UNTIL to (chat.muteUntilMs ?: 0),
RecipientTable.MESSAGE_EXPIRATION_TIME to (chat.expirationTimerMs?.milliseconds?.inWholeSeconds ?: 0),
RecipientTable.MESSAGE_EXPIRATION_TIME_VERSION to chat.expireTimerVersion,

View File

@@ -121,7 +121,6 @@ class ChatItemArchiveImporter(
MessageTable.EXPIRES_IN,
MessageTable.EXPIRE_STARTED,
MessageTable.UNIDENTIFIED,
MessageTable.REMOTE_DELETED,
MessageTable.NETWORK_FAILURES,
MessageTable.QUOTE_ID,
MessageTable.QUOTE_AUTHOR,
@@ -141,7 +140,8 @@ class ChatItemArchiveImporter(
MessageTable.NOTIFIED,
MessageTable.PINNED_UNTIL,
MessageTable.PINNING_MESSAGE_ID,
MessageTable.PINNED_AT
MessageTable.PINNED_AT,
MessageTable.DELETED_BY
)
private val REACTION_COLUMNS = arrayOf(
@@ -193,6 +193,12 @@ class ChatItemArchiveImporter(
Log.w(TAG, ImportSkips.chatIdRemoteRecipientNotFound(chatItem.dateSent, chatItem.chatId))
return
}
if (chatItem.adminDeletedMessage != null && importState.remoteToLocalRecipientId[chatItem.adminDeletedMessage.adminId] == null) {
Log.w(TAG, ImportSkips.missingAdminDeleteRecipient(chatItem.dateSent, chatItem.chatId))
return
}
val messageInsert = chatItem.toMessageInsert(fromLocalRecipientId, chatLocalRecipientId, localThreadId)
if (chatItem.revisions.isNotEmpty()) {
// Flush to avoid having revisions cross batch boundaries, which will cause a foreign key failure
@@ -672,7 +678,6 @@ class ChatItemArchiveImporter(
contentValues.put(MessageTable.QUOTE_MISSING, 0)
contentValues.put(MessageTable.QUOTE_TYPE, 0)
contentValues.put(MessageTable.VIEW_ONCE, 0)
contentValues.put(MessageTable.REMOTE_DELETED, 0)
contentValues.put(MessageTable.PARENT_STORY_ID, 0)
if (this.pinDetails != null) {
@@ -683,12 +688,13 @@ class ChatItemArchiveImporter(
when {
this.standardMessage != null -> contentValues.addStandardMessage(this.standardMessage)
this.remoteDeletedMessage != null -> contentValues.put(MessageTable.REMOTE_DELETED, 1)
this.remoteDeletedMessage != null -> contentValues.put(MessageTable.DELETED_BY, fromRecipientId.toLong())
this.updateMessage != null -> contentValues.addUpdateMessage(this.updateMessage, fromRecipientId, toRecipientId)
this.paymentNotification != null -> contentValues.addPaymentNotification(this, chatRecipientId)
this.giftBadge != null -> contentValues.addGiftBadge(this.giftBadge)
this.viewOnceMessage != null -> contentValues.addViewOnce(this.viewOnceMessage)
this.directStoryReplyMessage != null -> contentValues.addDirectStoryReply(this.directStoryReplyMessage, toRecipientId)
this.adminDeletedMessage != null -> contentValues.put(MessageTable.DELETED_BY, importState.remoteToLocalRecipientId[this.adminDeletedMessage.adminId]!!.toLong())
}
return contentValues

View File

@@ -125,7 +125,7 @@ object AccountDataArchiveProcessor {
hasSeenGroupStoryEducationSheet = signalStore.storyValues.userHasSeenGroupStoryEducationSheet,
hasCompletedUsernameOnboarding = signalStore.uiHintValues.hasCompletedUsernameOnboarding(),
customChatColors = db.chatColorsTable.getSavedChatColors().toRemoteChatColors().also { colors -> exportState.customChatColorIds.addAll(colors.map { it.id }) },
optimizeOnDeviceStorage = signalStore.backupValues.optimizeStorage,
optimizeOnDeviceStorage = signalStore.backupValues.optimizeStorage && signalStore.backupValues.backupTier == MessageBackupTier.PAID,
backupTier = signalStore.backupValues.backupTier.toRemoteBackupTier(),
defaultSentMediaQuality = signalStore.settingsValues.sentMediaQuality.toRemoteSentMediaQuality(),
autoDownloadSettings = AccountData.AutoDownloadSettings(

View File

@@ -0,0 +1,127 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.saveable.rememberSaveable
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.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
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.ComposeBottomSheetDialogFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.signal.core.ui.R as CoreUiR
/**
* Bottom sheet shown after a successful paid backup subscription from a storage upsell megaphone.
* Allows the user to start their first backup and optionally enable storage optimization.
*/
class BackupSetupCompleteBottomSheet : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.75f
@Composable
override fun SheetContent() {
SetupCompleteSheetContent(
onBackUpNowClick = { optimizeStorage ->
SignalStore.backup.optimizeStorage = optimizeStorage
BackupMessagesJob.enqueue()
dismissAllowingStateLoss()
}
)
}
}
@Composable
private fun SetupCompleteSheetContent(
onBackUpNowClick: (optimizeStorage: Boolean) -> Unit
) {
var optimizeStorage by rememberSaveable { mutableStateOf(true) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
) {
BottomSheets.Handle()
Spacer(modifier = Modifier.size(26.dp))
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.image_signal_backups_subscribed),
contentDescription = null,
modifier = Modifier
.size(80.dp)
.padding(2.dp)
)
Text(
text = stringResource(R.string.BackupSetupCompleteBottomSheet__title),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
)
Text(
text = stringResource(R.string.BackupSetupCompleteBottomSheet__body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 24.dp)
)
Rows.ToggleRow(
checked = optimizeStorage,
text = stringResource(R.string.BackupSetupCompleteBottomSheet__optimize_storage),
label = stringResource(R.string.BackupSetupCompleteBottomSheet__optimize_subtitle),
onCheckChanged = { optimizeStorage = it },
modifier = Modifier.padding(bottom = 24.dp)
)
Buttons.LargeTonal(
onClick = { onBackUpNowClick(optimizeStorage) },
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 56.dp)
) {
Text(text = stringResource(R.string.BackupSetupCompleteBottomSheet__back_up_now))
}
}
}
@DayNightPreviews
@Composable
private fun BackupSetupCompleteBottomSheetPreview() {
Previews.BottomSheetContentPreview {
SetupCompleteSheetContent(
onBackUpNowClick = {}
)
}
}

View File

@@ -0,0 +1,236 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import android.os.Bundle
import android.view.View
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
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.util.gibiBytes
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.billing.upgrade.UpgradeToPaidTierBottomSheet
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import java.math.BigDecimal
import java.util.Currency
import kotlin.time.Duration.Companion.days
import org.signal.core.ui.R as CoreUiR
/**
* Bottom sheet that upsells paid backup plans to users.
*/
class BackupUpsellBottomSheet : UpgradeToPaidTierBottomSheet() {
companion object {
private const val ARG_SHOW_POST_PAYMENT = "show_post_payment"
@JvmStatic
fun create(showPostPaymentSheet: Boolean): DialogFragment {
return BackupUpsellBottomSheet().apply {
arguments = bundleOf(ARG_SHOW_POST_PAYMENT to showPostPaymentSheet)
}
}
}
private val showPostPaymentSheet: Boolean by lazy(LazyThreadSafetyMode.NONE) {
requireArguments().getBoolean(ARG_SHOW_POST_PAYMENT, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (showPostPaymentSheet) {
parentFragmentManager.setFragmentResultListener(RESULT_KEY, requireActivity()) { _, bundle ->
if (bundle.getBoolean(RESULT_KEY, false)) {
BackupSetupCompleteBottomSheet().show(parentFragmentManager, "backup_setup_complete")
}
}
}
}
@Composable
override fun UpgradeSheetContent(
paidBackupType: MessageBackupsType.Paid,
freeBackupType: MessageBackupsType.Free,
isSubscribeEnabled: Boolean,
onSubscribeClick: () -> Unit
) {
UpsellSheetContent(
paidBackupType = paidBackupType,
isSubscribeEnabled = isSubscribeEnabled,
onSubscribeClick = onSubscribeClick,
onNoThanksClick = { dismissAllowingStateLoss() }
)
}
}
@Composable
private fun UpsellSheetContent(
paidBackupType: MessageBackupsType.Paid,
isSubscribeEnabled: Boolean,
onSubscribeClick: () -> Unit,
onNoThanksClick: () -> Unit
) {
val resources = LocalContext.current.resources
val pricePerMonth = remember(paidBackupType) {
FiatMoneyUtil.format(resources, paidBackupType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
) {
BottomSheets.Handle()
Spacer(modifier = Modifier.size(26.dp))
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.image_signal_backups),
contentDescription = null,
modifier = Modifier
.size(80.dp)
.padding(2.dp)
)
Text(
text = stringResource(R.string.BackupUpsellBottomSheet__title),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
)
Text(
text = stringResource(R.string.BackupUpsellBottomSheet__body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 24.dp)
)
FeatureCard(pricePerMonth = pricePerMonth)
Buttons.LargeTonal(
enabled = isSubscribeEnabled,
onClick = onSubscribeClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 16.dp)
) {
Text(text = stringResource(R.string.BackupUpsellBottomSheet__subscribe_for, pricePerMonth))
}
TextButton(
enabled = isSubscribeEnabled,
onClick = onNoThanksClick,
modifier = Modifier.padding(bottom = 32.dp)
) {
Text(text = stringResource(R.string.BackupUpsellBottomSheet__no_thanks))
}
}
}
@Composable
private fun FeatureCard(pricePerMonth: String) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp)
)
.padding(16.dp)
) {
Text(
text = stringResource(R.string.BackupUpsellBottomSheet__price_per_month, pricePerMonth),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = stringResource(R.string.BackupUpsellBottomSheet__text_and_all_media),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 12.dp)
)
FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__full_text_media_backup))
FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__storage_100gb))
FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__save_on_device_storage))
FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__thanks_for_supporting))
}
}
@Composable
private fun FeatureBullet(text: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(vertical = 2.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_check_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
@DayNightPreviews
@Composable
private fun BackupUpsellBottomSheetPreview() {
Previews.BottomSheetContentPreview {
UpsellSheetContent(
paidBackupType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal("1.99"), Currency.getInstance("USD")),
mediaTtl = 30.days,
storageAllowanceBytes = 100.gibiBytes.inWholeBytes
),
isSubscribeEnabled = true,
onSubscribeClick = {},
onNoThanksClick = {}
)
}
}

View File

@@ -219,9 +219,16 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
private fun handleDeleteSelectedRows() {
val count = callLogActionMode.getCount()
val selectionState = viewModel.selectionStateSnapshot
val hasCallLinks = selectionState.isExclusionary() || selectionState.selected().any { it is CallLogRow.Id.CallLink }
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, count, count))
.setMessage(getString(R.string.CallLogFragment__call_links_youve_created))
.apply {
if (hasCallLinks) {
setMessage(getString(R.string.CallLogFragment__call_links_youve_created))
}
}
.setPositiveButton(R.string.CallLogFragment__delete) { _, _ ->
performDeletion(count, viewModel.stageSelectionDeletion())
callLogActionMode.end()
@@ -380,7 +387,11 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
override fun deleteCall(call: CallLogRow) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
.setMessage(getString(R.string.CallLogFragment__call_links_youve_created))
.apply {
if (call is CallLogRow.CallLink) {
setMessage(getString(R.string.CallLogFragment__call_links_youve_created))
}
}
.setPositiveButton(R.string.CallLogFragment__delete) { _, _ ->
performDeletion(1, viewModel.stageCallDeletion(call))
}

View File

@@ -84,7 +84,7 @@ private fun NewCallScreen(
val context = LocalActivity.current as FragmentActivity
val callbacks = remember {
object : UiCallbacks {
object : NewCallUiCallbacks {
override fun onSearchQueryChanged(query: String) = viewModel.onSearchQueryChanged(query)
override fun onRecipientSelected(selection: RecipientSelection) = viewModel.startCall(selection)
override fun onInviteToSignal() = context.startActivity(AppSettingsActivity.invite(context))
@@ -111,7 +111,7 @@ private fun NewCallScreen(
)
}
private interface UiCallbacks :
private interface NewCallUiCallbacks :
RecipientPickerCallbacks.ListActions,
RecipientPickerCallbacks.Refresh,
RecipientPickerCallbacks.NewCall {
@@ -120,7 +120,7 @@ private interface UiCallbacks :
fun onUserMessageDismissed(userMessage: UserMessage)
fun onBackPressed()
object Empty : UiCallbacks {
object Empty : NewCallUiCallbacks {
override fun onSearchQueryChanged(query: String) = Unit
override fun onRecipientSelected(selection: RecipientSelection) = Unit
override fun onInviteToSignal() = Unit
@@ -134,7 +134,7 @@ private interface UiCallbacks :
@Composable
private fun NewCallScreenUi(
uiState: NewCallUiState,
callbacks: UiCallbacks
callbacks: NewCallUiCallbacks
) {
val snackbarHostState = remember { SnackbarHostState() }
@@ -173,7 +173,7 @@ private fun NewCallScreenUi(
}
@Composable
private fun TopAppBarActions(callbacks: UiCallbacks) {
private fun TopAppBarActions(callbacks: NewCallUiCallbacks) {
val menuController = remember { DropdownMenus.MenuController() }
IconButton(
onClick = { menuController.show() },
@@ -250,7 +250,7 @@ private fun NewCallScreenPreview() {
uiState = NewCallUiState(
forceSplitPane = false
),
callbacks = UiCallbacks.Empty
callbacks = NewCallUiCallbacks.Empty
)
}
}

View File

@@ -15,6 +15,7 @@ import android.text.TextUtils;
import android.text.TextUtils.TruncateAt;
import android.util.AttributeSet;
import android.view.ActionMode;
import android.view.View;
import android.view.Menu;
import android.view.MenuItem;
import android.view.inputmethod.EditorInfo;
@@ -284,6 +285,10 @@ public class ComposeText extends EmojiEditText {
}
private void initialize() {
if (Build.VERSION.SDK_INT >= 26) {
setImportantForAutofill(View.IMPORTANT_FOR_AUTOFILL_NO);
}
if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
setImeOptions(getImeOptions() | 16777216);
}

View File

@@ -291,7 +291,11 @@ public class ConversationItemFooter extends ConstraintLayout {
dateView.setText(null);
} else if (messageRecord.isFailed()) {
int errorMsg;
if (messageRecord.hasFailedWithNetworkFailures()) {
if (messageRecord.isFailedAdminDelete() && messageRecord.isIdentityMismatchFailure()) {
errorMsg = R.string.ConversationItem_error_partially_not_deleted;
} else if (messageRecord.isFailedAdminDelete()) {
errorMsg = R.string.ConversationItem_error_delete_failed;
} else if (messageRecord.hasFailedWithNetworkFailures()) {
errorMsg = R.string.ConversationItem_error_network_not_delivered;
} else if (messageRecord.getToRecipient().isPushGroup() && messageRecord.isIdentityMismatchFailure()) {
errorMsg = R.string.ConversationItem_error_partially_not_delivered;
@@ -397,7 +401,7 @@ public class ConversationItemFooter extends ConstraintLayout {
}
if (onlyShowSendingStatus) {
if (messageRecord.isOutgoing() && messageRecord.isPending()) {
if (messageRecord.isPending()) {
deliveryStatusView.setPending();
} else {
deliveryStatusView.setNone();

View File

@@ -64,6 +64,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
private var insets: WindowInsetsCompat? = null
private var windowTypes: Int = InsetAwareConstraintLayout.windowTypes
private var navigationBarInsetOverride: Int? = null
private val windowInsetsListener = androidx.core.view.OnApplyWindowInsetsListener { _, insets ->
this.insets = insets
@@ -114,6 +115,23 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
}
}
fun setNavigationBarInsetOverride(inset: Int?) {
if (navigationBarInsetOverride == inset) return
navigationBarInsetOverride = inset
if (inset != null) {
// Apply immediately so layout is correct before next inset dispatch (important for
// Android 15 bubble where insets can arrive late or with different values).
navigationBarGuideline?.setGuidelineEnd(inset)
if (!isKeyboardShowing) {
keyboardGuideline?.setGuidelineEnd(inset)
}
requestLayout()
}
if (insets != null) {
applyInsets(insets!!.getInsets(windowTypes), insets!!.getInsets(keyboardType))
}
}
fun addKeyboardStateListener(listener: KeyboardStateListener) {
keyboardStateListeners += listener
}
@@ -134,7 +152,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
val isLtr = ViewUtil.isLtr(this)
val statusBar = windowInsets.top
val navigationBar = if (windowInsets.bottom == 0 && Build.VERSION.SDK_INT <= 29) {
val navigationBar = navigationBarInsetOverride ?: if (windowInsets.bottom == 0 && Build.VERSION.SDK_INT <= 29) {
ViewUtil.getNavigationBarHeight(resources)
} else {
windowInsets.bottom

View File

@@ -135,7 +135,17 @@ public class EmojiTextView extends AppCompatTextView {
spoilerRendererDelegate = new SpoilerRendererDelegate(this);
}
textDirection = getLayoutDirection() == LAYOUT_DIRECTION_LTR ? TextDirectionHeuristics.FIRSTSTRONG_RTL : TextDirectionHeuristics.ANYRTL_LTR;
if (getLayoutDirection() == LAYOUT_DIRECTION_LTR) {
textDirection = TextDirectionHeuristics.FIRSTSTRONG_RTL;
if (getTextDirection() == TEXT_DIRECTION_INHERIT) {
setTextDirection(TEXT_DIRECTION_FIRST_STRONG_RTL);
}
} else {
textDirection = TextDirectionHeuristics.ANYRTL_LTR;
if (getTextDirection() == TEXT_DIRECTION_INHERIT) {
setTextDirection(TEXT_DIRECTION_ANY_RTL);
}
}
setEmojiCompatEnabled(useSystemEmoji());
}
@@ -264,6 +274,8 @@ public class EmojiTextView extends AppCompatTextView {
previousOverflowText = overflowText;
useSystemEmoji = useSystemEmoji();
previousTransformationMethod = getTransformationMethod();
lastSizeChangedWidth = -1;
lastSizeChangedHeight = -1;
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
// We ellipsize them ourselves by manually truncating the appropriate section.
@@ -590,7 +602,7 @@ public class EmojiTextView extends AppCompatTextView {
lastSizeChangedWidth = w;
lastSizeChangedHeight = h;
if (!sizeChangeInProgress) {
if (!sizeChangeInProgress && getMaxLines() > 0 && getMaxLines() < Integer.MAX_VALUE) {
sizeChangeInProgress = true;
resetText();
}

View File

@@ -5,11 +5,14 @@
package org.thoughtcrime.securesms.components.emoji
import android.content.Context
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -21,10 +24,12 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.TextUnit
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Applies Signal or System emoji to the given content based off user settings.
@@ -34,6 +39,7 @@ import org.signal.core.ui.compose.Previews
@Composable
fun Emojifier(
text: String,
useSystemEmoji: Boolean = !LocalInspectionMode.current && SignalStore.settings.isPreferSystemEmoji,
content: @Composable (AnnotatedString, Map<String, InlineTextContent>) -> Unit = { annotatedText, inlineContent ->
Text(
text = annotatedText,
@@ -41,38 +47,56 @@ fun Emojifier(
)
}
) {
if (LocalInspectionMode.current) {
if (useSystemEmoji) {
content(buildAnnotatedString { append(text) }, emptyMap())
return
}
val context = LocalContext.current
val candidates = remember(text) { EmojiProvider.getCandidates(text) }
val candidateMap: Map<String, InlineTextContent> = remember(text) {
candidates?.associate { candidate ->
candidate.drawInfo.emoji to InlineTextContent(placeholder = Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)) {
Image(
painter = rememberDrawablePainter(EmojiProvider.getEmojiDrawable(context, candidate.drawInfo.emoji)),
contentDescription = null
)
}
} ?: emptyMap()
val fontSize = LocalTextStyle.current.fontSize
val foundEmojis: List<EmojiParser.Candidate> = remember(text) {
EmojiProvider.getCandidates(text)?.list.orEmpty()
}
val inlineContentByEmoji: Map<String, InlineTextContent> = remember(text, fontSize) {
foundEmojis.associate { it.drawInfo.emoji to createInlineContent(context, it.drawInfo.emoji, fontSize) }
}
val annotatedString = buildAnnotatedString {
append(text)
val annotatedString = remember(text) { buildAnnotatedString(text, foundEmojis) }
content(annotatedString, inlineContentByEmoji)
}
candidates?.forEach {
addStringAnnotation(
tag = "EMOJI",
annotation = it.drawInfo.emoji,
start = it.startIndex,
end = it.endIndex
)
private fun createInlineContent(context: Context, emoji: String, fontSize: TextUnit): InlineTextContent {
return InlineTextContent(
placeholder = Placeholder(width = fontSize, height = fontSize, PlaceholderVerticalAlign.TextCenter)
) {
Image(
painter = rememberDrawablePainter(EmojiProvider.getEmojiDrawable(context, emoji)),
contentDescription = null
)
}
}
/**
* Constructs an [AnnotatedString] from [text], substituting each emoji in [foundEmojis] with an inline content placeholder.
*/
private fun buildAnnotatedString(
text: String,
foundEmojis: List<EmojiParser.Candidate>
): AnnotatedString = buildAnnotatedString {
var nextSegmentStartIndex = 0
foundEmojis.forEach { emoji ->
if (emoji.startIndex > nextSegmentStartIndex) {
append(text, start = nextSegmentStartIndex, end = emoji.startIndex)
}
appendInlineContent(emoji.drawInfo.emoji)
nextSegmentStartIndex = emoji.endIndex
}
content(annotatedString, candidateMap)
if (nextSegmentStartIndex < text.length) {
append(text, start = nextSegmentStartIndex, end = text.length)
}
}
@Composable

View File

@@ -41,6 +41,9 @@ public class EmojiParser {
this.emojiTree = emojiTree;
}
/**
* Returns an ordered list of every emoji occurrence found in the given text.
*/
public @NonNull CandidateList findCandidates(@Nullable CharSequence text) {
List<Candidate> results = new LinkedList<>();

View File

@@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.SignalE164Util
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -52,7 +52,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
when (val appSettingsRoute: AppSettingsRoute? = intent?.getParcelableExtraCompat(START_ROUTE, AppSettingsRoute::class.java)) {
AppSettingsRoute.Empty -> null
is AppSettingsRoute.BackupsRoute.Local -> {
if (SignalStore.backup.newLocalBackupsEnabled || RemoteConfig.unifiedLocalBackups && (!SignalStore.settings.isBackupEnabled || appSettingsRoute.triggerUpdateFlow)) {
if (SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && (!SignalStore.settings.isBackupEnabled || appSettingsRoute.triggerUpdateFlow))) {
AppSettingsFragmentDirections.actionDirectToLocalBackupsFragment()
.setTriggerUpdateFlow(appSettingsRoute.triggerUpdateFlow)
} else {

View File

@@ -166,8 +166,7 @@ class BackupStateObserver(
}
val price = latestPayment.data.amount!!.toFiatMoney()
val isKeepAlive = latestPayment.data.redemption?.keepAlive == true
val isPending = latestPayment.state == InAppPaymentTable.State.PENDING && !isKeepAlive
val isPending = SignalDatabase.inAppPayments.hasPendingBackupRedemption()
if (isPending) {
Log.d(TAG, "[getDatabaseBackupState] We have a pending subscription.")
return BackupState.Pending(price = price)
@@ -243,8 +242,7 @@ class BackupStateObserver(
* Utilizes everything we can to resolve the most accurate backup state available, including database and network.
*/
private suspend fun getNetworkBackupState(lastPurchase: InAppPaymentTable.InAppPayment?): BackupState {
val isKeepAlive = lastPurchase?.data?.redemption?.keepAlive == true
if (lastPurchase?.state == InAppPaymentTable.State.PENDING && !isKeepAlive) {
if (lastPurchase?.state == InAppPaymentTable.State.PENDING) {
Log.d(TAG, "[getNetworkBackupState] We have a pending subscription.")
return BackupState.Pending(
price = lastPurchase.data.amount!!.toFiatMoney()

View File

@@ -57,7 +57,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBa
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.math.BigDecimal
import java.util.Currency
@@ -104,13 +104,12 @@ class BackupsSettingsFragment : ComposeFragment() {
}
},
onOnDeviceBackupsRowClick = {
if (SignalStore.backup.newLocalBackupsEnabled || RemoteConfig.unifiedLocalBackups && !SignalStore.settings.isBackupEnabled) {
if (SignalStore.backup.newLocalBackupsEnabled || (Environment.Backups.isNewFormatSupportedForLocalBackup() && !SignalStore.settings.isBackupEnabled)) {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_localBackupsFragment)
} else {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment)
}
},
onNewOnDeviceBackupsRowClick = { findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_internalLocalBackupFragment) },
onBackupTierInternalOverrideChanged = { viewModel.onBackupTierInternalOverrideChanged(it) }
)
}
@@ -122,7 +121,6 @@ private fun BackupsSettingsContent(
onNavigationClick: () -> Unit = {},
onBackupsRowClick: () -> Unit = {},
onOnDeviceBackupsRowClick: () -> Unit = {},
onNewOnDeviceBackupsRowClick: () -> Unit = {},
onBackupTierInternalOverrideChanged: (MessageBackupTier?) -> Unit = {}
) {
Scaffolds.Settings(
@@ -241,16 +239,6 @@ private fun BackupsSettingsContent(
onClick = onOnDeviceBackupsRowClick
)
}
if (backupsSettingsState.showNewLocalBackup) {
item {
Rows.TextRow(
text = "INTERNAL ONLY - New Local Backup",
label = "Use new local backup format",
onClick = onNewOnDeviceBackupsRowClick
)
}
}
}
}
}

View File

@@ -17,6 +17,5 @@ data class BackupsSettingsState(
val backupState: BackupState,
val lastBackupAt: Duration = SignalStore.backup.lastBackupTime.milliseconds,
val showBackupTierInternalOverride: Boolean = false,
val backupTierInternalOverride: MessageBackupTier? = null,
val showNewLocalBackup: Boolean = false
val backupTierInternalOverride: MessageBackupTier? = null
)

View File

@@ -21,7 +21,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.RemoteConfig
import kotlin.time.Duration.Companion.milliseconds
class BackupsSettingsViewModel : ViewModel() {
@@ -46,8 +45,7 @@ class BackupsSettingsViewModel : ViewModel() {
backupState = enabledState,
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
showBackupTierInternalOverride = Environment.IS_STAGING,
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride,
showNewLocalBackup = RemoteConfig.internalUser || Environment.IS_NIGHTLY
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride
)
}
}

View File

@@ -1,275 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
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.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
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.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.util.StorageUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.LocalBackupV2Event
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.service.LocalBackupListener
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.formatHours
import java.time.LocalTime
import java.util.Locale
/**
* App settings internal screen for enabling and creating new local backups.
*/
class InternalNewLocalBackupCreateFragment : ComposeFragment() {
private val TAG = Log.tag(InternalNewLocalBackupCreateFragment::class)
private lateinit var chooseBackupLocationLauncher: ActivityResultLauncher<Intent>
private var createStatus by mutableStateOf("None")
private val directoryFlow = SignalStore.backup.newLocalBackupsDirectoryFlow.map { if (Build.VERSION.SDK_INT >= 24 && it != null) StorageUtil.getDisplayPath(requireContext(), Uri.parse(it)) else it }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
chooseBackupLocationLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK && result.data?.data != null) {
handleBackupLocationSelected(result.data!!.data!!)
} else {
Log.w(TAG, "Backup location selection cancelled or failed")
}
}
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEvent(event: LocalBackupV2Event) {
createStatus = "${event.type}: ${event.count} / ${event.estimatedTotalCount}"
}
@Composable
override fun FragmentContent() {
val context = LocalContext.current
val backupsEnabled by SignalStore.backup.newLocalBackupsEnabledFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsEnabled)
val selectedDirectory by directoryFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsDirectory)
val lastBackupTime by SignalStore.backup.newLocalBackupsLastBackupTimeFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsLastBackupTime)
val lastBackupTimeString = remember(lastBackupTime) { calculateLastBackupTimeString(context, lastBackupTime) }
val backupTime = remember { LocalTime.of(SignalStore.settings.backupHour, SignalStore.settings.backupMinute).formatHours(requireContext()) }
InternalLocalBackupScreen(
backupsEnabled = backupsEnabled,
selectedDirectory = selectedDirectory,
lastBackupTimeString = lastBackupTimeString,
backupTime = backupTime,
createStatus = createStatus,
callback = CallbackImpl()
)
}
private fun launchBackupDirectoryPicker() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (Build.VERSION.SDK_INT >= 26) {
val latestDirectory = SignalStore.settings.latestSignalBackupDirectory
if (latestDirectory != null) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, latestDirectory)
}
}
intent.addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
try {
Log.d(TAG, "Launching backup directory picker")
chooseBackupLocationLauncher.launch(intent)
} catch (e: Exception) {
Log.w(TAG, "Failed to launch backup directory picker", e)
Toast.makeText(requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
}
}
private fun handleBackupLocationSelected(uri: Uri) {
Log.i(TAG, "Backup location selected: $uri")
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
Toast.makeText(requireContext(), "Directory selected: $uri", Toast.LENGTH_SHORT).show()
}
private fun calculateLastBackupTimeString(context: Context, lastBackupTimestamp: Long): String {
return if (lastBackupTimestamp > 0) {
val relativeTime = DateUtils.getDatelessRelativeTimeSpanFormattedDate(
context,
Locale.getDefault(),
lastBackupTimestamp
)
if (relativeTime.isRelative) {
relativeTime.value
} else {
val day = DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), lastBackupTimestamp)
val time = relativeTime.value
context.getString(R.string.RemoteBackupsSettingsFragment__s_at_s, day, time)
}
} else {
context.getString(R.string.RemoteBackupsSettingsFragment__never)
}
}
private inner class CallbackImpl : Callback {
override fun onNavigationClick() {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
override fun onToggleBackupsClick(enabled: Boolean) {
SignalStore.backup.newLocalBackupsEnabled = enabled
if (enabled) {
LocalBackupListener.schedule(requireContext())
}
}
override fun onSelectDirectoryClick() {
launchBackupDirectoryPicker()
}
override fun onEnqueueBackupClick() {
createStatus = "Starting..."
LocalBackupJob.enqueueArchive(false)
}
}
}
private interface Callback {
fun onNavigationClick()
fun onToggleBackupsClick(enabled: Boolean)
fun onSelectDirectoryClick()
fun onEnqueueBackupClick()
object Empty : Callback {
override fun onNavigationClick() = Unit
override fun onToggleBackupsClick(enabled: Boolean) = Unit
override fun onSelectDirectoryClick() = Unit
override fun onEnqueueBackupClick() = Unit
}
}
@Composable
private fun InternalLocalBackupScreen(
backupsEnabled: Boolean = false,
selectedDirectory: String? = null,
lastBackupTimeString: String = "Never",
backupTime: String = "Unknown",
createStatus: String = "None",
callback: Callback
) {
Scaffolds.Settings(
title = "New Local Backups",
navigationIcon = SignalIcons.ArrowStart.imageVector,
onNavigationClick = callback::onNavigationClick
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues)
) {
item {
Rows.ToggleRow(
checked = backupsEnabled,
text = "Enable New Local Backups",
label = if (backupsEnabled) "Backups are enabled" else "Backups are disabled",
onCheckChanged = callback::onToggleBackupsClick
)
}
item {
Rows.TextRow(
text = "Last Backup",
label = lastBackupTimeString
)
}
item {
Rows.TextRow(
text = "Backup Schedule Time (same as v1)",
label = backupTime
)
}
item {
Rows.TextRow(
text = "Select Backup Directory",
label = selectedDirectory ?: "No directory selected",
onClick = callback::onSelectDirectoryClick
)
}
item {
Rows.TextRow(
text = "Create Backup Now",
label = "Enqueue LocalArchiveJob",
onClick = callback::onEnqueueBackupClick
)
}
item {
Rows.TextRow(
text = "Create Status",
label = createStatus
)
}
}
}
}
@DayNightPreviews
@Composable
fun InternalLocalBackupScreenPreview() {
Previews.Preview {
InternalLocalBackupScreen(
backupsEnabled = true,
selectedDirectory = "/storage/emulated/0/Signal/Backups",
lastBackupTimeString = "1 hour ago",
callback = Callback.Empty
)
}
}

View File

@@ -4,19 +4,19 @@
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.LifecycleResumeEffect
@@ -31,6 +31,9 @@ import androidx.navigation3.ui.NavDisplay
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Launchers
import org.signal.core.ui.util.StorageUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyEducationScreen
@@ -39,6 +42,7 @@ import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRec
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyRecordScreen
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsKeyVerifyScreen
import org.thoughtcrime.securesms.keyvalue.SignalStore
import kotlin.time.Duration.Companion.milliseconds
private val TAG = Log.tag(LocalBackupsFragment::class)
@@ -127,6 +131,7 @@ class LocalBackupsFragment : ComposeFragment() {
val state: LocalBackupsKeyState by viewModel.backupState.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
val backupKeyUpdatedMessage = stringResource(R.string.OnDeviceBackupsFragment__backup_key_updated)
var upgradeInProgress by remember { mutableStateOf(false) }
MessageBackupsKeyVerifyScreen(
backupKey = state.accountEntropyPool.displayValue,
@@ -139,7 +144,9 @@ class LocalBackupsFragment : ComposeFragment() {
backstack.removeAll { it != LocalBackupsNavKey.SETTINGS }
scope.launch {
upgradeInProgress = true
viewModel.handleUpgrade(requireContext())
upgradeInProgress = false
snackbarHostState.showSnackbar(
message = backupKeyUpdatedMessage
@@ -147,6 +154,12 @@ class LocalBackupsFragment : ComposeFragment() {
}
}
)
Dialogs.IndeterminateProgressDialog(
visible = upgradeInProgress,
delayDuration = 100.milliseconds,
minimumDisplayDuration = 500.milliseconds
)
}
else -> error("Unknown key: $key")
@@ -158,18 +171,17 @@ class LocalBackupsFragment : ComposeFragment() {
}
@Composable
private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>): ActivityResultLauncher<Intent> {
private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>): ActivityResultLauncher<Uri?> {
val context = LocalContext.current
return rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uri = result.data?.data
if (result.resultCode == Activity.RESULT_OK && uri != null) {
return Launchers.rememberOpenDocumentTreeLauncher { uri ->
if (uri != null) {
Log.i(TAG, "Backup location selected: $uri")
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
backStack.add(LocalBackupsNavKey.YOUR_RECOVERY_KEY)
Toast.makeText(context, context.getString(R.string.OnDeviceBackupsFragment__directory_selected, uri), Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.OnDeviceBackupsFragment__directory_selected, StorageUtil.getDisplayPath(context, uri)), Toast.LENGTH_SHORT).show()
} else {
Log.w(TAG, "Unified backup location selection cancelled or failed")
}

View File

@@ -6,9 +6,7 @@ package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Build
import android.provider.DocumentsContract
import android.net.Uri
import android.text.format.DateFormat
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
@@ -52,7 +50,7 @@ sealed interface LocalBackupsSettingsCallback {
class DefaultLocalBackupsSettingsCallback(
private val fragment: LocalBackupsFragment,
private val chooseBackupLocationLauncher: ActivityResultLauncher<Intent>,
private val chooseBackupLocationLauncher: ActivityResultLauncher<Uri?>,
private val viewModel: LocalBackupsViewModel
) : LocalBackupsSettingsCallback {
@@ -65,22 +63,10 @@ class DefaultLocalBackupsSettingsCallback(
}
override fun onLaunchBackupLocationPickerClick() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (Build.VERSION.SDK_INT >= 26) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, SignalStore.settings.latestSignalBackupDirectory)
}
intent.addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
try {
Log.d(TAG, "Starting choose backup location dialog")
chooseBackupLocationLauncher.launch(intent)
} catch (e: ActivityNotFoundException) {
chooseBackupLocationLauncher.launch(SignalStore.settings.latestSignalBackupDirectory)
} catch (_: ActivityNotFoundException) {
Toast.makeText(fragment.requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
}
}
@@ -112,6 +98,7 @@ class DefaultLocalBackupsSettingsCallback(
override fun onCreateBackupClick() {
if (BackupUtil.isUserSelectionRequired(fragment.requireContext())) {
Log.i(TAG, "Queueing backup...")
viewModel.onBackupStarted()
enqueueArchive(false)
} else {
Permissions.with(fragment)
@@ -119,6 +106,7 @@ class DefaultLocalBackupsSettingsCallback(
.ifNecessary()
.onAllGranted {
Log.i(TAG, "Queuing backup...")
viewModel.onBackupStarted()
enqueueArchive(false)
}
.withPermanentDenialDialog(

View File

@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.content.Context
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
@@ -16,6 +17,7 @@ import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.ui.util.StorageUtil
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.BackupPassphrase
@@ -27,7 +29,6 @@ import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.formatHours
import java.text.NumberFormat
@@ -51,7 +52,7 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
private val internalSettingsState = MutableStateFlow(
LocalBackupsSettingsState(
backupsEnabled = SignalStore.backup.newLocalBackupsEnabled,
folderDisplayName = SignalStore.backup.newLocalBackupsDirectory
folderDisplayName = getDisplayName(AppDependencies.application, SignalStore.backup.newLocalBackupsDirectory)
)
)
@@ -71,7 +72,7 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
viewModelScope.launch {
SignalStore.backup.newLocalBackupsDirectoryFlow.collect { directory ->
internalSettingsState.update { it.copy(folderDisplayName = directory) }
internalSettingsState.update { it.copy(folderDisplayName = getDisplayName(applicationContext, directory)) }
}
}
@@ -96,7 +97,6 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
val clientDeprecated = SignalStore.misc.isClientDeprecated
val legacyLocalBackupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(context)
val canTurnOn = legacyLocalBackupsEnabled || (!userUnregistered && !clientDeprecated)
val isLegacyBackup = !RemoteConfig.unifiedLocalBackups || (SignalStore.settings.isBackupEnabled && !SignalStore.backup.newLocalBackupsEnabled)
if (SignalStore.backup.newLocalBackupsEnabled) {
if (!BackupUtil.canUserAccessUnifiedBackupDirectory(context)) {
@@ -116,6 +116,19 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
}
}
fun onBackupStarted() {
val context = AppDependencies.application
internalSettingsState.update {
it.copy(
progress = BackupProgressState.InProgress(
summary = context.getString(R.string.BackupsPreferenceFragment__in_progress),
percentLabel = context.getString(R.string.BackupsPreferenceFragment__d_so_far, 0),
progressFraction = null
)
)
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onBackupEvent(event: LocalBackupV2Event) {
val context = AppDependencies.application
@@ -155,13 +168,13 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
withContext(Dispatchers.IO) {
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE)
AppDependencies.jobManager.flush()
SignalStore.backup.newLocalBackupsDirectory = SignalStore.settings.signalBackupDirectory?.toString()
BackupPassphrase.set(context, null)
SignalStore.settings.isBackupEnabled = false
BackupUtil.deleteAllBackups()
}
SignalStore.backup.newLocalBackupsDirectory = SignalStore.settings.signalBackupDirectory?.toString()
BackupPassphrase.set(context, null)
SignalStore.settings.isBackupEnabled = false
BackupUtil.deleteAllBackups()
}
SignalStore.backup.newLocalBackupsEnabled = true
@@ -169,6 +182,13 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
}
}
private fun getDisplayName(context: Context, directoryUri: String?): String? {
if (directoryUri == null) {
return null
}
return StorageUtil.getDisplayPath(context, Uri.parse(directoryUri))
}
private fun calculateLastBackupTimeString(context: Context, lastBackupTimestamp: Long): String {
return if (lastBackupTimestamp > 0) {
val relativeTime = DateUtils.getDatelessRelativeTimeSpanFormattedDate(

View File

@@ -190,6 +190,16 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
promptUserForSentTimestamp()
}
)
switchPref(
title = DSLSettingsText.from("Disable internal user flag"),
summary = DSLSettingsText.from("Experience life as a non-internal user. Force-stop the app to be an internal user again."),
isChecked = state.disableInternalUser,
onClick = {
viewModel.setDisableInternalUser(!state.disableInternalUser)
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("App UI"))
@@ -798,6 +808,13 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Add remote backups note"),
onClick = {
viewModel.addSampleReleaseNote("remote_backups")
}
)
clickPref(
title = DSLSettingsText.from("Add remote donate megaphone"),
onClick = {

View File

@@ -42,7 +42,7 @@ class InternalSettingsRepository(context: Context) {
}
}
fun addSampleReleaseNote() {
fun addSampleReleaseNote(callToAction: String) {
SignalExecutors.UNBOUNDED.execute {
AppDependencies.jobManager.runSynchronously(CreateReleaseChannelJob.create(), 5000)
@@ -52,7 +52,7 @@ class InternalSettingsRepository(context: Context) {
val bodyRangeList = BodyRangeList.Builder()
.addStyle(BodyRangeList.BodyRange.Style.BOLD, 0, title.length)
bodyRangeList.addButton("Call to Action Text", "action", body.lastIndex, 0)
bodyRangeList.addButton("Call to Action Text", callToAction, body.lastIndex, 0)
val recipientId = SignalStore.releaseChannel.releaseChannelRecipientId!!
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))

View File

@@ -31,5 +31,6 @@ data class InternalSettingsState(
val hasPendingOneTimeDonation: Boolean,
val hevcEncoding: Boolean,
val forceSplitPane: Boolean,
val useNewMediaActivity: Boolean
val useNewMediaActivity: Boolean,
val disableInternalUser: Boolean
)

View File

@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.keyvalue.InternalValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.livedata.Store
class InternalSettingsViewModel(private val repository: InternalSettingsRepository) : ViewModel() {
@@ -154,8 +155,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun addSampleReleaseNote() {
repository.addSampleReleaseNote()
fun addSampleReleaseNote(callToAction: String = "action") {
repository.addSampleReleaseNote(callToAction)
}
fun addRemoteDonateMegaphone() {
@@ -202,7 +203,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null,
hevcEncoding = SignalStore.internal.hevcEncoding,
forceSplitPane = SignalStore.internal.forceSplitPane,
useNewMediaActivity = SignalStore.internal.useNewMediaActivity
useNewMediaActivity = SignalStore.internal.useNewMediaActivity,
disableInternalUser = RemoteConfig.internalUserDisabled
)
fun onClearOnboardingState() {
@@ -213,6 +215,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
StoryOnboardingDownloadJob.enqueueIfNeeded()
}
fun setDisableInternalUser(disabled: Boolean) {
RemoteConfig.internalUserDisabled = disabled
refresh()
}
fun setForceSplitPane(forceSplitPane: Boolean) {
SignalStore.internal.forceSplitPane = forceSplitPane
refresh()

View File

@@ -84,7 +84,7 @@ import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.BackupValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.ui.restore.local.InternalNewLocalRestoreActivity
import org.thoughtcrime.securesms.registration.ui.restore.local.RestoreLocalBackupActivity
class InternalBackupPlaygroundFragment : ComposeFragment() {
@@ -230,7 +230,7 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
.setTitle("Are you sure?")
.setMessage("After you choose a file to import, this will delete all of your chats, then restore them from the file! Only do this on a test device!")
.setPositiveButton("Wipe and restore") { _, _ ->
startActivity(InternalNewLocalRestoreActivity.getIntent(context, finish = false))
startActivity(RestoreLocalBackupActivity.getIntent(context, finish = false))
}
.show()
},

View File

@@ -310,7 +310,7 @@ class DonateToSignalFragment :
text = DSLSettingsText.from(R.string.SubscribeFragment__cancel_subscription),
isEnabled = state.areFieldsEnabled,
onClick = {
if (state.monthlyDonationState.transactionState.isTransactionJobPending) {
if (state.monthlyDonationState.transactionState.isTransactionJobPending && !state.monthlyDonationState.transactionState.isKeepAlive) {
showDonationPendingDialog(state)
} else {
MaterialAlertDialogBuilder(requireContext())

View File

@@ -139,7 +139,8 @@ data class DonateToSignalState(
data class TransactionState(
val isTransactionJobPending: Boolean = false,
val isLevelUpdateInProgress: Boolean = false
val isLevelUpdateInProgress: Boolean = false,
val isKeepAlive: Boolean = false
) {
val isInProgress: Boolean = isTransactionJobPending || isLevelUpdateInProgress
}

View File

@@ -341,7 +341,7 @@ class DonateToSignalViewModel(
state.copy(
monthlyDonationState = state.monthlyDonationState.copy(
nonVerifiedMonthlyDonation = if (jobStatus is DonationRedemptionJobStatus.PendingExternalVerification) jobStatus.nonVerifiedMonthlyDonation else null,
transactionState = DonateToSignalState.TransactionState(jobStatus.isInProgress(), levelUpdateProcessing)
transactionState = DonateToSignalState.TransactionState(jobStatus.isInProgress(), levelUpdateProcessing, jobStatus is DonationRedemptionJobStatus.PendingKeepAlive)
)
)
}

View File

@@ -10,7 +10,7 @@ object BankDetailsValidator {
private val EMAIL_REGEX: Regex = ".+@.+\\..+".toRegex()
fun validName(name: String): Boolean {
return name.length >= 2
return name.length >= 3
}
fun validEmail(email: String): Boolean {

View File

@@ -312,7 +312,7 @@ private fun BankTransferDetailsContent(
isError = state.showNameError(),
supportingText = {
if (state.showNameError()) {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_2_characters))
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_3_characters))
}
},
modifier = Modifier

View File

@@ -266,7 +266,7 @@ private fun IdealTransferDetailsContent(
isError = state.showNameError(),
supportingText = {
if (state.showNameError()) {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_2_characters))
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_3_characters))
}
},
modifier = Modifier

View File

@@ -34,7 +34,6 @@ object ActiveSubscriptionPreference {
val activeSubscription: ActiveSubscription.Subscription?,
val subscriberRequiresCancel: Boolean,
val onContactSupport: () -> Unit,
val onPendingClick: (FiatMoney) -> Unit,
val onRowClick: (ManageDonationsState.RedemptionState) -> Unit
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
@@ -79,7 +78,7 @@ object ActiveSubscriptionPreference {
when (model.redemptionState) {
ManageDonationsState.RedemptionState.NONE -> presentRenewalState(model)
ManageDonationsState.RedemptionState.IS_PENDING_BANK_TRANSFER -> presentPendingBankTransferState(model)
ManageDonationsState.RedemptionState.IS_PENDING_BANK_TRANSFER -> presentPendingBankTransferState()
ManageDonationsState.RedemptionState.IN_PROGRESS -> presentInProgressState()
ManageDonationsState.RedemptionState.FAILED -> presentFailureState(model)
ManageDonationsState.RedemptionState.SUBSCRIPTION_REFRESH -> presentRefreshState()
@@ -102,10 +101,9 @@ object ActiveSubscriptionPreference {
progress.visible = true
}
private fun presentPendingBankTransferState(model: Model) {
private fun presentPendingBankTransferState() {
expiry.text = context.getString(R.string.MySupportPreference__payment_pending)
progress.visible = true
itemView.setOnClickListener { model.onPendingClick(model.price) }
}
private fun presentInProgressState() {

View File

@@ -294,9 +294,6 @@ class ManageDonationsFragment :
subscriberRequiresCancel = state.subscriberRequiresCancel,
onRowClick = {
launcher.launch(InAppPaymentType.RECURRING_DONATION)
},
onPendingClick = {
displayPendingDialog(it)
}
)
)
@@ -317,7 +314,6 @@ class ManageDonationsFragment :
onContactSupport = {},
activeSubscription = null,
subscriberRequiresCancel = state.subscriberRequiresCancel,
onPendingClick = {},
onRowClick = {}
)
)

View File

@@ -35,10 +35,10 @@ 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.requireParcelableCompat
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.AvatarPreviewActivity
import org.thoughtcrime.securesms.BlockUnblockDialog
import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.PushContactSelectionActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
@@ -69,9 +69,10 @@ import org.thoughtcrime.securesms.components.settings.conversation.preferences.R
import org.thoughtcrime.securesms.components.settings.conversation.preferences.SharedMediaPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.ColorizerV2
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.memberlabel.MemberLabelEducationSheet
import org.thoughtcrime.securesms.groups.memberlabel.StyledMemberLabel
import org.thoughtcrime.securesms.groups.ui.GroupErrors
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog
@@ -119,15 +120,16 @@ private const val REQUEST_CODE_ADD_CONTACT = 2
private const val REQUEST_CODE_ADD_MEMBERS_TO_GROUP = 3
private const val REQUEST_CODE_RETURN_FROM_MEDIA = 4
class ConversationSettingsFragment : DSLSettingsFragment(
layoutId = R.layout.conversation_settings_fragment,
menuId = R.menu.conversation_settings
) {
class ConversationSettingsFragment :
DSLSettingsFragment(
layoutId = R.layout.conversation_settings_fragment,
menuId = R.menu.conversation_settings
) {
private val args: ConversationSettingsFragmentArgs by navArgs()
private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) }
private val alertDisabledTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary_50) }
private val colorizer = Colorizer()
private val colorizer = ColorizerV2()
private val blockIcon by lazy {
ContextUtil.requireDrawable(requireContext(), R.drawable.symbol_block_24).apply {
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
@@ -189,6 +191,16 @@ class ConversationSettingsFragment : DSLSettingsFragment(
super.onViewCreated(view, savedInstanceState)
parentFragmentManager.setFragmentResultListener(MemberLabelEducationSheet.RESULT_EDIT_MEMBER_LABEL, viewLifecycleOwner) { _, bundle ->
val groupId = bundle.requireParcelableCompat(MemberLabelEducationSheet.KEY_GROUP_ID, GroupId.V2::class.java)
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupId))
}
parentFragmentManager.setFragmentResultListener(AboutSheet.RESULT_EDIT_MEMBER_LABEL, viewLifecycleOwner) { _, bundle ->
val groupId = bundle.requireParcelableCompat(AboutSheet.RESULT_GROUP_ID, GroupId.V2::class.java)
navController.safeNavigate(ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupId))
}
recyclerView?.addOnScrollListener(ConversationSettingsOnUserScrolledAnimationHelper(toolbarAvatarContainer, toolbarTitle, toolbarBackground))
}
@@ -469,9 +481,11 @@ class ConversationSettingsFragment : DSLSettingsFragment(
YouAreAlreadyInACallSnackbar.show(requireView())
}
},
onMuteClick = {
onMuteClick = { view ->
if (!state.buttonStripState.isMuted) {
MuteDialog.show(requireContext(), viewModel::setMuteUntil)
MuteContextMenu.show(view, requireView() as ViewGroup, childFragmentManager, viewLifecycleOwner) { duration ->
viewModel.setMuteUntil(duration)
}
} else {
MaterialAlertDialogBuilder(requireContext())
.setMessage(state.recipient.muteUntil.formatMutedUntil(requireContext()))
@@ -578,11 +592,19 @@ class ConversationSettingsFragment : DSLSettingsFragment(
if (!state.recipient.isSelf) {
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__sounds_and_notifications),
title = if (RemoteConfig.internalUser) {
DSLSettingsText.from("${getString(R.string.ConversationSettingsFragment__sounds_and_notifications)} (Internal Only)")
} else {
DSLSettingsText.from(R.string.ConversationSettingsFragment__sounds_and_notifications)
},
icon = DSLSettingsIcon.from(R.drawable.symbol_speaker_24),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(state.recipient.id)
val action = if (RemoteConfig.internalUser) {
ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment2(state.recipient.id)
} else {
ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(state.recipient.id)
}
navController.safeNavigate(action)
}
@@ -739,7 +761,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
customPref(
RecipientPreference.Model(
recipient = group,
onClick = {
onRowClick = {
CommunicationActions.startConversation(requireActivity(), group, null)
requireActivity().finish()
}
@@ -787,13 +809,26 @@ class ConversationSettingsFragment : DSLSettingsFragment(
)
for (member in groupState.members) {
val canSetMemberLabel = member.member.isSelf && groupState.canSetOwnMemberLabel
val memberLabel = member.getMemberLabel(groupState)
customPref(
RecipientPreference.Model(
recipient = member.member,
isAdmin = member.isAdmin,
memberLabel = member.getMemberLabel(groupState),
memberLabel = memberLabel,
canSetMemberLabel = canSetMemberLabel,
lifecycleOwner = viewLifecycleOwner,
onClick = {
onRowClick = {
if (canSetMemberLabel && memberLabel == null) {
val action = ConversationSettingsFragmentDirections
.actionConversationSettingsFragmentToMemberLabelFragment(groupState.groupId)
navController.safeNavigate(action)
} else {
RecipientBottomSheetDialogFragment.show(parentFragmentManager, member.member.id, groupState.groupId)
}
},
onAvatarClick = {
RecipientBottomSheetDialogFragment.show(parentFragmentManager, member.member.id, groupState.groupId)
}
)
@@ -829,7 +864,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__group_member_label),
icon = DSLSettingsIcon.from(R.drawable.symbol_tag_24),
isEnabled = !state.isDeprecatedOrUnregistered,
isEnabled = groupState.canSetOwnMemberLabel && !state.isDeprecatedOrUnregistered,
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToMemberLabelFragment(groupState.groupId)
navController.safeNavigate(action)
@@ -1013,7 +1048,9 @@ class ConversationSettingsFragment : DSLSettingsFragment(
}
private fun showGroupInvitesSentDialog(showGroupInvitesSentDialog: ConversationSettingsEvent.ShowGroupInvitesSentDialog) {
GroupInviteSentDialog.showInvitesSent(requireContext(), viewLifecycleOwner, showGroupInvitesSentDialog.invitesSentTo)
if (showGroupInvitesSentDialog.invitesSentTo.isNotEmpty()) {
GroupInviteSentDialog.show(childFragmentManager, showGroupInvitesSentDialog.invitesSentTo)
}
}
private fun showMembersAdded(showMembersAdded: ConversationSettingsEvent.ShowMembersAdded) {

View File

@@ -84,7 +84,8 @@ sealed class SpecificSettingsState {
val membershipCountDescription: String = "",
val legacyGroupState: LegacyGroupPreference.State = LegacyGroupPreference.State.NONE,
val isAnnouncementGroup: Boolean = false,
val memberLabelsByRecipientId: Map<RecipientId, MemberLabel> = emptyMap()
val memberLabelsByRecipientId: Map<RecipientId, MemberLabel> = emptyMap(),
val canSetOwnMemberLabel: Boolean = false
) : SpecificSettingsState() {
override val isLoaded: Boolean = groupTitleLoaded && groupDescriptionLoaded

View File

@@ -362,6 +362,7 @@ sealed class ConversationSettingsViewModel(
if (groupId.isV2) {
loadMemberLabels(groupId.requireV2(), fullMembers)
loadCanSetMemberLabel(groupId.requireV2())
}
state.copy(
@@ -520,6 +521,17 @@ sealed class ConversationSettingsViewModel(
)
}
}
private fun loadCanSetMemberLabel(groupId: GroupId.V2) = viewModelScope.launch(SignalDispatchers.IO) {
val canSetLabel = MemberLabelRepository.instance.canSetLabel(groupId, Recipient.self())
store.update {
it.copy(
specificSettingsState = it.requireGroupSettingsState().copy(
canSetOwnMemberLabel = canSetLabel
)
)
}
}
}
class Factory(

View File

@@ -0,0 +1,49 @@
package org.thoughtcrime.securesms.components.settings.conversation
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import java.util.concurrent.TimeUnit
object MuteContextMenu {
@JvmStatic
fun show(anchor: View, container: ViewGroup, fragmentManager: FragmentManager, lifecycleOwner: LifecycleOwner, action: (Long) -> Unit): SignalContextMenu {
fragmentManager.setFragmentResultListener(MuteUntilTimePickerBottomSheet.REQUEST_KEY, lifecycleOwner) { _, bundle ->
action(bundle.getLong(MuteUntilTimePickerBottomSheet.RESULT_TIMESTAMP))
}
val context = anchor.context
val actionItems = listOf(
ActionItem(R.drawable.ic_daytime_24, context.getString(R.string.arrays__mute_for_one_hour)) {
action(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1))
},
ActionItem(R.drawable.ic_nighttime_26, context.getString(R.string.arrays__mute_for_eight_hours)) {
action(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(8))
},
ActionItem(R.drawable.symbol_calendar_one, context.getString(R.string.arrays__mute_for_one_day)) {
action(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1))
},
ActionItem(R.drawable.symbol_calendar_week, context.getString(R.string.arrays__mute_for_seven_days)) {
action(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7))
},
ActionItem(R.drawable.symbol_calendar_24, context.getString(R.string.MuteDialog__mute_until)) {
MuteUntilTimePickerBottomSheet.show(fragmentManager)
},
ActionItem(R.drawable.symbol_bell_slash_24, context.getString(R.string.arrays__always)) {
action(Long.MAX_VALUE)
}
)
return SignalContextMenu.Builder(anchor, container)
.offsetX(12.dp)
.offsetY(12.dp)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
.show(actionItems)
}
}

View File

@@ -0,0 +1,272 @@
package org.thoughtcrime.securesms.components.settings.conversation
import android.text.format.DateFormat
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import com.google.android.material.datepicker.CalendarConstraints
import com.google.android.material.datepicker.DateValidatorPointForward
import com.google.android.material.datepicker.MaterialDatePicker
import com.google.android.material.timepicker.MaterialTimePicker
import com.google.android.material.timepicker.TimeFormat
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.atMidnight
import org.thoughtcrime.securesms.util.atUTC
import org.thoughtcrime.securesms.util.formatHours
import org.thoughtcrime.securesms.util.toLocalDateTime
import org.thoughtcrime.securesms.util.toMillis
import java.time.DayOfWeek
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAdjusters
import java.util.Locale
class MuteUntilTimePickerBottomSheet : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.66f
companion object {
const val REQUEST_KEY = "mute_until_result"
const val RESULT_TIMESTAMP = "timestamp"
@JvmStatic
fun show(fragmentManager: FragmentManager) {
val fragment = MuteUntilTimePickerBottomSheet()
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
@Composable
override fun SheetContent() {
val context = LocalContext.current
val now = remember { LocalDateTime.now() }
val defaultDateTime = remember {
if (now.hour < 17) {
now.withHour(17).withMinute(0).withSecond(0).withNano(0)
} else {
val nextMorning = if (now.dayOfWeek == DayOfWeek.FRIDAY || now.dayOfWeek == DayOfWeek.SATURDAY || now.dayOfWeek == DayOfWeek.SUNDAY) {
now.with(TemporalAdjusters.next(DayOfWeek.MONDAY))
} else {
now.plusDays(1)
}
nextMorning.withHour(8).withMinute(0).withSecond(0).withNano(0)
}
}
var selectedDate by remember { mutableLongStateOf(defaultDateTime.toMillis()) }
var selectedHour by remember { mutableIntStateOf(defaultDateTime.hour) }
var selectedMinute by remember { mutableIntStateOf(defaultDateTime.minute) }
val dateText = remember(selectedDate) {
DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), selectedDate)
}
val timeText = remember(selectedHour, selectedMinute) {
LocalTime.of(selectedHour, selectedMinute).formatHours(context)
}
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)
)
}
MuteUntilSheetContent(
dateText = dateText,
timeText = timeText,
timezoneDisclaimer = timezoneDisclaimer,
onDateClick = {
val local = LocalDateTime.now().atMidnight().atUTC().toMillis()
val datePicker = MaterialDatePicker.Builder.datePicker()
.setTitleText(context.getString(R.string.MuteUntilTimePickerBottomSheet__select_date_title))
.setSelection(selectedDate)
.setCalendarConstraints(CalendarConstraints.Builder().setStart(local).setValidator(DateValidatorPointForward.now()).build())
.build()
datePicker.addOnDismissListener {
datePicker.clearOnDismissListeners()
datePicker.clearOnPositiveButtonClickListeners()
}
datePicker.addOnPositiveButtonClickListener {
selectedDate = it.toLocalDateTime(ZoneOffset.UTC).atZone(ZoneId.systemDefault()).toMillis()
}
datePicker.show(childFragmentManager, "DATE_PICKER")
},
onTimeClick = {
val timeFormat = if (DateFormat.is24HourFormat(context)) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H
val timePicker = MaterialTimePicker.Builder()
.setTimeFormat(timeFormat)
.setHour(selectedHour)
.setMinute(selectedMinute)
.setTitleText(context.getString(R.string.MuteUntilTimePickerBottomSheet__select_time_title))
.build()
timePicker.addOnDismissListener {
timePicker.clearOnDismissListeners()
timePicker.clearOnPositiveButtonClickListeners()
}
timePicker.addOnPositiveButtonClickListener {
selectedHour = timePicker.hour
selectedMinute = timePicker.minute
}
timePicker.show(childFragmentManager, "TIME_PICKER")
},
onMuteClick = {
val timestamp = selectedDate.toLocalDateTime()
.withHour(selectedHour)
.withMinute(selectedMinute)
.withSecond(0)
.withNano(0)
.toMillis()
if (timestamp > System.currentTimeMillis()) {
setFragmentResult(REQUEST_KEY, bundleOf(RESULT_TIMESTAMP to timestamp))
dismissAllowingStateLoss()
}
}
)
}
}
@Composable
private fun MuteUntilSheetContent(
dateText: String,
timeText: String,
timezoneDisclaimer: String,
onDateClick: () -> Unit,
onTimeClick: () -> Unit,
onMuteClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
BottomSheets.Handle()
Text(
text = stringResource(R.string.MuteUntilTimePickerBottomSheet__dialog_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 18.dp, bottom = 24.dp)
)
Text(
text = timezoneDisclaimer,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier
.padding(horizontal = 56.dp)
.align(Alignment.Start)
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable(onClick = onDateClick)
) {
Text(
text = dateText,
style = MaterialTheme.typography.bodyLarge
)
Icon(
painter = painterResource(R.drawable.ic_expand_down_24),
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp)
.size(24.dp)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable(onClick = onTimeClick)
) {
Text(
text = timeText,
style = MaterialTheme.typography.bodyLarge
)
Icon(
painter = painterResource(R.drawable.ic_expand_down_24),
contentDescription = null,
modifier = Modifier
.padding(start = 8.dp)
.size(24.dp)
)
}
}
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(start = 18.dp, end = 18.dp, top = 14.dp, bottom = 24.dp)
) {
Buttons.MediumTonal(
onClick = onMuteClick
) {
Text(stringResource(R.string.MuteUntilTimePickerBottomSheet__mute_notifications))
}
}
}
}
@DayNightPreviews
@Composable
private fun MuteUntilSheetContentPreview() {
Previews.BottomSheetContentPreview {
MuteUntilSheetContent(
dateText = "Today",
timeText = "5:00 PM",
timezoneDisclaimer = "All times in (GMT-05:00) Eastern Standard Time",
onDateClick = {},
onTimeClick = {},
onMuteClick = {}
)
}
}

View File

@@ -30,7 +30,7 @@ object ButtonStripPreference {
val onMessageClick: () -> Unit = {},
val onVideoClick: () -> Unit = {},
val onAudioClick: () -> Unit = {},
val onMuteClick: () -> Unit = {},
val onMuteClick: (View) -> Unit = {},
val onSearchClick: () -> Unit = {}
) : PreferenceModel<Model>() {
override fun areContentsTheSame(newItem: Model): Boolean {
@@ -97,7 +97,7 @@ object ButtonStripPreference {
message.setOnClickListener { model.onMessageClick() }
videoCall.setOnClickListener { model.onVideoClick() }
audioCall.setOnClickListener { model.onAudioClick() }
mute.setOnClickListener { model.onMuteClick() }
mute.setOnClickListener { model.onMuteClick(it) }
search.setOnClickListener { model.onSearchClick() }
addToStory.setOnClickListener { model.onAddToStoryClick() }
}

View File

@@ -3,8 +3,6 @@ package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.text.SpannableStringBuilder
import android.view.View
import android.widget.TextView
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
@@ -36,8 +34,10 @@ object RecipientPreference {
val recipient: Recipient,
val isAdmin: Boolean = false,
val memberLabel: StyledMemberLabel? = null,
val canSetMemberLabel: Boolean = false,
val lifecycleOwner: LifecycleOwner? = null,
val onClick: (() -> Unit)? = null
val onRowClick: (() -> Unit)? = null,
val onAvatarClick: (() -> Unit)? = null
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return recipient.id == newItem.recipient.id
@@ -47,7 +47,8 @@ object RecipientPreference {
return super.areContentsTheSame(newItem) &&
recipient.hasSameContent(newItem.recipient) &&
isAdmin == newItem.isAdmin &&
memberLabel == newItem.memberLabel
memberLabel == newItem.memberLabel &&
canSetMemberLabel == newItem.canSetMemberLabel
}
}
@@ -56,28 +57,38 @@ object RecipientPreference {
private val name: TextView = itemView.findViewById(R.id.recipient_name)
private val about: TextView? = itemView.findViewById(R.id.recipient_about)
private val memberLabelView: MemberLabelPillView? = itemView.findViewById(R.id.recipient_member_label)
private val addMemberLabelView: TextView? = itemView.findViewById(R.id.add_member_label)
private val admin: View? = itemView.findViewById(R.id.admin)
private val badge: BadgeImageView = itemView.findViewById(R.id.recipient_badge)
private var recipient: Recipient? = null
private var canSetMemberLabel: Boolean = false
private var memberLabel: StyledMemberLabel? = null
private val recipientObserver = Observer<Recipient> { recipient ->
onRecipientChanged(recipient)
onRecipientChanged(recipient = recipient, memberLabel = memberLabel, canSetMemberLabel = canSetMemberLabel)
}
override fun bind(model: Model) {
if (model.onClick != null) {
itemView.setOnClickListener { model.onClick.invoke() }
if (model.onRowClick != null) {
itemView.setOnClickListener { model.onRowClick.invoke() }
} else {
itemView.setOnClickListener(null)
}
if (model.onAvatarClick != null) {
avatar.setOnClickListener { model.onAvatarClick.invoke() }
} else {
avatar.setOnClickListener(null)
}
canSetMemberLabel = model.canSetMemberLabel
memberLabel = model.memberLabel
if (model.lifecycleOwner != null) {
observeRecipient(model.lifecycleOwner, model.recipient)
model.memberLabel?.let(::showMemberLabel)
} else {
onRecipientChanged(model.recipient, model.memberLabel)
}
onRecipientChanged(model.recipient, model.memberLabel, model.canSetMemberLabel)
admin?.visible = model.isAdmin
}
@@ -86,7 +97,7 @@ object RecipientPreference {
unbind()
}
private fun onRecipientChanged(recipient: Recipient, memberLabel: StyledMemberLabel? = null) {
private fun onRecipientChanged(recipient: Recipient, memberLabel: StyledMemberLabel? = null, canSetMemberLabel: Boolean = false) {
avatar.setRecipient(recipient)
badge.setBadgeFromRecipient(recipient)
name.text = if (recipient.isSelf) {
@@ -104,17 +115,17 @@ object RecipientPreference {
}
}
val aboutText = recipient.combinedAboutAndEmoji
when {
memberLabel != null -> showMemberLabel(memberLabel)
!recipient.combinedAboutAndEmoji.isNullOrEmpty() -> {
about?.text = recipient.combinedAboutAndEmoji
about?.visible = true
memberLabelView?.visible = false
}
recipient.isSelf && canSetMemberLabel -> showAddMemberLabel()
!aboutText.isNullOrBlank() -> showAbout(aboutText)
else -> {
memberLabelView?.visible = false
addMemberLabelView?.visible = false
about?.visible = false
}
}
@@ -122,18 +133,29 @@ object RecipientPreference {
private fun showMemberLabel(styledLabel: StyledMemberLabel) {
memberLabelView?.apply {
style = MemberLabelPillView.Style(
horizontalPadding = 8.dp,
verticalPadding = 2.dp,
textStyle = { MaterialTheme.typography.labelSmall }
)
style = MemberLabelPillView.Style.Compact
setLabel(styledLabel.label, styledLabel.tintColor)
visible = true
}
addMemberLabelView?.visible = false
about?.visible = false
}
private fun showAddMemberLabel() {
addMemberLabelView?.visible = true
memberLabelView?.visible = false
about?.visible = false
}
private fun showAbout(text: String) {
about?.text = text
about?.visible = true
memberLabelView?.visible = false
addMemberLabelView?.visible = false
}
private fun observeRecipient(lifecycleOwner: LifecycleOwner?, recipient: Recipient?) {
this.recipient?.live()?.liveData?.removeObserver(recipientObserver)

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
/**
* Represents all user-driven actions that can occur on the Sounds & Notifications settings screen.
*/
sealed interface SoundsAndNotificationsEvent {
/**
* Mutes notifications for this recipient until the given epoch-millisecond timestamp.
*
* @param muteUntil Epoch-millisecond timestamp after which notifications should resume.
* Use [Long.MAX_VALUE] to mute indefinitely.
*/
data class SetMuteUntil(val muteUntil: Long) : SoundsAndNotificationsEvent
/**
* Clears any active mute, immediately restoring notifications for this recipient.
*/
data object Unmute : SoundsAndNotificationsEvent
/**
* Updates the mention notification setting for this recipient.
* Only relevant for group conversations that support @mentions.
*
* @param setting The new [NotificationSetting] to apply for @mention notifications.
*/
data class SetMentionSetting(val setting: NotificationSetting) : SoundsAndNotificationsEvent
/**
* Updates the call notification setting for this recipient.
* Controls whether incoming calls still produce notifications while the conversation is muted.
*
* @param setting The new [NotificationSetting] to apply for call notifications.
*/
data class SetCallNotificationSetting(val setting: NotificationSetting) : SoundsAndNotificationsEvent
/**
* Updates the reply notification setting for this recipient.
* Controls whether replies directed at the current user still produce notifications while muted.
*
* @param setting The new [NotificationSetting] to apply for reply notifications.
*/
data class SetReplyNotificationSetting(val setting: NotificationSetting) : SoundsAndNotificationsEvent
/**
* Signals that the user tapped the "Custom Notifications" row and wishes to navigate to the
* [custom notifications settings screen][org.thoughtcrime.securesms.components.settings.conversation.sounds.custom.CustomNotificationsSettingsFragment].
*/
data object NavigateToCustomNotifications : SoundsAndNotificationsEvent
}

View File

@@ -16,9 +16,10 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class SoundsAndNotificationsSettingsFragment : DSLSettingsFragment(
titleId = R.string.ConversationSettingsFragment__sounds_and_notifications
) {
class SoundsAndNotificationsSettingsFragment :
DSLSettingsFragment(
titleId = R.string.ConversationSettingsFragment__sounds_and_notifications
) {
private val mentionLabels: Array<String> by lazy {
resources.getStringArray(R.array.SoundsAndNotificationsSettingsFragment__mention_labels)
@@ -66,7 +67,7 @@ class SoundsAndNotificationsSettingsFragment : DSLSettingsFragment(
summary = DSLSettingsText.from(muteSummary),
onClick = {
if (state.muteUntil <= 0) {
MuteDialog.show(requireContext(), viewModel::setMuteUntil)
MuteDialog.show(requireContext(), childFragmentManager, viewLifecycleOwner, viewModel::setMuteUntil)
} else {
MaterialAlertDialogBuilder(requireContext())
.setMessage(muteSummary)
@@ -81,7 +82,7 @@ class SoundsAndNotificationsSettingsFragment : DSLSettingsFragment(
)
if (state.hasMentionsSupport) {
val mentionSelection = if (state.mentionSetting == RecipientTable.MentionSetting.ALWAYS_NOTIFY) {
val mentionSelection = if (state.mentionSetting == RecipientTable.NotificationSetting.ALWAYS_NOTIFY) {
0
} else {
1
@@ -95,9 +96,9 @@ class SoundsAndNotificationsSettingsFragment : DSLSettingsFragment(
onSelected = {
viewModel.setMentionSetting(
if (it == 0) {
RecipientTable.MentionSetting.ALWAYS_NOTIFY
RecipientTable.NotificationSetting.ALWAYS_NOTIFY
} else {
RecipientTable.MentionSetting.DO_NOT_NOTIFY
RecipientTable.NotificationSetting.DO_NOT_NOTIFY
}
)
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.Navigation
import org.signal.core.ui.compose.ComposeFragment
import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class SoundsAndNotificationsSettingsFragment2 : ComposeFragment() {
private val viewModel: SoundsAndNotificationsSettingsViewModel2 by viewModels(
factoryProducer = {
val recipientId = SoundsAndNotificationsSettingsFragment2Args.fromBundle(requireArguments()).recipientId
SoundsAndNotificationsSettingsViewModel2.Factory(recipientId)
}
)
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
if (!state.channelConsistencyCheckComplete || state.recipientId == Recipient.UNKNOWN.id) {
return
}
SoundsAndNotificationsSettingsScreen(
state = state,
formatMuteUntil = { it.formatMutedUntil(requireContext()) },
onEvent = { event ->
when (event) {
is SoundsAndNotificationsEvent.NavigateToCustomNotifications -> {
val action = SoundsAndNotificationsSettingsFragment2Directions
.actionSoundsAndNotificationsSettingsFragment2ToCustomNotificationsSettingsFragment(state.recipientId)
Navigation.findNavController(requireView()).safeNavigate(action)
}
else -> viewModel.onEvent(event)
}
},
onNavigationClick = {
requireActivity().onBackPressedDispatcher.onBackPressed()
},
onMuteClick = {
MuteDialog.show(requireContext(), childFragmentManager, viewLifecycleOwner) { muteUntil ->
viewModel.onEvent(SoundsAndNotificationsEvent.SetMuteUntil(muteUntil))
}
}
)
}
}

View File

@@ -25,7 +25,7 @@ class SoundsAndNotificationsSettingsRepository(private val context: Context) {
}
}
fun setMentionSetting(recipientId: RecipientId, mentionSetting: RecipientTable.MentionSetting) {
fun setMentionSetting(recipientId: RecipientId, mentionSetting: RecipientTable.NotificationSetting) {
SignalExecutors.BOUNDED.execute {
SignalDatabase.recipients.setMentionSetting(recipientId, mentionSetting)
}

View File

@@ -0,0 +1,309 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
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.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Texts
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
import org.signal.core.ui.R as CoreUiR
@Composable
fun SoundsAndNotificationsSettingsScreen(
state: SoundsAndNotificationsSettingsState2,
formatMuteUntil: (Long) -> String,
onEvent: (SoundsAndNotificationsEvent) -> Unit,
onNavigationClick: () -> Unit,
onMuteClick: () -> Unit
) {
val isMuted = state.muteUntil > 0
var showUnmuteDialog by remember { mutableStateOf(false) }
Scaffolds.Settings(
title = stringResource(R.string.ConversationSettingsFragment__sounds_and_notifications),
onNavigationClick = onNavigationClick,
navigationIcon = SignalIcons.ArrowStart.imageVector,
navigationContentDescription = stringResource(R.string.CallScreenTopBar__go_back)
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues)
) {
// Custom notifications
item {
val summary = if (state.hasCustomNotificationSettings) {
stringResource(R.string.preferences_on)
} else {
stringResource(R.string.preferences_off)
}
Rows.TextRow(
text = stringResource(R.string.SoundsAndNotificationsSettingsFragment__custom_notifications),
label = summary,
icon = painterResource(R.drawable.ic_speaker_24),
onClick = { onEvent(SoundsAndNotificationsEvent.NavigateToCustomNotifications) }
)
}
// Mute
item {
val muteSummary = if (isMuted) {
formatMuteUntil(state.muteUntil)
} else {
stringResource(R.string.SoundsAndNotificationsSettingsFragment__not_muted)
}
val muteIcon = if (isMuted) {
R.drawable.ic_bell_disabled_24
} else {
R.drawable.ic_bell_24
}
Rows.TextRow(
text = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mute_notifications),
label = muteSummary,
icon = painterResource(muteIcon),
onClick = {
if (isMuted) showUnmuteDialog = true else onMuteClick()
}
)
}
// Divider + When muted section
item {
Dividers.Default()
}
item {
Texts.SectionHeader(text = stringResource(R.string.SoundsAndNotificationsSettingsFragment__when_muted))
}
// Calls
item {
NotificationSettingRow(
title = stringResource(R.string.SoundsAndNotificationsSettingsFragment__calls),
dialogTitle = stringResource(R.string.SoundsAndNotificationsSettingsFragment__calls),
dialogMessage = stringResource(R.string.SoundsAndNotificationsSettingsFragment__calls_dialog_message),
icon = painterResource(CoreUiR.drawable.symbol_phone_24),
setting = state.callNotificationSetting,
onSelected = { onEvent(SoundsAndNotificationsEvent.SetCallNotificationSetting(it)) }
)
}
// Mentions (only for groups)
if (state.hasMentionsSupport) {
item {
NotificationSettingRow(
title = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mentions),
dialogTitle = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mentions),
dialogMessage = stringResource(R.string.SoundsAndNotificationsSettingsFragment__mentions_dialog_message),
icon = painterResource(R.drawable.ic_at_24),
setting = state.mentionSetting,
onSelected = { onEvent(SoundsAndNotificationsEvent.SetMentionSetting(it)) }
)
}
}
// Replies (only for groups)
if (state.hasMentionsSupport) {
item {
NotificationSettingRow(
title = stringResource(R.string.SoundsAndNotificationsSettingsFragment__replies_to_you),
dialogTitle = stringResource(R.string.SoundsAndNotificationsSettingsFragment__replies_to_you),
dialogMessage = stringResource(R.string.SoundsAndNotificationsSettingsFragment__replies_dialog_message),
icon = painterResource(R.drawable.symbol_reply_24),
setting = state.replyNotificationSetting,
onSelected = { onEvent(SoundsAndNotificationsEvent.SetReplyNotificationSetting(it)) }
)
}
}
}
}
if (showUnmuteDialog) {
Dialogs.SimpleAlertDialog(
title = Dialogs.NoTitle,
body = formatMuteUntil(state.muteUntil),
confirm = stringResource(R.string.ConversationSettingsFragment__unmute),
dismiss = stringResource(android.R.string.cancel),
onConfirm = { onEvent(SoundsAndNotificationsEvent.Unmute) },
onDismiss = { showUnmuteDialog = false }
)
}
}
@Composable
private fun NotificationSettingRow(
title: String,
dialogTitle: String,
dialogMessage: String,
icon: Painter,
setting: NotificationSetting,
onSelected: (NotificationSetting) -> Unit
) {
var showDialog by remember { mutableStateOf(false) }
val labels = arrayOf(
stringResource(R.string.SoundsAndNotificationsSettingsFragment__always_notify),
stringResource(R.string.SoundsAndNotificationsSettingsFragment__do_not_notify)
)
val selectedLabel = if (setting == NotificationSetting.ALWAYS_NOTIFY) labels[0] else labels[1]
Rows.TextRow(
text = title,
label = selectedLabel,
icon = icon,
onClick = { showDialog = true }
)
if (showDialog) {
NotificationSettingDialog(
title = dialogTitle,
message = dialogMessage,
labels = labels,
selectedIndex = if (setting == NotificationSetting.ALWAYS_NOTIFY) 0 else 1,
onDismiss = { showDialog = false },
onSelected = { index ->
onSelected(if (index == 0) NotificationSetting.ALWAYS_NOTIFY else NotificationSetting.DO_NOT_NOTIFY)
showDialog = false
}
)
}
}
@Composable
private fun NotificationSettingDialog(
title: String,
message: String,
labels: Array<String>,
selectedIndex: Int,
onDismiss: () -> Unit,
onSelected: (Int) -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = AlertDialogDefaults.shape,
color = SignalTheme.colors.colorSurface2
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier
.padding(top = 24.dp)
.horizontalGutters()
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(top = 8.dp)
.horizontalGutters()
)
Column(modifier = Modifier.padding(top = 16.dp, bottom = 16.dp)) {
labels.forEachIndexed { index, label ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 48.dp)
.clickable { onSelected(index) }
.horizontalGutters()
) {
RadioButton(
selected = index == selectedIndex,
onClick = { onSelected(index) }
)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp)
)
}
}
}
}
}
}
}
@DayNightPreviews
@Composable
private fun SoundsAndNotificationsSettingsScreenMutedPreview() {
Previews.Preview {
SoundsAndNotificationsSettingsScreen(
state = SoundsAndNotificationsSettingsState2(
muteUntil = Long.MAX_VALUE,
callNotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
mentionSetting = NotificationSetting.ALWAYS_NOTIFY,
replyNotificationSetting = NotificationSetting.DO_NOT_NOTIFY,
hasMentionsSupport = true,
hasCustomNotificationSettings = false,
channelConsistencyCheckComplete = true
),
formatMuteUntil = { "Always" },
onEvent = {},
onNavigationClick = {},
onMuteClick = {}
)
}
}
@DayNightPreviews
@Composable
private fun SoundsAndNotificationsSettingsScreenUnmutedPreview() {
Previews.Preview {
SoundsAndNotificationsSettingsScreen(
state = SoundsAndNotificationsSettingsState2(
muteUntil = 0L,
callNotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
mentionSetting = NotificationSetting.ALWAYS_NOTIFY,
replyNotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
hasMentionsSupport = false,
hasCustomNotificationSettings = true,
channelConsistencyCheckComplete = true
),
formatMuteUntil = { "" },
onEvent = {},
onNavigationClick = {},
onMuteClick = {}
)
}
}

View File

@@ -7,7 +7,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
data class SoundsAndNotificationsSettingsState(
val recipientId: RecipientId = Recipient.UNKNOWN.id,
val muteUntil: Long = 0L,
val mentionSetting: RecipientTable.MentionSetting = RecipientTable.MentionSetting.DO_NOT_NOTIFY,
val mentionSetting: RecipientTable.NotificationSetting = RecipientTable.NotificationSetting.DO_NOT_NOTIFY,
val hasCustomNotificationSettings: Boolean = false,
val hasMentionsSupport: Boolean = false,
val channelConsistencyCheckComplete: Boolean = false

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
data class SoundsAndNotificationsSettingsState2(
val recipientId: RecipientId = Recipient.UNKNOWN.id,
val muteUntil: Long = 0L,
val mentionSetting: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
val callNotificationSetting: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
val replyNotificationSetting: NotificationSetting = NotificationSetting.ALWAYS_NOTIFY,
val hasCustomNotificationSettings: Boolean = false,
val hasMentionsSupport: Boolean = false,
val channelConsistencyCheckComplete: Boolean = false
) {
val isMuted = muteUntil > 0
}

View File

@@ -38,7 +38,7 @@ class SoundsAndNotificationsSettingsViewModel(
repository.setMuteUntil(recipientId, 0L)
}
fun setMentionSetting(mentionSetting: RecipientTable.MentionSetting) {
fun setMentionSetting(mentionSetting: RecipientTable.NotificationSetting) {
repository.setMentionSetting(recipientId, mentionSetting)
}

View File

@@ -0,0 +1,102 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.database.RecipientTable.NotificationSetting
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver
import org.thoughtcrime.securesms.recipients.RecipientId
class SoundsAndNotificationsSettingsViewModel2(
private val recipientId: RecipientId
) : ViewModel(), RecipientForeverObserver {
private val _state = MutableStateFlow(SoundsAndNotificationsSettingsState2())
val state: StateFlow<SoundsAndNotificationsSettingsState2> = _state
private val liveRecipient = Recipient.live(recipientId)
init {
liveRecipient.observeForever(this)
onRecipientChanged(liveRecipient.get())
viewModelScope.launch(Dispatchers.IO) {
if (NotificationChannels.supported()) {
NotificationChannels.getInstance().ensureCustomChannelConsistency()
}
_state.update { it.copy(channelConsistencyCheckComplete = true) }
}
}
override fun onRecipientChanged(recipient: Recipient) {
_state.update {
it.copy(
recipientId = recipientId,
muteUntil = if (recipient.isMuted) recipient.muteUntil else 0L,
mentionSetting = recipient.mentionSetting,
callNotificationSetting = recipient.callNotificationSetting,
replyNotificationSetting = recipient.replyNotificationSetting,
hasMentionsSupport = recipient.isPushV2Group,
hasCustomNotificationSettings = recipient.notificationChannel != null || !NotificationChannels.supported()
)
}
}
override fun onCleared() {
liveRecipient.removeForeverObserver(this)
}
fun onEvent(event: SoundsAndNotificationsEvent) {
when (event) {
is SoundsAndNotificationsEvent.SetMuteUntil -> applySetMuteUntil(event.muteUntil)
is SoundsAndNotificationsEvent.Unmute -> applySetMuteUntil(0L)
is SoundsAndNotificationsEvent.SetMentionSetting -> applySetMentionSetting(event.setting)
is SoundsAndNotificationsEvent.SetCallNotificationSetting -> applySetCallNotificationSetting(event.setting)
is SoundsAndNotificationsEvent.SetReplyNotificationSetting -> applySetReplyNotificationSetting(event.setting)
is SoundsAndNotificationsEvent.NavigateToCustomNotifications -> Unit // Navigation handled by UI
}
}
private fun applySetMuteUntil(muteUntil: Long) {
viewModelScope.launch(Dispatchers.IO) {
SignalDatabase.recipients.setMuted(recipientId, muteUntil)
}
}
private fun applySetMentionSetting(setting: NotificationSetting) {
viewModelScope.launch(Dispatchers.IO) {
SignalDatabase.recipients.setMentionSetting(recipientId, setting)
}
}
private fun applySetCallNotificationSetting(setting: NotificationSetting) {
viewModelScope.launch(Dispatchers.IO) {
SignalDatabase.recipients.setCallNotificationSetting(recipientId, setting)
}
}
private fun applySetReplyNotificationSetting(setting: NotificationSetting) {
viewModelScope.launch(Dispatchers.IO) {
SignalDatabase.recipients.setReplyNotificationSetting(recipientId, setting)
}
}
class Factory(private val recipientId: RecipientId) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(SoundsAndNotificationsSettingsViewModel2(recipientId)))
}
}
}

View File

@@ -69,6 +69,7 @@ import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.RemoteConfig
/**
* Renders information about a call (1:1, group, or call link) and provides actions available for
@@ -114,6 +115,12 @@ object CallInfoView {
onShareLinkClicked = callbacks::onShareLinkClicked,
onEditNameClicked = onEditNameClicked,
onBlock = callbacks::onBlock,
onMuteAudio = callbacks::onMuteAudio,
onRemoveFromCall = callbacks::onRemoveFromCall,
onContactDetails = callbacks::onContactDetails,
onViewSafetyNumber = callbacks::onViewSafetyNumber,
onGoToChat = callbacks::onGoToChat,
isInternalUser = RemoteConfig.internalUser,
modifier = modifier
)
}
@@ -122,6 +129,11 @@ object CallInfoView {
fun onShareLinkClicked()
fun onEditNameClicked(name: String)
fun onBlock(callParticipant: CallParticipant)
fun onMuteAudio(callParticipant: CallParticipant)
fun onRemoveFromCall(callParticipant: CallParticipant)
fun onContactDetails(callParticipant: CallParticipant)
fun onViewSafetyNumber(callParticipant: CallParticipant)
fun onGoToChat(callParticipant: CallParticipant)
}
}
@@ -135,7 +147,12 @@ private fun CallInfoPreview() {
controlAndInfoState = ControlAndInfoState(),
onShareLinkClicked = { },
onEditNameClicked = { },
onBlock = { }
onBlock = { },
onMuteAudio = { },
onRemoveFromCall = { },
onContactDetails = { },
onViewSafetyNumber = { },
onGoToChat = { }
)
}
}
@@ -147,8 +164,15 @@ private fun CallInfo(
onShareLinkClicked: () -> Unit,
onEditNameClicked: () -> Unit,
onBlock: (CallParticipant) -> Unit,
onMuteAudio: (CallParticipant) -> Unit = {},
onRemoveFromCall: (CallParticipant) -> Unit = {},
onContactDetails: (CallParticipant) -> Unit = {},
onViewSafetyNumber: (CallParticipant) -> Unit = {},
onGoToChat: (CallParticipant) -> Unit = {},
isInternalUser: Boolean = false,
modifier: Modifier = Modifier
) {
var selectedParticipant by remember { mutableStateOf<CallParticipant?>(null) }
val listState = rememberLazyListState()
LaunchedEffect(controlAndInfoState.resetScrollState) {
@@ -252,7 +276,16 @@ private fun CallInfo(
CallParticipantRow(
callParticipant = it,
isSelfAdmin = controlAndInfoState.isSelfAdmin() && !participantsState.inCallLobby,
onBlockClicked = onBlock
onBlockClicked = onBlock,
onParticipantClicked = if (isInternalUser) {
{ participant ->
if (!participant.recipient.isSelf) {
selectedParticipant = participant
}
}
} else {
null
}
)
}
@@ -312,6 +345,20 @@ private fun CallInfo(
Spacer(modifier = Modifier.size(48.dp))
}
}
selectedParticipant?.let { participant ->
ParticipantActionsSheet(
callParticipant = participant,
isSelfAdmin = controlAndInfoState.isSelfAdmin(),
isCallLink = controlAndInfoState.callLink != null,
onDismiss = { selectedParticipant = null },
onMuteAudio = onMuteAudio,
onRemoveFromCall = onRemoveFromCall,
onContactDetails = onContactDetails,
onViewSafetyNumber = onViewSafetyNumber,
onGoToChat = onGoToChat
)
}
}
@Composable
@@ -336,9 +383,10 @@ private fun CallParticipantRowPreview() {
Previews.Preview {
Surface {
CallParticipantRow(
CallParticipant(recipient = Recipient(isResolving = false, systemContactName = "Miles Morales")),
isSelfAdmin = true
) {}
callParticipant = CallParticipant(recipient = Recipient(isResolving = false, systemContactName = "Miles Morales")),
isSelfAdmin = true,
onBlockClicked = {}
)
}
}
}
@@ -357,7 +405,8 @@ private fun HandRaisedRowPreview() {
private fun CallParticipantRow(
callParticipant: CallParticipant,
isSelfAdmin: Boolean,
onBlockClicked: (CallParticipant) -> Unit
onBlockClicked: (CallParticipant) -> Unit,
onParticipantClicked: ((CallParticipant) -> Unit)? = null
) {
CallParticipantRow(
initialRecipient = callParticipant.recipient,
@@ -368,7 +417,12 @@ private fun CallParticipantRow(
showHandRaised = false,
canLowerHand = false,
isSelfAdmin = isSelfAdmin,
onBlockClicked = { onBlockClicked(callParticipant) }
onBlockClicked = { onBlockClicked(callParticipant) },
onRowClicked = if (onParticipantClicked != null && !callParticipant.recipient.isSelf) {
{ onParticipantClicked(callParticipant) }
} else {
null
}
)
}
@@ -396,14 +450,22 @@ private fun CallParticipantRow(
isMicrophoneEnabled: Boolean,
showHandRaised: Boolean,
canLowerHand: Boolean,
isSelfAdmin: Boolean,
onBlockClicked: () -> Unit
isSelfAdmin: Boolean = false,
onBlockClicked: () -> Unit = {},
onRowClicked: (() -> Unit)? = null
) {
Row(
modifier = Modifier
val rowModifier = if (onRowClicked != null) {
Modifier
.fillMaxWidth()
.clickable(onClick = onRowClicked)
.padding(Rows.defaultPadding())
} else {
Modifier
.fillMaxWidth()
.padding(Rows.defaultPadding())
) {
}
Row(modifier = rowModifier) {
val recipient by ((if (LocalInspectionMode.current) Observable.just(Recipient.UNKNOWN) else Recipient.observable(initialRecipient.id)))
.toFlowable(BackpressureStrategy.LATEST)
.toLiveData()
@@ -512,8 +574,9 @@ private fun GroupMemberRow(
isMicrophoneEnabled = false,
showHandRaised = false,
canLowerHand = false,
isSelfAdmin = isSelfAdmin
) {}
isSelfAdmin = isSelfAdmin,
onBlockClicked = {}
)
}
@Composable

View File

@@ -11,9 +11,10 @@ import org.thoughtcrime.securesms.database.CallLinkTable
@Immutable
data class ControlAndInfoState(
val callLink: CallLinkTable.CallLink? = null,
val isGroupAdmin: Boolean = false,
val resetScrollState: Long = 0
) {
fun isSelfAdmin(): Boolean {
return callLink?.credentials?.adminPassBytes != null
return callLink?.credentials?.adminPassBytes != null || isGroupAdmin
}
}

View File

@@ -13,9 +13,12 @@ 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 io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsRepository
import org.thoughtcrime.securesms.database.GroupTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
@@ -42,6 +45,16 @@ class ControlsAndInfoViewModel(
disposables += CallLinks.watchCallLink(recipient.requireCallLinkRoomId()).subscribeBy {
_state.value = _state.value.copy(callLink = it)
}
} else if (recipient.isGroup && callRecipientId != recipient.id) {
callRecipientId = recipient.id
disposables += Single.fromCallable {
val groupRecord = SignalDatabase.groups.getGroup(recipient.requireGroupId())
groupRecord.isPresent && groupRecord.get().memberLevel(Recipient.self()) == GroupTable.MemberLevel.ADMINISTRATOR
}
.subscribeOn(Schedulers.io())
.subscribeBy { isAdmin ->
_state.value = _state.value.copy(isGroupAdmin = isAdmin)
}
}
}

View File

@@ -0,0 +1,235 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.controls
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.toLiveData
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Observable
import org.signal.core.ui.compose.AllNightPreviews
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.recipients.Recipient
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ParticipantActionsSheet(
callParticipant: CallParticipant,
isSelfAdmin: Boolean,
isCallLink: Boolean,
onDismiss: () -> Unit,
onMuteAudio: (CallParticipant) -> Unit,
onRemoveFromCall: (CallParticipant) -> Unit,
onContactDetails: (CallParticipant) -> Unit,
onViewSafetyNumber: (CallParticipant) -> Unit,
onGoToChat: (CallParticipant) -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
val recipient by (
(if (LocalInspectionMode.current) Observable.just(Recipient.UNKNOWN) else Recipient.observable(callParticipant.recipient.id))
.toFlowable(BackpressureStrategy.LATEST)
.toLiveData()
.observeAsState(initial = callParticipant.recipient)
)
ParticipantActionsSheetContent(
recipient = recipient,
callParticipant = callParticipant,
isSelfAdmin = isSelfAdmin,
isCallLink = isCallLink,
onDismiss = onDismiss,
onMuteAudio = onMuteAudio,
onRemoveFromCall = onRemoveFromCall,
onContactDetails = onContactDetails,
onViewSafetyNumber = onViewSafetyNumber,
onGoToChat = onGoToChat
)
}
}
@Composable
private fun ParticipantActionsSheetContent(
recipient: Recipient,
callParticipant: CallParticipant,
isSelfAdmin: Boolean,
isCallLink: Boolean,
onDismiss: () -> Unit,
onMuteAudio: (CallParticipant) -> Unit,
onRemoveFromCall: (CallParticipant) -> Unit,
onContactDetails: (CallParticipant) -> Unit,
onViewSafetyNumber: (CallParticipant) -> Unit,
onGoToChat: (CallParticipant) -> Unit
) {
ParticipantHeader(recipient = recipient)
val hasAdminActions = isSelfAdmin && (callParticipant.isMicrophoneEnabled || isCallLink)
if (hasAdminActions) {
Dividers.Default()
if (callParticipant.isMicrophoneEnabled) {
Rows.TextRow(
text = stringResource(id = R.string.CallParticipantSheet__mute_audio),
icon = painterResource(id = R.drawable.symbol_mic_slash_24),
onClick = {
onMuteAudio(callParticipant)
onDismiss()
}
)
}
if (isCallLink) {
Rows.TextRow(
text = stringResource(id = R.string.CallParticipantSheet__remove_from_call),
icon = painterResource(id = R.drawable.symbol_minus_circle_24),
onClick = {
onRemoveFromCall(callParticipant)
onDismiss()
}
)
}
}
Dividers.Default()
Rows.TextRow(
text = stringResource(id = R.string.CallParticipantSheet__contact_details),
icon = painterResource(id = R.drawable.symbol_person_24),
onClick = {
onContactDetails(callParticipant)
onDismiss()
}
)
Rows.TextRow(
text = stringResource(id = R.string.ConversationSettingsFragment__view_safety_number),
icon = painterResource(id = R.drawable.symbol_safety_number_24),
onClick = {
onViewSafetyNumber(callParticipant)
onDismiss()
}
)
Rows.TextRow(
text = stringResource(id = R.string.CallContextMenu__go_to_chat),
icon = painterResource(id = R.drawable.symbol_open_24),
onClick = {
onGoToChat(callParticipant)
onDismiss()
}
)
Spacer(modifier = Modifier.size(48.dp))
}
@Composable
private fun ParticipantHeader(recipient: Recipient) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
if (LocalInspectionMode.current) {
Spacer(modifier = Modifier.size(64.dp))
} else {
AndroidView(
factory = ::AvatarImageView,
modifier = Modifier.size(64.dp)
) {
it.setAvatarUsingProfile(recipient)
}
}
Spacer(modifier = Modifier.size(12.dp))
Text(
text = recipient.getDisplayName(androidx.compose.ui.platform.LocalContext.current),
style = MaterialTheme.typography.titleLarge
)
val e164 = recipient.e164
if (e164.isPresent) {
Spacer(modifier = Modifier.size(2.dp))
Text(
text = e164.get(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@AllNightPreviews
@Composable
private fun ParticipantActionsSheetAdminPreview() {
Previews.BottomSheetPreview {
ParticipantActionsSheetContent(
recipient = Recipient(isResolving = false, systemContactName = "Peter Parker"),
callParticipant = CallParticipant(
recipient = Recipient(isResolving = false, systemContactName = "Peter Parker"),
isMicrophoneEnabled = true
),
isSelfAdmin = true,
isCallLink = true,
onDismiss = {},
onMuteAudio = {},
onRemoveFromCall = {},
onContactDetails = {},
onViewSafetyNumber = {},
onGoToChat = {}
)
}
}
@AllNightPreviews
@Composable
private fun ParticipantActionsSheetNonAdminPreview() {
Previews.BottomSheetPreview {
ParticipantActionsSheetContent(
recipient = Recipient(isResolving = false, systemContactName = "Gwen Stacy"),
callParticipant = CallParticipant(
recipient = Recipient(isResolving = false, systemContactName = "Gwen Stacy")
),
isSelfAdmin = false,
isCallLink = false,
onDismiss = {},
onMuteAudio = {},
onRemoveFromCall = {},
onContactDetails = {},
onViewSafetyNumber = {},
onGoToChat = {}
)
}
}

View File

@@ -216,6 +216,16 @@ data class CallControlsState(
val startCallButtonText: Int = R.string.WebRtcCallView__start_call,
val displayEndCallButton: Boolean = false
) {
val hasAnyControls: Boolean
get() = displayAudioOutputToggle ||
displayVideoToggle ||
displayMicToggle ||
displayGroupRingingToggle ||
displayAdditionalActions ||
displayStartCallButton ||
displayEndCallButton
companion object {
/**
* Presentation-level method to build out the controls state from legacy objects.

View File

@@ -16,11 +16,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints
@@ -35,6 +35,31 @@ import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Mutable holder for bar dimensions, used to pass measurement results from
* BlurrableContentLayer to PipLayer during the same Layout measurement pass
* without requiring SubcomposeLayout.
*/
private class BarDimensions {
var heightPx: Int = 0
var widthPx: Int = 0
}
/**
* Arranges call screen content in coordinated layers so the local PiP can avoid centered bars.
*
* @param callGridSlot Main call grid content.
* @param pictureInPictureSlot Local participant PiP content.
* @param reactionsSlot Reactions overlay content.
* @param raiseHandSlot Slot for the raised-hand bar.
* @param callLinkBarSlot Slot for the call-link bar.
* @param callOverflowSlot Overflow participants strip.
* @param audioIndicatorSlot Participant audio indicator content.
* @param bottomInset Bottom inset used to keep content clear of anchored UI.
* @param bottomSheetWidth Maximum width of centered bottom content.
* @param localRenderState Current local renderer mode.
* @param modifier Modifier applied to the root layout.
*/
@Composable
fun CallElementsLayout(
callGridSlot: @Composable () -> Unit,
@@ -71,48 +96,46 @@ fun CallElementsLayout(
bottomSheetWidth.roundToPx()
}
SubcomposeLayout(modifier = modifier) { constraints ->
// Holder to capture measurements from BlurrableContentLayer
var measuredBarsHeightPx = 0
var measuredBarsWidthPx = 0
val barDimensions = remember { BarDimensions() }
// Subcompose and measure the blurrable layer first - it will measure all content internally
// and report back the bars dimensions via the onMeasured callback
val blurrableLayerPlaceable = subcompose("blurrable") {
BlurrableContentLayer(
isFocused = isFocused,
isPortrait = isPortrait,
bottomInsetPx = bottomInsetPx,
bottomSheetWidthPx = bottomSheetWidthPx,
barsSlot = { Bars() },
callGridSlot = callGridSlot,
reactionsSlot = reactionsSlot,
callOverflowSlot = callOverflowSlot,
audioIndicatorSlot = audioIndicatorSlot,
onMeasured = { barsHeight, barsWidth ->
measuredBarsHeightPx = barsHeight
measuredBarsWidthPx = barsWidth
}
)
}.map { it.measure(constraints) }
// Use the wider of bars or bottom sheet for space calculation
val centeredContentWidthPx = maxOf(measuredBarsWidthPx, bottomSheetWidthPx)
val pipLayerPlaceable = subcompose("pip") {
PipLayer(
pictureInPictureSlot = pictureInPictureSlot,
localRenderState = localRenderState,
bottomInsetPx = bottomInsetPx,
barsHeightPx = measuredBarsHeightPx,
pipSizePx = pipSizePx,
centeredContentWidthPx = centeredContentWidthPx
)
}.map { it.measure(constraints) }
Layout(
contents = listOf(
{
BlurrableContentLayer(
isFocused = isFocused,
isPortrait = isPortrait,
bottomInsetPx = bottomInsetPx,
bottomSheetWidthPx = bottomSheetWidthPx,
barsSlot = { Bars() },
callGridSlot = callGridSlot,
reactionsSlot = reactionsSlot,
callOverflowSlot = callOverflowSlot,
audioIndicatorSlot = audioIndicatorSlot,
onMeasured = { barsHeight, barsWidth ->
barDimensions.heightPx = barsHeight
barDimensions.widthPx = barsWidth
}
)
},
{
PipLayer(
pictureInPictureSlot = pictureInPictureSlot,
localRenderState = localRenderState,
bottomInsetPx = bottomInsetPx,
barDimensions = barDimensions,
pipSizePx = pipSizePx,
bottomSheetWidthPx = bottomSheetWidthPx
)
}
),
modifier = modifier
) { (blurrableMeasurables, pipMeasurables), constraints ->
val blurrablePlaceables = blurrableMeasurables.map { it.measure(constraints) }
val pipPlaceables = pipMeasurables.map { it.measure(constraints) }
layout(constraints.maxWidth, constraints.maxHeight) {
blurrableLayerPlaceable.forEach { it.place(0, 0) }
pipLayerPlaceable.forEach { it.place(0, 0) }
blurrablePlaceables.forEach { it.place(0, 0) }
pipPlaceables.forEach { it.place(0, 0) }
}
}
}
@@ -169,7 +192,6 @@ private fun BlurrableContentLayer(
nonOverflowConstraints
}
// Cap bars width to sheet max width (bars can be narrower if content doesn't fill)
val barsMaxWidth = minOf(barConstraints.maxWidth, bottomSheetWidthPx)
val barsConstrainedToSheet = barConstraints.copy(maxWidth = barsMaxWidth)
@@ -177,7 +199,6 @@ private fun BlurrableContentLayer(
val barsHeightPx = barsPlaceables.sumOf { it.height }
val barsWidthPx = barsPlaceables.maxOfOrNull { it.width } ?: 0
// Report measurements to parent for PipLayer positioning
onMeasured(barsHeightPx, barsWidthPx)
val reactionsConstraints = barConstraints.offset(vertical = -barsHeightPx)
@@ -229,9 +250,9 @@ private fun PipLayer(
pictureInPictureSlot: @Composable () -> Unit,
localRenderState: WebRtcLocalRenderState,
bottomInsetPx: Int,
barsHeightPx: Int,
barDimensions: BarDimensions,
pipSizePx: Size,
centeredContentWidthPx: Int
bottomSheetWidthPx: Int
) {
Layout(
content = pictureInPictureSlot,
@@ -239,13 +260,14 @@ private fun PipLayer(
) { measurables, constraints ->
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val centeredContentWidthPx = maxOf(barDimensions.widthPx, bottomSheetWidthPx)
val pictureInPictureConstraints: Constraints = when (localRenderState) {
WebRtcLocalRenderState.GONE, WebRtcLocalRenderState.SMALLER_RECTANGLE, WebRtcLocalRenderState.LARGE, WebRtcLocalRenderState.LARGE_NO_VIDEO, WebRtcLocalRenderState.FOCUSED -> constraints
WebRtcLocalRenderState.SMALL_RECTANGLE, WebRtcLocalRenderState.EXPANDED -> {
// Check if there's enough space on either side of the centered content (bars/sheet)
val spaceOnEachSide = (looseConstraints.maxWidth - centeredContentWidthPx) / 2
val shouldOffset = centeredContentWidthPx > 0 && spaceOnEachSide < pipSizePx.width
val offsetAmount = bottomInsetPx + barsHeightPx
val offsetAmount = bottomInsetPx + barDimensions.heightPx
looseConstraints.offset(vertical = if (shouldOffset) -offsetAmount else 0)
}
}

View File

@@ -530,6 +530,9 @@ fun <T> CallGrid(
val hasExistingItems = knownKeys.isNotEmpty()
newKeys.forEach { key ->
if (exitingItems.any { it.key == key }) {
exitingItems = exitingItems.filterNot { it.key == key }
}
if (hasExistingItems) {
alphaAnimatables[key] = Animatable(0f)
scaleAnimatables[key] = Animatable(CallGridDefaults.ENTER_SCALE_START)
@@ -569,7 +572,9 @@ fun <T> CallGrid(
launch { scaleAnimatables[key]?.animateTo(CallGridDefaults.EXIT_SCALE_END, CallGridDefaults.scaleAnimationSpec) }
}
exitingItems = exitingItems.filterNot { it.key == key }
removeAnimationState(key)
if (key !in knownKeys) {
removeAnimationState(key)
}
}
} else {
removeAnimationState(key)

View File

@@ -15,10 +15,13 @@ 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.webrtc.controls.CallInfoView
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity
/**
* Callbacks for the CallInfoView, shared between CallActivity and ControlsAndInfoController.
@@ -60,4 +63,34 @@ class CallInfoCallbacks(
}
.show()
}
override fun onMuteAudio(callParticipant: CallParticipant) {
AppDependencies.signalCallManager.sendRemoteMuteRequest(callParticipant)
}
override fun onRemoveFromCall(callParticipant: CallParticipant) {
MaterialAlertDialogBuilder(activity)
.setNegativeButton(android.R.string.cancel, null)
.setMessage(activity.resources.getString(R.string.CallLinkInfoSheet__remove_s_from_the_call, callParticipant.recipient.getShortDisplayName(activity)))
.setPositiveButton(R.string.CallLinkInfoSheet__remove) { _, _ ->
AppDependencies.signalCallManager.removeFromCallLink(callParticipant)
}
.setNeutralButton(R.string.CallLinkInfoSheet__block_from_call) { _, _ ->
AppDependencies.signalCallManager.blockFromCallLink(callParticipant)
}
.show()
}
override fun onContactDetails(callParticipant: CallParticipant) {
activity.startActivity(ConversationSettingsActivity.forRecipient(activity, callParticipant.recipient.id))
}
override fun onViewSafetyNumber(callParticipant: CallParticipant) {
val identityRecord = AppDependencies.protocolStore.aci().identities().getIdentityRecord(callParticipant.recipient.id)
VerifyIdentityActivity.startOrShowExchangeMessagesDialog(activity, identityRecord.orElse(null))
}
override fun onGoToChat(callParticipant: CallParticipant) {
CommunicationActions.startConversation(activity, callParticipant.recipient, null)
}
}

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.statusBarsPadding
@@ -15,7 +16,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import org.signal.core.ui.compose.AllNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
@@ -28,12 +31,17 @@ import org.thoughtcrime.securesms.recipients.RecipientId
fun CallParticipantsPager(
callParticipantsPagerState: CallParticipantsPagerState,
pagerState: PagerState,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onTap: (() -> Unit)? = null,
onParticipantLongPress: ((CallParticipant) -> Unit)? = null
) {
if (callParticipantsPagerState.focusedParticipant == null) {
return
}
val currentOnTap = rememberUpdatedState(onTap)
val currentOnLongPress = rememberUpdatedState(onParticipantLongPress)
val firstParticipantAR = rememberParticipantAspectRatio(
callParticipantsPagerState.callParticipants.firstOrNull()?.videoSink
)
@@ -48,13 +56,24 @@ fun CallParticipantsPager(
modifier = mod,
itemKey = { it.callParticipantId }
) { participant, itemModifier ->
val longPressModifier = if (!participant.recipient.isSelf && currentOnLongPress.value != null) {
itemModifier.pointerInput(participant.callParticipantId) {
detectTapGestures(
onTap = { currentOnTap.value?.invoke() },
onLongPress = { currentOnLongPress.value?.invoke(participant) }
)
}
} else {
itemModifier
}
RemoteParticipantContent(
participant = participant,
renderInPip = state.isRenderInPip,
raiseHandAllowed = false,
onInfoMoreInfoClick = null,
showAudioIndicator = state.callParticipants.size > 1,
modifier = itemModifier
modifier = longPressModifier
)
}
}

View File

@@ -14,6 +14,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -25,7 +26,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetValue
@@ -35,6 +39,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
@@ -48,6 +53,8 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -57,6 +64,7 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.TriggerAlignedPopupState
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiStrings
import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState
import org.thoughtcrime.securesms.components.webrtc.controls.RaiseHandSnackbar
@@ -118,7 +126,15 @@ fun CallScreen(
onCallScreenDialogDismissed: () -> Unit = {},
onWifiToCellularPopupDismissed: () -> Unit = {},
onSwipeToSpeakerHintDismissed: () -> Unit = {},
onRemoteMuteToastDismissed: () -> Unit = {}
onRemoteMuteToastDismissed: () -> Unit = {},
isInternalUser: Boolean = false,
isSelfAdmin: Boolean = false,
isCallLink: Boolean = false,
onMuteAudio: (CallParticipant) -> Unit = {},
onRemoveFromCall: (CallParticipant) -> Unit = {},
onContactDetails: (CallParticipant) -> Unit = {},
onViewSafetyNumber: (CallParticipant) -> Unit = {},
onGoToChat: (CallParticipant) -> Unit = {}
) {
if (webRtcCallState == WebRtcViewModel.State.CALL_INCOMING) {
IncomingCallScreen(
@@ -180,11 +196,12 @@ fun CallScreen(
val maxOffset = maxHeight - maxSheetHeight
var peekHeight by remember { mutableFloatStateOf(88f) }
val effectivePeekHeight = if (callControlsState.hasAnyControls) peekHeight else 0f
BottomSheetScaffold(
scaffoldState = callScreenController.scaffoldState,
sheetDragHandle = null,
sheetPeekHeight = peekHeight.dp,
sheetPeekHeight = effectivePeekHeight.dp,
sheetContainerColor = SignalTheme.colors.colorSurface1,
containerColor = Color.Black,
sheetMaxWidth = CallScreenMetrics.SheetMaxWidth,
@@ -311,22 +328,53 @@ fun CallScreen(
)
}
} else if (webRtcCallState.isPassedPreJoin) {
var longPressedParticipantId by remember { mutableStateOf<CallParticipantId?>(null) }
val longPressedParticipant = longPressedParticipantId?.let { id ->
callParticipantsPagerState.callParticipants.find { it.callParticipantId == id }
}
CallElementsLayout(
callGridSlot = {
CallParticipantsPager(
callParticipantsPagerState = callParticipantsPagerState,
pagerState = callScreenController.callParticipantsVerticalPagerState,
modifier = Modifier
.fillMaxSize()
.clickable(
onClick = {
Box {
CallParticipantsPager(
callParticipantsPagerState = callParticipantsPagerState,
pagerState = callScreenController.callParticipantsVerticalPagerState,
modifier = Modifier
.fillMaxSize()
.clickable(
onClick = {
scope.launch {
callScreenController.handleEvent(CallScreenController.Event.TOGGLE_CONTROLS)
}
},
enabled = !callControlsState.skipHiddenState
),
onTap = {
if (!callControlsState.skipHiddenState) {
scope.launch {
callScreenController.handleEvent(CallScreenController.Event.TOGGLE_CONTROLS)
}
},
enabled = !callControlsState.skipHiddenState
)
)
}
},
onParticipantLongPress = if (isInternalUser) {
{ participant -> longPressedParticipantId = participant.callParticipantId }
} else {
null
}
)
ParticipantContextMenu(
participant = longPressedParticipant,
isSelfAdmin = isSelfAdmin,
isCallLink = isCallLink,
onDismiss = { longPressedParticipantId = null },
onMuteAudio = onMuteAudio,
onRemoveFromCall = onRemoveFromCall,
onContactDetails = onContactDetails,
onViewSafetyNumber = onViewSafetyNumber,
onGoToChat = onGoToChat
)
}
},
pictureInPictureSlot = {
MoveableLocalVideoRenderer(
@@ -517,6 +565,140 @@ private fun AnimatedCallStateUpdate(
}
}
@Composable
private fun ParticipantContextMenu(
participant: CallParticipant?,
isSelfAdmin: Boolean,
isCallLink: Boolean,
onDismiss: () -> Unit,
onMuteAudio: (CallParticipant) -> Unit,
onRemoveFromCall: (CallParticipant) -> Unit,
onContactDetails: (CallParticipant) -> Unit,
onViewSafetyNumber: (CallParticipant) -> Unit,
onGoToChat: (CallParticipant) -> Unit
) {
DropdownMenu(
expanded = participant != null,
onDismissRequest = onDismiss
) {
val resolved = participant ?: return@DropdownMenu
DropdownMenuItem(
text = {
Text(
text = resolved.recipient.getShortDisplayName(androidx.compose.ui.platform.LocalContext.current),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
onClick = {},
enabled = false
)
// Divider (default divider has too much padding)
Box(
Modifier
.fillMaxWidth()
.height(1.5.dp)
.background(color = MaterialTheme.colorScheme.surfaceVariant)
)
if (isSelfAdmin && resolved.isMicrophoneEnabled) {
DropdownMenuItem(
text = { Text(stringResource(R.string.CallParticipantSheet__mute_audio)) },
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_mic_slash_24), contentDescription = null) },
onClick = {
onMuteAudio(resolved)
onDismiss()
}
)
}
if (isSelfAdmin && isCallLink) {
DropdownMenuItem(
text = { Text(stringResource(R.string.CallParticipantSheet__remove_from_call)) },
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_minus_circle_24), contentDescription = null) },
onClick = {
onRemoveFromCall(resolved)
onDismiss()
}
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.CallParticipantSheet__contact_details)) },
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_person_24), contentDescription = null) },
onClick = {
onContactDetails(resolved)
onDismiss()
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.ConversationSettingsFragment__view_safety_number)) },
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_safety_number_24), contentDescription = null) },
onClick = {
onViewSafetyNumber(resolved)
onDismiss()
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.CallContextMenu__go_to_chat)) },
leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_open_24), contentDescription = null) },
onClick = {
onGoToChat(resolved)
onDismiss()
}
)
}
}
@AllNightPreviews
@Composable
private fun ParticipantContextMenuAdminPreview() {
Previews.Preview {
Box {
ParticipantContextMenu(
participant = CallParticipant(
recipient = Recipient(isResolving = false, systemContactName = "Peter Parker"),
isMicrophoneEnabled = true
),
isSelfAdmin = true,
isCallLink = true,
onDismiss = {},
onMuteAudio = {},
onRemoveFromCall = {},
onContactDetails = {},
onViewSafetyNumber = {},
onGoToChat = {}
)
}
}
}
@AllNightPreviews
@Composable
private fun ParticipantContextMenuNonAdminPreview() {
Previews.Preview {
Box {
ParticipantContextMenu(
participant = CallParticipant(
recipient = Recipient(isResolving = false, systemContactName = "Gwen Stacy")
),
isSelfAdmin = false,
isCallLink = false,
onDismiss = {},
onMuteAudio = {},
onRemoveFromCall = {},
onContactDetails = {},
onViewSafetyNumber = {},
onGoToChat = {}
)
}
}
}
@AllNightPreviews
@Composable
private fun CallScreenPreview() {

View File

@@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDial
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.WindowUtil
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState
import kotlin.time.Duration.Companion.seconds
@@ -173,6 +174,8 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
}
}
val controlAndInfoState by controlsAndInfoViewModel.state
SignalTheme(isDarkMode = true) {
CallScreen(
callRecipient = recipient,
@@ -217,7 +220,15 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
onWifiToCellularPopupDismissed = { callScreenViewModel.callScreenState.update { it.copy(displayWifiToCellularPopup = false) } },
onSwipeToSpeakerHintDismissed = { callScreenViewModel.callScreenState.update { it.copy(displaySwipeToSpeakerHint = false) } },
onRemoteMuteToastDismissed = { callScreenViewModel.callScreenState.update { it.copy(remoteMuteToastMessage = null) } },
callParticipantUpdatePopupController = callParticipantUpdatePopupController
callParticipantUpdatePopupController = callParticipantUpdatePopupController,
isInternalUser = RemoteConfig.internalUser,
isSelfAdmin = controlAndInfoState.isSelfAdmin(),
isCallLink = controlAndInfoState.callLink != null,
onMuteAudio = callInfoCallbacks::onMuteAudio,
onRemoveFromCall = callInfoCallbacks::onRemoveFromCall,
onContactDetails = callInfoCallbacks::onContactDetails,
onViewSafetyNumber = callInfoCallbacks::onViewSafetyNumber,
onGoToChat = callInfoCallbacks::onGoToChat
)
}
}

View File

@@ -1110,18 +1110,18 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
/**
* Controls lock screen and screen-on behavior based on call state.
* - Show over lock screen: Only for incoming ringing calls, so user can answer.
* - Show over lock screen: For any ongoing call state, so the call UI remains visible
* if the call was answered from the lock screen.
* - Turn screen on: For any ongoing call state, so screen stays on during call.
*/
private fun setTurnScreenOnForCallState(callState: WebRtcViewModel.State) {
val isIncomingRinging = callState == WebRtcViewModel.State.CALL_INCOMING
val isOngoingCall = callState.inOngoingCall
if (Build.VERSION.SDK_INT >= 27) {
setShowWhenLocked(isIncomingRinging)
setShowWhenLocked(isOngoingCall)
setTurnScreenOn(isOngoingCall)
} else {
@Suppress("DEPRECATION")
if (isIncomingRinging) {
if (isOngoingCall) {
window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)

View File

@@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -83,7 +84,7 @@ class WebRtcCallViewModel : ViewModel() {
private val groupMemberStateUpdater = FlowCollector<List<GroupMemberEntry.FullMember>> { m -> participantsState.update { CallParticipantsState.update(it, m) } }
private val shouldShowSpeakerHint: Flow<Boolean> = participantsState.map(this::shouldShowSpeakerHint)
private val shouldShowSpeakerHint: Flow<Boolean> = participantsState.map(this::shouldShowSpeakerHint).distinctUntilChanged()
private val elapsedTimeHandler = Handler(Looper.getMainLooper())
private val elapsedTimeRunnable = Runnable { handleTick() }
@@ -174,6 +175,7 @@ class WebRtcCallViewModel : ViewModel() {
0
}
}
.onStart { emit(0) }
return combine(
callParticipantsState,

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