Compare commits

..

157 Commits

Author SHA1 Message Date
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
920 changed files with 41866 additions and 27684 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
+3 -7
View File
@@ -24,8 +24,8 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1677
val canonicalVersionName = "8.7.1"
val canonicalVersionCode = 1684
val canonicalVersionName = "8.9.1"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -597,6 +597,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"))
@@ -678,7 +679,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 +689,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)
-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,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
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
@@ -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
@@ -88,6 +87,7 @@ 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
@@ -429,15 +429,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 {
@@ -465,7 +465,7 @@ class MainActivity :
anchors.indexOf(paneExpansionState.currentAnchor)
}
LaunchedEffect(windowSizeClass) {
LaunchedEffect(anchors) {
val index = when {
paneAnchorIndex < 0 -> 1
paneAnchorIndex > anchors.lastIndex -> anchors.lastIndex
@@ -478,7 +478,7 @@ class MainActivity :
}
}
val chatNavGraphState = ChatNavGraphState.remember(windowSizeClass)
val chatNavGraphState = ChatNavGraphState.remember(isSplitPane)
val mutableInteractionSource = remember { MutableInteractionSource() }
MainNavigationDetailLocationEffect(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
@@ -624,7 +624,7 @@ class MainActivity :
onDestinationSelected = mainNavigationCallback
)
if (!windowSizeClass.isSplitPane()) {
if (!LocalResources.current.rememberIsSplitPane()) {
Spacer(Modifier.navigationBarsPadding())
}
}
@@ -640,7 +640,7 @@ class MainActivity :
}
},
secondaryContent = {
val listContainerColor = if (windowSizeClass.isSplitPane()) {
val listContainerColor = if (isSplitPane) {
SignalTheme.colors.colorSurface1
} else {
MaterialTheme.colorScheme.surface
@@ -781,12 +781,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)
)
@@ -800,18 +800,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()
@@ -178,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;
@@ -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,
@@ -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"
}
@@ -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
@@ -1690,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)
}
}
@@ -2094,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")
}
@@ -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
@@ -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 */
@@ -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)
@@ -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
@@ -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()
)
}
@@ -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
@@ -83,7 +83,7 @@ fun CallLinkDetailsScreen(
state = state,
showAlreadyInACall = showAlreadyInACall,
callback = callback,
showNavigationIcon = !currentWindowAdaptiveInfo().windowSizeClass.isSplitPane()
showNavigationIcon = !LocalResources.current.rememberIsSplitPane()
)
}
@@ -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 {
@@ -401,7 +401,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 +574,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))
}
@@ -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
}
}
@@ -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;
}
}
}
@@ -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;
@@ -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);
@@ -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);
}
@@ -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());
}
}
@@ -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()
@@ -38,6 +38,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 +63,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 +78,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
@@ -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,7 +49,6 @@ 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
@@ -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()
@@ -142,6 +142,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 +153,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,6 +164,7 @@ 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
)
}
@@ -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
@@ -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
}
@@ -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
}
@@ -18,10 +18,10 @@ 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.IdealWeroButton
import org.thoughtcrime.securesms.components.settings.app.subscription.models.PayPalButton
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
@@ -51,6 +51,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
GooglePayButton.register(adapter)
PayPalButton.register(adapter)
IndeterminateLoadingCircle.register(adapter)
IdealWeroButton.register(adapter)
lifecycleDisposable.bindTo(viewLifecycleOwner)
@@ -190,17 +191,16 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
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))
}
}
customPref(
IdealWeroButton.Model(
onClick = {
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.IDEAL)
.subscribeBy {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
}
}
)
)
}
}
@@ -10,11 +10,13 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.annotation.VisibleForTesting
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.R
import java.net.URISyntaxException
/**
* Encapsulates the logic for navigating a user to a deeplink from within a webview or parsing out the fallback
@@ -30,18 +32,31 @@ object ExternalNavigationHelper {
return false
}
val intent = try {
Intent.parseUri(url.toString(), Intent.URI_INTENT_SCHEME).sanitizeWebIntent()
} catch (e: URISyntaxException) {
Log.w(TAG, "Failed to parse web intent URI.", e)
return false
}
val targetLabel = resolveTargetLabel(context, intent)
val message = if (targetLabel != null) {
context.getString(R.string.ExternalNavigationHelper__once_payment_confirmed_in_app, targetLabel)
} else {
context.getString(R.string.ExternalNavigationHelper__once_this_payment_is_confirmed)
}
MaterialAlertDialogBuilder(context)
.setTitle(R.string.ExternalNavigationHelper__leave_signal_to_confirm_payment)
.setMessage(R.string.ExternalNavigationHelper__once_this_payment_is_confirmed)
.setPositiveButton(android.R.string.ok) { _, _ -> attemptIntentLaunch(context, url, launchIntent) }
.setMessage(message)
.setPositiveButton(android.R.string.ok) { _, _ -> attemptIntentLaunch(context, intent, launchIntent) }
.setNegativeButton(android.R.string.cancel, null)
.show()
return true
}
private fun attemptIntentLaunch(context: Context, url: Uri, launchIntent: (Intent) -> Unit) {
val intent = Intent.parseUri(url.toString(), Intent.URI_INTENT_SCHEME)
private fun attemptIntentLaunch(context: Context, intent: Intent, launchIntent: (Intent) -> Unit) {
try {
launchIntent(intent)
} catch (e: ActivityNotFoundException) {
@@ -50,7 +65,7 @@ object ExternalNavigationHelper {
val fallback = intent.getStringExtra("browser_fallback_url")
if (fallback.isNotNullOrBlank()) {
try {
launchIntent(Intent.parseUri(fallback, Intent.URI_INTENT_SCHEME))
launchIntent(Intent.parseUri(fallback, Intent.URI_INTENT_SCHEME).sanitizeWebIntent())
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "Failed to launch fallback URL.", e)
toastOnActivityNotFound(context)
@@ -59,6 +74,30 @@ object ExternalNavigationHelper {
}
}
private fun resolveTargetLabel(context: Context, intent: Intent): CharSequence? {
val resolveInfo = context.packageManager.resolveActivity(intent, 0) ?: return null
return resolveInfo.loadLabel(context.packageManager).toString().takeIf { it.isNotBlank() }
}
/**
* Sanitize an intent parsed from a web-originated URI to prevent targeting
* non-exported or internal activities. This mirrors the sanitization that
* browsers apply to intent:// URIs before dispatching them.
*/
@VisibleForTesting
fun Intent.sanitizeWebIntent(): Intent {
component = null
selector = null
addCategory(Intent.CATEGORY_BROWSABLE)
flags = flags and (
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
).inv()
return this
}
private fun toastOnActivityNotFound(context: Context) {
Toast.makeText(context, R.string.CommunicationActions_no_browser_found, Toast.LENGTH_SHORT).show()
}
@@ -153,7 +153,7 @@ class IdealTransferDetailsFragment : ComposeFragment(), InAppPaymentCheckoutDele
if (state.inAppPayment!!.type.recurring) { // TODO [message-requests] -- handle backup
val formattedMoney = FiatMoneyUtil.format(requireContext().resources, state.inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.IdealTransferDetailsFragment__confirm_your_donation_with_ideal))
.setTitle(getString(R.string.IdealTransferDetailsFragment__confirm_your_donation_with_ideal_wero))
.setMessage(getString(R.string.IdealTransferDetailsFragment__to_setup_your_recurring_donation, formattedMoney))
.setPositiveButton(R.string.IdealTransferDetailsFragment__continue) { _, _ ->
continueTransfer()
@@ -218,7 +218,7 @@ private fun IdealTransferDetailsContent(
onDonateClick: () -> Unit
) {
Scaffolds.Settings(
title = stringResource(id = R.string.GatewaySelectorBottomSheet__ideal),
title = stringResource(id = R.string.GatewaySelectorBottomSheet__ideal_wero),
onNavigationClick = onNavigationClick,
navigationIcon = SignalIcons.ArrowStart.imageVector
) {
@@ -130,7 +130,7 @@ class ManageDonationsFragment :
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ManageDonationsFragment__couldnt_confirm_donation)
.setMessage(getString(R.string.ManageDonationsFragment__your_monthly_s_donation_couldnt_be_confirmed, amount))
.setMessage(getString(R.string.ManageDonationsFragment__your_monthly_s_donation_couldnt_be_confirmed_ideal_wero, amount))
.setPositiveButton(android.R.string.ok, null)
.show()
} else if (state.pendingOneTimeDonation?.pendingVerification == true &&
@@ -143,7 +143,7 @@ class ManageDonationsFragment :
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ManageDonationsFragment__couldnt_confirm_donation)
.setMessage(getString(R.string.ManageDonationsFragment__your_one_time_s_donation_couldnt_be_confirmed, amount))
.setMessage(getString(R.string.ManageDonationsFragment__your_one_time_s_donation_couldnt_be_confirmed_ideal_wero, amount))
.setPositiveButton(android.R.string.ok, null)
.show()
}
@@ -440,7 +440,7 @@ class ManageDonationsFragment :
else -> {
val message = if (isIdeal) {
R.string.DonationsErrors__your_ideal_couldnt_be_processed
R.string.DonationsErrors__your_ideal_wero_couldnt_be_processed
} else {
R.string.DonationsErrors__try_another_payment_method
}
@@ -0,0 +1,87 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.models
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ButtonColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.models.DSLComposePreference
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
/**
* DSL Ideal | Wero button for the payments gateway.
*/
object IdealWeroButton {
@Stable
class Model(val onClick: () -> Unit) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean = true
}
class ViewHolder(itemView: ComposeView) : DSLComposePreference.ViewHolder<Model>(itemView) {
@Composable
override fun Content(model: Model) {
IdealWeroButton(model)
}
}
fun register(adapter: MappingAdapter) {
DSLComposePreference.register(adapter) { ViewHolder(it) }
}
}
@Composable
private fun IdealWeroButton(model: IdealWeroButton.Model) {
var enabled by remember { mutableStateOf(true) }
Buttons.LargeTonal(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
onClick = {
enabled = false
model.onClick()
},
enabled = enabled,
modifier = Modifier
.height(44.dp)
.horizontalGutters()
.fillMaxWidth(),
colors = ButtonColors(
containerColor = colorResource(org.signal.core.ui.R.color.signal_light_colorPrimaryContainer),
contentColor = colorResource(org.signal.core.ui.R.color.signal_light_colorOnPrimaryContainer),
disabledContainerColor = colorResource(org.signal.core.ui.R.color.signal_light_colorPrimaryContainer),
disabledContentColor = colorResource(org.signal.core.ui.R.color.signal_light_colorOnPrimaryContainer)
)
) {
Image(
imageVector = ImageVector.vectorResource(R.drawable.logo_ideal_wero),
contentDescription = stringResource(R.string.GatewaySelectorBottomSheet__ideal_wero)
)
}
}
@DayNightPreviews
@Composable
private fun IdealWeroButtonPreview() {
Previews.Preview {
IdealWeroButton(model = remember { IdealWeroButton.Model(onClick = {}) })
}
}
@@ -30,7 +30,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.coroutines.launch
import org.signal.core.ui.getWindowSizeClass
import org.signal.core.ui.isSplitPane
import org.signal.core.ui.permissions.Permissions
import org.signal.core.util.DimensionUnit
@@ -277,7 +276,7 @@ class ConversationSettingsFragment :
views = listOf(toolbar!!),
lifecycleOwner = viewLifecycleOwner,
setStatusBarColor = { color ->
if (!resources.getWindowSizeClass().isSplitPane() || activity is ConversationSettingsActivity) {
if (!resources.isSplitPane() || activity is ConversationSettingsActivity) {
WindowUtil.setStatusBarColor(requireActivity().window, color)
}
}
@@ -215,7 +215,7 @@ class ConversationSettingsRepository(
@WorkerThread
fun isMessageRequestAccepted(recipient: Recipient): Boolean {
return RecipientUtil.isMessageRequestAccepted(context, recipient)
return RecipientUtil.isMessageRequestAccepted(recipient)
}
fun getMembershipCountDescription(liveGroup: LiveGroup): LiveData<String> {
@@ -44,7 +44,7 @@ object BioTextPreference {
override fun getSubhead1Text(context: Context): String? {
return if (recipient.isReleaseNotes) {
context.getString(R.string.ReleaseNotes__signal_release_notes_and_news)
null
} else {
recipient.combinedAboutAndEmoji
}
@@ -0,0 +1,68 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.models
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.util.adapter.mapping.Factory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
/**
* Allows hosting compose code in a DSL adapter.
*/
object DSLComposePreference {
/**
* Initializes the ComposeView to play nice with RecyclerView and manages the Model in a State.
*/
abstract class ViewHolder<T : MappingModel<T>>(composeView: ComposeView) : MappingViewHolder<T>(composeView) {
private var model: T? by mutableStateOf(null)
init {
composeView.setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool
)
composeView.setContent {
val model = this.model ?: return@setContent
SignalTheme {
Content(model)
}
}
}
override fun bind(model: T) {
this.model = model
}
@Composable
abstract fun Content(model: T)
}
/**
* Does not need to be used directly, but does need to be non-private so that the inline register method can see it.
*/
class ComposeFactory<T : MappingModel<T>>(
private val create: (ComposeView) -> MappingViewHolder<T>
) : Factory<T> {
override fun createViewHolder(parent: ViewGroup): MappingViewHolder<T> {
return create(ComposeView(parent.context))
}
}
inline fun <reified T : MappingModel<T>> register(adapter: MappingAdapter, noinline create: (ComposeView) -> MappingViewHolder<T>) {
adapter.registerFactory(T::class.java, ComposeFactory(create))
}
}
@@ -1,16 +1,15 @@
package org.thoughtcrime.securesms.components.voice;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Process;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -23,14 +22,8 @@ import androidx.media3.common.PlaybackException;
import androidx.media3.common.PlaybackParameters;
import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaController;
import androidx.media3.session.MediaSession;
import androidx.media3.session.MediaSessionService;
import androidx.media3.session.SessionToken;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
@@ -45,8 +38,6 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob;
import org.thoughtcrime.securesms.mms.PartUriParser;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.service.KeyCachingService;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
@@ -66,7 +57,6 @@ public class VoiceNotePlaybackService extends MediaSessionService {
private MediaSession mediaSession;
private VoiceNotePlayer player;
private KeyClearedReceiver keyClearedReceiver;
private VoiceNotePlayerCallback voiceNotePlayerCallback;
private final DatabaseObserver.Observer attachmentDeletionObserver = this::onAttachmentDeleted;
@@ -88,8 +78,6 @@ public class VoiceNotePlaybackService extends MediaSessionService {
mediaSession = session;
}
keyClearedReceiver = new KeyClearedReceiver(this, session.getToken());
setMediaNotificationProvider(new VoiceNoteMediaNotificationProvider(this));
setListener(new MediaSessionServiceListener());
AppDependencies.getDatabaseObserver().registerAttachmentDeletedObserver(attachmentDeletionObserver);
@@ -121,11 +109,6 @@ public class VoiceNotePlaybackService extends MediaSessionService {
mediaSession = null;
}
KeyClearedReceiver receiver = keyClearedReceiver;
if (receiver != null) {
receiver.unregister();
}
clearListener();
super.onDestroy();
}
@@ -133,6 +116,10 @@ public class VoiceNotePlaybackService extends MediaSessionService {
@Nullable
@Override
public MediaSession onGetSession(@NonNull MediaSession.ControllerInfo controllerInfo) {
if (Build.VERSION.SDK_INT >= 28 && controllerInfo.getUid() != Process.myUid()) {
Log.w(TAG, "Denying session to external caller: " + controllerInfo.getPackageName());
return null;
}
return mediaSession;
}
@@ -375,71 +362,6 @@ public class VoiceNotePlaybackService extends MediaSessionService {
}
}
/**
* Receiver to stop playback and kill the notification if user locks signal via screen lock.
* This registers itself as a receiver on the [Context] as soon as it can.
*/
private static class KeyClearedReceiver extends BroadcastReceiver {
private static final String TAG = Log.tag(KeyClearedReceiver.class);
private static final IntentFilter KEY_CLEARED_FILTER = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT);
private final Context context;
private final ListenableFuture<MediaController> controllerFuture;
private MediaController controller;
private boolean registered;
private KeyClearedReceiver(@NonNull Context context, @NonNull SessionToken token) {
this.context = context;
Log.d(TAG, "Creating media controller…");
controllerFuture = new MediaController.Builder(context, token).buildAsync();
Futures.addCallback(controllerFuture, new FutureCallback<>() {
@Override
public void onSuccess(@Nullable MediaController result) {
Log.d(TAG, "Successfully created media controller.");
controller = result;
register();
}
@Override
public void onFailure(@NonNull Throwable t) {
Log.w(TAG, "KeyClearedReceiver.onFailure", t);
}
}, ContextCompat.getMainExecutor(context));
}
void register() {
if (controller == null) {
Log.w(TAG, "Failed to register KeyClearedReceiver because MediaController was null.");
return;
}
if (!registered) {
ContextCompat.registerReceiver(context, this, KEY_CLEARED_FILTER, ContextCompat.RECEIVER_NOT_EXPORTED);
registered = true;
Log.d(TAG, "Successfully registered.");
}
}
void unregister() {
if (registered) {
context.unregisterReceiver(this);
registered = false;
}
MediaController.releaseFuture(controllerFuture);
}
@Override
public void onReceive(Context context, Intent intent) {
if (controller == null) {
Log.w(TAG, "Received broadcast but could not stop playback because MediaController was null.");
} else {
Log.i(TAG, "Received broadcast, stopping playback.");
controller.stop();
}
}
}
private static class MediaSessionServiceListener implements Listener {
@Override
public void onForegroundServiceStartNotAllowedException() {
@@ -8,7 +8,9 @@ package org.thoughtcrime.securesms.components.voice
import android.content.Context
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Process
import android.widget.Toast
import androidx.annotation.MainThread
import androidx.annotation.OptIn
@@ -94,6 +96,10 @@ class VoiceNotePlayerCallback(val context: Context, val player: VoiceNotePlayer)
private var latestUri = Uri.EMPTY
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult {
if (Build.VERSION.SDK_INT >= 28 && controller.uid != Process.myUid()) {
Log.w(TAG, "Rejecting connection from external caller: ${controller.packageName}")
return MediaSession.ConnectionResult.reject()
}
return MediaSession.ConnectionResult.accept(CUSTOM_COMMANDS, SUPPORTED_ACTIONS)
}
@@ -3,8 +3,7 @@ package org.thoughtcrime.securesms.components.webrtc;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import org.signal.core.util.SetUtil;
import org.thoughtcrime.securesms.events.CallParticipant;
@@ -68,12 +67,12 @@ public final class CallParticipantListUpdate {
public static @NonNull CallParticipantListUpdate computeDeltaUpdate(@NonNull List<CallParticipant> oldList,
@NonNull List<CallParticipant> newList)
{
Set<CallParticipantListUpdate.Wrapper> oldParticipants = Stream.of(oldList)
.filter(p -> p.getCallParticipantId().demuxId != CallParticipantId.DEFAULT_ID)
Set<CallParticipantListUpdate.Wrapper> oldParticipants = oldList.stream()
.filter(p -> p.getCallParticipantId().demuxId != CallParticipantId.DEFAULT_ID)
.map(CallParticipantListUpdate::createWrapper)
.collect(Collectors.toSet());
Set<CallParticipantListUpdate.Wrapper> newParticipants = Stream.of(newList)
.filter(p -> p.getCallParticipantId().demuxId != CallParticipantId.DEFAULT_ID)
Set<CallParticipantListUpdate.Wrapper> newParticipants = newList.stream()
.filter(p -> p.getCallParticipantId().demuxId != CallParticipantId.DEFAULT_ID)
.map(CallParticipantListUpdate::createWrapper)
.collect(Collectors.toSet());
Set<CallParticipantListUpdate.Wrapper> added = SetUtil.difference(newParticipants, oldParticipants);
@@ -4,7 +4,6 @@ import android.content.Context
import androidx.annotation.Discouraged
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import com.annimon.stream.OptionalLong
import kotlinx.collections.immutable.toImmutableList
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls.FoldableState
@@ -19,6 +18,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.ringrtc.CameraState
import org.thoughtcrime.securesms.service.webrtc.collections.ParticipantCollection
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState
import java.util.Optional
import java.util.concurrent.TimeUnit
/**
@@ -37,7 +37,7 @@ data class CallParticipantsState(
val isInPipMode: Boolean = false,
private val showVideoForOutgoing: Boolean = false,
val isViewingFocusedParticipant: Boolean = false,
val remoteDevicesCount: OptionalLong = OptionalLong.empty(),
val remoteDevicesCount: Optional<Long> = Optional.empty(),
private val foldableState: FoldableState = FoldableState.flat(),
val isInOutgoingRingingMode: Boolean = false,
val recipient: Recipient = Recipient.UNKNOWN,
@@ -87,11 +87,11 @@ data class CallParticipantsState(
return listParticipants
}
val participantCount: OptionalLong
val participantCount: Optional<Long>
get() {
val includeSelf = groupCallState == WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED
return remoteDevicesCount.map { l: Long -> l + if (includeSelf) 1L else 0L }
.or { if (includeSelf) OptionalLong.of(1L) else OptionalLong.empty() }
.or { if (includeSelf) Optional.of(1L) else Optional.empty() }
}
fun getPreJoinGroupDescription(context: Context): String? {
@@ -69,7 +69,6 @@ import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.RemoteConfig
/**
* Renders information about a call (1:1, group, or call link) and provides actions available for
@@ -91,7 +90,7 @@ object CallInfoView {
inCallLobby = state.callState == WebRtcViewModel.State.CALL_PRE_JOIN,
ringGroup = state.ringGroup,
includeSelf = state.groupCallState === WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED || state.groupCallState === WebRtcViewModel.GroupCallState.IDLE,
participantCount = if (state.participantCount.isPresent) state.participantCount.asLong.toInt() else 0,
participantCount = if (state.participantCount.isPresent) state.participantCount.get().toInt() else 0,
remoteParticipants = state.allRemoteParticipants.sortedBy { it.callParticipantId.recipientId },
localParticipant = state.localParticipant,
groupMembers = state.groupMembers.filterNot { it.member.isSelf },
@@ -120,7 +119,6 @@ object CallInfoView {
onContactDetails = callbacks::onContactDetails,
onViewSafetyNumber = callbacks::onViewSafetyNumber,
onGoToChat = callbacks::onGoToChat,
isInternalUser = RemoteConfig.internalUser,
modifier = modifier
)
}
@@ -169,7 +167,6 @@ private fun CallInfo(
onContactDetails: (CallParticipant) -> Unit = {},
onViewSafetyNumber: (CallParticipant) -> Unit = {},
onGoToChat: (CallParticipant) -> Unit = {},
isInternalUser: Boolean = false,
modifier: Modifier = Modifier
) {
var selectedParticipant by remember { mutableStateOf<CallParticipant?>(null) }
@@ -278,14 +275,10 @@ private fun CallInfo(
isSelfAdmin = controlAndInfoState.isSelfAdmin() && !participantsState.inCallLobby,
isCallLink = controlAndInfoState.callLink != null,
onBlockClicked = onBlock,
onParticipantClicked = if (isInternalUser) {
{ participant ->
if (!participant.recipient.isSelf) {
selectedParticipant = participant
}
onParticipantClicked = { participant ->
if (!participant.recipient.isSelf) {
selectedParticipant = participant
}
} else {
null
}
)
}
@@ -14,11 +14,17 @@ import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import org.signal.core.ui.compose.AllNightPreviews
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
@@ -33,7 +39,7 @@ fun CallParticipantsPager(
pagerState: PagerState,
modifier: Modifier = Modifier,
onTap: (() -> Unit)? = null,
onParticipantLongPress: ((CallParticipant) -> Unit)? = null
onParticipantLongPress: ((CallParticipant, Offset) -> Unit)? = null
) {
if (callParticipantsPagerState.focusedParticipant == null) {
return
@@ -57,12 +63,15 @@ fun CallParticipantsPager(
itemKey = { it.callParticipantId }
) { participant, itemModifier ->
val longPressModifier = if (!participant.recipient.isSelf && currentOnLongPress.value != null) {
itemModifier.pointerInput(participant.callParticipantId) {
detectTapGestures(
onTap = { currentOnTap.value?.invoke() },
onLongPress = { currentOnLongPress.value?.invoke(participant) }
)
}
var itemWindowOrigin by remember(participant.callParticipantId) { mutableStateOf(Offset.Zero) }
itemModifier
.onGloballyPositioned { coords -> itemWindowOrigin = coords.positionInRoot() }
.pointerInput(participant.callParticipantId) {
detectTapGestures(
onTap = { currentOnTap.value?.invoke() },
onLongPress = { local -> currentOnLongPress.value?.invoke(participant, itemWindowOrigin + local) }
)
}
} else {
itemModifier
}
@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
@@ -47,6 +48,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
@@ -55,6 +57,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -127,7 +130,6 @@ fun CallScreen(
onWifiToCellularPopupDismissed: () -> Unit = {},
onSwipeToSpeakerHintDismissed: () -> Unit = {},
onRemoteMuteToastDismissed: () -> Unit = {},
isInternalUser: Boolean = false,
isSelfAdmin: Boolean = false,
isCallLink: Boolean = false,
onMuteAudio: (CallParticipant) -> Unit = {},
@@ -329,13 +331,20 @@ fun CallScreen(
}
} else if (webRtcCallState.isPassedPreJoin) {
var longPressedParticipantId by remember { mutableStateOf<CallParticipantId?>(null) }
var longPressWindowOffset by remember { mutableStateOf(Offset.Zero) }
var anchorWindowOrigin by remember { mutableStateOf(Offset.Zero) }
val longPressedParticipant = longPressedParticipantId?.let { id ->
callParticipantsPagerState.callParticipants.find { it.callParticipantId == id }
}
val density = LocalDensity.current
val contextMenuAnchorOffset = remember(longPressWindowOffset, anchorWindowOrigin, density) {
val local = longPressWindowOffset - anchorWindowOrigin
with(density) { IntOffset(local.x.toInt(), local.y.toInt()) }
}
CallElementsLayout(
callGridSlot = {
Box {
Box(modifier = Modifier.onGloballyPositioned { anchorWindowOrigin = it.positionInRoot() }) {
CallParticipantsPager(
callParticipantsPagerState = callParticipantsPagerState,
pagerState = callScreenController.callParticipantsVerticalPagerState,
@@ -356,24 +365,25 @@ fun CallScreen(
}
}
},
onParticipantLongPress = if (isInternalUser) {
{ participant -> longPressedParticipantId = participant.callParticipantId }
} else {
null
onParticipantLongPress = { participant, windowOffset ->
longPressedParticipantId = participant.callParticipantId
longPressWindowOffset = windowOffset
}
)
ParticipantContextMenu(
participant = longPressedParticipant,
isSelfAdmin = isSelfAdmin,
isCallLink = isCallLink,
onDismiss = { longPressedParticipantId = null },
onMuteAudio = onMuteAudio,
onRemoveFromCall = onRemoveFromCall,
onContactDetails = onContactDetails,
onViewSafetyNumber = onViewSafetyNumber,
onGoToChat = onGoToChat
)
Box(modifier = Modifier.offset { contextMenuAnchorOffset }) {
ParticipantContextMenu(
participant = longPressedParticipant,
isSelfAdmin = isSelfAdmin,
isCallLink = isCallLink,
onDismiss = { longPressedParticipantId = null },
onMuteAudio = onMuteAudio,
onRemoveFromCall = onRemoveFromCall,
onContactDetails = onContactDetails,
onViewSafetyNumber = onViewSafetyNumber,
onGoToChat = onGoToChat
)
}
}
},
pictureInPictureSlot = {
@@ -49,7 +49,6 @@ import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDial
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.WindowUtil
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState
import kotlin.time.Duration.Companion.seconds
@@ -221,7 +220,6 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
onSwipeToSpeakerHintDismissed = { callScreenViewModel.callScreenState.update { it.copy(displaySwipeToSpeakerHint = false) } },
onRemoteMuteToastDismissed = { callScreenViewModel.callScreenState.update { it.copy(remoteMuteToastMessage = null) } },
callParticipantUpdatePopupController = callParticipantUpdatePopupController,
isInternalUser = RemoteConfig.internalUser,
isSelfAdmin = controlAndInfoState.isSelfAdmin(),
isCallLink = controlAndInfoState.callLink != null,
onMuteAudio = callInfoCallbacks::onMuteAudio,
@@ -21,6 +21,7 @@ import android.view.Surface
import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatDelegate
@@ -148,6 +149,20 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
initializeResources()
initializeViewModel()
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (viewModel.callParticipantsStateSnapshot.callState != WebRtcViewModel.State.CALL_INCOMING && enterPipModeIfPossible()) {
return
}
isEnabled = false
onBackPressedDispatcher.onBackPressed()
isEnabled = true
}
}
)
// Restore saved state if recreated while in PIP mode
val savedAspectRatio = savedInstanceState?.getFloat(SAVED_STATE_PIP_ASPECT_RATIO, 0f) ?: 0f
lastLocalParticipantLandscape = savedInstanceState?.getBoolean(SAVED_STATE_LOCAL_PARTICIPANT_LANDSCAPE, false) ?: false
@@ -331,12 +346,6 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
}
}
override fun onBackPressed() {
if (viewModel.callParticipantsStateSnapshot.callState == WebRtcViewModel.State.CALL_INCOMING || !enterPipModeIfPossible()) {
super.onBackPressed()
}
}
override fun onSendAnywayAfterSafetyNumberChange(changedRecipients: MutableList<RecipientId>) {
val state: CallParticipantsState = viewModel.callParticipantsStateSnapshot ?: return
@@ -1369,7 +1378,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
}
override fun onNavigateUpClicked() {
onBackPressed()
onBackPressedDispatcher.onBackPressed()
}
override fun toggleControls() {
@@ -1,76 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.compose
import android.app.Activity
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.PixelCopy
import android.view.View
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.boundsInWindow
/**
* Helper class for screenshotting compose views.
*
* You need to call bind from the compose, passing in the
* LocalView.current view with bounds fetched from when the
* composable is globally positioned.
*
* See QrCodeBadge.kt for an example
*/
class ScreenshotController {
private var screenshotCallback: (() -> Bitmap?)? = null
fun bind(view: View, bounds: Rect?) {
if (bounds == null) {
screenshotCallback = null
return
}
screenshotCallback = {
val bitmap = Bitmap.createBitmap(
bounds.width.toInt(),
bounds.height.toInt(),
Bitmap.Config.ARGB_8888
)
if (Build.VERSION.SDK_INT >= 26) {
PixelCopy.request(
(view.context as Activity).window,
android.graphics.Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt()),
bitmap,
{},
Handler(Looper.getMainLooper())
)
} else {
val canvas = Canvas(bitmap)
.apply {
translate(-bounds.left, -bounds.top)
}
view.draw(canvas)
}
bitmap
}
}
fun screenshot(): Bitmap? {
return screenshotCallback?.invoke()
}
}
fun LayoutCoordinates.getScreenshotBounds(): Rect {
return if (Build.VERSION.SDK_INT >= 26) {
this.boundsInWindow()
} else {
this.boundsInRoot()
}
}
@@ -1,168 +0,0 @@
package org.thoughtcrime.securesms.contacts;
/*
* Copyright (C) 2006 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import android.database.AbstractCursor;
import android.database.CursorWindow;
import java.util.ArrayList;
/**
* A convenience class that presents a two-dimensional ArrayList
* as a Cursor.
*/
public class ArrayListCursor extends AbstractCursor {
private String[] mColumnNames;
private ArrayList<Object>[] mRows;
@SuppressWarnings({"unchecked"})
public ArrayListCursor(String[] columnNames, ArrayList<ArrayList> rows) {
int colCount = columnNames.length;
boolean foundID = false;
// Add an _id column if not in columnNames
for (int i = 0; i < colCount; ++i) {
if (columnNames[i].compareToIgnoreCase("_id") == 0) {
mColumnNames = columnNames;
foundID = true;
break;
}
}
if (!foundID) {
mColumnNames = new String[colCount + 1];
System.arraycopy(columnNames, 0, mColumnNames, 0, columnNames.length);
mColumnNames[colCount] = "_id";
}
int rowCount = rows.size();
mRows = new ArrayList[rowCount];
for (int i = 0; i < rowCount; ++i) {
mRows[i] = rows.get(i);
if (!foundID) {
mRows[i].add(i);
}
}
}
@Override
public void fillWindow(int position, CursorWindow window) {
if (position < 0 || position > getCount()) {
return;
}
window.acquireReference();
try {
int oldpos = mPos;
mPos = position - 1;
window.clear();
window.setStartPosition(position);
int columnNum = getColumnCount();
window.setNumColumns(columnNum);
while (moveToNext() && window.allocRow()) {
for (int i = 0; i < columnNum; i++) {
final Object data = mRows[mPos].get(i);
if (data != null) {
if (data instanceof byte[]) {
byte[] field = (byte[]) data;
if (!window.putBlob(field, mPos, i)) {
window.freeLastRow();
break;
}
} else {
String field = data.toString();
if (!window.putString(field, mPos, i)) {
window.freeLastRow();
break;
}
}
} else {
if (!window.putNull(mPos, i)) {
window.freeLastRow();
break;
}
}
}
}
mPos = oldpos;
} catch (IllegalStateException e){
// simply ignore it
} finally {
window.releaseReference();
}
}
@Override
public int getCount() {
return mRows.length;
}
public boolean deleteRow() {
return false;
}
@Override
public String[] getColumnNames() {
return mColumnNames;
}
@Override
public byte[] getBlob(int columnIndex) {
return (byte[]) mRows[mPos].get(columnIndex);
}
@Override
public String getString(int columnIndex) {
Object cell = mRows[mPos].get(columnIndex);
return (cell == null) ? null : cell.toString();
}
@Override
public short getShort(int columnIndex) {
Number num = (Number) mRows[mPos].get(columnIndex);
return num.shortValue();
}
@Override
public int getInt(int columnIndex) {
Number num = (Number) mRows[mPos].get(columnIndex);
return num.intValue();
}
@Override
public long getLong(int columnIndex) {
Number num = (Number) mRows[mPos].get(columnIndex);
return num.longValue();
}
@Override
public float getFloat(int columnIndex) {
Number num = (Number) mRows[mPos].get(columnIndex);
return num.floatValue();
}
@Override
public double getDouble(int columnIndex) {
Number num = (Number) mRows[mPos].get(columnIndex);
return num.doubleValue();
}
@Override
public boolean isNull(int columnIndex) {
return mRows[mPos].get(columnIndex) == null;
}
}
@@ -1,168 +0,0 @@
/**
* Copyright (C) 2011 Whisper Systems
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.contacts;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import android.provider.ContactsContract.CommonDataKinds.Phone;
import android.provider.ContactsContract.Contacts;
import java.util.LinkedList;
import java.util.List;
/**
* This class was originally a layer of indirection between
* ContactAccessorNewApi and ContactAccessorOldApi, which corresponded
* to the API changes between 1.x and 2.x.
*
* Now that we no longer support 1.x, this class mostly serves as a place
* to encapsulate Contact-related logic. It's still a singleton, mostly
* just because that's how it's currently called from everywhere.
*
* @author Moxie Marlinspike
*/
public class ContactAccessor {
private static final ContactAccessor instance = new ContactAccessor();
public static ContactAccessor getInstance() {
return instance;
}
public ContactData getContactData(Context context, Uri uri) {
String displayName = getNameFromContact(context, uri);
long id = Long.parseLong(uri.getLastPathSegment());
ContactData contactData = new ContactData(id, displayName);
try (Cursor numberCursor = context.getContentResolver().query(Phone.CONTENT_URI,
null,
Phone.CONTACT_ID + " = ?",
new String[] { contactData.id + "" },
null))
{
while (numberCursor != null && numberCursor.moveToNext()) {
int type = numberCursor.getInt(numberCursor.getColumnIndexOrThrow(Phone.TYPE));
String label = numberCursor.getString(numberCursor.getColumnIndexOrThrow(Phone.LABEL));
String number = numberCursor.getString(numberCursor.getColumnIndexOrThrow(Phone.NUMBER));
String typeLabel = Phone.getTypeLabel(context.getResources(), type, label).toString();
contactData.numbers.add(new NumberData(typeLabel, number));
}
}
return contactData;
}
private String getNameFromContact(Context context, Uri uri) {
Cursor cursor = null;
try {
cursor = context.getContentResolver().query(uri, new String[] { Contacts.DISPLAY_NAME }, null, null, null);
if (cursor != null && cursor.moveToFirst()) {
return cursor.getString(0);
}
} finally {
if (cursor != null)
cursor.close();
}
return null;
}
public static class NumberData implements Parcelable {
public static final Parcelable.Creator<NumberData> CREATOR = new Parcelable.Creator<NumberData>() {
public NumberData createFromParcel(Parcel in) {
return new NumberData(in);
}
public NumberData[] newArray(int size) {
return new NumberData[size];
}
};
public final String number;
public final String type;
public NumberData(String type, String number) {
this.type = type;
this.number = number;
}
public NumberData(Parcel in) {
number = in.readString();
type = in.readString();
}
public int describeContents() {
return 0;
}
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(number);
dest.writeString(type);
}
}
public static class ContactData implements Parcelable {
public static final Parcelable.Creator<ContactData> CREATOR = new Parcelable.Creator<ContactData>() {
public ContactData createFromParcel(Parcel in) {
return new ContactData(in);
}
public ContactData[] newArray(int size) {
return new ContactData[size];
}
};
public final long id;
public final String name;
public final List<NumberData> numbers;
public ContactData(long id, String name) {
this.id = id;
this.name = name;
this.numbers = new LinkedList<NumberData>();
}
public ContactData(Parcel in) {
id = in.readLong();
name = in.readString();
numbers = new LinkedList<NumberData>();
in.readTypedList(numbers, NumberData.CREATOR);
}
public int describeContents() {
return 0;
}
public void writeToParcel(Parcel dest, int flags) {
dest.writeLong(id);
dest.writeString(name);
dest.writeTypedList(numbers);
}
}
}
@@ -7,7 +7,6 @@ import android.content.Context;
import android.content.SyncResult;
import android.os.Bundle;
import com.annimon.stream.Stream;
import org.signal.contacts.SystemContactsRepository;
import org.signal.core.util.logging.Log;
@@ -70,11 +69,10 @@ public class ContactsSyncAdapter extends AbstractThreadedSyncAdapter {
Log.w(TAG, e);
}
} else if (unknownSystemE164s.size() > 0) {
List<Recipient> recipients = Stream.of(unknownSystemE164s)
.filter(s -> s.startsWith("+"))
.map(s -> Recipient.external(s))
.filter(it -> it != null)
.toList();
List<Recipient> recipients = unknownSystemE164s.stream()
.filter(s -> s.startsWith("+"))
.map(s -> Recipient.external(s))
.filter(it -> it != null).collect(Collectors.toList());
Log.i(TAG, "There are " + unknownSystemE164s.size() + " unknown E164s, which are now " + recipients.size() + " recipients. Only syncing these specific contacts.");
@@ -1,35 +0,0 @@
/**
* Copyright (C) 2011 Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.contacts;
/**
* Name and number tuple.
*
* @author Moxie Marlinspike
*
*/
public class NameAndNumber {
public String name;
public String number;
public NameAndNumber(String name, String number) {
this.name = name;
this.number = number;
}
public NameAndNumber() {}
}
@@ -0,0 +1,262 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.view.View
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView.RecyclerViewReadyCallback
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.stories.settings.custom.PrivateStorySettingsFragment
import org.thoughtcrime.securesms.stories.settings.my.MyStorySettingsFragment
import org.thoughtcrime.securesms.stories.settings.privacy.ChooseInitialMyStoryMembershipBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.signal.core.ui.R as CoreUiR
/**
* A composable that displays a paged, selectable contact list driven by a [ContactSearchViewModel].
*
* Intended for use in two ways:
* 1. Directly inside a Compose layout the caller creates and holds a [ContactSearchViewModel]
* via `viewModel()` or a parent composable and passes it in.
* 2. Via [ContactSearchView] in XML/View-based layouts [ContactSearchView] creates the ViewModel
* and delegates its `Content()` to this function.
*
* The [PagingMappingAdapter] is created internally via `remember` and re-created if
* [displayOptions] or [adapterFactory] change.
*
* @param viewModel Drives the list managed by the caller.
* @param mapStateToConfiguration Maps the current [ContactSearchState] to the active
* [ContactSearchConfiguration], re-evaluated whenever state changes.
* @param modifier Modifier applied to the composable root.
* @param displayOptions Controls checkbox and secondary-info visibility.
* @param callbacks Hooks for filtering and reacting to selection changes.
* @param storyFragmentManager [FragmentManager] used to show story-related dialogs.
* Pass `null` to disable story context menus and dialogs.
* @param onListCommitted Called after each list commit with the committed item count.
* @param itemDecorations [RecyclerView.ItemDecoration]s added to the internal list.
* @param contentBottomPadding Extra bottom padding so last items scroll above overlaid UI.
* Automatically disables `clipToPadding` when non-zero.
* @param adapterFactory Factory for the adapter swap for custom adapters (e.g.
* [ContactSelectionListAdapter]).
* @param scrollListeners [RecyclerView.OnScrollListener]s attached to the inner list.
* @param onRecyclerViewReady Called once with the inner [RecyclerView] after first composition.
* Useful for attaching fast-scrollers or custom item animators.
*/
@Composable
fun ContactSearch(
viewModel: ContactSearchViewModel,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
modifier: Modifier = Modifier,
displayOptions: ContactSearchAdapter.DisplayOptions = ContactSearchAdapter.DisplayOptions(),
callbacks: ContactSearchCallbacks = remember { ContactSearchCallbacks.Simple() },
storyFragmentManager: FragmentManager? = null,
onListCommitted: (Int) -> Unit = {},
itemDecorations: List<RecyclerView.ItemDecoration> = emptyList(),
contentBottomPadding: Dp = 0.dp,
adapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory,
scrollListeners: List<RecyclerView.OnScrollListener> = emptyList(),
onRecyclerViewReady: RecyclerViewReadyCallback? = null
) {
val mappingModels by viewModel.mappingModels.collectAsStateWithLifecycle()
val controller by viewModel.controller.collectAsStateWithLifecycle()
val configState by viewModel.configurationState.collectAsStateWithLifecycle()
val currentMapStateToConfiguration by rememberUpdatedState(mapStateToConfiguration)
val currentOnListCommitted by rememberUpdatedState(onListCommitted)
// Held as State references (not delegated) so click-callback lambdas captured inside
// remember() always read the latest value without recreating the adapter.
val currentCallbacks = rememberUpdatedState(callbacks)
val currentStoryFragmentManager = rememberUpdatedState(storyFragmentManager)
val context = LocalContext.current
val contextState = rememberUpdatedState(context)
val adapter = remember(viewModel.fixedContacts, displayOptions, adapterFactory) {
adapterFactory.create(
context = context,
fixedContacts = viewModel.fixedContacts,
displayOptions = displayOptions,
callbacks = DefaultClickCallbacks(viewModel, currentCallbacks, currentStoryFragmentManager),
longClickCallbacks = ContactSearchAdapter.LongClickCallbacksAdapter(),
storyContextMenuCallbacks = DefaultStoryContextMenuCallbacks(viewModel, currentStoryFragmentManager, contextState),
callButtonClickCallbacks = ContactSearchAdapter.EmptyCallButtonClickCallbacks
)
}
LaunchedEffect(mappingModels) {
adapter.submitList(mappingModels) {
currentOnListCommitted(mappingModels.size)
}
}
LaunchedEffect(controller) {
controller?.let { adapter.setPagingController(it) }
}
LaunchedEffect(configState) {
viewModel.setConfiguration(currentMapStateToConfiguration(configState))
}
val recyclerView = remember(context) {
RecyclerView(context).apply {
layoutManager = LinearLayoutManager(context)
}
}
DisposableEffect(recyclerView, itemDecorations) {
itemDecorations.forEach { recyclerView.addItemDecoration(it) }
onDispose {
itemDecorations.forEach { recyclerView.removeItemDecoration(it) }
}
}
DisposableEffect(recyclerView, scrollListeners) {
scrollListeners.forEach { recyclerView.addOnScrollListener(it) }
onDispose {
scrollListeners.forEach { recyclerView.removeOnScrollListener(it) }
}
}
val bottomPaddingPx = with(LocalDensity.current) { contentBottomPadding.roundToPx() }
LaunchedEffect(recyclerView) {
onRecyclerViewReady?.onRecyclerViewReady(recyclerView)
}
AndroidView(
factory = { recyclerView },
update = { rv ->
if (rv.adapter !== adapter) {
rv.adapter = adapter
}
rv.setPadding(0, 0, 0, bottomPaddingPx)
rv.clipToPadding = bottomPaddingPx == 0
rv.clipChildren = bottomPaddingPx == 0
},
modifier = modifier.fillMaxSize()
)
}
private class DefaultClickCallbacks(
private val viewModel: ContactSearchViewModel,
private val callbacks: State<ContactSearchCallbacks>,
private val fragmentManager: State<FragmentManager?>
) : ContactSearchAdapter.ClickCallbacks {
companion object {
private val TAG = Log.tag(DefaultClickCallbacks::class.java)
}
override fun onStoryClicked(view: View, story: ContactSearchData.Story, isSelected: Boolean) {
Log.d(TAG, "onStoryClicked()")
if (story.recipient.isMyStory && !SignalStore.story.userHasBeenNotifiedAboutStories) {
fragmentManager.value?.let { ChooseInitialMyStoryMembershipBottomSheetDialogFragment.show(it) }
} else {
toggle(view, story, isSelected)
}
}
override fun onKnownRecipientClicked(view: View, knownRecipient: ContactSearchData.KnownRecipient, isSelected: Boolean) {
Log.d(TAG, "onKnownRecipientClicked()")
toggle(view, knownRecipient, isSelected)
}
override fun onExpandClicked(expand: ContactSearchData.Expand) {
Log.d(TAG, "onExpandClicked()")
viewModel.expandSection(expand.sectionKey)
}
override fun onChatTypeClicked(view: View, chatTypeRow: ContactSearchData.ChatTypeRow, isSelected: Boolean) {
Log.d(TAG, "onChatTypeClicked()")
if (isSelected) {
viewModel.setKeysNotSelected(setOf(chatTypeRow.contactSearchKey))
} else {
viewModel.setKeysSelected(callbacks.value.onBeforeContactsSelected(view, setOf(chatTypeRow.contactSearchKey)))
}
}
private fun toggle(view: View, data: ContactSearchData, isSelected: Boolean) {
if (isSelected) {
Log.d(TAG, "toggle(OFF) ${data.contactSearchKey}")
callbacks.value.onContactDeselected(view, data.contactSearchKey)
viewModel.setKeysNotSelected(setOf(data.contactSearchKey))
} else {
Log.d(TAG, "toggle(ON) ${data.contactSearchKey}")
viewModel.setKeysSelected(callbacks.value.onBeforeContactsSelected(view, setOf(data.contactSearchKey)))
}
}
}
private class DefaultStoryContextMenuCallbacks(
private val viewModel: ContactSearchViewModel,
private val fragmentManager: State<FragmentManager?>,
private val context: State<Context>
) : ContactSearchAdapter.StoryContextMenuCallbacks {
override fun onOpenStorySettings(story: ContactSearchData.Story) {
val fm = fragmentManager.value ?: return
if (story.recipient.isMyStory) {
MyStorySettingsFragment.createAsDialog().show(fm, null)
} else {
PrivateStorySettingsFragment.createAsDialog(story.recipient.requireDistributionListId()).show(fm, null)
}
}
override fun onRemoveGroupStory(story: ContactSearchData.Story, isSelected: Boolean) {
fragmentManager.value ?: return
MaterialAlertDialogBuilder(context.value)
.setTitle(R.string.ContactSearchMediator__remove_group_story)
.setMessage(R.string.ContactSearchMediator__this_will_remove)
.setPositiveButton(R.string.ContactSearchMediator__remove) { _, _ -> viewModel.removeGroupStory(story) }
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
override fun onDeletePrivateStory(story: ContactSearchData.Story, isSelected: Boolean) {
fragmentManager.value ?: return
val ctx = context.value
MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.ContactSearchMediator__delete_story)
.setMessage(ctx.getString(R.string.ContactSearchMediator__delete_the_custom, story.recipient.getDisplayName(ctx)))
.setPositiveButton(SpanUtil.color(ContextCompat.getColor(ctx, CoreUiR.color.signal_colorError), ctx.getString(R.string.ContactSearchMediator__delete))) { _, _ -> viewModel.deletePrivateStory(story) }
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
}
@DayNightPreviews
@Composable
private fun ContactSearchPreview() {
Previews.Preview {
Box(modifier = Modifier.fillMaxSize())
}
}
@@ -825,6 +825,37 @@ open class ContactSearchAdapter(
class LongClickCallbacksAdapter : LongClickCallbacks {
override fun onKnownRecipientLongClick(view: View, data: ContactSearchData.KnownRecipient): Boolean = false
}
/**
* Creates a [PagingMappingAdapter] backed by [ContactSearchAdapter] (or a subclass).
* Pass a custom implementation to inject alternative adapters for testing or specialised UIs.
*/
fun interface AdapterFactory {
fun create(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayOptions: DisplayOptions,
callbacks: ClickCallbacks,
longClickCallbacks: LongClickCallbacks,
storyContextMenuCallbacks: StoryContextMenuCallbacks,
callButtonClickCallbacks: CallButtonClickCallbacks
): PagingMappingAdapter<ContactSearchKey>
}
/** Standard implementation that creates a plain [ContactSearchAdapter]. */
object DefaultAdapterFactory : AdapterFactory {
override fun create(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayOptions: DisplayOptions,
callbacks: ClickCallbacks,
longClickCallbacks: LongClickCallbacks,
storyContextMenuCallbacks: StoryContextMenuCallbacks,
callButtonClickCallbacks: CallButtonClickCallbacks
): PagingMappingAdapter<ContactSearchKey> {
return ContactSearchAdapter(context, fixedContacts, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks)
}
}
}
private data class RecipientDisplayName(val recipient: Recipient, val displayName: String)
@@ -0,0 +1,49 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contacts.paged
import android.view.View
import org.signal.core.util.logging.Log
/**
* Hooks for observing and intercepting contact selection changes driven by a
* [ContactSearchViewModel]. Pass an implementation to [ContactSearchView.bind] or
* [ContactSearch] to intercept selection events (e.g. apply selection limits or show
* confirmation dialogs) and to react to list commits.
*/
interface ContactSearchCallbacks {
/**
* Called before [contactSearchKeys] are added to the selection. Return the keys that should
* actually be selected return an empty set to cancel the entire selection, or a filtered
* subset to allow only some keys through.
*/
fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey>
/** Called after [contactSearchKey] has been removed from the selection. */
fun onContactDeselected(view: View?, contactSearchKey: ContactSearchKey)
/** Called after each [androidx.recyclerview.widget.RecyclerView.Adapter.submitList] completes, with the committed list [size]. */
fun onAdapterListCommitted(size: Int)
/** No-op implementation — override only the methods you need. */
open class Simple : ContactSearchCallbacks {
override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey> {
Log.d(TAG, "onBeforeContactsSelected() Selecting: ${contactSearchKeys.map { it.toString() }}")
return contactSearchKeys
}
override fun onContactDeselected(view: View?, contactSearchKey: ContactSearchKey) {
Log.i(TAG, "onContactDeselected() Deselected: $contactSearchKey")
}
override fun onAdapterListCommitted(size: Int) = Unit
companion object {
private val TAG = Log.tag(Simple::class.java)
}
}
}
@@ -107,7 +107,7 @@ class ContactSearchConfiguration private constructor(
/**
* A set of arbitrary rows, in the order given in the builder. Usage requires
* an implementation of [ArbitraryRepository] to be passed into [ContactSearchMediator]
* an implementation of [ArbitraryRepository] to be passed into [ContactSearchViewModel.Factory]
*
* Key: [ContactSearchKey.Arbitrary]
* Data: [ContactSearchData.Arbitrary]
@@ -200,7 +200,7 @@ class ContactSearchConfiguration private constructor(
/**
* Chat types that are displayed when creating a chat folder.
*
* Key: [ContactSearchKey.ChatType]
* Key: [ContactSearchKey.ChatTypeSearchKey]
* Data: [ContactSearchData.ChatTypeRow]
* Model: [ContactSearchAdapter.ChatTypeModel]
*/
@@ -1,289 +0,0 @@
package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.view.View
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.search.SearchFilter
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.stories.settings.custom.PrivateStorySettingsFragment
import org.thoughtcrime.securesms.stories.settings.my.MyStorySettingsFragment
import org.thoughtcrime.securesms.stories.settings.privacy.ChooseInitialMyStoryMembershipBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
import java.util.concurrent.TimeUnit
import org.signal.core.ui.R as CoreUiR
/**
* This mediator serves as the delegate for interacting with the ContactSearch* framework.
*
* @param fragment The fragment displaying the content search results.
* @param fixedContacts Contacts which are "pre-selected" (for example, already a member of a group we're adding to)
* @param selectionLimits [SelectionLimits] describing how large the result set can be.
* @param displayCheckBox Whether or not to display checkboxes on items.
* @param displaySecondaryInformation Whether or not to display phone numbers on known contacts.
* @param mapStateToConfiguration Maps a [ContactSearchState] to a [ContactSearchConfiguration]
* @param callbacks Hooks to help process, filter, and react to selection
* @param performSafetyNumberChecks Whether to perform safety number checks for selected users
* @param adapterFactory A factory for creating an instance of [PagingMappingAdapter] to display items
* @param arbitraryRepository A repository for managing [ContactSearchKey.Arbitrary] data
*/
class ContactSearchMediator(
private val fragment: Fragment,
private val fixedContacts: Set<ContactSearchKey> = setOf(),
selectionLimits: SelectionLimits,
private val isMultiSelect: Boolean = true,
displayOptions: ContactSearchAdapter.DisplayOptions,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
private val callbacks: Callbacks = SimpleCallbacks(),
performSafetyNumberChecks: Boolean = true,
adapterFactory: AdapterFactory = DefaultAdapterFactory,
arbitraryRepository: ArbitraryRepository? = null
) {
companion object {
private val TAG = Log.tag(ContactSearchMediator::class.java)
}
private val queryDebouncer = Debouncer(300, TimeUnit.MILLISECONDS)
private val viewModel: ContactSearchViewModel = ViewModelProvider(
fragment,
ContactSearchViewModel.Factory(
selectionLimits = selectionLimits,
isMultiSelect = isMultiSelect,
repository = ContactSearchRepository(),
performSafetyNumberChecks = performSafetyNumberChecks,
arbitraryRepository = arbitraryRepository,
searchRepository = SearchRepository(fragment.requireContext().getString(R.string.note_to_self)),
contactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(fragment.requireContext())
)
)[ContactSearchViewModel::class.java]
val adapter = adapterFactory.create(
context = fragment.requireContext(),
fixedContacts = fixedContacts,
displayOptions = displayOptions,
callbacks = object : ContactSearchAdapter.ClickCallbacks {
override fun onStoryClicked(view: View, story: ContactSearchData.Story, isSelected: Boolean) {
Log.d(TAG, "onStoryClicked() Recipient: ${story.recipient.id}")
toggleStorySelection(view, story, isSelected)
}
override fun onKnownRecipientClicked(view: View, knownRecipient: ContactSearchData.KnownRecipient, isSelected: Boolean) {
Log.d(TAG, "onKnownRecipientClicked() Recipient: ${knownRecipient.recipient.id}")
toggleSelection(view, knownRecipient, isSelected)
}
override fun onExpandClicked(expand: ContactSearchData.Expand) {
Log.d(TAG, "onExpandClicked()")
viewModel.expandSection(expand.sectionKey)
}
override fun onChatTypeClicked(view: View, chatTypeRow: ContactSearchData.ChatTypeRow, isSelected: Boolean) {
Log.d(TAG, "onChatTypeClicked() chatType $chatTypeRow")
toggleChatTypeSelection(view, chatTypeRow, isSelected)
}
},
longClickCallbacks = ContactSearchAdapter.LongClickCallbacksAdapter(),
storyContextMenuCallbacks = StoryContextMenuCallbacks(),
callButtonClickCallbacks = ContactSearchAdapter.EmptyCallButtonClickCallbacks
)
init {
val dataAndSelection: LiveData<Pair<List<ContactSearchData>, Set<ContactSearchKey>>> = LiveDataUtil.combineLatest(
viewModel.data,
viewModel.selectionState,
::Pair
)
dataAndSelection.observe(fragment.viewLifecycleOwner) { (data, selection) ->
adapter.submitList(ContactSearchAdapter.toMappingModelList(data, selection, arbitraryRepository), {
callbacks.onAdapterListCommitted(data.size)
})
}
viewModel.controller.observe(fragment.viewLifecycleOwner) { controller ->
adapter.setPagingController(controller)
}
viewModel.configurationState.observe(fragment.viewLifecycleOwner) {
viewModel.setConfiguration(mapStateToConfiguration(it))
}
}
fun onFilterChanged(filter: String?) {
queryDebouncer.publish {
viewModel.setQuery(filter)
}
}
fun getFilter(): String? = viewModel.getQuery()
fun onConversationFilterRequestChanged(conversationFilterRequest: ConversationFilterRequest) {
viewModel.setConversationFilterRequest(conversationFilterRequest)
}
fun onSearchFilterChanged(searchFilter: SearchFilter) {
viewModel.setSearchFilter(searchFilter)
}
fun setKeysSelected(keys: Set<ContactSearchKey>) {
Log.d(TAG, "setKeysSelected() Keys: ${keys.map { it.toString() }}")
viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(null, keys))
}
fun setKeysNotSelected(keys: Set<ContactSearchKey>) {
keys.forEach {
callbacks.onContactDeselected(null, it)
}
viewModel.setKeysNotSelected(keys)
}
fun clearSelection() {
viewModel.clearSelection()
}
fun getSelectedContacts(): Set<ContactSearchKey> {
return viewModel.getSelectedContacts()
}
fun getFixedContactsSize(): Int {
return fixedContacts.size
}
fun getSelectionState(): LiveData<Set<ContactSearchKey>> {
return viewModel.selectionState
}
fun getErrorEvents(): Observable<ContactSearchError> {
return viewModel.errorEventsStream.observeOn(AndroidSchedulers.mainThread())
}
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.RecipientSearchKey>) {
viewModel.addToVisibleGroupStories(groupStories)
}
fun refresh() {
viewModel.refresh()
}
private fun toggleStorySelection(view: View, contactSearchData: ContactSearchData.Story, isSelected: Boolean) {
if (contactSearchData.recipient.isMyStory && !SignalStore.story.userHasBeenNotifiedAboutStories) {
ChooseInitialMyStoryMembershipBottomSheetDialogFragment.show(fragment.childFragmentManager)
} else {
toggleSelection(view, contactSearchData, isSelected)
}
}
private fun toggleSelection(view: View, contactSearchData: ContactSearchData, isSelected: Boolean) {
return if (isSelected) {
Log.d(TAG, "toggleSelection(OFF) ${contactSearchData.contactSearchKey}")
callbacks.onContactDeselected(view, contactSearchData.contactSearchKey)
viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey))
} else {
Log.d(TAG, "toggleSelection(ON) ${contactSearchData.contactSearchKey}")
viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(view, setOf(contactSearchData.contactSearchKey)))
}
}
private fun toggleChatTypeSelection(view: View, contactSearchData: ContactSearchData, isSelected: Boolean) {
return if (isSelected) {
Log.d(TAG, "toggleSelection(OFF) ${contactSearchData.contactSearchKey}")
viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey))
} else {
Log.d(TAG, "toggleSelection(ON) ${contactSearchData.contactSearchKey}")
viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(view, setOf(contactSearchData.contactSearchKey)))
}
}
private inner class StoryContextMenuCallbacks : ContactSearchAdapter.StoryContextMenuCallbacks {
override fun onOpenStorySettings(story: ContactSearchData.Story) {
if (story.recipient.isMyStory) {
MyStorySettingsFragment.createAsDialog()
.show(fragment.childFragmentManager, null)
} else {
PrivateStorySettingsFragment.createAsDialog(story.recipient.requireDistributionListId())
.show(fragment.childFragmentManager, null)
}
}
override fun onRemoveGroupStory(story: ContactSearchData.Story, isSelected: Boolean) {
MaterialAlertDialogBuilder(fragment.requireContext())
.setTitle(R.string.ContactSearchMediator__remove_group_story)
.setMessage(R.string.ContactSearchMediator__this_will_remove)
.setPositiveButton(R.string.ContactSearchMediator__remove) { _, _ -> viewModel.removeGroupStory(story) }
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
override fun onDeletePrivateStory(story: ContactSearchData.Story, isSelected: Boolean) {
MaterialAlertDialogBuilder(fragment.requireContext())
.setTitle(R.string.ContactSearchMediator__delete_story)
.setMessage(fragment.getString(R.string.ContactSearchMediator__delete_the_custom, story.recipient.getDisplayName(fragment.requireContext())))
.setPositiveButton(SpanUtil.color(ContextCompat.getColor(fragment.requireContext(), CoreUiR.color.signal_colorError), fragment.getString(R.string.ContactSearchMediator__delete))) { _, _ -> viewModel.deletePrivateStory(story) }
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
}
interface Callbacks {
fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey>
fun onContactDeselected(view: View?, contactSearchKey: ContactSearchKey)
fun onAdapterListCommitted(size: Int)
}
open class SimpleCallbacks : Callbacks {
override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey> {
Log.d(TAG, "onBeforeContactsSelected() Selecting: ${contactSearchKeys.map { it.toString() }}")
return contactSearchKeys
}
override fun onContactDeselected(view: View?, contactSearchKey: ContactSearchKey) {
Log.i(TAG, "onContactDeselected() Deselected: $contactSearchKey}")
}
override fun onAdapterListCommitted(size: Int) = Unit
}
/**
* Wraps the construction of a PagingMappingAdapter<ContactSearchKey> so that it can
* be swapped for another implementation, allow listeners to be wrapped, etc.
*/
fun interface AdapterFactory {
fun create(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayOptions: ContactSearchAdapter.DisplayOptions,
callbacks: ContactSearchAdapter.ClickCallbacks,
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks,
callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
): PagingMappingAdapter<ContactSearchKey>
}
private object DefaultAdapterFactory : AdapterFactory {
override fun create(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayOptions: ContactSearchAdapter.DisplayOptions,
callbacks: ContactSearchAdapter.ClickCallbacks,
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks,
callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
): PagingMappingAdapter<ContactSearchKey> {
return ContactSearchAdapter(context, fixedContacts, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks)
}
}
}
@@ -0,0 +1,137 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.util.AttributeSet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView
/**
* A Compose-compatible wrapper view for the ContactSearch framework.
*
* Usage:
* 1. Create a [ContactSearchViewModel] in the host fragment (via `viewModels { ... }` or
* `ViewModelProvider`).
* 2. Declare `<ContactSearchView>` in your fragment's XML layout.
* 3. Call [bind] from `onViewCreated`, passing the ViewModel and the Fragment.
* 4. Call ViewModel methods directly for all operations, including query updates.
*/
class ContactSearchView : AbstractComposeView {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
/**
* Called once with the inner [RecyclerView] after first composition.
* Java callers may implement this as a lambda: `rv -> fastScroller.setRecyclerView(rv)`.
*/
fun interface RecyclerViewReadyCallback {
fun onRecyclerViewReady(recyclerView: RecyclerView)
}
private var viewModel: ContactSearchViewModel? by mutableStateOf(null)
private var currentFragmentManager: FragmentManager? = null
private var currentDisplayOptions: ContactSearchAdapter.DisplayOptions? = null
private var currentMapStateToConfiguration: ((ContactSearchState) -> ContactSearchConfiguration)? = null
private var currentCallbacks: ContactSearchCallbacks = ContactSearchCallbacks.Simple()
private var currentItemDecorations: List<RecyclerView.ItemDecoration> = emptyList()
private var currentContentBottomPadding: Dp = 0.dp
private var currentAdapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory
private var currentScrollListeners: List<RecyclerView.OnScrollListener> = emptyList()
private var recyclerView: RecyclerView? = null
private var currentOnRecyclerViewReady: RecyclerViewReadyCallback? = null
init {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
}
/**
* Configures and activates the contact search. Must be called exactly once from the host
* fragment's `onViewCreated`. The [viewModel] must be created and held by the caller so it
* can be accessed directly for selection queries and mutations.
*
* Pre-selected/fixed contacts (e.g. existing group members) are owned by the ViewModel and
* passed via [ContactSearchViewModel.Factory].
*
* @param viewModel The externally-created ViewModel. Fixed contacts are a
* constructor parameter of [ContactSearchViewModel.Factory].
* @param fragmentManager Used for showing story-related dialogs. Pass
* [childFragmentManager] from a Fragment or
* [supportFragmentManager] from an Activity.
* @param displayOptions Controls checkbox and secondary-info visibility.
* @param mapStateToConfiguration Maps the current [ContactSearchState] to the active
* [ContactSearchConfiguration], re-evaluated on every state change.
* @param callbacks Hooks for filtering and reacting to selection changes.
* @param itemDecorations [RecyclerView.ItemDecoration]s added to the internal list.
* @param contentBottomPaddingDp Extra bottom padding (in dp) so last items scroll above overlaid
* UI. Java callers pass a plain `float`.
* @param adapterFactory Factory for the adapter swap for custom adapters.
* @param scrollListeners [RecyclerView.OnScrollListener]s attached to the inner list.
* @param onRecyclerViewReady Called once with the inner [RecyclerView] after first composition.
* Useful for attaching fast-scrollers or custom item animators.
*/
fun bind(
viewModel: ContactSearchViewModel,
fragmentManager: FragmentManager,
displayOptions: ContactSearchAdapter.DisplayOptions,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
callbacks: ContactSearchCallbacks = ContactSearchCallbacks.Simple(),
itemDecorations: List<RecyclerView.ItemDecoration> = emptyList(),
contentBottomPaddingDp: Float = 0f,
adapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory,
scrollListeners: List<RecyclerView.OnScrollListener> = emptyList(),
onRecyclerViewReady: RecyclerViewReadyCallback? = null
) {
check(this.viewModel == null) { "ContactSearchView.bind() may only be called once" }
currentFragmentManager = fragmentManager
currentDisplayOptions = displayOptions
currentMapStateToConfiguration = mapStateToConfiguration
currentCallbacks = callbacks
currentItemDecorations = itemDecorations
currentContentBottomPadding = contentBottomPaddingDp.dp
currentAdapterFactory = adapterFactory
currentScrollListeners = scrollListeners
currentOnRecyclerViewReady = onRecyclerViewReady
this.viewModel = viewModel // triggers recomposition
}
override fun canScrollVertically(direction: Int): Boolean {
return recyclerView?.canScrollVertically(direction) ?: super.canScrollVertically(direction)
}
@Composable
override fun Content() {
val vm = viewModel ?: return
val displayOptions = currentDisplayOptions ?: return
val mapStateToConfiguration = currentMapStateToConfiguration ?: return
ContactSearch(
viewModel = vm,
mapStateToConfiguration = mapStateToConfiguration,
displayOptions = displayOptions,
callbacks = currentCallbacks,
storyFragmentManager = currentFragmentManager,
onListCommitted = { currentCallbacks.onAdapterListCommitted(it) },
itemDecorations = currentItemDecorations,
contentBottomPadding = currentContentBottomPadding,
adapterFactory = currentAdapterFactory,
scrollListeners = currentScrollListeners,
onRecyclerViewReady = RecyclerViewReadyCallback { recyclerView ->
this@ContactSearchView.recyclerView = recyclerView
currentOnRecyclerViewReady?.onRecyclerViewReady(recyclerView)
}
)
}
}
@@ -1,45 +1,66 @@
package org.thoughtcrime.securesms.contacts.paged
import androidx.compose.runtime.Stable
import androidx.lifecycle.AbstractSavedStateViewModelFactory
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.map
import androidx.lifecycle.switchMap
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.subjects.PublishSubject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import org.signal.paging.LivePagedData
import kotlinx.coroutines.launch
import org.signal.paging.PagedData
import org.signal.paging.PagingConfig
import org.signal.paging.PagingController
import org.signal.paging.StateFlowPagedData
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.search.SearchFilter
import org.thoughtcrime.securesms.search.SearchRepository
import org.thoughtcrime.securesms.util.livedata.Store
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.whispersystems.signalservice.api.util.Preconditions
/**
* Simple, reusable view model that manages a ContactSearchPagedDataSource as well as filter and expansion state.
* Manages paged contact search data, query/filter state, and contact selection. Drives
* [ContactSearch] / [ContactSearchView] and can also be used standalone via
* [bindAdapterToLifecycle] when only the data pipeline is needed (no Compose surface).
*
* Create via [Factory] and scope to the host Fragment or Activity. All state is exposed as
* [kotlinx.coroutines.flow.StateFlow] so it can be collected from Compose or coroutine scopes.
*
* @param fixedContacts Pre-selected contacts that cannot be deselected (e.g. existing group
* members). Owned here rather than by the UI layer.
*/
@Stable
class ContactSearchViewModel(
private val savedStateHandle: SavedStateHandle,
private val selectionLimits: SelectionLimits,
private val isMultiSelect: Boolean,
private val contactSearchRepository: ContactSearchRepository,
private val performSafetyNumberChecks: Boolean,
private val arbitraryRepository: ArbitraryRepository?,
val arbitraryRepository: ArbitraryRepository?,
private val searchRepository: SearchRepository,
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository,
val fixedContacts: Set<ContactSearchKey> = emptySet()
) : ViewModel() {
companion object {
@@ -56,16 +77,41 @@ class ContactSearchViewModel(
.setStartIndex(0)
.build()
private val pagedData = MutableLiveData<LivePagedData<ContactSearchKey, ContactSearchData>>()
private val configurationStore = Store(ContactSearchState(query = savedStateHandle[QUERY]))
private val pagedData = MutableStateFlow<StateFlowPagedData<ContactSearchKey, ContactSearchData>?>(null)
private val internalConfigurationState = MutableStateFlow(ContactSearchState(query = savedStateHandle[QUERY]))
private val internalSelectedContacts = MutableStateFlow<Set<ContactSearchKey>>(emptySet())
private val errorEvents = PublishSubject.create<ContactSearchError>()
private val rawQuery = MutableStateFlow<String?>(savedStateHandle[QUERY])
val controller: LiveData<PagingController<ContactSearchKey>> = pagedData.map { it.controller }
val data: LiveData<List<ContactSearchData>> = pagedData.switchMap { it.data }
val configurationState: LiveData<ContactSearchState> = configurationStore.stateLiveData
private val selectedContacts: StateFlow<Set<ContactSearchKey>> = internalSelectedContacts
val selectionState: LiveData<Set<ContactSearchKey>> = selectedContacts.asLiveData()
init {
viewModelScope.launch {
rawQuery.drop(1).debounce(300).collect { query ->
savedStateHandle[QUERY] = query
internalConfigurationState.update { it.copy(query = query) }
}
}
}
/** The paging controller for the current data source. Null until [setConfiguration] is called. */
val controller: StateFlow<PagingController<ContactSearchKey>?> = pagedData
.map { it?.controller }
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
/** Raw paged contact data. Prefer [mappingModels] for binding to an adapter. */
val data: StateFlow<List<ContactSearchData>> = pagedData
.flatMapLatest { it?.data ?: flowOf(emptyList()) }
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
/** The current query/filter/expansion state. Changes here trigger a new [setConfiguration] call via the Compose layer or [bindAdapterToLifecycle]. */
val configurationState: StateFlow<ContactSearchState> = internalConfigurationState
/** Currently selected contact keys, excluding [fixedContacts]. */
val selectionState: StateFlow<Set<ContactSearchKey>> = internalSelectedContacts
/** Adapter-ready models combining [data] with [selectionState]. Suitable for direct submission to a [ContactSearchAdapter]. */
val mappingModels: StateFlow<MappingModelList> = combine(data, selectionState) { contactData, selection ->
ContactSearchAdapter.toMappingModelList(contactData, selection, arbitraryRepository)
}.stateIn(viewModelScope, SharingStarted.Eagerly, MappingModelList())
val errorEventsStream: Observable<ContactSearchError> = errorEvents
@@ -80,26 +126,25 @@ class ContactSearchViewModel(
searchRepository = searchRepository,
contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository
)
pagedData.value = PagedData.createForLiveData(pagedDataSource, pagingConfig)
pagedData.value = PagedData.createForStateFlow(pagedDataSource, pagingConfig)
}
fun getQuery(): String? = savedStateHandle[QUERY]
fun getQuery(): String? = rawQuery.value
fun setQuery(query: String?) {
savedStateHandle[QUERY] = query
configurationStore.update { it.copy(query = query) }
rawQuery.value = query
}
fun setConversationFilterRequest(conversationFilterRequest: ConversationFilterRequest) {
configurationStore.update { it.copy(conversationFilterRequest = conversationFilterRequest) }
internalConfigurationState.update { it.copy(conversationFilterRequest = conversationFilterRequest) }
}
fun setSearchFilter(searchFilter: SearchFilter) {
configurationStore.update { it.copy(searchFilter = searchFilter) }
internalConfigurationState.update { it.copy(searchFilter = searchFilter) }
}
fun expandSection(sectionKey: ContactSearchConfiguration.SectionKey) {
configurationStore.update { it.copy(expandedSections = it.expandedSections + sectionKey) }
internalConfigurationState.update { it.copy(expandedSections = it.expandedSections + sectionKey) }
}
fun setKeysSelected(contactSearchKeys: Set<ContactSearchKey>) {
@@ -135,7 +180,7 @@ class ContactSearchViewModel(
}
fun getSelectedContacts(): Set<ContactSearchKey> {
return selectedContacts.value
return internalSelectedContacts.value
}
fun clearSelection() {
@@ -144,7 +189,7 @@ class ContactSearchViewModel(
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.RecipientSearchKey>) {
disposables += contactSearchRepository.markDisplayAsStory(groupStories.map { it.recipientId }).subscribe {
configurationStore.update { state ->
internalConfigurationState.update { state ->
state.copy(
groupStories = state.groupStories + groupStories.map {
val recipient = Recipient.resolved(it.recipientId)
@@ -159,7 +204,7 @@ class ContactSearchViewModel(
Preconditions.checkArgument(story.recipient.isGroup)
setKeysNotSelected(setOf(story.contactSearchKey))
disposables += contactSearchRepository.unmarkDisplayAsStory(story.recipient.requireGroupId()).subscribe {
configurationStore.update { state ->
internalConfigurationState.update { state ->
state.copy(
groupStories = state.groupStories.filter { it.recipient.id == story.recipient.id }.toSet()
)
@@ -176,6 +221,8 @@ class ContactSearchViewModel(
}
}
fun getFixedContactsSize(): Int = fixedContacts.size
fun refresh() {
controller.value?.onDataInvalidated()
}
@@ -187,7 +234,8 @@ class ContactSearchViewModel(
private val performSafetyNumberChecks: Boolean,
private val arbitraryRepository: ArbitraryRepository?,
private val searchRepository: SearchRepository,
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository,
private val fixedContacts: Set<ContactSearchKey> = emptySet()
) : AbstractSavedStateViewModelFactory() {
override fun <T : ViewModel> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T {
return modelClass.cast(
@@ -199,9 +247,31 @@ class ContactSearchViewModel(
performSafetyNumberChecks = performSafetyNumberChecks,
arbitraryRepository = arbitraryRepository,
searchRepository = searchRepository,
contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository
contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository,
fixedContacts = fixedContacts
)
) as T
}
}
}
/**
* Wires the three core flows of [ContactSearchViewModel] to a [PagingMappingAdapter], scoped to
* the given [LifecycleOwner]. Designed for Java callers that create the adapter directly (without
* [ContactSearchView]) and only need the data pipeline, not a full Compose surface.
*
* Call once from `onViewCreated` after constructing the ViewModel and adapter.
*/
fun ContactSearchViewModel.bindAdapterToLifecycle(
lifecycleOwner: LifecycleOwner,
adapter: PagingMappingAdapter<ContactSearchKey>,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration
) {
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch { mappingModels.collect { adapter.submitList(it) } }
launch { controller.collect { it?.let { c -> adapter.setPagingController(c) } } }
launch { configurationState.collect { setConfiguration(mapStateToConfiguration(it)) } }
}
}
}
@@ -14,7 +14,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import com.bumptech.glide.RequestManager;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
@@ -75,9 +75,9 @@ class ContactFieldAdapter extends RecyclerView.Adapter<ContactFieldAdapter.Conta
fields.add(new Field(avatar));
}
fields.addAll(Stream.of(phoneNumbers).map(phone -> new Field(context, phone, locale)).toList());
fields.addAll(Stream.of(emails).map(email -> new Field(context, email)).toList());
fields.addAll(Stream.of(postalAddresses).map(address -> new Field(context, address)).toList());
fields.addAll(phoneNumbers.stream().map(phone -> new Field(context, phone, locale)).collect(Collectors.toList()));
fields.addAll(emails.stream().map(email -> new Field(context, email)).collect(Collectors.toList()));
fields.addAll(postalAddresses.stream().map(address -> new Field(context, address)).collect(Collectors.toList()));
notifyDataSetChanged();
}
@@ -8,7 +8,7 @@ import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import org.thoughtcrime.securesms.contactshare.Contact.Name;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
@@ -82,7 +82,7 @@ class ContactShareEditViewModel extends ViewModel {
}
private <E extends Selectable> List<E> trimSelectables(List<E> selectables) {
return Stream.of(selectables).filter(Selectable::isSelected).toList();
return selectables.stream().filter(Selectable::isSelected).collect(Collectors.toList());
}
@NonNull
@@ -12,7 +12,6 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
@@ -98,7 +97,7 @@ public final class ContactUtil {
return null;
}
List<Phone> mobileNumbers = Stream.of(contact.getPhoneNumbers()).filter(number -> number.getType() == Phone.Type.MOBILE).toList();
List<Phone> mobileNumbers = contact.getPhoneNumbers().stream().filter(number -> number.getType() == Phone.Type.MOBILE).collect(Collectors.toList());
if (mobileNumbers.size() > 0) {
return mobileNumbers.get(0);
}
@@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.adapter.StableIdGenerator;
import java.util.ArrayList;
import java.util.stream.Collectors;
import java.util.List;
import java.util.concurrent.TimeUnit;
@@ -75,7 +76,7 @@ class AttachmentKeyboardMediaAdapter extends RecyclerView.Adapter<AttachmentKeyb
public void setMedia(@NonNull List<Media> media, boolean addFooter) {
this.media.clear();
this.media.addAll(media.stream().map(MediaContent::new).collect(java.util.stream.Collectors.toList()));
this.media.addAll(media.stream().map(MediaContent::new).collect(Collectors.toList()));
if (addFooter) {
this.media.add(new MediaContent(true));
}
@@ -13,12 +13,9 @@ import org.signal.core.models.UriSerializer
import org.signal.core.models.media.Media
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.conversation.ConversationIntents.ConversationScreenType
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.mms.SlideFactory
import org.thoughtcrime.securesms.recipients.Recipient.Companion.resolved
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
@Serializable
@Parcelize
@@ -38,19 +35,12 @@ data class ConversationArgs(
val giftBadge: Badge?,
val shareDataTimestamp: Long,
val conversationScreenType: ConversationScreenType,
val isIncognito: Boolean = false
val isIncognito: Boolean = false,
val hasWallpaper: Boolean = false
) : Parcelable {
@IgnoredOnParcel
val draftMediaType: SlideFactory.MediaType? = SlideFactory.MediaType.from(draftContentType)
@IgnoredOnParcel
val wallpaper: ChatWallpaper?
get() = resolved(recipientId).wallpaper
@IgnoredOnParcel
val chatColors: ChatColors
get() = resolved(recipientId).chatColors
fun canInitializeFromDatabase(): Boolean {
return draftText == null && (draftMedia == null || ConversationIntents.isBubbleIntentUri(draftMedia) || ConversationIntents.isNotificationIntentUri(draftMedia)) && draftMediaType == null
}
@@ -1,327 +0,0 @@
package org.thoughtcrime.securesms.conversation;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat;
import androidx.core.view.ViewKt;
import com.bumptech.glide.RequestManager;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.conversation.colors.AvatarGradientColors;
import org.thoughtcrime.securesms.conversation.v2.data.AvatarDownloadStateCache;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.databinding.ConversationHeaderViewBinding;
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.whispersystems.signalservice.api.util.Preconditions;
public class ConversationHeaderView extends ConstraintLayout {
private static final String TAG = Log.tag(ConversationHeaderView.class);
private static final int FADE_DURATION = 150;
private static final int LOADING_DELAY = 800;
private final ConversationHeaderViewBinding binding;
private boolean inProgress = false;
private Handler handler = new Handler();
public ConversationHeaderView(Context context) {
this(context, null);
}
public ConversationHeaderView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ConversationHeaderView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
inflate(getContext(), R.layout.conversation_header_view, this);
binding = ConversationHeaderViewBinding.bind(this);
}
public void showProgressBar(@NonNull Recipient recipient) {
if (!inProgress) {
inProgress = true;
animateAvatarLoading(recipient);
binding.messageRequestAvatarTapToView.setVisibility(GONE);
binding.messageRequestAvatarTapToView.setOnClickListener(null);
handler.postDelayed(() -> {
boolean isDownloading = AvatarDownloadStateCache.getDownloadState(recipient) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS;
binding.progressBar.setVisibility(isDownloading ? View.VISIBLE : View.GONE);
}, LOADING_DELAY);
}
}
public void hideProgressBar() {
inProgress = false;
binding.progressBar.setVisibility(View.GONE);
}
public void showFailedAvatarDownload(@NonNull Recipient recipient) {
AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.NONE);
binding.progressBar.setVisibility(View.GONE);
binding.messageRequestAvatar.setImageDrawable(AvatarGradientColors.getGradientDrawable(recipient));
}
public void setBadge(@Nullable Recipient recipient) {
if (recipient == null || recipient.isSelf()) {
binding.messageRequestBadge.setBadge(null);
} else {
binding.messageRequestBadge.setBadgeFromRecipient(recipient);
}
}
public void setAvatar(@NonNull RequestManager requestManager, @Nullable Recipient recipient) {
if (recipient == null) {
return;
}
if (AvatarDownloadStateCache.getDownloadState(recipient) != AvatarDownloadStateCache.DownloadState.IN_PROGRESS) {
binding.messageRequestAvatar.setAvatar(requestManager, recipient, false, false, true);
hideProgressBar();
}
if (recipient.getShouldBlurAvatar() && recipient.getHasAvatar()) {
binding.messageRequestAvatarTapToView.setVisibility(VISIBLE);
binding.messageRequestAvatarTapToView.setOnClickListener(v -> {
AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.IN_PROGRESS);
SignalExecutors.BOUNDED.execute(() -> SignalDatabase.recipients().manuallyUpdateShowAvatar(recipient.getId(), true));
if (recipient.isPushV2Group()) {
AvatarGroupsV2DownloadJob.enqueueUnblurredAvatar(recipient.requireGroupId().requireV2());
} else {
RetrieveProfileAvatarJob.enqueueUnblurredAvatar(recipient);
}
});
} else {
binding.messageRequestAvatarTapToView.setVisibility(GONE);
binding.messageRequestAvatarTapToView.setOnClickListener(null);
}
}
public String setTitle(@NonNull Recipient recipient, @NonNull Runnable onTitleClicked) {
CharSequence title = recipient.getDisplayNameForHeadline(getContext());
if (recipient.isIndividual() && !recipient.isSelf()) {
binding.messageRequestTitle.setOnClickListener(v -> onTitleClicked.run());
} else {
binding.messageRequestTitle.setOnClickListener(null);
}
binding.messageRequestTitle.setText(title);
return title.toString();
}
public void showReleaseNoteHeader() {
binding.messageRequestInfo.setVisibility(View.GONE);
binding.releaseHeaderContainer.setVisibility(View.VISIBLE);
binding.releaseHeaderDescription1.setText(prependIcon(getContext().getString(R.string.ReleaseNotes__this_is_official_chat_period), R.drawable.symbol_official_20));
binding.releaseHeaderDescription2.setText(prependIcon(getContext().getString(R.string.ReleaseNotes__keep_up_to_date_period), R.drawable.symbol_bell_20));
}
public void setAbout(@NonNull Recipient recipient) {
String about = recipient.getCombinedAboutAndEmoji();
binding.messageRequestAbout.setText(about);
binding.messageRequestAbout.setVisibility(TextUtils.isEmpty(about) || recipient.isReleaseNotes() ? GONE : VISIBLE);
}
public void setSubtitle(@NonNull CharSequence subtitle, @DrawableRes int iconRes, @Nullable String substring, @Nullable Runnable onClick) {
if (TextUtils.isEmpty(subtitle)) {
hideSubtitle();
return;
}
if (onClick != null && substring != null) {
binding.messageRequestSubtitle.setMovementMethod(LinkMovementMethod.getInstance());
CharSequence builder = SpanUtil.clickSubstring(
subtitle,
substring,
listener -> onClick.run(),
ContextCompat.getColor(getContext(), org.signal.core.ui.R.color.signal_colorOnSurface),
true
);
binding.messageRequestSubtitle.setText(prependIcon(builder, iconRes));
} else {
binding.messageRequestSubtitle.setText(prependIcon(subtitle, iconRes));
}
binding.messageRequestSubtitle.setVisibility(View.VISIBLE);
}
public void setDescription(@Nullable CharSequence description, @DrawableRes int iconRes) {
if (TextUtils.isEmpty(description)) {
hideDescription();
return;
}
binding.messageRequestDescription.setText(prependIcon(description, iconRes));
binding.messageRequestDescription.setVisibility(View.VISIBLE);
updateOutlineVisibility();
}
public @NonNull EmojiTextView getDescription() {
return binding.messageRequestDescription;
}
public void setButton(@NonNull CharSequence button, Runnable onClick) {
binding.messageRequestButton.setText(button);
binding.messageRequestButton.setOnClickListener(v -> onClick.run());
binding.messageRequestButton.setVisibility(View.VISIBLE);
}
public void showWarningSubtitle() {
binding.messageRequestReviewCarefully.setVisibility(View.VISIBLE);
}
public void hideWarningSubtitle() {
binding.messageRequestReviewCarefully.setVisibility(View.GONE);
}
public void setUnverifiedNameSubtitle(@DrawableRes int iconRes, boolean forGroup, @NonNull Runnable onClick) {
binding.messageRequestProfileNameUnverified.setVisibility(View.VISIBLE);
binding.messageRequestProfileNameUnverified.setOnClickListener(view -> onClick.run());
String substring = forGroup ? getContext().getString(R.string.ConversationFragment_group_names)
: getContext().getString(R.string.ConversationFragment_profile_names);
String fullString = forGroup ? getContext().getString(R.string.ConversationFragment_group_names_not_verified, substring)
: getContext().getString(R.string.ConversationFragment_profile_names_not_verified, substring);
CharSequence builder = SpanUtil.underlineSubstring(fullString, substring);
binding.messageRequestProfileNameUnverified.setText(prependIcon(builder, iconRes, forGroup));
}
public void hideUnverifiedNameSubtitle() {
binding.messageRequestProfileNameUnverified.setVisibility(View.GONE);
}
public void showBackgroundBubble(boolean enabled) {
if (enabled) {
setBackgroundResource(R.drawable.wallpaper_bubble_background_18);
} else {
setBackground(null);
}
updateOutlineVisibility();
}
public void hideSubtitle() {
binding.messageRequestSubtitle.setVisibility(View.GONE);
updateOutlineVisibility();
}
public void showDescription() {
binding.messageRequestDescription.setVisibility(View.VISIBLE);
updateOutlineVisibility();
}
public void hideDescription() {
binding.messageRequestDescription.setVisibility(View.GONE);
updateOutlineVisibility();
}
public void hideButton() {
binding.messageRequestButton.setVisibility(View.GONE);
}
public void setLinkifyDescription(boolean enable) {
binding.messageRequestDescription.setMovementMethod(enable ? LongClickMovementMethod.getInstance(getContext()) : null);
}
private void animateAvatarLoading(@NonNull Recipient recipient) {
Drawable loadingProfile = AppCompatResources.getDrawable(getContext(), R.drawable.circle_profile_photo);
ObjectAnimator animator = ObjectAnimator.ofFloat(binding.messageRequestAvatar, "alpha", 1f, 0f).setDuration(FADE_DURATION);
animator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
if (AvatarDownloadStateCache.getDownloadState(recipient) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS) {
binding.messageRequestAvatar.setImageDrawable(loadingProfile);
}
ObjectAnimator.ofFloat(binding.messageRequestAvatar, "alpha", 0f, 1f).setDuration(FADE_DURATION).start();
}
});
animator.start();
}
private void updateOutlineVisibility() {
if (ViewKt.isVisible(binding.messageRequestSubtitle) || ViewKt.isVisible(binding.messageRequestDescription)) {
if (getBackground() != null) {
binding.messageRequestInfoOutline.setVisibility(View.GONE);
binding.messageRequestDivider.setVisibility(View.VISIBLE);
} else {
binding.messageRequestInfoOutline.setVisibility(View.VISIBLE);
binding.messageRequestDivider.setVisibility(View.GONE);
}
} else {
binding.messageRequestInfoOutline.setVisibility(View.GONE);
binding.messageRequestDivider.setVisibility(View.GONE);
}
}
public void updateOutlineBoxSize() {
int visibleCount = 0;
for (int i = 0; i < binding.messageRequestInfo.getChildCount(); i++) {
if (ViewKt.isVisible(binding.messageRequestInfo.getChildAt(i))) {
visibleCount++;
}
}
if (getBackground() != null) {
ViewUtil.setPaddingTop(binding.messageRequestInfo, 0);
ViewUtil.setPaddingBottom(binding.messageRequestInfo, getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_header_padding));
int margin = getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_header_margin);
ViewUtil.setLeftMargin(this, margin);
ViewUtil.setRightMargin(this, margin);
}
int padding = visibleCount == 1 ? getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_header_padding) : getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_header_padding_expanded);
ViewUtil.setPaddingStart(binding.messageRequestInfo, padding);
ViewUtil.setPaddingEnd(binding.messageRequestInfo, padding);
}
private @NonNull CharSequence prependIcon(@NonNull CharSequence input, @DrawableRes int iconRes) {
return prependIcon(input, iconRes, false);
}
private @NonNull CharSequence prependIcon(@NonNull CharSequence input, @DrawableRes int iconRes, boolean useIntrinsicWidth) {
Drawable drawable = ContextCompat.getDrawable(getContext(), iconRes);
Preconditions.checkNotNull(drawable);
int width = useIntrinsicWidth ? drawable.getIntrinsicWidth() : (int) DimensionUnit.SP.toPixels(16);
drawable.setBounds(0, 0, width, (int) DimensionUnit.SP.toPixels(16));
drawable.setColorFilter(ContextCompat.getColor(getContext(), org.signal.core.ui.R.color.signal_colorOnSurface), PorterDuff.Mode.SRC_ATOP);
return new SpannableStringBuilder()
.append(SpanUtil.buildCenteredImageSpan(drawable))
.append(SpanUtil.space(8, DimensionUnit.SP))
.append(input);
}
}
@@ -0,0 +1,653 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.widget.ImageView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.LaunchedEffect
import androidx.compose.runtime.SnapshotMutationPolicy
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.coroutines.delay
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.theme.SignalTheme
import org.signal.core.util.BidiUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarImage
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageLarge
import org.thoughtcrime.securesms.conversation.colors.AvatarGradientColors
import org.thoughtcrime.securesms.conversation.v2.data.AvatarDownloadStateCache
import org.thoughtcrime.securesms.fonts.SignalSymbols
import org.thoughtcrime.securesms.fonts.SignalSymbols.buildSignalSymbolAnnotatedString
import org.thoughtcrime.securesms.fonts.SignalSymbols.signalSymbolText
import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil
import org.thoughtcrime.securesms.messagerequests.GroupInfo
import org.thoughtcrime.securesms.messagerequests.MessageRequestRecipientInfo
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.LongClickMovementMethod
import org.thoughtcrime.securesms.util.SignalE164Util
import org.signal.core.ui.R as CoreUiR
private val AvatarSize = 74.dp
private val AvatarOverlapAbove = 16.dp
private val AvatarOverlapBelow = AvatarSize - AvatarOverlapAbove
private val BorderShape = RoundedCornerShape(40.dp)
class ConversationHeaderView : AbstractComposeView {
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
init {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
}
var callbacks: ConversationHeaderCallbacks = ConversationHeaderCallbacks.Empty
var recipientInfo: MessageRequestRecipientInfo? by mutableStateOf(null, policy = RecipientInfoContentPolicy)
var avatarDownloadState: AvatarDownloadStateCache.DownloadState by mutableStateOf(AvatarDownloadStateCache.DownloadState.NONE)
@Composable
override fun Content() {
val info = recipientInfo ?: return
val recipient = info.recipient
val groupInfo = info.groupInfo
val isSelf = recipient.isSelf
val isReleaseNotes = recipient.isReleaseNotes
val isOfficialAccount = recipient.showVerified
val showUnverifiedName = if (recipient.isGroup) {
!info.groupInfo.nameVerified
} else if (!isOfficialAccount) {
recipient.nickname.isEmpty && !recipient.isSystemContact
} else {
false
}
val displayName = if (isSelf) BidiUtil.isolateBidi(context.getString(R.string.note_to_self)) else recipient.getDisplayName(context)
val phoneNumber = if (!recipient.isGroup && !isOfficialAccount && recipient.shouldShowE164) {
recipient.e164.map { SignalE164Util.prettyPrint(it) }.orElse(null)?.takeIf { it != displayName }
} else {
null
}
SignalTheme {
ConversationHeaderContent(
recipientId = recipient.id,
displayName = displayName,
showVerified = isOfficialAccount,
isSystemContact = recipient.isSystemContact,
showChevron = recipient.isIndividual && !isOfficialAccount,
isSelf = isSelf,
isReleaseNotes = isReleaseNotes,
badge = if (!isOfficialAccount) recipient.featuredBadge else null,
showUnverifiedName = showUnverifiedName,
isGroup = recipient.isGroup,
hasWallpaper = recipient.hasWallpaper,
phoneNumber = phoneNumber,
groupInfo = if (recipient.isGroup) groupInfo else null,
groupDescription = if (recipient.isGroup) groupInfo.description else null,
linkifyGroupDescription = info.messageRequestState?.isAccepted == true,
sharedGroups = info.sharedGroups,
showSafetyTips = info.messageRequestState?.isAccepted == false,
avatarDownloadState = avatarDownloadState,
shouldBlurAvatar = recipient.shouldBlurAvatar && recipient.hasAvatar,
callbacks = callbacks
)
}
}
}
interface ConversationHeaderCallbacks {
fun onSafetyTipsClicked(forGroup: Boolean) = Unit
fun onUnverifiedNameClicked(forGroup: Boolean) = Unit
fun onTitleClicked() = Unit
fun onGroupSettingsClicked() = Unit
fun onShowGroupDescriptionClicked(groupName: String, description: String, linkifyWebLinks: Boolean) = Unit
fun onAvatarTapToViewClicked() = Unit
companion object Empty : ConversationHeaderCallbacks
}
private object RecipientInfoContentPolicy : SnapshotMutationPolicy<MessageRequestRecipientInfo?> {
override fun equivalent(a: MessageRequestRecipientInfo?, b: MessageRequestRecipientInfo?): Boolean {
if (a === b) return true
if (a == null || b == null) return false
return a.recipient.hasSameContent(b.recipient) &&
a.groupInfo == b.groupInfo &&
a.sharedGroups == b.sharedGroups &&
a.messageRequestState == b.messageRequestState
}
}
@Composable
private fun ConversationHeaderContent(
recipientId: RecipientId,
displayName: String,
showVerified: Boolean = false,
isSystemContact: Boolean = false,
showChevron: Boolean = false,
isSelf: Boolean = false,
isReleaseNotes: Boolean = false,
badge: Badge?,
showUnverifiedName: Boolean,
isGroup: Boolean,
hasWallpaper: Boolean = false,
phoneNumber: String? = null,
groupInfo: GroupInfo? = null,
groupDescription: String? = null,
linkifyGroupDescription: Boolean = false,
sharedGroups: List<String> = emptyList(),
showSafetyTips: Boolean = false,
avatarDownloadState: AvatarDownloadStateCache.DownloadState,
shouldBlurAvatar: Boolean = false,
callbacks: ConversationHeaderCallbacks = ConversationHeaderCallbacks.Empty
) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.TopCenter
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(top = AvatarOverlapAbove)
.width(277.dp)
.then(
if (isReleaseNotes) {
Modifier
.clip(BorderShape)
.background(colorResource(R.color.release_notes_header_background))
.border(width = 2.dp, color = colorResource(R.color.release_notes_header_border), shape = BorderShape)
} else if (hasWallpaper) {
Modifier
.clip(BorderShape)
.background(if (isSystemInDarkTheme()) SignalTheme.colors.colorTransparentInverse5 else SignalTheme.colors.colorTransparent5)
} else {
Modifier.border(width = 2.5.dp, color = SignalTheme.colors.colorSurface3, shape = BorderShape)
}
)
.padding(top = AvatarOverlapBelow + 12.dp, bottom = 24.dp, start = 24.dp, end = 24.dp)
) {
HeadlineDisplayName(
displayName = displayName,
showVerified = showVerified,
isSystemContact = isSystemContact,
showChevron = showChevron,
modifier = Modifier.clickable { callbacks.onTitleClicked() }
)
if (isSelf) {
OfficialChatPill()
Text(
text = stringResource(R.string.ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp)
)
}
if (isReleaseNotes) {
OfficialChatPill()
Text(
text = stringResource(R.string.ConversationFragment_release_notes_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp)
)
}
if (showUnverifiedName) {
UnverifiedNamePill(
onClick = { callbacks.onUnverifiedNameClicked(isGroup) },
modifier = Modifier.padding(top = 8.dp)
)
}
if (phoneNumber != null) {
Text(
text = signalSymbolText(
text = phoneNumber,
glyphStart = SignalSymbols.Glyph.PHONE
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
)
}
if (!groupDescription.isNullOrEmpty()) {
GroupDescription(
description = groupDescription,
linkify = linkifyGroupDescription,
onMoreClicked = { callbacks.onShowGroupDescriptionClicked(displayName.toString(), groupDescription, linkifyGroupDescription) },
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
)
}
if (groupInfo != null) {
GroupMemberSubtitle(
groupInfo = groupInfo,
onGroupSettingsClicked = { callbacks.onGroupSettingsClicked() },
modifier = Modifier.padding(top = 8.dp)
)
}
if (!isSelf && !isReleaseNotes && (sharedGroups.isNotEmpty() || !isGroup)) {
SharedGroupsDescription(
sharedGroups = sharedGroups,
modifier = Modifier.padding(top = 8.dp)
)
}
if (showSafetyTips) {
Buttons.Small(
onClick = { callbacks.onSafetyTipsClicked(isGroup) },
modifier = Modifier.padding(top = 12.dp)
) {
Text(text = stringResource(R.string.ConversationFragment_safety_tips))
}
}
}
AvatarWithBadge(
recipientId = recipientId,
badge = badge,
useProfile = !isSelf,
avatarDownloadState = avatarDownloadState,
shouldBlurAvatar = shouldBlurAvatar,
onTapToView = callbacks::onAvatarTapToViewClicked
)
}
}
@Composable
private fun AvatarWithBadge(
recipientId: RecipientId,
badge: Badge?,
useProfile: Boolean = true,
avatarDownloadState: AvatarDownloadStateCache.DownloadState,
shouldBlurAvatar: Boolean = false,
onTapToView: () -> Unit = {}
) {
val showBlur = shouldBlurAvatar && avatarDownloadState != AvatarDownloadStateCache.DownloadState.IN_PROGRESS
val showProgress = avatarDownloadState == AvatarDownloadStateCache.DownloadState.IN_PROGRESS
val showGradient = showBlur || showProgress || avatarDownloadState == AvatarDownloadStateCache.DownloadState.FAILED
Box(contentAlignment = Alignment.Center) {
Crossfade(
targetState = showGradient,
animationSpec = tween(durationMillis = 220),
label = "avatar-crossfade"
) { gradient ->
if (gradient) {
AndroidView(
factory = { context ->
ImageView(context).apply {
scaleType = ImageView.ScaleType.CENTER_CROP
}
},
update = { view ->
view.setImageDrawable(AvatarGradientColors.getGradientDrawable(Recipient.resolved(recipientId)))
},
modifier = Modifier
.size(AvatarSize)
.clip(CircleShape)
)
} else {
AvatarImage(
recipientId = recipientId,
useProfile = useProfile,
modifier = Modifier.size(AvatarSize)
)
}
}
AnimatedVisibility(
visible = showProgress,
enter = fadeIn(tween(durationMillis = 220)),
exit = fadeOut(tween(durationMillis = 220))
) {
var showSpinner by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(800)
showSpinner = AvatarDownloadStateCache.getDownloadState(recipientId) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS
}
if (showSpinner) {
CircularProgressIndicator(
strokeWidth = 3.dp,
color = Color.White,
modifier = Modifier.size(36.dp)
)
}
}
AnimatedVisibility(
visible = showBlur,
enter = fadeIn(tween(durationMillis = 220)),
exit = fadeOut(tween(durationMillis = 220))
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.size(AvatarSize)
.clip(CircleShape)
.clickable(onClick = onTapToView)
) {
Icon(
painter = painterResource(R.drawable.ic_tap_outline_24),
contentDescription = null,
tint = Color.White
)
Spacer(Modifier.size(4.dp))
Text(
text = stringResource(R.string.MessageRequestProfileView_view),
style = MaterialTheme.typography.bodySmall,
color = Color.White
)
}
}
if (badge != null) {
BadgeImageLarge(
badge = badge,
modifier = Modifier
.size(36.dp)
.align(Alignment.BottomEnd)
)
}
}
}
@Composable
private fun UnverifiedNamePill(
onClick: () -> Unit = {},
modifier: Modifier = Modifier
) {
Text(
text = signalSymbolText(
text = stringResource(R.string.ConversationFragment_name_not_verified),
glyphStart = SignalSymbols.Glyph.PERSON_QUESTION,
glyphStartWeight = SignalSymbols.Weight.BOLD,
glyphStartSize = 14.sp
),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = SignalTheme.colors.colorOnWarning,
modifier = modifier
.clip(RoundedCornerShape(26.dp))
.clickable(onClick = onClick)
.background(SignalTheme.colors.colorWarning)
.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
@Composable
private fun SharedGroupsDescription(
sharedGroups: List<String>,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val description = when (sharedGroups.size) {
0 -> stringResource(R.string.ConversationUpdateItem_no_groups_in_common_review_requests_carefully)
1 -> stringResource(R.string.MessageRequestProfileView_member_of_one_group, sharedGroups[0])
2 -> stringResource(R.string.MessageRequestProfileView_member_of_two_groups, sharedGroups[0], sharedGroups[1])
else -> {
val others = sharedGroups.size - 2
stringResource(
R.string.MessageRequestProfileView_member_of_many_groups,
sharedGroups[0],
sharedGroups[1],
context.resources.getQuantityString(R.plurals.MessageRequestProfileView_member_of_d_additional_groups, others, others)
)
}
}
Text(
text = signalSymbolText(
text = description,
glyphStart = SignalSymbols.Glyph.GROUP
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = modifier
)
}
@Composable
private fun GroupMemberSubtitle(
groupInfo: GroupInfo,
onGroupSettingsClicked: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val memberCount = groupInfo.fullMemberCount
val styledText = if (groupInfo.isMember) {
val names = groupInfo.membersPreview.map { it.getDisplayName(context) }
val othersCount = memberCount - 3
val othersText = if (othersCount > 0) pluralStringResource(R.plurals.MessageRequestProfileView_other_members, othersCount, othersCount) else null
val fullText = when (names.size) {
0 -> stringResource(R.string.MessageRequestProfileView_group_members_zero)
1 -> stringResource(R.string.MessageRequestProfileView_group_members_one_and_you, names[0])
2 -> stringResource(R.string.MessageRequestProfileView_group_members_two_and_you, names[0], names[1])
else -> stringResource(R.string.MessageRequestProfileView_group_members_other, names[0], names[1], names[2], othersText ?: "")
}
buildSignalSymbolAnnotatedString(glyphStart = SignalSymbols.Glyph.GROUP) {
if (othersText != null) {
val othersStart = fullText.indexOf(othersText)
if (othersStart >= 0) {
append(fullText.take(othersStart))
withLink(LinkAnnotation.Clickable(tag = "group_settings", styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.onSurface))) { onGroupSettingsClicked() }) {
withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
append(othersText)
}
}
append(fullText.substring(othersStart + othersText.length))
} else {
append(fullText)
}
} else {
append(fullText)
}
}
} else {
buildSignalSymbolAnnotatedString(glyphStart = SignalSymbols.Glyph.GROUP) {
withLink(LinkAnnotation.Clickable(tag = "group_settings", styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.onSurface))) { onGroupSettingsClicked() }) {
withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
append(pluralStringResource(R.plurals.ConversationFragment_group_member_count, memberCount, memberCount))
}
}
}
}
Text(
text = styledText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = modifier
)
}
@Composable
private fun GroupDescription(
description: String,
linkify: Boolean,
onMoreClicked: () -> Unit,
modifier: Modifier = Modifier
) {
AndroidView(
factory = { context ->
EmojiTextView(context).apply {
layoutParams = android.view.ViewGroup.LayoutParams(
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
android.view.ViewGroup.LayoutParams.WRAP_CONTENT
)
setTextAppearance(CoreUiR.style.Signal_Text_BodyMedium)
gravity = Gravity.CENTER
movementMethod = LongClickMovementMethod.getInstance(context)
}
},
update = { view ->
GroupDescriptionUtil.setText(view.context, view, description, linkify) {
onMoreClicked()
}
},
modifier = modifier.fillMaxWidth()
)
}
@Composable
private fun OfficialChatPill() {
val pillShape = RoundedCornerShape(26.dp)
Text(
text = signalSymbolText(
text = stringResource(R.string.ConversationFragment_official_chat),
glyphStart = SignalSymbols.Glyph.OFFICIAL_BADGE,
glyphStartWeight = SignalSymbols.Weight.BOLD
),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 8.dp)
.clip(pillShape)
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
@DayNightPreviews
@Composable
private fun ConversationHeaderPreview() {
Previews.Preview {
ConversationHeaderContent(
recipientId = RecipientId.from(1),
displayName = "Katie Hall",
showChevron = true,
badge = null,
showUnverifiedName = true,
isGroup = false,
phoneNumber = "+1 (555) 867-5309",
sharedGroups = emptyList(),
showSafetyTips = true,
avatarDownloadState = AvatarDownloadStateCache.DownloadState.NONE
)
}
}
@DayNightPreviews
@Composable
private fun ConversationHeaderWithGroupsPreview() {
Previews.Preview {
ConversationHeaderContent(
recipientId = RecipientId.from(1),
displayName = "Katie Hall",
showChevron = true,
badge = null,
showUnverifiedName = false,
isGroup = false,
sharedGroups = listOf("NYC Rock Climbers", "Dinner Party"),
avatarDownloadState = AvatarDownloadStateCache.DownloadState.NONE
)
}
}
@DayNightPreviews
@Composable
private fun ConversationHeaderGroupPreview() {
Previews.Preview {
ConversationHeaderContent(
recipientId = RecipientId.from(1),
displayName = "Trail Crew",
badge = null,
showUnverifiedName = true,
isGroup = true,
groupInfo = GroupInfo(fullMemberCount = 12, isMember = false),
avatarDownloadState = AvatarDownloadStateCache.DownloadState.NONE
)
}
}
@DayNightPreviews
@Composable
private fun ConversationHeaderNoteToSelfPreview() {
Previews.Preview {
ConversationHeaderContent(
recipientId = RecipientId.from(1),
displayName = "Note to Self",
showVerified = true,
isSelf = true,
badge = null,
showUnverifiedName = false,
isGroup = false,
avatarDownloadState = AvatarDownloadStateCache.DownloadState.NONE
)
}
}
@@ -10,11 +10,11 @@ import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.badges.models.Badge;
import org.signal.core.models.media.Media;
import org.thoughtcrime.securesms.MainActivity;
import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
import org.signal.core.models.media.Media;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.stickers.StickerLocator;
@@ -48,6 +48,7 @@ public class ConversationIntents {
private static final String EXTRA_SHARE_DATA_TIMESTAMP = "share_data_timestamp";
private static final String EXTRA_CONVERSATION_TYPE = "conversation_type";
private static final String EXTRA_INCOGNITO = "incognito";
private static final String EXTRA_HAS_WALLPAPER = "has_wallpaper";
private static final String INTENT_DATA = "intent_data";
private static final String INTENT_TYPE = "intent_type";
@@ -75,12 +76,15 @@ public class ConversationIntents {
}
}
public static @NonNull Builder createPopUpBuilder(@NonNull Context context, @NonNull RecipientId recipientId, long threadId) {
return new Builder(context, ConversationPopupActivity.class, recipientId, threadId, ConversationScreenType.POPUP);
public static @NonNull Builder createPopUpBuilder(@NonNull Context context, @NonNull RecipientId recipientId, long threadId, boolean hasWallpaper) {
return new Builder(context, ConversationPopupActivity.class, recipientId, threadId, ConversationScreenType.POPUP)
.withHasWallpaper(hasWallpaper);
}
public static @NonNull Intent createBubbleIntent(@NonNull Context context, @NonNull RecipientId recipientId, long threadId) {
return new Builder(context, BubbleConversationActivity.class, recipientId, threadId, ConversationScreenType.BUBBLE).build();
public static @NonNull Intent createBubbleIntent(@NonNull Context context, @NonNull RecipientId recipientId, long threadId, boolean hasWallpaper) {
return new Builder(context, BubbleConversationActivity.class, recipientId, threadId, ConversationScreenType.BUBBLE)
.withHasWallpaper(hasWallpaper)
.build();
}
/**
@@ -156,7 +160,9 @@ public class ConversationIntents {
null,
-1L,
ConversationScreenType.BUBBLE,
false);
false,
Boolean.parseBoolean(intentDataUri.getQueryParameter(EXTRA_HAS_WALLPAPER))
);
}
return new ConversationArgs(RecipientId.from(Objects.requireNonNull(arguments.getString(EXTRA_RECIPIENT))),
@@ -174,7 +180,8 @@ public class ConversationIntents {
arguments.getParcelable(EXTRA_GIFT_BADGE),
arguments.getLong(EXTRA_SHARE_DATA_TIMESTAMP, -1L),
ConversationScreenType.from(arguments.getInt(EXTRA_CONVERSATION_TYPE, 0)),
arguments.getBoolean(EXTRA_INCOGNITO, false));
arguments.getBoolean(EXTRA_INCOGNITO, false),
arguments.getBoolean(EXTRA_HAS_WALLPAPER, false));
}
public final static class Builder {
@@ -197,6 +204,7 @@ public class ConversationIntents {
private Badge giftBadge;
private long shareDataTimestamp = -1L;
private boolean incognito;
private boolean hasWallpaper;
private int flags;
private Builder(@NonNull Context context,
@@ -226,6 +234,7 @@ public class ConversationIntents {
giftBadge = args.getGiftBadge();
shareDataTimestamp = args.getShareDataTimestamp();
incognito = args.isIncognito();
hasWallpaper = args.getHasWallpaper();
return this;
}
@@ -295,6 +304,11 @@ public class ConversationIntents {
return this;
}
public @NonNull Builder withHasWallpaper(boolean hasWallpaper) {
this.hasWallpaper = hasWallpaper;
return this;
}
public @NonNull Builder withFlags(int flags) {
this.flags = flags;
return this;
@@ -317,7 +331,8 @@ public class ConversationIntents {
giftBadge,
shareDataTimestamp,
conversationScreenType,
incognito
incognito,
hasWallpaper
);
}
@@ -337,6 +352,7 @@ public class ConversationIntents {
intent.setData(new Uri.Builder().authority(BUBBLE_AUTHORITY)
.appendQueryParameter(EXTRA_RECIPIENT, recipientId.serialize())
.appendQueryParameter(EXTRA_THREAD_ID, String.valueOf(threadId))
.appendQueryParameter(EXTRA_HAS_WALLPAPER, String.valueOf(hasWallpaper))
.build());
return intent;
@@ -353,6 +369,7 @@ public class ConversationIntents {
intent.putExtra(EXTRA_SHARE_DATA_TIMESTAMP, shareDataTimestamp);
intent.putExtra(EXTRA_CONVERSATION_TYPE, conversationScreenType.code);
intent.putExtra(EXTRA_INCOGNITO, incognito);
intent.putExtra(EXTRA_HAS_WALLPAPER, hasWallpaper);
if (draftText != null) {
intent.putExtra(EXTRA_TEXT, draftText);
@@ -204,6 +204,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private Optional<MessageRecord> nextMessageRecord;
private Locale locale;
private boolean groupThread;
private boolean isReleaseNotes;
private LiveRecipient author;
private RequestManager requestManager;
private Optional<MessageRecord> previousMessage;
@@ -412,6 +413,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
this.batchSelected = batchSelected;
this.conversationRecipient = conversationRecipient.live();
this.groupThread = conversationRecipient.isGroup();
this.isReleaseNotes = conversationRecipient.isReleaseNotes();
this.author = messageRecord.getFromRecipient().live();
this.canPlayContent = false;
this.mediaItem = null;
@@ -772,6 +774,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private @ColorInt int getDefaultBubbleColor(boolean hasWallpaper) {
if (isReleaseNotes) {
return ContextCompat.getColor(context, R.color.release_notes_bubble);
}
return hasWallpaper ? defaultBubbleColorForWallpaper : defaultBubbleColor;
}
@@ -919,9 +924,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
footer.setOnlyShowSendingStatus(messageRecord.isRemoteDelete(), messageRecord);
} else {
bodyBubble.getBackground().setColorFilter(getDefaultBubbleColor(hasWallpaper), PorterDuff.Mode.SRC_IN);
footer.setTextColor(colorizer.getIncomingFooterTextColor(context, hasWallpaper));
footer.setIconColor(colorizer.getIncomingFooterIconColor(context, hasWallpaper));
footer.setRevealDotColor(colorizer.getIncomingFooterIconColor(context, hasWallpaper));
if (isReleaseNotes) {
int releaseNotesTextColor = ContextCompat.getColor(context, R.color.release_notes_bubble_text);
bodyText.setTextColor(releaseNotesTextColor);
bodyText.setLinkTextColor(releaseNotesTextColor);
footer.setTextColor(releaseNotesTextColor);
footer.setIconColor(releaseNotesTextColor);
footer.setRevealDotColor(releaseNotesTextColor);
} else {
footer.setTextColor(colorizer.getIncomingFooterTextColor(context, hasWallpaper));
footer.setIconColor(colorizer.getIncomingFooterIconColor(context, hasWallpaper));
footer.setRevealDotColor(colorizer.getIncomingFooterIconColor(context, hasWallpaper));
}
footer.setOnlyShowSendingStatus(false, messageRecord);
}
@@ -1718,8 +1732,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
int end = messageBody.getSpanEnd(placeholder);
URLSpan span = new InterceptableLongClickCopyLinkSpan(placeholder.getValue(),
urlClickListener,
ContextCompat.getColor(getContext(), R.color.signal_accent_primary),
false);
ContextCompat.getColor(getContext(), isReleaseNotes ? R.color.release_notes_bubble_text : R.color.signal_accent_primary),
isReleaseNotes);
messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
@@ -2026,8 +2040,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (groupThread && !messageRecord.isOutgoing()) {
String senderName = recipient.getDisplayName(getContext());
int senderColor = colorizer.getIncomingGroupSenderColor(getContext(), messageRecord.getFromRecipient());
senderWithLabelView.setSender(senderName, senderColor);
senderWithLabelView.setLabel(conversationMessage.getMemberLabel());
senderWithLabelView.bind(senderName, senderColor, conversationMessage.getMemberLabel());
}
}
@@ -9,9 +9,6 @@ import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.components.Outliner;
import org.thoughtcrime.securesms.util.Projection;
import org.signal.core.util.Util;
@@ -20,6 +17,8 @@ import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class ConversationItemBodyBubble extends LinearLayout {
@@ -99,7 +98,7 @@ public class ConversationItemBodyBubble extends LinearLayout {
public @NonNull Set<Projection> getProjections() {
return Stream.of(quoteViewProjection, videoPlayerProjection)
.filterNot(Objects::isNull)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
}
@@ -167,6 +167,14 @@ internal object ConversationOptionsMenu {
if (recipient.isReleaseNotes) {
hideMenuItem(menu, R.id.menu_add_shortcut)
menu.findItem(R.id.menu_mute_notifications)?.apply {
setIcon(R.drawable.symbol_bell_24)
setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
}
menu.findItem(R.id.menu_unmute_notifications)?.apply {
setIcon(R.drawable.symbol_bell_slash_24)
setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
}
}
if (!SignalStore.labs.individualChatPlaintextExport) {

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