mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-11 18:50:15 +01:00
Compare commits
202 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b98b03971 | |||
| dfbdf30535 | |||
| d567555047 | |||
| 7658f6c36c | |||
| 51bd2d51c6 | |||
| a00978d96e | |||
| b700529c3b | |||
| 4051cf739c | |||
| 6031fc9113 | |||
| 454fe86dda | |||
| 92927ec69b | |||
| 9fa587b7e4 | |||
| 552361dff4 | |||
| 78a25a6186 | |||
| 58fcc07578 | |||
| 8cd92a400c | |||
| 5d207932c9 | |||
| 7c147982c4 | |||
| bde1a94122 | |||
| 2b66d7485a | |||
| 017b902c3c | |||
| 357fbfa8aa | |||
| 0ce667f4af | |||
| c4d78243c8 | |||
| 51e12b2c76 | |||
| 4dea1d8aa1 | |||
| 89c645dea3 | |||
| cd01d5f0b7 | |||
| 8730e28282 | |||
| 82046dd55f | |||
| 76e30ab09f | |||
| f680256f1d | |||
| da590a3241 | |||
| 91f73b473f | |||
| 53023517b3 | |||
| 7f831e6806 | |||
| 77a18111e1 | |||
| 2a699a23dd | |||
| 5643ffc1a9 | |||
| 90207b7dd7 | |||
| 5b7f668251 | |||
| 798bf3ec3e | |||
| 1c77c9d3fb | |||
| dd52d78ee0 | |||
| 4b1acca119 | |||
| 195fe60927 | |||
| f427f31303 | |||
| fa19ed7ffc | |||
| e5e99d4e03 | |||
| 26d1a7ada7 | |||
| 5dd11e26e4 | |||
| 9877b13c6e | |||
| d7d0fd3622 | |||
| 2439506c05 | |||
| 6088024f76 | |||
| 9decd81cfc | |||
| f27773a4e3 | |||
| 8d8c974a19 | |||
| 1a3e81dcb0 | |||
| d5f85c0661 | |||
| 91458f2702 | |||
| 6650ffc2c6 | |||
| ab0102a372 | |||
| a797bbf850 | |||
| 3804890265 | |||
| fcdbf93626 | |||
| f1b61f8f7e | |||
| ce582249ec | |||
| b21a72153a | |||
| 2a8bd20bb0 | |||
| c30e3cc1b7 | |||
| 5fedd81921 | |||
| 24069dc42e | |||
| ff15c8417a | |||
| cbf770d3ea | |||
| 676ab1ab6f | |||
| 9cc47942f2 | |||
| 45e6e06c01 | |||
| d2243707b5 | |||
| 48cd1c1da0 | |||
| 330a5aece2 | |||
| 8c4f614d17 | |||
| f40bcb73fa | |||
| 905a6f1a6b | |||
| 8f78471849 | |||
| 82df20190d | |||
| 7f6e96a522 | |||
| eded335766 | |||
| 7e4736969c | |||
| 78940ffc17 | |||
| 086883e565 | |||
| e9cdf0368e | |||
| 7be273f461 | |||
| e6cbb0073c | |||
| 469421fcf3 | |||
| 6d6d277277 | |||
| 8a5faba985 | |||
| 7aadc208e1 | |||
| 3c68e29679 | |||
| 4756b8d70b | |||
| c2d927029a | |||
| 629b96dd20 | |||
| 01705459cf | |||
| c449f72786 | |||
| 773d6c36dc | |||
| b4bfb67a44 | |||
| 3165c854df | |||
| f5cb1b0efa | |||
| 179908fba6 | |||
| d6ec4bfbd3 | |||
| 237ac9f94a | |||
| 66f69854cf | |||
| 8f47592fc0 | |||
| 3ea7bf77e0 | |||
| 2b67b1c44f | |||
| ebccc6db30 | |||
| 98d9b12438 | |||
| 5db8463c70 | |||
| 813252989b | |||
| 0319adbce4 | |||
| de584ccb7d | |||
| bd89c7fc39 | |||
| bef4bb40ca | |||
| b57d922cdf | |||
| 8c1cc03c6f | |||
| f0109f3e6b | |||
| ed89f3a78e | |||
| faa6a1d3f0 | |||
| 969635d942 | |||
| 7665ae1464 | |||
| 9c18e3698e | |||
| df406633ff | |||
| d121f9402b | |||
| 5310c19b99 | |||
| cd92feb2b7 | |||
| 3b603f08ed | |||
| 281f062b29 | |||
| b054a7eb76 | |||
| 33b9c88ecd | |||
| 253d36ae13 | |||
| 8306f8ec5b | |||
| 69b6d7ef9a | |||
| aeeba3d2df | |||
| dfd2f7baf9 | |||
| 5de17a971d | |||
| 001896d244 | |||
| 1844b128e1 | |||
| 08623cc0c4 | |||
| f93a948169 | |||
| 76476191be | |||
| d00bb28ee4 | |||
| 453e5bede7 | |||
| c7c108bd77 | |||
| fb81574d35 | |||
| e6d3de091c | |||
| 99b8a6020d | |||
| 88b21b6113 | |||
| 256ee9b1aa | |||
| e2feaaf74c | |||
| 17def87c17 | |||
| d90e9919ae | |||
| 38baf17938 | |||
| 3f7707985f | |||
| a61072b249 | |||
| 80ff64ddd3 | |||
| 95c0467bda | |||
| ff88d259fd | |||
| 6e747019d4 | |||
| 9e7a40a63d | |||
| 38eed43046 | |||
| 4c76cb682e | |||
| c47adb7482 | |||
| 3c2ccef9a8 | |||
| fb0c4757f2 | |||
| b8b9a632b5 | |||
| 9b4a13a491 | |||
| 1cdd49721d | |||
| 8b895738c0 | |||
| 6ab3cd3390 | |||
| 11c8a726ec | |||
| 264447a6d9 | |||
| a7bb2831f8 | |||
| e05586a1c9 | |||
| 0e8dedf4d0 | |||
| 0e11a1fe3e | |||
| f1ebd2dc81 | |||
| 8ea90c8a43 | |||
| 6456dcf657 | |||
| bb151c91e9 | |||
| ce6f39ae68 | |||
| 58e8ea08c2 | |||
| 4dd74d9ab4 | |||
| 3ef3a516b3 | |||
| 518a81c7fa | |||
| f81325e7ca | |||
| cc847cb229 | |||
| 7320a0ef46 | |||
| 7c45686440 | |||
| 8b5b83e974 | |||
| a4a3861398 | |||
| 01bdaaea84 | |||
| 1f02fba696 |
@@ -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
|
||||
|
||||
@@ -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/*"
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ plugins {
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
|
||||
val canonicalVersionCode = 1675
|
||||
val canonicalVersionName = "8.6.2"
|
||||
val canonicalVersionCode = 1683
|
||||
val canonicalVersionName = "8.9.0"
|
||||
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)
|
||||
|
||||
@@ -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("mms", new String[] {"_id"},"
|
||||
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("part", new String[] {"_id", "ct", "_data", "encrypted"},"
|
||||
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("thread", new String[] {"_id"}, 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("SELECT DISTINCT date AS date_received, status, " +"
|
||||
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("mms", new String[] {"_id", "network_failures"}, "network_failures IS NOT NULL", 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"
|
||||
|
||||
+1
-1
@@ -4,12 +4,12 @@ import android.app.Application
|
||||
import io.mockk.mockk
|
||||
import io.mockk.spyk
|
||||
import org.signal.core.util.billing.BillingApi
|
||||
import org.signal.network.api.ArchiveApi
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
|
||||
import org.whispersystems.signalservice.api.SignalServiceDataStore
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageSender
|
||||
import org.whispersystems.signalservice.api.account.AccountApi
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveApi
|
||||
import org.whispersystems.signalservice.api.attachment.AttachmentApi
|
||||
import org.whispersystems.signalservice.api.donations.DonationsApi
|
||||
import org.whispersystems.signalservice.api.keys.KeysApi
|
||||
|
||||
@@ -75,8 +75,8 @@ object MockProvider {
|
||||
val device = PreKeyResponseItem().apply {
|
||||
this.deviceId = deviceId
|
||||
registrationId = KeyHelper.generateRegistrationId(false)
|
||||
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
preKey = PreKeyEntity(oneTimePreKey.id, oneTimePreKey.keyPair.publicKey)
|
||||
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id.toLong(), signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
preKey = PreKeyEntity(oneTimePreKey.id.toLong(), oneTimePreKey.keyPair.publicKey)
|
||||
}
|
||||
|
||||
return PreKeyResponse().apply {
|
||||
|
||||
@@ -482,7 +482,7 @@
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
|
||||
|
||||
<activity
|
||||
android:name="org.signal.mediasend.MediaSendActivity"
|
||||
android:name=".mediasend.v3.MediaSendV3Activity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -62,6 +62,7 @@ import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
|
||||
import org.thoughtcrime.securesms.jobs.BackupRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.BackupSubscriptionCheckJob;
|
||||
import org.thoughtcrime.securesms.jobs.BuildExpirationConfirmationJob;
|
||||
import org.thoughtcrime.securesms.jobs.CallingAssetsDownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.CheckKeyTransparencyJob;
|
||||
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
|
||||
@@ -102,12 +103,14 @@ import org.thoughtcrime.securesms.service.MessageBackupListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
|
||||
import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
|
||||
import org.thoughtcrime.securesms.service.webrtc.CallingAssets;
|
||||
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.DeviceProperties;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.Environment;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
|
||||
@@ -226,6 +229,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
|
||||
.addPostRender(AndroidTelecomUtil::registerPhoneAccount)
|
||||
.addPostRender(() -> AppDependencies.getJobManager().add(new FontDownloaderJob()))
|
||||
.addPostRender(() -> AppDependencies.getJobManager().add(new CallingAssetsDownloadJob()))
|
||||
.addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary)
|
||||
.addPostRender(GroupV2UpdateSelfProfileKeyJob::enqueueForGroupsIfNecessary)
|
||||
.addPostRender(StoryOnboardingDownloadJob.Companion::enqueueIfNeeded)
|
||||
@@ -400,6 +404,20 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
AppDependencies.init(this, new ApplicationDependencyProvider(this));
|
||||
}
|
||||
AppForegroundObserver.begin();
|
||||
|
||||
if (Environment.USE_NEW_REGISTRATION) {
|
||||
initializeRegistrationDependencies();
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeRegistrationDependencies() {
|
||||
org.signal.registration.RegistrationDependencies.Companion.provide(
|
||||
new org.signal.registration.RegistrationDependencies(
|
||||
new org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
|
||||
new org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController(this),
|
||||
null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private void initializeFirstEverAppLaunch() {
|
||||
|
||||
@@ -45,6 +45,9 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
import androidx.transition.AutoTransition;
|
||||
import androidx.transition.TransitionManager;
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
@@ -60,10 +63,12 @@ import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContacts;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchCallbacks;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
|
||||
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
|
||||
@@ -74,6 +79,7 @@ import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.signal.core.ui.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository;
|
||||
import org.thoughtcrime.securesms.search.SearchRepository;
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameAciFetchResult;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
@@ -86,7 +92,9 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -117,7 +125,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
private OnContactSelectedListener onContactSelectedListener;
|
||||
private SwipeRefreshLayout swipeRefresh;
|
||||
private String cursorFilter;
|
||||
private RecyclerView recyclerView;
|
||||
private ContactSearchView contactSearchView;
|
||||
private RecyclerViewFastScroller fastScroller;
|
||||
private RecyclerView chipRecycler;
|
||||
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
|
||||
@@ -126,8 +134,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
private LifecycleDisposable lifecycleDisposable;
|
||||
private HeaderActionProvider headerActionProvider;
|
||||
private TextView headerActionView;
|
||||
private ContactSearchMediator contactSearchMediator;
|
||||
private ContactSearchViewModel contactSearchViewModel;
|
||||
|
||||
@Nullable private RecyclerView innerRecyclerView;
|
||||
@Nullable private LinearLayoutManager innerLayoutManager;
|
||||
@Nullable private NewConversationCallback newConversationCallback;
|
||||
@Nullable private FindByCallback findByCallback;
|
||||
@Nullable private NewCallCallback newCallCallback;
|
||||
@@ -238,7 +248,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
handleContactPermissionGranted();
|
||||
} else {
|
||||
requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
|
||||
contactSearchMediator.refresh();
|
||||
contactSearchViewModel.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,29 +256,14 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false);
|
||||
|
||||
emptyText = view.findViewById(android.R.id.empty);
|
||||
recyclerView = view.findViewById(R.id.recycler_view);
|
||||
swipeRefresh = view.findViewById(R.id.swipe_refresh);
|
||||
emptyText = view.findViewById(android.R.id.empty);
|
||||
contactSearchView = view.findViewById(R.id.recycler_view);
|
||||
swipeRefresh = view.findViewById(R.id.swipe_refresh);
|
||||
fastScroller = view.findViewById(R.id.fast_scroller);
|
||||
chipRecycler = view.findViewById(R.id.chipRecycler);
|
||||
constraintLayout = view.findViewById(R.id.container);
|
||||
headerActionView = view.findViewById(R.id.header_action);
|
||||
|
||||
final LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
|
||||
|
||||
recyclerView.setLayoutManager(layoutManager);
|
||||
recyclerView.setItemAnimator(new DefaultItemAnimator() {
|
||||
@Override
|
||||
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
recyclerView.setAlpha(1f);
|
||||
}
|
||||
});
|
||||
|
||||
contactChipViewModel = new ViewModelProvider(this).get(ContactChipViewModel.class);
|
||||
contactChipAdapter = new MappingAdapter();
|
||||
lifecycleDisposable = new LifecycleDisposable();
|
||||
@@ -283,12 +278,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
|
||||
fragmentArgs = ContactSelectionArguments.fromBundle(safeArguments(), requireActivity().getIntent());
|
||||
|
||||
if (fragmentArgs.getRecyclerPadBottom() != -1) {
|
||||
ViewUtil.setPaddingBottom(recyclerView, fragmentArgs.getRecyclerPadBottom());
|
||||
}
|
||||
|
||||
recyclerView.setClipToPadding(fragmentArgs.getRecyclerChildClipping());
|
||||
|
||||
swipeRefresh.setNestedScrollingEnabled(fragmentArgs.isRefreshable());
|
||||
swipeRefresh.setEnabled(fragmentArgs.isRefreshable());
|
||||
|
||||
@@ -302,6 +291,26 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
|
||||
currentSelection = getCurrentSelection();
|
||||
|
||||
Set<ContactSearchKey> fixedContacts = currentSelection.stream()
|
||||
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
contactSearchViewModel = new ViewModelProvider(
|
||||
this,
|
||||
new ContactSearchViewModel.Factory(
|
||||
selectionLimit,
|
||||
isMulti,
|
||||
new ContactSearchRepository(),
|
||||
false,
|
||||
new ContactSelectionListAdapter.ArbitraryRepository(),
|
||||
new SearchRepository(requireContext().getString(R.string.note_to_self)),
|
||||
new ContactSearchPagedDataSourceRepository(requireContext()),
|
||||
fixedContacts
|
||||
)
|
||||
).get(ContactSearchViewModel.class);
|
||||
|
||||
List<RecyclerView.OnScrollListener> scrollListeners = new ArrayList<>();
|
||||
|
||||
final HeaderAction headerAction;
|
||||
if (headerActionProvider != null) {
|
||||
headerAction = headerActionProvider.getHeaderAction();
|
||||
@@ -310,24 +319,20 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
headerActionView.setText(headerAction.getLabel());
|
||||
headerActionView.setCompoundDrawablesRelativeWithIntrinsicBounds(headerAction.getIcon(), 0, 0, 0);
|
||||
headerActionView.setOnClickListener(v -> headerAction.getAction().run());
|
||||
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
|
||||
scrollListeners.add(new RecyclerView.OnScrollListener() {
|
||||
|
||||
private final Rect bounds = new Rect();
|
||||
|
||||
@Override
|
||||
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
|
||||
if (hideLetterHeaders()) {
|
||||
public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) {
|
||||
if (hideLetterHeaders() || innerLayoutManager == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
int firstPosition = layoutManager.findFirstVisibleItemPosition();
|
||||
int firstPosition = innerLayoutManager.findFirstVisibleItemPosition();
|
||||
if (firstPosition == 0) {
|
||||
View firstChild = recyclerView.getChildAt(0);
|
||||
recyclerView.getDecoratedBoundsWithMargins(firstChild, bounds);
|
||||
View firstChild = rv.getChildAt(0);
|
||||
rv.getDecoratedBoundsWithMargins(firstChild, bounds);
|
||||
headerActionView.setTranslationY(bounds.top);
|
||||
}
|
||||
}
|
||||
@@ -336,13 +341,104 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
headerActionView.setEnabled(false);
|
||||
}
|
||||
|
||||
contactSearchMediator = new ContactSearchMediator(
|
||||
this,
|
||||
currentSelection.stream()
|
||||
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
|
||||
.collect(java.util.stream.Collectors.toSet()),
|
||||
selectionLimit,
|
||||
isMulti,
|
||||
scrollListeners.add(new RecyclerView.OnScrollListener() {
|
||||
@Override
|
||||
public void onScrollStateChanged(@NonNull RecyclerView rv, int newState) {
|
||||
if (newState == RecyclerView.SCROLL_STATE_DRAGGING && scrollCallback != null) {
|
||||
scrollCallback.onBeginScroll();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
float contentBottomPaddingDp = fragmentArgs.getRecyclerPadBottom() != -1
|
||||
? fragmentArgs.getRecyclerPadBottom() / getResources().getDisplayMetrics().density
|
||||
: 0f;
|
||||
|
||||
ContactSearchAdapter.AdapterFactory adapterFactory =
|
||||
(context, fc, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) ->
|
||||
new ContactSelectionListAdapter(
|
||||
context,
|
||||
fc,
|
||||
displayOptions,
|
||||
new ContactSelectionListAdapter.OnContactSelectionClick() {
|
||||
@Override
|
||||
public void onDismissFindContactsBannerClicked() {
|
||||
SignalStore.uiHints().markDismissedContactsPermissionBanner();
|
||||
contactSearchViewModel.refresh();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindContactsClicked() {
|
||||
requestContactPermissions();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRefreshContactsClicked() {
|
||||
if (onRefreshListener != null && !isRefreshing()) {
|
||||
setRefreshing(true);
|
||||
onRefreshListener.onRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNewGroupClicked() {
|
||||
newConversationCallback.onNewGroup(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByPhoneNumberClicked() {
|
||||
findByCallback.onFindByPhoneNumber();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByUsernameClicked() {
|
||||
findByCallback.onFindByUsername();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInviteToSignalClicked() {
|
||||
if (newConversationCallback != null) {
|
||||
newConversationCallback.onInvite();
|
||||
}
|
||||
|
||||
if (newCallCallback != null) {
|
||||
newCallCallback.onInvite();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
|
||||
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
|
||||
callbacks.onExpandClicked(expand);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
|
||||
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
|
||||
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
|
||||
}
|
||||
},
|
||||
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
|
||||
storyContextMenuCallbacks,
|
||||
new CallButtonClickCallbacks()
|
||||
);
|
||||
|
||||
contactSearchView.bind(
|
||||
contactSearchViewModel,
|
||||
getChildFragmentManager(),
|
||||
new ContactSearchAdapter.DisplayOptions(
|
||||
isMulti,
|
||||
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
|
||||
@@ -350,94 +446,31 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
false
|
||||
),
|
||||
this::mapStateToConfiguration,
|
||||
new ContactSearchMediator.SimpleCallbacks() {
|
||||
new ContactSearchCallbacks.Simple() {
|
||||
@Override
|
||||
public void onAdapterListCommitted(int size) {
|
||||
onLoadFinished(size);
|
||||
}
|
||||
},
|
||||
false,
|
||||
(context, fixedContacts, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) -> new ContactSelectionListAdapter(
|
||||
context,
|
||||
fixedContacts,
|
||||
displayOptions,
|
||||
new ContactSelectionListAdapter.OnContactSelectionClick() {
|
||||
@Override
|
||||
public void onDismissFindContactsBannerClicked() {
|
||||
SignalStore.uiHints().markDismissedContactsPermissionBanner();
|
||||
contactSearchMediator.refresh();
|
||||
}
|
||||
Collections.singletonList(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders)),
|
||||
contentBottomPaddingDp,
|
||||
adapterFactory,
|
||||
scrollListeners,
|
||||
rv -> {
|
||||
innerRecyclerView = rv;
|
||||
innerLayoutManager = (LinearLayoutManager) rv.getLayoutManager();
|
||||
rv.setItemAnimator(new DefaultItemAnimator() {
|
||||
@Override
|
||||
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindContactsClicked() {
|
||||
requestContactPermissions();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRefreshContactsClicked() {
|
||||
if (onRefreshListener != null && !isRefreshing()) {
|
||||
setRefreshing(true);
|
||||
onRefreshListener.onRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNewGroupClicked() {
|
||||
newConversationCallback.onNewGroup(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByPhoneNumberClicked() {
|
||||
findByCallback.onFindByPhoneNumber();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByUsernameClicked() {
|
||||
findByCallback.onFindByUsername();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInviteToSignalClicked() {
|
||||
if (newConversationCallback != null) {
|
||||
newConversationCallback.onInvite();
|
||||
}
|
||||
|
||||
if (newCallCallback != null) {
|
||||
newCallCallback.onInvite();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
|
||||
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
|
||||
callbacks.onExpandClicked(expand);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
|
||||
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
|
||||
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
|
||||
}
|
||||
},
|
||||
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
|
||||
storyContextMenuCallbacks,
|
||||
new CallButtonClickCallbacks()
|
||||
|
||||
),
|
||||
new ContactSelectionListAdapter.ArbitraryRepository()
|
||||
@Override
|
||||
public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
contactSearchView.setAlpha(1f);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return view;
|
||||
@@ -460,30 +493,30 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
public @NonNull List<SelectedContact> getSelectedContacts() {
|
||||
if (contactSearchMediator == null) {
|
||||
if (contactSearchViewModel == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return contactSearchMediator.getSelectedContacts()
|
||||
.stream()
|
||||
.map(ContactSearchKey::requireSelectedContact)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
return contactSearchViewModel.getSelectedContacts()
|
||||
.stream()
|
||||
.map(ContactSearchKey::requireSelectedContact)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public int getSelectedContactsCount() {
|
||||
if (contactSearchMediator == null) {
|
||||
if (contactSearchViewModel == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return contactSearchMediator.getSelectedContacts().size();
|
||||
return contactSearchViewModel.getSelectedContacts().size();
|
||||
}
|
||||
|
||||
public int getTotalMemberCount() {
|
||||
if (contactSearchMediator == null) {
|
||||
if (contactSearchViewModel == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return getSelectedContactsCount() + contactSearchMediator.getFixedContactsSize();
|
||||
return getSelectedContactsCount() + contactSearchViewModel.getFixedContactsSize();
|
||||
}
|
||||
|
||||
private Set<RecipientId> getCurrentSelection() {
|
||||
@@ -499,36 +532,23 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
.request(Manifest.permission.WRITE_CONTACTS, Manifest.permission.READ_CONTACTS)
|
||||
.ifNecessary()
|
||||
.onAllGranted(() -> {
|
||||
recyclerView.setAlpha(0.5f);
|
||||
contactSearchView.setAlpha(0.5f);
|
||||
if (!TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) {
|
||||
handleContactPermissionGranted();
|
||||
} else {
|
||||
contactSearchMediator.refresh();
|
||||
contactSearchViewModel.refresh();
|
||||
if (onRefreshListener != null) {
|
||||
swipeRefresh.setRefreshing(true);
|
||||
onRefreshListener.onRefresh();
|
||||
}
|
||||
}
|
||||
})
|
||||
.onAnyDenied(() -> contactSearchMediator.refresh())
|
||||
.onAnyDenied(() -> contactSearchViewModel.refresh())
|
||||
.withPermanentDenialDialog(getString(R.string.ContactSelectionListFragment_signal_requires_the_contacts_permission_in_order_to_display_your_contacts), null, R.string.ContactSelectionListFragment_allow_access_contacts, R.string.ContactSelectionListFragment_to_find_people, getParentFragmentManager())
|
||||
.execute();
|
||||
}
|
||||
|
||||
private void initializeCursor() {
|
||||
recyclerView.addItemDecoration(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders));
|
||||
recyclerView.setAdapter(contactSearchMediator.getAdapter());
|
||||
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
|
||||
@Override
|
||||
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
|
||||
if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
|
||||
if (scrollCallback != null) {
|
||||
scrollCallback.onBeginScroll();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onSelectionChanged();
|
||||
}
|
||||
@@ -546,7 +566,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
this.resetPositionOnCommit = true;
|
||||
this.cursorFilter = filter;
|
||||
|
||||
contactSearchMediator.onFilterChanged(filter);
|
||||
contactSearchViewModel.setQuery(filter);
|
||||
}
|
||||
|
||||
public void resetQueryFilter() {
|
||||
@@ -557,7 +577,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
public void onDataRefreshed() {
|
||||
this.resetPositionOnCommit = true;
|
||||
swipeRefresh.setRefreshing(false);
|
||||
contactSearchMediator.refresh();
|
||||
contactSearchViewModel.refresh();
|
||||
}
|
||||
|
||||
public boolean hasQueryFilter() {
|
||||
@@ -573,26 +593,25 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
contactSearchMediator.clearSelection();
|
||||
contactSearchMediator.refresh();
|
||||
contactSearchViewModel.clearSelection();
|
||||
contactSearchViewModel.refresh();
|
||||
fastScroller.setVisibility(View.GONE);
|
||||
headerActionView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void onLoadFinished(int count) {
|
||||
if (resetPositionOnCommit) {
|
||||
if (resetPositionOnCommit && innerRecyclerView != null) {
|
||||
resetPositionOnCommit = false;
|
||||
recyclerView.scrollToPosition(0);
|
||||
innerRecyclerView.scrollToPosition(0);
|
||||
}
|
||||
|
||||
swipeRefresh.setVisibility(View.VISIBLE);
|
||||
|
||||
emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
|
||||
boolean useFastScroller = count > 20;
|
||||
recyclerView.setVerticalScrollBarEnabled(!useFastScroller);
|
||||
if (useFastScroller) {
|
||||
if (useFastScroller && innerRecyclerView != null) {
|
||||
fastScroller.setVisibility(View.VISIBLE);
|
||||
fastScroller.setRecyclerView(recyclerView);
|
||||
fastScroller.setRecyclerView(innerRecyclerView);
|
||||
} else {
|
||||
fastScroller.setRecyclerView(null);
|
||||
fastScroller.setVisibility(View.GONE);
|
||||
@@ -659,10 +678,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
Set<SelectedContact> toMarkSelected = contacts.stream()
|
||||
.filter(r -> !contactSearchMediator.getSelectedContacts()
|
||||
.contains(new ContactSearchKey.RecipientSearchKey(r, false)))
|
||||
.filter(r -> !contactSearchViewModel.getSelectedContacts()
|
||||
.contains(new ContactSearchKey.RecipientSearchKey(r, false)))
|
||||
.map(SelectedContact::forRecipientId)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (toMarkSelected.isEmpty()) {
|
||||
return;
|
||||
@@ -687,7 +706,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedContact.hasChatType() && !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
|
||||
if (selectedContact.hasChatType() && !contactSearchViewModel.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onBeforeContactSelected(true, Optional.empty(), null, Optional.of(selectedContact.getChatType()), allowed -> {
|
||||
if (allowed) {
|
||||
@@ -704,7 +723,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMulti || !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
|
||||
if (!isMulti || !contactSearchViewModel.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
|
||||
if (selectionHardLimitReached()) {
|
||||
if (onSelectionLimitReachedListener != null) {
|
||||
onSelectionLimitReachedListener.onHardLimitReached(selectionLimit.getHardLimit());
|
||||
@@ -771,8 +790,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
public boolean onItemLongClick(View anchorView, ContactSearchKey item) {
|
||||
if (onItemLongClickListener != null) {
|
||||
return onItemLongClickListener.onLongClick(anchorView, item, recyclerView);
|
||||
if (onItemLongClickListener != null && innerRecyclerView != null) {
|
||||
return onItemLongClickListener.onLongClick(anchorView, item, innerRecyclerView);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
@@ -792,7 +811,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
public void markContactSelected(@NonNull SelectedContact selectedContact) {
|
||||
contactSearchMediator.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey()));
|
||||
contactSearchViewModel.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey()));
|
||||
if (isMulti) {
|
||||
addChipForSelectedContact(selectedContact);
|
||||
}
|
||||
@@ -802,7 +821,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
|
||||
contactSearchMediator.setKeysNotSelected(Collections.singleton(selectedContact.toContactSearchKey()));
|
||||
contactSearchViewModel.setKeysNotSelected(Collections.singleton(selectedContact.toContactSearchKey()));
|
||||
contactChipViewModel.remove(selectedContact);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
@@ -864,8 +883,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
|
||||
AutoTransition transition = new AutoTransition();
|
||||
transition.setDuration(CHIP_GROUP_REVEAL_DURATION_MS);
|
||||
transition.excludeChildren(recyclerView, true);
|
||||
transition.excludeTarget(recyclerView, true);
|
||||
transition.excludeChildren(contactSearchView, true);
|
||||
transition.excludeTarget(contactSearchView, true);
|
||||
TransitionManager.beginDelayedTransition(constraintLayout, transition);
|
||||
|
||||
ConstraintSet constraintSet = new ConstraintSet();
|
||||
|
||||
@@ -44,7 +44,6 @@ import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
|
||||
import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
|
||||
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
|
||||
@@ -61,6 +60,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.DialogFragment
|
||||
@@ -73,7 +73,6 @@ import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import io.reactivex.rxjava3.subjects.Subject
|
||||
@@ -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
|
||||
@@ -163,6 +163,7 @@ import org.thoughtcrime.securesms.main.rememberFocusRequester
|
||||
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
|
||||
import org.thoughtcrime.securesms.mediasend.v3.mediaSendLauncher
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphone
|
||||
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphones
|
||||
@@ -271,7 +272,7 @@ class MainActivity :
|
||||
override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) }
|
||||
override val googlePayResultPublisher: Subject<GooglePayComponent.GooglePayResult> = PublishSubject.create()
|
||||
|
||||
private lateinit var mediaActivityLauncher: ActivityResultLauncher<MediaSendActivityContract.Args>
|
||||
private lateinit var mediaSendLauncher: ActivityResultLauncher<MediaSendActivityContract.Args>
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
|
||||
return motionEventRelay.offer(ev) || super.dispatchTouchEvent(ev)
|
||||
@@ -298,7 +299,7 @@ class MainActivity :
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
navigator = MainNavigator(this, mainNavigationViewModel)
|
||||
|
||||
mediaActivityLauncher = registerForActivityResult(MediaSendActivityContract()) { }
|
||||
mediaSendLauncher = mediaSendLauncher()
|
||||
|
||||
AppForegroundObserver.addListener(object : AppForegroundObserver.Listener {
|
||||
override fun onForeground() {
|
||||
@@ -428,15 +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 {
|
||||
@@ -464,7 +465,7 @@ class MainActivity :
|
||||
anchors.indexOf(paneExpansionState.currentAnchor)
|
||||
}
|
||||
|
||||
LaunchedEffect(windowSizeClass) {
|
||||
LaunchedEffect(anchors) {
|
||||
val index = when {
|
||||
paneAnchorIndex < 0 -> 1
|
||||
paneAnchorIndex > anchors.lastIndex -> anchors.lastIndex
|
||||
@@ -477,7 +478,7 @@ class MainActivity :
|
||||
}
|
||||
}
|
||||
|
||||
val chatNavGraphState = ChatNavGraphState.remember(windowSizeClass)
|
||||
val chatNavGraphState = ChatNavGraphState.remember(isSplitPane)
|
||||
val mutableInteractionSource = remember { MutableInteractionSource() }
|
||||
MainNavigationDetailLocationEffect(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
|
||||
|
||||
@@ -623,7 +624,7 @@ class MainActivity :
|
||||
onDestinationSelected = mainNavigationCallback
|
||||
)
|
||||
|
||||
if (!windowSizeClass.isSplitPane()) {
|
||||
if (!LocalResources.current.rememberIsSplitPane()) {
|
||||
Spacer(Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
@@ -639,7 +640,7 @@ class MainActivity :
|
||||
}
|
||||
},
|
||||
secondaryContent = {
|
||||
val listContainerColor = if (windowSizeClass.isSplitPane()) {
|
||||
val listContainerColor = if (isSplitPane) {
|
||||
SignalTheme.colors.colorSurface1
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
@@ -780,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)
|
||||
)
|
||||
@@ -799,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()
|
||||
@@ -1124,7 +1125,7 @@ class MainActivity :
|
||||
if (isForQuickRestore) {
|
||||
startActivity(MediaSelectionActivity.cameraForQuickRestore(context = this@MainActivity))
|
||||
} else if (SignalStore.internal.useNewMediaActivity) {
|
||||
mediaActivityLauncher.launch(
|
||||
mediaSendLauncher.launch(
|
||||
MediaSendActivityContract.Args(
|
||||
isCameraFirst = false,
|
||||
isStory = destination == MainNavigationListLocation.STORIES
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity;
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
|
||||
import org.thoughtcrime.securesms.util.Environment;
|
||||
import org.thoughtcrime.securesms.restore.RestoreActivity;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
@@ -134,8 +135,12 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
Intent intent = getIntentForState(applicationState);
|
||||
if (intent != null) {
|
||||
Log.d(TAG, "routeApplicationState(), intent: " + intent.getComponent());
|
||||
startActivity(intent);
|
||||
finish();
|
||||
if (applicationState == STATE_WELCOME_PUSH_SCREEN && Environment.USE_NEW_REGISTRATION) {
|
||||
startActivity(intent);
|
||||
} else {
|
||||
startActivity(intent);
|
||||
finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +178,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return STATE_ENTER_SIGNAL_PIN;
|
||||
} else if (userMustSetProfileName()) {
|
||||
return STATE_CREATE_PROFILE_NAME;
|
||||
} else if (userMustCreateSignalPin()) {
|
||||
} else if (userMustCreateSignalPin() && getClass() != CreateSvrPinActivity.class) {
|
||||
return STATE_CREATE_SIGNAL_PIN;
|
||||
} else if (EventBus.getDefault().getStickyEvent(TransferStatus.class) != null && getClass() != OldDeviceTransferActivity.class) {
|
||||
return STATE_TRANSFER_ONGOING;
|
||||
@@ -221,7 +226,11 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
}
|
||||
|
||||
private Intent getPushRegistrationIntent() {
|
||||
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
|
||||
if (Environment.USE_NEW_REGISTRATION) {
|
||||
return org.signal.registration.RegistrationActivity.createIntent(this);
|
||||
} else {
|
||||
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
|
||||
}
|
||||
}
|
||||
|
||||
private Intent getEnterSignalPinIntent() {
|
||||
|
||||
@@ -19,7 +19,7 @@ package org.thoughtcrime.securesms;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
@@ -58,7 +58,7 @@ public class PushContactSelectionActivity extends ContactSelectionActivity {
|
||||
protected final void onFinishedSelection() {
|
||||
Intent resultIntent = getIntent();
|
||||
List<SelectedContact> selectedContacts = contactsFragment.getSelectedContacts();
|
||||
List<RecipientId> recipients = Stream.of(selectedContacts).map(sc -> sc.getOrCreateRecipientId()).toList();
|
||||
List<RecipientId> recipients = selectedContacts.stream().map(sc -> sc.getOrCreateRecipientId()).collect(Collectors.toList());
|
||||
|
||||
resultIntent.putParcelableArrayListExtra(KEY_SELECTED_RECIPIENTS, new ArrayList<>(recipients));
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.thoughtcrime.securesms.apkupdate
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -53,6 +54,13 @@ object ApkUpdateInstaller {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isDownloadSuccessful(context, downloadId)) {
|
||||
Log.w(TAG, "DownloadId matches, but the download was not successful. The download may have failed due to a network issue. Clearing state and re-checking for updates.")
|
||||
SignalStore.apkUpdate.clearDownloadAttributes()
|
||||
AppDependencies.jobManager.add(ApkUpdateJob())
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMatchingDigest(context, downloadId, digest)) {
|
||||
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
|
||||
SignalStore.apkUpdate.clearDownloadAttributes()
|
||||
@@ -134,6 +142,35 @@ object ApkUpdateInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDownloadSuccessful(context: Context, downloadId: Long): Boolean {
|
||||
val query = DownloadManager.Query().setFilterById(downloadId)
|
||||
val cursor = context.getDownloadManager().query(query)
|
||||
|
||||
return cursor.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val status = cursor
|
||||
.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
.takeUnless { it == -1 }
|
||||
?.let { cursor.getInt(it) } ?: DownloadManager.STATUS_FAILED
|
||||
|
||||
if (status == DownloadManager.STATUS_SUCCESSFUL) {
|
||||
return@use true
|
||||
}
|
||||
|
||||
val reason = cursor
|
||||
.getColumnIndex(DownloadManager.COLUMN_REASON)
|
||||
.takeUnless { it == -1 }
|
||||
?.let { cursor.getInt(it) }
|
||||
|
||||
Log.w(TAG, "Download not successful. Status: $status, Reason: $reason")
|
||||
false
|
||||
} else {
|
||||
Log.w(TAG, "Download ID $downloadId not found in DownloadManager.")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isMatchingDigest(context: Context, downloadId: Long, expectedDigest: ByteArray): Boolean {
|
||||
return try {
|
||||
FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor).use { stream ->
|
||||
|
||||
@@ -145,6 +145,11 @@ class PointerAttachment : Attachment {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
val cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0)
|
||||
if (cdn == Cdn.S3) {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
return Optional.of(
|
||||
PointerAttachment(
|
||||
quote = true,
|
||||
@@ -153,7 +158,7 @@ class PointerAttachment : Attachment {
|
||||
transferState = AttachmentTable.TRANSFER_PROGRESS_PENDING,
|
||||
size = (if (thumbnail != null) thumbnail.asPointer().size.orElse(0) else 0).toLong(),
|
||||
fileName = quotedAttachment.fileName,
|
||||
cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0),
|
||||
cdn = cdn,
|
||||
location = thumbnail?.asPointer()?.remoteId?.toString() ?: "0",
|
||||
key = thumbnail?.asPointer()?.key?.let { Base64.encodeWithPadding(it) },
|
||||
iv = null,
|
||||
|
||||
@@ -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
|
||||
@@ -424,6 +424,12 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
fun markOutOfRemoteStorageSpaceError() {
|
||||
if (SignalStore.backup.isNotEnoughRemoteStorageSpace) {
|
||||
return
|
||||
}
|
||||
|
||||
SignalStore.backup.markNotEnoughRemoteStorageSpace()
|
||||
|
||||
val context = AppDependencies.application
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(context, 0, AppSettingsActivity.remoteBackups(context), cancelCurrent())
|
||||
@@ -436,8 +442,6 @@ object BackupRepository {
|
||||
.build()
|
||||
|
||||
ServiceUtil.getNotificationManager(context).notify(NotificationIds.OUT_OF_REMOTE_STORAGE, notification)
|
||||
|
||||
SignalStore.backup.markNotEnoughRemoteStorageSpace()
|
||||
}
|
||||
|
||||
fun clearOutOfRemoteStorageSpaceError() {
|
||||
@@ -1686,10 +1690,10 @@ object BackupRepository {
|
||||
*
|
||||
* It's important to note that in order to get this to the archive cdn, you still need to use [copyAttachmentToArchive].
|
||||
*/
|
||||
fun getAttachmentUploadForm(): NetworkResult<AttachmentUploadForm> {
|
||||
fun getAttachmentUploadForm(uploadLength: Long): NetworkResult<AttachmentUploadForm> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
SignalNetwork.archive.getMediaUploadForm(SignalStore.account.requireAci(), credential.mediaBackupAccess)
|
||||
SignalNetwork.archive.getMediaUploadForm(SignalStore.account.requireAci(), credential.mediaBackupAccess, uploadLength)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2090,7 +2094,7 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* See [org.whispersystems.signalservice.api.archive.ArchiveApi.getSvrBAuthorization].
|
||||
* See [org.signal.network.api.ArchiveApi.getSvrBAuthorization].
|
||||
*/
|
||||
fun getSvrBAuth(): NetworkResult<AuthCredentials> {
|
||||
return initBackupAndFetchAuth()
|
||||
|
||||
+4
@@ -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")
|
||||
}
|
||||
|
||||
+12
-4
@@ -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 */
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+3
-3
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,14 +32,14 @@ import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* Applies Signal or System emoji to the given content based off user settings.
|
||||
* Applies Signal or System emoji to the given content based on user settings.
|
||||
*
|
||||
* Text is transformed and passed to content as an annotated string and inline content map.
|
||||
*/
|
||||
@Composable
|
||||
fun Emojifier(
|
||||
text: String,
|
||||
useSystemEmoji: Boolean = !LocalInspectionMode.current && SignalStore.settings.isPreferSystemEmoji,
|
||||
useSystemEmoji: Boolean = LocalInspectionMode.current || SignalStore.settings.isPreferSystemEmoji,
|
||||
content: @Composable (AnnotatedString, Map<String, InlineTextContent>) -> Unit = { annotatedText, inlineContent ->
|
||||
Text(
|
||||
text = annotatedText,
|
||||
|
||||
+2
-2
@@ -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() {
|
||||
|
||||
+6
-7
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+9
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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
|
||||
)
|
||||
|
||||
+5
-3
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
+4
-4
@@ -335,7 +335,7 @@ class ChangeNumberRepository(
|
||||
} else {
|
||||
PreKeyUtil.generateSignedPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), pniIdentity.privateKey)
|
||||
}
|
||||
devicePniSignedPreKeys[deviceId] = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
devicePniSignedPreKeys[deviceId] = SignedPreKeyEntity(signedPreKeyRecord.id.toLong(), signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
|
||||
// Last-resort kyber prekeys
|
||||
val lastResortKyberPreKeyRecord: KyberPreKeyRecord = if (deviceId == primaryDeviceId) {
|
||||
@@ -343,7 +343,7 @@ class ChangeNumberRepository(
|
||||
} else {
|
||||
PreKeyUtil.generateLastResortKyberPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), pniIdentity.privateKey)
|
||||
}
|
||||
devicePniLastResortKyberPreKeys[deviceId] = KyberPreKeyEntity(lastResortKyberPreKeyRecord.id, lastResortKyberPreKeyRecord.keyPair.publicKey, lastResortKyberPreKeyRecord.signature)
|
||||
devicePniLastResortKyberPreKeys[deviceId] = KyberPreKeyEntity(lastResortKyberPreKeyRecord.id.toLong(), lastResortKyberPreKeyRecord.keyPair.publicKey, lastResortKyberPreKeyRecord.signature)
|
||||
|
||||
// Registration Ids
|
||||
var pniRegistrationId = -1
|
||||
@@ -383,8 +383,8 @@ class ChangeNumberRepository(
|
||||
previousPni = SignalStore.account.pni!!.toByteString(),
|
||||
pniIdentityKeyPair = pniIdentity.serialize().toByteString(),
|
||||
pniRegistrationId = pniRegistrationIds[primaryDeviceId]!!,
|
||||
pniSignedPreKeyId = devicePniSignedPreKeys[primaryDeviceId]!!.keyId,
|
||||
pniLastResortKyberPreKeyId = devicePniLastResortKyberPreKeys[primaryDeviceId]!!.keyId,
|
||||
pniSignedPreKeyId = devicePniSignedPreKeys[primaryDeviceId]!!.keyId.toInt(),
|
||||
pniLastResortKyberPreKeyId = devicePniLastResortKyberPreKeys[primaryDeviceId]!!.keyId.toInt(),
|
||||
previousE164 = SignalStore.account.requireE164(),
|
||||
newE164 = newE164
|
||||
)
|
||||
|
||||
+15
-15
@@ -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()
|
||||
|
||||
|
||||
+3
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+1
@@ -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()
|
||||
|
||||
+4
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -664,7 +664,7 @@ object InAppPaymentsRepository {
|
||||
timestamp = insertedAt.inWholeMilliseconds,
|
||||
error = null,
|
||||
pendingVerification = true,
|
||||
checkedVerification = data.waitForAuth!!.checkedVerification
|
||||
checkedVerification = data.waitForAuth?.checkedVerification ?: false
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
+12
-12
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+44
-5
@@ -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()
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
) {
|
||||
|
||||
+3
-3
@@ -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
|
||||
}
|
||||
|
||||
+87
@@ -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 = {}) })
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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> {
|
||||
|
||||
+2
-2
@@ -66,8 +66,8 @@ object CallPreference {
|
||||
MessageTypes.MISSED_VIDEO_CALL_TYPE -> getMissedCallString(true, call.event)
|
||||
MessageTypes.INCOMING_AUDIO_CALL_TYPE -> if (call.isDisplayedAsMissedCallInUi) getMissedCallString(false, call.event) else R.string.MessageRecord_incoming_voice_call
|
||||
MessageTypes.INCOMING_VIDEO_CALL_TYPE -> if (call.isDisplayedAsMissedCallInUi) getMissedCallString(true, call.event) else R.string.MessageRecord_incoming_video_call
|
||||
MessageTypes.OUTGOING_AUDIO_CALL_TYPE -> R.string.MessageRecord_outgoing_voice_call
|
||||
MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.string.MessageRecord_outgoing_video_call
|
||||
MessageTypes.OUTGOING_AUDIO_CALL_TYPE -> if (call.event == CallTable.Event.NOT_ACCEPTED) R.string.MessageRecord_unanswered_voice_call else R.string.MessageRecord_outgoing_voice_call
|
||||
MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> if (call.event == CallTable.Event.NOT_ACCEPTED) R.string.MessageRecord_unanswered_video_call else R.string.MessageRecord_outgoing_video_call
|
||||
MessageTypes.GROUP_CALL_TYPE -> when {
|
||||
call.isDisplayedAsMissedCallInUi -> if (call.event == CallTable.Event.MISSED_NOTIFICATION_PROFILE) R.string.CallPreference__missed_group_call_notification_profile else R.string.CallPreference__missed_group_call
|
||||
call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.string.CallPreference__group_call
|
||||
|
||||
+68
@@ -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))
|
||||
}
|
||||
}
|
||||
+6
-84
@@ -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() {
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.video.exo.SignalMediaSourceFactory
|
||||
|
||||
/**
|
||||
@@ -35,6 +36,10 @@ class VoiceNotePlayer @JvmOverloads constructor(
|
||||
.setHandleAudioBecomingNoisy(true).build()
|
||||
) : ForwardingPlayer(internalPlayer) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(VoiceNotePlayer::class.java)
|
||||
}
|
||||
|
||||
init {
|
||||
val audioManager = ContextCompat.getSystemService(context, AudioManager::class.java)
|
||||
|
||||
@@ -47,6 +52,10 @@ class VoiceNotePlayer @JvmOverloads constructor(
|
||||
.build()
|
||||
)
|
||||
.setOnAudioFocusChangeListener {
|
||||
if (it == AudioManager.AUDIOFOCUS_LOSS || it == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) {
|
||||
Log.d(TAG, "Audio focus change to $it. Pausing.")
|
||||
this.pause()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
} else {
|
||||
|
||||
+8
@@ -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)
|
||||
}
|
||||
|
||||
@@ -207,6 +213,8 @@ class VoiceNotePlayerCallback(val context: Context, val player: VoiceNotePlayer)
|
||||
player.setAudioAttributes(attributes, newStreamType == AudioManager.STREAM_MUSIC)
|
||||
if (newStreamType == AudioManager.STREAM_VOICE_CALL) {
|
||||
player.playWhenReady = true
|
||||
} else {
|
||||
Log.i(TAG, "Audio stream set to $newStreamType. Not playing when ready.")
|
||||
}
|
||||
}
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
|
||||
+21
-11
@@ -15,7 +15,9 @@ import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionCommand
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val TAG = Log.tag(VoiceNoteProximityWakeLockManager::class.java)
|
||||
@@ -31,6 +33,7 @@ class VoiceNoteProximityWakeLockManager(
|
||||
|
||||
private val wakeLock: PowerManager.WakeLock? = ServiceUtil.getPowerManager(activity.applicationContext).newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG)
|
||||
|
||||
private val audioManager: AudioManagerCompat = AppDependencies.androidCallAudioManager
|
||||
private val sensorManager: SensorManager = ServiceUtil.getSensorManager(activity)
|
||||
private val proximitySensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY)
|
||||
|
||||
@@ -58,7 +61,7 @@ class VoiceNoteProximityWakeLockManager(
|
||||
}
|
||||
|
||||
fun unregisterCallbacksAndRelease() {
|
||||
mediaController.addListener(mediaControllerCallback)
|
||||
mediaController.removeListener(mediaControllerCallback)
|
||||
cleanUpWakeLock()
|
||||
}
|
||||
|
||||
@@ -91,20 +94,24 @@ class VoiceNoteProximityWakeLockManager(
|
||||
inner class ProximityListener : Player.Listener {
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
super.onEvents(player, events)
|
||||
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
|
||||
if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED, Player.EVENT_IS_PLAYING_CHANGED)) {
|
||||
if (!isActivityResumed()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (player.isPlaying) {
|
||||
if (startTime == -1L) {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Player became active with start time $startTime, registering sensor listener.")
|
||||
startTime = System.currentTimeMillis()
|
||||
if (wakeLock?.isHeld == false) {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Acquiring wakelock")
|
||||
wakeLock.acquire(TimeUnit.MINUTES.toMillis(30))
|
||||
if (audioManager.isHeadsetConnected) {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Headset connected, skipping proximity sensor registration.")
|
||||
} else {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Player became active with start time $startTime, registering sensor listener.")
|
||||
startTime = System.currentTimeMillis()
|
||||
if (wakeLock?.isHeld == false) {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Acquiring wakelock")
|
||||
wakeLock.acquire(TimeUnit.MINUTES.toMillis(30))
|
||||
}
|
||||
sensorManager.registerListener(hardwareSensorEventListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
}
|
||||
sensorManager.registerListener(hardwareSensorEventListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
} else {
|
||||
Log.d(TAG, "[onPlaybackStateChanged] Player became active without start time, skipping sensor registration")
|
||||
}
|
||||
@@ -118,11 +125,14 @@ class VoiceNoteProximityWakeLockManager(
|
||||
|
||||
inner class HardwareSensorEventListener : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
if (startTime == -1L ||
|
||||
System.currentTimeMillis() - startTime <= 500 ||
|
||||
if (System.currentTimeMillis() - startTime <= 500) {
|
||||
Log.i(TAG, "Ignoring sensor change because it's too close to start time.")
|
||||
return
|
||||
} else if (startTime == -1L ||
|
||||
!isActivityResumed() ||
|
||||
!mediaController.isPlaying ||
|
||||
event.sensor.type != Sensor.TYPE_PROXIMITY
|
||||
event.sensor.type != Sensor.TYPE_PROXIMITY ||
|
||||
audioManager.isHeadsetConnected()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
+5
-6
@@ -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);
|
||||
|
||||
+5
-5
@@ -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? {
|
||||
@@ -358,7 +358,7 @@ data class CallParticipantsState(
|
||||
@PluralsRes multipleParticipants: Int,
|
||||
members: List<GroupMemberEntry.FullMember>
|
||||
): String {
|
||||
val eligibleMembers: List<GroupMemberEntry.FullMember> = members.filterNot { it.member.isSelf || it.member.isBlocked }
|
||||
val eligibleMembers: List<GroupMemberEntry.FullMember> = members.filterNot { it.member.isSelf || it.member.isBlocked || it.member.isUnregistered }
|
||||
|
||||
return when (eligibleMembers.size) {
|
||||
0 -> noParticipants?.let { context.getString(noParticipants) } ?: ""
|
||||
|
||||
+4
-11
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+16
-7
@@ -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 = {
|
||||
|
||||
-2
@@ -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,
|
||||
|
||||
+16
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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,129 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
|
||||
init {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
}
|
||||
|
||||
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 currentOnRecyclerViewReady: RecyclerViewReadyCallback? = null
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
@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 = currentOnRecyclerViewReady
|
||||
)
|
||||
}
|
||||
}
|
||||
+99
-29
@@ -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();
|
||||
}
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user