Compare commits

...

170 Commits

Author SHA1 Message Date
jeffrey-signal
6d5bb65c19 Bump version to 7.63.2 2025-10-31 18:23:31 -04:00
jeffrey-signal
501f3466a2 Update translations and other static files. 2025-10-31 17:52:54 -04:00
jeffrey-signal
84c713c2f7 Fix bottom chrome visibility in landscape split-screen with bottom nav. 2025-10-31 17:48:18 -04:00
Michelle Tang
303c2ea14a Adding UI polish for polls. 2025-10-31 17:48:18 -04:00
Alex Hart
109f651681 Remove custom WindowSizeClass and just depend on Material Adaptive WindowSizeClass.
Co-authored-by: jeffrey-signal <jeffrey@signal.org>
2025-10-31 17:48:18 -04:00
Greyson Parrelli
95c9776b4d Fix query selection crash during backup. 2025-10-31 17:48:18 -04:00
jeffrey-signal
89e6479021 Bump version to 7.63.1 2025-10-30 17:40:15 -04:00
jeffrey-signal
e6cb2a9273 Update translations and other static files. 2025-10-30 17:16:23 -04:00
Cody Henthorne
636f6a338e Use better check for websocket can connect check. 2025-10-30 14:47:17 -04:00
Alex Hart
41ba3383b2 Make navigation rail adaptive for small medium screens. 2025-10-30 15:15:11 -03:00
Alex Hart
9095ddaf19 Fix hijacking of navigation bar color when opening emoji keyboard. 2025-10-30 13:44:44 -03:00
Alex Hart
b4802c4bf6 Wait for global layout before animating in bottom action bar. 2025-10-30 13:13:19 -03:00
Alex Hart
eb72b88a16 Add better handling for pane dragging during search. 2025-10-29 16:46:29 -03:00
jeffrey-signal
be933648b2 Bump version to 7.63.0 2025-10-29 14:46:49 -04:00
jeffrey-signal
d4588d738f Update translations and other static files. 2025-10-29 14:42:50 -04:00
Michelle Tang
f4cca5ecc1 Bump protocol version number for polls. 2025-10-29 14:33:52 -04:00
Alex Hart
605b85455b Fix action mode back handling in conversation fragment. 2025-10-29 14:33:52 -04:00
Michelle Tang
18c7dbca08 Update vote logic for poll percentages. 2025-10-29 14:33:52 -04:00
Alex Hart
55040091af Convert private story settings fragment to compose. 2025-10-29 14:33:52 -04:00
Alex Hart
24c8501985 Report first render to main activity within nav hosts. 2025-10-29 14:33:52 -04:00
Cody Henthorne
5f5e0963e1 Prevent wallpaper archive thumbnail generation. 2025-10-29 14:33:52 -04:00
Cody Henthorne
4a163167e0 Fix OOM during backup export. 2025-10-29 14:33:52 -04:00
Cody Henthorne
e690c54f7c Fail gracefully when crash occurs during backup restore. 2025-10-29 14:33:52 -04:00
Alex Hart
1a39119c2b Fix list action mode exit on back. 2025-10-29 14:33:52 -04:00
Alex Hart
3cd86182db Update support logic for large screens. 2025-10-29 14:33:52 -04:00
Alex Hart
c89a3a2bf9 Increment largeScreenUI feature flag. 2025-10-29 14:33:52 -04:00
Cody Henthorne
908ca124f1 Clear recipient remap cache on restore. 2025-10-29 14:33:52 -04:00
Greyson Parrelli
5640e9c9b8 Show deprecation notice for API 21. 2025-10-29 14:33:52 -04:00
Alex Hart
6d84ea984d Preserve pane expansion state across window size class changes. 2025-10-29 14:33:52 -04:00
Michelle Tang
47201f4955 Add various backup fixes for polls. 2025-10-29 14:33:51 -04:00
Alex Hart
3f5a4ebf7b Utilize edgeToEdge instead of fullscreen helper. 2025-10-29 14:33:51 -04:00
Alex Hart
e0d56bfadf Reimplement conversation action mode to not use system actionmode. 2025-10-29 14:33:51 -04:00
Cody Henthorne
b9e0d9978b Delete attachment transfer files after restore or download. 2025-10-29 14:33:51 -04:00
Greyson Parrelli
ec76372e4d Fix potential thread conflict in backup import. 2025-10-29 14:33:51 -04:00
Cody Henthorne
fd902159ee Remove mock web server and prevent libsignal from connecting for android tests. 2025-10-29 14:33:51 -04:00
Michelle Tang
f16405fabf Add read support for binary service ids. 2025-10-29 14:33:51 -04:00
Alex Hart
bf4aa9cae9 Fix story shared element transition on foldables. 2025-10-29 14:33:51 -04:00
Alex Hart
ae8b8bbe7c AppScaffold Animation Performance impromements. 2025-10-29 14:33:51 -04:00
Alex Hart
443463aca8 Set pane role when we move to a fullscreen anchor. 2025-10-29 14:33:51 -04:00
Cody Henthorne
b300c911d7 Fix incorrect attachment archive state after changing remote key after upload. 2025-10-29 14:33:51 -04:00
Greyson Parrelli
6196fb4f44 Show toast when ignoring backup enable megaphone. 2025-10-29 14:33:51 -04:00
Greyson Parrelli
4ecd3ec052 Add more safety checks to e164 conversion in backup export. 2025-10-29 14:33:51 -04:00
Greyson Parrelli
b10a57de63 Temporarily disable 7 day backup warning. 2025-10-29 14:33:51 -04:00
Greyson Parrelli
3c27a690fd Address validation error around calls with invalid ringers. 2025-10-29 14:33:51 -04:00
Cody Henthorne
dcbd4a3fc4 Fix long text rendering as attachment bug. 2025-10-29 14:33:50 -04:00
Alex Hart
043b7b0a3d Fix two pane layout positioning and sizing. 2025-10-29 14:33:50 -04:00
Alex Hart
1862dded65 Fix NPE when slide URI is null. 2025-10-29 14:33:50 -04:00
Alex Hart
75a53974a4 Fix update of windowsizeclass. 2025-10-29 14:33:50 -04:00
Alex Hart
21138b9190 Set initial anchor index. 2025-10-29 14:33:50 -04:00
Alex Hart
8fbfc40ed5 Utilize an effect to initialize the current anchor. 2025-10-29 14:33:50 -04:00
Alex Hart
63ab448a27 Fix logo color and sizing in empty screen. 2025-10-29 14:33:50 -04:00
Alex Hart
d88c8baa83 Fix tap areas in stories viewer. 2025-10-29 14:33:50 -04:00
Greyson Parrelli
8d931391db Improve backup import resilience for duplicate messages. 2025-10-29 14:33:50 -04:00
andrew-signal
19afd5c0e6 Optimize padding block size based on real world experience. 2025-10-29 14:33:50 -04:00
Alex Hart
5a9c546dac Increase extended partition width to 24dp. 2025-10-29 14:33:50 -04:00
Greyson Parrelli
e288b8b429 Do not attempt to re-upload known-bad backup files. 2025-10-29 14:33:50 -04:00
Alex Hart
9ddc914cac Fix collapsed keyboard inset. 2025-10-29 14:33:50 -04:00
Greyson Parrelli
17e7b1735f Fix validation error with chat folder export. 2025-10-29 14:33:49 -04:00
Greyson Parrelli
0b27c42e89 Update archive processors with consistent naming scheme. 2025-10-29 14:33:49 -04:00
Greyson Parrelli
b15b50798a Fix quoting file-only messages. 2025-10-29 14:33:49 -04:00
Alex Hart
7b7b6a32ee Deselect active item when in full-screen pane. 2025-10-29 14:33:49 -04:00
Greyson Parrelli
4fc516c84f Fix screenshot protection on details screen.
Fixes #14397
2025-10-29 14:33:49 -04:00
Cody Henthorne
76e92f29b9 Fix call requests to a PNI. 2025-10-29 14:33:49 -04:00
Greyson Parrelli
55617c18f0 Fix color of caption button. 2025-10-29 14:33:49 -04:00
Greyson Parrelli
ef05f33f08 Suppor tmore pasted image types. 2025-10-29 14:33:49 -04:00
Greyson Parrelli
c25ce2bcdd Fix bug related to in-memory jobs not being prioritized properly. 2025-10-29 14:33:49 -04:00
andrew-signal
9ed921f58c Bump to libsignal v0.85.1 2025-10-29 14:33:49 -04:00
Greyson Parrelli
56a4ccb96d Fix time pattern matching for emoji search.
Fixes #14391
2025-10-29 14:33:49 -04:00
Greyson Parrelli
a8e65619d9 Downgrade warning log during backup creation. 2025-10-29 14:33:49 -04:00
Greyson Parrelli
123b88e032 Validate attachment key size. 2025-10-29 14:33:49 -04:00
Alex Hart
c268625f52 Ignore call to visibility if there's no view. 2025-10-29 14:33:49 -04:00
Alex Hart
74f9f39656 Do not update status bar or nav bar coloring if large screen support is enabled. 2025-10-29 14:33:49 -04:00
Alex Hart
9ddc600972 Ignore message backups checkout activity tests. 2025-10-29 14:33:49 -04:00
jeffrey-signal
d903bcf2b1 Enable split pane UI for create group screen. 2025-10-29 14:33:48 -04:00
jeffrey-signal
19558c5325 Create group v2 - Implement navigation to group details screen. 2025-10-29 14:24:38 -04:00
Alex Hart
9d545412a5 Update local inapppayment cancelation state to match that of the activesubscription when we discover it. 2025-10-29 14:24:38 -04:00
jeffrey-signal
7301dda5d1 Bump version to 7.62.3 2025-10-29 14:12:41 -04:00
jeffrey-signal
c88c565af3 Update translations and other static files. 2025-10-29 14:04:31 -04:00
jeffrey-signal
f932ea9f1f Fix AppScaffold content window insets. 2025-10-29 13:21:07 -04:00
Cody Henthorne
77e2d58dea Fix manual restore from remote flow. 2025-10-29 13:03:14 -04:00
jeffrey-signal
d261f3ebf5 Bump version to 7.62.2 2025-10-27 20:29:00 -04:00
jeffrey-signal
9f69ffbb88 Update translations and other static files. 2025-10-27 20:28:36 -04:00
jeffrey-signal
ab781cab8a Fix AppScaffold content window insets. 2025-10-27 20:16:21 -04:00
jeffrey-signal
6d843a9725 Bump version to 7.62.1 2025-10-27 19:10:31 -04:00
jeffrey-signal
a387d63b77 Update translations and other static files. 2025-10-27 18:52:42 -04:00
jeffrey-signal
37544aa8b7 Enable edge to edge for new conversation and create group screens. 2025-10-27 17:59:05 -04:00
jeffrey-signal
963a72a660 Convert ContactFilterView to compose. 2025-10-27 17:58:19 -04:00
Greyson Parrelli
c316381159 Bump version to 7.62.0 2025-10-22 16:05:59 -04:00
Greyson Parrelli
3c44d90da7 Update translations and other static files. 2025-10-22 16:05:26 -04:00
Cody Henthorne
90201a464d Fix invalid navigation to remote restore activity post registration. 2025-10-22 15:59:48 -04:00
Greyson Parrelli
5f8eaa4f1c Update reproducible build script to handle acceptable resource differences.
Fixes #13565
2025-10-22 15:53:09 -04:00
jeffrey-signal
d6446d2954 Add split-pane UI for create group screen. 2025-10-22 15:53:09 -04:00
Alex Hart
d763baa270 Fix margin on call log item. 2025-10-22 15:53:09 -04:00
Cody Henthorne
ea70d68ecc Fix incorrect navigation to RestoreActivty after app cold start. 2025-10-22 15:53:09 -04:00
Kanake
26cb17e25c Fix browser not opening in certain contexts.
Fixes #14368
Resolves #14388
2025-10-22 15:53:09 -04:00
Kanake
602fc8c6e7 Fix RTL issue with phone number in settings.
Fixes #14239
Resolves #14387
2025-10-22 15:53:09 -04:00
Alex Hart
e4fd7a6aee Persist stored vertical insets between activity restarts. 2025-10-22 15:53:09 -04:00
Alex Hart
12cb74bc05 Fix e2e test. 2025-10-22 15:53:09 -04:00
Alex Hart
0266de3532 Update dark colors for reaction overaly. 2025-10-22 15:53:09 -04:00
Jeffrey Starke
e235ce52e5 Standardize internal UI state property naming. 2025-10-22 15:53:09 -04:00
Alex Hart
d4c266561f Add "fake" chat list bitmap to fake transition. 2025-10-22 15:53:09 -04:00
jeffrey-signal
bd25447a8f Make optional RecipientPicker callbacks opt-in by splitting into smaller interfaces. 2025-10-22 15:53:09 -04:00
jeffrey-signal
2c435ef751 Disable password manager suggestions on new conversation search box. 2025-10-22 15:53:09 -04:00
Michelle Tang
96310ba1d0 Fix chat folder creation in large font sizes. 2025-10-22 15:53:09 -04:00
Michelle Tang
869eada21c Update recovery key education sheet. 2025-10-22 15:53:09 -04:00
Michelle Tang
1d13a62088 Fix read more button on stories. 2025-10-22 15:53:09 -04:00
Alex Hart
43bb32e64b Display back button when the conversation pane is expanded. 2025-10-22 15:53:09 -04:00
Alex Hart
f38262c0ab Add initial Call Quality UX. 2025-10-22 15:53:09 -04:00
Alex Hart
6e0bfa2cee Fixes for reaction overlay. 2025-10-22 15:53:09 -04:00
Alex Hart
07d270a82d Remove deprecated view model factory. 2025-10-22 15:53:09 -04:00
Alex Hart
1b2e80d2c8 Fix crash when opening archived chats. 2025-10-22 15:53:09 -04:00
Alex Hart
329389bb41 Utilize fixed rounded corner sheet for recipients fragment. 2025-10-22 15:53:09 -04:00
Alex Hart
c8f801da83 Allow detail content to slide with pane as we remove it. 2025-10-20 13:04:54 -03:00
Alex Hart
20f0764c68 Fix pane anchor for detail only offset. 2025-10-20 12:15:03 -03:00
Alex Hart
10f17a1bba Clear keyboard focus when entering multiselect mode. 2025-10-20 11:32:11 -03:00
Alex Hart
c2b02ea07c Ensure multiselect restarts as necessary on call log fragment. 2025-10-20 11:24:03 -03:00
Alex Hart
81e8ebe839 Do not hide navigation rail when performing multi-select in conversation list. 2025-10-20 11:13:01 -03:00
Alex Hart
d665856a7c Utilize height size class when dealing with landscape orientations. 2025-10-20 10:11:29 -03:00
Michelle Tang
1544cb81d5 Update UI when viewing votes. 2025-10-17 19:46:42 -04:00
Michelle Tang
e4abc6d256 Adding various poll UI updates. 2025-10-17 19:46:42 -04:00
Alex Hart
7901cad90b Show sheet for validation error. 2025-10-17 19:46:42 -04:00
Jim Gustafson
ea5a84b3dd Update to RingRTC v2.59.2 2025-10-17 19:46:42 -04:00
jeffrey-signal
9d0422a898 Enable split pane UI for new conversation screen. 2025-10-17 19:46:42 -04:00
jeffrey-signal
4fd4792dd8 New conversation v2 - Implement remaining functionality. 2025-10-17 19:46:42 -04:00
Alex Hart
802f980c6f Add CallQualitySurvey proto. 2025-10-17 19:46:42 -04:00
Alex Hart
435be7c63d Fix auto-expansion of panes on click. 2025-10-17 19:46:42 -04:00
jeffrey-signal
e2b57b55d6 Add snackbar host to AppScaffold. 2025-10-17 19:46:42 -04:00
Michelle Tang
b3f74d37e1 Add poll icon when quoting a poll. 2025-10-17 19:46:42 -04:00
Michelle Tang
91b70038e6 Add ability to cancel a pending vote. 2025-10-17 19:46:42 -04:00
Cody Henthorne
08eca9ac27 Prevent invalid profile keys from being processed for storage service. 2025-10-17 19:46:42 -04:00
Cody Henthorne
55916f31aa Only consider backup5 for link and sync. 2025-10-17 19:46:42 -04:00
Cody Henthorne
b9abe9c119 Bump version to 7.61.3 2025-10-17 19:46:08 -04:00
Cody Henthorne
cb1605bf23 Update translations and other static files. 2025-10-17 19:43:34 -04:00
Alex Hart
dcc533ef49 Remove body of migration 293. 2025-10-17 19:35:41 -04:00
Cody Henthorne
cdafe47c9a Bump version to 7.61.2 2025-10-16 16:34:33 -04:00
Cody Henthorne
365ad54f10 Update translations and other static files. 2025-10-16 16:34:25 -04:00
Cody Henthorne
ded8c99ce2 Attempt to fix sqlexception in migrations. 2025-10-16 16:23:46 -04:00
Cody Henthorne
b1d7da5320 Bump version to 7.61.1 2025-10-16 15:56:11 -04:00
Cody Henthorne
467fa11a17 Update translations and other static files. 2025-10-16 15:55:27 -04:00
Cody Henthorne
3346497a25 Fix crash importing recipients without identifiers. 2025-10-16 15:49:02 -04:00
Cody Henthorne
6ea0e176c9 Bump version to 7.61.0 2025-10-15 15:39:04 -04:00
Cody Henthorne
8ea443cde1 Update translations and other static files. 2025-10-15 15:33:51 -04:00
Alex Hart
c2d0d80b9f Fix flickering footer when inlining causes 1-2px change in width. 2025-10-15 15:26:34 -04:00
Alex Hart
cbe72307a0 Add proper selection state support to Chats and Calls tabs. 2025-10-15 15:26:34 -04:00
Alex Hart
e57b47ec82 Fix NoSuchMessageException when loading stories. 2025-10-15 15:26:34 -04:00
Alex Hart
518bf04e1d Filter attachments except for LONG_TEXT when receiving an EditMessage. 2025-10-15 15:26:34 -04:00
Cody Henthorne
a430e9b3d3 Enable link device UX for nightly builds. 2025-10-15 15:26:34 -04:00
Cody Henthorne
75ce72ee83 Fix set archive transfer state race for duplicate attachments. 2025-10-15 15:26:34 -04:00
jeffrey-signal
5d60ab35de New conversation v2 - Add support for find by username/phone/contacts and group creation. 2025-10-15 15:26:34 -04:00
Alex Hart
33f9369883 Fix inline search display on landscape orientations. 2025-10-15 15:26:34 -04:00
Alex Hart
7d1abf0f7c Fix automated tests for message backups checkout flow. 2025-10-15 15:26:34 -04:00
Alex Hart
17d1061204 Utilize pointerIndex instead of a pointerId for motion events.
Fixes #14290
2025-10-15 15:26:34 -04:00
Alex Hart
feb37eea2d Fix BioRecipientState if recipient does not have an e164. 2025-10-15 15:26:34 -04:00
Alex Hart
6bde2fd20a Fix improper media selection after deletion. 2025-10-15 15:26:34 -04:00
Cody Henthorne
7b25cc399d Implement skip restore on the iOS to android failure screen. 2025-10-15 15:26:34 -04:00
Michelle Tang
525175f04a Add polls to backups. 2025-10-15 15:26:34 -04:00
Cody Henthorne
a2aabeaad2 Fix duplicate messages during backup import causing unique constraint failure bug. 2025-10-15 15:26:34 -04:00
Taha Murtaza Ali
cdfcdcc3b7 Utilize proper theme color. 2025-10-15 15:26:34 -04:00
jeffrey-signal
56244ad873 Add default style for dropdown menu item text. 2025-10-15 15:26:34 -04:00
andrew-signal
e6399517ee Update manifest to reflect constrained satellite network support.
Co-authored-by: Avanish Subbiah <subbiah@purdue.edu>
2025-10-15 15:26:34 -04:00
andrew-signal
1c3223f551 Bump to libsignal v0.84.0 2025-10-15 15:26:34 -04:00
Jordan Rose
f4f2976907 Remove redundant IdentityKeyUtil helper. 2025-10-15 15:26:34 -04:00
Jeffrey Starke
76f65198bb Fix sticker management top app bar menu padding. 2025-10-15 15:26:34 -04:00
Alex Hart
971bcf4f41 Utilize snapshotFlow to fix insets. 2025-10-15 15:26:34 -04:00
jeffrey-signal
b49074a786 New Conversation v2 - Fix find by username/phone options disappearing after rotation. 2025-10-15 15:26:34 -04:00
andrew-signal
eea89d3b62 Add helper to catch/wrap exceptions that originate inside runWithUnauthChatConnection. 2025-10-15 15:26:34 -04:00
Alex Hart
3f7b73cf5e Require ACI for ReadMessage sender field. 2025-10-15 15:26:33 -04:00
Alex Hart
cbc547d322 Fix ANR when trying to load a message composed of 10,000 random emoji. 2025-10-15 15:26:33 -04:00
Alex Hart
c9a59a7417 Fix ANR when trying to load a message composed of 10,000 random emoji. 2025-10-15 15:26:33 -04:00
Cody Henthorne
f8eaa96412 Fix blocking bugs for internal link and sync testing. 2025-10-15 15:26:33 -04:00
422 changed files with 17584 additions and 5086 deletions

View File

@@ -1 +1,2 @@
java openjdk-17.0.2
uv latest

View File

@@ -22,8 +22,8 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1599
val canonicalVersionName = "7.60.2"
val canonicalVersionCode = 1610
val canonicalVersionName = "7.63.2"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -293,6 +293,7 @@ android {
manifestPlaceholders["mapsKey"] = getMapsKey()
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Debug\"")
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "true")
}
getByName("release") {
@@ -318,7 +319,6 @@ android {
isMinifyEnabled = false
matchingFallbacks += "debug"
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Spinner\"")
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "true")
}
create("perf") {
@@ -378,6 +378,7 @@ android {
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
buildConfigField("String", "APK_UPDATE_MANIFEST_URL", "\"${apkUpdateManifestUrl}\"")
buildConfigField("String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\"")
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "true")
}
create("prod") {
@@ -649,7 +650,6 @@ dependencies {
androidTestImplementation(testLibs.androidx.test.ext.junit.ktx)
androidTestImplementation(testLibs.assertk)
androidTestImplementation(testLibs.mockk.android)
androidTestImplementation(testLibs.square.okhttp.mockserver)
androidTestImplementation(testLibs.diff.utils)
androidTestUtil(testLibs.androidx.test.orchestrator)

View File

@@ -211,6 +211,11 @@ class ArchiveImportExportTests {
runTests { it.startsWith("chat_item_view_once_") }
}
// @Test
fun chatItemPoll() {
runTests { it.startsWith("chat_item_poll_") }
}
// @Test
fun notificationProfiles() {
runTests { it.startsWith("notification_profile_") }

View File

@@ -25,12 +25,14 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.StandardTestDispatcher
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.billing.BillingProduct
import org.signal.core.util.billing.BillingPurchaseResult
import org.signal.core.util.billing.BillingPurchaseState
import org.signal.core.util.billing.BillingResponseCode
import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
@@ -45,6 +47,7 @@ import org.thoughtcrime.securesms.testing.SignalActivityRule
import java.math.BigDecimal
import java.util.Currency
@Ignore
@RunWith(AndroidJUnit4::class)
class MessageBackupsCheckoutActivityTest {
@@ -63,6 +66,7 @@ class MessageBackupsCheckoutActivityTest {
@Before
fun setUp() {
every { AppDependencies.billingApi.getBillingPurchaseResults() } returns purchaseResults
coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.OK
coEvery { AppDependencies.billingApi.queryProduct() } returns BillingProduct(price = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")))
coEvery { AppDependencies.billingApi.launchBillingFlow(any()) } returns Unit
}
@@ -136,7 +140,7 @@ class MessageBackupsCheckoutActivityTest {
// Key education screen
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyEducationScreen__your_backup_key)).assertIsDisplayed()
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__next)).performClick()
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyEducationScreen__view_recovery_key)).performClick()
// Key record screen
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key)).assertIsDisplayed()

View File

@@ -19,6 +19,7 @@ import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.Base64
import org.signal.core.util.Base64.decodeBase64OrThrow
import org.signal.core.util.copyTo
import org.signal.core.util.stream.NullOutputStream
@@ -37,6 +38,7 @@ import org.whispersystems.signalservice.api.crypto.AttachmentCipherOutputStream
import org.whispersystems.signalservice.api.crypto.NoCipherOutputStream
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.Optional
@@ -326,6 +328,92 @@ class AttachmentTableTest {
assertThat(attachments).isEmpty()
}
/**
* There's a race condition where the following was happening:
*
* 1. Receive attachment A
* 2. Download attachment A
* 3. Enqueue copy to archive job for A (old media name)
* 4. Receive attachment B that is identical to A
* 5. Dedupe B with A's data file but update A to match B's "newer" remote key
* 6. Enqueue copy to archive job for B (new media name)
* 7. Copy to archive for A succeeds for old media name, updating A and B to FINISHED
* 8. Copy to archive for B for new media name early aborts because B is already marked FINISHED
*
* THe problem is Step 7 because it's marking attachments as archived but under the old media and not the new media name.
*
* This tests recreates the flow but ensures Step 7 doesn't mark A and B as finished so that Step 8 will not early abort and copy
* B over with the new media name.
*/
@Test
fun givenAnDuplicateAttachmentPriorToCopyToArchive_whenICopyFirstAttachmentToArchive_thenIDoNotExpectBothAttachmentsToChangeArchiveStateToFinished() {
val data = byteArrayOf(1, 2, 3, 4, 5)
val attachment1 = createAttachmentPointer("remote-key-1".toByteArray(), data.size)
val attachment2 = createAttachmentPointer("remote-key-2".toByteArray(), data.size)
// Insert Message 1
val message1Result = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 0.days, attachment = attachment1)).get()
val message1Id = message1Result.messageId
val attachment1Id = message1Result.insertedAttachments!![attachment1]!!
// AttachmentDownloadJob#onAdded
SignalDatabase.attachments.setTransferState(message1Id, attachment1Id, AttachmentTable.TRANSFER_PROGRESS_STARTED)
// Insert Message 2
val message2Result = SignalDatabase.messages.insertMessageInbox(createIncomingMessage(serverTime = 1.days, attachment = attachment2)).get()
val message2Id = message2Result.messageId
val attachment2Id = message2Result.insertedAttachments!![attachment2]!!
// AttachmentDownloadJob#onAdded
SignalDatabase.attachments.setTransferState(message2Id, attachment2Id, AttachmentTable.TRANSFER_PROGRESS_STARTED)
// Finalize Attachment 1 download
SignalDatabase.attachments.finalizeAttachmentAfterDownload(message1Id, attachment1Id, ByteArrayInputStream(data))
// CopyAttachmentToArchiveJob#onAdded
SignalDatabase.attachments.setArchiveTransferState(attachment1Id, AttachmentTable.ArchiveTransferState.COPY_PENDING)
// Verify Attachment 1 data matches original Attachment 1 data from insert
var dbAttachment1 = SignalDatabase.attachments.getAttachment(attachment1Id)!!
assertThat(dbAttachment1.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.COPY_PENDING)
assertThat(dbAttachment1.remoteKey).isEqualTo(Base64.encodeWithPadding("remote-key-1".toByteArray()))
val attachment1InitialRemoteKey = dbAttachment1.remoteKey!!
val attachment1InitialPlaintextHash = dbAttachment1.dataHash!!
// Finalize Attachment 2
SignalDatabase.attachments.finalizeAttachmentAfterDownload(message2Id, attachment2Id, ByteArrayInputStream(data))
// Verify Attachment 1 data matches Attachment 2 data from insert and dedupe in finalize
dbAttachment1 = SignalDatabase.attachments.getAttachment(attachment1Id)!!
var dbAttachment2 = SignalDatabase.attachments.getAttachment(attachment2Id)!!
assertThat(dbAttachment1.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
assertThat(dbAttachment2.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
assertThat(dbAttachment1.remoteKey).isEqualTo(dbAttachment2.remoteKey)
assertThat(dbAttachment1.dataHash).isEqualTo(dbAttachment2.dataHash)
val attachment2InitialRemoteKey = dbAttachment2.remoteKey!!
val attachment2InitialPlaintextHash = dbAttachment2.dataHash!!
// "Finish" Copy to Archive for Attachment 1
SignalDatabase.attachments.setArchiveTransferState(attachment1Id, attachment1InitialRemoteKey, attachment1InitialPlaintextHash, AttachmentTable.ArchiveTransferState.FINISHED)
dbAttachment1 = SignalDatabase.attachments.getAttachment(attachment1Id)!!
dbAttachment2 = SignalDatabase.attachments.getAttachment(attachment2Id)!!
// Verify Attachment 1 and 2 are not updated as FINISHED since Attachment 1's media name parts have changed
assertThat(dbAttachment1.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
assertThat(dbAttachment2.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.NONE)
// "Finish" Copy to Archive for Attachment 2
SignalDatabase.attachments.setArchiveTransferState(attachment2Id, attachment2InitialRemoteKey, attachment2InitialPlaintextHash, AttachmentTable.ArchiveTransferState.FINISHED)
dbAttachment1 = SignalDatabase.attachments.getAttachment(attachment1Id)!!
dbAttachment2 = SignalDatabase.attachments.getAttachment(attachment2Id)!!
// Verify Attachment 1 and 2 are updated as FINISHED
assertThat(dbAttachment1.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
assertThat(dbAttachment2.archiveTransferState).isEqualTo(AttachmentTable.ArchiveTransferState.FINISHED)
}
private fun createIncomingMessage(
serverTime: Duration,
attachment: Attachment,
@@ -343,7 +431,7 @@ class AttachmentTableTest {
)
}
private fun createAttachmentPointer(key: ByteArray, digest: ByteArray, size: Int): Attachment {
private fun createAttachmentPointer(key: ByteArray, size: Int): Attachment {
return PointerAttachment.forPointer(
pointer = Optional.of(
SignalServiceAttachmentPointer(
@@ -355,7 +443,7 @@ class AttachmentTableTest {
preview = Optional.empty(),
width = 2,
height = 2,
digest = Optional.of(digest),
digest = Optional.of(byteArrayOf()),
incrementalDigest = Optional.empty(),
incrementalMacChunkSize = 0,
fileName = Optional.of("file.jpg"),

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -11,6 +12,7 @@ import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.polls.Voter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.SignalActivityRule
@@ -28,7 +30,7 @@ class PollTablesTest {
id = 1,
question = "how do you feel about unit testing?",
pollOptions = listOf(
PollOption(1, "yay", listOf(1)),
PollOption(1, "yay", listOf(Voter(1, 1))),
PollOption(2, "ok", emptyList()),
PollOption(3, "nay", emptyList())
),
@@ -79,7 +81,7 @@ class PollTablesTest {
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(3), voterId = 1, voteCount = 2, messageId = MessageId(1))
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(1), voterId = 1, voteCount = 3, messageId = MessageId(1))
assertEquals(poll1, SignalDatabase.polls.getPoll(1))
assertEquals(listOf(Voter(1, 3)), SignalDatabase.polls.getPoll(1)!!.pollOptions[0].voters)
}
@Test
@@ -99,7 +101,7 @@ class PollTablesTest {
val voteCount = SignalDatabase.polls.insertVote(poll, pollOption)
assertEquals(1, voteCount)
assertEquals(listOf(0), SignalDatabase.polls.getVotes(poll.id, false))
assertEquals(listOf(0), SignalDatabase.polls.getVotes(poll.id, false, voteCount))
}
@Test
@@ -109,23 +111,25 @@ class PollTablesTest {
val pollOption = poll.pollOptions.first()
val voteCount = SignalDatabase.polls.removeVote(poll, pollOption)
SignalDatabase.polls.markPendingAsRemoved(poll.id, Recipient.self().id.toLong(), voteCount, 1)
SignalDatabase.polls.markPendingAsRemoved(poll.id, Recipient.self().id.toLong(), voteCount, 1, pollOption.id)
assertEquals(1, voteCount)
val status = SignalDatabase.polls.getPollVoteStateForGivenVote(poll.id, voteCount)
assertEquals(PollTables.VoteState.REMOVED, status)
val votes = SignalDatabase.polls.getVotes(poll.id, false, voteCount)
assertTrue(votes.isEmpty())
}
@Test
fun givenAVote_whenISetPollOptionId_thenOptionIdIsUpdated() {
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
fun givenAPendingVote_whenIRevertThatVote_thenItGoesToMostRecentResolvedState() {
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", true, listOf("yay", "ok", "nay"), 1, 1)
val poll = SignalDatabase.polls.getPoll(1)!!
val option = poll.pollOptions.first()
SignalDatabase.polls.insertVotes(poll.id, listOf(option.id), Recipient.self().id.toLong(), 5, MessageId(1))
SignalDatabase.polls.setPollVoteStateForGivenVote(poll.id, Recipient.self().id.toLong(), 5, 1, true)
val status = SignalDatabase.polls.getPollVoteStateForGivenVote(poll.id, 5)
SignalDatabase.polls.markPendingAsAdded(poll.id, Recipient.self().id.toLong(), 5, 1, option.id)
SignalDatabase.polls.removeVote(poll, option)
assertEquals(PollTables.VoteState.ADDED, status)
SignalDatabase.polls.removePendingVote(poll.id, option.id, 6, 1)
val votes = SignalDatabase.polls.getVotes(1, true, 6)
assertEquals(listOf(0), votes)
}
}

View File

@@ -1,29 +1,11 @@
package org.thoughtcrime.securesms.dependencies
import android.app.Application
import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import okhttp3.ConnectionSpec
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import okio.ByteString
import org.signal.core.util.Base64
import org.signal.core.util.billing.BillingApi
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
import org.thoughtcrime.securesms.push.SignalServiceTrustStore
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
import org.thoughtcrime.securesms.testing.Get
import org.thoughtcrime.securesms.testing.Verb
import org.thoughtcrime.securesms.testing.runSync
import org.thoughtcrime.securesms.testing.success
import org.whispersystems.signalservice.api.SignalServiceDataStore
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.account.AccountApi
@@ -32,17 +14,8 @@ import org.whispersystems.signalservice.api.attachment.AttachmentApi
import org.whispersystems.signalservice.api.donations.DonationsApi
import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.message.MessageApi
import org.whispersystems.signalservice.api.push.TrustStore
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl
import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl
import org.whispersystems.signalservice.internal.configuration.SignalSvr2Url
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import java.net.InetAddress
import java.util.Optional
/**
* Dependency provider used for instrumentation tests (aka androidTests).
@@ -51,70 +24,12 @@ import java.util.Optional
*/
class InstrumentationApplicationDependencyProvider(val application: Application, private val default: ApplicationDependencyProvider) : AppDependencies.Provider by default {
private val serviceTrustStore: TrustStore
private val uncensoredConfiguration: SignalServiceConfiguration
private val serviceNetworkAccessMock: SignalServiceNetworkAccess
private val recipientCache: LiveRecipientCache
private var signalServiceMessageSender: SignalServiceMessageSender? = null
private var billingApi: BillingApi = mockk()
private var accountApi: AccountApi = mockk()
init {
runSync {
webServer = MockWebServer()
webServer.start(InetAddress.getByAddress(byteArrayOf(0x7f, 0x0, 0x0, 0x1)), 8080)
baseUrl = webServer.url("").toString()
addMockWebRequestHandlers(
Get("/v1/websocket/?login=") {
MockResponse().success().withWebSocketUpgrade(mockIdentifiedWebSocket)
},
Get("/v1/websocket", {
val path = it.path
return@Get path == null || !path.contains("login")
}) {
MockResponse().success().withWebSocketUpgrade(object : WebSocketListener() {})
}
)
}
webServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val handler = handlers.firstOrNull { it.requestPredicate(request) }
return handler?.responseFactory?.invoke(request) ?: MockResponse().setResponseCode(500)
}
}
serviceTrustStore = SignalServiceTrustStore(application)
uncensoredConfiguration = SignalServiceConfiguration(
signalServiceUrls = arrayOf(SignalServiceUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
signalCdnUrlMap = mapOf(
0 to arrayOf(SignalCdnUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
2 to arrayOf(SignalCdnUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT))
),
signalStorageUrls = arrayOf(SignalStorageUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
signalCdsiUrls = arrayOf(SignalCdsiUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
signalSvr2Urls = arrayOf(SignalSvr2Url(baseUrl, serviceTrustStore, "localhost", ConnectionSpec.CLEARTEXT)),
networkInterceptors = emptyList(),
dns = Optional.of(SignalServiceNetworkAccess.DNS),
signalProxy = Optional.empty(),
systemHttpProxy = Optional.empty(),
zkGroupServerPublicParams = Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS),
genericServerPublicParams = Base64.decode(BuildConfig.GENERIC_SERVER_PUBLIC_PARAMS),
backupServerPublicParams = Base64.decode(BuildConfig.BACKUP_SERVER_PUBLIC_PARAMS),
censored = false
)
serviceNetworkAccessMock = mockk()
every { serviceNetworkAccessMock.isCensored() } returns false
every { serviceNetworkAccessMock.isCensored(any()) } returns false
every { serviceNetworkAccessMock.isCountryCodeCensoredByDefault(any()) } returns false
every { serviceNetworkAccessMock.getConfiguration() } returns uncensoredConfiguration
every { serviceNetworkAccessMock.getConfiguration(any()) } returns uncensoredConfiguration
every { serviceNetworkAccessMock.uncensoredConfiguration } returns uncensoredConfiguration
recipientCache = LiveRecipientCache(application) { r -> r.run() }
}
@@ -122,10 +37,6 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
override fun provideAccountApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): AccountApi = accountApi
override fun provideSignalServiceNetworkAccess(): SignalServiceNetworkAccess {
return serviceNetworkAccessMock
}
override fun provideRecipientCache(): LiveRecipientCache {
return recipientCache
}
@@ -150,54 +61,4 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
}
return signalServiceMessageSender!!
}
class MockWebSocket : WebSocketListener() {
private val TAG = "MockWebSocket"
var webSocket: WebSocket? = null
private set
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.i(TAG, "onOpen(${webSocket.hashCode()})")
this.webSocket = webSocket
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
Log.i(TAG, "onClosing(${webSocket.hashCode()}): $code, $reason")
this.webSocket = null
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.i(TAG, "onClosed(${webSocket.hashCode()}): $code, $reason")
this.webSocket = null
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.w(TAG, "onFailure(${webSocket.hashCode()})", t)
this.webSocket = null
}
}
companion object {
lateinit var webServer: MockWebServer
private set
lateinit var baseUrl: String
private set
val mockIdentifiedWebSocket = MockWebSocket()
private val handlers: MutableList<Verb> = mutableListOf()
fun addMockWebRequestHandlers(vararg verbs: Verb) {
handlers.addAll(verbs)
}
fun injectWebSocketMessage(value: ByteString) {
mockIdentifiedWebSocket.webSocket!!.send(value)
}
fun clearHandlers() {
handlers.clear()
}
}
}

View File

@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.jobs
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEmpty
import okhttp3.mockwebserver.MockResponse
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -11,19 +10,13 @@ import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType
import org.signal.donations.json.StripeIntentStatus
import org.signal.donations.json.StripePaymentIntent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue
import org.thoughtcrime.securesms.database.DonationReceiptTable
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.testing.Get
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.success
import org.thoughtcrime.securesms.util.TestStripePaths
import java.math.BigDecimal
import java.util.Currency
@@ -46,8 +39,6 @@ class InAppPaymentAuthCheckJobTest {
@Test
fun givenCanceledOneTimeAuthRequiredPayment_whenICheck_thenIDoNotExpectAReceipt() {
initializeMockGetPaymentIntent(status = StripeIntentStatus.CANCELED)
SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.ONE_TIME_DONATION,
state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION,
@@ -67,19 +58,4 @@ class InAppPaymentAuthCheckJobTest {
val receipts = SignalDatabase.donationReceipts.getReceipts(InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION)
assertThat(receipts).isEmpty()
}
private fun initializeMockGetPaymentIntent(status: StripeIntentStatus) {
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get(TestStripePaths.getPaymentIntentPath(TEST_INTENT_ID, TEST_CLIENT_SECRET)) {
MockResponse().success(
StripePaymentIntent(
id = TEST_INTENT_ID,
clientSecret = TEST_CLIENT_SECRET,
status = status,
paymentMethod = null
)
)
}
)
}
}

View File

@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.polls.Voter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.GroupTestingUtils
@@ -187,7 +188,7 @@ class DataMessageProcessorTest_polls {
assertThat(messageId!!.id).isEqualTo(1)
val poll = SignalDatabase.polls.getPoll(messageId.id)
assert(poll != null)
assertThat(poll!!.pollOptions[0].voterIds).isEqualTo(listOf(bob.id.toLong()))
assertThat(poll!!.pollOptions[0].voters).isEqualTo(listOf(Voter(bob.id.toLong(), 1)))
}
@Test
@@ -207,9 +208,9 @@ class DataMessageProcessorTest_polls {
assert(messageId != null)
val poll = SignalDatabase.polls.getPoll(messageId!!.id)
assert(poll != null)
assertThat(poll!!.pollOptions[0].voterIds).isEqualTo(listOf(bob.id.toLong()))
assertThat(poll.pollOptions[1].voterIds).isEqualTo(listOf(bob.id.toLong()))
assertThat(poll.pollOptions[2].voterIds).isEqualTo(listOf(bob.id.toLong()))
assertThat(poll!!.pollOptions[0].voters).isEqualTo(listOf(Voter(bob.id.toLong(), 1)))
assertThat(poll.pollOptions[1].voters).isEqualTo(listOf(Voter(bob.id.toLong(), 1)))
assertThat(poll.pollOptions[2].voters).isEqualTo(listOf(Voter(bob.id.toLong(), 1)))
}
@Test

View File

@@ -44,7 +44,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
init {
val threadIdSlot = slot<Long>()
mockkStatic(ThreadUpdateJob::class)
every { ThreadUpdateJob.enqueue(capture(threadIdSlot)) } answers {
every { ThreadUpdateJob.enqueue(capture(threadIdSlot), any()) } answers {
SignalDatabase.threads.update(threadIdSlot.captured, false)
}
}
@@ -148,7 +148,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
.groupChangeUpdate(GroupsV2UpdateMessageConverter.translateDecryptedChange(SignalStore.account.getServiceIds(), decryptedGroupV2Context))
.build()
val outgoingMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, startTime)
val outgoingMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, startTime, false)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val messageId = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId, false, null).messageId

View File

@@ -16,7 +16,6 @@ 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.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.AliceClient
import org.thoughtcrime.securesms.testing.BobClient
@@ -94,13 +93,7 @@ class MessageProcessingPerformanceTest {
val lastTimestamp = envelopes.last().timestamp ?: 0
// Inject the envelopes into the websocket
Thread {
for (envelope in envelopes) {
Log.i(TIMING_TAG, "Retrieved envelope! ${envelope.timestamp}")
InstrumentationApplicationDependencyProvider.injectWebSocketMessage(envelope.toWebSocketPayload())
}
InstrumentationApplicationDependencyProvider.injectWebSocketMessage(webSocketTombstone())
}.start()
// TODO: mock websocket messages
// Wait until they've all been fully decrypted + processed
harness

View File

@@ -22,20 +22,14 @@ import assertk.assertThat
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import io.reactivex.rxjava3.schedulers.TestScheduler
import okhttp3.mockwebserver.MockResponse
import org.junit.After
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.testing.Put
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.success
import org.whispersystems.signalservice.api.util.Usernames
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse
import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class)
@@ -53,11 +47,6 @@ class UsernameEditFragmentTest {
computationTestScheduler = computationScheduler
)
@After
fun tearDown() {
InstrumentationApplicationDependencyProvider.clearHandlers()
}
@Ignore("Flakey espresso test.")
@Test
fun testUsernameCreationOutsideOfRegistration() {
@@ -82,14 +71,7 @@ class UsernameEditFragmentTest {
val discriminator = "4578"
val username = "$nickname${Usernames.DELIMITER}$discriminator"
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Put("/v1/accounts/username/reserved") {
MockResponse().success(ReserveUsernameResponse(username))
},
Put("/v1/accounts/username/confirm") {
MockResponse().success()
}
)
// TODO: mock network requests as necessary
val scenario = createScenario(UsernameEditMode.NORMAL)
scenario.moveToState(Lifecycle.State.RESUMED)

View File

@@ -14,6 +14,7 @@ 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.api.push.ServiceId
import org.whispersystems.signalservice.api.util.toByteArray
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.DataMessage
import org.whispersystems.signalservice.internal.push.Envelope
@@ -52,13 +53,16 @@ object FakeClientHelpers {
}
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())
.serverGuid(UUID.randomUUID().toString())
.destinationServiceIdBinary(destination.toByteString())
.serverGuid(serverGuid.toString())
.serverGuidBinary(serverGuid.toByteArray().toByteString())
.content(Base64.decode(this.content).toByteString())
.urgent(true)
.story(false)

View File

@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.api.util.toByteArray
import org.whispersystems.signalservice.internal.push.AddressableMessage
import org.whispersystems.signalservice.internal.push.AttachmentPointer
import org.whispersystems.signalservice.internal.push.BodyRange
@@ -43,7 +44,7 @@ object MessageContentFuzzer {
return Envelope.Builder()
.timestamp(timestamp)
.serverTimestamp(timestamp + 5)
.serverGuid(serverGuid.toString())
.serverGuidBinary(serverGuid.toByteArray().toByteString())
.build()
}
@@ -127,7 +128,7 @@ object MessageContentFuzzer {
unidentifiedStatus(
deliveredTo.map {
SyncMessage.Sent.UnidentifiedDeliveryStatus.Builder().buildWith {
destinationServiceId = Recipient.resolved(it).requireServiceId().toString()
destinationServiceIdBinary = Recipient.resolved(it).requireServiceId().toByteString()
unidentified = true
}
}
@@ -147,7 +148,7 @@ object MessageContentFuzzer {
SyncMessage.Builder().buildWith {
read = timestamps.map { (senderId, timestamp) ->
SyncMessage.Read.Builder().buildWith {
this.senderAci = Recipient.resolved(senderId).requireAci().toString()
this.senderAciBinary = Recipient.resolved(senderId).requireAci().toByteString()
this.timestamp = timestamp
}
}
@@ -167,12 +168,12 @@ object MessageContentFuzzer {
conversation = if (conversation.isGroup) {
ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString())
} else {
ConversationIdentifier(threadServiceId = conversation.requireAci().toString())
ConversationIdentifier(threadServiceIdBinary = conversation.requireAci().toByteString())
},
messages = conversationDeletes.map { (author, timestamp) ->
AddressableMessage(
authorServiceId = Recipient.resolved(author).requireAci().toString(),
authorServiceIdBinary = Recipient.resolved(author).requireAci().toByteString(),
sentTimestamp = timestamp
)
}
@@ -195,19 +196,19 @@ object MessageContentFuzzer {
conversation = if (conversation.isGroup) {
ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString())
} else {
ConversationIdentifier(threadServiceId = conversation.requireAci().toString())
ConversationIdentifier(threadServiceIdBinary = conversation.requireAci().toByteString())
},
mostRecentMessages = delete.messages.map { (author, timestamp) ->
AddressableMessage(
authorServiceId = Recipient.resolved(author).requireAci().toString(),
authorServiceIdBinary = Recipient.resolved(author).requireAci().toByteString(),
sentTimestamp = timestamp
)
},
mostRecentNonExpiringMessages = delete.nonExpiringMessages.map { (author, timestamp) ->
AddressableMessage(
authorServiceId = Recipient.resolved(author).requireAci().toString(),
authorServiceIdBinary = Recipient.resolved(author).requireAci().toByteString(),
sentTimestamp = timestamp
)
},
@@ -232,7 +233,7 @@ object MessageContentFuzzer {
conversation = if (conversation.isGroup) {
ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString())
} else {
ConversationIdentifier(threadServiceId = conversation.requireAci().toString())
ConversationIdentifier(threadServiceIdBinary = conversation.requireAci().toByteString())
}
)
}
@@ -254,10 +255,10 @@ object MessageContentFuzzer {
conversation = if (conversation.isGroup) {
ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString())
} else {
ConversationIdentifier(threadServiceId = conversation.requireAci().toString())
ConversationIdentifier(threadServiceIdBinary = conversation.requireAci().toByteString())
},
targetMessage = AddressableMessage(
authorServiceId = Recipient.resolved(message.first).requireAci().toString(),
authorServiceIdBinary = Recipient.resolved(message.first).requireAci().toByteString(),
sentTimestamp = message.second
),
clientUuid = uuid?.let { UuidUtil.toByteString(it) },
@@ -290,7 +291,7 @@ object MessageContentFuzzer {
val quoted = quoteAble.random(random)
quote = DataMessage.Quote.Builder().buildWith {
id = quoted.envelope.timestamp
authorAci = quoted.metadata.sourceServiceId.toString()
authorAciBinary = quoted.metadata.sourceServiceId.toByteString()
text = quoted.content.dataMessage?.body
attachments(quoted.content.dataMessage?.attachments ?: emptyList())
bodyRanges(quoted.content.dataMessage?.bodyRanges ?: emptyList())
@@ -302,7 +303,7 @@ object MessageContentFuzzer {
val quoted = quoteAble.random(random)
quote = DataMessage.Quote.Builder().buildWith {
id = random.nextLong(quoted.envelope.timestamp!! - 1000000, quoted.envelope.timestamp!!)
authorAci = quoted.metadata.sourceServiceId.toString()
authorAciBinary = quoted.metadata.sourceServiceId.toByteString()
text = quoted.content.dataMessage?.body
}
}
@@ -329,7 +330,7 @@ object MessageContentFuzzer {
reaction = DataMessage.Reaction.Builder().buildWith {
emoji = emojis.random(random)
remove = false
targetAuthorAci = reactTo.metadata.sourceServiceId.toString()
targetAuthorAciBinary = reactTo.metadata.sourceServiceId.toByteString()
targetSentTimestamp = reactTo.envelope.timestamp
}
}

View File

@@ -1,59 +0,0 @@
package org.thoughtcrime.securesms.testing
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.RecordedRequest
import okhttp3.mockwebserver.SocketPolicy
import org.thoughtcrime.securesms.util.JsonUtils
import java.util.concurrent.TimeUnit
typealias ResponseFactory = (request: RecordedRequest) -> MockResponse
typealias RequestPredicate = (request: RecordedRequest) -> Boolean
/**
* Represent an HTTP verb for mocking web requests.
*/
sealed class Verb(val requestPredicate: RequestPredicate, val responseFactory: ResponseFactory)
class Get(path: String, predicate: RequestPredicate, responseFactory: ResponseFactory) : Verb(defaultRequestPredicate("GET", path, predicate), responseFactory) {
constructor(path: String, responseFactory: ResponseFactory) : this(path, { true }, responseFactory)
}
class Put(path: String, responseFactory: ResponseFactory) : Verb(defaultRequestPredicate("PUT", path), responseFactory)
class Post(path: String, responseFactory: ResponseFactory) : Verb(defaultRequestPredicate("POST", path), responseFactory)
class Delete(path: String, responseFactory: ResponseFactory) : Verb(defaultRequestPredicate("DELETE", path), responseFactory)
fun MockResponse.success(response: Any? = null): MockResponse {
return setResponseCode(200).apply {
if (response != null) {
setBody(JsonUtils.toJson(response))
}
}
}
fun MockResponse.failure(code: Int, response: Any? = null): MockResponse {
return setResponseCode(code).apply {
if (response != null) {
setBody(JsonUtils.toJson(response))
}
}
}
fun MockResponse.connectionFailure(): MockResponse {
return setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)
}
fun MockResponse.timeout(): MockResponse {
return setHeadersDelay(1, TimeUnit.DAYS)
.setBodyDelay(1, TimeUnit.DAYS)
}
inline fun <reified T> RecordedRequest.parsedRequestBody(): T {
val bodyString = String(body.readByteArray())
return JsonUtils.fromJson(bodyString, T::class.java)
}
private fun defaultRequestPredicate(verb: String, path: String, predicate: RequestPredicate = { true }): RequestPredicate = { request ->
request.method == verb && request.path?.startsWith("/$path") == true && predicate(request)
}

View File

@@ -9,20 +9,17 @@ import android.preference.PreferenceManager
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import org.junit.rules.ExternalResource
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.thoughtcrime.securesms.SignalInstrumentationApplicationContext
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.IdentityTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.keyvalue.NewAccount
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.ProfileName
@@ -81,8 +78,6 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
others[1].asMember()
)
}
InstrumentationApplicationDependencyProvider.clearHandlers()
}
private fun setupSelf(): Recipient {
@@ -95,7 +90,6 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
SignalStore.account.generateAciIdentityKeyIfNecessary()
SignalStore.account.generatePniIdentityKeyIfNecessary()
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(Put("/v2/keys") { MockResponse().success() })
runBlocking {
val registrationData = RegistrationData(
code = "123123",
@@ -148,7 +142,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()
val otherIdentity = IdentityKeyPair.generate()
AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(aci.toString(), 1), otherIdentity.publicKey)
others += recipientId
othersKeys += otherIdentity
@@ -161,7 +155,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
return androidx.test.core.app.launchActivity(Intent(context, T::class.java).apply(initIntent))
}
fun changeIdentityKey(recipient: Recipient, identityKey: IdentityKey = IdentityKeyUtil.generateIdentityKeyPair().publicKey) {
fun changeIdentityKey(recipient: Recipient, identityKey: IdentityKey = IdentityKeyPair.generate().publicKey) {
AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(recipient.requireServiceId().toString(), 0), identityKey)
}

View File

@@ -4,8 +4,8 @@ import android.app.Application
import android.content.SharedPreferences
import android.preference.PreferenceManager
import kotlinx.coroutines.runBlocking
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.SignalDatabase
@@ -96,7 +96,7 @@ object TestUsers {
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()
val otherIdentity = IdentityKeyPair.generate()
AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(aci.toString(), 1), otherIdentity.publicKey)
others += recipientId

View File

@@ -129,6 +129,10 @@
<meta-data android:name="firebase_messaging_auto_init_enabled" android:value="false" />
<meta-data android:name="android.webkit.WebView.MetricsOptOut" android:value="true" />
<meta-data
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
android:value="org.thoughtcrime.securesms" />
<activity android:name=".components.webrtc.v2.WebRtcCallActivity"
android:theme="@style/TextSecure.DarkTheme.WebRTCCall"
android:excludeFromRecents="true"
@@ -692,13 +696,8 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".NewConversationActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity
android:name=".conversation.NewConversationActivityV2"
android:name=".conversation.NewConversationActivity"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar" />
@@ -1046,7 +1045,7 @@
<activity android:name=".MainActivity"
android:enableOnBackInvokedCallback="true"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout"
android:configChanges="touchscreen|keyboard|keyboardHidden"
android:windowSoftInputMode="stateUnchanged"
android:resizeableActivity="true"
android:exported="false"/>
@@ -1056,9 +1055,10 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".groups.ui.creategroup.CreateGroupActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:exported="false"/>
<activity
android:name=".groups.ui.creategroup.CreateGroupActivity"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar" />
<activity android:name=".groups.ui.addtogroup.AddToGroupsActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"

View File

@@ -490,7 +490,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
}
private void ensureProfileUploaded() {
if (SignalStore.account().isRegistered() && !SignalStore.registration().hasUploadedProfile() && !Recipient.self().getProfileName().isEmpty()) {
if (SignalStore.account().isRegistered() && !SignalStore.registration().hasUploadedProfile() && !Recipient.self().getProfileName().isEmpty() && SignalStore.account().isPrimaryDevice()) {
Log.w(TAG, "User has a profile, but has not uploaded one. Uploading now.");
AppDependencies.getJobManager().add(new ProfileUploadJob());
}

View File

@@ -29,6 +29,7 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
@@ -69,9 +70,9 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
@Override
protected void onCreate(Bundle icicle, boolean ready) {
if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) {
if (!getIntent().hasExtra(ContactSelectionArguments.DISPLAY_MODE)) {
int displayMode = ContactSelectionDisplayMode.FLAG_PUSH | ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_SELF;
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode);
getIntent().putExtra(ContactSelectionArguments.DISPLAY_MODE, displayMode);
}
setContentView(getIntent().getIntExtra(EXTRA_LAYOUT_RES_ID, R.layout.contact_selection_activity));

View File

@@ -20,7 +20,6 @@ package org.thoughtcrime.securesms;
import android.Manifest;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.graphics.Rect;
import android.os.AsyncTask;
import android.os.Bundle;
@@ -66,6 +65,7 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.groups.SelectionLimits;
@@ -86,7 +86,6 @@ import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@@ -97,7 +96,7 @@ import io.reactivex.rxjava3.disposables.Disposable;
import kotlin.Unit;
/**
* Fragment for selecting a one or more contacts from a list.
* Fragment for selecting one or more contacts from a list.
*
* @author Moxie Marlinspike
*/
@@ -110,17 +109,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public static final int NO_LIMIT = Integer.MAX_VALUE;
public static final String DISPLAY_MODE = "display_mode";
public static final String REFRESHABLE = "refreshable";
public static final String RECENTS = "recents";
public static final String SELECTION_LIMITS = "selection_limits";
public static final String CURRENT_SELECTION = "current_selection";
public static final String HIDE_COUNT = "hide_count";
public static final String CAN_SELECT_SELF = "can_select_self";
public static final String DISPLAY_CHIPS = "display_chips";
public static final String RV_PADDING_BOTTOM = "recycler_view_padding_bottom";
public static final String RV_CLIP = "recycler_view_clipping";
public static final String INCLUDE_CHAT_TYPES = "include_chat_types";
private ContactSelectionArguments fragmentArgs;
private ConstraintLayout constraintLayout;
private TextView emptyText;
@@ -157,11 +146,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
super.onAttach(context);
if (context instanceof NewConversationCallback) {
newConversationCallback = (NewConversationCallback) context;
setNewConversationCallback((NewConversationCallback) context);
}
if (context instanceof FindByCallback) {
showFindByUsernameAndPhoneOptions((FindByCallback) context);
setFindByCallback((FindByCallback) context);
}
if (context instanceof NewCallCallback) {
@@ -169,11 +158,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
if (getParentFragment() instanceof ScrollCallback) {
scrollCallback = (ScrollCallback) getParentFragment();
setScrollCallback((ScrollCallback) getParentFragment());
}
if (context instanceof ScrollCallback) {
scrollCallback = (ScrollCallback) context;
setScrollCallback((ScrollCallback) context);
}
if (getParentFragment() instanceof OnContactSelectedListener) {
@@ -201,22 +190,34 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
if (context instanceof OnItemLongClickListener) {
onItemLongClickListener = (OnItemLongClickListener) context;
setOnItemLongClickListener((OnItemLongClickListener) context);
}
if (getParentFragment() instanceof OnItemLongClickListener) {
onItemLongClickListener = (OnItemLongClickListener) getParentFragment();
setOnItemLongClickListener((OnItemLongClickListener) getParentFragment());
}
}
public void showFindByUsernameAndPhoneOptions(@Nullable FindByCallback callback) {
public void setNewConversationCallback(@Nullable NewConversationCallback callback) {
this.newConversationCallback = callback;
}
public void setFindByCallback(@Nullable FindByCallback callback) {
this.findByCallback = callback;
}
public void setScrollCallback(@Nullable ScrollCallback callback) {
this.scrollCallback = callback;
}
public void setOnContactSelectedListener(@Nullable OnContactSelectedListener listener) {
this.onContactSelectedListener = listener;
}
public void setOnItemLongClickListener(@Nullable OnItemLongClickListener listener) {
this.onItemLongClickListener = listener;
}
@Override
public void onActivityCreated(Bundle icicle) {
super.onActivityCreated(icicle);
@@ -275,28 +276,20 @@ public final class ContactSelectionListFragment extends LoggingFragment {
lifecycleDisposable.add(disposable);
Intent intent = requireActivity().getIntent();
Bundle arguments = safeArguments();
fragmentArgs = ContactSelectionArguments.fromBundle(safeArguments(), requireActivity().getIntent());
int recyclerViewPadBottom = arguments.getInt(RV_PADDING_BOTTOM, intent.getIntExtra(RV_PADDING_BOTTOM, -1));
boolean recyclerViewClipping = arguments.getBoolean(RV_CLIP, intent.getBooleanExtra(RV_CLIP, true));
if (recyclerViewPadBottom != -1) {
ViewUtil.setPaddingBottom(recyclerView, recyclerViewPadBottom);
if (fragmentArgs.getRecyclerPadBottom() != -1) {
ViewUtil.setPaddingBottom(recyclerView, fragmentArgs.getRecyclerPadBottom());
}
recyclerView.setClipToPadding(recyclerViewClipping);
recyclerView.setClipToPadding(fragmentArgs.getRecyclerChildClipping());
boolean isRefreshable = arguments.getBoolean(REFRESHABLE, intent.getBooleanExtra(REFRESHABLE, true));
swipeRefresh.setNestedScrollingEnabled(isRefreshable);
swipeRefresh.setEnabled(isRefreshable);
swipeRefresh.setNestedScrollingEnabled(fragmentArgs.isRefreshable());
swipeRefresh.setEnabled(fragmentArgs.isRefreshable());
selectionLimit = arguments.getParcelable(SELECTION_LIMITS);
if (selectionLimit == null) {
selectionLimit = intent.getParcelableExtra(SELECTION_LIMITS);
}
isMulti = selectionLimit != null;
canSelectSelf = arguments.getBoolean(CAN_SELECT_SELF, intent.getBooleanExtra(CAN_SELECT_SELF, !isMulti));
selectionLimit = fragmentArgs.getSelectionLimits();
isMulti = selectionLimit != null;
canSelectSelf = fragmentArgs.getCanSelectSelf();
if (!isMulti) {
selectionLimit = SelectionLimits.NO_LIMITS;
@@ -453,14 +446,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
onRefreshListener = null;
}
public int getSelectedMembersSize() {
if (contactSearchMediator == null) {
return 0;
}
return contactSearchMediator.getSelectedMembersSize();
}
private @NonNull Bundle safeArguments() {
return getArguments() != null ? getArguments() : new Bundle();
}
@@ -482,7 +467,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public int getSelectedContactsCount() {
return getSelectedMembersSize();
if (contactSearchMediator == null) {
return 0;
}
return contactSearchMediator.getSelectedContacts().size();
}
public int getTotalMemberCount() {
@@ -494,13 +483,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
private Set<RecipientId> getCurrentSelection() {
List<RecipientId> currentSelection = safeArguments().getParcelableArrayList(CURRENT_SELECTION);
if (currentSelection == null) {
currentSelection = requireActivity().getIntent().getParcelableArrayListExtra(CURRENT_SELECTION);
}
return currentSelection == null ? Collections.emptySet()
: Collections.unmodifiableSet(new HashSet<>(currentSelection));
return Set.copyOf(fragmentArgs.getCurrentSelection());
}
public boolean isMulti() {
@@ -617,7 +600,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
private boolean shouldDisplayRecents() {
return safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
return fragmentArgs.getIncludeRecents();
}
@SuppressLint("StaticFieldLeak")
@@ -869,7 +852,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
private void setChipGroupVisibility(int visibility) {
if (!safeArguments().getBoolean(DISPLAY_CHIPS, requireActivity().getIntent().getBooleanExtra(DISPLAY_CHIPS, true))) {
if (!fragmentArgs.getDisplayChips()) {
return;
}
@@ -885,7 +868,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
constraintSet.applyTo(constraintLayout);
}
public void setOnRefreshListener(SwipeRefreshLayout.OnRefreshListener onRefreshListener) {
public void setOnRefreshListener(@Nullable SwipeRefreshLayout.OnRefreshListener onRefreshListener) {
this.onRefreshListener = onRefreshListener;
this.swipeRefresh.setOnRefreshListener(onRefreshListener);
}
@@ -896,9 +879,9 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
private @NonNull ContactSearchConfiguration mapStateToConfiguration(@NonNull ContactSearchState contactSearchState) {
int displayMode = safeArguments().getInt(DISPLAY_MODE, requireActivity().getIntent().getIntExtra(DISPLAY_MODE, ContactSelectionDisplayMode.FLAG_ALL));
int displayMode = fragmentArgs.getDisplayMode();
boolean includeRecents = safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
boolean includeRecents = fragmentArgs.getIncludeRecents();
boolean includePushContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_PUSH);
boolean includeSmsContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_SMS);
boolean includeActiveGroups = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS);
@@ -910,7 +893,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
boolean includeGroupsAfterContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS);
boolean blocked = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_BLOCK);
boolean includeGroupMembers = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUP_MEMBERS);
boolean includeChatTypes = safeArguments().getBoolean(INCLUDE_CHAT_TYPES);
boolean includeChatTypes = fragmentArgs.getIncludeChatTypes();
boolean hasQuery = !TextUtils.isEmpty(contactSearchState.getQuery());
ContactSearchConfiguration.TransportType transportType = resolveTransportType(includePushContacts, includeSmsContacts);
@@ -928,12 +911,15 @@ public final class ContactSelectionListFragment extends LoggingFragment {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
}
if (newConversationCallback != null && !hasQuery) {
if (fragmentArgs.getEnableCreateNewGroup() && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode());
}
if (findByCallback != null && !hasQuery) {
if (fragmentArgs.getEnableFindByUsername() && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_USERNAME.getCode());
}
if (fragmentArgs.getEnableFindByPhoneNumber() && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
}

View File

@@ -16,7 +16,6 @@ import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
@@ -30,6 +29,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
@@ -46,9 +47,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.fragment.app.DialogFragment
@@ -60,6 +63,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.RecyclerView
import androidx.window.core.layout.WindowSizeClass
import androidx.window.core.layout.WindowWidthSizeClass
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
@@ -89,6 +94,7 @@ import org.thoughtcrime.securesms.components.settings.app.notifications.manual.N
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.NewConversationActivity
import org.thoughtcrime.securesms.conversation.v2.MotionEventRelay
import org.thoughtcrime.securesms.conversation.v2.ShareDataTimestampViewModel
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
@@ -101,8 +107,8 @@ import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.main.ChatNavGraphState
import org.thoughtcrime.securesms.main.DetailsScreenNavHost
import org.thoughtcrime.securesms.main.InsetsViewModelUpdater
import org.thoughtcrime.securesms.main.MainBottomChrome
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
import org.thoughtcrime.securesms.main.MainBottomChromeState
@@ -158,8 +164,10 @@ import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.viewModel
import org.thoughtcrime.securesms.window.AppPaneDragHandle
import org.thoughtcrime.securesms.window.AppScaffold
import org.thoughtcrime.securesms.window.AppScaffoldAnimationStateFactory
import org.thoughtcrime.securesms.window.AppScaffoldNavigator
import org.thoughtcrime.securesms.window.WindowSizeClass
import org.thoughtcrime.securesms.window.NavigationType
import org.thoughtcrime.securesms.window.isSplitPane
import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDelegate
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
@@ -285,22 +293,6 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
}
val callback = object : OnBackPressedCallback(toolbarViewModel.state.value.mode == MainToolbarMode.ACTION_MODE) {
override fun handleOnBackPressed() {
toolbarCallback.onCloseActionModeClick()
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
toolbarViewModel.state.collect { state ->
callback.isEnabled = state.mode == MainToolbarMode.ACTION_MODE
}
}
}
onBackPressedDispatcher.addCallback(this, callback)
shareDataTimestampViewModel.setTimestampFromActivityCreation(savedInstanceState, intent)
setContent {
@@ -318,14 +310,27 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
}
val isNavigationVisible = mainToolbarState.mode == MainToolbarMode.FULL
val isBackHandlerEnabled = mainToolbarState.destination != MainNavigationListLocation.CHATS
val isActionModeActive = mainToolbarState.mode == MainToolbarMode.ACTION_MODE
val isNavigationRailVisible = mainToolbarState.mode != MainToolbarMode.SEARCH
val isNavigationBarVisible = mainToolbarState.mode == MainToolbarMode.FULL
val isBackHandlerEnabled = mainToolbarState.destination != MainNavigationListLocation.CHATS && !isActionModeActive
BackHandler(enabled = isBackHandlerEnabled) {
mainNavigationViewModel.setFocusedPane(ThreePaneScaffoldRole.Secondary)
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
}
BackHandler(enabled = isActionModeActive) {
toolbarCallback.onCloseActionModeClick()
}
val focusManager = LocalFocusManager.current
LaunchedEffect(mainToolbarState.mode) {
if (mainToolbarState.mode == MainToolbarMode.ACTION_MODE) {
focusManager.clearFocus()
}
}
val mainBottomChromeState = remember(mainToolbarState.destination, snackbar, mainToolbarState.mode, megaphone) {
MainBottomChromeState(
destination = mainToolbarState.destination,
@@ -338,25 +343,51 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
)
}
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
val contentLayoutData = MainContentLayoutData.rememberContentLayoutData()
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val contentLayoutData = MainContentLayoutData.rememberContentLayoutData(mainToolbarState.mode)
MainContainer {
val wrappedNavigator = rememberNavigator(windowSizeClass, contentLayoutData, maxWidth)
val listPaneWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
val halfPartitionWidth = contentLayoutData.partitionWidth / 2
val navigationType = NavigationType.rememberNavigationType()
val detailOffset = if (mainToolbarState.mode == MainToolbarMode.SEARCH || mainToolbarState.mode == MainToolbarMode.ACTION_MODE) 0.dp else 72.dp
val detailOnlyAnchor = PaneExpansionAnchor.Offset.fromStart(detailOffset + contentLayoutData.listPaddingStart + halfPartitionWidth)
val detailAndListAnchor = PaneExpansionAnchor.Offset.fromStart(listPaneWidth + halfPartitionWidth)
val listOnlyAnchor = PaneExpansionAnchor.Offset.fromEnd(contentLayoutData.detailPaddingEnd - halfPartitionWidth)
val anchors = remember(contentLayoutData, mainToolbarState) {
val halfPartitionWidth = contentLayoutData.partitionWidth / 2
val detailOffset = when {
mainToolbarState.mode == MainToolbarMode.SEARCH -> 0.dp
navigationType == NavigationType.BAR -> 0.dp
else -> 80.dp
}
val detailOnlyAnchor = PaneExpansionAnchor.Offset.fromStart(detailOffset + contentLayoutData.listPaddingStart + halfPartitionWidth)
val detailAndListAnchor = PaneExpansionAnchor.Offset.fromStart(listPaneWidth + halfPartitionWidth)
val listOnlyAnchor = PaneExpansionAnchor.Offset.fromEnd(contentLayoutData.detailPaddingEnd - halfPartitionWidth)
listOf(detailOnlyAnchor, detailAndListAnchor, listOnlyAnchor)
}
val (detailOnlyAnchor, detailAndListAnchor, listOnlyAnchor) = anchors
val paneExpansionState = rememberPaneExpansionState(
anchors = listOf(detailOnlyAnchor, detailAndListAnchor, listOnlyAnchor)
key = wrappedNavigator.scaffoldValue.paneExpansionStateKey,
anchors = anchors,
initialAnchoredIndex = 1
)
val paneAnchorIndex = rememberSaveable(paneExpansionState.currentAnchor) {
anchors.indexOf(paneExpansionState.currentAnchor)
}
LaunchedEffect(windowSizeClass) {
val anchor = anchors[paneAnchorIndex]
paneExpansionState.animateTo(anchor)
}
val chatNavGraphState = ChatNavGraphState.remember(windowSizeClass)
val mutableInteractionSource = remember { MutableInteractionSource() }
val mainNavigationDetailLocation by rememberMainNavigationDetailLocation(mainNavigationViewModel)
val mainNavigationDetailLocation by rememberMainNavigationDetailLocation(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
val chatsNavHostController = rememberDetailNavHostController(
onRequestFocus = rememberFocusRequester(
@@ -365,7 +396,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
isTargetListLocation = { it in listOf(MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE) }
)
) {
chatNavGraphBuilder()
chatNavGraphBuilder(chatNavGraphState)
}
val callsNavHostController = rememberDetailNavHostController(
@@ -397,31 +428,81 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}.navigateToDetailLocation(mainNavigationDetailLocation)
}
is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
is MainNavigationDetailLocation.Chats -> {
chatNavGraphState.writeGraphicsLayerToBitmap()
chatsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
}
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
}
}
LaunchedEffect(mainNavigationDetailLocation) {
if (paneExpansionState.currentAnchor == listOnlyAnchor && wrappedNavigator.currentDestination?.pane == ThreePaneScaffoldRole.Primary) {
paneExpansionState.animateTo(detailOnlyAnchor)
}
}
LaunchedEffect(mainNavigationState.currentListLocation) {
if (paneExpansionState.currentAnchor == detailOnlyAnchor && wrappedNavigator.currentDestination?.pane == ThreePaneScaffoldRole.Secondary) {
val scope = rememberCoroutineScope()
BackHandler(paneExpansionState.currentAnchor == detailOnlyAnchor) {
scope.launch {
paneExpansionState.animateTo(listOnlyAnchor)
}
}
InsetsViewModelUpdater()
LaunchedEffect(paneExpansionState.currentAnchor, detailOnlyAnchor, listOnlyAnchor, detailAndListAnchor) {
val isFullScreenPane = when (paneExpansionState.currentAnchor) {
listOnlyAnchor, detailOnlyAnchor -> {
true
}
else -> {
false
}
}
mainNavigationViewModel.onPaneAnchorChanged(isFullScreenPane)
}
LaunchedEffect(paneExpansionState.currentAnchor) {
when (paneExpansionState.currentAnchor) {
listOnlyAnchor -> {
mainNavigationViewModel.setFocusedPane(ThreePaneScaffoldRole.Secondary)
}
detailOnlyAnchor -> {
mainNavigationViewModel.setFocusedPane(ThreePaneScaffoldRole.Primary)
}
else -> Unit
}
}
val paneFocusRequest by mainNavigationViewModel.paneFocusRequests.collectAsStateWithLifecycle(null)
LaunchedEffect(paneFocusRequest) {
if (paneFocusRequest == null) {
return@LaunchedEffect
}
if (paneFocusRequest == ThreePaneScaffoldRole.Secondary && paneExpansionState.currentAnchor == detailOnlyAnchor) {
paneExpansionState.animateTo(listOnlyAnchor)
}
if (paneFocusRequest == ThreePaneScaffoldRole.Primary && paneExpansionState.currentAnchor == listOnlyAnchor) {
paneExpansionState.animateTo(detailOnlyAnchor)
}
}
val noEnterTransitionFactory = remember {
AppScaffoldAnimationStateFactory(
enabledStates = AppScaffoldNavigator.NavigationState.entries.filterNot {
it == AppScaffoldNavigator.NavigationState.ENTER
}.toSet()
)
}
AppScaffold(
navigator = wrappedNavigator,
modifier = chatNavGraphState.writeContentToGraphicsLayer(),
paneExpansionState = paneExpansionState,
contentWindowInsets = WindowInsets(),
bottomNavContent = {
if (isNavigationVisible) {
if (isNavigationBarVisible) {
Column(
modifier = Modifier
.clip(contentLayoutData.navigationBarShape)
@@ -439,7 +520,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
},
navRailContent = {
if (isNavigationVisible) {
if (isNavigationRailVisible) {
MainNavigationRail(
state = mainNavigationState,
mainFloatingActionButtonsCallback = mainBottomChromeCallback,
@@ -448,7 +529,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
},
secondaryContent = {
val listContainerColor = if (windowSizeClass.isMedium()) {
val listContainerColor = if (windowSizeClass.isSplitPane() && windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.MEDIUM) {
SignalTheme.colors.colorSurface1
} else {
MaterialTheme.colorScheme.surface
@@ -547,7 +628,14 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
mutableInteractionSource = mutableInteractionSource
)
}
} else null
} else {
null
},
animatorFactory = if (mainNavigationState.currentListLocation == MainNavigationListLocation.CHATS || mainNavigationState.currentListLocation == MainNavigationListLocation.ARCHIVE) {
noEnterTransitionFactory
} else {
AppScaffoldAnimationStateFactory.Default
}
)
}
}
@@ -621,10 +709,10 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
@Composable
private fun MainContainer(content: @Composable BoxWithConstraintsScope.() -> Unit) {
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(this)) {
val backgroundColor = if (windowSizeClass.isCompact()) {
val backgroundColor = if (!windowSizeClass.isSplitPane()) {
MaterialTheme.colorScheme.surface
} else {
SignalTheme.colors.colorSurface1
@@ -911,7 +999,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
inner class ToolbarCallback : MainToolbarCallback {
override fun onNewGroupClick() {
startActivity(CreateGroupActivity.newIntent(this@MainActivity))
startActivity(CreateGroupActivity.createIntent(this@MainActivity))
}
override fun onClearPassphraseClick() {
@@ -997,7 +1085,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
inner class BottomChromeCallback : MainBottomChromeCallback {
override fun onNewChatClick() {
startActivity(Intent(this@MainActivity, NewConversationActivity::class.java))
startActivity(NewConversationActivity.createIntent(this@MainActivity))
}
override fun onNewCallClick() {

View File

@@ -1,7 +1,6 @@
package org.thoughtcrime.securesms;
import android.app.Activity;
import android.content.Intent;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
@@ -56,7 +55,7 @@ public class MainNavigator {
}
public void goToGroupCreation() {
activity.startActivity(CreateGroupActivity.newIntent(activity));
activity.startActivity(CreateGroupActivity.createIntent(activity));
}
private @NonNull FragmentManager getFragmentManager() {

View File

@@ -1,403 +0,0 @@
/*
* Copyright (C) 2015 Open Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms;
import android.content.Intent;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.snackbar.Snackbar;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar;
import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.contacts.management.ContactsManagementRepository;
import org.thoughtcrime.securesms.contacts.management.ContactsManagementViewModel;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientRepository;
import org.thoughtcrime.securesms.recipients.ui.findby.FindByActivity;
import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import io.reactivex.rxjava3.disposables.Disposable;
/**
* Activity container for starting a new conversation.
*
* @author Moxie Marlinspike
*/
public class NewConversationActivity extends ContactSelectionActivity
implements ContactSelectionListFragment.NewConversationCallback, ContactSelectionListFragment.OnItemLongClickListener, ContactSelectionListFragment.FindByCallback
{
@SuppressWarnings("unused")
private static final String TAG = Log.tag(NewConversationActivity.class);
private ContactsManagementViewModel viewModel;
private ActivityResultLauncher<Intent> contactLauncher;
private ActivityResultLauncher<Intent> createGroupLauncher;
private ActivityResultLauncher<FindByMode> findByLauncher;
private final LifecycleDisposable disposables = new LifecycleDisposable();
@Override
public void onCreate(Bundle bundle, boolean ready) {
super.onCreate(bundle, ready);
assert getSupportActionBar() != null;
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setTitle(R.string.NewConversationActivity__new_message);
disposables.bindTo(this);
ContactsManagementRepository repository = new ContactsManagementRepository(this);
ContactsManagementViewModel.Factory factory = new ContactsManagementViewModel.Factory(repository);
contactLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), activityResult -> {
if (activityResult.getResultCode() != RESULT_CANCELED) {
handleManualRefresh();
}
});
findByLauncher = registerForActivityResult(new FindByActivity.Contract(), result -> {
if (result != null) {
launch(result);
}
});
createGroupLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
if (result.getResultCode() == RESULT_OK) {
finish();
}
});
viewModel = new ViewModelProvider(this, factory).get(ContactsManagementViewModel.class);
}
@Override
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
if (recipientId.isPresent()) {
launch(Recipient.resolved(recipientId.get()));
} else {
Log.i(TAG, "[onContactSelected] Maybe creating a new recipient.");
if (SignalStore.account().isRegistered()) {
Log.i(TAG, "[onContactSelected] Doing contact refresh.");
AlertDialog progress = SimpleProgressDialog.show(this);
SimpleTask.run(getLifecycle(), () -> RecipientRepository.lookupNewE164(number), result -> {
progress.dismiss();
if (result instanceof RecipientRepository.LookupResult.Success) {
Recipient resolved = Recipient.resolved(((RecipientRepository.LookupResult.Success) result).getRecipientId());
if (resolved.isRegistered() && resolved.getHasServiceId()) {
launch(resolved);
}
} else if (result instanceof RecipientRepository.LookupResult.NotFound || result instanceof RecipientRepository.LookupResult.InvalidEntry) {
new MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.NewConversationActivity__s_is_not_a_signal_user, number))
.setPositiveButton(android.R.string.ok, null)
.show();
} else {
new MaterialAlertDialogBuilder(this)
.setMessage(R.string.NetworkFailure__network_error_check_your_connection_and_try_again)
.setPositiveButton(android.R.string.ok, null)
.show();
}
});
}
}
callback.accept(true);
}
@Override
public void onSelectionChanged() {
}
private void launch(Recipient recipient) {
launch(recipient.getId());
}
private void launch(RecipientId recipientId) {
Disposable disposable = ConversationIntents.createBuilder(this, recipientId, -1L)
.map(builder -> builder
.withDraftText(getIntent().getStringExtra(Intent.EXTRA_TEXT))
.withDataUri(getIntent().getData())
.withDataType(getIntent().getType())
.build())
.subscribe(intent -> {
startActivity(intent);
finish();
});
disposables.add(disposable);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
super.onOptionsItemSelected(item);
int itemId = item.getItemId();
if (itemId == android.R.id.home) {
super.onBackPressed();
return true;
} else if (itemId == R.id.menu_refresh) {
handleManualRefresh();
return true;
} else if (itemId == R.id.menu_new_group) {
handleCreateGroup();
return true;
} else if (itemId == R.id.menu_invite) {
handleInvite();
return true;
} else {
return false;
}
}
private void handleManualRefresh() {
if (!contactsFragment.isRefreshing()) {
contactsFragment.setRefreshing(true);
onRefresh();
}
}
private void handleCreateGroup() {
createGroupLauncher.launch(CreateGroupActivity.newIntent(this));
}
private void handleInvite() {
startActivity(AppSettingsActivity.invite(this));
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
menu.clear();
getMenuInflater().inflate(R.menu.new_conversation_activity, menu);
super.onCreateOptionsMenu(menu);
return true;
}
@Override
public void onInvite() {
handleInvite();
finish();
}
@Override
public void onNewGroup(boolean forceV1) {
handleCreateGroup();
// finish();
}
@Override
public void onFindByUsername() {
findByLauncher.launch(FindByMode.USERNAME);
}
@Override
public void onFindByPhoneNumber() {
findByLauncher.launch(FindByMode.PHONE_NUMBER);
}
@Override
public boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView) {
RecipientId recipientId = contactSearchKey.requireRecipientSearchKey().getRecipientId();
List<ActionItem> actions = generateContextualActionsForRecipient(recipientId);
if (actions.isEmpty()) {
return false;
}
new SignalContextMenu.Builder(anchorView, (ViewGroup) anchorView.getRootView())
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
.offsetX((int) DimensionUnit.DP.toPixels(12))
.offsetY((int) DimensionUnit.DP.toPixels(12))
.onDismiss(() -> recyclerView.suppressLayout(false))
.show(actions);
recyclerView.suppressLayout(true);
return true;
}
private @NonNull List<ActionItem> generateContextualActionsForRecipient(@NonNull RecipientId recipientId) {
Recipient recipient = Recipient.resolved(recipientId);
return Stream.of(
createMessageActionItem(recipient),
createAudioCallActionItem(recipient),
createVideoCallActionItem(recipient),
createRemoveActionItem(recipient),
createBlockActionItem(recipient)
).filter(Objects::nonNull).collect(Collectors.toList());
}
private @NonNull ActionItem createMessageActionItem(@NonNull Recipient recipient) {
return new ActionItem(
R.drawable.ic_chat_message_24,
getString(R.string.NewConversationActivity__message),
R.color.signal_colorOnSurface,
() -> {
Disposable disposable = ConversationIntents.createBuilder(this, recipient.getId(), -1L)
.subscribe(builder -> startActivity(builder.build()));
disposables.add(disposable);
}
);
}
private @Nullable ActionItem createAudioCallActionItem(@NonNull Recipient recipient) {
if (recipient.isSelf() || recipient.isGroup()) {
return null;
}
if (recipient.isRegistered()) {
return new ActionItem(
R.drawable.ic_phone_right_24,
getString(R.string.NewConversationActivity__audio_call),
R.color.signal_colorOnSurface,
() -> CommunicationActions.startVoiceCall(this, recipient, () -> {
YouAreAlreadyInACallSnackbar.show(findViewById(android.R.id.content));
})
);
} else {
return null;
}
}
private @Nullable ActionItem createVideoCallActionItem(@NonNull Recipient recipient) {
if (recipient.isSelf() || recipient.isMmsGroup() || !recipient.isRegistered()) {
return null;
}
return new ActionItem(
R.drawable.ic_video_call_24,
getString(R.string.NewConversationActivity__video_call),
R.color.signal_colorOnSurface,
() -> CommunicationActions.startVideoCall(this, recipient, () -> {
YouAreAlreadyInACallSnackbar.show(findViewById(android.R.id.content));
})
);
}
private @Nullable ActionItem createRemoveActionItem(@NonNull Recipient recipient) {
if (recipient.isSelf() || recipient.isGroup()) {
return null;
}
return new ActionItem(
R.drawable.ic_minus_circle_20, // TODO [alex] -- correct asset
getString(R.string.NewConversationActivity__remove),
R.color.signal_colorOnSurface,
() -> displayRemovalDialog(recipient)
);
}
@SuppressWarnings("CodeBlock2Expr")
private @Nullable ActionItem createBlockActionItem(@NonNull Recipient recipient) {
if (recipient.isSelf()) {
return null;
}
return new ActionItem(
R.drawable.ic_block_tinted_24,
getString(R.string.NewConversationActivity__block),
R.color.signal_colorError,
() -> BlockUnblockDialog.showBlockFor(this,
this.getLifecycle(),
recipient,
() -> {
disposables.add(viewModel.blockContact(recipient).subscribe(() -> {
handleManualRefresh();
displaySnackbar(R.string.NewConversationActivity__s_has_been_blocked, recipient.getDisplayName(this));
contactsFragment.reset();
}, (throwable) -> {
displaySnackbar(R.string.NewConversationActivity__block_failed);
}));
})
);
}
private void displayIsInSystemContactsDialog(@NonNull Recipient recipient) {
new MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.NewConversationActivity__unable_to_remove_s, recipient.getShortDisplayName(this)))
.setMessage(R.string.NewConversationActivity__this_person_is_saved_to_your)
.setPositiveButton(R.string.NewConversationActivity__view_contact,
(dialog, which) -> contactLauncher.launch(new Intent(Intent.ACTION_VIEW, recipient.getContactUri()))
)
.setNegativeButton(android.R.string.cancel, null)
.show();
}
private void displayRemovalDialog(@NonNull Recipient recipient) {
new MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.NewConversationActivity__remove_s, recipient.getShortDisplayName(this)))
.setMessage(R.string.NewConversationActivity__you_wont_see_this_person)
.setPositiveButton(R.string.NewConversationActivity__remove,
(dialog, which) -> {
disposables.add(viewModel.hideContact(recipient).subscribe(() -> {
handleManualRefresh();
displaySnackbar(R.string.NewConversationActivity__s_has_been_removed, recipient.getDisplayName(this));
contactsFragment.reset();
}));
}
)
.setNegativeButton(android.R.string.cancel, null)
.show();
}
private void displaySnackbar(@StringRes int message, Object... formatArgs) {
Snackbar.make(findViewById(android.R.id.content), getString(message, formatArgs), Snackbar.LENGTH_SHORT).show();
}
}

View File

@@ -195,7 +195,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return !SignalStore.registration().isRegistrationComplete() &&
!SignalStore.svr().hasPin() &&
!SignalStore.svr().lastPinCreateFailed() &&
!SignalStore.svr().hasOptedOut();
!SignalStore.svr().hasOptedOut() &&
SignalStore.account().isPrimaryDevice();
}
private boolean userMustSetProfileName() {

View File

@@ -12,6 +12,7 @@ import androidx.annotation.NonNull;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.conversation.NewConversationActivity;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.Rfc5724Uri;
@@ -41,8 +42,7 @@ public class SystemContactsEntrypointActivity extends Activity {
final Intent nextIntent;
if (TextUtils.isEmpty(destination.destination)) {
nextIntent = new Intent(this, NewConversationActivity.class);
nextIntent.putExtra(Intent.EXTRA_TEXT, destination.getBody());
nextIntent = NewConversationActivity.createIntent(this, destination.getBody());
Toast.makeText(this, R.string.ConversationActivity_specify_recipient, Toast.LENGTH_LONG).show();
} else {
Recipient recipient = Recipient.external(destination.getDestination());
@@ -54,8 +54,7 @@ public class SystemContactsEntrypointActivity extends Activity {
.withDraftText(destination.getBody())
.build();
} else {
nextIntent = new Intent(this, NewConversationActivity.class);
nextIntent.putExtra(Intent.EXTRA_TEXT, destination.getBody());
nextIntent = NewConversationActivity.createIntent(this, destination.getBody());
Toast.makeText(this, R.string.ConversationActivity_specify_recipient, Toast.LENGTH_LONG).show();
}
}

View File

@@ -18,6 +18,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.database.model.ProfileAvatarFileDetails
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.rememberRecipientField
@Composable
@@ -26,6 +27,21 @@ fun AvatarImage(
modifier: Modifier = Modifier,
useProfile: Boolean = true,
contentDescription: String? = null
) {
AvatarImage(
recipientId = recipient.id,
modifier = modifier,
useProfile = useProfile,
contentDescription = contentDescription
)
}
@Composable
fun AvatarImage(
recipientId: RecipientId,
modifier: Modifier = Modifier,
useProfile: Boolean = true,
contentDescription: String? = null
) {
if (LocalInspectionMode.current) {
Spacer(
@@ -34,7 +50,7 @@ fun AvatarImage(
)
} else {
val context = LocalContext.current
val avatarImageState by rememberRecipientField(recipient) {
val avatarImageState by rememberRecipientField(recipientId) {
AvatarImageState(
getDisplayName(context),
this,

View File

@@ -119,10 +119,26 @@ object ExportSkips {
return log(sentTimestamp, "Failed to parse thread merge event.")
}
fun pollTerminateIsEmpty(sentTimestamp: Long): String {
return log(sentTimestamp, "Poll terminate update was empty.")
}
fun invalidPollQuestion(sentTimestamp: Long): String {
return log(sentTimestamp, "Poll question was invalid.")
}
fun invalidPollOption(sentTimestamp: Long): String {
return log(sentTimestamp, "Poll option was invalid.")
}
fun individualChatUpdateInWrongTypeOfChat(sentTimestamp: Long): String {
return log(sentTimestamp, "A chat update that only makes sense for individual chats was found in a different kind of chat.")
}
fun callWithMissingRecipient(sentTimestamp: Long): String {
return log(sentTimestamp, "A call had a ringer with no matching exported Recipient.")
}
private fun log(sentTimestamp: Long, message: String): String {
return "[SKIP][$sentTimestamp] $message"
}
@@ -199,6 +215,10 @@ object ExportOddities {
* These represent situations where we will skip importing a data frame due to the data being invalid.
*/
object ImportSkips {
fun recipientWithoutId(): String {
return log(0, " No aci, pni, or e164 available for recipient")
}
fun fromRecipientNotFound(sentTimestamp: Long): String {
return log(sentTimestamp, "Failed to find the fromRecipient for the message.")
}
@@ -223,6 +243,14 @@ object ImportSkips {
return log(0, "Failed to parse notificationProfileId for the provided notification profile.")
}
fun failedToCreateChat(): String {
return log(0, "Failed to create a Chat. Likely a duplicate recipient was found. Keeping pre-existing data and skipping data in this frame.")
}
fun missingChatRecipient(chatId: Long): String {
return log(0, "Missing recipient for chat $chatId")
}
private fun log(sentTimestamp: Long, message: String): String {
return "[SKIP][$sentTimestamp] $message"
}

View File

@@ -65,9 +65,9 @@ import org.thoughtcrime.securesms.backup.v2.importer.ChatItemArchiveImporter
import org.thoughtcrime.securesms.backup.v2.processor.AccountDataArchiveProcessor
import org.thoughtcrime.securesms.backup.v2.processor.AdHocCallArchiveProcessor
import org.thoughtcrime.securesms.backup.v2.processor.ChatArchiveProcessor
import org.thoughtcrime.securesms.backup.v2.processor.ChatFolderProcessor
import org.thoughtcrime.securesms.backup.v2.processor.ChatFolderArchiveProcessor
import org.thoughtcrime.securesms.backup.v2.processor.ChatItemArchiveProcessor
import org.thoughtcrime.securesms.backup.v2.processor.NotificationProfileProcessor
import org.thoughtcrime.securesms.backup.v2.processor.NotificationProfileArchiveProcessor
import org.thoughtcrime.securesms.backup.v2.processor.RecipientArchiveProcessor
import org.thoughtcrime.securesms.backup.v2.processor.StickerArchiveProcessor
import org.thoughtcrime.securesms.backup.v2.proto.BackupDebugInfo
@@ -564,7 +564,7 @@ object BackupRepository {
return false
}
return !SignalStore.backup.hasBackupBeenUploaded && SignalStore.backup.hasBackupFailure && System.currentTimeMillis().milliseconds > SignalStore.backup.nextBackupFailureSheetSnoozeTime
return (!SignalStore.backup.hasBackupBeenUploaded || SignalStore.backup.hasValidationError) && SignalStore.backup.hasBackupFailure && System.currentTimeMillis().milliseconds > SignalStore.backup.nextBackupFailureSheetSnoozeTime
}
/**
@@ -572,6 +572,11 @@ object BackupRepository {
*/
@JvmStatic
fun shouldDisplayCouldNotCompleteBackupSheet(): Boolean {
// Temporarily disabling. May re-enable in the future.
if (true) {
return false
}
if (shouldNotDisplayBackupFailedMessaging()) {
return false
}
@@ -1035,7 +1040,7 @@ object BackupRepository {
}
progressEmitter?.onNotificationProfile()
NotificationProfileProcessor.export(dbSnapshot, exportState) { frame ->
NotificationProfileArchiveProcessor.export(dbSnapshot, exportState) { frame ->
writer.write(frame)
extraFrameOperation?.invoke(frame)
eventTimer.emit("notification-profile")
@@ -1047,7 +1052,7 @@ object BackupRepository {
}
progressEmitter?.onChatFolder()
ChatFolderProcessor.export(dbSnapshot, exportState) { frame ->
ChatFolderArchiveProcessor.export(dbSnapshot, exportState) { frame ->
writer.write(frame)
extraFrameOperation?.invoke(frame)
eventTimer.emit("chat-folder")
@@ -1124,20 +1129,25 @@ object BackupRepository {
forwardSecrecyToken: BackupForwardSecrecyToken,
cancellationSignal: () -> Boolean = { false }
): ImportResult {
val frameReader = if (backupKey == null) {
PlainTextBackupReader(inputStreamFactory(), length)
} else {
EncryptedBackupReader.createForSignalBackup(
key = backupKey,
aci = selfData.aci,
forwardSecrecyToken = forwardSecrecyToken,
length = length,
dataStream = inputStreamFactory
)
}
try {
val frameReader = if (backupKey == null) {
PlainTextBackupReader(inputStreamFactory(), length)
} else {
EncryptedBackupReader.createForSignalBackup(
key = backupKey,
aci = selfData.aci,
forwardSecrecyToken = forwardSecrecyToken,
length = length,
dataStream = inputStreamFactory
)
}
return frameReader.use { reader ->
import(reader, selfData, cancellationSignal)
return frameReader.use { reader ->
import(reader, selfData, cancellationSignal)
}
} catch (e: IOException) {
Log.w(TAG, "Unable to restore signal backup", e)
return ImportResult.Failure
}
}
@@ -1281,6 +1291,7 @@ object BackupRepository {
}
RecipientId.clearCache()
SignalDatabase.remappedRecords.clearCache()
AppDependencies.recipientCache.clear()
AppDependencies.recipientCache.clearSelf()
SignalDatabase.threads.clearCache()
@@ -1338,13 +1349,13 @@ object BackupRepository {
}
frame.notificationProfile != null -> {
NotificationProfileProcessor.import(frame.notificationProfile, importState)
NotificationProfileArchiveProcessor.import(frame.notificationProfile, importState)
eventTimer.emit("notification-profile")
frameCount++
}
frame.chatFolder != null -> {
ChatFolderProcessor.import(frame.chatFolder, importState)
ChatFolderArchiveProcessor.import(frame.chatFolder, importState)
eventTimer.emit("chat-folder")
frameCount++
}
@@ -1381,6 +1392,9 @@ object BackupRepository {
stopwatch.split("frames")
Log.d(TAG, "[import] Remove duplicate messages...")
SignalDatabase.messages.removeDuplicatesPostBackupRestore()
Log.d(TAG, "[import] Rebuilding FTS index...")
SignalDatabase.messageSearch.rebuildIndex()
@@ -1420,6 +1434,7 @@ object BackupRepository {
SignalDatabase.rawDatabase.forceForeignKeyConstraintsEnabled(true)
}
SignalDatabase.remappedRecords.clearCache()
AppDependencies.recipientCache.clear()
AppDependencies.recipientCache.warmUp()
SignalDatabase.threads.clearCache()

View File

@@ -14,6 +14,7 @@ import org.signal.core.util.EventTimer
import org.signal.core.util.Hex
import org.signal.core.util.ParallelEventTimer
import org.signal.core.util.StringUtil
import org.signal.core.util.bytes
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.emptyIfNull
import org.signal.core.util.isNotNullOrBlank
@@ -50,6 +51,8 @@ import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall
import org.thoughtcrime.securesms.backup.v2.proto.LearnedProfileChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment
import org.thoughtcrime.securesms.backup.v2.proto.PaymentNotification
import org.thoughtcrime.securesms.backup.v2.proto.Poll
import org.thoughtcrime.securesms.backup.v2.proto.PollTerminateUpdate
import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.Quote
import org.thoughtcrime.securesms.backup.v2.proto.Reaction
@@ -93,9 +96,11 @@ import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.payments.FailureReason
import org.thoughtcrime.securesms.payments.State
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.JsonUtils
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.mb
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.api.util.toByteArray
import java.io.Closeable
@@ -114,6 +119,8 @@ private val TAG = Log.tag(ChatItemArchiveExporter::class.java)
private val MAX_INLINED_BODY_SIZE = 128.kibiBytes.bytes.toInt()
private val MAX_INLINED_BODY_SIZE_WITH_LONG_ATTACHMENT_POINTER = 2.kibiBytes.bytes.toInt()
private val MAX_INLINED_QUOTE_BODY_SIZE = 2.kibiBytes.bytes.toInt()
private const val MAX_POLL_CHARACTER_LENGTH = 100
private const val MAX_POLL_OPTIONS = 10
/**
* An iterator for chat items with a clever performance twist: rather than do the extra queries one at a time (for reactions,
@@ -134,6 +141,7 @@ class ChatItemArchiveExporter(
companion object {
val EXPIRATION_CUTOFF = 1.days
private val MAX_BUFFER_MEMORY_SIZE = 15.mb
}
/** Timer for more macro-level events, like fetching extra data vs transforming the data. */
@@ -371,6 +379,30 @@ class ChatItemArchiveExporter(
transformTimer.emit("story")
}
MessageTypes.isPollTerminate(record.type) -> {
val pollTerminateUpdate = record.toRemotePollTerminateUpdate()
if (pollTerminateUpdate == null) {
Log.w(TAG, ExportSkips.pollTerminateIsEmpty(record.dateSent))
continue
}
builder.updateMessage = ChatUpdateMessage(pollTerminate = pollTerminateUpdate)
transformTimer.emit("poll-terminate")
}
extraData.pollsById[record.id] != null -> {
val poll = extraData.pollsById[record.id]!!
if (poll.question.isEmpty() || poll.question.length > MAX_POLL_CHARACTER_LENGTH) {
Log.w(TAG, ExportSkips.invalidPollQuestion(record.dateSent))
continue
}
if (poll.pollOptions.isEmpty() || poll.pollOptions.size > MAX_POLL_OPTIONS || poll.pollOptions.any { it.text.isEmpty() || it.text.length > MAX_POLL_CHARACTER_LENGTH }) {
Log.w(TAG, ExportSkips.invalidPollOption(record.dateSent))
continue
}
builder.poll = poll.toRemotePollMessage(reactionRecords = extraData.reactionsById[record.id])
transformTimer.emit("poll")
}
else -> {
val attachments = extraData.attachmentsById[record.id]
val sticker = attachments?.firstOrNull { dbAttachment -> dbAttachment.isSticker }
@@ -434,12 +466,18 @@ class ChatItemArchiveExporter(
private fun readNextMessageRecordBatch(pastIds: Set<Long>): LinkedHashMap<Long, BackupMessageRecord> {
return cursorGenerator(lastSeenReceivedTime, batchSize).use { cursor ->
val records: LinkedHashMap<Long, BackupMessageRecord> = LinkedHashMap(batchSize)
while (cursor.moveToNext()) {
var estimatedRecordsMemorySize = 0
while (cursor.moveToNext() && estimatedRecordsMemorySize < MAX_BUFFER_MEMORY_SIZE) {
cursor.toBackupMessageRecord(pastIds, backupStartTime)?.let { record ->
records[record.id] = record
lastSeenReceivedTime = record.dateReceived
estimatedRecordsMemorySize += record.estimatedSizeInBytes
}
}
if (estimatedRecordsMemorySize > MAX_BUFFER_MEMORY_SIZE) {
Log.d(TAG, "[readNextMessageRecordBatch] recordsSize = ${records.size} recordsMemSize: ${estimatedRecordsMemorySize.bytes.toUnitString(spaced = false)}")
}
records
}
}
@@ -471,16 +509,24 @@ class ChatItemArchiveExporter(
}
}
val pollsFuture = executor.submitTyped {
extraDataTimer.timeEvent("polls") {
db.pollTable.getPollsForMessages(messageIds = messageIds, includePending = false)
}
}
val mentionsResult = mentionsFuture.get()
val reactionsResult = reactionsFuture.get()
val attachmentsResult = attachmentsFuture.get()
val groupReceiptsResult = groupReceiptsFuture.get()
val pollsResult = pollsFuture.get()
return ExtraMessageData(
mentionsById = mentionsResult,
reactionsById = reactionsResult,
attachmentsById = attachmentsResult,
groupReceiptsById = groupReceiptsResult
groupReceiptsById = groupReceiptsResult,
pollsById = pollsResult
)
}
}
@@ -720,7 +766,7 @@ private fun CallTable.Call.toRemoteCallUpdate(exportState: ExportState, messageR
CallTable.Event.OUTGOING_RING -> GroupCall.State.OUTGOING_RING
CallTable.Event.DELETE -> return null
},
ringerRecipientId = this.ringerRecipient?.toLong(),
ringerRecipientId = this.ringerRecipient?.toLong()?.takeIf { exportState.recipientIdToAci[it] != null },
startedCallRecipientId = groupCallUpdateDetails.startedCallUuid.takeIf { it.isNotEmpty() }?.let { exportState.aciToRecipientId[it] },
startedCallTimestamp = this.timestamp.clampToValidBackupRange(),
endedCallTimestamp = groupCallUpdateDetails.endedCallTimestamp.clampToValidBackupRange().takeIf { it > 0 },
@@ -783,6 +829,14 @@ private fun BackupMessageRecord.toRemotePaymentNotificationUpdate(db: SignalData
}
}
private fun BackupMessageRecord.toRemotePollTerminateUpdate(): PollTerminateUpdate? {
val pollTerminate = this.messageExtras?.pollTerminate ?: return null
return PollTerminateUpdate(
targetSentTimestamp = pollTerminate.targetTimestamp,
question = pollTerminate.question
)
}
private fun BackupMessageRecord.toRemoteSharedContact(attachments: List<DatabaseAttachment>?): Contact? {
if (this.sharedContacts.isNullOrEmpty()) {
return null
@@ -1082,6 +1136,7 @@ private fun BackupMessageRecord.toRemoteQuote(exportState: ExportState, attachme
}
}
QuoteModel.Type.GIFT_BADGE -> Quote.Type.GIFT_BADGE
QuoteModel.Type.POLL -> Quote.Type.POLL
}
val bodyRanges = this.quoteBodyRanges?.toRemoteBodyRanges(dateSent) ?: emptyList()
@@ -1131,6 +1186,26 @@ private fun BackupMessageRecord.toRemoteGiftBadgeUpdate(): BackupGiftBadge? {
)
}
private fun PollRecord.toRemotePollMessage(reactionRecords: List<ReactionRecord>?): Poll {
return Poll(
question = this.question,
allowMultiple = this.allowMultipleVotes,
hasEnded = this.hasEnded,
options = this.pollOptions.map { option ->
Poll.PollOption(
option = option.text,
votes = option.voters.map { voter ->
Poll.PollOption.PollVote(
voterId = voter.id,
voteCount = voter.voteCount
)
}
)
},
reactions = reactionRecords?.toRemote() ?: emptyList()
)
}
private fun DatabaseAttachment.toRemoteStickerMessage(sentTimestamp: Long, reactions: List<ReactionRecord>?): StickerMessage? {
val stickerLocator = this.stickerLocator!!
@@ -1491,7 +1566,8 @@ private fun Long.isDirectionlessType(): Boolean {
MessageTypes.isGroupCall(this) ||
MessageTypes.isGroupUpdate(this) ||
MessageTypes.isGroupV1MigrationEvent(this) ||
MessageTypes.isGroupQuit(this)
MessageTypes.isGroupQuit(this) ||
MessageTypes.isPollTerminate(this)
}
private fun Long.isIdentityVerifyType(): Boolean {
@@ -1506,7 +1582,7 @@ private fun String.e164ToLong(): Long? {
this
}
return fixed.toLongOrNull()
return fixed.toLongOrNull()?.takeIf { it > 0L }
}
private fun <T> ExecutorService.submitTyped(callable: Callable<T>): Future<T> {
@@ -1522,7 +1598,8 @@ private fun ChatItem.validateChatItem(exportState: ExportState): ChatItem? {
this.paymentNotification == null &&
this.giftBadge == null &&
this.viewOnceMessage == null &&
this.directStoryReplyMessage == null
this.directStoryReplyMessage == null &&
this.poll == null
) {
Log.w(TAG, ExportSkips.emptyChatItem(this.dateSent))
return null
@@ -1611,6 +1688,7 @@ private fun Cursor.toBackupMessageRecord(pastIds: Set<Long>, backupStartTime: Lo
val expiresIn = this.requireLong(MessageTable.EXPIRES_IN)
val expireStarted = this.requireLong(MessageTable.EXPIRE_STARTED)
val messageExtras = this.requireBlob(MessageTable.MESSAGE_EXTRAS)
return BackupMessageRecord(
id = id,
@@ -1645,9 +1723,10 @@ private fun Cursor.toBackupMessageRecord(pastIds: Set<Long>, backupStartTime: Lo
networkFailureRecipientIds = this.requireString(MessageTable.NETWORK_FAILURES).parseNetworkFailures(),
identityMismatchRecipientIds = this.requireString(MessageTable.MISMATCHED_IDENTITIES).parseIdentityMismatches(),
baseType = this.requireLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK,
messageExtras = this.requireBlob(MessageTable.MESSAGE_EXTRAS).parseMessageExtras(),
messageExtras = messageExtras.parseMessageExtras(),
viewOnce = this.requireBoolean(MessageTable.VIEW_ONCE),
parentStoryId = this.requireLong(MessageTable.PARENT_STORY_ID)
parentStoryId = this.requireLong(MessageTable.PARENT_STORY_ID),
messageExtrasSize = messageExtras?.size ?: 0
)
}
@@ -1686,14 +1765,24 @@ private class BackupMessageRecord(
val identityMismatchRecipientIds: Set<Long>,
val baseType: Long,
val messageExtras: MessageExtras?,
val viewOnce: Boolean
)
val viewOnce: Boolean,
private val messageExtrasSize: Int
) {
val estimatedSizeInBytes: Int = (body?.length ?: 0) +
(linkPreview?.length ?: 0) +
(sharedContacts?.length ?: 0) +
(quoteBody?.length ?: 0) +
(quoteBodyRanges?.size ?: 0) +
messageExtrasSize +
((17 + networkFailureRecipientIds.size + identityMismatchRecipientIds.size) * 8)
}
private data class ExtraMessageData(
val mentionsById: Map<Long, List<Mention>>,
val reactionsById: Map<Long, List<ReactionRecord>>,
val attachmentsById: Map<Long, List<DatabaseAttachment>>,
val groupReceiptsById: Map<Long, List<GroupReceiptTable.GroupReceiptInfo>>
val groupReceiptsById: Map<Long, List<GroupReceiptTable.GroupReceiptInfo>>,
val pollsById: Map<Long, PollRecord>
)
private enum class Direction {

View File

@@ -28,7 +28,8 @@ import kotlin.time.Duration.Companion.milliseconds
* Handles the importing of [Chat] models into the local database.
*/
object ChatArchiveImporter {
fun import(chat: Chat, recipientId: RecipientId, importState: ImportState): Long {
fun import(chat: Chat, recipientId: RecipientId, importState: ImportState): Long? {
val chatColor = chat.style?.toLocal(importState)
val wallpaperAttachmentId: AttachmentId? = chat.style?.wallpaperPhoto?.let { filePointer ->
@@ -49,6 +50,11 @@ object ChatArchiveImporter {
ThreadTable.ACTIVE to 1
)
.run()
.takeIf { it > 0L }
if (threadId == null) {
return null
}
SignalDatabase.writableDatabase
.update(

View File

@@ -62,6 +62,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescrip
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
import org.thoughtcrime.securesms.database.model.databaseprotos.PaymentTombstone
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails
import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent
import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
@@ -72,6 +73,7 @@ import org.thoughtcrime.securesms.payments.Direction
import org.thoughtcrime.securesms.payments.FailureReason
import org.thoughtcrime.securesms.payments.State
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData
import org.thoughtcrime.securesms.polls.Voter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stickers.StickerLocator
@@ -82,6 +84,7 @@ import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.internal.push.DataMessage
import java.math.BigInteger
import java.sql.SQLException
import java.util.Optional
import java.util.UUID
import org.thoughtcrime.securesms.backup.v2.proto.GiftBadge as BackupGiftBadge
@@ -224,12 +227,17 @@ class ChatItemArchiveImporter(
}
var messageInsertIndex = 0
SqlUtil.buildBulkInsert(MessageTable.TABLE_NAME, MESSAGE_COLUMNS, buffer.messages.map { it.contentValues }).forEach { query ->
db.rawQuery("${query.where} RETURNING ${MessageTable.ID}", query.whereArgs).forEach { cursor ->
val finalMessageId = cursor.requireLong(MessageTable.ID)
val relatedInsert = buffer.messages[messageInsertIndex++]
relatedInsert.followUp?.invoke(finalMessageId)
try {
SqlUtil.buildBulkInsert(MessageTable.TABLE_NAME, MESSAGE_COLUMNS, buffer.messages.map { it.contentValues }, onConflict = "IGNORE").forEach { query ->
db.rawQuery("${query.where} RETURNING ${MessageTable.ID}", query.whereArgs).forEach { cursor ->
val finalMessageId = cursor.requireLong(MessageTable.ID)
val relatedInsert = buffer.messages[messageInsertIndex++]
relatedInsert.followUp?.invoke(finalMessageId)
}
}
} catch (e: SQLException) {
Log.w(TAG, "Failed to bulk-insert message! Trying one at at time.", e)
performIndividualMessageInserts(buffer.messages)
}
SqlUtil.buildBulkInsert(ReactionTable.TABLE_NAME, REACTION_COLUMNS, buffer.reactions).forEach {
@@ -247,6 +255,18 @@ class ChatItemArchiveImporter(
return true
}
private fun performIndividualMessageInserts(messageInserts: List<MessageInsert>) {
for (message in messageInserts) {
val values = message.contentValues
try {
db.insert(MessageTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values)
message.followUp?.invoke(messageId - 1)
} catch (e: SQLException) {
Log.w(TAG, "Failed to insert message with timestamp ${message.contentValues.get(MessageTable.DATE_SENT)}. Must skip.", e)
}
}
}
private fun ChatItem.toMessageInsert(fromRecipientId: RecipientId, chatRecipientId: RecipientId, threadId: Long): MessageInsert {
val contentValues = this.toMessageContentValues(fromRecipientId, chatRecipientId, threadId)
@@ -304,6 +324,21 @@ class ChatItemArchiveImporter(
)
db.insert(CallTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values)
}
} else if (this.updateMessage.pollTerminate != null) {
followUps += { endPollMessageId ->
val pollMessageId = SignalDatabase.messages.getMessageFor(updateMessage.pollTerminate.targetSentTimestamp, fromRecipientId)?.id ?: -1
val pollId = SignalDatabase.polls.getPollId(pollMessageId)
val messageExtras = MessageExtras(pollTerminate = PollTerminate(question = updateMessage.pollTerminate.question, messageId = pollMessageId, targetTimestamp = updateMessage.pollTerminate.targetSentTimestamp))
db.update(MessageTable.TABLE_NAME)
.values(MessageTable.MESSAGE_EXTRAS to messageExtras.encode())
.where("${MessageTable.ID} = ?", endPollMessageId)
.run()
if (pollId != null) {
SignalDatabase.polls.endPoll(pollId = pollId, endingMessageId = endPollMessageId)
}
}
}
}
@@ -459,6 +494,35 @@ class ChatItemArchiveImporter(
}
}
if (this.poll != null) {
contentValues.put(MessageTable.BODY, poll.question)
contentValues.put(MessageTable.VOTES_LAST_SEEN, System.currentTimeMillis())
followUps += { messageRowId ->
val pollId = SignalDatabase.polls.insertPoll(
question = poll.question,
allowMultipleVotes = poll.allowMultiple,
options = poll.options.map { it.option },
authorId = fromRecipientId.toLong(),
messageId = messageRowId
)
val localOptionIds = SignalDatabase.polls.getPollOptionIds(pollId)
poll.options.forEachIndexed { index, option ->
val localVoterIds = option.votes.map { importState.remoteToLocalRecipientId[it.voterId]?.toLong() }
val voteCounts = option.votes.map { it.voteCount }
val localVoters = localVoterIds.mapIndexedNotNull { index, id -> id?.let { Voter(id = id, voteCount = voteCounts[index]) } }
SignalDatabase.polls.addPollVotes(pollId = pollId, optionId = localOptionIds[index], voters = localVoters)
}
if (poll.hasEnded) {
// At this point, we don't know what message ended the poll. Instead, we set it to -1 to indicate that it
// is ended and will update endingMessageId when we process the poll terminate message (if it exists).
SignalDatabase.polls.endPoll(pollId = pollId, endingMessageId = -1)
}
}
}
val followUp: ((Long) -> Unit)? = if (followUps.isNotEmpty()) {
{ messageId ->
followUps.forEach { it(messageId) }
@@ -629,6 +693,7 @@ class ChatItemArchiveImporter(
this.stickerMessage != null -> this.stickerMessage.reactions
this.viewOnceMessage != null -> this.viewOnceMessage.reactions
this.directStoryReplyMessage != null -> this.directStoryReplyMessage.reactions
this.poll != null -> this.poll.reactions
else -> emptyList()
}
@@ -774,6 +839,9 @@ class ChatItemArchiveImporter(
val messageExtras = MessageExtras(profileChangeDetails = profileChangeDetails).encode()
put(MessageTable.MESSAGE_EXTRAS, messageExtras)
}
updateMessage.pollTerminate != null -> {
typeFlags = MessageTypes.SPECIAL_TYPE_POLL_TERMINATE or (getAsLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK.inv())
}
updateMessage.sessionSwitchover != null -> {
typeFlags = MessageTypes.SESSION_SWITCHOVER_TYPE or (getAsLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK.inv())
val sessionSwitchoverDetails = SessionSwitchoverEvent(e164 = updateMessage.sessionSwitchover.e164.toString()).encode()
@@ -1004,6 +1072,7 @@ class ChatItemArchiveImporter(
Quote.Type.NORMAL -> QuoteModel.Type.NORMAL.code
Quote.Type.GIFT_BADGE -> QuoteModel.Type.GIFT_BADGE.code
Quote.Type.VIEW_ONCE -> QuoteModel.Type.NORMAL.code
Quote.Type.POLL -> QuoteModel.Type.POLL.code
}
}
@@ -1210,8 +1279,7 @@ class ChatItemArchiveImporter(
private class MessageInsert(
val contentValues: ContentValues,
val followUp: ((Long) -> Unit)?,
val edits: List<MessageInsert>? = null
val followUp: ((Long) -> Unit)?
)
private class Buffer(

View File

@@ -8,8 +8,10 @@ package org.thoughtcrime.securesms.backup.v2.importer
import androidx.core.content.contentValuesOf
import org.signal.core.util.Base64
import org.signal.core.util.insertInto
import org.signal.core.util.logging.Log
import org.signal.core.util.toInt
import org.signal.core.util.update
import org.thoughtcrime.securesms.backup.v2.ImportSkips
import org.thoughtcrime.securesms.backup.v2.proto.Contact
import org.thoughtcrime.securesms.backup.v2.util.toLocal
import org.thoughtcrime.securesms.database.IdentityTable
@@ -28,14 +30,22 @@ import org.whispersystems.signalservice.api.push.ServiceId.PNI
* Handles the importing of [Contact] models into the local database.
*/
object ContactArchiveImporter {
fun import(contact: Contact): RecipientId {
private val TAG = Log.tag(ContactArchiveImporter::class)
fun import(contact: Contact): RecipientId? {
val aci = ACI.parseOrNull(contact.aci?.toByteArray())
val pni = PNI.parseOrNull(contact.pni?.toByteArray())
val e164 = contact.formattedE164
if (aci == null && pni == null && e164 == null) {
Log.w(TAG, ImportSkips.recipientWithoutId())
return null
}
val id = SignalDatabase.recipients.getAndPossiblyMergePnpVerified(
aci = aci,
pni = pni,
e164 = contact.formattedE164
e164 = e164
)
val profileKey = contact.profileKey?.toByteArray()

View File

@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.backup.v2.processor
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.ExportSkips
import org.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.database.getAdhocCallsForBackup
@@ -28,7 +29,7 @@ object AdHocCallArchiveProcessor {
if (exportState.recipientIds.contains(callLog.recipientId)) {
emitter.emit(Frame(adHocCall = callLog))
} else {
Log.w(TAG, "Dropping adhoc call for non-exported recipient.")
Log.w(TAG, ExportSkips.callWithMissingRecipient(callLog.callTimestamp))
}
}
}

View File

@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.processor
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.ImportSkips
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.database.getThreadsForBackup
import org.thoughtcrime.securesms.backup.v2.importer.ChatArchiveImporter
@@ -39,11 +40,16 @@ object ChatArchiveProcessor {
fun import(chat: Chat, importState: ImportState) {
val recipientId: RecipientId? = importState.remoteToLocalRecipientId[chat.recipientId]
if (recipientId == null) {
Log.w(TAG, "Missing recipient for chat ${chat.id}")
Log.w(TAG, ImportSkips.missingChatRecipient(chat.id))
return
}
val threadId = ChatArchiveImporter.import(chat, recipientId, importState)
if (threadId == null) {
Log.w(TAG, ImportSkips.failedToCreateChat())
return
}
importState.chatIdToLocalRecipientId[chat.id] = recipientId
importState.chatIdToLocalThreadId[chat.id] = threadId
importState.chatIdToBackupRecipientId[chat.id] = chat.recipientId

View File

@@ -29,9 +29,9 @@ import org.thoughtcrime.securesms.backup.v2.proto.ChatFolder as ChatFolderProto
/**
* Handles exporting and importing [ChatFolderRecord]s.
*/
object ChatFolderProcessor {
object ChatFolderArchiveProcessor {
private val TAG = Log.tag(ChatFolderProcessor::class)
private val TAG = Log.tag(ChatFolderArchiveProcessor::class)
fun export(db: SignalDatabase, exportState: ExportState, emitter: BackupFrameEmitter) {
val folders = db
@@ -113,10 +113,10 @@ object ChatFolderProcessor {
private fun ChatFolderRecord.toBackupFrame(includedRecipientIds: List<Long>, excludedRecipientIds: List<Long>): Frame {
val chatFolder = ChatFolderProto(
name = this.name,
showOnlyUnread = this.showUnread,
showMutedChats = this.showMutedChats,
includeAllIndividualChats = this.showIndividualChats,
includeAllGroupChats = this.showGroupChats,
showOnlyUnread = this.showUnread && this.folderType != ChatFolderRecord.FolderType.ALL,
showMutedChats = this.showMutedChats || this.folderType == ChatFolderRecord.FolderType.ALL,
includeAllIndividualChats = this.showIndividualChats || this.folderType == ChatFolderRecord.FolderType.ALL,
includeAllGroupChats = this.showGroupChats || this.folderType == ChatFolderRecord.FolderType.ALL,
folderType = when (this.folderType) {
ChatFolderRecord.FolderType.ALL -> ChatFolderProto.FolderType.ALL
ChatFolderRecord.FolderType.CUSTOM -> ChatFolderProto.FolderType.CUSTOM

View File

@@ -31,9 +31,9 @@ import org.thoughtcrime.securesms.backup.v2.proto.NotificationProfile as Notific
/**
* Handles exporting and importing [NotificationProfile] models.
*/
object NotificationProfileProcessor {
object NotificationProfileArchiveProcessor {
private val TAG = Log.tag(NotificationProfileProcessor::class)
private val TAG = Log.tag(NotificationProfileArchiveProcessor::class)
fun export(db: SignalDatabase, exportState: ExportState, emitter: BackupFrameEmitter) {
db.notificationProfileTables

View File

@@ -98,7 +98,7 @@ object RecipientArchiveProcessor {
}
fun import(recipient: ArchiveRecipient, importState: ImportState) {
val newId = when {
val newId: RecipientId? = when {
recipient.contact != null -> ContactArchiveImporter.import(recipient.contact)
recipient.group != null -> GroupArchiveImporter.import(recipient.group)
recipient.distributionList != null -> DistributionListArchiveImporter.import(recipient.distributionList, importState)

View File

@@ -5,9 +5,12 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -15,6 +18,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -72,20 +76,19 @@ fun MessageBackupsKeyEducationScreen(
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyEducationScreen__your_backup_key_is_a),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp)
InfoRow(
R.drawable.symbol_number_24,
R.string.MessageBackupsKeyEducationScreen__your_backup_key_is_a
)
Text(
text = stringResource(R.string.MessageBackupsKeyEducationScreen__if_you_forget_your_key),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp)
InfoRow(
R.drawable.symbol_lock_24,
R.string.MessageBackupsKeyEducationScreen__store_your_recovery
)
InfoRow(
R.drawable.symbol_error_circle_24,
R.string.MessageBackupsKeyEducationScreen__if_you_lose_it
)
Spacer(
@@ -101,10 +104,11 @@ fun MessageBackupsKeyEducationScreen(
) {
Buttons.LargeTonal(
onClick = onNextClick,
modifier = Modifier.align(Alignment.BottomEnd)
modifier = Modifier.align(Alignment.Center)
) {
Text(
text = stringResource(R.string.MessageBackupsKeyEducationScreen__next)
text = stringResource(R.string.MessageBackupsKeyEducationScreen__view_recovery_key),
modifier = Modifier.padding(horizontal = 20.dp)
)
}
}
@@ -112,6 +116,27 @@ fun MessageBackupsKeyEducationScreen(
}
}
@Composable
private fun InfoRow(@DrawableRes iconId: Int, @StringRes textId: Int) {
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier.padding(top = 24.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(iconId),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(textId),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp)
)
}
}
@DayNightPreviews
@Composable
private fun MessageBackupsKeyEducationScreenPreview() {

View File

@@ -11,11 +11,11 @@ import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupK
* View model for [ForgotBackupKeyFragment]
*/
class ForgotBackupKeyViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
private val _uiState = MutableStateFlow(BackupKeyDisplayUiState())
val uiState: StateFlow<BackupKeyDisplayUiState> = _uiState
private val internalUiState = MutableStateFlow(BackupKeyDisplayUiState())
val uiState: StateFlow<BackupKeyDisplayUiState> = internalUiState
override fun updateBackupKeySaveState(newState: BackupKeySaveState?) {
_uiState.update { it.copy(keySaveState = newState) }
internalUiState.update { it.copy(keySaveState = newState) }
}
}

View File

@@ -35,7 +35,7 @@ private fun AccountData.getAllReferencedArchiveAttachmentInfos(): Set<ArchiveAtt
}
private fun Chat.getAllReferencedArchiveAttachmentInfos(): Set<ArchiveAttachmentInfo> {
val info = this.style?.wallpaperPhoto?.toArchiveAttachmentInfo()
val info = this.style?.wallpaperPhoto?.toArchiveAttachmentInfo(isWallpaper = true)
return if (info != null) {
setOf(info)
@@ -73,7 +73,7 @@ private fun ChatItem.getAllReferencedArchiveAttachmentInfos(): Set<ArchiveAttach
return out ?: emptySet()
}
private fun FilePointer.toArchiveAttachmentInfo(forQuote: Boolean = false): ArchiveAttachmentInfo? {
private fun FilePointer.toArchiveAttachmentInfo(forQuote: Boolean = false, isWallpaper: Boolean = false): ArchiveAttachmentInfo? {
if (this.locatorInfo?.key == null) {
return null
}
@@ -87,7 +87,8 @@ private fun FilePointer.toArchiveAttachmentInfo(forQuote: Boolean = false): Arch
remoteKey = this.locatorInfo.key,
cdn = this.locatorInfo.mediaTierCdnNumber ?: Cdn.CDN_0.cdnNumber,
contentType = this.contentType,
forQuote = forQuote
forQuote = forQuote,
isWallpaper = isWallpaper
)
}
@@ -96,7 +97,8 @@ data class ArchiveAttachmentInfo(
val remoteKey: ByteString,
val cdn: Int,
val contentType: String?,
val forQuote: Boolean
val forQuote: Boolean,
val isWallpaper: Boolean = false
) {
val fullSizeMediaName: MediaName get() = MediaName.fromPlaintextHashAndRemoteKey(plaintextHash.toByteArray(), remoteKey.toByteArray())
val thumbnailMediaName: MediaName get() = MediaName.fromPlaintextHashAndRemoteKeyForThumbnail(plaintextHash.toByteArray(), remoteKey.toByteArray())

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.banner.banners
import android.os.Build
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
import org.thoughtcrime.securesms.banner.ui.compose.Importance
class DeprecatedSdkBanner() : Banner<Unit>() {
override val enabled: Boolean
get() = Build.VERSION.SDK_INT < 23
override val dataFlow: Flow<Unit> = flowOf(Unit)
@Composable
override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) = Banner(contentPadding)
}
@Composable
private fun Banner(contentPadding: PaddingValues) {
DefaultBanner(
title = null,
body = stringResource(id = R.string.DeprecatedSdkBanner_body),
importance = Importance.ERROR,
paddingValues = contentPadding
)
}
@DayNightPreviews
@Composable
private fun BannerPreview() {
Previews.Preview {
Banner(contentPadding = PaddingValues(0.dp))
}
}

View File

@@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -172,10 +173,9 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
ContactSelectionListFragment fragment = new ContactSelectionListFragment();
Intent intent = getIntent();
intent.putExtra(ContactSelectionListFragment.REFRESHABLE, false);
intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, 1);
intent.putExtra(ContactSelectionListFragment.HIDE_COUNT, true);
intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE,
intent.putExtra(ContactSelectionArguments.REFRESHABLE, false);
intent.putExtra(ContactSelectionArguments.SELECTION_LIMITS, 1);
intent.putExtra(ContactSelectionArguments.DISPLAY_MODE,
ContactSelectionDisplayMode.FLAG_PUSH |
ContactSelectionDisplayMode.FLAG_SMS |
ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS |

View File

@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -47,7 +48,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsViewModel
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.window.WindowSizeClass
import org.thoughtcrime.securesms.window.isSplitPane
class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
@@ -109,7 +110,7 @@ fun EditCallLinkNameScreen(
onNavigationClick = {
backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed()
},
showNavigationIcon = !WindowSizeClass.rememberWindowSizeClass().isSplitPane()
showNavigationIcon = !currentWindowAdaptiveInfo().windowSizeClass.isSplitPane()
)
}

View File

@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -55,7 +56,7 @@ import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import org.thoughtcrime.securesms.sharing.v2.ShareActivity
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.window.WindowSizeClass
import org.thoughtcrime.securesms.window.isSplitPane
import java.time.Instant
@Composable
@@ -84,7 +85,7 @@ fun CallLinkDetailsScreen(
state = state,
showAlreadyInACall = showAlreadyInACall,
callback = callback,
showNavigationIcon = !WindowSizeClass.rememberWindowSizeClass().isSplitPane()
showNavigationIcon = !currentWindowAdaptiveInfo().windowSizeClass.isSplitPane()
)
}

View File

@@ -92,6 +92,7 @@ class CallLogAdapter(
fun submitCallRows(
rows: List<CallLogRow?>,
selectionState: CallLogSelectionState,
activeCallLogRowId: CallLogRow.Id?,
localCallRecipientId: RecipientId,
onCommit: () -> Unit
): Int {
@@ -99,8 +100,19 @@ class CallLogAdapter(
.filterNotNull()
.map {
when (it) {
is CallLogRow.Call -> CallModel(it, selectionState, itemCount, it.peer.id == localCallRecipientId)
is CallLogRow.CallLink -> CallLinkModel(it, selectionState, itemCount, it.recipient.id == localCallRecipientId)
is CallLogRow.Call -> CallModel(
call = it,
selectionState = selectionState,
itemCount = itemCount,
isLocalDeviceInCall = it.peer.id == localCallRecipientId
)
is CallLogRow.CallLink -> CallLinkModel(
callLink = it,
selectionState = selectionState,
activeCallLogRowId = activeCallLogRowId,
itemCount = itemCount,
isLocalDeviceInCall = it.recipient.id == localCallRecipientId
)
is CallLogRow.ClearFilter -> ClearFilterModel()
is CallLogRow.ClearFilterEmpty -> ClearFilterEmptyModel()
is CallLogRow.CreateCallLink -> CreateCallLinkModel()
@@ -148,6 +160,7 @@ class CallLogAdapter(
private class CallLinkModel(
val callLink: CallLogRow.CallLink,
val selectionState: CallLogSelectionState,
val activeCallLogRowId: CallLogRow.Id?,
val itemCount: Int,
val isLocalDeviceInCall: Boolean
) : MappingModel<CallLinkModel> {
@@ -159,12 +172,13 @@ class CallLogAdapter(
override fun areContentsTheSame(newItem: CallLinkModel): Boolean {
return callLink == newItem.callLink &&
isSelectionStateTheSame(newItem) &&
isActiveIdStateTheSame(newItem) &&
isItemCountTheSame(newItem) &&
isLocalDeviceInCall == newItem.isLocalDeviceInCall
}
override fun getChangePayload(newItem: CallLinkModel): Any? {
return if (callLink == newItem.callLink && (!isSelectionStateTheSame(newItem) || !isItemCountTheSame(newItem))) {
return if (callLink == newItem.callLink && (!isSelectionStateTheSame(newItem) || !isItemCountTheSame(newItem) || !isActiveIdStateTheSame(newItem))) {
PAYLOAD_SELECTION_STATE
} else {
null
@@ -176,6 +190,13 @@ class CallLogAdapter(
selectionState.isNotEmpty(itemCount) == newItem.selectionState.isNotEmpty(newItem.itemCount)
}
private fun isActiveIdStateTheSame(newItem: CallLinkModel): Boolean {
val isOldItemActive = activeCallLogRowId == callLink.id
val isNewItemActive = newItem.activeCallLogRowId == newItem.callLink.id
return (isOldItemActive && isNewItemActive) || (!isOldItemActive && !isNewItemActive)
}
private fun isItemCountTheSame(newItem: CallLinkModel): Boolean {
return itemCount == newItem.itemCount
}
@@ -220,6 +241,8 @@ class CallLogAdapter(
binding.callSelected.isChecked = model.selectionState.contains(model.callLink.id)
binding.callSelected.visible = model.selectionState.isNotEmpty(model.itemCount)
itemView.isActivated = model.activeCallLogRowId == model.callLink.id
if (payload.isNotEmpty()) {
return
}

View File

@@ -18,6 +18,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.kotlin.Flowables
import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.coroutines.launch
@@ -25,6 +26,7 @@ import org.signal.core.util.DimensionUnit
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.concurrent.addTo
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.MainNavigator
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.create.CreateCallLinkBottomSheetDialogFragment
@@ -59,7 +61,8 @@ import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.doAfterNextLayout
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.visible
import org.thoughtcrime.securesms.window.WindowSizeClass.Companion.getWindowSizeClass
import org.thoughtcrime.securesms.window.getWindowSizeClass
import org.thoughtcrime.securesms.window.isSplitPane
import java.util.Objects
/**
@@ -122,12 +125,13 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
)
disposables += scrollToPositionDelegate
disposables += Flowables.combineLatest(viewModel.data, viewModel.selected)
disposables += Flowables.combineLatest(viewModel.data, viewModel.selected, mainNavigationViewModel.observableActiveCallId.toFlowable(BackpressureStrategy.LATEST))
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (data, selected) ->
.subscribe { (data, selected, activeRowId) ->
val filteredCount = callLogAdapter.submitCallRows(
data,
selected,
activeCallLogRowId = activeRowId.orNull().takeIf { resources.getWindowSizeClass().isSplitPane() },
viewModel.callLogPeekHelper.localDeviceCallRecipientId,
scrollToPositionDelegate::notifyListCommitted
)
@@ -139,6 +143,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (selected, totalCount) ->
if (selected.isNotEmpty(totalCount)) {
callLogActionMode.start()
callLogActionMode.setCount(selected.count(totalCount))
} else if (mainToolbarViewModel.isInActionMode()) {
callLogActionMode.end()
@@ -180,7 +185,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
}
if (resources.getWindowSizeClass().isCompact()) {
if (!resources.getWindowSizeClass().isSplitPane()) {
ViewUtil.setBottomMargin(binding.bottomActionBar, ViewUtil.getNavigationBarHeight(binding.bottomActionBar))
}
@@ -203,7 +208,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
private fun initializeTapToScrollToTop(scrollToPositionDelegate: ScrollToPositionDelegate) {
disposables += mainNavigationViewModel.tabClickEvents
disposables += mainNavigationViewModel.tabClickEventsObservable
.filter { it == MainNavigationListLocation.CALLS }
.subscribeBy(onNext = {
scrollToPositionDelegate.resetScrollPosition()

View File

@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
import org.thoughtcrime.securesms.contacts.paged.ChatType
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -100,7 +101,7 @@ class NewCallActivity : ContactSelectionActivity(), ContactSelectionListFragment
fun createIntent(context: Context): Intent {
return Intent(context, NewCallActivity::class.java)
.putExtra(
ContactSelectionListFragment.DISPLAY_MODE,
ContactSelectionArguments.DISPLAY_MODE,
ContactSelectionDisplayMode.none()
.withPush()
.withActiveGroups()

View File

@@ -0,0 +1,87 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.quality
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity
import org.thoughtcrime.securesms.util.viewModel
/**
* Fragment which manages sheets for walking the user through collecting call
* quality feedback.
*/
class CallQualityBottomSheetFragment : ComposeBottomSheetDialogFragment() {
companion object {
const val REQUEST_KEY = "CallQualityBottomSheetRequestKey"
}
private val viewModel: CallQualityScreenViewModel by viewModel {
CallQualityScreenViewModel()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
setFragmentResultListener(CallQualitySomethingElseFragment.REQUEST_KEY) { key, bundle ->
val result = bundle.getString(CallQualitySomethingElseFragment.REQUEST_KEY) ?: ""
viewModel.onSomethingElseDescriptionChanged(result)
}
}
@Composable
override fun SheetContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
CallQualitySheet(
state = state,
callback = remember { Callback() }
)
}
private inner class Callback : CallQualitySheetCallback {
override fun dismiss() {
this@CallQualityBottomSheetFragment.dismissAllowingStateLoss()
}
override fun viewDebugLog() {
startActivity(
Intent(requireContext(), SubmitDebugLogActivity::class.java).apply {
putExtra(SubmitDebugLogActivity.ARG_VIEW_ONLY, true)
}
)
}
override fun describeYourIssue() {
CallQualitySomethingElseFragment.create(
viewModel.state.value.somethingElseDescription
).show(parentFragmentManager, null)
}
override fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) {
viewModel.onCallQualityIssueSelectionChanged(selection)
}
override fun onShareDebugLogChanged(shareDebugLog: Boolean) {
viewModel.onShareDebugLogChanged(shareDebugLog)
}
override fun submit() {
viewModel.submit()
dismiss()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to true))
}
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.quality
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
class CallQualityScreenViewModel : ViewModel() {
private val internalState = MutableStateFlow(CallQualitySheetState())
val state: StateFlow<CallQualitySheetState> = internalState
fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) {
internalState.update { it.copy(selectedQualityIssues = selection) }
}
fun onSomethingElseDescriptionChanged(somethingElseDescription: String) {
internalState.update { it.copy(somethingElseDescription = somethingElseDescription) }
}
fun onShareDebugLogChanged(shareDebugLog: Boolean) {
internalState.update { it.copy(isShareDebugLogSelected = shareDebugLog) }
}
fun submit() {
// Enqueue job.
}
}

View File

@@ -0,0 +1,632 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.quality
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.InputChip
import androidx.compose.material3.InputChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
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.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.IconButtons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CallQualitySheet(
state: CallQualitySheetState = remember { CallQualitySheetState() },
callback: CallQualitySheetCallback = CallQualitySheetCallback.Empty
) {
var navEntry: CallQualitySheetNavEntry by remember { mutableStateOf(CallQualitySheetNavEntry.HowWasYourCall) }
when (navEntry) {
CallQualitySheetNavEntry.HowWasYourCall -> HowWasYourCall(
onGreatClick = {
navEntry = CallQualitySheetNavEntry.HelpUsImprove
},
onHadIssuesClick = {
navEntry = CallQualitySheetNavEntry.WhatIssuesDidYouHave
},
onCancelClick = callback::dismiss
)
CallQualitySheetNavEntry.WhatIssuesDidYouHave -> WhatIssuesDidYouHave(
selectedQualityIssues = state.selectedQualityIssues,
somethingElseDescription = state.somethingElseDescription,
onCallQualityIssueSelectionChanged = callback::onCallQualityIssueSelectionChanged,
onContinueClick = {
navEntry = CallQualitySheetNavEntry.HelpUsImprove
},
onDescribeYourIssueClick = callback::describeYourIssue,
onCancelClick = callback::dismiss
)
CallQualitySheetNavEntry.HelpUsImprove -> HelpUsImprove(
isShareDebugLogSelected = state.isShareDebugLogSelected,
onViewDebugLogClick = callback::viewDebugLog,
onCancelClick = callback::dismiss,
onShareDebugLogChanged = callback::onShareDebugLogChanged,
onSubmitClick = callback::submit
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HowWasYourCall(
onHadIssuesClick: () -> Unit,
onGreatClick: () -> Unit,
onCancelClick: () -> Unit
) {
Sheet(onDismissRequest = onCancelClick) {
SheetTitle(text = stringResource(R.string.CallQualitySheet__how_was_your_call))
SheetSubtitle(text = stringResource(R.string.CallQualitySheet__how_was_your_call_subtitle))
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp)
) {
HadIssuesButton(onClick = onHadIssuesClick)
GreatButton(onClick = onGreatClick)
}
CancelButton(
onClick = onCancelClick,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = 32.dp, bottom = 24.dp)
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WhatIssuesDidYouHave(
selectedQualityIssues: Set<CallQualityIssue>,
somethingElseDescription: String,
onCallQualityIssueSelectionChanged: (Set<CallQualityIssue>) -> Unit,
onCancelClick: () -> Unit,
onContinueClick: () -> Unit,
onDescribeYourIssueClick: () -> Unit
) {
Sheet(onDismissRequest = onCancelClick) {
SheetTitle(text = stringResource(R.string.CallQualitySheet__what_issues_did_you_have))
SheetSubtitle(text = stringResource(R.string.CallQualitySheet__select_all_that_apply))
val qualityIssueDisplaySet = rememberQualityDisplaySet(selectedQualityIssues)
val onCallQualityIssueClick: (CallQualityIssue) -> Unit = remember(selectedQualityIssues, onCallQualityIssueSelectionChanged) {
{ issue ->
val isRemoving = issue in selectedQualityIssues
val selection = when {
isRemoving && issue == CallQualityIssue.AUDIO_ISSUE -> {
selectedQualityIssues.filterNot { it.category == CallQualityIssueCategory.AUDIO }.toSet()
}
isRemoving && issue == CallQualityIssue.VIDEO_ISSUE -> {
selectedQualityIssues.filterNot { it.category == CallQualityIssueCategory.VIDEO }.toSet()
}
isRemoving -> {
selectedQualityIssues - issue
}
else -> {
selectedQualityIssues + issue
}
}
onCallQualityIssueSelectionChanged(selection)
}
}
FlowRow(
modifier = Modifier
.animateContentSize()
.fillMaxWidth()
.horizontalGutters(),
horizontalArrangement = Arrangement.Center
) {
qualityIssueDisplaySet.forEach { issue ->
val isIssueSelected = issue in selectedQualityIssues
InputChip(
selected = isIssueSelected,
onClick = {
onCallQualityIssueClick(issue)
},
colors = InputChipDefaults.inputChipColors(
leadingIconColor = MaterialTheme.colorScheme.onSurface,
selectedLeadingIconColor = MaterialTheme.colorScheme.onSurface,
labelColor = MaterialTheme.colorScheme.onSurface
),
leadingIcon = {
Icon(
imageVector = if (isIssueSelected) {
ImageVector.vectorResource(R.drawable.symbol_check_24)
} else {
ImageVector.vectorResource(issue.category.icon)
},
contentDescription = null
)
},
label = {
Text(text = stringResource(issue.label))
},
modifier = Modifier.padding(horizontal = 4.dp)
)
}
if (CallQualityIssue.SOMETHING_ELSE in selectedQualityIssues) {
val text = somethingElseDescription.ifEmpty {
stringResource(R.string.CallQualitySheet__describe_your_issue)
}
val textColor = if (somethingElseDescription.isNotEmpty()) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val textUnderlineStrokeWidthPx = with(LocalDensity.current) {
1.dp.toPx()
}
val textUnderlineColor = MaterialTheme.colorScheme.outline
Text(
text = text,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.clickable(
role = Role.Button,
onClick = onDescribeYourIssueClick
)
.fillMaxWidth()
.padding(top = 24.dp)
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
.drawWithContent {
drawContent()
val width = size.width
val height = size.height - textUnderlineStrokeWidthPx / 2f
drawLine(
color = textUnderlineColor,
start = Offset(x = 0f, y = height),
end = Offset(x = width, y = height),
strokeWidth = textUnderlineStrokeWidthPx
)
}
.padding(16.dp)
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(top = 32.dp, bottom = 24.dp)
) {
CancelButton(
onClick = onCancelClick
)
Buttons.LargeTonal(
onClick = onContinueClick
) {
Text(text = stringResource(R.string.CallQualitySheet__continue))
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HelpUsImprove(
isShareDebugLogSelected: Boolean,
onShareDebugLogChanged: (Boolean) -> Unit,
onViewDebugLogClick: () -> Unit,
onCancelClick: () -> Unit,
onSubmitClick: () -> Unit
) {
Sheet(onDismissRequest = onCancelClick) {
SheetTitle(text = stringResource(R.string.CallQualitySheet__help_us_improve))
SheetSubtitle(
text = buildAnnotatedString {
append(stringResource(R.string.CallQualitySheet__help_us_improve_description_prefix))
append(" ")
withLink(
link = LinkAnnotation.Clickable(
"view-your-debug-log",
linkInteractionListener = { onViewDebugLogClick() },
styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary))
)
) {
append(stringResource(R.string.CallQualitySheet__view_your_debug_log))
}
append(" ")
append(stringResource(R.string.CallQualitySheet__help_us_improve_description_suffix))
}
)
Rows.ToggleRow(
checked = isShareDebugLogSelected,
text = stringResource(R.string.CallQualitySheet__share_debug_log),
onCheckChanged = onShareDebugLogChanged
)
Text(
text = stringResource(R.string.CallQualitySheet__debug_log_privacy_notice),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.horizontalGutters().padding(top = 14.dp)
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
.padding(top = 32.dp, bottom = 24.dp)
) {
CancelButton(
onClick = onCancelClick
)
Buttons.LargeTonal(
onClick = onSubmitClick
) {
Text(text = stringResource(R.string.CallQualitySheet__submit))
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun Sheet(
onDismissRequest: () -> Unit,
content: @Composable ColumnScope.() -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
dragHandle = null,
sheetGesturesEnabled = false,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
) {
content()
}
}
@Composable
private fun SheetTitle(
text: String
) {
Text(
text = text,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier
.fillMaxWidth()
.padding(top = 46.dp, bottom = 10.dp)
.horizontalGutters()
)
}
@Composable
private fun SheetSubtitle(
text: String
) {
Text(
text = text,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
)
}
@Composable
private fun SheetSubtitle(
text: AnnotatedString
) {
Text(
text = text,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
)
}
@Composable
private fun HadIssuesButton(
onClick: () -> Unit
) {
FeedbackButton(
text = stringResource(R.string.CallQualitySheet__had_issues),
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.error,
onClick = onClick
)
}
@Composable
private fun GreatButton(
onClick: () -> Unit
) {
FeedbackButton(
text = stringResource(R.string.CallQualitySheet__great),
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.primary,
onClick = onClick
)
}
@Composable
private fun FeedbackButton(
text: String,
onClick: () -> Unit,
containerColor: Color,
contentColor: Color
// imageVector icon
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = spacedBy(12.dp)
) {
IconButtons.IconButton(
onClick = onClick,
size = 72.dp,
modifier = Modifier
.clip(CircleShape)
.background(color = containerColor)
) {
// TODO - icon with contentcolor tint
}
Text(
text = text,
style = MaterialTheme.typography.bodyLarge
)
}
}
@Composable
fun CancelButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
TextButton(onClick = onClick, modifier = modifier) {
Text(text = stringResource(android.R.string.cancel))
}
}
@PreviewLightDark
@Composable
private fun CallQualityScreenPreview() {
var state by remember { mutableStateOf(CallQualitySheetState()) }
Previews.Preview {
CallQualitySheet(
state = state,
callback = remember {
object : CallQualitySheetCallback by CallQualitySheetCallback.Empty {
override fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) {
state = state.copy(selectedQualityIssues = selection)
}
}
}
)
}
}
@PreviewLightDark
@Composable
private fun HowWasYourCallPreview() {
Previews.BottomSheetPreview {
Column {
HowWasYourCall(
onGreatClick = {},
onCancelClick = {},
onHadIssuesClick = {}
)
}
}
}
@PreviewLightDark
@Composable
private fun WhatIssuesDidYouHavePreview() {
Previews.BottomSheetPreview {
var userSelection by remember { mutableStateOf<Set<CallQualityIssue>>(emptySet()) }
Column {
WhatIssuesDidYouHave(
selectedQualityIssues = userSelection,
somethingElseDescription = "",
onCallQualityIssueSelectionChanged = {
userSelection = it
},
onCancelClick = {},
onContinueClick = {},
onDescribeYourIssueClick = {}
)
}
}
}
@PreviewLightDark
@Composable
private fun HelpUsImprovePreview() {
Previews.BottomSheetPreview {
Column {
HelpUsImprove(
isShareDebugLogSelected = true,
onViewDebugLogClick = {},
onCancelClick = {},
onShareDebugLogChanged = {},
onSubmitClick = {}
)
}
}
}
@PreviewLightDark
@Composable
private fun SomethingElseContentPreview() {
Previews.Preview {
CallQualitySomethingElseScreen(
somethingElseDescription = "About 5 minutes into a call with my friend",
onCancelClick = {},
onSaveClick = {}
)
}
}
@Composable
private fun rememberQualityDisplaySet(userSelection: Set<CallQualityIssue>): Set<CallQualityIssue> {
return remember(userSelection) {
val displaySet = mutableSetOf<CallQualityIssue>()
displaySet.add(CallQualityIssue.AUDIO_ISSUE)
if (CallQualityIssue.AUDIO_ISSUE in userSelection) {
displaySet.add(CallQualityIssue.AUDIO_STUTTERING)
displaySet.add(CallQualityIssue.AUDIO_CUT_OUT)
displaySet.add(CallQualityIssue.AUDIO_I_HEARD_ECHO)
displaySet.add(CallQualityIssue.AUDIO_OTHERS_HEARD_ECHO)
}
displaySet.add(CallQualityIssue.VIDEO_ISSUE)
if (CallQualityIssue.VIDEO_ISSUE in userSelection) {
displaySet.add(CallQualityIssue.VIDEO_POOR_QUALITY)
displaySet.add(CallQualityIssue.VIDEO_LOW_RESOLUTION)
displaySet.add(CallQualityIssue.VIDEO_CAMERA_MALFUNCTION)
}
displaySet.add(CallQualityIssue.CALL_DROPPED)
displaySet.add(CallQualityIssue.SOMETHING_ELSE)
displaySet
}
}
data class CallQualitySheetState(
val selectedQualityIssues: Set<CallQualityIssue> = emptySet(),
val somethingElseDescription: String = "",
val isShareDebugLogSelected: Boolean = false
)
interface CallQualitySheetCallback {
fun dismiss()
fun viewDebugLog()
fun describeYourIssue()
fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>)
fun onShareDebugLogChanged(shareDebugLog: Boolean)
fun submit()
object Empty : CallQualitySheetCallback {
override fun dismiss() = Unit
override fun viewDebugLog() = Unit
override fun describeYourIssue() = Unit
override fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) = Unit
override fun onShareDebugLogChanged(shareDebugLog: Boolean) = Unit
override fun submit() = Unit
}
}
private enum class CallQualitySheetNavEntry {
HowWasYourCall,
WhatIssuesDidYouHave,
HelpUsImprove
}
enum class CallQualityIssueCategory(
@param:DrawableRes val icon: Int
) {
AUDIO(icon = R.drawable.symbol_speaker_24),
VIDEO(icon = R.drawable.symbol_video_24),
CALL_DROPPED(icon = R.drawable.symbol_x_circle_24),
SOMETHING_ELSE(icon = R.drawable.symbol_error_circle_24)
}
enum class CallQualityIssue(
val category: CallQualityIssueCategory,
@param:StringRes val label: Int
) {
AUDIO_ISSUE(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_issue),
AUDIO_STUTTERING(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_stuttering),
AUDIO_CUT_OUT(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_cut_out),
AUDIO_I_HEARD_ECHO(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__i_heard_echo),
AUDIO_OTHERS_HEARD_ECHO(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__others_heard_echo),
VIDEO_ISSUE(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__video_issue),
VIDEO_POOR_QUALITY(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__poor_video_quality),
VIDEO_LOW_RESOLUTION(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__low_resolution),
VIDEO_CAMERA_MALFUNCTION(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__camera_did_not_work),
CALL_DROPPED(category = CallQualityIssueCategory.CALL_DROPPED, label = R.string.CallQualityIssue__call_droppped),
SOMETHING_ELSE(category = CallQualityIssueCategory.SOMETHING_ELSE, label = R.string.CallQualityIssue__something_else)
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.quality
import android.app.Dialog
import android.os.Bundle
import android.view.WindowManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult
import org.thoughtcrime.securesms.compose.ComposeFullScreenDialogFragment
/**
* Fragment which allows user to enter additional text to describe a call issue.
*/
class CallQualitySomethingElseFragment : ComposeFullScreenDialogFragment() {
companion object {
const val REQUEST_KEY = "CallQualitySomethingElseRequestKey"
fun create(somethingElseDescription: String): DialogFragment {
return CallQualitySomethingElseFragment().apply {
arguments = bundleOf(REQUEST_KEY to somethingElseDescription)
}
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.window!!.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
return dialog
}
@Composable
override fun DialogContent() {
val initialState = remember { requireArguments().getString(REQUEST_KEY) ?: "" }
CallQualitySomethingElseScreen(
somethingElseDescription = initialState,
onSaveClick = {
dismissAllowingStateLoss()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
},
onCancelClick = {
dismissAllowingStateLoss()
}
)
}
}

View File

@@ -0,0 +1,106 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.quality
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.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.TextFields
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
@Composable
fun CallQualitySomethingElseScreen(
somethingElseDescription: String,
onCancelClick: () -> Unit,
onSaveClick: (String) -> Unit
) {
Scaffolds.Settings(
title = stringResource(R.string.CallQualitySomethingElseScreen__title),
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24),
onNavigationClick = onCancelClick,
navigationContentDescription = stringResource(R.string.CallQualitySomethingElseScreen__back),
modifier = Modifier.imePadding()
) { paddingValues ->
var issue by remember { mutableStateOf(somethingElseDescription) }
val focusRequester = remember { FocusRequester() }
Column(
modifier = Modifier.padding(paddingValues)
) {
TextFields.TextField(
label = {
Text(stringResource(R.string.CallQualitySomethingElseScreen__describe_your_issue))
},
value = issue,
minLines = 4,
maxLines = 4,
onValueChange = {
issue = it
},
modifier = Modifier
.focusRequester(focusRequester)
.fillMaxWidth()
.horizontalGutters()
)
Text(
text = stringResource(R.string.CallQualitySomethingElseScreen__privacy_notice),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.horizontalGutters()
.padding(top = 24.dp, bottom = 32.dp)
)
Spacer(modifier = Modifier.weight(1f))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
.padding(bottom = 16.dp)
) {
CancelButton(
onClick = onCancelClick
)
Buttons.LargeTonal(
onClick = { onSaveClick(issue) }
) {
Text(text = stringResource(R.string.CallQualitySomethingElseScreen__save))
}
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}

View File

@@ -23,6 +23,7 @@ import android.view.inputmethod.InputConnection;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.content.ContextCompat;
import androidx.core.view.inputmethod.EditorInfoCompat;
import androidx.core.view.inputmethod.InputConnectionCompat;
@@ -236,7 +237,7 @@ public class ComposeText extends EmojiEditText {
return null;
}
EditorInfoCompat.setContentMimeTypes(editorInfo, new String[] { "image/jpeg", "image/png", "image/gif" });
EditorInfoCompat.setContentMimeTypes(editorInfo, new String[] { "image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif", "image/avif" });
return InputConnectionCompat.createWrapper(inputConnection, editorInfo, new CommitContentListener(mediaListener));
}
@@ -477,16 +478,20 @@ public class ComposeText extends EmojiEditText {
/**
* Return true if we think the user may be inputting a time.
*/
private static boolean couldBeTimeEntry(@NonNull CharSequence text, int startIndex) {
@VisibleForTesting
static boolean couldBeTimeEntry(@NonNull CharSequence text, int startIndex) {
if (startIndex <= 0 || startIndex + 1 >= text.length()) {
return false;
}
int startOfToken = startIndex;
while (startOfToken > 0 && !Character.isWhitespace(text.charAt(startOfToken))) {
startOfToken--;
while (startOfToken > 0) {
int prevIndex = startOfToken - 1;
if (Character.isWhitespace(text.charAt(prevIndex))) {
break;
}
startOfToken = prevIndex;
}
startOfToken++;
int endOfToken = startIndex;
while (endOfToken < text.length() && !Character.isWhitespace(text.charAt(endOfToken))) {

View File

@@ -27,9 +27,10 @@ import org.thoughtcrime.securesms.util.ViewUtil;
/**
* A search input field for finding recipients.
* <p>
* In compose, use RecipientSearchField instead.
*
* @deprecated Use the RecipientSearchBar composable instead.
*/
@Deprecated
public final class ContactFilterView extends FrameLayout {
private OnFilterChangedListener listener;
@@ -147,6 +148,11 @@ public final class ContactFilterView extends FrameLayout {
ViewUtil.focusAndShowKeyboard(searchText);
}
public void setText(String text) {
searchText.setText(text);
searchText.setSelection(text.length());
}
public void clear() {
searchText.setText("");
notifyListener();

View File

@@ -19,6 +19,9 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.WindowUtil
import org.thoughtcrime.securesms.window.getWindowSizeClass
import org.thoughtcrime.securesms.window.isLargeScreenSupportEnabled
import org.thoughtcrime.securesms.window.isSplitPane
import com.google.android.material.R as MaterialR
/**
@@ -26,6 +29,17 @@ import com.google.android.material.R as MaterialR
*/
abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFragment() {
/**
* Sheet corner radius in DP
*/
protected val cornerRadius: Int by lazy {
if (isLargeScreenSupportEnabled() && resources.getWindowSizeClass().isSplitPane()) {
32
} else {
18
}
}
protected open val peekHeightPercentage: Float = 0.5f
@StyleRes
@@ -54,8 +68,8 @@ abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFr
dialog.behavior.peekHeight = (resources.displayMetrics.heightPixels * peekHeightPercentage).toInt()
val shapeAppearanceModel = ShapeAppearanceModel.builder()
.setTopLeftCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), 18).toFloat())
.setTopRightCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), 18).toFloat())
.setTopLeftCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), cornerRadius).toFloat())
.setTopRightCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), cornerRadius).toFloat())
.build()
dialogBackground = MaterialShapeDrawable(shapeAppearanceModel)

View File

@@ -13,6 +13,7 @@ import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.DialogFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.WindowUtil;
/**
* Base dialog fragment for rendering as a full screen dialog with animation
@@ -43,6 +44,12 @@ public abstract class FullScreenDialogFragment extends DialogFragment {
return view;
}
@Override
public void onResume() {
super.onResume();
WindowUtil.initializeScreenshotSecurity(requireContext(), requireDialog().getWindow());
}
protected void onNavigateUp() {
dismissAllowingStateLoss();
}

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components
import android.content.Context
import android.content.res.Configuration
import android.util.AttributeSet
import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout
@@ -13,9 +14,8 @@ import androidx.core.view.WindowInsetsCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.main.InsetsViewModel
import org.thoughtcrime.securesms.main.VerticalInsets
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.window.WindowSizeClass.Companion.getWindowSizeClass
import kotlin.math.roundToInt
/**
@@ -66,7 +66,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
private var insets: WindowInsetsCompat? = null
private var windowTypes: Int = InsetAwareConstraintLayout.windowTypes
private var verticalInsetOverride: InsetsViewModel.Insets = InsetsViewModel.Insets.Zero
private var verticalInsetOverride: VerticalInsets = VerticalInsets.Zero
private val windowInsetsListener = androidx.core.view.OnApplyWindowInsetsListener { _, insets ->
this.insets = insets
@@ -130,7 +130,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
}
}
fun applyInsets(insets: InsetsViewModel.Insets) {
fun applyInsets(insets: VerticalInsets) {
verticalInsetOverride = insets
if (this.insets != null) {
@@ -138,14 +138,6 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
}
}
fun clearVerticalInsetOverride() {
verticalInsetOverride = InsetsViewModel.Insets.Zero
if (this.insets != null) {
applyInsets(this.insets!!.getInsets(windowTypes), this.insets!!.getInsets(keyboardType))
}
}
fun addKeyboardStateListener(listener: KeyboardStateListener) {
keyboardStateListeners += listener
}
@@ -165,8 +157,8 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
private fun applyInsets(windowInsets: Insets, keyboardInsets: Insets) {
val isLtr = ViewUtil.isLtr(this)
val statusBar = if (verticalInsetOverride == InsetsViewModel.Insets.Zero) windowInsets.top else verticalInsetOverride.statusBar.roundToInt()
val navigationBar = if (verticalInsetOverride == InsetsViewModel.Insets.Zero) windowInsets.bottom else verticalInsetOverride.navBar.roundToInt()
val statusBar = if (verticalInsetOverride == VerticalInsets.Zero) windowInsets.top else verticalInsetOverride.statusBar.roundToInt()
val navigationBar = if (verticalInsetOverride == VerticalInsets.Zero) windowInsets.bottom else verticalInsetOverride.navBar.roundToInt()
val parentStart = if (isLtr) windowInsets.left else windowInsets.right
val parentEnd = if (isLtr) windowInsets.right else windowInsets.left
@@ -190,9 +182,9 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
}
} else if (!overridingKeyboard) {
if (!keyboardAnimator.animating) {
keyboardGuideline?.setGuidelineEnd(windowInsets.bottom)
keyboardGuideline?.setGuidelineEnd(navigationBar)
} else {
keyboardAnimator.endingGuidelineEnd = windowInsets.bottom
keyboardAnimator.endingGuidelineEnd = navigationBar
}
}
@@ -249,7 +241,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
}
private fun isLandscape(): Boolean {
return resources.getWindowSizeClass().isLandscape()
return resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
}
private val Guideline?.guidelineEnd: Int

View File

@@ -4,6 +4,7 @@ package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
@@ -32,6 +33,7 @@ import org.thoughtcrime.securesms.components.quotes.QuoteViewColorTheme;
import org.thoughtcrime.securesms.conversation.MessageStyler;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
import org.thoughtcrime.securesms.fonts.SignalSymbols;
import org.thoughtcrime.securesms.mms.DecryptableUri;
import org.thoughtcrime.securesms.mms.QuoteModel;
import org.thoughtcrime.securesms.mms.Slide;
@@ -43,6 +45,7 @@ import org.thoughtcrime.securesms.stories.StoryTextPostModel;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.Stub;
import java.io.IOException;
@@ -231,7 +234,14 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
}
private @Nullable CharSequence resolveBody(@Nullable CharSequence body, @NonNull QuoteModel.Type quoteType) {
return quoteType == QuoteModel.Type.GIFT_BADGE ? getContext().getString(R.string.QuoteView__donation_for_a_friend) : body;
switch (quoteType) {
case GIFT_BADGE:
return getContext().getString(R.string.QuoteView__donation_for_a_friend);
case POLL:
return getContext().getString(R.string.Poll__poll_question, body);
default:
return body;
}
}
public void setTopCornerSizes(boolean topLeftLarge, boolean topRightLarge) {
@@ -317,6 +327,14 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
Log.w(TAG, "Could not parse body of text post.", e);
bodyView.setText("");
}
} else if (quoteType == QuoteModel.Type.POLL) {
CharSequence glyph = SignalSymbols.getSpannedString(getContext(), SignalSymbols.Weight.REGULAR, SignalSymbols.Glyph.POLL, -1);
// TODO(michelle): Update with RTL poll icon
SpannableStringBuilder builder = new SpannableStringBuilder()
.append(glyph)
.append(" ")
.append(body);
bodyView.setText(body == null ? "" : builder);
} else {
bodyView.setText(body == null ? "" : body);
}
@@ -404,7 +422,7 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
return;
}
if (TextUtils.isEmpty(quoteTargetContentType) || slide == null || slide.getUri() == null) {
if (TextUtils.isEmpty(quoteTargetContentType)) {
thumbnailView.setVisibility(GONE);
attachmentNameViewStub.setVisibility(GONE);
@@ -431,12 +449,12 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
attachmentVideoOVerlayStub.setVisibility(VISIBLE);
}
requestManager.load(new DecryptableUri(slide.getUri()))
requestManager.load(slide.getUri() != null ? new DecryptableUri(slide.getUri()) : null)
.centerCrop()
.override(thumbWidth, thumbHeight)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.into(thumbnailView);
} else if (MediaUtil.isAudioType(quoteTargetContentType)) {
} else if (MediaUtil.isAudioType(quoteTargetContentType) || MediaUtil.isLongTextType(quoteTargetContentType)) {
thumbnailView.setVisibility(GONE);
attachmentNameViewStub.setVisibility(GONE);

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.compose
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.signal.core.ui.compose.IconButtons
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
/**
* A consistent ActionMode top-bar for dealing with multiselect scenarios.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ActionModeTopBar(
title: String,
onCloseClick: () -> Unit,
toolbarColor: Color? = null,
windowInsets: WindowInsets = TopAppBarDefaults.windowInsets
) {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = toolbarColor ?: MaterialTheme.colorScheme.surface
),
navigationIcon = {
IconButtons.IconButton(onClick = onCloseClick) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_x_24),
contentDescription = stringResource(R.string.CallScreenTopBar__go_back)
)
}
},
title = {
Text(text = title)
},
windowInsets = windowInsets
)
}
@PreviewLightDark
@Composable
fun ActionModeTopBarPreview() {
Previews.Preview {
ActionModeTopBar(
title = "1 selected",
onCloseClick = {}
)
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.compose
import android.content.Context
import android.util.AttributeSet
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.AbstractComposeView
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.util.DynamicTheme
/**
* A View wrapper for [ActionModeTopBar] so that we can use the same UI element in View and Compose land.
*/
class ActionModeTopBarView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AbstractComposeView(context, attrs, defStyleAttr) {
var title by mutableStateOf("")
var onCloseClick: () -> Unit by mutableStateOf({})
@Composable
override fun Content() {
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(context)) {
Surface(
color = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface
) {
ActionModeTopBar(
title = title,
toolbarColor = Color.Transparent,
onCloseClick = onCloseClick,
windowInsets = WindowInsets()
)
}
}
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.compose
import android.os.Build
import android.view.inputmethod.EditorInfo
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.platform.InterceptPlatformTextInput
import androidx.compose.ui.platform.PlatformTextInputMethodRequest
/**
* When [enabled]=true, this function sets the [EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING] flag for all text fields within its content to enable the
* incognito keyboard.
*
* This workaround is needed until it's possible to configure granular IME options for a [TextField].
* https://issuetracker.google.com/issues/359257538
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ProvideIncognitoKeyboard(
enabled: Boolean,
content: @Composable () -> Unit
) {
if (enabled) {
InterceptPlatformTextInput(
interceptor = { request, nextHandler ->
val modifiedRequest = PlatformTextInputMethodRequest { outAttributes ->
request.createInputConnection(outAttributes).also {
if (Build.VERSION.SDK_INT >= 26) {
outAttributes.imeOptions = outAttributes.imeOptions or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING
}
}
}
nextHandler.startInputMethod(modifiedRequest)
}
) {
content()
}
} else {
content()
}
}

View File

@@ -8,10 +8,12 @@ package org.thoughtcrime.securesms.components.compose
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.thoughtcrime.securesms.window.WindowSizeClass
import androidx.window.core.layout.WindowWidthSizeClass
import org.thoughtcrime.securesms.window.isAtLeast
/**
* Displays the screen title for split-pane UIs on tablets and foldable devices.
@@ -21,7 +23,7 @@ fun ScreenTitlePane(
title: String,
modifier: Modifier = Modifier
) {
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
Text(
text = title,
@@ -29,9 +31,8 @@ fun ScreenTitlePane(
color = MaterialTheme.colorScheme.onSurface,
modifier = modifier
.padding(
start = if (windowSizeClass.isExtended()) 80.dp else 20.dp,
start = if (windowSizeClass.windowWidthSizeClass.isAtLeast(WindowWidthSizeClass.EXPANDED)) 80.dp else 20.dp,
end = 20.dp,
top = 12.dp,
bottom = 12.dp
)
)

View File

@@ -8,6 +8,7 @@ import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.Annotation;
import android.text.Layout;
import android.text.PrecomputedText;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
@@ -29,10 +30,12 @@ import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.appcompat.widget.AppCompatTextView;
import androidx.core.content.ContextCompat;
import androidx.core.text.PrecomputedTextCompat;
import androidx.core.view.GestureDetectorCompat;
import androidx.core.view.ViewKt;
import androidx.core.widget.TextViewCompat;
import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
@@ -42,10 +45,14 @@ import org.thoughtcrime.securesms.conversation.MessageStyler;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor;
import java.lang.ref.Reference;
import java.lang.ref.WeakReference;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.Executor;
import kotlin.Unit;
@@ -85,6 +92,12 @@ public class EmojiTextView extends AppCompatTextView {
private int lastSizeChangedWidth = -1;
private int lastSizeChangedHeight = -1;
// Utilized for async text loading when a large number of emoji is present.
private int taskNumber = 0;
private Executor backgroundExecutor = new SerialMonoLifoExecutor(SignalExecutors.UNBOUNDED);
private CharSequence requestedText = null;
private BufferType requestedType = null;
private MentionRendererDelegate mentionRendererDelegate;
private SpoilerRendererDelegate spoilerRendererDelegate;
@@ -128,7 +141,7 @@ public class EmojiTextView extends AppCompatTextView {
public void setMaxLength(int maxLength) {
this.maxLength = maxLength;
setText(getText());
setTextAsync(getText());
}
@Override
@@ -162,8 +175,115 @@ public class EmojiTextView extends AppCompatTextView {
}
}
/**
* Recommended method for calling through to reset the text flow within this file.
* Doing so will ensure we call setTextAsync with the requested arguments as necessary.
*/
private void resetText() {
if (requestedText == null || requestedType == null) {
return;
}
setTextAsync(requestedText, requestedType);
}
public void setTextAsync(@Nullable CharSequence text) {
setTextAsync(text, BufferType.SPANNABLE);
}
/**
* Sets the text. If there are more than 100 emoji candidates, we utilize PrecomputedTextCompat.
*/
public void setTextAsync(@Nullable CharSequence text, BufferType type) {
taskNumber++;
final int number = taskNumber;
EmojiParser.CandidateList candidates = isInEditMode() ? null : EmojiProvider.getCandidates(text);
if (candidates == null || candidates.size() <= 100) {
setText(text, type);
if (sizeChangeInProgress) {
sizeChangeInProgress = false;
}
return;
}
final PrecomputedTextCompat.Params params = getTextMetricsParamsCompat();
final Reference<EmojiTextView> ref = new WeakReference<>(this);
backgroundExecutor.execute(() -> {
EmojiTextView textView = ref.get();
if (textView != null) {
final CharSequence textToSet;
synchronized (textView) {
textToSet = getTextToSet(text, type);
}
if (textToSet == null) {
return;
}
final PrecomputedTextCompat precomputedTextCompat = PrecomputedTextCompat.create(textToSet, params);
textView.post(() -> {
if (textView.taskNumber != number) {
return;
}
textView.setPrecomputedText(precomputedTextCompat);
if (textView.sizeChangeInProgress) {
textView.sizeChangeInProgress = false;
}
});
}
});
}
/**
* Note if you aren't sure how many emoji are going to be displayed, it may be better to utilize [setTextAsync]
*/
@Override
public void setText(@Nullable CharSequence text, BufferType type) {
boolean isPrecomputed = (text instanceof PrecomputedTextCompat || (Build.VERSION.SDK_INT >= 28 && text instanceof PrecomputedText));
if (!isPrecomputed) {
text = getTextToSet(text, type);
}
if (text == null) {
return;
}
super.setText(text, BufferType.SPANNABLE);
previousText = text;
previousBufferType = type;
previousOverflowText = overflowText;
useSystemEmoji = useSystemEmoji();
previousTransformationMethod = getTransformationMethod();
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
// We ellipsize them ourselves by manually truncating the appropriate section.
if (getText() != null && getText().length() > 0 && isEllipsizedAtEnd()) {
if (getMaxLines() > 0 && getMaxLines() != Integer.MAX_VALUE) {
ellipsizeEmojiTextForMaxLines();
} else if (maxLength > 0) {
ellipsizeAnyTextForMaxLength();
}
}
if (getLayoutParams() != null && getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT) {
requestLayout();
}
}
private @Nullable CharSequence getTextToSet(@Nullable CharSequence text, BufferType type) {
if (text == null) {
return "";
}
EmojiParser.CandidateList candidates = isInEditMode() ? null : EmojiProvider.getCandidates(text);
if (scaleEmojis &&
@@ -187,15 +307,9 @@ public class EmojiTextView extends AppCompatTextView {
}
if (unchanged(text, overflowText, type)) {
return;
return null;
}
previousText = text;
previousOverflowText = overflowText;
previousBufferType = type;
useSystemEmoji = useSystemEmoji();
previousTransformationMethod = getTransformationMethod();
Spannable textToSet;
if (useSystemEmoji || candidates == null || candidates.size() == 0) {
textToSet = new SpannableStringBuilder(Optional.ofNullable(text).orElse(""));
@@ -203,21 +317,7 @@ public class EmojiTextView extends AppCompatTextView {
textToSet = new SpannableStringBuilder(EmojiProvider.emojify(candidates, text, this, isJumbomoji || forceJumboEmoji));
}
super.setText(textToSet, BufferType.SPANNABLE);
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
// We ellipsize them ourselves by manually truncating the appropriate section.
if (getText() != null && getText().length() > 0 && isEllipsizedAtEnd()) {
if (getMaxLines() > 0 && getMaxLines() != Integer.MAX_VALUE) {
ellipsizeEmojiTextForMaxLines();
} else if (maxLength > 0) {
ellipsizeAnyTextForMaxLength();
}
}
if (getLayoutParams() != null && getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT) {
requestLayout();
}
return textToSet;
}
/**
@@ -288,12 +388,15 @@ public class EmojiTextView extends AppCompatTextView {
CharSequence text = getText();
if (text != null) {
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
if (widthSpecMode != MeasureSpec.AT_MOST) {
return widthMeasureSpec;
}
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
float measuredTextWidth = hasMetricAffectingSpan(text) ? Layout.getDesiredWidth(text, getPaint()) : getLongestLineWidth(text);
int desiredWidth = (int) measuredTextWidth + getPaddingLeft() + getPaddingRight();
if (widthSpecMode == MeasureSpec.AT_MOST && desiredWidth < widthSpecSize) {
if (desiredWidth < widthSpecSize) {
return MeasureSpec.makeMeasureSpec(desiredWidth + 3, MeasureSpec.EXACTLY);
}
}
@@ -338,7 +441,8 @@ public class EmojiTextView extends AppCompatTextView {
public void setOverflowText(@Nullable CharSequence overflowText) {
this.overflowText = overflowText;
setText(previousText, BufferType.SPANNABLE);
this.requestedType = BufferType.SPANNABLE;
resetText();
}
@SuppressLint("ClickableViewAccessibility")
@@ -472,8 +576,7 @@ public class EmojiTextView extends AppCompatTextView {
if (!sizeChangeInProgress) {
sizeChangeInProgress = true;
setText(previousText, previousBufferType);
sizeChangeInProgress = false;
resetText();
}
}

View File

@@ -35,7 +35,9 @@ import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
@@ -290,68 +292,70 @@ private fun AppSettingsContent(
BackupFailureState.NONE -> Unit
}
item {
Rows.TextRow(
text = stringResource(R.string.AccountSettingsFragment__account),
icon = painterResource(R.drawable.symbol_person_circle_24),
onClick = {
callbacks.navigate(AppSettingsRoute.AccountRoute.Account)
}
)
}
if (state.isPrimaryDevice) {
item {
Rows.TextRow(
text = stringResource(R.string.AccountSettingsFragment__account),
icon = painterResource(R.drawable.symbol_person_circle_24),
onClick = {
callbacks.navigate(AppSettingsRoute.AccountRoute.Account)
}
)
}
item {
Rows.TextRow(
text = stringResource(R.string.preferences__linked_devices),
icon = painterResource(R.drawable.symbol_devices_24),
onClick = {
callbacks.navigate(AppSettingsRoute.LinkDeviceRoute.LinkDevice)
},
enabled = isRegisteredAndUpToDate
)
}
item {
Rows.TextRow(
text = stringResource(R.string.preferences__linked_devices),
icon = painterResource(R.drawable.symbol_devices_24),
onClick = {
callbacks.navigate(AppSettingsRoute.LinkDeviceRoute.LinkDevice)
},
enabled = isRegisteredAndUpToDate
)
}
item {
val context = LocalContext.current
val donateUrl = stringResource(R.string.donate_url)
item {
val context = LocalContext.current
val donateUrl = stringResource(R.string.donate_url)
Rows.TextRow(
text = {
Text(
text = stringResource(R.string.preferences__donate_to_signal),
modifier = Modifier.weight(1f)
)
if (state.hasExpiredGiftBadge) {
Icon(
painter = painterResource(R.drawable.symbol_info_fill_24),
tint = colorResource(R.color.signal_accent_primary),
contentDescription = null
Rows.TextRow(
text = {
Text(
text = stringResource(R.string.preferences__donate_to_signal),
modifier = Modifier.weight(1f)
)
}
},
icon = {
Icon(
painter = painterResource(R.drawable.symbol_heart_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
},
onClick = {
if (state.allowUserToGoToDonationManagementScreen) {
callbacks.navigate(AppSettingsRoute.DonationsRoute.Donations())
} else {
CommunicationActions.openBrowserLink(context, donateUrl)
}
},
onLongClick = {
callbacks.copyDonorBadgeSubscriberIdToClipboard()
}
)
}
item {
Dividers.Default()
if (state.hasExpiredGiftBadge) {
Icon(
painter = painterResource(R.drawable.symbol_info_fill_24),
tint = colorResource(R.color.signal_accent_primary),
contentDescription = null
)
}
},
icon = {
Icon(
painter = painterResource(R.drawable.symbol_heart_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
},
onClick = {
if (state.allowUserToGoToDonationManagementScreen) {
callbacks.navigate(AppSettingsRoute.DonationsRoute.Donations())
} else {
CommunicationActions.openBrowserLink(context, donateUrl)
}
},
onLongClick = {
callbacks.copyDonorBadgeSubscriberIdToClipboard()
}
)
}
item {
Dividers.Default()
}
}
item {
@@ -408,29 +412,31 @@ private fun AppSettingsContent(
)
}
item {
Rows.TextRow(
text = {
TextWithBetaLabel(
text = stringResource(R.string.preferences_chats__backups),
textStyle = MaterialTheme.typography.bodyLarge
)
},
icon = {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_backup_24),
contentDescription = stringResource(R.string.preferences_chats__backups),
tint = MaterialTheme.colorScheme.onSurface
)
},
onClick = {
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups)
},
onLongClick = {
callbacks.copyRemoteBackupsSubscriberIdToClipboard()
},
enabled = isRegisteredAndUpToDate
)
if (state.isPrimaryDevice) {
item {
Rows.TextRow(
text = {
TextWithBetaLabel(
text = stringResource(R.string.preferences_chats__backups),
textStyle = MaterialTheme.typography.bodyLarge
)
},
icon = {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_backup_24),
contentDescription = stringResource(R.string.preferences_chats__backups),
tint = MaterialTheme.colorScheme.onSurface
)
},
onClick = {
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups)
},
onLongClick = {
callbacks.copyRemoteBackupsSubscriberIdToClipboard()
},
enabled = isRegisteredAndUpToDate
)
}
}
item {
@@ -455,7 +461,7 @@ private fun AppSettingsContent(
}
}
if (state.showPayments) {
if (state.isPrimaryDevice && state.showPayments) {
item {
Dividers.Default()
}
@@ -630,7 +636,10 @@ private fun BioRow(
Text(
text = prettyPhoneNumber,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = TextStyle(
textDirection = TextDirection.ContentOrLtr
)
)
if (hasUsername) {
@@ -692,6 +701,7 @@ private fun AppSettingsContentPreview() {
)
),
state = AppSettingsState(
isPrimaryDevice = true,
unreadPaymentsCount = 5,
hasExpiredGiftBadge = true,
allowUserToGoToDonationManagementScreen = true,

View File

@@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.util.RemoteConfig
@Immutable
data class AppSettingsState(
val isPrimaryDevice: Boolean,
val unreadPaymentsCount: Int,
val hasExpiredGiftBadge: Boolean,
val allowUserToGoToDonationManagementScreen: Boolean,

View File

@@ -21,6 +21,7 @@ class AppSettingsViewModel : ViewModel() {
private val store = Store(
AppSettingsState(
isPrimaryDevice = SignalStore.account.isPrimaryDevice,
unreadPaymentsCount = 0,
hasExpiredGiftBadge = SignalStore.inAppPayments.getExpiredGiftBadge() != null,
allowUserToGoToDonationManagementScreen = SignalStore.inAppPayments.isLikelyASustainer() || InAppDonations.hasAtLeastOnePaymentMethodAvailable(),

View File

@@ -21,7 +21,7 @@ class BioRecipientState(
val username: String = recipient.username.orElse("")
val featuredBadge: Badge? = recipient.featuredBadge
val profileName: ProfileName = recipient.profileName
val e164: String = recipient.requireE164()
val e164: String = recipient.e164.orElse("")
val combinedAboutAndEmoji: String? = recipient.combinedAboutAndEmoji
override fun equals(other: Any?): Boolean {

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