Compare commits

...

277 Commits

Author SHA1 Message Date
Greyson Parrelli 0a45b9b5e3 Bump version to 8.10.0 2026-05-06 13:21:37 -04:00
Greyson Parrelli 99b0061127 Update translations and other static files. 2026-05-06 13:21:07 -04:00
jeffrey-signal 7b11cc1676 Use GroupId navigation args for conversation settings instead of Parcelable. 2026-05-06 13:08:39 -04:00
jeffrey-signal 663e0a616e Fix message bubble caption exceeding the media width. 2026-05-06 13:08:39 -04:00
Alex Hart d05338cee0 Align sync message tracking with iOS. 2026-05-06 13:08:39 -04:00
Alex Hart ce294dbc0b Add mapping-based lazycolumn / lazyrow. 2026-05-06 13:08:39 -04:00
andrew-signal d0efd8d4b0 Bump to libsignal v0.93.2 2026-05-06 13:08:38 -04:00
Greyson Parrelli c8875b5ad1 Limit R8 threads to 1 to fix non-deterministic output. 2026-05-06 13:08:38 -04:00
jeffrey-signal 188458f772 Open chat tab conversation settings in the detail pane where available. 2026-05-06 13:08:38 -04:00
jeffrey-signal ed7fd10749 Split MainNavigationRouter into focused domain-specific routers. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 2ffbf09b1b Fix lint crash by switching static-ips to properties. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 799e57dbe9 Fix bug allowing concurrent execution of jobs in the same queue.
There was an issue where a higher priority job in the same queue would
become the new most eligible job, even if the current most eligible job
was actively running.
2026-05-06 13:08:38 -04:00
Greyson Parrelli 572c11ee6d Update to AGP 9.1.1 2026-05-06 13:08:38 -04:00
Alex Hart 4dd5a4ee53 Reduce Compose overhead on lower-end device.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-06 13:08:38 -04:00
Alex Hart 370fca3c89 Block screen recording during registration by applying FLAG_SECURE.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-06 13:08:38 -04:00
Cody Henthorne d91f130238 Update color and styling of release note update items. 2026-05-06 13:08:38 -04:00
Greyson Parrelli bb20432417 Fix verification message archive restore. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 8138ea5f8f Show error dialog when ChallengeRequired has empty challenges list in change number flow. 2026-05-06 13:08:38 -04:00
Greyson Parrelli f235aa0599 Fix audio message timestamp truncation when playback speed toggle is visible. 2026-05-06 13:08:38 -04:00
jeffrey-signal c7d719e983 Fix message text sometimes overlapping the footer text.
Resolves signalapp/Signal-Android#13580
2026-05-06 13:08:38 -04:00
adel-signal cf71d43a2f Update DRED duration remote config away from global 2026-05-06 13:08:38 -04:00
Greyson Parrelli 1e70e825a3 Skip session switchover events from non-ACI contacts during backup export. 2026-05-06 13:08:38 -04:00
Greyson Parrelli cce1979716 Catch ForegroundServiceStartNotAllowedException in AttachmentProgressService listener. 2026-05-06 13:08:38 -04:00
Greyson Parrelli ad7e9c0fd7 Skip unlock animation to reduce screen lock dismiss latency. 2026-05-06 13:08:38 -04:00
Greyson Parrelli bd3e1e8059 Catch IllegalArgumentException when setting precomputed text with stale params. 2026-05-06 13:08:38 -04:00
Greyson Parrelli adb9e2173f Reroute ISE to NoSessionException. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 958c6f451f Fix infinite spinner on RegistrationLockFragment when server rejects registration lock token. 2026-05-06 13:08:38 -04:00
Greyson Parrelli ab090236a1 Switch username scanner to use CameraScreen. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 23698dbc28 Switch linked device scanner to use CameraScreen. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 0542262c49 Improve error handling resiliance. 2026-05-06 13:08:38 -04:00
Greyson Parrelli e2d4ca9a4c Improve StorageSyncJob job data reliability. 2026-05-06 13:08:38 -04:00
Greyson Parrelli e54f3f501a Fix improper index usage on story queries. 2026-05-06 13:08:38 -04:00
Cody Henthorne 638d4997d1 Improve chat open performance when thread pool is saturated.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-06 13:08:38 -04:00
Jim Gustafson cbd05c4dff Update to RingRTC v2.68.1 2026-05-06 13:08:38 -04:00
andrew-signal ef396b5758 Bump libsignal to v0.93.1 2026-05-06 13:08:38 -04:00
Alex Hart 1d36ecafe1 Clean up back-pressed behavior which could result in an empty backstack and crash.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
Co-authored-by: jeffrey-signal <jeffrey@signal.org>
2026-05-06 13:08:38 -04:00
Alex Hart 07329c5b0d Migrate VerifyDisplayFragment to compose. 2026-05-06 13:08:38 -04:00
Alex Hart 7fc4ec3006 Migrate donation gateway sheet to compose. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 9e7477bbeb Ensure that story error query uses proper index. 2026-05-06 13:08:38 -04:00
Alex Hart c83054906b Upgrade compose bom.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-06 13:08:38 -04:00
Cody Henthorne 011dc3495f Fix FiatMoneyTests run on non-US locales. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 41b833e788 Improve deletion in all media screen. 2026-05-06 13:08:38 -04:00
Cody Henthorne e11f7225d3 Fix crash and subsequent retry after upload to archive fails length check. 2026-05-06 13:08:38 -04:00
Cody Henthorne bb261a3d85 Favor internal channel ids for recipients over externally created ones. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 116f702be6 Add Live Queries tab to Spinner. 2026-05-06 13:08:38 -04:00
Cody Henthorne 4d09776277 Improve db usage around ensuring custom notification channel stae. 2026-05-06 13:08:38 -04:00
jeffrey-signal f32184c27e Fix padding around incoming caller name. 2026-05-06 13:08:38 -04:00
Greyson Parrelli 5fc037b324 Upgrade to SQLCipher 8.14.0 2026-05-06 13:08:38 -04:00
Cody Henthorne fc9d3e11e8 Only retrieve remote announcements during specific time window. 2026-05-06 13:08:38 -04:00
Cody Henthorne a951c7edfe Fix undownloaded voice note button UI bug. 2026-05-06 13:08:38 -04:00
Cody Henthorne 9d1714d452 Fix unique constraint crash when remapping recipients in name collision table. 2026-05-06 13:08:37 -04:00
Jordan Rose 9c2825f202 Consistently use core-util Hex utility class. 2026-05-06 13:08:37 -04:00
Greyson Parrelli a8969b34a4 Fix HUF currency formatting. 2026-05-06 13:08:37 -04:00
Cody Henthorne 1f59f3c2c4 Use correct wakelock for link device sync. 2026-05-06 13:08:37 -04:00
jeffrey-signal c6d91dce6e Convert ContextUtil to Kotlin. 2026-05-06 13:08:37 -04:00
jeffrey-signal 40c4633d41 Add utility method for resolving a FragmentActivity from Context. 2026-05-06 13:08:37 -04:00
Cody Henthorne edfe89683b Attempt to fix date headers overlaping scheduled messages. 2026-05-06 13:08:37 -04:00
Cody Henthorne cc3bedd154 Center release channel media bubbles in chat. 2026-05-06 13:08:37 -04:00
Cody Henthorne 56803a8850 Add internal preference to disable ANR induced crashing. 2026-05-06 13:08:37 -04:00
andrew-signal 2fdb712b38 Reattempt auth connection when network validated status changes. 2026-05-06 13:08:37 -04:00
Cody Henthorne 3d39045d1b Fix quickstart build failure. 2026-05-06 13:08:37 -04:00
Greyson Parrelli 90385b4e1c Fix flakey registration test. 2026-05-06 13:08:37 -04:00
Greyson Parrelli a02b66601c Remove support for END_SESSION message. 2026-05-06 13:08:37 -04:00
Alex Hart a83c57ff73 Use adaptive bitmap for dynamic shortcut icons to remove white border.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-06 13:08:37 -04:00
Alex Hart 3d063b38be Increase okhttp read/write timeouts for debug log uploads.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-05-06 13:08:37 -04:00
dependabot[bot] 03d20cb46a Update reproducible build dependencies. 2026-05-06 13:08:37 -04:00
Cody Henthorne 561186df90 Adjust auto-download checks. 2026-05-06 13:08:37 -04:00
Alex Hart fdcd21132c Update logic for processing qrs in CameraScreenViewModel. 2026-04-27 16:44:56 -04:00
Cody Henthorne 1043851423 Bump version to 8.9.1 2026-04-27 16:40:08 -04:00
Cody Henthorne 9bcbacc3d8 Update translations and other static files. 2026-04-27 16:40:08 -04:00
Cody Henthorne c2d7ee6926 Update release notes chat styling. 2026-04-27 16:39:59 -04:00
jeffrey-signal ceecacb47e Fix pull-to-refresh triggering when attempting to scroll up in contact search.
Resolves signalapp/Signal-Android#14742
2026-04-27 16:09:56 -04:00
Greyson Parrelli f4986273e4 Fix quote resolution failing for sent sync messages. 2026-04-27 13:34:52 -04:00
Greyson Parrelli 5f60adbe69 Update emoji_data.json 2026-04-27 13:34:52 -04:00
Alex Hart db6efeaf3d Revert "Migrate VerifyScanFragment to compose."
This reverts commit 9fa587b7e4.
2026-04-23 16:29:35 -03:00
Alex Hart 9b98b03971 Bump version to 8.9.0 2026-04-22 16:00:26 -03:00
Alex Hart dfbdf30535 Update baseline profile. 2026-04-22 15:49:27 -03:00
Alex Hart d567555047 Update translations and other static files. 2026-04-22 15:18:32 -03:00
Alex Hart 7658f6c36c Migrate ideal icon and copy. 2026-04-22 15:12:47 -03:00
Greyson Parrelli 51bd2d51c6 Add missing remember keys for pane anchors and preferred width to fix stale layout on resize. 2026-04-22 15:12:47 -03:00
Jesse Weinstein a00978d96e Two trivial parameter renaming fixes
Closes signalapp/Signal-Android#14736
2026-04-22 15:12:47 -03:00
jeffrey-signal b700529c3b Fix stuck outgoing messages when there a no remaining linked devices. 2026-04-22 15:12:47 -03:00
jeffrey-signal 4051cf739c Fix crash when long-pressing a message when in conversation bubble mode. 2026-04-22 15:12:47 -03:00
Alex Hart 6031fc9113 Modify heuristic for split-pane determination. 2026-04-22 15:12:47 -03:00
Michelle Tang 454fe86dda Adjust spinner in phone number screen. 2026-04-22 15:12:47 -03:00
Michelle Tang 92927ec69b Clear existing key transparency data. 2026-04-22 15:12:47 -03:00
Alex Hart 9fa587b7e4 Migrate VerifyScanFragment to compose. 2026-04-22 15:12:47 -03:00
Jesse Weinstein 552361dff4 Remove unused StickyListHeaders dependency.
Closes signalapp/Signal-Android#14729
2026-04-22 15:12:47 -03:00
Greyson Parrelli 78a25a6186 Restrict setExactAndAllowWhileIdle to API >= 31. 2026-04-22 15:12:47 -03:00
Cody Henthorne 58fcc07578 Convert flakey MessageTable story instrumentation tests to unit tests. 2026-04-22 15:12:47 -03:00
Cody Henthorne 8cd92a400c Add debug and testing apis to Spinner. 2026-04-22 15:12:47 -03:00
Greyson Parrelli 5d207932c9 Check instrumentation compilation in qa task. 2026-04-22 15:12:47 -03:00
Greyson Parrelli 7c147982c4 Supply size to attachment form endpoint for archive backfill. 2026-04-22 15:12:47 -03:00
Alex Hart bde1a94122 Parallelize file deletion when turning off local backups. 2026-04-22 15:12:47 -03:00
Alex Hart 2b66d7485a Fix PiP on-back behavior.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-22 15:12:47 -03:00
Greyson Parrelli 017b902c3c Increase regV5 test coverage. 2026-04-22 15:12:47 -03:00
dependabot[bot] 357fbfa8aa Update reproducible build dependencies. 2026-04-22 15:12:47 -03:00
Michelle Tang 0ce667f4af Update enter aep for large screens. 2026-04-22 15:12:47 -03:00
jeffrey-signal c4d78243c8 Fix message backups education screen typo. 2026-04-22 15:12:47 -03:00
Greyson Parrelli 51e12b2c76 Add flag to try different alarm for scheduled messages. 2026-04-22 15:12:47 -03:00
Greyson Parrelli 4dea1d8aa1 Move storage service operations into the network module. 2026-04-22 15:12:47 -03:00
Greyson Parrelli 89c645dea3 Create a network module. 2026-04-22 15:12:47 -03:00
Greyson Parrelli cd01d5f0b7 Enable remote mute for external users. 2026-04-17 17:00:09 -04:00
Michelle Tang 8730e28282 Update restore selection for large screens. 2026-04-17 16:31:59 -04:00
Greyson Parrelli 82046dd55f Add support for remote backup restore to regV5. 2026-04-17 15:54:24 -04:00
Cody Henthorne 76e30ab09f Add verified group title tracking and syncing. 2026-04-17 15:52:56 -04:00
Greyson Parrelli f680256f1d Remove range from copyright. 2026-04-17 15:26:52 -04:00
Michelle Tang da590a3241 Update verification code screen. 2026-04-17 15:26:52 -04:00
Alex Hart 91f73b473f Sanitize donations webview intents.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-17 15:26:52 -04:00
Alex Hart 53023517b3 Add authority check to VoiceCallShare. 2026-04-17 15:26:52 -04:00
Alex Hart 7f831e6806 Convert SafetyNumberReview dialogs to compose. 2026-04-17 15:26:51 -04:00
Alex Hart 77a18111e1 Convert search mediator to compose / viewmodel pattern. 2026-04-17 15:26:51 -04:00
Alex Hart 2a699a23dd Fix backup key verification megaphone ignoring snooze by using lastSeenTime.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-17 15:26:51 -04:00
Alex Hart 5643ffc1a9 Trust zero bottom inset when gesture navigation is detected on API <= 29. 2026-04-17 15:26:51 -04:00
Cody Henthorne 90207b7dd7 Convert handful of recipient/db heavy androidTests to regular unit tests. 2026-04-17 15:26:50 -04:00
andrew-signal 5b7f668251 Bump libsignal to v0.92.2 2026-04-17 15:26:50 -04:00
adel-signal 798bf3ec3e Update to RingRTC v2.68.0 2026-04-17 15:26:50 -04:00
Michelle Tang 1c77c9d3fb Check for valid phone number. 2026-04-17 15:26:50 -04:00
Michelle Tang dd52d78ee0 Update country picker for large screen. 2026-04-17 15:26:50 -04:00
Michelle Tang 4b1acca119 Scroll to initial country. 2026-04-17 15:26:49 -04:00
Michelle Tang 195fe60927 Update phone entry for large screen. 2026-04-17 15:26:49 -04:00
Cody Henthorne f427f31303 Improve group change defensive checks and update logic. 2026-04-17 15:26:49 -04:00
Greyson Parrelli fa19ed7ffc Use viewmodel entry scoping in regV5. 2026-04-17 15:26:49 -04:00
jeffrey-signal e5e99d4e03 Bump version to 8.8.2 2026-04-17 15:21:14 -04:00
jeffrey-signal 26d1a7ada7 Update baseline profile. 2026-04-17 15:00:00 -04:00
jeffrey-signal 5dd11e26e4 Update translations and other static files. 2026-04-17 14:53:54 -04:00
Alex Hart 9877b13c6e Add ability to launch into message backups checkout.
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-04-17 12:33:52 -03:00
Greyson Parrelli d7d0fd3622 Rotate backup megaphone flag. 2026-04-17 10:09:12 -04:00
Sten Tijhuis 2439506c05 Update GitHub Actions versions and pin to commit SHAs.
Closes signalapp/Signal-Android#14715
2026-04-16 19:07:34 -04:00
jeffrey-signal 6088024f76 Revert "Use existing okhttp client + package checks for web apk."
This reverts commit df406633ff.
2026-04-16 19:01:09 -04:00
jeffrey-signal 9decd81cfc Bump version to 8.8.1 2026-04-16 14:25:08 -04:00
jeffrey-signal f27773a4e3 Update baseline profile. 2026-04-16 13:27:52 -04:00
jeffrey-signal 8d8c974a19 Update translations and other static files. 2026-04-16 13:20:10 -04:00
Cody Henthorne 1a3e81dcb0 Fix bad apostrophe escaping in new safety tip strings. 2026-04-16 13:08:59 -04:00
jeffrey-signal d5f85c0661 Bump version to 8.8.0 2026-04-15 20:18:12 -04:00
jeffrey-signal 91458f2702 Update baseline profile. 2026-04-15 20:18:12 -04:00
jeffrey-signal 6650ffc2c6 Update translations and other static files. 2026-04-15 20:17:59 -04:00
Cody Henthorne ab0102a372 Do not force-apply P2P group changes unless the change adds or removes us.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-15 14:45:14 -04:00
Cody Henthorne a797bbf850 Improve web socket behaviors around keep alive and shutdown. 2026-04-15 14:45:14 -04:00
Cody Henthorne 3804890265 Stop putting e164s into SignalProtocolAddress. 2026-04-15 14:45:14 -04:00
Greyson Parrelli fcdbf93626 Improve regV5 restore flows. 2026-04-15 14:45:14 -04:00
Michelle Tang f1b61f8f7e Add test dispatcher to phone number tests. 2026-04-15 14:45:14 -04:00
Michelle Tang ce582249ec Ask for permissions on the same screen. 2026-04-15 14:45:13 -04:00
Alex Hart b21a72153a Implement proper text-entry component for large screen media send flow. 2026-04-15 14:45:13 -04:00
jeffrey-signal 2a8bd20bb0 Fix sender name/label clipping in recycled conversation items.
Resolves signalapp/Signal-Android#14646
2026-04-15 14:45:13 -04:00
jeffrey-signal c30e3cc1b7 Disable group member labels while in message request state. 2026-04-15 14:45:13 -04:00
Greyson Parrelli 5fedd81921 Convert IndividualSendJob to kotlin. 2026-04-15 14:45:12 -04:00
jeffrey-signal 24069dc42e Fix self avatar in reaction bottom sheet. 2026-04-15 14:45:12 -04:00
Cody Henthorne ff15c8417a Clear upload spec when resume location is invalid in archive upload.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-15 14:45:12 -04:00
Greyson Parrelli cbf770d3ea Convert PushSendJob to kotlin. 2026-04-15 14:45:12 -04:00
Greyson Parrelli 676ab1ab6f Merge SendJob into PushSendJob. 2026-04-15 14:45:12 -04:00
Greyson Parrelli 9cc47942f2 Add process check to VoiceNotePlaybackService MediaSession access. 2026-04-15 14:45:12 -04:00
Greyson Parrelli 45e6e06c01 Improve error handling for empty prekey bundles. 2026-04-15 14:45:11 -04:00
Michelle Tang d2243707b5 Update permissions UI. 2026-04-15 14:45:11 -04:00
Jesse Weinstein 48cd1c1da0 Convert Windows line endings to Unix format.
Closes signalapp/Signal-Android#14632
2026-04-15 14:45:11 -04:00
thomasboom 330a5aece2 Show "None" for media auto-download when all options are disabled.
Co-authored-by: jeffrey-signal <jeffrey@signal.org>

Closes signalapp/Signal-Android#14678
2026-04-15 14:45:11 -04:00
Cody Henthorne 8c4f614d17 Allow reply action on notifications for messages with media attachments. 2026-04-15 14:45:11 -04:00
Leo Heitmann Ruiz f40bcb73fa Fix duplicate word in bug report template.
Closes signalapp/Signal-Android#14695
2026-04-15 14:45:10 -04:00
Jesse Weinstein 905a6f1a6b Fix typo in comment referencing ChatTypeSearchKey.
Closes signalapp/Signal-Android#14684
2026-04-15 14:45:10 -04:00
Jesse Weinstein 8f78471849 Remove the annimon.stream library.
Resolves #14719
2026-04-15 14:45:10 -04:00
Jesse Weinstein 82df20190d Remove all remaining usages of annimon.stream. 2026-04-15 14:45:10 -04:00
Greyson Parrelli 7f6e96a522 Check DownloadManager status to properly detect errors. 2026-04-15 14:45:09 -04:00
Greyson Parrelli eded335766 Fix potential crash when saving to fallback attachment folder.
Fixes #14720
2026-04-15 14:45:09 -04:00
Michelle Tang 7e4736969c Update country selection. 2026-04-15 14:45:09 -04:00
Greyson Parrelli 78940ffc17 Switch the labs plaintext export to share a single zip. 2026-04-15 14:45:09 -04:00
Jesse Weinstein 086883e565 Convert all the toList calls to collect(Collectors.toList)
Resolves #14718
2026-04-15 14:45:06 -04:00
Michelle Tang e9cdf0368e Update phone number UI. 2026-04-15 14:43:45 -04:00
Greyson Parrelli 7be273f461 Do not interrupt actively playing voice notes when locking. 2026-04-15 14:43:45 -04:00
Jesse Weinstein e6cbb0073c Remove more usages of annimon.stream.
Resolves #14717
2026-04-15 14:43:41 -04:00
Cody Henthorne 469421fcf3 Fix message request accepted to recipient from previous backup imports. 2026-04-15 14:43:12 -04:00
Greyson Parrelli 6d6d277277 Add share and forward entries to all media context menu. 2026-04-15 14:43:12 -04:00
jeffrey-signal 8a5faba985 Rename DragAndDrop -> DragToReorder to differentiate it from Android's drag-and-drop framework. 2026-04-15 14:43:12 -04:00
Cody Henthorne 7aadc208e1 Promptly remove terminated groups from shortcuts. 2026-04-15 14:43:11 -04:00
Greyson Parrelli 3c68e29679 Update image pasting to use ViewCompat.setOnReceiveContentListener. 2026-04-15 14:43:11 -04:00
Cody Henthorne 4756b8d70b Update conversation header and message request UI. 2026-04-15 14:43:11 -04:00
Alex Hart c2d927029a Add new ImageEditor compose component and wire in crop and drawing tools. 2026-04-13 16:25:00 -04:00
jeffrey-signal 629b96dd20 Fix wallpaper ANR regression while maintaining correct incoming message bubble colors. 2026-04-13 16:25:00 -04:00
Greyson Parrelli 01705459cf Include registered state in support email. 2026-04-13 16:25:00 -04:00
Greyson Parrelli c449f72786 Allow internal shares as long as they originate from our process. 2026-04-13 16:25:00 -04:00
Alex Hart 773d6c36dc Add large-screen media send toolbars for image editing. 2026-04-13 16:25:00 -04:00
andrew-signal b4bfb67a44 Bump to libsignal v0.91.2 2026-04-13 16:25:00 -04:00
Michelle Tang 3165c854df Remove unused strings. 2026-04-13 16:25:00 -04:00
Cody Henthorne f5cb1b0efa Restrict telecom usage to API 37. 2026-04-13 16:25:00 -04:00
Greyson Parrelli 179908fba6 Update registration error strings for SMS send failures. 2026-04-13 16:25:00 -04:00
Greyson Parrelli d6ec4bfbd3 Do not show getting started after local restore. 2026-04-13 16:25:00 -04:00
Greyson Parrelli 237ac9f94a Use original message timestamp for saved attachment MediaStore metadata.
Fixes #14584
2026-04-13 16:25:00 -04:00
Greyson Parrelli 66f69854cf Expand whitespace character detection in StringUtil.isVisuallyEmpty.
Fixes #14470
2026-04-13 16:25:00 -04:00
Greyson Parrelli 8f47592fc0 Fix reconciliation error for thumbnails for quotes. 2026-04-13 16:25:00 -04:00
Greyson Parrelli 3ea7bf77e0 Add release note validation check. 2026-04-13 16:25:00 -04:00
Greyson Parrelli 2b67b1c44f Remove legacy ClassicOpenHelper. 2026-04-13 16:25:00 -04:00
Jesse Weinstein ebccc6db30 Remove a lot of dead code.
Resolves #14672
2026-04-13 16:25:00 -04:00
Greyson Parrelli 98d9b12438 Add null check in UriUtil. 2026-04-13 16:22:10 -04:00
Greyson Parrelli 5db8463c70 Improve content proxy domain matching. 2026-04-13 16:22:10 -04:00
Greyson Parrelli 813252989b Notify CallRequestController after cancel. 2026-04-13 16:22:09 -04:00
Greyson Parrelli 0319adbce4 Add null check to orientation unboxing. 2026-04-13 16:22:09 -04:00
Greyson Parrelli de584ccb7d Use message digest for faster comparison. 2026-04-13 16:22:09 -04:00
Greyson Parrelli bd89c7fc39 Add null check to pin message import. 2026-04-13 16:22:09 -04:00
Greyson Parrelli bef4bb40ca Do not swallow IOException during backup creation. 2026-04-13 16:22:08 -04:00
Jesse Weinstein b57d922cdf Remove use of annimon.stream in several places.
Resolves #14705
2026-04-13 16:22:08 -04:00
Greyson Parrelli 8c1cc03c6f Use non-deprecated libsignal network constructor. 2026-04-13 16:22:08 -04:00
jeffrey-signal f0109f3e6b Improve drag-to-reorder auto scroll behavior when dragging an item up the list. 2026-04-13 16:22:08 -04:00
Greyson Parrelli ed89f3a78e Don't connect to the websocket if we know we're unregistered. 2026-04-13 16:22:07 -04:00
Alex Hart faa6a1d3f0 Welcome screen polish. 2026-04-13 16:22:07 -04:00
Greyson Parrelli 969635d942 Add redirect validation for link previews. 2026-04-13 16:22:07 -04:00
Greyson Parrelli 7665ae1464 Verify payment address identity key against local identity store. 2026-04-13 16:22:07 -04:00
Greyson Parrelli 9c18e3698e Apply CDN restrictions to quote attachments. 2026-04-13 16:22:07 -04:00
Greyson Parrelli df406633ff Use existing okhttp client + package checks for web apk. 2026-04-13 16:22:06 -04:00
Greyson Parrelli d121f9402b Improve quote validation. 2026-04-13 16:22:06 -04:00
Greyson Parrelli 5310c19b99 Update isValidExternalUri. 2026-04-13 16:22:06 -04:00
Greyson Parrelli cd92feb2b7 Write profile avatars to temp file before renaming to final location. 2026-04-13 16:22:06 -04:00
Greyson Parrelli 3b603f08ed Add defensive size check to stream read. 2026-04-13 16:22:06 -04:00
Greyson Parrelli 281f062b29 Remove the DeprecatedPersistentBlobProvider. 2026-04-13 16:22:05 -04:00
jeffrey-signal b054a7eb76 Bump version to 8.7.3 2026-04-13 16:15:41 -04:00
jeffrey-signal 33b9c88ecd Update baseline profile. 2026-04-13 15:40:05 -04:00
jeffrey-signal 253d36ae13 Update translations and other static files. 2026-04-13 15:32:54 -04:00
Michelle Tang 8306f8ec5b Improve collapsed set selection. 2026-04-13 14:05:20 -04:00
Michelle Tang 69b6d7ef9a Fix missing gallery photos.
Resolves signalapp/Signal-Android#14709
2026-04-13 14:05:00 -04:00
Greyson Parrelli aeeba3d2df Fix NPE when there's no retryafter duration. 2026-04-12 14:20:32 -04:00
Greyson Parrelli dfd2f7baf9 Bump version to 8.7.2 2026-04-09 22:44:57 -04:00
Greyson Parrelli 5de17a971d Update translations and other static files. 2026-04-09 22:44:34 -04:00
Greyson Parrelli 001896d244 Fix image transition animation. 2026-04-09 22:02:46 -04:00
Michelle Tang 1844b128e1 Use server timestamp for admin delete. 2026-04-09 17:17:55 -04:00
Michelle Tang 08623cc0c4 Use proper sender for early messages. 2026-04-09 15:44:35 -04:00
Greyson Parrelli f93a948169 Fix PIN creation loop during registration. 2026-04-09 13:48:46 -04:00
Cody Henthorne 76476191be Show better error ux for group calls you cannot start. 2026-04-09 10:27:44 -04:00
Greyson Parrelli d00bb28ee4 Bump version to 8.7.1 2026-04-08 22:10:47 -04:00
Greyson Parrelli 453e5bede7 Fix bad bubble tints for chats with wallpapers. 2026-04-08 22:04:17 -04:00
Michelle Tang c7c108bd77 Fix missing gallery photos. 2026-04-08 19:40:13 -04:00
Greyson Parrelli fb81574d35 Bump version to 8.7.0 2026-04-08 16:39:21 -04:00
Greyson Parrelli e6d3de091c Update translations and other static files. 2026-04-08 16:39:21 -04:00
Greyson Parrelli 99b8a6020d Fix flaky registration tests. 2026-04-08 16:39:21 -04:00
Greyson Parrelli 88b21b6113 Improve validator testing. 2026-04-08 16:39:21 -04:00
Greyson Parrelli 256ee9b1aa Delete unused apns database. 2026-04-08 16:39:21 -04:00
Alex Hart e2feaaf74c Add initial working E2E flow for MediaSendV3. 2026-04-08 16:39:21 -04:00
jeffrey-signal 17def87c17 Fix compose preview rendering when using Emojifier. 2026-04-08 16:39:20 -04:00
Alex Hart d90e9919ae Adaptive welcome screen with compact, medium, and large layouts. 2026-04-08 16:39:20 -04:00
Jim Gustafson 38baf17938 Update to RingRTC v2.67.2 2026-04-08 16:39:20 -04:00
scueZ 3f7707985f Skip confusing delete dialog body text in Note to Self.
Resolves #14708
2026-04-08 16:39:20 -04:00
jeffrey-signal a61072b249 Member label performance optimizations. 2026-04-08 16:39:20 -04:00
jeffrey-signal 80ff64ddd3 Prevent unregistered accounts from showing in group call participants. 2026-04-08 16:39:20 -04:00
Cody Henthorne 95c0467bda Show unanswered outgoing calls as unanswered. 2026-04-08 16:39:20 -04:00
Greyson Parrelli ff88d259fd Use long for key id. 2026-04-08 16:39:20 -04:00
Alex Hart 6e747019d4 Fix NPE in toPendingOneTimeDonation when waitForAuth is null.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-08 16:29:15 -04:00
Greyson Parrelli 9e7a40a63d Extend proper base activity. 2026-04-08 16:29:14 -04:00
Greyson Parrelli 38eed43046 Add long-press context menu in all media screen. 2026-04-08 15:50:43 -04:00
Greyson Parrelli 4c76cb682e Give a media/no-media choice in labs plaintext export. 2026-04-08 15:50:43 -04:00
Michelle Tang c47adb7482 Update padding sizes of update items. 2026-04-08 15:50:43 -04:00
Alex Hart 3c2ccef9a8 Fix upgrade card text color not adapting to dark mode.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-08 15:50:43 -04:00
jeffrey-signal fb0c4757f2 Fix media count indicator button colors so they match the chat color. 2026-04-08 15:50:43 -04:00
jeffrey-signal b8b9a632b5 Always prefetch wallpaper before opening a conversation. 2026-04-08 15:50:43 -04:00
Greyson Parrelli 9b4a13a491 Potential fix to configuration cache issues with translations. 2026-04-08 15:50:43 -04:00
Cody Henthorne 1cdd49721d Add logging around rotate storage id failures during storage sync. 2026-04-08 15:50:43 -04:00
Cody Henthorne 8b895738c0 Update telecom to 1.1.0-alpha04 2026-04-07 10:05:42 -04:00
Cody Henthorne 6ab3cd3390 Don't show terminated groups after storage service restore. 2026-04-07 09:39:06 -04:00
Alex Hart 11c8a726ec Increment localPlaintextExport flag to lock version. 2026-04-06 16:47:01 -04:00
Alex Hart 264447a6d9 Add breakpoint helper and expand device previews.
Co-authored-by: jeffrey-signal <jeffrey@signal.org>
2026-04-06 16:47:01 -04:00
Michelle Tang a7bb2831f8 Fix possible misuse of mp4 sanitizer. 2026-04-06 16:47:01 -04:00
Greyson Parrelli e05586a1c9 Convert RegistrationNetworkResult to RequestResult. 2026-04-06 16:47:01 -04:00
Greyson Parrelli 0e8dedf4d0 App ability to regV5 in the main app, behind compile flag. 2026-04-06 16:47:01 -04:00
Michelle Tang 0e11a1fe3e Add logs for voice note proximity. 2026-04-06 16:47:01 -04:00
adel-signal f1ebd2dc81 Add CallingAssetsDownloadJob to app startup to init calling assets
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-06 16:47:01 -04:00
Michelle Tang 8ea90c8a43 Cancel checking for messages on foreground. 2026-04-06 16:46:05 -04:00
Michelle Tang 6456dcf657 Fix potential edit message race condition. 2026-04-06 16:46:05 -04:00
Greyson Parrelli bb151c91e9 Add basic infra for regV5 local restore. 2026-04-06 16:46:05 -04:00
Greyson Parrelli ce6f39ae68 Update to the new attachment upload form libsignal method. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 58e8ea08c2 Bump to libsignal v0.91.0 2026-04-06 16:46:05 -04:00
Michelle Tang 4dd74d9ab4 Fix collapsed tests. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 3ef3a516b3 Prevent repeated storage-full notifications during backup.
When remote backup storage is full, hundreds of CopyAttachmentToArchiveJob
instances each independently call markOutOfRemoteStorageSpaceError(), which
re-posts the notification every time. Even though the notification ID is the
same, each call re-alerts the user with sound and vibration.

Guard markOutOfRemoteStorageSpaceError() to only post the notification once
by checking the flag before proceeding, and move the flag-set before the
notification post to prevent races. Also add an early exit in
CopyAttachmentToArchiveJob to skip the network quota check when already
marked as out of storage space.
2026-04-06 16:46:05 -04:00
Greyson Parrelli 518a81c7fa Do not start a call while one is in progress. 2026-04-06 16:46:05 -04:00
Michelle Tang f81325e7ca Pause voice notes when joining calls. 2026-04-06 16:46:05 -04:00
Greyson Parrelli cc847cb229 Fix potential glide lifecycle issue with transition animation. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 7320a0ef46 Guard against potential crash when reacting to a message. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 7c45686440 Fix potential missing ACI crash in verify screen. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 8b5b83e974 Remove unnecessary transaction in LocalMetricsDatabase.
There was a native crash associated with it, unclear the cause, but
maybe this will help.
2026-04-06 16:46:05 -04:00
Michelle Tang a4a3861398 Disable proximity sensor when using headsets for voice notes. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 01bdaaea84 Improve ANR stack trace perf. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 1f02fba696 Include captcha info in support email template. 2026-04-06 16:46:04 -04:00
1188 changed files with 62319 additions and 29079 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ body:
label: "Guidelines"
description: "Search issues here: https://github.com/signalapp/Signal-Android/issues/?q=is%3Aissue+"
options:
- label: I have searched searched open and closed issues for duplicates
- label: I have searched open and closed issues for duplicates
required: true
- label: I am submitting a bug report for existing functionality that does not work as intended
required: true
+27
View File
@@ -0,0 +1,27 @@
version: 2
updates:
# Automatically keep GitHub Actions SHA-pinned to the latest commit SHAs.
# Dependabot will update both the SHA and the inline version comment (e.g. # v6)
# while leaving any extra documentation comments intact.
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
labels:
- "dependencies"
commit-message:
prefix: "ci"
groups:
actions:
patterns:
- "actions/*"
gradle-actions:
patterns:
- "gradle/*"
peter-evans:
patterns:
- "peter-evans/*"
usefulness:
patterns:
- "usefulness/*"
+8 -4
View File
@@ -16,26 +16,30 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
- name: set up JDK 17
uses: actions/setup-java@v4
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 17
cache: gradle
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@v5
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
- name: Build with Gradle
run: ./gradlew qa
- name: Archive reports for failed build
if: ${{ failure() }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
# gh api repos/actions/upload-artifact/commits/v7 --jq '.sha'
with:
name: reports
path: '*/build/reports'
+19 -10
View File
@@ -14,15 +14,17 @@ jobs:
assemble-base:
if: ${{ github.repository != 'signalapp/Signal-Android' }}
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
ref: ${{ github.event.pull_request.base.sha }}
- name: set up JDK 17
uses: actions/setup-java@v3
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 17
@@ -32,11 +34,13 @@ jobs:
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;${{ env.NDK_VERSION }}"
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@v5
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
- name: Cache base apk
id: cache-base
uses: actions/cache@v4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
# gh api repos/actions/cache/commits/v5 --jq '.sha'
with:
path: diffuse-base.apk
key: diffuse-${{ github.event.pull_request.base.sha }}
@@ -49,7 +53,8 @@ jobs:
if: steps.cache-base.outputs.cache-hit != 'true'
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-base.apk
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
clean: 'false'
@@ -61,18 +66,21 @@ jobs:
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-new.apk
- id: diffuse
uses: usefulness/diffuse-action@v1
uses: usefulness/diffuse-action@41995fe8ff6be0a8847e63bdc5a4679c704b455c # v1
# gh api repos/usefulness/diffuse-action/commits/v1 --jq '.sha'
with:
old-file-path: diffuse-base.apk
new-file-path: diffuse-new.apk
- uses: peter-evans/find-comment@v2
- uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4
# gh api repos/peter-evans/find-comment/commits/v4 --jq '.sha'
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
body-includes: Diffuse output
- uses: peter-evans/create-or-update-comment@v3
- uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5
# gh api repos/peter-evans/create-or-update-comment/commits/v5 --jq '.sha'
with:
body: |
Diffuse output:
@@ -83,7 +91,8 @@ jobs:
issue-number: ${{ github.event.pull_request.number }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
# gh api repos/actions/upload-artifact/commits/v7 --jq '.sha'
with:
name: diffuse-output
path: ${{ steps.diffuse.outputs.diff-file }}
+2 -1
View File
@@ -11,7 +11,8 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
- name: Build image
run: |
cd reproducible-builds
+2 -1
View File
@@ -14,7 +14,8 @@ jobs:
actions: write
steps:
- uses: actions/stale@v10
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
# gh api repos/actions/stale/commits/v10 --jq '.sha'
with:
days-before-stale: 60
days-before-close: 7
+2 -2
View File
@@ -1,6 +1,6 @@
# Signal Android
Signal is a simple, powerful, and secure messenger that uses your phone's data connection (WiFi/3G/4G/5G) to communicate securely.
Signal is a simple, powerful, and secure messenger that uses your phone's data connection (WiFi/4G/5G) to communicate securely.
Millions of people use Signal every day for free and instantaneous communication anywhere in the world. Send and receive high-fidelity messages, participate in HD voice/video calls, and explore a growing set of new features that help you stay connected.
@@ -63,7 +63,7 @@ The form and manner of this distribution makes it eligible for export under the
## License
Copyright 2013-2025 Signal Messenger, LLC
Copyright 2013 Signal Messenger, LLC
Licensed under the GNU AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html
+141 -106
View File
@@ -1,7 +1,10 @@
@file:Suppress("UnstableApiUsage")
import com.android.build.api.dsl.ManagedVirtualDevice
import com.android.build.api.artifact.ArtifactTransformationRequest
import com.android.build.api.artifact.SingleArtifact
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
@@ -10,7 +13,6 @@ import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.ktlint)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlinx.serialization)
@@ -22,21 +24,22 @@ plugins {
id("licenses")
}
apply(from = "static-ips.gradle.kts")
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
val canonicalVersionCode = 1675
val canonicalVersionName = "8.6.2"
val canonicalVersionCode = 1685
val canonicalVersionName = "8.10.0"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
// We don't want versions to ever end in 0 so that they don't conflict with nightly versions
val possibleHotfixVersions = (0 until maxHotfixVersions).toList().filter { it % 10 != 0 }
val debugKeystorePropertiesProvider = providers.of(PropertiesFileValueSource::class.java) {
val debugKeystorePropertiesProvider: Provider<Properties> = providers.of(PropertiesFileValueSource::class.java) {
parameters.file.set(rootProject.layout.projectDirectory.file("keystore.debug.properties"))
}
val languagesProvider = providers.of(LanguageListValueSource::class.java) {
val languagesProvider: Provider<List<String>> = providers.of(LanguageListValueSource::class.java) {
parameters.resDir.set(layout.projectDirectory.dir("src/main/res"))
}
@@ -81,6 +84,21 @@ val selectableVariants = listOf(
"githubProdRelease"
)
// Wire 5.x iterates Android source sets and expects matching Kotlin source sets.
// AGP 9.0's built-in Kotlin doesn't create all source sets automatically.
val kotlinExt = extensions.getByName("kotlin") as KotlinAndroidProjectExtension
android.sourceSets.all {
kotlinExt.sourceSets.findByName(name) ?: kotlinExt.sourceSets.create(name)
}
// AGP 9.0's built-in Kotlin doesn't pick up extra java.srcDir entries from Android
// source sets, so add the testShared dir directly to test/androidTest Kotlin
// compile tasks.
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).configureEach {
if (name.contains("UnitTest") || name.contains("AndroidTest")) {
source("$projectDir/src/testShared")
}
}
wire {
kotlin {
javaInterop = true
@@ -94,8 +112,6 @@ wire {
srcDir("${project.rootDir}/lib/libsignal-service/src/main/protowire")
srcDir("${project.rootDir}/lib/archive/src/main/protowire")
}
// Handled by libsignal
prune("signalservice.DecryptionErrorMessage")
}
ktlint {
@@ -106,7 +122,7 @@ android {
namespace = "org.thoughtcrime.securesms"
buildToolsVersion = libs.versions.buildTools.get()
compileSdkVersion = libs.versions.compileSdk.get()
compileSdkVersion(libs.versions.compileSdk.get())
ndkVersion = libs.versions.ndk.get()
flavorDimensions += listOf("distribution", "environment")
@@ -114,13 +130,7 @@ android {
android.bundle.language.enableSplit = false
kotlinOptions {
jvmTarget = libs.versions.kotlinJvmTarget.get()
freeCompilerArgs = listOf("-Xjvm-default=all")
suppressWarnings = true
}
debugKeystorePropertiesProvider.orNull?.let { properties ->
debugKeystorePropertiesProvider.get().takeIf { it.isNotEmpty() }?.let { properties ->
signingConfigs.getByName("debug").apply {
storeFile = file("${project.rootDir}/${properties.getProperty("storeFile")}")
storePassword = properties.getProperty("storePassword")
@@ -137,8 +147,8 @@ android {
}
managedDevices {
devices {
create<ManagedVirtualDevice>("pixel3api30") {
localDevices {
create("pixel3api30") {
device = "Pixel 3"
apiLevel = 30
systemImageSource = "google-atd"
@@ -195,10 +205,6 @@ android {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
defaultConfig {
if (currentHotfixVersion >= maxHotfixVersions) {
throw AssertionError("Hotfix version offset is too large!")
@@ -291,7 +297,7 @@ android {
isDefault = true
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android.txt"),
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard/proguard-firebase-messaging.pro",
"proguard/proguard-google-play-services.pro",
"proguard/proguard-jackson.pro",
@@ -308,6 +314,7 @@ android {
"proguard/proguard-retrolambda.pro",
"proguard/proguard-okhttp.pro",
"proguard/proguard-ez-vcard.pro",
"proguard/proguard-dnsjava.pro",
"proguard/proguard.cfg"
)
testProguardFiles(
@@ -480,70 +487,6 @@ android {
lintConfig = rootProject.file("lint.xml")
}
androidComponents {
beforeVariants { variant ->
variant.enable = variant.name in selectableVariants
}
onVariants(selector().all()) { variant: com.android.build.api.variant.ApplicationVariant ->
// Include the test-only library on debug builds.
if (variant.buildType != "instrumentation") {
variant.packaging.jniLibs.excludes.add("**/libsignal_jni_testing.so")
}
// Starting with minSdk 23, Android leaves native libraries uncompressed, which is fine for the Play Store, but not for our self-distributed APKs.
// This reverts it to the legacy behavior, compressing the native libraries, and drastically reducing the APK file size.
if (variant.name.contains("website", ignoreCase = true) || variant.name.contains("github", ignoreCase = true)) {
variant.packaging.jniLibs.useLegacyPackaging.set(true)
}
// Version overrides
if (variant.name.contains("nightly", ignoreCase = true)) {
var tag = getNightlyTagForCurrentCommit()
if (!tag.isNullOrEmpty()) {
if (tag.startsWith("v")) {
tag = tag.substring(1)
}
// We add a multiple of maxHotfixVersions to nightlies to ensure we're always at least that many versions ahead
val nightlyBuffer = (5 * maxHotfixVersions)
val nightlyVersionCode = (canonicalVersionCode * maxHotfixVersions) + (getNightlyBuildNumber(tag) * 10) + nightlyBuffer
variant.outputs.forEach { output ->
output.versionName.set("$tag | ${getLastCommitDateTimeUtc()}")
output.versionCode.set(nightlyVersionCode)
}
}
}
}
onVariants(selector().withBuildType("quickstart")) { variant ->
val environment = variant.flavorName?.let { name ->
when {
name.contains("staging", ignoreCase = true) -> "staging"
name.contains("prod", ignoreCase = true) -> "prod"
else -> "prod"
}
} ?: "prod"
val taskProvider = tasks.register<CopyQuickstartCredentialsTask>("copyQuickstartCredentials${variant.name.capitalize()}") {
if (quickstartCredentialsDir != null) {
inputDir.set(File(quickstartCredentialsDir))
}
filePrefix.set("${environment}_")
}
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
}
onVariants(selector().withBuildType("benchmark")) { variant ->
val taskProvider = tasks.register<CopyBenchmarkBackupTask>("copyBenchmarkBackup${variant.name.capitalize()}") {
if (benchmarkBackupFile != null) {
inputFile.set(File(benchmarkBackupFile))
}
}
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
}
}
val releaseDir = "$projectDir/src/release/java"
val debugDir = "$projectDir/src/debug/java"
@@ -565,15 +508,79 @@ android {
manifest.srcFile("$projectDir/src/benchmarkShared/AndroidManifest.xml")
}
}
}
applicationVariants.configureEach {
outputs.configureEach {
if (this is com.android.build.gradle.internal.api.BaseVariantOutputImpl) {
val fileVersionName = versionName.substringBefore(" |")
outputFileName = outputFileName.replace(".apk", "-$fileVersionName.apk")
androidComponents {
beforeVariants { variant ->
variant.enable = variant.name in selectableVariants
}
onVariants(selector().all()) { variant: com.android.build.api.variant.ApplicationVariant ->
// Rename APK to include version name
val renameTask = tasks.register<RenameApkTask>("renameApk${variant.name.replaceFirstChar { it.uppercase() }}")
val renameRequest = variant.artifacts.use(renameTask)
.wiredWithDirectories(RenameApkTask::apkFolder, RenameApkTask::outFolder)
.toTransformMany(SingleArtifact.APK)
renameTask.configure {
transformationRequest.set(renameRequest)
}
// Include the test-only library on debug builds.
if (variant.buildType != "instrumentation") {
variant.packaging.jniLibs.excludes.add("**/libsignal_jni_testing.so")
}
// Starting with minSdk 23, Android leaves native libraries uncompressed, which is fine for the Play Store, but not for our self-distributed APKs.
// This reverts it to the legacy behavior, compressing the native libraries, and drastically reducing the APK file size.
if (variant.name.contains("website", ignoreCase = true) || variant.name.contains("github", ignoreCase = true)) {
variant.packaging.jniLibs.useLegacyPackaging.set(true)
}
// Version overrides
if (variant.name.contains("nightly", ignoreCase = true)) {
var tag = getNightlyTagForCurrentCommit()
if (!tag.isNullOrEmpty()) {
if (tag.startsWith("v")) {
tag = tag.substring(1)
}
// We add a multiple of maxHotfixVersions to nightlies to ensure we're always at least that many versions ahead
val nightlyBuffer = (5 * maxHotfixVersions)
val nightlyVersionCode = (canonicalVersionCode * maxHotfixVersions) + (getNightlyBuildNumber(tag) * 10) + nightlyBuffer
variant.outputs.forEach { output ->
output.versionName.set("$tag | ${getLastCommitDateTimeUtc()}")
output.versionCode.set(nightlyVersionCode)
}
}
}
}
onVariants(selector().withBuildType("quickstart")) { variant ->
val environment = variant.flavorName?.let { name ->
when {
name.contains("staging", ignoreCase = true) -> "staging"
name.contains("prod", ignoreCase = true) -> "prod"
else -> "prod"
}
} ?: "prod"
val taskProvider = tasks.register<CopyQuickstartCredentialsTask>("copyQuickstartCredentials${variant.name.capitalize()}") {
if (quickstartCredentialsDir != null) {
inputDir.set(File(quickstartCredentialsDir))
}
filePrefix.set("${environment}_")
}
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
}
onVariants(selector().withBuildType("benchmark")) { variant ->
val taskProvider = tasks.register<CopyBenchmarkBackupTask>("copyBenchmarkBackup${variant.name.capitalize()}") {
if (benchmarkBackupFile != null) {
inputFile.set(File(benchmarkBackupFile))
}
}
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
}
}
baselineProfile {
@@ -590,6 +597,14 @@ baselineProfile {
dexLayoutOptimization = false
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.fromTarget(libs.versions.kotlinJvmTarget.get())
freeCompilerArgs.addAll("-Xjvm-default=all")
suppressWarnings = true
}
}
dependencies {
lintChecks(project(":lintchecks"))
ktlintRuleset(libs.ktlint.twitter.compose)
@@ -597,6 +612,7 @@ dependencies {
implementation(project(":lib:archive"))
implementation(project(":lib:libsignal-service"))
implementation(project(":lib:network"))
implementation(project(":lib:paging"))
implementation(project(":core:util"))
implementation(project(":lib:glide"))
@@ -618,11 +634,7 @@ dependencies {
implementation(project(":lib:apng"))
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.appcompat) {
version {
strictly("1.6.1")
}
}
implementation(libs.androidx.appcompat)
implementation(libs.androidx.window.window)
implementation(libs.androidx.window.java)
implementation(libs.androidx.recyclerview)
@@ -678,7 +690,6 @@ dependencies {
implementation(libs.mobilecoin)
implementation(libs.signal.ringrtc)
implementation(libs.leolin.shortcutbadger)
implementation(libs.emilsjolander.stickylistheaders)
implementation(libs.glide.glide)
implementation(libs.roundedimageview)
implementation(libs.materialish.progress)
@@ -689,10 +700,6 @@ dependencies {
implementation(libs.subsampling.scale.image.view) {
exclude(group = "com.android.support", module = "support-annotations")
}
implementation(libs.android.tooltips) {
exclude(group = "com.android.support", module = "appcompat-v7")
}
implementation(libs.stream)
implementation(libs.lottie)
implementation(libs.lottie.compose)
implementation(libs.signal.android.database.sqlcipher)
@@ -747,6 +754,7 @@ dependencies {
}
testImplementation(testLibs.conscrypt.openjdk.uber)
testImplementation(testLibs.mockk)
testImplementation(testFixtures(project(":core:ui")))
testImplementation(testFixtures(project(":lib:libsignal-service")))
testImplementation(testLibs.espresso.core)
testImplementation(testLibs.kotlinx.coroutines.test)
@@ -861,7 +869,7 @@ abstract class LanguageListValueSource : ValueSource<List<String>, LanguageListV
}
}
abstract class PropertiesFileValueSource : ValueSource<Properties?, PropertiesFileValueSource.Params> {
abstract class PropertiesFileValueSource : ValueSource<Properties, PropertiesFileValueSource.Params> {
interface Params : ValueSourceParameters {
@get:InputFile
@get:Optional
@@ -869,9 +877,9 @@ abstract class PropertiesFileValueSource : ValueSource<Properties?, PropertiesFi
val file: RegularFileProperty
}
override fun obtain(): Properties? {
override fun obtain(): Properties {
val f: File = parameters.file.asFile.get()
if (!f.exists()) return null
if (!f.exists()) return Properties()
return Properties().apply {
f.inputStream().use { load(it) }
@@ -941,3 +949,30 @@ abstract class CopyBenchmarkBackupTask : DefaultTask() {
backupFile.copyTo(dest.resolve("backup.binproto"), overwrite = true)
}
}
abstract class RenameApkTask : DefaultTask() {
@get:InputFiles
abstract val apkFolder: DirectoryProperty
@get:OutputDirectory
abstract val outFolder: DirectoryProperty
@get:Internal
abstract val transformationRequest: Property<ArtifactTransformationRequest<RenameApkTask>>
@TaskAction
fun rename() {
transformationRequest.get().submit(this) { artifact ->
val originalFile = File(artifact.outputFile)
val versionName = artifact.versionName?.substringBefore(" |")
val newName = if (!versionName.isNullOrEmpty()) {
originalFile.name.replace(".apk", "-$versionName.apk")
} else {
originalFile.name
}
val newFile = File(outFolder.get().asFile, newName)
originalFile.copyTo(newFile, overwrite = true)
newFile
}
}
}
-55
View File
@@ -26454,61 +26454,6 @@
column="7"/>
</issue>
<issue
id="Recycle"
message="This `Cursor` should be freed up after use with `#close()`"
errorLine1=" Cursor mmsCursor = db.query(&quot;mms&quot;, new String[] {&quot;_id&quot;},"
errorLine2=" ~~~~~">
<location
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
line="298"
column="38"/>
</issue>
<issue
id="Recycle"
message="This `Cursor` should be freed up after use with `#close()`"
errorLine1=" Cursor partCursor = db.query(&quot;part&quot;, new String[] {&quot;_id&quot;, &quot;ct&quot;, &quot;_data&quot;, &quot;encrypted&quot;},"
errorLine2=" ~~~~~">
<location
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
line="310"
column="32"/>
</issue>
<issue
id="Recycle"
message="This `Cursor` should be freed up after use with `#close()`"
errorLine1=" Cursor threadCursor = db.query(&quot;thread&quot;, new String[] {&quot;_id&quot;}, null, null, null, null, null);"
errorLine2=" ~~~~~">
<location
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
line="708"
column="32"/>
</issue>
<issue
id="Recycle"
message="This `Cursor` should be freed up after use with `#close()`"
errorLine1=" Cursor cursor = db.rawQuery(&quot;SELECT DISTINCT date AS date_received, status, &quot; +"
errorLine2=" ~~~~~~~~">
<location
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
line="713"
column="28"/>
</issue>
<issue
id="Recycle"
message="This `Cursor` should be freed up after use with `#close()`"
errorLine1=" cursor = db.query(&quot;mms&quot;, new String[] {&quot;_id&quot;, &quot;network_failures&quot;}, &quot;network_failures IS NOT NULL&quot;, null, null, null, null);"
errorLine2=" ~~~~~">
<location
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
line="1037"
column="19"/>
</issue>
<issue
id="ObsoleteSdkInt"
message="Unnecessary; SDK_INT is always >= 21"
+4
View File
@@ -0,0 +1,4 @@
# dnsjava references desktop/server-only classes that are absent on Android.
-dontwarn com.sun.jna.**
-dontwarn javax.naming.**
-dontwarn lombok.Generated
@@ -4,12 +4,12 @@ import android.app.Application
import io.mockk.mockk
import io.mockk.spyk
import org.signal.core.util.billing.BillingApi
import org.signal.network.api.ArchiveApi
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
import org.whispersystems.signalservice.api.SignalServiceDataStore
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.account.AccountApi
import org.whispersystems.signalservice.api.archive.ArchiveApi
import org.whispersystems.signalservice.api.attachment.AttachmentApi
import org.whispersystems.signalservice.api.donations.DonationsApi
import org.whispersystems.signalservice.api.keys.KeysApi
@@ -75,8 +75,8 @@ object MockProvider {
val device = PreKeyResponseItem().apply {
this.deviceId = deviceId
registrationId = KeyHelper.generateRegistrationId(false)
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
preKey = PreKeyEntity(oneTimePreKey.id, oneTimePreKey.keyPair.publicKey)
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id.toLong(), signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
preKey = PreKeyEntity(oneTimePreKey.id.toLong(), oneTimePreKey.keyPair.publicKey)
}
return PreKeyResponse().apply {
@@ -74,7 +74,7 @@ class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPai
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, null)
if (!aciStore.containsSession(getAliceProtocolAddress())) {
val sessionBuilder = SignalSessionBuilder(sessionLock, SessionBuilder(aciStore, getAliceProtocolAddress()))
val sessionBuilder = SignalSessionBuilder(sessionLock, SessionBuilder(aciStore, getAliceProtocolAddress(), SignalProtocolAddress(serviceAddress.identifier, 1)))
sessionBuilder.process(getAlicePreKeyBundle())
}
+1 -1
View File
@@ -482,7 +482,7 @@
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
<activity
android:name="org.signal.mediasend.MediaSendActivity"
android:name=".mediasend.v3.MediaSendV3Activity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
android:exported="false"
android:launchMode="singleTop"
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -62,6 +62,7 @@ import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
import org.thoughtcrime.securesms.jobs.BackupRefreshJob;
import org.thoughtcrime.securesms.jobs.BackupSubscriptionCheckJob;
import org.thoughtcrime.securesms.jobs.BuildExpirationConfirmationJob;
import org.thoughtcrime.securesms.jobs.CallingAssetsDownloadJob;
import org.thoughtcrime.securesms.jobs.CheckKeyTransparencyJob;
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
@@ -102,12 +103,14 @@ import org.thoughtcrime.securesms.service.MessageBackupListener;
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
import org.thoughtcrime.securesms.service.webrtc.CallingAssets;
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
@@ -226,6 +229,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
.addPostRender(AndroidTelecomUtil::registerPhoneAccount)
.addPostRender(() -> AppDependencies.getJobManager().add(new FontDownloaderJob()))
.addPostRender(() -> AppDependencies.getJobManager().add(new CallingAssetsDownloadJob()))
.addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary)
.addPostRender(GroupV2UpdateSelfProfileKeyJob::enqueueForGroupsIfNecessary)
.addPostRender(StoryOnboardingDownloadJob.Companion::enqueueIfNeeded)
@@ -317,7 +321,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
* This is so we can capture ANR's that happen on boot before the foreground event.
*/
private void startAnrDetector() {
AnrDetector.start(TimeUnit.SECONDS.toMillis(5), RemoteConfig::internalUser, (dumps) -> {
AnrDetector.start(TimeUnit.SECONDS.toMillis(5), () -> RemoteConfig.internalUser() && SignalStore.internal().getAnrDetectionCrashes(), (dumps) -> {
LogDatabase.getInstance(this).anrs().save(System.currentTimeMillis(), dumps);
return Unit.INSTANCE;
});
@@ -400,6 +404,20 @@ public class ApplicationContext extends Application implements AppForegroundObse
AppDependencies.init(this, new ApplicationDependencyProvider(this));
}
AppForegroundObserver.begin();
if (Environment.USE_NEW_REGISTRATION) {
initializeRegistrationDependencies();
}
}
private void initializeRegistrationDependencies() {
org.signal.registration.RegistrationDependencies.Companion.provide(
new org.signal.registration.RegistrationDependencies(
new org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
new org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController(this),
null
)
);
}
private void initializeFirstEverAppLaunch() {
@@ -45,6 +45,9 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import androidx.transition.AutoTransition;
import androidx.transition.TransitionManager;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.concurrent.LifecycleDisposable;
@@ -60,10 +63,12 @@ import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.contacts.SelectedContacts;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchCallbacks;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
@@ -74,6 +79,7 @@ import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.signal.core.ui.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameAciFetchResult;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -86,7 +92,9 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.stream.Collectors;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@@ -117,7 +125,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
private OnContactSelectedListener onContactSelectedListener;
private SwipeRefreshLayout swipeRefresh;
private String cursorFilter;
private RecyclerView recyclerView;
private ContactSearchView contactSearchView;
private RecyclerViewFastScroller fastScroller;
private RecyclerView chipRecycler;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
@@ -126,8 +134,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
private LifecycleDisposable lifecycleDisposable;
private HeaderActionProvider headerActionProvider;
private TextView headerActionView;
private ContactSearchMediator contactSearchMediator;
private ContactSearchViewModel contactSearchViewModel;
@Nullable private RecyclerView innerRecyclerView;
@Nullable private LinearLayoutManager innerLayoutManager;
@Nullable private NewConversationCallback newConversationCallback;
@Nullable private FindByCallback findByCallback;
@Nullable private NewCallCallback newCallCallback;
@@ -238,7 +248,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
handleContactPermissionGranted();
} else {
requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
contactSearchMediator.refresh();
contactSearchViewModel.refresh();
}
}
@@ -246,29 +256,14 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false);
emptyText = view.findViewById(android.R.id.empty);
recyclerView = view.findViewById(R.id.recycler_view);
swipeRefresh = view.findViewById(R.id.swipe_refresh);
emptyText = view.findViewById(android.R.id.empty);
contactSearchView = view.findViewById(R.id.recycler_view);
swipeRefresh = view.findViewById(R.id.swipe_refresh);
fastScroller = view.findViewById(R.id.fast_scroller);
chipRecycler = view.findViewById(R.id.chipRecycler);
constraintLayout = view.findViewById(R.id.container);
headerActionView = view.findViewById(R.id.header_action);
final LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
recyclerView.setLayoutManager(layoutManager);
recyclerView.setItemAnimator(new DefaultItemAnimator() {
@Override
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
return true;
}
@Override
public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) {
recyclerView.setAlpha(1f);
}
});
contactChipViewModel = new ViewModelProvider(this).get(ContactChipViewModel.class);
contactChipAdapter = new MappingAdapter();
lifecycleDisposable = new LifecycleDisposable();
@@ -283,12 +278,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
fragmentArgs = ContactSelectionArguments.fromBundle(safeArguments(), requireActivity().getIntent());
if (fragmentArgs.getRecyclerPadBottom() != -1) {
ViewUtil.setPaddingBottom(recyclerView, fragmentArgs.getRecyclerPadBottom());
}
recyclerView.setClipToPadding(fragmentArgs.getRecyclerChildClipping());
swipeRefresh.setNestedScrollingEnabled(fragmentArgs.isRefreshable());
swipeRefresh.setEnabled(fragmentArgs.isRefreshable());
@@ -302,6 +291,26 @@ public final class ContactSelectionListFragment extends LoggingFragment {
currentSelection = getCurrentSelection();
Set<ContactSearchKey> fixedContacts = currentSelection.stream()
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
.collect(Collectors.toSet());
contactSearchViewModel = new ViewModelProvider(
this,
new ContactSearchViewModel.Factory(
selectionLimit,
isMulti,
new ContactSearchRepository(),
false,
new ContactSelectionListAdapter.ArbitraryRepository(),
new SearchRepository(requireContext().getString(R.string.note_to_self)),
new ContactSearchPagedDataSourceRepository(requireContext()),
fixedContacts
)
).get(ContactSearchViewModel.class);
List<RecyclerView.OnScrollListener> scrollListeners = new ArrayList<>();
final HeaderAction headerAction;
if (headerActionProvider != null) {
headerAction = headerActionProvider.getHeaderAction();
@@ -310,24 +319,20 @@ public final class ContactSelectionListFragment extends LoggingFragment {
headerActionView.setText(headerAction.getLabel());
headerActionView.setCompoundDrawablesRelativeWithIntrinsicBounds(headerAction.getIcon(), 0, 0, 0);
headerActionView.setOnClickListener(v -> headerAction.getAction().run());
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
scrollListeners.add(new RecyclerView.OnScrollListener() {
private final Rect bounds = new Rect();
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
}
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (hideLetterHeaders()) {
public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) {
if (hideLetterHeaders() || innerLayoutManager == null) {
return;
}
int firstPosition = layoutManager.findFirstVisibleItemPosition();
int firstPosition = innerLayoutManager.findFirstVisibleItemPosition();
if (firstPosition == 0) {
View firstChild = recyclerView.getChildAt(0);
recyclerView.getDecoratedBoundsWithMargins(firstChild, bounds);
View firstChild = rv.getChildAt(0);
rv.getDecoratedBoundsWithMargins(firstChild, bounds);
headerActionView.setTranslationY(bounds.top);
}
}
@@ -336,13 +341,104 @@ public final class ContactSelectionListFragment extends LoggingFragment {
headerActionView.setEnabled(false);
}
contactSearchMediator = new ContactSearchMediator(
this,
currentSelection.stream()
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
.collect(java.util.stream.Collectors.toSet()),
selectionLimit,
isMulti,
scrollListeners.add(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView rv, int newState) {
if (newState == RecyclerView.SCROLL_STATE_DRAGGING && scrollCallback != null) {
scrollCallback.onBeginScroll();
}
}
});
float contentBottomPaddingDp = fragmentArgs.getRecyclerPadBottom() != -1
? fragmentArgs.getRecyclerPadBottom() / getResources().getDisplayMetrics().density
: 0f;
ContactSearchAdapter.AdapterFactory adapterFactory =
(context, fc, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) ->
new ContactSelectionListAdapter(
context,
fc,
displayOptions,
new ContactSelectionListAdapter.OnContactSelectionClick() {
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
contactSearchViewModel.refresh();
}
@Override
public void onFindContactsClicked() {
requestContactPermissions();
}
@Override
public void onRefreshContactsClicked() {
if (onRefreshListener != null && !isRefreshing()) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
@Override
public void onNewGroupClicked() {
newConversationCallback.onNewGroup(false);
}
@Override
public void onFindByPhoneNumberClicked() {
findByCallback.onFindByPhoneNumber();
}
@Override
public void onFindByUsernameClicked() {
findByCallback.onFindByUsername();
}
@Override
public void onInviteToSignalClicked() {
if (newConversationCallback != null) {
newConversationCallback.onInvite();
}
if (newCallCallback != null) {
newCallCallback.onInvite();
}
}
@Override
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
callbacks.onExpandClicked(expand);
}
@Override
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
@Override
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
storyContextMenuCallbacks,
new CallButtonClickCallbacks()
);
contactSearchView.bind(
contactSearchViewModel,
getChildFragmentManager(),
new ContactSearchAdapter.DisplayOptions(
isMulti,
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
@@ -350,94 +446,31 @@ public final class ContactSelectionListFragment extends LoggingFragment {
false
),
this::mapStateToConfiguration,
new ContactSearchMediator.SimpleCallbacks() {
new ContactSearchCallbacks.Simple() {
@Override
public void onAdapterListCommitted(int size) {
onLoadFinished(size);
}
},
false,
(context, fixedContacts, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) -> new ContactSelectionListAdapter(
context,
fixedContacts,
displayOptions,
new ContactSelectionListAdapter.OnContactSelectionClick() {
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
contactSearchMediator.refresh();
}
Collections.singletonList(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders)),
contentBottomPaddingDp,
adapterFactory,
scrollListeners,
rv -> {
innerRecyclerView = rv;
innerLayoutManager = (LinearLayoutManager) rv.getLayoutManager();
rv.setItemAnimator(new DefaultItemAnimator() {
@Override
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
return true;
}
@Override
public void onFindContactsClicked() {
requestContactPermissions();
}
@Override
public void onRefreshContactsClicked() {
if (onRefreshListener != null && !isRefreshing()) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
@Override
public void onNewGroupClicked() {
newConversationCallback.onNewGroup(false);
}
@Override
public void onFindByPhoneNumberClicked() {
findByCallback.onFindByPhoneNumber();
}
@Override
public void onFindByUsernameClicked() {
findByCallback.onFindByUsername();
}
@Override
public void onInviteToSignalClicked() {
if (newConversationCallback != null) {
newConversationCallback.onInvite();
}
if (newCallCallback != null) {
newCallCallback.onInvite();
}
}
@Override
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
callbacks.onExpandClicked(expand);
}
@Override
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
@Override
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
storyContextMenuCallbacks,
new CallButtonClickCallbacks()
),
new ContactSelectionListAdapter.ArbitraryRepository()
@Override
public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) {
contactSearchView.setAlpha(1f);
}
});
}
);
return view;
@@ -460,30 +493,30 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public @NonNull List<SelectedContact> getSelectedContacts() {
if (contactSearchMediator == null) {
if (contactSearchViewModel == null) {
return Collections.emptyList();
}
return contactSearchMediator.getSelectedContacts()
.stream()
.map(ContactSearchKey::requireSelectedContact)
.collect(java.util.stream.Collectors.toList());
return contactSearchViewModel.getSelectedContacts()
.stream()
.map(ContactSearchKey::requireSelectedContact)
.collect(Collectors.toList());
}
public int getSelectedContactsCount() {
if (contactSearchMediator == null) {
if (contactSearchViewModel == null) {
return 0;
}
return contactSearchMediator.getSelectedContacts().size();
return contactSearchViewModel.getSelectedContacts().size();
}
public int getTotalMemberCount() {
if (contactSearchMediator == null) {
if (contactSearchViewModel == null) {
return 0;
}
return getSelectedContactsCount() + contactSearchMediator.getFixedContactsSize();
return getSelectedContactsCount() + contactSearchViewModel.getFixedContactsSize();
}
private Set<RecipientId> getCurrentSelection() {
@@ -499,36 +532,23 @@ public final class ContactSelectionListFragment extends LoggingFragment {
.request(Manifest.permission.WRITE_CONTACTS, Manifest.permission.READ_CONTACTS)
.ifNecessary()
.onAllGranted(() -> {
recyclerView.setAlpha(0.5f);
contactSearchView.setAlpha(0.5f);
if (!TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) {
handleContactPermissionGranted();
} else {
contactSearchMediator.refresh();
contactSearchViewModel.refresh();
if (onRefreshListener != null) {
swipeRefresh.setRefreshing(true);
onRefreshListener.onRefresh();
}
}
})
.onAnyDenied(() -> contactSearchMediator.refresh())
.onAnyDenied(() -> contactSearchViewModel.refresh())
.withPermanentDenialDialog(getString(R.string.ContactSelectionListFragment_signal_requires_the_contacts_permission_in_order_to_display_your_contacts), null, R.string.ContactSelectionListFragment_allow_access_contacts, R.string.ContactSelectionListFragment_to_find_people, getParentFragmentManager())
.execute();
}
private void initializeCursor() {
recyclerView.addItemDecoration(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders));
recyclerView.setAdapter(contactSearchMediator.getAdapter());
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
if (scrollCallback != null) {
scrollCallback.onBeginScroll();
}
}
}
});
if (onContactSelectedListener != null) {
onContactSelectedListener.onSelectionChanged();
}
@@ -546,7 +566,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
this.resetPositionOnCommit = true;
this.cursorFilter = filter;
contactSearchMediator.onFilterChanged(filter);
contactSearchViewModel.setQuery(filter);
}
public void resetQueryFilter() {
@@ -557,7 +577,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public void onDataRefreshed() {
this.resetPositionOnCommit = true;
swipeRefresh.setRefreshing(false);
contactSearchMediator.refresh();
contactSearchViewModel.refresh();
}
public boolean hasQueryFilter() {
@@ -573,26 +593,25 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public void reset() {
contactSearchMediator.clearSelection();
contactSearchMediator.refresh();
contactSearchViewModel.clearSelection();
contactSearchViewModel.refresh();
fastScroller.setVisibility(View.GONE);
headerActionView.setVisibility(View.GONE);
}
private void onLoadFinished(int count) {
if (resetPositionOnCommit) {
if (resetPositionOnCommit && innerRecyclerView != null) {
resetPositionOnCommit = false;
recyclerView.scrollToPosition(0);
innerRecyclerView.scrollToPosition(0);
}
swipeRefresh.setVisibility(View.VISIBLE);
emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
boolean useFastScroller = count > 20;
recyclerView.setVerticalScrollBarEnabled(!useFastScroller);
if (useFastScroller) {
if (useFastScroller && innerRecyclerView != null) {
fastScroller.setVisibility(View.VISIBLE);
fastScroller.setRecyclerView(recyclerView);
fastScroller.setRecyclerView(innerRecyclerView);
} else {
fastScroller.setRecyclerView(null);
fastScroller.setVisibility(View.GONE);
@@ -659,10 +678,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
Set<SelectedContact> toMarkSelected = contacts.stream()
.filter(r -> !contactSearchMediator.getSelectedContacts()
.contains(new ContactSearchKey.RecipientSearchKey(r, false)))
.filter(r -> !contactSearchViewModel.getSelectedContacts()
.contains(new ContactSearchKey.RecipientSearchKey(r, false)))
.map(SelectedContact::forRecipientId)
.collect(java.util.stream.Collectors.toSet());
.collect(Collectors.toSet());
if (toMarkSelected.isEmpty()) {
return;
@@ -687,7 +706,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
return;
}
if (selectedContact.hasChatType() && !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
if (selectedContact.hasChatType() && !contactSearchViewModel.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
if (onContactSelectedListener != null) {
onContactSelectedListener.onBeforeContactSelected(true, Optional.empty(), null, Optional.of(selectedContact.getChatType()), allowed -> {
if (allowed) {
@@ -704,7 +723,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
return;
}
if (!isMulti || !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
if (!isMulti || !contactSearchViewModel.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
if (selectionHardLimitReached()) {
if (onSelectionLimitReachedListener != null) {
onSelectionLimitReachedListener.onHardLimitReached(selectionLimit.getHardLimit());
@@ -771,8 +790,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public boolean onItemLongClick(View anchorView, ContactSearchKey item) {
if (onItemLongClickListener != null) {
return onItemLongClickListener.onLongClick(anchorView, item, recyclerView);
if (onItemLongClickListener != null && innerRecyclerView != null) {
return onItemLongClickListener.onLongClick(anchorView, item, innerRecyclerView);
} else {
return false;
}
@@ -792,7 +811,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public void markContactSelected(@NonNull SelectedContact selectedContact) {
contactSearchMediator.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey()));
contactSearchViewModel.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey()));
if (isMulti) {
addChipForSelectedContact(selectedContact);
}
@@ -802,7 +821,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
contactSearchMediator.setKeysNotSelected(Collections.singleton(selectedContact.toContactSearchKey()));
contactSearchViewModel.setKeysNotSelected(Collections.singleton(selectedContact.toContactSearchKey()));
contactChipViewModel.remove(selectedContact);
if (onContactSelectedListener != null) {
@@ -864,8 +883,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
AutoTransition transition = new AutoTransition();
transition.setDuration(CHIP_GROUP_REVEAL_DURATION_MS);
transition.excludeChildren(recyclerView, true);
transition.excludeTarget(recyclerView, true);
transition.excludeChildren(contactSearchView, true);
transition.excludeTarget(contactSearchView, true);
TransitionManager.beginDelayedTransition(constraintLayout, transition);
ConstraintSet constraintSet = new ConstraintSet();
@@ -44,7 +44,6 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
@@ -61,6 +60,7 @@ 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.platform.LocalResources
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.fragment.app.DialogFragment
@@ -73,7 +73,6 @@ import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.RecyclerView
import androidx.window.core.layout.WindowSizeClass
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
@@ -86,8 +85,8 @@ import kotlinx.coroutines.withContext
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.isSplitPane
import org.signal.core.ui.permissions.Permissions
import org.signal.core.ui.rememberIsSplitPane
import org.signal.core.util.Util
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableCompat
@@ -163,6 +162,7 @@ import org.thoughtcrime.securesms.main.rememberFocusRequester
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mediasend.v3.mediaSendLauncher
import org.thoughtcrime.securesms.megaphone.Megaphone
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController
import org.thoughtcrime.securesms.megaphone.Megaphones
@@ -271,7 +271,7 @@ class MainActivity :
override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) }
override val googlePayResultPublisher: Subject<GooglePayComponent.GooglePayResult> = PublishSubject.create()
private lateinit var mediaActivityLauncher: ActivityResultLauncher<MediaSendActivityContract.Args>
private lateinit var mediaSendLauncher: ActivityResultLauncher<MediaSendActivityContract.Args>
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return motionEventRelay.offer(ev) || super.dispatchTouchEvent(ev)
@@ -298,7 +298,7 @@ class MainActivity :
super.onCreate(savedInstanceState, ready)
navigator = MainNavigator(this, mainNavigationViewModel)
mediaActivityLauncher = registerForActivityResult(MediaSendActivityContract()) { }
mediaSendLauncher = mediaSendLauncher()
AppForegroundObserver.addListener(object : AppForegroundObserver.Listener {
override fun onForeground() {
@@ -428,15 +428,15 @@ class MainActivity :
)
}
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val isSplitPane = LocalResources.current.rememberIsSplitPane()
val contentLayoutData = MainContentLayoutData.rememberContentLayoutData(mainToolbarState.mode)
MainContainer {
val wrappedNavigator = rememberNavigator(windowSizeClass, contentLayoutData, maxWidth)
val wrappedNavigator = rememberNavigator(isSplitPane, contentLayoutData, maxWidth)
val listPaneWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
val navigationType = NavigationType.rememberNavigationType()
val anchors = remember(contentLayoutData, mainToolbarState) {
val anchors = remember(contentLayoutData, mainToolbarState, listPaneWidth, navigationType) {
val halfPartitionWidth = contentLayoutData.partitionWidth / 2
val detailOffset = when {
@@ -464,7 +464,7 @@ class MainActivity :
anchors.indexOf(paneExpansionState.currentAnchor)
}
LaunchedEffect(windowSizeClass) {
LaunchedEffect(anchors) {
val index = when {
paneAnchorIndex < 0 -> 1
paneAnchorIndex > anchors.lastIndex -> anchors.lastIndex
@@ -477,7 +477,7 @@ class MainActivity :
}
}
val chatNavGraphState = ChatNavGraphState.remember(windowSizeClass)
val chatNavGraphState = ChatNavGraphState.remember(isSplitPane)
val mutableInteractionSource = remember { MutableInteractionSource() }
MainNavigationDetailLocationEffect(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
@@ -520,15 +520,14 @@ class MainActivity :
}.navigateToDetailLocation(location)
}
is MainNavigationDetailLocation.Chats -> {
if (location is MainNavigationDetailLocation.Chats.Conversation) {
chatNavGraphState.writeGraphicsLayerToBitmap()
}
is MainNavigationDetailLocation.Conversation -> {
chatNavGraphState.writeGraphicsLayerToBitmap()
chatsNavHostController.navigateToDetailLocation(location)
}
is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.CallLinkDetails -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(location)
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(location)
}
}
@@ -623,7 +622,7 @@ class MainActivity :
onDestinationSelected = mainNavigationCallback
)
if (!windowSizeClass.isSplitPane()) {
if (!LocalResources.current.rememberIsSplitPane()) {
Spacer(Modifier.navigationBarsPadding())
}
}
@@ -639,7 +638,7 @@ class MainActivity :
}
},
secondaryContent = {
val listContainerColor = if (windowSizeClass.isSplitPane()) {
val listContainerColor = if (isSplitPane) {
SignalTheme.colors.colorSurface1
} else {
MaterialTheme.colorScheme.surface
@@ -780,12 +779,12 @@ class MainActivity :
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
private fun rememberNavigator(
windowSizeClass: WindowSizeClass,
isSplitPane: Boolean,
contentLayoutData: MainContentLayoutData,
maxWidth: Dp
): AppScaffoldNavigator<Any> {
val scaffoldNavigator = rememberThreePaneScaffoldNavigatorDelegate(
isSplitPane = windowSizeClass.isSplitPane(),
isSplitPane = isSplitPane,
horizontalPartitionSpacerSize = contentLayoutData.partitionWidth,
defaultPanePreferredWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
)
@@ -799,18 +798,18 @@ class MainActivity :
@Composable
private fun MainContainer(content: @Composable BoxWithConstraintsScope.() -> Unit) {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val isSplitPane = LocalResources.current.rememberIsSplitPane()
CompositionLocalProvider(LocalSnackbarStateConsumerRegistry provides mainNavigationViewModel.snackbarRegistry) {
SignalTheme {
val backgroundColor = if (!windowSizeClass.isSplitPane()) {
val backgroundColor = if (!isSplitPane) {
MaterialTheme.colorScheme.surface
} else {
SignalTheme.colors.colorSurface1
}
val modifier = when {
windowSizeClass.isSplitPane() -> {
isSplitPane -> {
Modifier
.systemBarsPadding()
.displayCutoutPadding()
@@ -847,7 +846,7 @@ class MainActivity :
val detailLocation = extras.getParcelableCompat(KEY_DETAIL_LOCATION, MainNavigationDetailLocation::class.java)
if (detailLocation != null) {
mainNavigationViewModel.goTo(detailLocation)
goTo(detailLocation)
return
}
@@ -1033,7 +1032,7 @@ class MainActivity :
private fun handleConversationIntent(intent: Intent) {
if (ConversationIntents.isConversationIntent(intent)) {
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
intent.action = null
setIntent(intent)
}
@@ -1124,7 +1123,7 @@ class MainActivity :
if (isForQuickRestore) {
startActivity(MediaSelectionActivity.cameraForQuickRestore(context = this@MainActivity))
} else if (SignalStore.internal.useNewMediaActivity) {
mediaActivityLauncher.launch(
mediaSendLauncher.launch(
MediaSendActivityContract.Args(
isCameraFirst = false,
isStory = destination == MainNavigationListLocation.STORIES
@@ -50,7 +50,7 @@ public class MainNavigator {
.withStartingPosition(startingPosition)
.asIncognito(incognito)
.toConversationArgs())
.subscribe(args -> viewModel.goTo(new MainNavigationDetailLocation.Chats.Conversation(args)));
.subscribe(args -> viewModel.goTo(new MainNavigationDetailLocation.Conversation(args)));
lifecycleDisposable.add(disposable);
}
@@ -16,7 +16,6 @@
*/
package org.thoughtcrime.securesms;
import android.animation.Animator;
import android.app.KeyguardManager;
import android.content.Context;
import android.content.Intent;
@@ -49,7 +48,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.AnimatingToggle;
import org.thoughtcrime.securesms.crypto.InvalidPassphraseException;
import org.thoughtcrime.securesms.crypto.MasterSecret;
@@ -389,13 +387,7 @@ public class PassphrasePromptActivity extends PassphraseActivity {
Log.i(TAG, "onAuthenticationSucceeded");
lockScreenButton.setOnClickListener(null);
unlockView.addAnimatorListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
handleAuthenticated();
}
});
unlockView.playAnimation();
handleAuthenticated();
}
@Override
@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.restore.RestoreActivity;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
@@ -134,8 +135,12 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
Intent intent = getIntentForState(applicationState);
if (intent != null) {
Log.d(TAG, "routeApplicationState(), intent: " + intent.getComponent());
startActivity(intent);
finish();
if (applicationState == STATE_WELCOME_PUSH_SCREEN && Environment.USE_NEW_REGISTRATION) {
startActivity(intent);
} else {
startActivity(intent);
finish();
}
}
}
@@ -173,7 +178,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return STATE_ENTER_SIGNAL_PIN;
} else if (userMustSetProfileName()) {
return STATE_CREATE_PROFILE_NAME;
} else if (userMustCreateSignalPin()) {
} else if (userMustCreateSignalPin() && getClass() != CreateSvrPinActivity.class) {
return STATE_CREATE_SIGNAL_PIN;
} else if (EventBus.getDefault().getStickyEvent(TransferStatus.class) != null && getClass() != OldDeviceTransferActivity.class) {
return STATE_TRANSFER_ONGOING;
@@ -221,7 +226,11 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getPushRegistrationIntent() {
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
if (Environment.USE_NEW_REGISTRATION) {
return org.signal.registration.RegistrationActivity.createIntent(this);
} else {
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
}
}
private Intent getEnterSignalPinIntent() {
@@ -19,7 +19,7 @@ package org.thoughtcrime.securesms;
import android.content.Intent;
import android.os.Bundle;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.contacts.SelectedContact;
@@ -58,7 +58,7 @@ public class PushContactSelectionActivity extends ContactSelectionActivity {
protected final void onFinishedSelection() {
Intent resultIntent = getIntent();
List<SelectedContact> selectedContacts = contactsFragment.getSelectedContacts();
List<RecipientId> recipients = Stream.of(selectedContacts).map(sc -> sc.getOrCreateRecipientId()).toList();
List<RecipientId> recipients = selectedContacts.stream().map(sc -> sc.getOrCreateRecipientId()).collect(Collectors.toList());
resultIntent.putParcelableArrayListExtra(KEY_SELECTED_RECIPIENTS, new ArrayList<>(recipients));
@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.apkupdate
import android.app.DownloadManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
@@ -53,6 +54,13 @@ object ApkUpdateInstaller {
return
}
if (!isDownloadSuccessful(context, downloadId)) {
Log.w(TAG, "DownloadId matches, but the download was not successful. The download may have failed due to a network issue. Clearing state and re-checking for updates.")
SignalStore.apkUpdate.clearDownloadAttributes()
AppDependencies.jobManager.add(ApkUpdateJob())
return
}
if (!isMatchingDigest(context, downloadId, digest)) {
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate.clearDownloadAttributes()
@@ -134,6 +142,35 @@ object ApkUpdateInstaller {
}
}
private fun isDownloadSuccessful(context: Context, downloadId: Long): Boolean {
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = context.getDownloadManager().query(query)
return cursor.use { cursor ->
if (cursor.moveToFirst()) {
val status = cursor
.getColumnIndex(DownloadManager.COLUMN_STATUS)
.takeUnless { it == -1 }
?.let { cursor.getInt(it) } ?: DownloadManager.STATUS_FAILED
if (status == DownloadManager.STATUS_SUCCESSFUL) {
return@use true
}
val reason = cursor
.getColumnIndex(DownloadManager.COLUMN_REASON)
.takeUnless { it == -1 }
?.let { cursor.getInt(it) }
Log.w(TAG, "Download not successful. Status: $status, Reason: $reason")
false
} else {
Log.w(TAG, "Download ID $downloadId not found in DownloadManager.")
false
}
}
}
private fun isMatchingDigest(context: Context, downloadId: Long, expectedDigest: ByteArray): Boolean {
return try {
FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor).use { stream ->
@@ -145,6 +145,11 @@ class PointerAttachment : Attachment {
return Optional.empty()
}
val cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0)
if (cdn == Cdn.S3) {
return Optional.empty()
}
return Optional.of(
PointerAttachment(
quote = true,
@@ -153,7 +158,7 @@ class PointerAttachment : Attachment {
transferState = AttachmentTable.TRANSFER_PROGRESS_PENDING,
size = (if (thumbnail != null) thumbnail.asPointer().size.orElse(0) else 0).toLong(),
fileName = quotedAttachment.fileName,
cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0),
cdn = cdn,
location = thumbnail?.asPointer()?.remoteId?.toString() ?: "0",
key = thumbnail?.asPointer()?.key?.let { Base64.encodeWithPadding(it) },
iv = null,
@@ -16,8 +16,8 @@ import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager
import org.signal.core.util.logging.Log
import org.signal.core.util.safeUnregisterReceiver
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioHandler
import java.util.concurrent.TimeUnit
@@ -11,8 +11,6 @@ import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;
import androidx.documentfile.provider.DocumentFile;
import com.annimon.stream.function.Predicate;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.greenrobot.eventbus.EventBus;
@@ -71,6 +69,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import okio.ByteString;
@@ -175,6 +175,10 @@ object ExportSkips {
return log(sentTimestamp, "Invalid e164 in sessions switchover event. Exporting an empty event.")
}
fun donationRequestNotInReleaseNotesChat(sentTimestamp: Long): String {
return log(sentTimestamp, "Donation request not in Release Notes chat.")
}
private fun log(sentTimestamp: Long, message: String): String {
return "[SKIP][$sentTimestamp] $message"
}
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.update
import org.signal.core.util.bytes
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.signal.core.util.safeUnregisterReceiver
import org.signal.core.util.throttleLatest
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.attachments.AttachmentId
@@ -31,7 +32,6 @@ import org.thoughtcrime.securesms.jobmanager.impl.DiskSpaceNotLowConstraint
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobmanager.impl.WifiConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
@@ -203,6 +203,7 @@ object ArchiveRestoreProgress {
state.hasActivelyRestoredThisRun -> ArchiveRestoreProgressState.RestoreStatus.FINISHED
else -> ArchiveRestoreProgressState.RestoreStatus.NONE
}
else -> {
val availableBytes = SignalStore.backup.spaceAvailableOnDiskBytes
@@ -66,6 +66,7 @@ import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.libsignal.zkgroup.backups.BackupLevel
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.signal.network.api.SvrBApi
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
@@ -161,7 +162,6 @@ import org.whispersystems.signalservice.api.link.TransferArchiveResponse
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.svr.SvrBApi
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import org.whispersystems.signalservice.internal.push.AuthCredentials
@@ -424,6 +424,12 @@ object BackupRepository {
}
fun markOutOfRemoteStorageSpaceError() {
if (SignalStore.backup.isNotEnoughRemoteStorageSpace) {
return
}
SignalStore.backup.markNotEnoughRemoteStorageSpace()
val context = AppDependencies.application
val pendingIntent = PendingIntent.getActivity(context, 0, AppSettingsActivity.remoteBackups(context), cancelCurrent())
@@ -436,8 +442,6 @@ object BackupRepository {
.build()
ServiceUtil.getNotificationManager(context).notify(NotificationIds.OUT_OF_REMOTE_STORAGE, notification)
SignalStore.backup.markNotEnoughRemoteStorageSpace()
}
fun clearOutOfRemoteStorageSpaceError() {
@@ -1686,10 +1690,10 @@ object BackupRepository {
*
* It's important to note that in order to get this to the archive cdn, you still need to use [copyAttachmentToArchive].
*/
fun getAttachmentUploadForm(): NetworkResult<AttachmentUploadForm> {
fun getAttachmentUploadForm(uploadLength: Long): NetworkResult<AttachmentUploadForm> {
return initBackupAndFetchAuth()
.then { credential ->
SignalNetwork.archive.getMediaUploadForm(SignalStore.account.requireAci(), credential.mediaBackupAccess)
SignalNetwork.archive.getMediaUploadForm(SignalStore.account.requireAci(), credential.mediaBackupAccess, uploadLength)
}
}
@@ -2090,7 +2094,7 @@ object BackupRepository {
}
/**
* See [org.whispersystems.signalservice.api.archive.ArchiveApi.getSvrBAuthorization].
* See [org.signal.network.api.ArchiveApi.getSvrBAuthorization].
*/
fun getSvrBAuth(): NetworkResult<AuthCredentials> {
return initBackupAndFetchAuth()
@@ -241,6 +241,10 @@ class ChatItemArchiveExporter(
}
MessageTypes.isReleaseChannelDonationRequest(record.type) -> {
if (exportState.threadIdToRecipientId[builder.chatId] != exportState.releaseNoteRecipientId) {
Log.w(TAG, ExportSkips.donationRequestNotInReleaseNotesChat(builder.dateSent))
continue
}
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.RELEASE_CHANNEL_DONATION_REQUEST)
transformTimer.emit("simple-update")
}
@@ -318,7 +322,7 @@ class ChatItemArchiveExporter(
}
MessageTypes.isSessionSwitchoverType(record.type) -> {
builder.updateMessage = record.toRemoteSessionSwitchoverUpdate(record.dateSent) ?: continue
builder.updateMessage = record.toRemoteSessionSwitchoverUpdate(record.dateSent)?.takeIf { builder.authorIsAciContact(exportState) } ?: continue
transformTimer.emit("sse")
}
@@ -362,12 +362,13 @@ class ChatItemArchiveImporter(
} else if (pinMessage != null) {
followUps += { pinUpdateMessageId ->
val targetAuthorId = importState.remoteToLocalRecipientId[pinMessage.authorId]
if (targetAuthorId != null) {
val targetAuthorAci = targetAuthorId?.let { recipients.getRecord(it).aci }
if (targetAuthorId != null && targetAuthorAci != null) {
val pinnedMessageId = SignalDatabase.messages.getMessageFor(pinMessage.targetSentTimestamp, targetAuthorId)?.id ?: -1
val messageExtras = MessageExtras(
pinnedMessage = PinnedMessage(
pinnedMessageId = pinnedMessageId,
targetAuthorAci = recipients.getRecord(targetAuthorId).aci!!.toByteString(),
targetAuthorAci = targetAuthorAci.toByteString(),
targetTimestamp = pinMessage.targetSentTimestamp
)
)
@@ -383,6 +384,8 @@ class ChatItemArchiveImporter(
.where("${MessageTable.ID} = ?", pinnedMessageId)
.run()
}
} else {
Log.w(TAG, "Pin message target author not found or has no ACI, skipping pin message extras.")
}
}
}
@@ -715,7 +718,7 @@ class ChatItemArchiveImporter(
when {
itemStandardMessage != null -> contentValues.addStandardMessage(itemStandardMessage)
itemRemoteDeletedMessage != null -> contentValues.put(MessageTable.DELETED_BY, fromRecipientId.toLong())
itemUpdateMessage != null -> contentValues.addUpdateMessage(itemUpdateMessage, fromRecipientId, toRecipientId)
itemUpdateMessage != null -> contentValues.addUpdateMessage(itemUpdateMessage, fromRecipientId, toRecipientId, chatRecipientId)
itemPaymentNotification != null -> contentValues.addPaymentNotification(this, chatRecipientId)
itemGiftBadge != null -> contentValues.addGiftBadge(itemGiftBadge)
itemViewOnceMessage != null -> contentValues.addViewOnce(itemViewOnceMessage)
@@ -863,7 +866,7 @@ class ChatItemArchiveImporter(
}
}
private fun ContentValues.addUpdateMessage(updateMessage: ChatUpdateMessage, fromRecipientId: RecipientId, toRecipientId: RecipientId) {
private fun ContentValues.addUpdateMessage(updateMessage: ChatUpdateMessage, fromRecipientId: RecipientId, toRecipientId: RecipientId, chatRecipientId: RecipientId) {
var typeFlags: Long = 0
val simpleUpdate = updateMessage.simpleUpdate
val expirationTimerChange = updateMessage.expirationTimerChange
@@ -883,8 +886,8 @@ class ChatItemArchiveImporter(
SimpleChatUpdate.Type.UNKNOWN -> typeWithoutBase
SimpleChatUpdate.Type.JOINED_SIGNAL -> MessageTypes.JOINED_TYPE or typeWithoutBase
SimpleChatUpdate.Type.IDENTITY_UPDATE -> MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT or typeWithoutBase
SimpleChatUpdate.Type.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT or typeWithoutBase
SimpleChatUpdate.Type.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT or typeWithoutBase
SimpleChatUpdate.Type.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT or typeWithoutBase or MessageTypes.BASE_SENT_TYPE
SimpleChatUpdate.Type.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT or typeWithoutBase or MessageTypes.BASE_SENT_TYPE
SimpleChatUpdate.Type.CHANGE_NUMBER -> MessageTypes.CHANGE_NUMBER_TYPE
SimpleChatUpdate.Type.RELEASE_CHANNEL_DONATION_REQUEST -> MessageTypes.RELEASE_CHANNEL_DONATION_REQUEST_TYPE
SimpleChatUpdate.Type.END_SESSION -> MessageTypes.END_SESSION_BIT or typeWithoutBase
@@ -904,6 +907,11 @@ class ChatItemArchiveImporter(
put(MessageTable.FROM_RECIPIENT_ID, toRecipientId.serialize())
put(MessageTable.TO_RECIPIENT_ID, fromRecipientId.serialize())
}
// directionless 1:1 message requests expect to recipient to be the other recipient not self
if (simpleUpdate.type == SimpleChatUpdate.Type.MESSAGE_REQUEST_ACCEPTED) {
put(MessageTable.TO_RECIPIENT_ID, chatRecipientId.serialize())
}
}
expirationTimerChange != null -> {
typeFlags = getAsLong(MessageTable.TYPE) or MessageTypes.EXPIRATION_TIMER_UPDATE_BIT
@@ -14,6 +14,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.supervisorScope
import org.signal.archive.local.ArchivedFilesReader
import org.signal.core.models.backup.MediaName
import org.signal.core.util.Stopwatch
@@ -122,6 +123,57 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
fun openInputStream(context: Context, uri: Uri): InputStream? {
return context.contentResolver.openInputStream(uri)
}
/**
* Recursively delete the entire SignalBackups directory using parallelized SAF calls.
*/
@JvmStatic
@JvmOverloads
fun deleteAll(signalBackupsDir: DocumentFile, progressListener: AllFilesProgressListener? = null) {
Log.i(TAG, "Deleting all backup data")
val units = mutableListOf<DocumentFile>()
for (child in signalBackupsDir.listFiles()) {
if (child.isDirectory && child.name == "files") {
units += child.listFiles()
} else {
units += child
}
}
if (units.isEmpty()) {
signalBackupsDir.delete()
return
}
val total = units.size
val completed = AtomicInteger(0)
val deleted = AtomicInteger(0)
val concurrency = Runtime.getRuntime().availableProcessors().coerceAtMost(8)
val chunkSize = ((total + concurrency - 1) / concurrency).coerceAtLeast(1)
runBlocking {
coroutineScope {
units.chunked(chunkSize).map { chunk ->
async(Dispatchers.IO) {
for (unit in chunk) {
if (unit.delete()) {
deleted.incrementAndGet()
}
progressListener?.onProgress(completed.incrementAndGet(), total)
}
}
}.awaitAll()
}
}
for (child in signalBackupsDir.listFiles()) {
child.delete()
}
signalBackupsDir.delete()
Log.d(TAG, "Deleted ${deleted.get()}/$total top-level units")
}
}
private val signalBackups: DocumentFile
@@ -236,8 +288,14 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
/**
* Clean up unused files in the shared files directory leveraged across all current snapshots. A file
* is unused if it is not referenced directly by any current snapshots.
*
* @param allFilesProgressListener reports progress of the enumeration phase (fast, 256 shards)
* @param deletionProgressListener reports progress of the deletion phase (slow, potentially thousands of SAF calls). Fires from multiple threads.
*/
fun deleteUnusedFiles(allFilesProgressListener: AllFilesProgressListener? = null) {
fun deleteUnusedFiles(
allFilesProgressListener: AllFilesProgressListener? = null,
deletionProgressListener: AllFilesProgressListener? = null
) {
Log.i(TAG, "Deleting unused files")
val allFiles: MutableMap<String, DocumentFileInfo> = filesFileSystem.allFiles(allFilesProgressListener).toMutableMap()
@@ -251,16 +309,38 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
}
}
var deleted = 0
allFiles
.values
.forEach {
if (it.documentFile.delete()) {
deleted++
}
}
val toDelete = allFiles.values.toList()
val total = toDelete.size
if (total == 0) {
Log.d(TAG, "Cleanup removed 0/0 files")
return
}
Log.d(TAG, "Cleanup removed $deleted/${allFiles.size} files")
val deleted = AtomicInteger(0)
val completed = AtomicInteger(0)
val concurrency = Runtime.getRuntime().availableProcessors().coerceAtMost(8)
val chunkSize = ((total + concurrency - 1) / concurrency).coerceAtLeast(1)
runBlocking {
supervisorScope {
toDelete.chunked(chunkSize).map { chunk ->
async(Dispatchers.IO) {
try {
for (info in chunk) {
if (info.documentFile.delete()) {
deleted.incrementAndGet()
}
deletionProgressListener?.onProgress(completed.incrementAndGet(), total)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to clean up a chunk.", e)
}
}
}.awaitAll()
}
}
Log.d(TAG, "Cleanup removed ${deleted.get()}/$total files")
}
/** Useful metadata for a given archive snapshot */
@@ -333,9 +333,10 @@ private fun BackupFailedBody() {
append(stringResource(id = R.string.BackupAlertBottomSheet__an_error_occurred))
append(" ")
val link = stringResource(R.string.remote_backup_support_url)
withLink(
LinkAnnotation.Clickable(tag = "learn-more") {
CommunicationActions.openBrowserLink(context, context.getString(R.string.remote_backup_support_url))
CommunicationActions.openBrowserLink(context, link)
}
) {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
@@ -205,7 +205,7 @@ private fun FeatureBullet(text: String) {
modifier = Modifier.padding(vertical = 2.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_check_24),
imageVector = ImageVector.vectorResource(id = CoreUiR.drawable.symbol_check_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
@@ -31,10 +31,11 @@ class NoRemoteStorageSpaceAvailableBottomSheet : ComposeBottomSheetDialogFragmen
@Composable
override fun SheetContent() {
val context = LocalContext.current
val supportUrl = stringResource(R.string.remote_backup_support_url)
NoRemoteStorageSpaceAvailableBottomSheetContent(
onLearnMoreClick = {
CommunicationActions.openBrowserLink(context, context.getString(R.string.remote_backup_support_url))
CommunicationActions.openBrowserLink(context, supportUrl)
},
onContactSupportClick = {
ContactSupportDialogFragment.create(
@@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString.Builder
@@ -37,7 +38,6 @@ import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.BackupValues
import org.thoughtcrime.securesms.util.DateUtils
import java.util.Locale
import kotlin.time.Duration.Companion.days
import org.signal.core.ui.R as CoreUiR
@@ -53,7 +53,7 @@ fun BackupCreateErrorRow(
onLearnMoreClick: () -> Unit = {}
) {
val context = LocalContext.current
val locale = Locale.getDefault()
val locale = LocalLocale.current
when (error) {
BackupValues.BackupCreationError.TRANSIENT -> {
@@ -82,7 +82,7 @@ fun BackupCreateErrorRow(
BackupValues.BackupCreationError.BACKUP_FILE_TOO_LARGE -> {
BackupAlertText {
if (lastMessageCutoffTime > 0) {
append(stringResource(R.string.BackupStatusRow__not_backing_up_old_messages, DateUtils.getDayPrecisionTimeString(context, locale, lastMessageCutoffTime)))
append(stringResource(R.string.BackupStatusRow__not_backing_up_old_messages, DateUtils.getDayPrecisionTimeString(context, locale.platformLocale, lastMessageCutoffTime)))
} else {
append(stringResource(R.string.BackupStatusRow__backup_file_too_large))
}
@@ -91,7 +91,7 @@ fun BackupCreateErrorRow(
BackupValues.BackupCreationError.NOT_ENOUGH_DISK_SPACE -> {
BackupAlertText {
append(stringResource(R.string.BackupStatusRow__not_enough_disk_space, DateUtils.getDayPrecisionTimeString(context, locale, lastMessageCutoffTime)))
append(stringResource(R.string.BackupStatusRow__not_enough_disk_space, DateUtils.getDayPrecisionTimeString(context, locale.platformLocale, lastMessageCutoffTime)))
}
}
}
@@ -43,6 +43,7 @@ import org.signal.core.models.AccountEntropyPool
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.fonts.MonoTypeface
import org.thoughtcrime.securesms.registration.ui.restore.BackupKeyVisualTransformation
import org.thoughtcrime.securesms.registration.ui.restore.attachBackupKeyAutoFillHelper
@@ -59,6 +60,8 @@ fun EnterKeyScreen(
captionContent: @Composable () -> Unit,
seeKeyButton: @Composable () -> Unit
) {
TemporaryScreenshotSecurity.bind()
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
@@ -24,6 +24,7 @@ import kotlinx.coroutines.withContext
import org.signal.core.util.billing.BillingPurchaseResult
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.signal.core.util.next
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
@@ -45,7 +46,6 @@ import org.thoughtcrime.securesms.jobs.InAppPaymentPurchaseTokenJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.next
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import kotlin.time.Duration.Companion.seconds
@@ -59,6 +59,7 @@ import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.Util
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
import org.thoughtcrime.securesms.fonts.MonoTypeface
@@ -133,6 +134,8 @@ fun MessageBackupsKeyRecordScreen(
mode: MessageBackupsKeyRecordMode = MessageBackupsKeyRecordMode.Next(onNextClick = {}),
notifyKeyIsSameAsOnDeviceBackupKey: Boolean = false
) {
TemporaryScreenshotSecurity.bind()
val snackbarHostState = remember { SnackbarHostState() }
val backupKeyString = remember(backupKey) {
backupKey.chunked(4).joinToString(" ")
@@ -108,9 +108,10 @@ fun VerifyBackupPinScreen(
append(stringResource(id = R.string.VerifyBackupPinScreen__enter_the_backup_key_that_you_recorded))
append(" ")
val supportUrl = stringResource(R.string.remote_backup_support_url)
withLink(
LinkAnnotation.Clickable(tag = "learn-more") {
CommunicationActions.openBrowserLink(context, context.getString(R.string.remote_backup_support_url))
CommunicationActions.openBrowserLink(context, supportUrl)
}
) {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
@@ -10,8 +10,8 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.components.settings.models.SplashImage
@@ -16,7 +16,6 @@ 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
@@ -27,6 +26,7 @@ import androidx.compose.ui.Alignment.Companion.End
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
@@ -43,7 +43,7 @@ import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeDialogFragment
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.isSplitPane
import org.signal.core.ui.rememberIsSplitPane
import org.signal.core.util.BreakIteratorCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsViewModel
@@ -109,7 +109,7 @@ fun EditCallLinkNameScreen(
onNavigationClick = {
backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed()
},
showNavigationIcon = !currentWindowAdaptiveInfo().windowSizeClass.isSplitPane()
showNavigationIcon = !LocalResources.current.rememberIsSplitPane()
)
}
@@ -16,9 +16,8 @@ import androidx.fragment.app.FragmentActivity
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.getParcelableExtraCompat
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.main.MainNavigationCallDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationRouter
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.viewModel
@@ -56,23 +55,17 @@ class CallLinkDetailsActivity : FragmentActivity() {
}
}
private inner class Router : MainNavigationRouter {
override fun goTo(location: MainNavigationDetailLocation) {
private inner class Router : MainNavigationCallDetailRouter {
override fun goToCallDetail(location: MainNavigationDetailLocation.Calls) {
when (location) {
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
EditCallLinkNameDialogFragment().apply {
arguments = bundleOf(EditCallLinkNameDialogFragment.ARG_NAME to viewModel.nameSnapshot)
}.show(supportFragmentManager, null)
}
is MainNavigationDetailLocation.Empty -> {
finishAfterTransition()
}
else -> error("Unsupported route $location")
}
}
override fun goTo(location: MainNavigationListLocation) = Unit
override fun exitDetailLocation() = finishAfterTransition()
}
}
@@ -15,12 +15,12 @@ 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
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.app.ShareCompat
@@ -37,7 +37,7 @@ import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.isSplitPane
import org.signal.core.ui.rememberIsSplitPane
import org.signal.core.util.Util
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.ringrtc.CallLinkState.Restrictions
@@ -46,8 +46,8 @@ import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.YouAreAlrea
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.SignalCallRow
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.main.MainNavigationCallDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationRouter
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
@@ -63,7 +63,7 @@ fun CallLinkDetailsScreen(
viewModel: CallLinkDetailsViewModel = viewModel {
CallLinkDetailsViewModel(roomId)
},
router: MainNavigationRouter = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalActivity.current as ComponentActivity) {
router: MainNavigationCallDetailRouter = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalActivity.current as ComponentActivity) {
error("Should already be created.")
}
) {
@@ -83,14 +83,14 @@ fun CallLinkDetailsScreen(
state = state,
showAlreadyInACall = showAlreadyInACall,
callback = callback,
showNavigationIcon = !currentWindowAdaptiveInfo().windowSizeClass.isSplitPane()
showNavigationIcon = !LocalResources.current.rememberIsSplitPane()
)
}
class DefaultCallLinkDetailsCallback(
private val activity: FragmentActivity,
private val viewModel: CallLinkDetailsViewModel,
private val router: MainNavigationRouter
private val router: MainNavigationCallDetailRouter
) : CallLinkDetailsCallback {
private val lifecycleDisposable = LifecycleDisposable()
@@ -113,7 +113,7 @@ class DefaultCallLinkDetailsCallback(
}
override fun onEditNameClicked() {
router.goTo(MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
router.goToCallDetail(MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
}
override fun onShareClicked() {
@@ -152,7 +152,7 @@ class DefaultCallLinkDetailsCallback(
viewModel.setDisplayRevocationDialog(false)
activity.lifecycleScope.launch {
if (viewModel.delete()) {
router.goTo(MainNavigationDetailLocation.Empty)
router.exitDetailLocation()
}
}
}
@@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil
import java.util.concurrent.Executor
import kotlin.math.max
import kotlin.math.min
@@ -67,7 +68,7 @@ class CallEventCache(
val output = mutableListOf<CallLogRow.Call>()
val groupCallStateMap = mutableMapOf<Long, CallLogRow.GroupCallState>()
val canUserBeginCallMap = mutableMapOf<Long, Boolean>()
val canUserBeginCallMap = mutableMapOf<Long, CallLogRow.CanStartCall>()
val callLinksSeen = hashSetOf<Long>()
while (recordIterator.hasNext()) {
@@ -85,7 +86,7 @@ class CallEventCache(
private fun ListIterator<CacheRecord>.readNextCallLog(
filterState: FilterState,
groupCallStateMap: MutableMap<Long, CallLogRow.GroupCallState>,
canUserBeginCallMap: MutableMap<Long, Boolean>,
canUserBeginCallMap: MutableMap<Long, CallLogRow.CanStartCall>,
callLinksSeen: MutableSet<Long>
): CallLogRow.Call? {
val parent = next()
@@ -143,14 +144,16 @@ class CallEventCache(
return (child.timestamp - parent.timestamp) <= 4.hours.inWholeMilliseconds
}
private fun canUserBeginCall(peer: Recipient, decryptedGroup: ByteArray?): Boolean {
return if (peer.isGroup && decryptedGroup != null) {
private fun canUserBeginCall(peer: Recipient, decryptedGroup: ByteArray?): CallLogRow.CanStartCall {
if (peer.isGroup && decryptedGroup != null) {
val proto = DecryptedGroup.ADAPTER.decode(decryptedGroup)
return proto.isAnnouncementGroup != EnabledState.ENABLED ||
proto.members.firstOrNull() { it.aciBytes == SignalStore.account.aci?.toByteString() }?.role == Member.Role.ADMINISTRATOR
} else {
true
when {
proto.terminated -> return CallLogRow.CanStartCall.GROUP_TERMINATED
DecryptedGroupUtil.findMemberByAci(proto.members, SignalStore.account.requireAci()).isEmpty -> return CallLogRow.CanStartCall.NOT_A_MEMBER
proto.isAnnouncementGroup == EnabledState.ENABLED && proto.members.firstOrNull { it.aciBytes == SignalStore.account.aci?.toByteString() }?.role != Member.Role.ADMINISTRATOR -> return CallLogRow.CanStartCall.ADMIN_ONLY
}
}
return CallLogRow.CanStartCall.ALLOWED
}
private fun getGroupCallState(body: String?): CallLogRow.GroupCallState {
@@ -167,7 +170,7 @@ class CallEventCache(
children: Set<Long>,
filterState: FilterState,
groupCallStateCache: MutableMap<Long, CallLogRow.GroupCallState>,
canUserBeginCallMap: MutableMap<Long, Boolean>
canUserBeginCallMap: MutableMap<Long, CallLogRow.CanStartCall>
): CallLogRow.Call {
val peer = Recipient.resolved(RecipientId.from(parent.peer))
return CallLogRow.Call(
@@ -195,10 +198,10 @@ class CallEventCache(
searchQuery = filterState.query,
callLinkPeekInfo = AppDependencies.signalCallManager.peekInfoSnapshot[peer.id],
canUserBeginCall = if (peer.isGroup) {
if (peer.isActiveGroup) {
canUserBeginCallMap.getOrPut(parent.peer) { canUserBeginCall(peer, parent.decryptedGroupBytes) }
} else false
} else true
canUserBeginCallMap.getOrPut(parent.peer) { canUserBeginCall(peer, parent.decryptedGroupBytes) }
} else {
CallLogRow.CanStartCall.ALLOWED
}
)
}
@@ -223,7 +223,7 @@ class CallLogAdapter(
binding: CallLogAdapterItemBinding,
private val onCallLinkClicked: (CallLogRow.CallLink) -> Unit,
private val onCallLinkLongClicked: (View, CallLogRow.CallLink) -> Boolean,
private val onStartVideoCallClicked: (Recipient, Boolean) -> Unit
private val onStartVideoCallClicked: (Recipient, CallLogRow.CanStartCall) -> Unit
) : BindingViewHolder<CallLinkModel, CallLogAdapterItemBinding>(binding) {
override fun bind(model: CallLinkModel) {
if (payload.size == 1 && payload.contains(PAYLOAD_TIMESTAMP)) {
@@ -280,7 +280,7 @@ class CallLogAdapter(
}
)
binding.groupCallButton.setOnClickListener {
onStartVideoCallClicked(model.callLink.recipient, true)
onStartVideoCallClicked(model.callLink.recipient, CallLogRow.CanStartCall.ALLOWED)
}
binding.callType.visible = false
binding.groupCallButton.visible = true
@@ -288,7 +288,7 @@ class CallLogAdapter(
binding.callType.setImageResource(R.drawable.symbol_video_24)
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
binding.callType.setOnClickListener {
onStartVideoCallClicked(model.callLink.recipient, true)
onStartVideoCallClicked(model.callLink.recipient, CallLogRow.CanStartCall.ALLOWED)
}
binding.callType.visible = true
binding.groupCallButton.visible = false
@@ -301,7 +301,7 @@ class CallLogAdapter(
private val onCallClicked: (CallLogRow.Call) -> Unit,
private val onCallLongClicked: (View, CallLogRow.Call) -> Boolean,
private val onStartAudioCallClicked: (Recipient) -> Unit,
private val onStartVideoCallClicked: (Recipient, Boolean) -> Unit
private val onStartVideoCallClicked: (Recipient, CallLogRow.CanStartCall) -> Unit
) : BindingViewHolder<CallModel, CallLogAdapterItemBinding>(binding) {
override fun bind(model: CallModel) {
itemView.setOnClickListener {
@@ -324,13 +324,16 @@ class CallLogAdapter(
return
}
presentRecipientDetails(model.call.peer, model.call.searchQuery)
presentRecipientDetails(model.call)
presentCallInfo(model.call, model.call.date)
presentCallType(model)
}
private fun presentRecipientDetails(recipient: Recipient, searchQuery: String?) {
binding.callRecipientAvatar.setAvatar(Glide.with(binding.callRecipientAvatar), recipient, true)
private fun presentRecipientDetails(call: CallLogRow.Call) {
val recipient = call.peer
val searchQuery = call.searchQuery
binding.callRecipientAvatar.setAvatar(Glide.with(binding.callRecipientAvatar), recipient, false)
binding.callRecipientAvatar.setOnClickListener { onCallClicked(call) }
binding.callRecipientBadge.setBadgeFromRecipient(recipient)
binding.callRecipientName.text = if (searchQuery != null) {
SearchUtil.getHighlightedSpan(
@@ -401,7 +404,7 @@ class CallLogAdapter(
CallTable.Type.VIDEO_CALL -> {
binding.callType.setImageResource(R.drawable.symbol_video_24)
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer, true) }
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer, CallLogRow.CanStartCall.ALLOWED) }
binding.callType.visible = true
binding.groupCallButton.visible = false
}
@@ -574,6 +577,6 @@ class CallLogAdapter(
/**
* Invoked when user presses the video icon
*/
fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: Boolean)
fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: CallLogRow.CanStartCall)
}
}
@@ -23,7 +23,6 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.coroutines.launch
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.getWindowSizeClass
import org.signal.core.ui.isSplitPane
import org.signal.core.util.DimensionUnit
import org.signal.core.util.concurrent.LifecycleDisposable
@@ -133,7 +132,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
val filteredCount = callLogAdapter.submitCallRows(
data,
selected,
activeCallLogRowId = activeRowId.orNull().takeIf { resources.getWindowSizeClass().isSplitPane() },
activeCallLogRowId = activeRowId.orNull().takeIf { resources.isSplitPane() },
viewModel.callLogPeekHelper.localDeviceCallRecipientId,
scrollToPositionDelegate::notifyListCommitted
)
@@ -187,7 +186,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
}
if (!resources.getWindowSizeClass().isSplitPane()) {
if (!resources.isSplitPane()) {
ViewUtil.setBottomMargin(binding.bottomActionBar, ViewUtil.getNavigationBarHeight(binding.bottomActionBar))
}
@@ -334,7 +333,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
viewModel.toggleSelected(callLogRow.id)
} else {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails(callLogRow.record.roomId))
mainNavigationViewModel.goTo(MainNavigationDetailLocation.CallLinkDetails(callLogRow.record.roomId))
}
}
@@ -364,18 +363,21 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
}
override fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: Boolean) {
if (canUserBeginCall) {
CommunicationActions.startVideoCall(this, recipient) {
mainNavigationViewModel.snackbarRegistry.emit(
SnackbarState(
getString(R.string.CommunicationActions__you_are_already_in_a_call),
hostKey = MainSnackbarHostKey.MainChrome
override fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: CallLogRow.CanStartCall) {
when (canUserBeginCall) {
CallLogRow.CanStartCall.ALLOWED -> {
CommunicationActions.startVideoCall(this, recipient) {
mainNavigationViewModel.snackbarRegistry.emit(
SnackbarState(
getString(R.string.CommunicationActions__you_are_already_in_a_call),
hostKey = MainSnackbarHostKey.MainChrome
)
)
)
}
}
} else {
ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
CallLogRow.CanStartCall.GROUP_TERMINATED -> ConversationDialogs.displayCannotStartGroupCallDueToGroupEndedDialog(requireContext())
CallLogRow.CanStartCall.NOT_A_MEMBER -> ConversationDialogs.displayCannotStartGroupCallDueToNoLongerAMemberDialog(requireContext())
CallLogRow.CanStartCall.ADMIN_ONLY -> ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
}
}
@@ -41,7 +41,7 @@ sealed class CallLogRow {
val children: Set<Long>,
val searchQuery: String?,
val callLinkPeekInfo: CallLinkPeekInfo?,
val canUserBeginCall: Boolean,
val canUserBeginCall: CanStartCall,
override val id: Id = Id.Call(children)
) : CallLogRow()
@@ -111,4 +111,11 @@ sealed class CallLogRow {
}
}
}
enum class CanStartCall {
ALLOWED,
ADMIN_ONLY,
NOT_A_MEMBER,
GROUP_TERMINATED
}
}
@@ -25,7 +25,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
@@ -214,7 +213,8 @@ private fun UserMessagesHost(
onDismiss: (UserMessage) -> Unit,
snackbarHostState: SnackbarHostState
) {
val context = LocalContext.current
val youAreAlreadyInACall = stringResource(R.string.CommunicationActions__you_are_already_in_a_call)
val errorRetrievingContacts = stringResource(R.string.ContactSelectionListFragment_error_retrieving_contacts_check_your_network_connection)
when (userMessage) {
null -> {}
@@ -228,14 +228,14 @@ private fun UserMessagesHost(
is UserMessage.UserAlreadyInAnotherCall -> LaunchedEffect(userMessage) {
snackbarHostState.showSnackbar(
message = context.getString(R.string.CommunicationActions__you_are_already_in_a_call)
message = youAreAlreadyInACall
)
onDismiss(userMessage)
}
is UserMessage.ContactsRefreshFailed -> LaunchedEffect(userMessage) {
snackbarHostState.showSnackbar(
message = context.getString(R.string.ContactSelectionListFragment_error_retrieving_contacts_check_your_network_connection)
message = errorRetrievingContacts
)
onDismiss(userMessage)
}
@@ -72,6 +72,7 @@ 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
import org.signal.core.ui.R as CoreUiR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -413,7 +414,7 @@ private fun IssueChip(
leadingIcon = {
Icon(
imageVector = if (isSelected) {
ImageVector.vectorResource(R.drawable.symbol_check_24)
ImageVector.vectorResource(CoreUiR.drawable.symbol_check_24)
} else {
ImageVector.vectorResource(issue.category.icon)
},
@@ -1,69 +0,0 @@
package org.thoughtcrime.securesms.color;
import android.content.Context;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class MaterialColors {
public static final MaterialColorList CONVERSATION_PALETTE = new MaterialColorList(new ArrayList<>(Arrays.asList(
MaterialColor.PLUM,
MaterialColor.CRIMSON,
MaterialColor.VERMILLION,
MaterialColor.VIOLET,
MaterialColor.INDIGO,
MaterialColor.TAUPE,
MaterialColor.ULTRAMARINE,
MaterialColor.BLUE,
MaterialColor.TEAL,
MaterialColor.FOREST,
MaterialColor.WINTERGREEN,
MaterialColor.BURLAP,
MaterialColor.STEEL
)));
public static class MaterialColorList {
private final List<MaterialColor> colors;
private MaterialColorList(List<MaterialColor> colors) {
this.colors = colors;
}
public MaterialColor get(int index) {
return colors.get(index);
}
public int size() {
return colors.size();
}
public @Nullable MaterialColor getByColor(Context context, int colorValue) {
for (MaterialColor color : colors) {
if (color.represents(context, colorValue)) {
return color;
}
}
return null;
}
public @ColorInt int[] asConversationColorArray(@NonNull Context context) {
int[] results = new int[colors.size()];
int index = 0;
for (MaterialColor color : colors) {
results[index++] = color.toConversationColor(context);
}
return results;
}
}
}
@@ -63,6 +63,7 @@ public final class AudioView extends FrameLayout {
@NonNull private final AnimatingToggle controlToggle;
@NonNull private final View progressAndPlay;
@NonNull private final LottieAnimationView playPauseButton;
@NonNull private final View downloadContainer;
@NonNull private final ImageView downloadButton;
@Nullable private final ProgressWheel circleProgress;
@NonNull private final SeekBar seekBar;
@@ -121,13 +122,14 @@ public final class AudioView extends FrameLayout {
throw new IllegalStateException("Unsupported mode: " + mode);
}
this.controlToggle = findViewById(R.id.control_toggle);
this.playPauseButton = findViewById(R.id.play);
this.progressAndPlay = findViewById(R.id.progress_and_play);
this.downloadButton = findViewById(R.id.download);
this.circleProgress = findViewById(R.id.circle_progress);
this.seekBar = findViewById(R.id.seek);
this.duration = findViewById(R.id.duration);
this.controlToggle = findViewById(R.id.control_toggle);
this.playPauseButton = findViewById(R.id.play);
this.progressAndPlay = findViewById(R.id.progress_and_play);
this.downloadContainer = findViewById(R.id.download_container);
this.downloadButton = findViewById(R.id.download);
this.circleProgress = findViewById(R.id.circle_progress);
this.seekBar = findViewById(R.id.seek);
this.duration = findViewById(R.id.duration);
lottieDirection = REVERSE;
this.playPauseButton.setOnClickListener(new PlayPauseClickedListener());
@@ -168,6 +170,7 @@ public final class AudioView extends FrameLayout {
public void setProgressAndPlayBackgroundTint(@ColorInt int color) {
progressAndPlay.getBackground().setColorFilter(color, PorterDuff.Mode.SRC_IN);
downloadContainer.getBackground().setColorFilter(color, PorterDuff.Mode.SRC_IN);
}
public Observer<VoiceNotePlaybackState> getPlaybackStateObserver() {
@@ -195,7 +198,7 @@ public final class AudioView extends FrameLayout {
}
if (showControls && audio.isPendingDownload()) {
controlToggle.displayQuick(downloadButton);
controlToggle.displayQuick(downloadContainer);
seekBar.setEnabled(false);
downloadButton.setOnClickListener(new DownloadClickedListener(audio));
if (circleProgress != null) {
@@ -30,11 +30,12 @@ import com.bumptech.glide.request.target.SimpleTarget;
import com.bumptech.glide.request.target.Target;
import com.bumptech.glide.request.transition.Transition;
import org.signal.core.util.ContextUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar;
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatarDrawable;
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity;
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavigator;
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.SystemContactPhoto;
@@ -53,9 +54,7 @@ import java.util.List;
import java.util.Objects;
public final class AvatarImageView extends AppCompatImageView {
private static final int SIZE_LARGE = 1;
private static final int SIZE_SMALL = 2;
@SuppressWarnings("unused")
private static final String TAG = Log.tag(AvatarImageView.class);
@@ -156,10 +155,10 @@ public final class AvatarImageView extends AppCompatImageView {
public void setAvatar(@NonNull RequestManager requestManager, @Nullable Recipient recipient, boolean quickContactEnabled, boolean useSelfProfileAvatar, boolean useBlurGradient) {
setAvatar(requestManager, recipient, new AvatarOptions.Builder(this)
.withUseSelfProfileAvatar(useSelfProfileAvatar)
.withQuickContactEnabled(quickContactEnabled)
.withUseBlurGradient(useBlurGradient)
.build());
.withUseSelfProfileAvatar(useSelfProfileAvatar)
.withQuickContactEnabled(quickContactEnabled)
.withUseBlurGradient(useBlurGradient)
.build());
}
private void setAvatar(@Nullable Recipient recipient, @NonNull AvatarOptions avatarOptions) {
@@ -182,8 +181,8 @@ public final class AvatarImageView extends AppCompatImageView {
if (!photo.equals(recipientContactPhoto) || shouldBlur != blurred || !Objects.equals(chatColors, this.chatColors) || !Objects.equals(initials, this.initials)) {
requestManager.clear(this);
this.chatColors = chatColors;
this.initials = initials;
this.chatColors = chatColors;
this.initials = initials;
recipientContactPhoto = photo;
FallbackAvatarProvider activeFallbackPhotoProvider = this.fallbackAvatarProvider;
@@ -215,12 +214,12 @@ public final class AvatarImageView extends AppCompatImageView {
List<Transformation<Bitmap>> transforms = Collections.singletonList(new CircleCrop());
RequestBuilder<Drawable> request = requestManager.load(photo.contactPhoto)
.fallback(fallback)
.error(fallback)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.downsample(DownsampleStrategy.CENTER_INSIDE)
.transform(new MultiTransformation<>(transforms))
.addListener(redownloadRequestListener);
.fallback(fallback)
.error(fallback)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.downsample(DownsampleStrategy.CENTER_INSIDE)
.transform(new MultiTransformation<>(transforms))
.addListener(redownloadRequestListener);
if (wasUnblurred) {
blurred = shouldBlur;
@@ -260,17 +259,12 @@ public final class AvatarImageView extends AppCompatImageView {
private void setAvatarClickHandler(@NonNull final Recipient recipient, boolean quickContactEnabled) {
if (quickContactEnabled) {
super.setOnClickListener(v -> {
Context context = getContext();
FragmentActivity activity = ContextUtil.requireFragmentActivity(getContext());
if (recipient.isPushGroup()) {
context.startActivity(ConversationSettingsActivity.forGroup(context, recipient.requireGroupId().requirePush()),
ConversationSettingsActivity.createTransitionBundle(context, this));
ConversationSettingsNavigator.navigate(activity, recipient);
} else {
if (context instanceof FragmentActivity) {
RecipientBottomSheetDialogFragment.show(((FragmentActivity) context).getSupportFragmentManager(), recipient.getId(), null);
} else {
context.startActivity(ConversationSettingsActivity.forRecipient(context, recipient.getId()),
ConversationSettingsActivity.createTransitionBundle(context, this));
}
RecipientBottomSheetDialogFragment.show(activity.getSupportFragmentManager(), recipient.getId(), null);
}
});
} else {
@@ -283,13 +277,13 @@ public final class AvatarImageView extends AppCompatImageView {
Drawable fallback = new FallbackAvatarDrawable(getContext(), new FallbackAvatar.Resource.Group(color)).circleCrop();
Glide.with(this)
.load(avatarBytes)
.dontAnimate()
.fallback(fallback)
.error(fallback)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.circleCrop()
.into(this);
.load(avatarBytes)
.dontAnimate()
.fallback(fallback)
.error(fallback)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.circleCrop()
.into(this);
}
public void setNonAvatarImageResource(@DrawableRes int imageResource) {
@@ -308,7 +302,8 @@ public final class AvatarImageView extends AppCompatImageView {
}
}
private static class DefaultFallbackAvatarProvider implements FallbackAvatarProvider {}
private static class DefaultFallbackAvatarProvider implements FallbackAvatarProvider {
}
private static class RecipientContactPhoto {
@@ -4,7 +4,6 @@ import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Canvas;
import android.os.Build;
import android.os.Bundle;
import android.text.Annotation;
import android.text.Editable;
import android.text.Selection;
@@ -26,9 +25,6 @@ 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;
import androidx.core.view.inputmethod.InputContentInfoCompat;
import org.signal.core.util.StringUtil;
import org.signal.core.util.logging.Log;
@@ -69,7 +65,6 @@ public class ComposeText extends EmojiEditText {
private MentionValidatorWatcher mentionValidatorWatcher;
private MessageSendType lastMessageSendType;
@Nullable private InputPanel.MediaListener mediaListener;
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
@Nullable private InlineQueryChangedListener inlineQueryChangedListener;
@Nullable private StylingChangedListener stylingChangedListener;
@@ -247,20 +242,7 @@ public class ComposeText extends EmojiEditText {
editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
}
if (mediaListener == null) {
return inputConnection;
}
if (inputConnection == null) {
return null;
}
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));
}
public void setMediaListener(@Nullable InputPanel.MediaListener mediaListener) {
this.mediaListener = mediaListener;
return inputConnection;
}
public boolean hasMentions() {
@@ -577,38 +559,6 @@ public class ComposeText extends EmojiEditText {
return true;
}
private static class CommitContentListener implements InputConnectionCompat.OnCommitContentListener {
private static final String TAG = Log.tag(CommitContentListener.class);
private final InputPanel.MediaListener mediaListener;
private CommitContentListener(@NonNull InputPanel.MediaListener mediaListener) {
this.mediaListener = mediaListener;
}
@Override
public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) {
if (Build.VERSION.SDK_INT >= 25 && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
try {
inputContentInfo.requestPermission();
} catch (Exception e) {
Log.w(TAG, e);
return false;
}
}
if (inputContentInfo.getDescription().getMimeTypeCount() > 0) {
mediaListener.onMediaSelected(inputContentInfo.getContentUri(),
inputContentInfo.getDescription().getMimeType(0));
return true;
}
return false;
}
}
private static class QueryStart {
public int index;
public boolean isMentionQuery;
@@ -254,7 +254,11 @@ public class ConversationItemFooter extends ConstraintLayout {
}
});
dateView.setMaxWidth(ViewUtil.dpToPx(32));
ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) dateView.getLayoutParams();
params.startToEnd = R.id.footer_audio_playback_speed_toggle;
params.constrainedWidth = true;
params.horizontalBias = 1f;
dateView.setLayoutParams(params);
}
private void hidePlaybackSpeedToggle() {
@@ -276,7 +280,11 @@ public class ConversationItemFooter extends ConstraintLayout {
}
});
dateView.setMaxWidth(Integer.MAX_VALUE);
ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) dateView.getLayoutParams();
params.startToEnd = ConstraintLayout.LayoutParams.UNSET;
params.constrainedWidth = false;
params.horizontalBias = 0.5f;
dateView.setLayoutParams(params);
}
private @NonNull Rect getPlaybackSpeedToggleTouchDelegateRect() {
@@ -9,10 +9,10 @@ import androidx.annotation.DrawableRes;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import org.signal.core.util.ContextUtil;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.SimpleEmojiTextView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SpanUtil;
@@ -5,7 +5,6 @@ import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.drawable.ColorDrawable;
import android.hardware.Camera;
import android.net.Uri;
import android.text.SpannableString;
import android.text.format.DateUtils;
import android.util.AttributeSet;
@@ -208,10 +207,6 @@ public class InputPanel extends ConstraintLayout
}
}
public void setMediaListener(@NonNull MediaListener listener) {
composeText.setMediaListener(listener);
}
public void setQuote(@NonNull RequestManager requestManager,
long id,
@NonNull Recipient author,
@@ -954,8 +949,4 @@ public class InputPanel extends ConstraintLayout
};
}
}
public interface MediaListener {
void onMediaSelected(@NonNull Uri uri, String contentType);
}
}
@@ -152,7 +152,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
val isLtr = ViewUtil.isLtr(this)
val statusBar = windowInsets.top
val navigationBar = navigationBarInsetOverride ?: if (windowInsets.bottom == 0 && Build.VERSION.SDK_INT <= 29) {
val navigationBar = navigationBarInsetOverride ?: if (windowInsets.bottom == 0 && Build.VERSION.SDK_INT <= 29 && !ViewUtil.isGestureNavigation(resources, insets)) {
ViewUtil.getNavigationBarHeight(resources)
} else {
windowInsets.bottom
@@ -289,8 +289,7 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
QuoteViewColorTheme colorTheme = getColorTheme();
int foregroundColor = colorTheme.getForegroundColor(getContext());
authorView.setSender(name, foregroundColor);
authorView.setLabel(memberLabel, foregroundColor, colorTheme.getLabelBackgroundColor(getContext()));
authorView.bind(name, foregroundColor, memberLabel, foregroundColor, colorTheme.getLabelBackgroundColor(getContext()));
}
private boolean isStoryReply() {
@@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.components
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.Shader
import android.graphics.drawable.Drawable
/**
* Draws [bitmap] as a repeating tiled pattern rotated by [rotationDegrees].
*/
class RotatedTiledDrawable(
private val bitmap: Bitmap,
private val rotationDegrees: Float
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
shader = android.graphics.BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
}
override fun onBoundsChange(bounds: android.graphics.Rect) {
paint.shader.setLocalMatrix(
Matrix().apply { setRotate(rotationDegrees, bounds.exactCenterX(), bounds.exactCenterY()) }
)
}
override fun draw(canvas: Canvas) {
canvas.drawRect(bounds, paint)
}
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
invalidateSelf()
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
invalidateSelf()
}
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
}
@@ -16,15 +16,14 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import com.annimon.stream.Stream;
import com.bumptech.glide.RequestManager;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.signal.glide.decryptableuri.DecryptableUri;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.contactshare.ContactUtil;
import org.thoughtcrime.securesms.database.RecipientTable;
import org.signal.glide.decryptableuri.DecryptableUri;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
@@ -116,7 +115,7 @@ public class SharedContactView extends LinearLayout implements RecipientForeverO
this.locale = locale;
this.contact = contact;
Stream.of(activeRecipients.values()).forEach(recipient -> recipient.removeForeverObserver(this));
activeRecipients.values().stream().forEach(recipient -> recipient.removeForeverObserver(this));
this.activeRecipients.clear();
presentContact(contact);
@@ -5,18 +5,41 @@
package org.thoughtcrime.securesms.components
import android.view.Window
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.fragment.app.Fragment
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import org.thoughtcrime.securesms.util.TextSecurePreferences
import java.util.WeakHashMap
/**
* Applies temporary screenshot security for the given component lifecycle.
*
* Multiple callers can request security on the same window concurrently; the
* flag is only cleared once every caller has released its hold.
*/
object TemporaryScreenshotSecurity {
private val activeHolds = WeakHashMap<Window, Int>()
@Composable
fun bind() {
val activity = LocalActivity.current as? ComponentActivity ?: return
DisposableEffect(activity) {
acquire(activity)
onDispose {
release(activity)
}
}
}
@JvmStatic
fun bindToViewLifecycleOwner(fragment: Fragment) {
val observer = LifecycleObserver { fragment.requireActivity() }
@@ -31,21 +54,37 @@ object TemporaryScreenshotSecurity {
activity.lifecycle.addObserver(observer)
}
private fun acquire(activity: ComponentActivity) {
val window = activity.window
val previous = activeHolds[window] ?: 0
activeHolds[window] = previous + 1
if (previous == 0 && !TextSecurePreferences.isScreenSecurityEnabled(activity)) {
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
private fun release(activity: ComponentActivity) {
val window = activity.window
val next = ((activeHolds[window] ?: 0) - 1).coerceAtLeast(0)
if (next == 0) {
activeHolds.remove(window)
if (!TextSecurePreferences.isScreenSecurityEnabled(activity)) {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
} else {
activeHolds[window] = next
}
}
private class LifecycleObserver(
private val activityProvider: () -> ComponentActivity
) : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
val activity = activityProvider()
if (!TextSecurePreferences.isScreenSecurityEnabled(activity)) {
activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
acquire(activityProvider())
}
override fun onPause(owner: LifecycleOwner) {
val activity = activityProvider()
if (!TextSecurePreferences.isScreenSecurityEnabled(activity)) {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
release(activityProvider())
}
}
}
@@ -7,13 +7,12 @@ import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.Util;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.signal.core.util.Util;
import java.util.ArrayList;
import java.util.Collections;
@@ -140,7 +139,7 @@ public class TypingStatusRepository {
notifier.postValue(new TypingState(new ArrayList<>(uniqueTypists), isReplacedByIncomingMessage));
Set<Long> activeThreads = Stream.of(typistMap.keySet()).filter(t -> !typistMap.get(t).isEmpty()).collect(Collectors.toSet());
Set<Long> activeThreads = typistMap.keySet().stream().filter(t -> !typistMap.get(t).isEmpty()).collect(Collectors.toSet());
threadsNotifier.postValue(activeThreads);
}
@@ -69,12 +69,14 @@ fun <Reason> SendSupportEmailEffect(
filterRes: ContactSupportCallbacks.StringForReason<Reason>,
hide: () -> Unit
) {
val subject = stringResource(subjectRes(contactSupportState.reason))
val helpDebugLog = stringResource(R.string.HelpFragment__debug_log)
val context = LocalContext.current
LaunchedEffect(contactSupportState.sendEmail) {
if (contactSupportState.sendEmail) {
val subject = context.getString(subjectRes(contactSupportState.reason))
val prefix = if (contactSupportState.debugLogUrl != null) {
"\n${context.getString(R.string.HelpFragment__debug_log)} ${contactSupportState.debugLogUrl}\n\n"
"\n$helpDebugLog ${contactSupportState.debugLogUrl}\n\n"
} else {
""
}
@@ -15,11 +15,11 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.LinearSmoothScroller;
import androidx.recyclerview.widget.RecyclerView;
import org.signal.core.util.ContextUtil;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiHeader;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiNoResultsModel;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
@@ -243,7 +243,11 @@ public class EmojiTextView extends AppCompatTextView {
return;
}
textView.setPrecomputedText(precomputedTextCompat);
try {
textView.setPrecomputedText(precomputedTextCompat);
} catch (IllegalArgumentException e) {
textView.setText(text, type);
}
if (textView.sizeChangeInProgress) {
textView.sizeChangeInProgress = false;
@@ -1,17 +1,15 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatImageButton;
import org.signal.core.util.ContextUtil;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
public class EmojiToggle extends AppCompatImageButton implements MediaKeyboard.MediaKeyboardListener {
@@ -32,14 +32,14 @@ import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Applies Signal or System emoji to the given content based off user settings.
* Applies Signal or System emoji to the given content based on user settings.
*
* Text is transformed and passed to content as an annotated string and inline content map.
*/
@Composable
fun Emojifier(
text: String,
useSystemEmoji: Boolean = !LocalInspectionMode.current && SignalStore.settings.isPreferSystemEmoji,
useSystemEmoji: Boolean = LocalInspectionMode.current || SignalStore.settings.isPreferSystemEmoji,
content: @Composable (AnnotatedString, Map<String, InlineTextContent>) -> Unit = { annotatedText, inlineContent ->
Text(
text = annotatedText,
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.ContextThemeWrapper;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import android.widget.FrameLayout;
@@ -15,6 +14,7 @@ import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import org.signal.core.util.ContextUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.InputAwareLayout.InputView;
@@ -29,12 +29,12 @@ public class MediaKeyboard extends FrameLayout implements InputView {
private static final String EMOJI_SEARCH = "emoji_search_fragment";
@Nullable private MediaKeyboardListener keyboardListener;
private boolean isInitialised;
private int latestKeyboardHeight;
private State keyboardState;
private KeyboardPagerFragment keyboardPagerFragment;
private FragmentManager fragmentManager;
private int mediaKeyboardTheme;
private boolean isInitialised;
private int latestKeyboardHeight;
private State keyboardState;
private KeyboardPagerFragment keyboardPagerFragment;
private FragmentManager fragmentManager;
private int mediaKeyboardTheme;
public MediaKeyboard(Context context) {
this(context, null);
@@ -175,7 +175,7 @@ public class MediaKeyboard extends FrameLayout implements InputView {
LayoutInflater.from(getContext()).inflate(R.layout.media_keyboard, this, true);
if (fragmentManager == null) {
FragmentActivity activity = resolveActivity(getContext());
FragmentActivity activity = ContextUtil.requireFragmentActivity(getContext());
fragmentManager = activity.getSupportFragmentManager();
}
@@ -188,25 +188,17 @@ public class MediaKeyboard extends FrameLayout implements InputView {
.replace(R.id.media_keyboard_fragment_container, keyboardPagerFragment, TAG)
.commitNowAllowingStateLoss();
keyboardState = State.NORMAL;
latestKeyboardHeight = -1;
isInitialised = true;
}
}
private static FragmentActivity resolveActivity(@Nullable Context context) {
if (context instanceof FragmentActivity) {
return (FragmentActivity) context;
} else if (context instanceof ContextThemeWrapper) {
return resolveActivity(((ContextThemeWrapper) context).getBaseContext());
} else {
throw new IllegalStateException("Could not locate FragmentActivity");
keyboardState = State.NORMAL;
latestKeyboardHeight = -1;
isInitialised = true;
}
}
public interface MediaKeyboardListener {
void onShown();
void onHidden();
void onKeyboardChanged(@NonNull KeyboardPage page);
}
@@ -9,7 +9,7 @@ import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.type.CollectionType;
import com.fasterxml.jackson.databind.type.TypeFactory;
@@ -72,7 +72,7 @@ public class RecentEmojiPageModel implements EmojiPageModel {
}
@Override public List<Emoji> getDisplayEmoji() {
return Stream.of(getEmoji()).map(Emoji::new).toList();
return getEmoji().stream().map(Emoji::new).collect(Collectors.toList());
}
@Override public @Nullable Uri getSpriteUri() {
@@ -8,13 +8,14 @@ import android.text.Spanned;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.Collections;
import java.util.List;
import java.util.stream.Stream;
/**
* This wraps an Android standard {@link Annotation} so it can leverage the built in
@@ -51,13 +52,12 @@ public final class MentionAnnotation {
public static @NonNull List<Mention> getMentionsFromAnnotations(@Nullable CharSequence text) {
if (text instanceof Spanned) {
Spanned spanned = (Spanned) text;
return Stream.of(getMentionAnnotations(spanned))
.map(annotation -> {
return getMentionAnnotations(spanned).stream()
.map(annotation -> {
int spanStart = spanned.getSpanStart(annotation);
int spanLength = spanned.getSpanEnd(annotation) - spanStart;
return new Mention(RecipientId.from(annotation.getValue()), spanStart, spanLength);
})
.toList();
}).collect(Collectors.toList());
}
return Collections.emptyList();
}
@@ -68,7 +68,6 @@ public final class MentionAnnotation {
public static @NonNull List<Annotation> getMentionAnnotations(@NonNull Spanned spanned, int start, int end) {
return Stream.of(spanned.getSpans(start, end, Annotation.class))
.filter(MentionAnnotation::isMentionAnnotation)
.toList();
.filter(MentionAnnotation::isMentionAnnotation).collect(Collectors.toList());
}
}
@@ -11,9 +11,9 @@ import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.core.graphics.drawable.DrawableCompat;
import org.signal.core.util.ContextUtil;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.signal.core.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -83,7 +83,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
)
AppSettingsRoute.ChatsRoute.Chats -> AppSettingsFragmentDirections.actionDirectToChatsSettingsFragment()
AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment()
is AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment().setLaunchCheckoutFlow(appSettingsRoute.launchCheckoutFlow)
AppSettingsRoute.Invite -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
else -> error("Unsupported start location: ${appSettingsRoute?.javaClass?.name}")
@@ -233,7 +233,8 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
}
@JvmStatic
fun backupsSettings(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Backups)
@JvmOverloads
fun backupsSettings(context: Context, launchCheckoutFlow: Boolean = false): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Backups(launchCheckoutFlow = launchCheckoutFlow))
@JvmStatic
fun invite(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.Invite)
@@ -417,7 +417,7 @@ private fun AppSettingsContent(
icon = SignalIcons.Backup.imageVector,
text = stringResource(R.string.preferences_chats__backups),
onClick = {
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups)
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups())
},
onLongClick = {
callbacks.copyRemoteBackupsSubscriberIdToClipboard()
@@ -31,6 +31,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
@@ -38,6 +39,7 @@ import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.coroutines.delay
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeFragment
@@ -62,6 +64,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.math.BigDecimal
import java.util.Currency
import java.util.Locale
import kotlin.getValue
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.minutes
@@ -76,11 +79,16 @@ class BackupsSettingsFragment : ComposeFragment() {
private lateinit var checkoutLauncher: ActivityResultLauncher<MessageBackupTier?>
private val viewModel: BackupsSettingsViewModel by viewModels()
private val args: BackupsSettingsFragmentArgs by navArgs()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
checkoutLauncher = createBackupsCheckoutLauncher {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_remoteBackupsSettingsFragment)
}
if (savedInstanceState == null && args.launchCheckoutFlow) {
checkoutLauncher.launch(null)
}
}
@Composable
@@ -500,6 +508,7 @@ private fun ActiveBackupsRow(
style = MaterialTheme.typography.bodyLarge
)
val locale = LocalLocale.current.platformLocale
when (val type = backupState.messageBackupsType) {
is MessageBackupsType.Paid -> {
val body = if (backupState is BackupState.Canceled) {
@@ -507,13 +516,13 @@ private fun ActiveBackupsRow(
} else if (type.pricePerMonth.amount == BigDecimal.ZERO) {
stringResource(
R.string.BackupsSettingsFragment_renews_s,
DateUtils.formatDateWithYear(Locale.getDefault(), backupState.renewalTime.inWholeMilliseconds)
DateUtils.formatDateWithYear(locale, backupState.renewalTime.inWholeMilliseconds)
)
} else {
stringResource(
R.string.BackupsSettingsFragment_s_month_renews_s,
FiatMoneyUtil.format(LocalContext.current.resources, type.pricePerMonth),
DateUtils.formatDateWithYear(Locale.getDefault(), backupState.renewalTime.inWholeMilliseconds)
DateUtils.formatDateWithYear(locale, backupState.renewalTime.inWholeMilliseconds)
)
}
@@ -4,6 +4,7 @@
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.widget.Toast
@@ -176,6 +177,7 @@ class LocalBackupsFragment : ComposeFragment() {
}
}
@SuppressLint("LocalContextGetResourceValueCall")
@Composable
private fun rememberChooseBackupLocationLauncher(backStack: NavBackStack<NavKey>): ActivityResultLauncher<Uri?> {
val context = LocalContext.current
@@ -233,7 +233,15 @@ internal fun LocalBackupsSettingsScreen(
}
if (state.isDeleting) {
Dialogs.IndeterminateProgressDialog(message = stringResource(id = R.string.BackupDialog_deleting_local_backup))
val message = stringResource(id = R.string.BackupDialog_deleting_local_backup)
if (state.deleteTotal > 0) {
Dialogs.DeterminateProgressDialog(
message = message,
progress = { state.deleteCompleted.toFloat() / state.deleteTotal }
)
} else {
Dialogs.IndeterminateProgressDialog(message = message)
}
}
}
@@ -19,5 +19,7 @@ data class LocalBackupsSettingsState(
val folderDisplayName: String? = null,
val scheduleTimeLabel: String? = null,
val progress: LocalBackupCreationProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle()),
val isDeleting: Boolean = false
val isDeleting: Boolean = false,
val deleteCompleted: Int = 0,
val deleteTotal: Int = 0
)
@@ -113,7 +113,7 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
}
fun turnOffAndDelete(context: Context) {
internalSettingsState.update { it.copy(isDeleting = true) }
internalSettingsState.update { it.copy(isDeleting = true, deleteCompleted = 0, deleteTotal = 0) }
viewModelScope.launch {
withContext(Dispatchers.IO) {
@@ -121,10 +121,12 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
val path = SignalStore.backup.newLocalBackupsDirectory
SignalStore.backup.newLocalBackupsDirectory = null
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE)
BackupUtil.deleteUnifiedBackups(context, path)
BackupUtil.deleteUnifiedBackups(context, path) { completed, total ->
internalSettingsState.update { it.copy(deleteCompleted = completed, deleteTotal = total) }
}
}
internalSettingsState.update { it.copy(isDeleting = false) }
internalSettingsState.update { it.copy(isDeleting = false, deleteCompleted = 0, deleteTotal = 0) }
}
}
@@ -49,12 +49,12 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
@@ -1052,9 +1052,10 @@ private fun BackupCard(
else -> error("Not supported here.")
}
val locale = LocalLocale.current.platformLocale
if (backupState.renewalTime > 0.seconds) {
Text(
text = stringResource(resource, DateUtils.formatDateWithYear(Locale.getDefault(), backupState.renewalTime.inWholeMilliseconds))
text = stringResource(resource, DateUtils.formatDateWithYear(locale, backupState.renewalTime.inWholeMilliseconds))
)
}
}
@@ -1874,8 +1875,10 @@ private fun ErrorCardPreview() {
@Composable
private fun PendingCardPreview() {
Previews.Preview {
val locale = LocalLocale.current.platformLocale
PendingCard(
price = FiatMoney(BigDecimal.TEN, Currency.getInstance(Locale.getDefault()))
price = FiatMoney(BigDecimal.TEN, Currency.getInstance(locale))
)
}
}
@@ -335,7 +335,7 @@ class ChangeNumberRepository(
} else {
PreKeyUtil.generateSignedPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), pniIdentity.privateKey)
}
devicePniSignedPreKeys[deviceId] = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
devicePniSignedPreKeys[deviceId] = SignedPreKeyEntity(signedPreKeyRecord.id.toLong(), signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
// Last-resort kyber prekeys
val lastResortKyberPreKeyRecord: KyberPreKeyRecord = if (deviceId == primaryDeviceId) {
@@ -343,7 +343,7 @@ class ChangeNumberRepository(
} else {
PreKeyUtil.generateLastResortKyberPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), pniIdentity.privateKey)
}
devicePniLastResortKyberPreKeys[deviceId] = KyberPreKeyEntity(lastResortKyberPreKeyRecord.id, lastResortKyberPreKeyRecord.keyPair.publicKey, lastResortKyberPreKeyRecord.signature)
devicePniLastResortKyberPreKeys[deviceId] = KyberPreKeyEntity(lastResortKyberPreKeyRecord.id.toLong(), lastResortKyberPreKeyRecord.keyPair.publicKey, lastResortKyberPreKeyRecord.signature)
// Registration Ids
var pniRegistrationId = -1
@@ -383,8 +383,8 @@ class ChangeNumberRepository(
previousPni = SignalStore.account.pni!!.toByteString(),
pniIdentityKeyPair = pniIdentity.serialize().toByteString(),
pniRegistrationId = pniRegistrationIds[primaryDeviceId]!!,
pniSignedPreKeyId = devicePniSignedPreKeys[primaryDeviceId]!!.keyId,
pniLastResortKyberPreKeyId = devicePniLastResortKyberPreKeys[primaryDeviceId]!!.keyId,
pniSignedPreKeyId = devicePniSignedPreKeys[primaryDeviceId]!!.keyId.toInt(),
pniLastResortKyberPreKeyId = devicePniLastResortKyberPreKeys[primaryDeviceId]!!.keyId.toInt(),
previousE164 = SignalStore.account.requireE164(),
newE164 = newE164
)
@@ -103,6 +103,10 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
is VerificationCodeRequestResult.ChallengeRequired -> {
Log.i(TAG, "Unable to request sms code due to challenges required: ${castResult.challenges.joinToString { it.key }}")
if (castResult.challenges.isEmpty()) {
Log.w(TAG, "Challenge required but no challenges listed, showing error.")
showErrorDialog(R.string.RegistrationActivity_sms_provider_error)
}
}
is VerificationCodeRequestResult.RateLimited -> {
@@ -52,10 +52,10 @@ import org.signal.core.ui.compose.DropdownMenus
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.copied.androidx.compose.DragAndDropEvent
import org.signal.core.ui.compose.copied.androidx.compose.DraggableItem
import org.signal.core.ui.compose.copied.androidx.compose.dragContainer
import org.signal.core.ui.compose.copied.androidx.compose.rememberDragDropState
import org.signal.core.ui.compose.list.ReorderListEvent
import org.signal.core.ui.compose.list.ReorderableItem
import org.signal.core.ui.compose.list.rememberReorderableListState
import org.signal.core.ui.compose.list.reorderableList
import org.signal.core.util.toInt
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -101,11 +101,11 @@ class ChatFoldersFragment : ComposeFragment() {
onDeleteDismissed = {
viewModel.showDeleteDialog(false)
},
onDragAndDropEvent = { event ->
onReorderListEvent = { event ->
when (event) {
is DragAndDropEvent.OnItemMove -> viewModel.updateItemPosition(event.fromIndex, event.toIndex)
is DragAndDropEvent.OnItemDrop -> viewModel.saveItemPositions()
is DragAndDropEvent.OnDragCancel -> {}
is ReorderListEvent.ItemMoved -> viewModel.updateItemPosition(event.fromIndex, event.toIndex)
is ReorderListEvent.ItemDropped -> viewModel.saveItemPositions()
is ReorderListEvent.DragCanceled -> {}
}
}
)
@@ -123,10 +123,10 @@ fun FoldersScreen(
onDeleteClicked: (ChatFolderRecord) -> Unit = {},
onDeleteConfirmed: () -> Unit = {},
onDeleteDismissed: () -> Unit = {},
onDragAndDropEvent: (DragAndDropEvent) -> Unit = {}
onReorderListEvent: (ReorderListEvent) -> Unit = {}
) {
val listState = rememberLazyListState()
val dragDropState = rememberDragDropState(listState, includeHeader = true, includeFooter = true, onEvent = onDragAndDropEvent)
val reorderableListState = rememberReorderableListState(listState, includeHeader = true, includeFooter = true, onEvent = onReorderListEvent)
LaunchedEffect(Unit) {
if (!SignalStore.uiHints.hasSeenChatFoldersEducationSheet) {
@@ -147,14 +147,14 @@ fun FoldersScreen(
}
LazyColumn(
modifier = Modifier.dragContainer(
dragDropState = dragDropState,
modifier = Modifier.reorderableList(
reorderableListState = reorderableListState,
dragHandleWidth = 56.dp
),
state = listState
) {
item {
DraggableItem(dragDropState, 0) {
ReorderableItem(reorderableListState, 0) {
Text(
text = stringResource(id = R.string.ChatFoldersFragment__organize_your_chats),
style = MaterialTheme.typography.bodyMedium,
@@ -175,7 +175,7 @@ fun FoldersScreen(
}
itemsIndexed(state.folders) { index, folder ->
DraggableItem(dragDropState, 1 + index) { isDragging ->
ReorderableItem(reorderableListState, 1 + index) { isDragging ->
val elevation = if (isDragging) 1.dp else 0.dp
val isAllChats = folder.folderType == ChatFolderRecord.FolderType.ALL
FolderRow(
@@ -193,7 +193,7 @@ fun FoldersScreen(
}
item {
DraggableItem(dragDropState, 1 + state.folders.size) {
ReorderableItem(reorderableListState, 1 + state.folders.size) {
if (state.suggestedFolders.isNotEmpty()) {
Dividers.Default()
@@ -27,6 +27,7 @@ import org.signal.core.util.bytes
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.util.AttachmentUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.webrtc.CallDataMode
import kotlin.math.abs
@@ -142,6 +143,7 @@ private fun DataAndStorageSettingsScreen(
labels = stringArrayResource(R.array.pref_media_download_entries),
values = stringArrayResource(R.array.pref_media_download_values),
selection = state.mobileAutoDownloadValues.toTypedArray(),
noSelectionLabel = stringResource(R.string.preferences__none),
onSelectionChanged = callbacks::onMobileDataAutoDownloadSelectionChanged
)
}
@@ -152,6 +154,7 @@ private fun DataAndStorageSettingsScreen(
labels = stringArrayResource(R.array.pref_media_download_entries),
values = stringArrayResource(R.array.pref_media_download_values),
selection = state.wifiAutoDownloadValues.toTypedArray(),
noSelectionLabel = stringResource(R.string.preferences__none),
onSelectionChanged = callbacks::onWifiDataAutoDownloadSelectionChanged
)
}
@@ -162,10 +165,26 @@ private fun DataAndStorageSettingsScreen(
labels = stringArrayResource(R.array.pref_media_download_entries),
values = stringArrayResource(R.array.pref_media_download_values),
selection = state.roamingAutoDownloadValues.toTypedArray(),
noSelectionLabel = stringResource(R.string.preferences__none),
onSelectionChanged = callbacks::onRoamingDataAutoDownloadSelectionChanged
)
}
item {
Rows.TextRow(
text = {
Text(
text = stringResource(
R.string.DataAndStorageSettingsFragment__voice_messages_and_stickers_under_size_are_always_auto_downloaded,
AttachmentUtil.SMALL_ATTACHMENT_SIZE.toUnitString()
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
item {
Dividers.Default()
}
@@ -370,6 +370,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
switchPref(
title = DSLSettingsText.from("Enable ANR-induced crashing"),
isChecked = SignalStore.internal.anrDetectionCrashes,
onClick = {
SignalStore.internal.anrDetectionCrashes = !SignalStore.internal.anrDetectionCrashes
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Logging"))
@@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
import org.thoughtcrime.securesms.database.model.addButton
import org.thoughtcrime.securesms.database.model.addLink
import org.thoughtcrime.securesms.database.model.addStyle
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -48,9 +49,12 @@ class InternalSettingsRepository(context: Context) {
val title = "Release Note Title"
val bodyText = "Release note body. Aren't I awesome?"
val body = "$title\n\n$bodyText"
val linkUrl = "https://signal.org"
val body = "$title\n\n$bodyText\n\n$linkUrl"
val linkStart = body.length - linkUrl.length
val bodyRangeList = BodyRangeList.Builder()
.addStyle(BodyRangeList.BodyRange.Style.BOLD, 0, title.length)
.addLink(linkUrl, linkStart, linkUrl.length)
bodyRangeList.addButton("Call to Action Text", callToAction, body.lastIndex, 0)
@@ -37,6 +37,7 @@ import org.signal.core.util.readNBytesOrThrow
import org.signal.core.util.roundedString
import org.signal.core.util.stream.LimitedInputStream
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.signal.network.api.SvrBApi
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
@@ -58,7 +59,6 @@ import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.svr.SvrBApi
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
@@ -50,8 +50,8 @@ import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.util.Hex
import org.signal.core.util.Util
import org.signal.libsignal.protocol.util.Hex
import org.thoughtcrime.securesms.components.settings.app.internal.sqlite.InternalSqlitePlaygroundViewModel.QueryResult
class InternalSqlitePlaygroundFragment : ComposeFragment() {
@@ -16,11 +16,11 @@ import kotlinx.coroutines.withContext
import org.signal.core.util.ByteSize
import org.signal.core.util.bytes
import org.signal.core.util.logging.Log
import org.signal.network.service.StorageServiceService
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.storage.SignalStorageManifest
import org.whispersystems.signalservice.api.storage.SignalStorageRecord
import org.whispersystems.signalservice.api.storage.StorageServiceRepository
class InternalStorageServicePlaygroundViewModel : ViewModel() {
@@ -47,12 +47,12 @@ class InternalStorageServicePlaygroundViewModel : ViewModel() {
fun onViewTabSelected() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val repository = StorageServiceRepository(AppDependencies.storageServiceApi)
val repository = StorageServiceService(AppDependencies.storageServiceApi)
val storageKey = SignalStore.storageService.storageKeyForInitialDataRestore ?: SignalStore.storageService.storageKey
val manifest = when (val result = repository.getStorageManifest(storageKey)) {
is StorageServiceRepository.ManifestResult.Success -> result.manifest
is StorageServiceRepository.ManifestResult.NotFoundError -> {
is StorageServiceService.ManifestResult.Success -> result.manifest
is StorageServiceService.ManifestResult.NotFoundError -> {
Log.w(TAG, "Manifest not found!")
_oneOffEvents.value = OneOffEvent.ManifestNotFoundError
return@withContext
@@ -66,7 +66,7 @@ class InternalStorageServicePlaygroundViewModel : ViewModel() {
_manifest.value = manifest
val records = when (val result = repository.readStorageRecords(storageKey, manifest.recordIkm, manifest.storageIds)) {
is StorageServiceRepository.StorageRecordResult.Success -> result.records
is StorageServiceService.StorageRecordResult.Success -> result.records
else -> {
Log.w(TAG, "Failed to fetch records!")
_oneOffEvents.value = OneOffEvent.StorageRecordDecryptionError
@@ -75,6 +75,7 @@ class AdvancedPrivacySettingsViewModel(
viewModelScope.launch(SignalDispatchers.IO) {
if (!enabled) {
SignalDatabase.recipients.clearAllKeyTransparencyData()
SignalStore.account.distinguishedHead = null
}
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
@@ -63,9 +63,11 @@ sealed interface AppSettingsRoute : Parcelable {
@Parcelize
sealed interface BackupsRoute : AppSettingsRoute {
data object Backups : BackupsRoute
data class Backups(
val launchCheckoutFlow: Boolean = false
) : BackupsRoute
data class Local(val triggerUpdateFlow: Boolean = false) : BackupsRoute
data class Remote(val backupLaterSelected: Boolean = false, val forQuickRestore: Boolean = false) : BackupsRoute
data class Remote(val forQuickRestore: Boolean = false) : BackupsRoute
data object DisplayKey : BackupsRoute
}
@@ -664,7 +664,7 @@ object InAppPaymentsRepository {
timestamp = insertedAt.inWholeMilliseconds,
error = null,
pendingVerification = true,
checkedVerification = data.waitForAuth!!.checkedVerification
checkedVerification = data.waitForAuth?.checkedVerification ?: false
)
}
@@ -34,9 +34,9 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
@@ -348,7 +348,7 @@ class DonateToSignalFragment :
if (state.oneTimeDonationState.isOneTimeDonationLongRunning) {
R.string.DonateToSignalFragment__bank_transfers_usually_take_1_business_day_to_process_onetime
} else if (state.oneTimeDonationState.isNonVerifiedIdeal) {
R.string.DonateToSignalFragment__your_ideal_payment_is_still_processing
R.string.DonateToSignalFragment__your_ideal_wero_payment_is_still_processing
} else {
R.string.DonateToSignalFragment__your_payment_is_still_being_processed_onetime
}
@@ -356,7 +356,7 @@ class DonateToSignalFragment :
if (state.monthlyDonationState.activeSubscription?.paymentMethod == ActiveSubscription.PaymentMethod.SEPA_DEBIT) {
R.string.DonateToSignalFragment__bank_transfers_usually_take_1_business_day_to_process_monthly
} else if (state.monthlyDonationState.nonVerifiedMonthlyDonation != null) {
R.string.DonateToSignalFragment__your_ideal_payment_is_still_processing
R.string.DonateToSignalFragment__your_ideal_wero_payment_is_still_processing
} else {
R.string.DonateToSignalFragment__your_payment_is_still_being_processed_monthly
}
@@ -46,8 +46,7 @@ sealed interface GatewayOrderStrategy {
}
companion object {
fun getStrategy(): GatewayOrderStrategy {
val self = Recipient.self()
fun getStrategy(self: Recipient = Recipient.self()): GatewayOrderStrategy {
val e164 = self.e164.orNull() ?: return Default
return if (PhoneNumberUtil.getInstance().parse(e164, "").countryCode == 1) {
@@ -1,34 +1,28 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.concurrent.LifecycleDisposable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.core.util.dp
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.NO_TINT
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.components.settings.app.subscription.models.PayPalButton
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.viewModel
import org.signal.core.ui.R as CoreUiR
@@ -36,172 +30,56 @@ import org.signal.core.ui.R as CoreUiR
/**
* Entry point to capturing the necessary payment token to pay for a donation
*/
class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
private val lifecycleDisposable = LifecycleDisposable()
class GatewaySelectorBottomSheet : ComposeBottomSheetDialogFragment() {
private val args: GatewaySelectorBottomSheetArgs by navArgs()
override val peekHeightPercentage: Float = 1f
private val viewModel: GatewaySelectorViewModel by viewModel {
GatewaySelectorViewModel(args, requireListener<GooglePayComponent>().googlePayRepository)
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
BadgeDisplay112.register(adapter)
GooglePayButton.register(adapter)
PayPalButton.register(adapter)
IndeterminateLoadingCircle.register(adapter)
@Composable
override fun SheetContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += viewModel.state.subscribe { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
GatewaySelectorBottomSheetContent(state, onEvent = this::onEvent)
}
private fun getConfiguration(state: GatewaySelectorState): DSLConfiguration {
return when (state) {
GatewaySelectorState.Loading -> {
configure {
space(16.dp)
customPref(IndeterminateLoadingCircle)
space(16.dp)
private fun onEvent(event: GatewaySelectorBottomSheetEvent) {
when (event) {
GatewaySelectorBottomSheetEvent.GOOGLE_PAY_SELECTED -> {
setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.GOOGLE_PAY)
}
GatewaySelectorBottomSheetEvent.PAYPAL_SELECTED -> {
setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.PAYPAL)
}
GatewaySelectorBottomSheetEvent.SEPA_SELECTED -> {
if (viewModel.checkIsSepaPaymentValidAmount()) {
setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.SEPA_DEBIT)
} else {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(FAILURE_KEY to true, SEPA_EURO_MAX to viewModel.getSepaMaximum()))
}
}
is GatewaySelectorState.Ready -> {
configure {
customPref(
BadgeDisplay112.Model(
badge = state.inAppPayment.data.badge!!.let { Badges.fromDatabaseBadge(it) },
withDisplayText = false
)
)
space(12.dp)
GatewaySelectorBottomSheetEvent.IDEAL_SELECTED -> {
setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.IDEAL)
}
presentTitleAndSubtitle(requireContext(), state.inAppPayment)
space(16.dp)
state.gatewayOrderStrategy.orderedGateways.forEach { gateway ->
when (gateway) {
InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING -> error("Unsupported payment method.")
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> renderGooglePayButton(state)
InAppPaymentData.PaymentMethodType.PAYPAL -> renderPayPalButton(state)
InAppPaymentData.PaymentMethodType.CARD -> renderCreditCardButton(state)
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> renderSEPADebitButton(state)
InAppPaymentData.PaymentMethodType.IDEAL -> renderIDEALButton(state)
InAppPaymentData.PaymentMethodType.UNKNOWN -> error("Unsupported payment method.")
}
}
space(16.dp)
}
GatewaySelectorBottomSheetEvent.CREDIT_CARD_SELECTED -> {
setPaymentMethodAndDismiss(InAppPaymentData.PaymentMethodType.CARD)
}
}
}
private fun DSLConfiguration.renderGooglePayButton(state: GatewaySelectorState.Ready) {
if (state.isGooglePayAvailable) {
space(16.dp)
customPref(
GooglePayButton.Model(
isEnabled = true,
onClick = {
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.GOOGLE_PAY)
.subscribeBy {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
}
}
)
)
}
}
private fun DSLConfiguration.renderPayPalButton(state: GatewaySelectorState.Ready) {
if (state.isPayPalAvailable) {
space(16.dp)
customPref(
PayPalButton.Model(
onClick = {
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.PAYPAL)
.subscribeBy {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
}
},
isEnabled = true
)
)
}
}
private fun DSLConfiguration.renderCreditCardButton(state: GatewaySelectorState.Ready) {
if (state.isCreditCardAvailable) {
space(16.dp)
primaryButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__credit_or_debit_card),
icon = DSLSettingsIcon.from(R.drawable.credit_card, CoreUiR.color.signal_colorOnCustom),
disableOnClick = true,
onClick = {
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.CARD)
.subscribeBy {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
}
}
)
}
}
private fun DSLConfiguration.renderSEPADebitButton(state: GatewaySelectorState.Ready) {
if (state.isSEPADebitAvailable) {
space(16.dp)
tonalButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__bank_transfer),
icon = DSLSettingsIcon.from(R.drawable.bank_transfer),
disableOnClick = true,
onClick = {
val price = state.inAppPayment.data.amount!!.toFiatMoney()
if (state.sepaEuroMaximum != null &&
price.currency == CurrencyUtil.EURO &&
price.amount > state.sepaEuroMaximum.amount
) {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(FAILURE_KEY to true, SEPA_EURO_MAX to state.sepaEuroMaximum.amount))
} else {
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.SEPA_DEBIT)
.subscribeBy {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
}
}
}
)
}
}
private fun DSLConfiguration.renderIDEALButton(state: GatewaySelectorState.Ready) {
if (state.isIDEALAvailable) {
space(16.dp)
tonalButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__ideal),
icon = DSLSettingsIcon.from(R.drawable.logo_ideal, NO_TINT),
disableOnClick = true,
onClick = {
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.IDEAL)
.subscribeBy {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
}
}
)
private fun setPaymentMethodAndDismiss(type: InAppPaymentData.PaymentMethodType) {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
val inAppPayment = viewModel.updateInAppPaymentMethod(type)
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to inAppPayment))
}
}
@@ -0,0 +1,366 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.util.money.FiatMoney
import org.signal.donations.DonateWithGooglePayButton
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImage112
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toDecimalValue
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.IdealWeroButton
import org.thoughtcrime.securesms.components.settings.app.subscription.ui.PayPalButton
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList
import org.thoughtcrime.securesms.database.model.databaseprotos.FiatValue
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.recipients.Recipient
import java.math.BigDecimal
import java.util.Currency
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun GatewaySelectorBottomSheetContent(
state: GatewaySelectorState,
onEvent: (GatewaySelectorBottomSheetEvent) -> Unit,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.testTag(GatewaySelectorTestTags.CONTAINER)
.verticalScroll(scrollState)
.horizontalGutters()
.fillMaxWidth()
) {
BottomSheets.Handle()
when (state) {
GatewaySelectorState.Loading -> Loading()
is GatewaySelectorState.Ready -> Ready(state, onEvent)
}
}
}
@Composable
private fun Loading() {
CircularProgressIndicator(
modifier = Modifier.padding(vertical = 16.dp)
)
}
@Composable
private fun Ready(state: GatewaySelectorState.Ready, onEvent: (GatewaySelectorBottomSheetEvent) -> Unit) {
BadgeImage112(
badge = state.inAppPayment.data.badge!!.let { Badges.fromDatabaseBadge(it) },
modifier = Modifier.size(112.dp)
)
Spacer(modifier = Modifier.size(12.dp))
TitleAndSubtitle(state.inAppPayment)
Spacer(modifier = Modifier.size(16.dp))
var isGatewaySelected by remember { mutableStateOf(false) }
val onGatewaySelected: (GatewaySelectorBottomSheetEvent) -> Unit = remember(onEvent) {
{
if (!isGatewaySelected) {
isGatewaySelected = true
onEvent(it)
}
}
}
state.gatewayOrderStrategy.orderedGateways.forEach {
when (it) {
InAppPaymentData.PaymentMethodType.UNKNOWN -> error("Unsupported payment method.")
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> {
if (state.isGooglePayAvailable) {
DonateWithGooglePayButton(
onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.GOOGLE_PAY_SELECTED) },
enabled = !isGatewaySelected,
modifier = Modifier
.testTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON)
.padding(top = 16.dp)
.fillMaxWidth()
.height(44.dp)
)
}
}
InAppPaymentData.PaymentMethodType.CARD -> {
if (state.isCreditCardAvailable) {
Buttons.LargePrimary(
onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.CREDIT_CARD_SELECTED) },
enabled = !isGatewaySelected,
modifier = Modifier
.testTag(GatewaySelectorTestTags.CREDIT_CARD_BUTTON)
.padding(top = 16.dp)
.fillMaxWidth()
.height(44.dp)
) {
Row(
horizontalArrangement = spacedBy(8.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.credit_card),
contentDescription = null
)
Text(
text = stringResource(R.string.GatewaySelectorBottomSheet__credit_or_debit_card)
)
}
}
}
}
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> {
if (state.isSEPADebitAvailable) {
Buttons.LargeTonal(
onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.SEPA_SELECTED) },
enabled = !isGatewaySelected,
modifier = Modifier
.testTag(GatewaySelectorTestTags.SEPA_BUTTON)
.padding(top = 16.dp)
.fillMaxWidth()
.height(44.dp)
) {
Row(
horizontalArrangement = spacedBy(8.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.bank_transfer),
contentDescription = null
)
Text(
text = stringResource(R.string.GatewaySelectorBottomSheet__bank_transfer)
)
}
}
}
}
InAppPaymentData.PaymentMethodType.IDEAL -> {
if (state.isIDEALAvailable) {
IdealWeroButton(
onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.IDEAL_SELECTED) },
enabled = !isGatewaySelected,
modifier = Modifier
.testTag(GatewaySelectorTestTags.IDEAL_BUTTON)
.padding(top = 16.dp)
.height(44.dp)
.fillMaxWidth()
)
}
}
InAppPaymentData.PaymentMethodType.PAYPAL -> {
if (state.isPayPalAvailable) {
PayPalButton(
enabled = !isGatewaySelected,
onClick = { onGatewaySelected(GatewaySelectorBottomSheetEvent.PAYPAL_SELECTED) },
modifier = Modifier
.testTag(GatewaySelectorTestTags.PAYPAL_BUTTON)
.padding(top = 16.dp)
.height(44.dp)
.fillMaxWidth()
)
}
}
InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING -> error("Unsupported payment method.")
}
}
Spacer(modifier = Modifier.size(16.dp))
}
@DayNightPreviews
@Composable
private fun GatewaySelectorBottomSheetContentLoadingPreview() {
Previews.BottomSheetContentPreview {
GatewaySelectorBottomSheetContent(
state = GatewaySelectorState.Loading,
onEvent = {}
)
}
}
@Composable
private fun TitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) {
when (inAppPayment.type) {
InAppPaymentType.UNKNOWN -> error("Unsupported type UNKNOWN")
InAppPaymentType.ONE_TIME_GIFT -> OneTimeGiftTitleAndSubtitle(inAppPayment)
InAppPaymentType.ONE_TIME_DONATION -> RecurringDonationTitleAndSubtitle(inAppPayment)
InAppPaymentType.RECURRING_DONATION -> OneTimeDonationTitleAndSubtitle(inAppPayment)
InAppPaymentType.RECURRING_BACKUP -> error("This type is not supported")
}
}
@Composable
private fun RecurringDonationTitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) {
Text(
text = stringResource(R.string.GatewaySelectorBottomSheet__donate_s_month_to_signal, rememberFormattedAmount(inAppPayment)),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 6.dp)
)
Text(
text = stringResource(R.string.GatewaySelectorBottomSheet__get_a_s_badge, inAppPayment.data.badge!!.name),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@Composable
private fun OneTimeDonationTitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) {
Text(
text = stringResource(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, rememberFormattedAmount(inAppPayment)),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 6.dp)
)
Text(
text = pluralStringResource(R.plurals.GatewaySelectorBottomSheet__get_a_s_badge_for_d_days, 30, inAppPayment.data.badge!!.name, 30),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@Composable
private fun OneTimeGiftTitleAndSubtitle(inAppPayment: InAppPaymentTable.InAppPayment) {
Text(
text = stringResource(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, rememberFormattedAmount(inAppPayment)),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 6.dp)
)
Text(
text = stringResource(R.string.GatewaySelectorBottomSheet__donate_for_a_friend),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@Composable
private fun rememberFormattedAmount(inAppPayment: InAppPaymentTable.InAppPayment): String {
val resources = LocalResources.current
return remember(inAppPayment.data.amount) {
FiatMoneyUtil.format(resources, inAppPayment.data.amount!!.toFiatMoney())
}
}
@DayNightPreviews
@Composable
private fun GatewaySelectorBottomSheetContentReadyOneTimeDonationPreview() {
Previews.BottomSheetContentPreview {
GatewaySelectorBottomSheetContent(
state = rememberGatewaySelectorBottomSheetContentPreviewState(InAppPaymentType.ONE_TIME_DONATION),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun GatewaySelectorBottomSheetContentReadyRecurringDonationPreview() {
Previews.BottomSheetContentPreview {
GatewaySelectorBottomSheetContent(
state = rememberGatewaySelectorBottomSheetContentPreviewState(InAppPaymentType.RECURRING_DONATION),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun GatewaySelectorBottomSheetContentReadyOneTimeGiftDonationPreview() {
Previews.BottomSheetContentPreview {
GatewaySelectorBottomSheetContent(
state = rememberGatewaySelectorBottomSheetContentPreviewState(InAppPaymentType.ONE_TIME_GIFT),
onEvent = {}
)
}
}
@Composable
@VisibleForTesting
fun rememberGatewaySelectorBottomSheetContentPreviewState(type: InAppPaymentType): GatewaySelectorState.Ready {
return remember {
GatewaySelectorState.Ready(
inAppPayment = InAppPaymentTable.InAppPayment(
id = InAppPaymentTable.InAppPaymentId(1),
type = type,
state = InAppPaymentTable.State.CREATED,
insertedAt = 1.milliseconds,
updatedAt = 1.milliseconds,
notified = true,
subscriberId = null,
endOfPeriod = 0.milliseconds,
data = InAppPaymentData(
badge = BadgeList.Badge(
name = type.name.lowercase()
),
amount = FiatValue(currencyCode = "USD", amount = BigDecimal.TEN.toDecimalValue())
)
),
gatewayOrderStrategy = GatewayOrderStrategy.getStrategy(
self = Recipient(
isResolving = false,
e164Value = "+15555555555"
)
),
isGooglePayAvailable = true,
isPayPalAvailable = true,
isCreditCardAvailable = true,
isSEPADebitAvailable = true,
isIDEALAvailable = true,
sepaEuroMaximum = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD"))
)
}
}
@@ -0,0 +1,14 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
enum class GatewaySelectorBottomSheetEvent {
GOOGLE_PAY_SELECTED,
PAYPAL_SELECTED,
SEPA_SELECTED,
IDEAL_SELECTED,
CREDIT_CARD_SELECTED
}

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