mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-09 09:40:14 +01:00
Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ plugins {
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
|
||||
val canonicalVersionCode = 1675
|
||||
val canonicalVersionName = "8.6.2"
|
||||
val canonicalVersionCode = 1682
|
||||
val canonicalVersionName = "8.8.2"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
@@ -692,7 +692,6 @@ dependencies {
|
||||
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"
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
@@ -35,8 +34,6 @@ class ThreadTableTest_active {
|
||||
fun setUp() {
|
||||
mockkStatic(RemoteConfig::class)
|
||||
|
||||
every { RemoteConfig.showChatFolders } returns true
|
||||
|
||||
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -30,8 +29,6 @@ class ThreadTableTest_pinned {
|
||||
fun setUp() {
|
||||
mockkStatic(RemoteConfig::class)
|
||||
|
||||
every { RemoteConfig.showChatFolders } returns true
|
||||
|
||||
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())))
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -87,6 +87,7 @@ import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -340,7 +341,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
this,
|
||||
currentSelection.stream()
|
||||
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
|
||||
.collect(java.util.stream.Collectors.toSet()),
|
||||
.collect(Collectors.toSet()),
|
||||
selectionLimit,
|
||||
isMulti,
|
||||
new ContactSearchAdapter.DisplayOptions(
|
||||
@@ -467,7 +468,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
return contactSearchMediator.getSelectedContacts()
|
||||
.stream()
|
||||
.map(ContactSearchKey::requireSelectedContact)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public int getSelectedContactsCount() {
|
||||
@@ -662,7 +663,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
.filter(r -> !contactSearchMediator.getSelectedContacts()
|
||||
.contains(new ContactSearchKey.RecipientSearchKey(r, false)))
|
||||
.map(SelectedContact::forRecipientId)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (toMarkSelected.isEmpty()) {
|
||||
return;
|
||||
|
||||
@@ -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() {
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+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
|
||||
|
||||
+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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,18 +364,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
-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
|
||||
)
|
||||
}
|
||||
|
||||
+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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+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
|
||||
|
||||
+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) } ?: ""
|
||||
|
||||
+1
-1
@@ -91,7 +91,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 },
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
+1
-1
@@ -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]
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
-327
@@ -1,327 +0,0 @@
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorListenerAdapter;
|
||||
import android.animation.ObjectAnimator;
|
||||
import android.content.Context;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Handler;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextUtils;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.view.ViewKt;
|
||||
|
||||
import com.bumptech.glide.RequestManager;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarGradientColors;
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.AvatarDownloadStateCache;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.databinding.ConversationHeaderViewBinding;
|
||||
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.whispersystems.signalservice.api.util.Preconditions;
|
||||
|
||||
public class ConversationHeaderView extends ConstraintLayout {
|
||||
|
||||
private static final String TAG = Log.tag(ConversationHeaderView.class);
|
||||
private static final int FADE_DURATION = 150;
|
||||
private static final int LOADING_DELAY = 800;
|
||||
|
||||
private final ConversationHeaderViewBinding binding;
|
||||
|
||||
private boolean inProgress = false;
|
||||
private Handler handler = new Handler();
|
||||
|
||||
public ConversationHeaderView(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public ConversationHeaderView(Context context, AttributeSet attrs) {
|
||||
this(context, attrs, 0);
|
||||
}
|
||||
|
||||
public ConversationHeaderView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
|
||||
inflate(getContext(), R.layout.conversation_header_view, this);
|
||||
|
||||
binding = ConversationHeaderViewBinding.bind(this);
|
||||
}
|
||||
|
||||
public void showProgressBar(@NonNull Recipient recipient) {
|
||||
if (!inProgress) {
|
||||
inProgress = true;
|
||||
animateAvatarLoading(recipient);
|
||||
binding.messageRequestAvatarTapToView.setVisibility(GONE);
|
||||
binding.messageRequestAvatarTapToView.setOnClickListener(null);
|
||||
handler.postDelayed(() -> {
|
||||
boolean isDownloading = AvatarDownloadStateCache.getDownloadState(recipient) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS;
|
||||
binding.progressBar.setVisibility(isDownloading ? View.VISIBLE : View.GONE);
|
||||
}, LOADING_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
public void hideProgressBar() {
|
||||
inProgress = false;
|
||||
binding.progressBar.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public void showFailedAvatarDownload(@NonNull Recipient recipient) {
|
||||
AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.NONE);
|
||||
binding.progressBar.setVisibility(View.GONE);
|
||||
binding.messageRequestAvatar.setImageDrawable(AvatarGradientColors.getGradientDrawable(recipient));
|
||||
}
|
||||
|
||||
public void setBadge(@Nullable Recipient recipient) {
|
||||
if (recipient == null || recipient.isSelf()) {
|
||||
binding.messageRequestBadge.setBadge(null);
|
||||
} else {
|
||||
binding.messageRequestBadge.setBadgeFromRecipient(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
public void setAvatar(@NonNull RequestManager requestManager, @Nullable Recipient recipient) {
|
||||
if (recipient == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (AvatarDownloadStateCache.getDownloadState(recipient) != AvatarDownloadStateCache.DownloadState.IN_PROGRESS) {
|
||||
binding.messageRequestAvatar.setAvatar(requestManager, recipient, false, false, true);
|
||||
hideProgressBar();
|
||||
}
|
||||
|
||||
if (recipient.getShouldBlurAvatar() && recipient.getHasAvatar()) {
|
||||
binding.messageRequestAvatarTapToView.setVisibility(VISIBLE);
|
||||
binding.messageRequestAvatarTapToView.setOnClickListener(v -> {
|
||||
AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.IN_PROGRESS);
|
||||
SignalExecutors.BOUNDED.execute(() -> SignalDatabase.recipients().manuallyUpdateShowAvatar(recipient.getId(), true));
|
||||
if (recipient.isPushV2Group()) {
|
||||
AvatarGroupsV2DownloadJob.enqueueUnblurredAvatar(recipient.requireGroupId().requireV2());
|
||||
} else {
|
||||
RetrieveProfileAvatarJob.enqueueUnblurredAvatar(recipient);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
binding.messageRequestAvatarTapToView.setVisibility(GONE);
|
||||
binding.messageRequestAvatarTapToView.setOnClickListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
public String setTitle(@NonNull Recipient recipient, @NonNull Runnable onTitleClicked) {
|
||||
CharSequence title = recipient.getDisplayNameForHeadline(getContext());
|
||||
|
||||
if (recipient.isIndividual() && !recipient.isSelf()) {
|
||||
binding.messageRequestTitle.setOnClickListener(v -> onTitleClicked.run());
|
||||
} else {
|
||||
binding.messageRequestTitle.setOnClickListener(null);
|
||||
}
|
||||
|
||||
binding.messageRequestTitle.setText(title);
|
||||
return title.toString();
|
||||
}
|
||||
|
||||
public void showReleaseNoteHeader() {
|
||||
binding.messageRequestInfo.setVisibility(View.GONE);
|
||||
binding.releaseHeaderContainer.setVisibility(View.VISIBLE);
|
||||
binding.releaseHeaderDescription1.setText(prependIcon(getContext().getString(R.string.ReleaseNotes__this_is_official_chat_period), R.drawable.symbol_official_20));
|
||||
binding.releaseHeaderDescription2.setText(prependIcon(getContext().getString(R.string.ReleaseNotes__keep_up_to_date_period), R.drawable.symbol_bell_20));
|
||||
}
|
||||
|
||||
public void setAbout(@NonNull Recipient recipient) {
|
||||
String about = recipient.getCombinedAboutAndEmoji();
|
||||
binding.messageRequestAbout.setText(about);
|
||||
binding.messageRequestAbout.setVisibility(TextUtils.isEmpty(about) || recipient.isReleaseNotes() ? GONE : VISIBLE);
|
||||
}
|
||||
|
||||
public void setSubtitle(@NonNull CharSequence subtitle, @DrawableRes int iconRes, @Nullable String substring, @Nullable Runnable onClick) {
|
||||
if (TextUtils.isEmpty(subtitle)) {
|
||||
hideSubtitle();
|
||||
return;
|
||||
}
|
||||
|
||||
if (onClick != null && substring != null) {
|
||||
binding.messageRequestSubtitle.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
CharSequence builder = SpanUtil.clickSubstring(
|
||||
subtitle,
|
||||
substring,
|
||||
listener -> onClick.run(),
|
||||
ContextCompat.getColor(getContext(), org.signal.core.ui.R.color.signal_colorOnSurface),
|
||||
true
|
||||
);
|
||||
binding.messageRequestSubtitle.setText(prependIcon(builder, iconRes));
|
||||
} else {
|
||||
binding.messageRequestSubtitle.setText(prependIcon(subtitle, iconRes));
|
||||
}
|
||||
|
||||
binding.messageRequestSubtitle.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
public void setDescription(@Nullable CharSequence description, @DrawableRes int iconRes) {
|
||||
if (TextUtils.isEmpty(description)) {
|
||||
hideDescription();
|
||||
return;
|
||||
}
|
||||
|
||||
binding.messageRequestDescription.setText(prependIcon(description, iconRes));
|
||||
binding.messageRequestDescription.setVisibility(View.VISIBLE);
|
||||
updateOutlineVisibility();
|
||||
}
|
||||
|
||||
public @NonNull EmojiTextView getDescription() {
|
||||
return binding.messageRequestDescription;
|
||||
}
|
||||
|
||||
public void setButton(@NonNull CharSequence button, Runnable onClick) {
|
||||
binding.messageRequestButton.setText(button);
|
||||
binding.messageRequestButton.setOnClickListener(v -> onClick.run());
|
||||
binding.messageRequestButton.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
public void showWarningSubtitle() {
|
||||
binding.messageRequestReviewCarefully.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
public void hideWarningSubtitle() {
|
||||
binding.messageRequestReviewCarefully.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public void setUnverifiedNameSubtitle(@DrawableRes int iconRes, boolean forGroup, @NonNull Runnable onClick) {
|
||||
binding.messageRequestProfileNameUnverified.setVisibility(View.VISIBLE);
|
||||
binding.messageRequestProfileNameUnverified.setOnClickListener(view -> onClick.run());
|
||||
|
||||
String substring = forGroup ? getContext().getString(R.string.ConversationFragment_group_names)
|
||||
: getContext().getString(R.string.ConversationFragment_profile_names);
|
||||
|
||||
String fullString = forGroup ? getContext().getString(R.string.ConversationFragment_group_names_not_verified, substring)
|
||||
: getContext().getString(R.string.ConversationFragment_profile_names_not_verified, substring);
|
||||
|
||||
CharSequence builder = SpanUtil.underlineSubstring(fullString, substring);
|
||||
binding.messageRequestProfileNameUnverified.setText(prependIcon(builder, iconRes, forGroup));
|
||||
}
|
||||
|
||||
public void hideUnverifiedNameSubtitle() {
|
||||
binding.messageRequestProfileNameUnverified.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public void showBackgroundBubble(boolean enabled) {
|
||||
if (enabled) {
|
||||
setBackgroundResource(R.drawable.wallpaper_bubble_background_18);
|
||||
} else {
|
||||
setBackground(null);
|
||||
}
|
||||
|
||||
updateOutlineVisibility();
|
||||
}
|
||||
|
||||
public void hideSubtitle() {
|
||||
binding.messageRequestSubtitle.setVisibility(View.GONE);
|
||||
updateOutlineVisibility();
|
||||
}
|
||||
|
||||
public void showDescription() {
|
||||
binding.messageRequestDescription.setVisibility(View.VISIBLE);
|
||||
updateOutlineVisibility();
|
||||
}
|
||||
|
||||
public void hideDescription() {
|
||||
binding.messageRequestDescription.setVisibility(View.GONE);
|
||||
updateOutlineVisibility();
|
||||
}
|
||||
|
||||
public void hideButton() {
|
||||
binding.messageRequestButton.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public void setLinkifyDescription(boolean enable) {
|
||||
binding.messageRequestDescription.setMovementMethod(enable ? LongClickMovementMethod.getInstance(getContext()) : null);
|
||||
}
|
||||
|
||||
private void animateAvatarLoading(@NonNull Recipient recipient) {
|
||||
Drawable loadingProfile = AppCompatResources.getDrawable(getContext(), R.drawable.circle_profile_photo);
|
||||
ObjectAnimator animator = ObjectAnimator.ofFloat(binding.messageRequestAvatar, "alpha", 1f, 0f).setDuration(FADE_DURATION);
|
||||
animator.addListener(new AnimatorListenerAdapter() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
if (AvatarDownloadStateCache.getDownloadState(recipient) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS) {
|
||||
binding.messageRequestAvatar.setImageDrawable(loadingProfile);
|
||||
}
|
||||
ObjectAnimator.ofFloat(binding.messageRequestAvatar, "alpha", 0f, 1f).setDuration(FADE_DURATION).start();
|
||||
}
|
||||
});
|
||||
|
||||
animator.start();
|
||||
}
|
||||
|
||||
private void updateOutlineVisibility() {
|
||||
if (ViewKt.isVisible(binding.messageRequestSubtitle) || ViewKt.isVisible(binding.messageRequestDescription)) {
|
||||
if (getBackground() != null) {
|
||||
binding.messageRequestInfoOutline.setVisibility(View.GONE);
|
||||
binding.messageRequestDivider.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
binding.messageRequestInfoOutline.setVisibility(View.VISIBLE);
|
||||
binding.messageRequestDivider.setVisibility(View.GONE);
|
||||
}
|
||||
} else {
|
||||
binding.messageRequestInfoOutline.setVisibility(View.GONE);
|
||||
binding.messageRequestDivider.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateOutlineBoxSize() {
|
||||
int visibleCount = 0;
|
||||
for (int i = 0; i < binding.messageRequestInfo.getChildCount(); i++) {
|
||||
if (ViewKt.isVisible(binding.messageRequestInfo.getChildAt(i))) {
|
||||
visibleCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (getBackground() != null) {
|
||||
ViewUtil.setPaddingTop(binding.messageRequestInfo, 0);
|
||||
ViewUtil.setPaddingBottom(binding.messageRequestInfo, getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_header_padding));
|
||||
int margin = getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_header_margin);
|
||||
ViewUtil.setLeftMargin(this, margin);
|
||||
ViewUtil.setRightMargin(this, margin);
|
||||
}
|
||||
|
||||
int padding = visibleCount == 1 ? getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_header_padding) : getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_header_padding_expanded);
|
||||
ViewUtil.setPaddingStart(binding.messageRequestInfo, padding);
|
||||
ViewUtil.setPaddingEnd(binding.messageRequestInfo, padding);
|
||||
}
|
||||
|
||||
private @NonNull CharSequence prependIcon(@NonNull CharSequence input, @DrawableRes int iconRes) {
|
||||
return prependIcon(input, iconRes, false);
|
||||
}
|
||||
|
||||
|
||||
private @NonNull CharSequence prependIcon(@NonNull CharSequence input, @DrawableRes int iconRes, boolean useIntrinsicWidth) {
|
||||
Drawable drawable = ContextCompat.getDrawable(getContext(), iconRes);
|
||||
Preconditions.checkNotNull(drawable);
|
||||
int width = useIntrinsicWidth ? drawable.getIntrinsicWidth() : (int) DimensionUnit.SP.toPixels(16);
|
||||
drawable.setBounds(0, 0, width, (int) DimensionUnit.SP.toPixels(16));
|
||||
drawable.setColorFilter(ContextCompat.getColor(getContext(), org.signal.core.ui.R.color.signal_colorOnSurface), PorterDuff.Mode.SRC_ATOP);
|
||||
|
||||
return new SpannableStringBuilder()
|
||||
.append(SpanUtil.buildCenteredImageSpan(drawable))
|
||||
.append(SpanUtil.space(8, DimensionUnit.SP))
|
||||
.append(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.widget.ImageView
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SnapshotMutationPolicy
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import kotlinx.coroutines.delay
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.BidiUtil
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.AvatarImage
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageLarge
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarGradientColors
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.AvatarDownloadStateCache
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols.buildSignalSymbolAnnotatedString
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols.signalSymbolText
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil
|
||||
import org.thoughtcrime.securesms.messagerequests.GroupInfo
|
||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestRecipientInfo
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.LongClickMovementMethod
|
||||
import org.thoughtcrime.securesms.util.SignalE164Util
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
private val AvatarSize = 74.dp
|
||||
private val AvatarOverlapAbove = 16.dp
|
||||
private val AvatarOverlapBelow = AvatarSize - AvatarOverlapAbove
|
||||
private val BorderShape = RoundedCornerShape(40.dp)
|
||||
|
||||
class ConversationHeaderView : AbstractComposeView {
|
||||
constructor(context: Context) : super(context)
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
|
||||
|
||||
init {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
}
|
||||
|
||||
var callbacks: ConversationHeaderCallbacks = ConversationHeaderCallbacks.Empty
|
||||
var recipientInfo: MessageRequestRecipientInfo? by mutableStateOf(null, policy = RecipientInfoContentPolicy)
|
||||
var avatarDownloadState: AvatarDownloadStateCache.DownloadState by mutableStateOf(AvatarDownloadStateCache.DownloadState.NONE)
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val info = recipientInfo ?: return
|
||||
val recipient = info.recipient
|
||||
val groupInfo = info.groupInfo
|
||||
val isSelf = recipient.isSelf
|
||||
val isReleaseNotes = recipient.isReleaseNotes
|
||||
val isOfficialAccount = recipient.showVerified
|
||||
|
||||
val showUnverifiedName = if (recipient.isGroup) {
|
||||
!groupInfo.hasExistingContacts && !(groupInfo.fullMemberCount == 1 && groupInfo.isMember)
|
||||
} else if (!isOfficialAccount) {
|
||||
recipient.nickname.isEmpty && !recipient.isSystemContact
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
val displayName = if (isSelf) BidiUtil.isolateBidi(context.getString(R.string.note_to_self)) else recipient.getDisplayName(context)
|
||||
val phoneNumber = if (!recipient.isGroup && !isOfficialAccount && recipient.shouldShowE164) {
|
||||
recipient.e164.map { SignalE164Util.prettyPrint(it) }.orElse(null)?.takeIf { it != displayName }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
SignalTheme {
|
||||
ConversationHeaderContent(
|
||||
recipientId = recipient.id,
|
||||
displayName = displayName,
|
||||
showVerified = isOfficialAccount,
|
||||
isSystemContact = recipient.isSystemContact,
|
||||
showChevron = recipient.isIndividual && !isOfficialAccount,
|
||||
isSelf = isSelf,
|
||||
isReleaseNotes = isReleaseNotes,
|
||||
badge = if (!isOfficialAccount) recipient.featuredBadge else null,
|
||||
showUnverifiedName = showUnverifiedName,
|
||||
isGroup = recipient.isGroup,
|
||||
hasWallpaper = recipient.hasWallpaper,
|
||||
phoneNumber = phoneNumber,
|
||||
groupInfo = if (recipient.isGroup) groupInfo else null,
|
||||
groupDescription = if (recipient.isGroup) groupInfo.description else null,
|
||||
linkifyGroupDescription = info.messageRequestState?.isAccepted == true,
|
||||
sharedGroups = info.sharedGroups,
|
||||
showSafetyTips = info.messageRequestState?.isAccepted == false,
|
||||
avatarDownloadState = avatarDownloadState,
|
||||
shouldBlurAvatar = recipient.shouldBlurAvatar && recipient.hasAvatar,
|
||||
callbacks = callbacks
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ConversationHeaderCallbacks {
|
||||
fun onSafetyTipsClicked(forGroup: Boolean) = Unit
|
||||
fun onUnverifiedNameClicked(forGroup: Boolean) = Unit
|
||||
fun onTitleClicked() = Unit
|
||||
fun onGroupSettingsClicked() = Unit
|
||||
fun onShowGroupDescriptionClicked(groupName: String, description: String, linkifyWebLinks: Boolean) = Unit
|
||||
fun onAvatarTapToViewClicked() = Unit
|
||||
|
||||
companion object Empty : ConversationHeaderCallbacks
|
||||
}
|
||||
|
||||
private object RecipientInfoContentPolicy : SnapshotMutationPolicy<MessageRequestRecipientInfo?> {
|
||||
override fun equivalent(a: MessageRequestRecipientInfo?, b: MessageRequestRecipientInfo?): Boolean {
|
||||
if (a === b) return true
|
||||
if (a == null || b == null) return false
|
||||
return a.recipient.hasSameContent(b.recipient) &&
|
||||
a.groupInfo == b.groupInfo &&
|
||||
a.sharedGroups == b.sharedGroups &&
|
||||
a.messageRequestState == b.messageRequestState
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConversationHeaderContent(
|
||||
recipientId: RecipientId,
|
||||
displayName: String,
|
||||
showVerified: Boolean = false,
|
||||
isSystemContact: Boolean = false,
|
||||
showChevron: Boolean = false,
|
||||
isSelf: Boolean = false,
|
||||
isReleaseNotes: Boolean = false,
|
||||
badge: Badge?,
|
||||
showUnverifiedName: Boolean,
|
||||
isGroup: Boolean,
|
||||
hasWallpaper: Boolean = false,
|
||||
phoneNumber: String? = null,
|
||||
groupInfo: GroupInfo? = null,
|
||||
groupDescription: String? = null,
|
||||
linkifyGroupDescription: Boolean = false,
|
||||
sharedGroups: List<String> = emptyList(),
|
||||
showSafetyTips: Boolean = false,
|
||||
avatarDownloadState: AvatarDownloadStateCache.DownloadState,
|
||||
shouldBlurAvatar: Boolean = false,
|
||||
callbacks: ConversationHeaderCallbacks = ConversationHeaderCallbacks.Empty
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.padding(top = AvatarOverlapAbove)
|
||||
.width(277.dp)
|
||||
.then(
|
||||
if (hasWallpaper) {
|
||||
Modifier
|
||||
.clip(BorderShape)
|
||||
.background(if (isSystemInDarkTheme()) SignalTheme.colors.colorTransparentInverse5 else SignalTheme.colors.colorTransparent5)
|
||||
} else {
|
||||
Modifier.border(width = 2.5.dp, color = SignalTheme.colors.colorSurface3, shape = BorderShape)
|
||||
}
|
||||
)
|
||||
.padding(top = AvatarOverlapBelow + 12.dp, bottom = 24.dp, start = 24.dp, end = 24.dp)
|
||||
) {
|
||||
HeadlineDisplayName(
|
||||
displayName = displayName,
|
||||
showVerified = showVerified,
|
||||
isSystemContact = isSystemContact,
|
||||
showChevron = showChevron,
|
||||
modifier = Modifier.clickable { callbacks.onTitleClicked() }
|
||||
)
|
||||
|
||||
if (isSelf) {
|
||||
OfficialChatPill()
|
||||
Text(
|
||||
text = stringResource(R.string.ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (isReleaseNotes) {
|
||||
OfficialChatPill()
|
||||
Text(
|
||||
text = stringResource(R.string.ConversationFragment_release_notes_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (showUnverifiedName) {
|
||||
UnverifiedNamePill(
|
||||
onClick = { callbacks.onUnverifiedNameClicked(isGroup) },
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (phoneNumber != null) {
|
||||
Text(
|
||||
text = signalSymbolText(
|
||||
text = phoneNumber,
|
||||
glyphStart = SignalSymbols.Glyph.PHONE
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (!groupDescription.isNullOrEmpty()) {
|
||||
GroupDescription(
|
||||
description = groupDescription,
|
||||
linkify = linkifyGroupDescription,
|
||||
onMoreClicked = { callbacks.onShowGroupDescriptionClicked(displayName.toString(), groupDescription, linkifyGroupDescription) },
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (groupInfo != null) {
|
||||
GroupMemberSubtitle(
|
||||
groupInfo = groupInfo,
|
||||
onGroupSettingsClicked = { callbacks.onGroupSettingsClicked() },
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (!isSelf && !isReleaseNotes && (sharedGroups.isNotEmpty() || !isGroup)) {
|
||||
SharedGroupsDescription(
|
||||
sharedGroups = sharedGroups,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (showSafetyTips) {
|
||||
Buttons.Small(
|
||||
onClick = { callbacks.onSafetyTipsClicked(isGroup) },
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.ConversationFragment_safety_tips))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AvatarWithBadge(
|
||||
recipientId = recipientId,
|
||||
badge = badge,
|
||||
useProfile = !isSelf,
|
||||
avatarDownloadState = avatarDownloadState,
|
||||
shouldBlurAvatar = shouldBlurAvatar,
|
||||
onTapToView = callbacks::onAvatarTapToViewClicked
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvatarWithBadge(
|
||||
recipientId: RecipientId,
|
||||
badge: Badge?,
|
||||
useProfile: Boolean = true,
|
||||
avatarDownloadState: AvatarDownloadStateCache.DownloadState,
|
||||
shouldBlurAvatar: Boolean = false,
|
||||
onTapToView: () -> Unit = {}
|
||||
) {
|
||||
val showBlur = shouldBlurAvatar && avatarDownloadState != AvatarDownloadStateCache.DownloadState.IN_PROGRESS
|
||||
val showProgress = avatarDownloadState == AvatarDownloadStateCache.DownloadState.IN_PROGRESS
|
||||
val showGradient = showBlur || showProgress || avatarDownloadState == AvatarDownloadStateCache.DownloadState.FAILED
|
||||
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Crossfade(
|
||||
targetState = showGradient,
|
||||
animationSpec = tween(durationMillis = 220),
|
||||
label = "avatar-crossfade"
|
||||
) { gradient ->
|
||||
if (gradient) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
ImageView(context).apply {
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
view.setImageDrawable(AvatarGradientColors.getGradientDrawable(Recipient.resolved(recipientId)))
|
||||
},
|
||||
modifier = Modifier
|
||||
.size(AvatarSize)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
} else {
|
||||
AvatarImage(
|
||||
recipientId = recipientId,
|
||||
useProfile = useProfile,
|
||||
modifier = Modifier.size(AvatarSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showProgress,
|
||||
enter = fadeIn(tween(durationMillis = 220)),
|
||||
exit = fadeOut(tween(durationMillis = 220))
|
||||
) {
|
||||
var showSpinner by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(800)
|
||||
showSpinner = AvatarDownloadStateCache.getDownloadState(recipientId) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS
|
||||
}
|
||||
|
||||
if (showSpinner) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = 3.dp,
|
||||
color = Color.White,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showBlur,
|
||||
enter = fadeIn(tween(durationMillis = 220)),
|
||||
exit = fadeOut(tween(durationMillis = 220))
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.size(AvatarSize)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onTapToView)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_tap_outline_24),
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.MessageRequestProfileView_view),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (badge != null) {
|
||||
BadgeImageLarge(
|
||||
badge = badge,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.align(Alignment.BottomEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnverifiedNamePill(
|
||||
onClick: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Text(
|
||||
text = signalSymbolText(
|
||||
text = stringResource(R.string.ConversationFragment_name_not_verified),
|
||||
glyphStart = SignalSymbols.Glyph.PERSON_QUESTION,
|
||||
glyphStartWeight = SignalSymbols.Weight.BOLD,
|
||||
glyphStartSize = 14.sp
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = SignalTheme.colors.colorOnWarning,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(26.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.background(SignalTheme.colors.colorWarning)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedGroupsDescription(
|
||||
sharedGroups: List<String>,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val description = when (sharedGroups.size) {
|
||||
0 -> stringResource(R.string.ConversationUpdateItem_no_groups_in_common_review_requests_carefully)
|
||||
1 -> stringResource(R.string.MessageRequestProfileView_member_of_one_group, sharedGroups[0])
|
||||
2 -> stringResource(R.string.MessageRequestProfileView_member_of_two_groups, sharedGroups[0], sharedGroups[1])
|
||||
else -> {
|
||||
val others = sharedGroups.size - 2
|
||||
stringResource(
|
||||
R.string.MessageRequestProfileView_member_of_many_groups,
|
||||
sharedGroups[0],
|
||||
sharedGroups[1],
|
||||
context.resources.getQuantityString(R.plurals.MessageRequestProfileView_member_of_d_additional_groups, others, others)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = signalSymbolText(
|
||||
text = description,
|
||||
glyphStart = SignalSymbols.Glyph.GROUP
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupMemberSubtitle(
|
||||
groupInfo: GroupInfo,
|
||||
onGroupSettingsClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val memberCount = groupInfo.fullMemberCount
|
||||
|
||||
val styledText = if (groupInfo.isMember) {
|
||||
val names = groupInfo.membersPreview.map { it.getDisplayName(context) }
|
||||
val othersCount = memberCount - 3
|
||||
val othersText = if (othersCount > 0) pluralStringResource(R.plurals.MessageRequestProfileView_other_members, othersCount, othersCount) else null
|
||||
|
||||
val fullText = when (names.size) {
|
||||
0 -> stringResource(R.string.MessageRequestProfileView_group_members_zero)
|
||||
1 -> stringResource(R.string.MessageRequestProfileView_group_members_one_and_you, names[0])
|
||||
2 -> stringResource(R.string.MessageRequestProfileView_group_members_two_and_you, names[0], names[1])
|
||||
else -> stringResource(R.string.MessageRequestProfileView_group_members_other, names[0], names[1], names[2], othersText ?: "")
|
||||
}
|
||||
|
||||
buildSignalSymbolAnnotatedString(glyphStart = SignalSymbols.Glyph.GROUP) {
|
||||
if (othersText != null) {
|
||||
val othersStart = fullText.indexOf(othersText)
|
||||
if (othersStart >= 0) {
|
||||
append(fullText.take(othersStart))
|
||||
withLink(LinkAnnotation.Clickable(tag = "group_settings", styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.onSurface))) { onGroupSettingsClicked() }) {
|
||||
withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
|
||||
append(othersText)
|
||||
}
|
||||
}
|
||||
append(fullText.substring(othersStart + othersText.length))
|
||||
} else {
|
||||
append(fullText)
|
||||
}
|
||||
} else {
|
||||
append(fullText)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
buildSignalSymbolAnnotatedString(glyphStart = SignalSymbols.Glyph.GROUP) {
|
||||
withLink(LinkAnnotation.Clickable(tag = "group_settings", styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.onSurface))) { onGroupSettingsClicked() }) {
|
||||
withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
|
||||
append(pluralStringResource(R.plurals.ConversationFragment_group_member_count, memberCount, memberCount))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = styledText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupDescription(
|
||||
description: String,
|
||||
linkify: Boolean,
|
||||
onMoreClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
EmojiTextView(context).apply {
|
||||
layoutParams = android.view.ViewGroup.LayoutParams(
|
||||
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
setTextAppearance(CoreUiR.style.Signal_Text_BodyMedium)
|
||||
gravity = Gravity.CENTER
|
||||
movementMethod = LongClickMovementMethod.getInstance(context)
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
GroupDescriptionUtil.setText(view.context, view, description, linkify) {
|
||||
onMoreClicked()
|
||||
}
|
||||
},
|
||||
modifier = modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OfficialChatPill() {
|
||||
val pillShape = RoundedCornerShape(26.dp)
|
||||
|
||||
Text(
|
||||
text = signalSymbolText(
|
||||
text = stringResource(R.string.ConversationFragment_official_chat),
|
||||
glyphStart = SignalSymbols.Glyph.OFFICIAL_BADGE,
|
||||
glyphStartWeight = SignalSymbols.Weight.BOLD
|
||||
),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp)
|
||||
.clip(pillShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ConversationHeaderPreview() {
|
||||
Previews.Preview {
|
||||
ConversationHeaderContent(
|
||||
recipientId = RecipientId.from(1),
|
||||
displayName = "Katie Hall",
|
||||
showChevron = true,
|
||||
badge = null,
|
||||
showUnverifiedName = true,
|
||||
isGroup = false,
|
||||
phoneNumber = "+1 (555) 867-5309",
|
||||
sharedGroups = emptyList(),
|
||||
showSafetyTips = true,
|
||||
avatarDownloadState = AvatarDownloadStateCache.DownloadState.NONE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ConversationHeaderWithGroupsPreview() {
|
||||
Previews.Preview {
|
||||
ConversationHeaderContent(
|
||||
recipientId = RecipientId.from(1),
|
||||
displayName = "Katie Hall",
|
||||
showChevron = true,
|
||||
badge = null,
|
||||
showUnverifiedName = false,
|
||||
isGroup = false,
|
||||
sharedGroups = listOf("NYC Rock Climbers", "Dinner Party"),
|
||||
avatarDownloadState = AvatarDownloadStateCache.DownloadState.NONE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ConversationHeaderGroupPreview() {
|
||||
Previews.Preview {
|
||||
ConversationHeaderContent(
|
||||
recipientId = RecipientId.from(1),
|
||||
displayName = "Trail Crew",
|
||||
badge = null,
|
||||
showUnverifiedName = true,
|
||||
isGroup = true,
|
||||
groupInfo = GroupInfo(fullMemberCount = 12, isMember = false),
|
||||
avatarDownloadState = AvatarDownloadStateCache.DownloadState.NONE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ConversationHeaderNoteToSelfPreview() {
|
||||
Previews.Preview {
|
||||
ConversationHeaderContent(
|
||||
recipientId = RecipientId.from(1),
|
||||
displayName = "Note to Self",
|
||||
showVerified = true,
|
||||
isSelf = true,
|
||||
badge = null,
|
||||
showUnverifiedName = false,
|
||||
isGroup = false,
|
||||
avatarDownloadState = AvatarDownloadStateCache.DownloadState.NONE
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,11 @@ import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge;
|
||||
import org.signal.core.models.media.Media;
|
||||
import org.thoughtcrime.securesms.MainActivity;
|
||||
import org.thoughtcrime.securesms.badges.models.Badge;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadTable;
|
||||
import org.signal.core.models.media.Media;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
@@ -48,6 +48,7 @@ public class ConversationIntents {
|
||||
private static final String EXTRA_SHARE_DATA_TIMESTAMP = "share_data_timestamp";
|
||||
private static final String EXTRA_CONVERSATION_TYPE = "conversation_type";
|
||||
private static final String EXTRA_INCOGNITO = "incognito";
|
||||
private static final String EXTRA_HAS_WALLPAPER = "has_wallpaper";
|
||||
private static final String INTENT_DATA = "intent_data";
|
||||
private static final String INTENT_TYPE = "intent_type";
|
||||
|
||||
@@ -75,12 +76,15 @@ public class ConversationIntents {
|
||||
}
|
||||
}
|
||||
|
||||
public static @NonNull Builder createPopUpBuilder(@NonNull Context context, @NonNull RecipientId recipientId, long threadId) {
|
||||
return new Builder(context, ConversationPopupActivity.class, recipientId, threadId, ConversationScreenType.POPUP);
|
||||
public static @NonNull Builder createPopUpBuilder(@NonNull Context context, @NonNull RecipientId recipientId, long threadId, boolean hasWallpaper) {
|
||||
return new Builder(context, ConversationPopupActivity.class, recipientId, threadId, ConversationScreenType.POPUP)
|
||||
.withHasWallpaper(hasWallpaper);
|
||||
}
|
||||
|
||||
public static @NonNull Intent createBubbleIntent(@NonNull Context context, @NonNull RecipientId recipientId, long threadId) {
|
||||
return new Builder(context, BubbleConversationActivity.class, recipientId, threadId, ConversationScreenType.BUBBLE).build();
|
||||
public static @NonNull Intent createBubbleIntent(@NonNull Context context, @NonNull RecipientId recipientId, long threadId, boolean hasWallpaper) {
|
||||
return new Builder(context, BubbleConversationActivity.class, recipientId, threadId, ConversationScreenType.BUBBLE)
|
||||
.withHasWallpaper(hasWallpaper)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,7 +160,9 @@ public class ConversationIntents {
|
||||
null,
|
||||
-1L,
|
||||
ConversationScreenType.BUBBLE,
|
||||
false);
|
||||
false,
|
||||
Boolean.parseBoolean(intentDataUri.getQueryParameter(EXTRA_HAS_WALLPAPER))
|
||||
);
|
||||
}
|
||||
|
||||
return new ConversationArgs(RecipientId.from(Objects.requireNonNull(arguments.getString(EXTRA_RECIPIENT))),
|
||||
@@ -174,7 +180,8 @@ public class ConversationIntents {
|
||||
arguments.getParcelable(EXTRA_GIFT_BADGE),
|
||||
arguments.getLong(EXTRA_SHARE_DATA_TIMESTAMP, -1L),
|
||||
ConversationScreenType.from(arguments.getInt(EXTRA_CONVERSATION_TYPE, 0)),
|
||||
arguments.getBoolean(EXTRA_INCOGNITO, false));
|
||||
arguments.getBoolean(EXTRA_INCOGNITO, false),
|
||||
arguments.getBoolean(EXTRA_HAS_WALLPAPER, false));
|
||||
}
|
||||
|
||||
public final static class Builder {
|
||||
@@ -197,6 +204,7 @@ public class ConversationIntents {
|
||||
private Badge giftBadge;
|
||||
private long shareDataTimestamp = -1L;
|
||||
private boolean incognito;
|
||||
private boolean hasWallpaper;
|
||||
private int flags;
|
||||
|
||||
private Builder(@NonNull Context context,
|
||||
@@ -226,6 +234,7 @@ public class ConversationIntents {
|
||||
giftBadge = args.getGiftBadge();
|
||||
shareDataTimestamp = args.getShareDataTimestamp();
|
||||
incognito = args.isIncognito();
|
||||
hasWallpaper = args.getHasWallpaper();
|
||||
|
||||
return this;
|
||||
}
|
||||
@@ -295,6 +304,11 @@ public class ConversationIntents {
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder withHasWallpaper(boolean hasWallpaper) {
|
||||
this.hasWallpaper = hasWallpaper;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder withFlags(int flags) {
|
||||
this.flags = flags;
|
||||
return this;
|
||||
@@ -317,7 +331,8 @@ public class ConversationIntents {
|
||||
giftBadge,
|
||||
shareDataTimestamp,
|
||||
conversationScreenType,
|
||||
incognito
|
||||
incognito,
|
||||
hasWallpaper
|
||||
);
|
||||
}
|
||||
|
||||
@@ -337,6 +352,7 @@ public class ConversationIntents {
|
||||
intent.setData(new Uri.Builder().authority(BUBBLE_AUTHORITY)
|
||||
.appendQueryParameter(EXTRA_RECIPIENT, recipientId.serialize())
|
||||
.appendQueryParameter(EXTRA_THREAD_ID, String.valueOf(threadId))
|
||||
.appendQueryParameter(EXTRA_HAS_WALLPAPER, String.valueOf(hasWallpaper))
|
||||
.build());
|
||||
|
||||
return intent;
|
||||
@@ -353,6 +369,7 @@ public class ConversationIntents {
|
||||
intent.putExtra(EXTRA_SHARE_DATA_TIMESTAMP, shareDataTimestamp);
|
||||
intent.putExtra(EXTRA_CONVERSATION_TYPE, conversationScreenType.code);
|
||||
intent.putExtra(EXTRA_INCOGNITO, incognito);
|
||||
intent.putExtra(EXTRA_HAS_WALLPAPER, hasWallpaper);
|
||||
|
||||
if (draftText != null) {
|
||||
intent.putExtra(EXTRA_TEXT, draftText);
|
||||
|
||||
@@ -2026,8 +2026,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (groupThread && !messageRecord.isOutgoing()) {
|
||||
String senderName = recipient.getDisplayName(getContext());
|
||||
int senderColor = colorizer.getIncomingGroupSenderColor(getContext(), messageRecord.getFromRecipient());
|
||||
senderWithLabelView.setSender(senderName, senderColor);
|
||||
senderWithLabelView.setLabel(conversationMessage.getMemberLabel());
|
||||
senderWithLabelView.bind(senderName, senderColor, conversationMessage.getMemberLabel());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-4
@@ -9,9 +9,6 @@ import android.widget.LinearLayout;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.components.Outliner;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
import org.signal.core.util.Util;
|
||||
@@ -20,6 +17,8 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class ConversationItemBodyBubble extends LinearLayout {
|
||||
|
||||
@@ -99,7 +98,7 @@ public class ConversationItemBodyBubble extends LinearLayout {
|
||||
|
||||
public @NonNull Set<Projection> getProjections() {
|
||||
return Stream.of(quoteViewProjection, videoPlayerProjection)
|
||||
.filterNot(Objects::isNull)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
|
||||
+20
-18
@@ -30,7 +30,7 @@ import androidx.core.view.ViewKt;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.signal.core.ui.compose.SignalIcons;
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
@@ -50,6 +50,8 @@ import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.LongStream;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import kotlin.Unit;
|
||||
|
||||
@@ -671,15 +673,15 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
}
|
||||
|
||||
private static @Nullable String getOldEmoji(@NonNull MessageRecord messageRecord) {
|
||||
return Stream.of(messageRecord.getReactions())
|
||||
.filter(record -> record.getAuthor()
|
||||
return messageRecord.getReactions().stream()
|
||||
.filter(record -> record.getAuthor()
|
||||
.serialize()
|
||||
.equals(Recipient.self()
|
||||
.getId()
|
||||
.serialize()))
|
||||
.findFirst()
|
||||
.map(ReactionRecord::getEmoji)
|
||||
.orElse(null);
|
||||
.findFirst()
|
||||
.map(ReactionRecord::getEmoji)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private @NonNull List<ActionItem> getMenuActionItems(@NonNull ConversationMessage conversationMessage) {
|
||||
@@ -704,7 +706,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
}
|
||||
|
||||
if (menuState.shouldShowSaveAttachmentAction()) {
|
||||
items.add(new ActionItem(R.drawable.symbol_save_android_24, getResources().getString(R.string.conversation_selection__menu_save), () -> handleActionItemClicked(Action.DOWNLOAD)));
|
||||
items.add(new ActionItem(org.signal.core.ui.R.drawable.symbol_save_android_24, getResources().getString(R.string.conversation_selection__menu_save), () -> handleActionItemClicked(Action.DOWNLOAD)));
|
||||
}
|
||||
|
||||
if (menuState.shouldShowCopyAction()) {
|
||||
@@ -776,14 +778,14 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
|
||||
int revealOffset = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_offset);
|
||||
|
||||
List<Animator> reveals = Stream.of(emojiViews)
|
||||
.mapIndexed((idx, v) -> {
|
||||
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_reveal);
|
||||
anim.setTarget(v);
|
||||
anim.setStartDelay(idx * animationEmojiStartDelayFactor);
|
||||
return anim;
|
||||
})
|
||||
.toList();
|
||||
List<Animator> reveals = LongStream.range(0, emojiViews.length)
|
||||
.boxed()
|
||||
.map(idx -> {
|
||||
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_reveal);
|
||||
anim.setTarget(emojiViews[idx.intValue()]);
|
||||
anim.setStartDelay(idx * animationEmojiStartDelayFactor);
|
||||
return anim;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
Animator backgroundRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
|
||||
backgroundRevealAnim.setTarget(backgroundView);
|
||||
@@ -821,12 +823,12 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_hide_duration);
|
||||
|
||||
List<Animator> animators = new ArrayList<>(Stream.of(emojiViews)
|
||||
.mapIndexed((idx, v) -> {
|
||||
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);
|
||||
.map( v -> {
|
||||
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);
|
||||
anim.setTarget(v);
|
||||
return anim;
|
||||
})
|
||||
.toList());
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
|
||||
backgroundHideAnim.setTarget(backgroundView);
|
||||
|
||||
+19
-5
@@ -12,7 +12,9 @@ import org.thoughtcrime.securesms.database.MessageTable;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadTable;
|
||||
import org.thoughtcrime.securesms.database.model.GroupRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
@@ -38,7 +40,7 @@ public class ConversationRepository {
|
||||
|
||||
private static final String TAG = Log.tag(ConversationRepository.class);
|
||||
|
||||
private final Context context;
|
||||
private final Context context;
|
||||
|
||||
public ConversationRepository() {
|
||||
this.context = AppDependencies.getApplication();
|
||||
@@ -52,7 +54,7 @@ public class ConversationRepository {
|
||||
int lastSeenPosition = 0;
|
||||
long lastScrolled = metadata.getLastScrolled();
|
||||
int lastScrolledPosition = 0;
|
||||
boolean isMessageRequestAccepted = RecipientUtil.isMessageRequestAccepted(context, threadId);
|
||||
boolean isMessageRequestAccepted = RecipientUtil.isMessageRequestAccepted(threadId);
|
||||
boolean isConversationHidden = RecipientUtil.isRecipientHidden(threadId);
|
||||
ConversationData.MessageRequestData messageRequestData = new ConversationData.MessageRequestData(isMessageRequestAccepted, isConversationHidden);
|
||||
boolean showUniversalExpireTimerUpdate = false;
|
||||
@@ -125,11 +127,23 @@ public class ConversationRepository {
|
||||
@NonNull
|
||||
public Single<ConversationMessage> resolveMessageToEdit(@NonNull ConversationMessage message) {
|
||||
return Single.fromCallable(() -> {
|
||||
MessageRecord messageRecord = message.getMessageRecord();
|
||||
ConversationMessage latestMessage = message;
|
||||
MessageRecord messageRecord = latestMessage.getMessageRecord();
|
||||
|
||||
MessageId latestRevisionId = messageRecord.isMms() ? ((MmsMessageRecord) messageRecord).getLatestRevisionId() : null;
|
||||
if (latestRevisionId != null) {
|
||||
MessageRecord latestRecord = SignalDatabase.messages().getMessageRecordOrNull(latestRevisionId.getId());
|
||||
if (latestRecord != null) {
|
||||
Log.e(TAG, "Resolving edit to latest revision: " + latestRevisionId.getId() + " (was: " + messageRecord.getId() + ")");
|
||||
messageRecord = latestRecord;
|
||||
latestMessage = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, messageRecord.getDisplayBody(context).toString(), message.getThreadRecipient());
|
||||
}
|
||||
}
|
||||
|
||||
if (MessageRecordUtil.hasTextSlide(messageRecord)) {
|
||||
TextSlide textSlide = MessageRecordUtil.requireTextSlide(messageRecord);
|
||||
if (textSlide.getUri() == null) {
|
||||
return message;
|
||||
return latestMessage;
|
||||
}
|
||||
|
||||
try (InputStream stream = PartAuthority.getAttachmentStream(context, textSlide.getUri())) {
|
||||
@@ -139,7 +153,7 @@ public class ConversationRepository {
|
||||
Log.w(TAG, "Failed to read text slide data.");
|
||||
}
|
||||
}
|
||||
return message;
|
||||
return latestMessage;
|
||||
}).subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread());
|
||||
}
|
||||
|
||||
+3
-2
@@ -199,13 +199,14 @@ public class ConversationTitleView extends ConstraintLayout {
|
||||
|
||||
private void setSelfTitle() {
|
||||
this.title.setText(R.string.note_to_self);
|
||||
this.subtitle.setText(R.string.ConversationFragment_official_chat);
|
||||
updateSubtitleVisibility();
|
||||
}
|
||||
|
||||
private void setReleaseNotesTitle(@NonNull Recipient recipient) {
|
||||
final String displayName = recipient.getDisplayName(getContext());
|
||||
this.title.setText(displayName);
|
||||
this.subtitle.setText(R.string.ReleaseNotes__official_only_chat);
|
||||
this.subtitle.setText(R.string.ConversationFragment_official_chat);
|
||||
updateSubtitleVisibility();
|
||||
}
|
||||
|
||||
@@ -221,7 +222,7 @@ public class ConversationTitleView extends ConstraintLayout {
|
||||
}
|
||||
|
||||
private void updateSubtitleVisibility() {
|
||||
subtitle.setVisibility(!isSelf && expirationBadgeContainer.getVisibility() != VISIBLE && !TextUtils.isEmpty(subtitle.getText()) ? VISIBLE : GONE);
|
||||
subtitle.setVisibility(expirationBadgeContainer.getVisibility() != VISIBLE && !TextUtils.isEmpty(subtitle.getText()) ? VISIBLE : GONE);
|
||||
updateVerifiedSubtitleVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -729,7 +729,7 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
}
|
||||
});
|
||||
} else if (conversationMessage.getMessageRecord().isMessageRequestAccepted()) {
|
||||
actionButton.setText(R.string.ConversationUpdateItem_options);
|
||||
actionButton.setText(R.string.ConversationUpdateItem_block_report);
|
||||
actionButton.setVisibility(VISIBLE);
|
||||
actionButton.setOnClickListener(v -> {
|
||||
if (batchSelected.isEmpty() && eventListener != null) {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.BaselineShift
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.LargeFontPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.Emojifier
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols.SignalSymbol
|
||||
|
||||
private const val VERIFIED_BADGE_ID = "verified_badge"
|
||||
|
||||
/**
|
||||
* Compose-native version of [org.thoughtcrime.securesms.recipients.Recipient.getDisplayNameForHeadline].
|
||||
*/
|
||||
@Composable
|
||||
fun HeadlineDisplayName(
|
||||
displayName: String,
|
||||
showVerified: Boolean,
|
||||
isSystemContact: Boolean,
|
||||
showChevron: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
|
||||
val chevronGlyph = if (isLtr) SignalSymbols.Glyph.CHEVRON_RIGHT else SignalSymbols.Glyph.CHEVRON_LEFT
|
||||
val outlineColor = MaterialTheme.colorScheme.outline
|
||||
val badgeOffset = with(LocalDensity.current) { (-1).sp.toDp() }
|
||||
|
||||
Emojifier(text = displayName) { emojiText, emojiInlineContent ->
|
||||
val styledText = buildAnnotatedString {
|
||||
if (!isLtr) {
|
||||
if (showChevron) {
|
||||
SignalSymbol(chevronGlyph, fontSize = 18.sp, color = outlineColor, baselineShift = BaselineShift(0.1f))
|
||||
append("\u00A0")
|
||||
}
|
||||
if (showVerified) {
|
||||
appendInlineContent(VERIFIED_BADGE_ID)
|
||||
append("\u00A0")
|
||||
} else if (isSystemContact) {
|
||||
SignalSymbol(SignalSymbols.Glyph.PERSON_CIRCLE, fontSize = 18.sp, baselineShift = BaselineShift(0.1f))
|
||||
append("\u00A0")
|
||||
}
|
||||
}
|
||||
|
||||
append(emojiText)
|
||||
|
||||
if (isLtr) {
|
||||
if (showVerified) {
|
||||
append("\u00A0")
|
||||
appendInlineContent(VERIFIED_BADGE_ID)
|
||||
} else if (isSystemContact) {
|
||||
append("\u00A0")
|
||||
SignalSymbol(SignalSymbols.Glyph.PERSON_CIRCLE, fontSize = 18.sp, baselineShift = BaselineShift(0.1f))
|
||||
}
|
||||
if (showChevron) {
|
||||
append("\u00A0")
|
||||
SignalSymbol(chevronGlyph, fontSize = 18.sp, color = outlineColor, baselineShift = BaselineShift(0.1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val inlineContent = if (showVerified) {
|
||||
emojiInlineContent + mapOf(
|
||||
VERIFIED_BADGE_ID to InlineTextContent(
|
||||
placeholder = Placeholder(width = 22.sp, height = 22.sp, placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_official_28),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize().offset(y = badgeOffset)
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
emojiInlineContent
|
||||
}
|
||||
|
||||
Text(
|
||||
text = styledText,
|
||||
inlineContent = inlineContent,
|
||||
style = MaterialTheme.typography.titleLarge.copy(textDirection = TextDirection.Ltr),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun HeadlineDisplayNamePreview() = Previews.Preview {
|
||||
HeadlineDisplayName(
|
||||
displayName = "Katie Hall",
|
||||
showVerified = false,
|
||||
isSystemContact = false,
|
||||
showChevron = true
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun HeadlineDisplayNameVerifiedPreview() = Previews.Preview {
|
||||
HeadlineDisplayName(
|
||||
displayName = "Katie Hall",
|
||||
showVerified = true,
|
||||
isSystemContact = false,
|
||||
showChevron = true
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun HeadlineDisplayNameSystemContactPreview() = Previews.Preview {
|
||||
HeadlineDisplayName(
|
||||
displayName = "Katie Hall",
|
||||
showVerified = false,
|
||||
isSystemContact = true,
|
||||
showChevron = true
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun HeadlineDisplayNameLongTextChevronPreview() = Previews.Preview {
|
||||
HeadlineDisplayName(
|
||||
displayName = "J. Jonah Jameson Jr.",
|
||||
showVerified = false,
|
||||
isSystemContact = false,
|
||||
showChevron = true,
|
||||
modifier = Modifier.width(120.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun HeadlineDisplayNameLongTextSystemContactPreview() = Previews.Preview {
|
||||
HeadlineDisplayName(
|
||||
displayName = "J. Jonah Jameson Jr.",
|
||||
showVerified = false,
|
||||
isSystemContact = true,
|
||||
showChevron = true,
|
||||
modifier = Modifier.width(120.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@LargeFontPreviews
|
||||
@Composable
|
||||
private fun HeadlineDisplayNameLargeFontChevronPreview() = Previews.Preview {
|
||||
HeadlineDisplayName(
|
||||
displayName = "Katie Hall",
|
||||
showVerified = false,
|
||||
isSystemContact = false,
|
||||
showChevron = true
|
||||
)
|
||||
}
|
||||
|
||||
@LargeFontPreviews
|
||||
@Composable
|
||||
private fun HeadlineDisplayNameLargeFontSystemContactPreview() = Previews.Preview {
|
||||
HeadlineDisplayName(
|
||||
displayName = "Katie Hall",
|
||||
showVerified = true,
|
||||
isSystemContact = true,
|
||||
showChevron = true
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import androidx.lifecycle.Lifecycle;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -128,7 +127,7 @@ public class MarkReadHelper {
|
||||
|
||||
if (item != null) {
|
||||
MessageRecord record = item.getMessageRecord();
|
||||
long latestReactionReceived = Stream.of(record.getReactions())
|
||||
long latestReactionReceived = record.getReactions().stream()
|
||||
.map(ReactionRecord::getDateReceived)
|
||||
.max(Long::compareTo)
|
||||
.orElse(0L);
|
||||
|
||||
@@ -93,7 +93,7 @@ object PinnedContextMenu {
|
||||
message.slideDeck.getStickerSlide() == null
|
||||
) {
|
||||
add(
|
||||
ActionItem(R.drawable.symbol_save_android_24, context.getString(R.string.conversation_selection__menu_save)) {
|
||||
ActionItem(CoreUiR.drawable.symbol_save_android_24, context.getString(R.string.conversation_selection__menu_save)) {
|
||||
callbacks.onSave()
|
||||
}
|
||||
)
|
||||
|
||||
+1
-1
@@ -154,7 +154,7 @@ class PinnedMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment()
|
||||
val callback = GiphyMp4ProjectionRecycler(holders)
|
||||
|
||||
GiphyMp4PlaybackController.attach(list, callback, maxPlayback)
|
||||
list.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
|
||||
list.addItemDecoration(GiphyMp4ItemDecoration(callback), 0)
|
||||
|
||||
return callback
|
||||
}
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment
|
||||
val callback = GiphyMp4ProjectionRecycler(holders)
|
||||
|
||||
GiphyMp4PlaybackController.attach(list, callback, maxPlayback)
|
||||
list.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
|
||||
list.addItemDecoration(GiphyMp4ItemDecoration(callback), 0)
|
||||
|
||||
return callback
|
||||
}
|
||||
|
||||
+71
-81
@@ -6,13 +6,10 @@
|
||||
package org.thoughtcrime.securesms.conversation.plaintext
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import org.signal.core.util.EventTimer
|
||||
import org.signal.core.util.ParallelEventTimer
|
||||
import org.signal.core.util.androidx.DocumentFileUtil.outputStream
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
@@ -25,14 +22,19 @@ import org.thoughtcrime.securesms.database.model.Quote
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.BufferedWriter
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.OutputStreamWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Future
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
/**
|
||||
* Exports a conversation thread as user-friendly plaintext with attachments.
|
||||
@@ -45,8 +47,9 @@ object PlaintextExportRepository {
|
||||
fun export(
|
||||
context: Context,
|
||||
threadId: Long,
|
||||
directoryUri: Uri,
|
||||
outputFile: File,
|
||||
chatName: String,
|
||||
includeMedia: Boolean,
|
||||
progressListener: ProgressListener,
|
||||
cancellationSignal: CancellationSignal
|
||||
): Boolean {
|
||||
@@ -54,45 +57,18 @@ object PlaintextExportRepository {
|
||||
val stats = getExportStats(threadId)
|
||||
eventTimer.emit("stats")
|
||||
|
||||
val root = DocumentFile.fromTreeUri(context, directoryUri) ?: run {
|
||||
Log.w(TAG, "Could not open directory")
|
||||
return false
|
||||
}
|
||||
|
||||
val sanitizedName = sanitizeFileName(chatName)
|
||||
if (root.findFile(sanitizedName) != null) {
|
||||
Log.w(TAG, "Export folder already exists: $sanitizedName")
|
||||
return false
|
||||
}
|
||||
|
||||
val chatDir = root.createDirectory(sanitizedName) ?: run {
|
||||
Log.w(TAG, "Could not create chat directory")
|
||||
return false
|
||||
}
|
||||
|
||||
val mediaDir = chatDir.createDirectory("media") ?: run {
|
||||
Log.w(TAG, "Could not create media directory")
|
||||
return false
|
||||
}
|
||||
|
||||
val chatFile = chatDir.createFile("text/plain", "chat.txt") ?: run {
|
||||
Log.w(TAG, "Could not create chat.txt")
|
||||
return false
|
||||
}
|
||||
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
val attachmentDateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
|
||||
val pendingAttachments = mutableListOf<PendingAttachment>()
|
||||
var messagesProcessed = 0
|
||||
|
||||
val outputStream = chatFile.outputStream(context) ?: run {
|
||||
Log.w(TAG, "Could not open chat.txt for writing")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
outputStream.bufferedWriter().use { writer ->
|
||||
ZipOutputStream(BufferedOutputStream(outputFile.outputStream())).use { zipOut ->
|
||||
zipOut.putNextEntry(ZipEntry("$sanitizedName/chat.txt"))
|
||||
val writer = BufferedWriter(OutputStreamWriter(zipOut, Charsets.UTF_8))
|
||||
|
||||
writer.write("Chat export: $chatName")
|
||||
writer.newLine()
|
||||
writer.write("Exported on: ${dateFormat.format(Date())}")
|
||||
@@ -103,7 +79,6 @@ object PlaintextExportRepository {
|
||||
|
||||
val extraDataTimer = ParallelEventTimer()
|
||||
|
||||
// Messages
|
||||
MessageTable.mmsReaderFor(SignalDatabase.messages.getConversation(threadId, dateReceiveOrderBy = "ASC")).use { reader ->
|
||||
while (true) {
|
||||
if (cancellationSignal.isCancelled()) return false
|
||||
@@ -117,51 +92,51 @@ object PlaintextExportRepository {
|
||||
for (message in batch) {
|
||||
if (cancellationSignal.isCancelled()) return false
|
||||
|
||||
writer.writeMessage(context, message, extraData, dateFormat, attachmentDateFormat, pendingAttachments)
|
||||
writer.writeMessage(context, message, extraData, dateFormat, attachmentDateFormat, pendingAttachments, includeMedia)
|
||||
writer.newLine()
|
||||
|
||||
messagesProcessed++
|
||||
progressListener.onProgress(messagesProcessed, stats.messageCount, 0, stats.attachmentCount)
|
||||
if (includeMedia) {
|
||||
progressListener.onProgress(messagesProcessed, stats.messageCount, 0, stats.attachmentCount)
|
||||
} else {
|
||||
progressListener.onProgress(messagesProcessed, stats.messageCount, 0, 0)
|
||||
}
|
||||
}
|
||||
eventTimer.emit("messages")
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "[PlaintextExport] ${extraDataTimer.stop().summary}")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error writing chat.txt", e)
|
||||
return false
|
||||
}
|
||||
|
||||
// Attachments — use createFile directly (like LocalArchiver's FilesFileSystem) to avoid
|
||||
// the extra content resolver queries that newFile/findFile perform.
|
||||
val totalAttachments = pendingAttachments.size
|
||||
var attachmentsProcessed = 0
|
||||
for (pending in pendingAttachments) {
|
||||
if (cancellationSignal.isCancelled()) return false
|
||||
writer.flush()
|
||||
zipOut.closeEntry()
|
||||
|
||||
try {
|
||||
val outputStream = mediaDir.createFile("application/octet-stream", pending.exportedName)?.let { it.outputStream(context) }
|
||||
if (outputStream == null) {
|
||||
Log.w(TAG, "Could not create attachment file: ${pending.exportedName}")
|
||||
attachmentsProcessed++
|
||||
progressListener.onProgress(stats.messageCount, stats.messageCount, attachmentsProcessed, totalAttachments)
|
||||
continue
|
||||
}
|
||||
if (includeMedia) {
|
||||
val totalAttachments = pendingAttachments.size
|
||||
var attachmentsProcessed = 0
|
||||
for (pending in pendingAttachments) {
|
||||
if (cancellationSignal.isCancelled()) return false
|
||||
|
||||
outputStream.use { out ->
|
||||
SignalDatabase.attachments.getAttachmentStream(pending.attachment.attachmentId, 0).use { input ->
|
||||
input.copyTo(out)
|
||||
try {
|
||||
zipOut.putNextEntry(ZipEntry("$sanitizedName/media/${pending.exportedName}"))
|
||||
SignalDatabase.attachments.getAttachmentStream(pending.attachment.attachmentId, 0).use { input ->
|
||||
input.copyTo(zipOut)
|
||||
}
|
||||
zipOut.closeEntry()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error exporting attachment: ${pending.exportedName}", e)
|
||||
}
|
||||
|
||||
attachmentsProcessed++
|
||||
progressListener.onProgress(stats.messageCount, stats.messageCount, attachmentsProcessed, totalAttachments)
|
||||
eventTimer.emit("media")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error exporting attachment: ${pending.exportedName}", e)
|
||||
}
|
||||
|
||||
attachmentsProcessed++
|
||||
progressListener.onProgress(stats.messageCount, stats.messageCount, attachmentsProcessed, totalAttachments)
|
||||
eventTimer.emit("media")
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error writing export zip", e)
|
||||
outputFile.delete()
|
||||
return false
|
||||
}
|
||||
|
||||
Log.d(TAG, "[PlaintextExport] ${eventTimer.stop().summary}")
|
||||
@@ -222,7 +197,8 @@ object PlaintextExportRepository {
|
||||
extraData: ExtraMessageData,
|
||||
dateFormat: SimpleDateFormat,
|
||||
attachmentDateFormat: SimpleDateFormat,
|
||||
pendingAttachments: MutableList<PendingAttachment>
|
||||
pendingAttachments: MutableList<PendingAttachment>,
|
||||
includeMedia: Boolean
|
||||
) {
|
||||
val timestamp = dateFormat.format(Date(message.dateSent))
|
||||
|
||||
@@ -262,7 +238,7 @@ object PlaintextExportRepository {
|
||||
}
|
||||
|
||||
if (stickerAttachment != null) {
|
||||
this.writeSticker(stickerAttachment, prefix, hasQuote, attachmentDateFormat, pendingAttachments)
|
||||
this.writeSticker(stickerAttachment, prefix, hasQuote, attachmentDateFormat, pendingAttachments, includeMedia)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -282,7 +258,7 @@ object PlaintextExportRepository {
|
||||
}
|
||||
|
||||
val wrotePrefix = !body.isNullOrEmpty() || hasQuote
|
||||
this.writeAttachments(mainAttachments, prefix, wrotePrefix, attachmentDateFormat, pendingAttachments)
|
||||
this.writeAttachments(mainAttachments, prefix, wrotePrefix, attachmentDateFormat, pendingAttachments, includeMedia)
|
||||
}
|
||||
|
||||
private fun BufferedWriter.writeUpdateMessage(context: Context, message: MmsMessageRecord, timestamp: String) {
|
||||
@@ -323,15 +299,20 @@ object PlaintextExportRepository {
|
||||
prefix: String,
|
||||
hasQuote: Boolean,
|
||||
attachmentDateFormat: SimpleDateFormat,
|
||||
pendingAttachments: MutableList<PendingAttachment>
|
||||
pendingAttachments: MutableList<PendingAttachment>,
|
||||
includeMedia: Boolean
|
||||
) {
|
||||
val emoji = stickerAttachment.stickerLocator?.emoji ?: ""
|
||||
val exportedName = buildAttachmentFileName(stickerAttachment, attachmentDateFormat)
|
||||
pendingAttachments.add(PendingAttachment(stickerAttachment, exportedName))
|
||||
if (!hasQuote) {
|
||||
this.write(prefix)
|
||||
}
|
||||
this.write("(Sticker) $emoji [See: media/$exportedName]")
|
||||
if (includeMedia) {
|
||||
val exportedName = buildAttachmentFileName(stickerAttachment, attachmentDateFormat)
|
||||
pendingAttachments.add(PendingAttachment(stickerAttachment, exportedName))
|
||||
this.write("(Sticker) $emoji [See: media/$exportedName]")
|
||||
} else {
|
||||
this.write("(Sticker) $emoji")
|
||||
}
|
||||
this.newLine()
|
||||
}
|
||||
|
||||
@@ -340,23 +321,32 @@ object PlaintextExportRepository {
|
||||
prefix: String,
|
||||
wrotePrefix: Boolean,
|
||||
attachmentDateFormat: SimpleDateFormat,
|
||||
pendingAttachments: MutableList<PendingAttachment>
|
||||
pendingAttachments: MutableList<PendingAttachment>,
|
||||
includeMedia: Boolean
|
||||
) {
|
||||
for ((index, attachment) in attachments.withIndex()) {
|
||||
val exportedName = buildAttachmentFileName(attachment, attachmentDateFormat)
|
||||
pendingAttachments.add(PendingAttachment(attachment, exportedName))
|
||||
|
||||
val label = getAttachmentLabel(attachment)
|
||||
|
||||
if (!wrotePrefix && index == 0) {
|
||||
this.write(prefix)
|
||||
}
|
||||
|
||||
val caption = attachment.caption
|
||||
if (caption != null) {
|
||||
this.write("[$label: media/$exportedName] $caption")
|
||||
if (includeMedia) {
|
||||
val exportedName = buildAttachmentFileName(attachment, attachmentDateFormat)
|
||||
pendingAttachments.add(PendingAttachment(attachment, exportedName))
|
||||
val caption = attachment.caption
|
||||
if (caption != null) {
|
||||
this.write("[$label: media/$exportedName] $caption")
|
||||
} else {
|
||||
this.write("[$label: media/$exportedName]")
|
||||
}
|
||||
} else {
|
||||
this.write("[$label: media/$exportedName]")
|
||||
val caption = attachment.caption
|
||||
if (caption != null) {
|
||||
this.write("[$label] $caption")
|
||||
} else {
|
||||
this.write("[$label]")
|
||||
}
|
||||
}
|
||||
this.newLine()
|
||||
}
|
||||
|
||||
+1
-1
@@ -139,7 +139,7 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
val callback = GiphyMp4ProjectionRecycler(holders)
|
||||
|
||||
GiphyMp4PlaybackController.attach(list, callback, maxPlayback)
|
||||
list.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
|
||||
list.addItemDecoration(GiphyMp4ItemDecoration(callback), 0)
|
||||
|
||||
return callback
|
||||
}
|
||||
|
||||
+1
-1
@@ -141,7 +141,7 @@ class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
val callback = GiphyMp4ProjectionRecycler(holders)
|
||||
|
||||
GiphyMp4PlaybackController.attach(binding.editHistoryList, callback, maxPlayback)
|
||||
binding.editHistoryList.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
|
||||
binding.editHistoryList.addItemDecoration(GiphyMp4ItemDecoration(callback), 0)
|
||||
|
||||
return callback
|
||||
}
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ final class SafetyNumberChangeAdapter extends ListAdapter<ChangedRecipient, Safe
|
||||
if (changedRecipient.isUnverified() || changedRecipient.isVerified()) {
|
||||
subtitle.setText(R.string.safety_number_change_dialog__previous_verified);
|
||||
|
||||
Drawable check = DrawableUtil.tint(ContextUtil.requireDrawable(itemView.getContext(), R.drawable.symbol_check_24), ContextCompat.getColor(itemView.getContext(), R.color.signal_text_secondary));
|
||||
Drawable check = DrawableUtil.tint(ContextUtil.requireDrawable(itemView.getContext(), org.signal.core.ui.R.drawable.symbol_check_24), ContextCompat.getColor(itemView.getContext(), R.color.signal_text_secondary));
|
||||
check.setBounds(0, 0, ViewUtil.dpToPx(12), ViewUtil.dpToPx(12));
|
||||
subtitle.setCompoundDrawables(check, null, null, null);
|
||||
} else if (changedRecipient.getRecipient().hasAUserSetDisplayName(itemView.getContext())) {
|
||||
|
||||
+10
-11
@@ -22,7 +22,7 @@ import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import java.util.stream.Collectors;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.verify.VerifyIdentityActivity;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class SafetyNumberChangeDialog extends DialogFragment implements SafetyNumberChangeAdapter.Callbacks {
|
||||
|
||||
@@ -71,11 +72,10 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa
|
||||
}
|
||||
|
||||
public static void showForGroupCall(@NonNull FragmentManager fragmentManager, @NonNull List<IdentityRecord> identityRecords) {
|
||||
List<String> ids = Stream.of(identityRecords)
|
||||
.filterNot(IdentityRecord::isFirstUse)
|
||||
.map(record -> record.getRecipientId().serialize())
|
||||
.distinct()
|
||||
.toList();
|
||||
List<String> ids = identityRecords.stream()
|
||||
.filter(identityRecord -> !identityRecord.isFirstUse())
|
||||
.map(record -> record.getRecipientId().serialize())
|
||||
.distinct().collect(Collectors.toList());
|
||||
|
||||
Bundle arguments = new Bundle();
|
||||
arguments.putStringArray(RECIPIENT_IDS_EXTRA, ids.toArray(new String[0]));
|
||||
@@ -92,10 +92,9 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> ids = Stream.of(recipientIds)
|
||||
.map(RecipientId::serialize)
|
||||
.distinct()
|
||||
.toList();
|
||||
List<String> ids = recipientIds.stream()
|
||||
.map(RecipientId::serialize)
|
||||
.distinct().collect(Collectors.toList());
|
||||
|
||||
Bundle arguments = new Bundle();
|
||||
arguments.putStringArray(RECIPIENT_IDS_EXTRA, ids.toArray(new String[0]));
|
||||
@@ -118,7 +117,7 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa
|
||||
super.onActivityCreated(savedInstanceState);
|
||||
|
||||
//noinspection ConstantConditions
|
||||
List<RecipientId> recipientIds = Stream.of(getArguments().getStringArray(RECIPIENT_IDS_EXTRA)).map(RecipientId::from).toList();
|
||||
List<RecipientId> recipientIds = Stream.of(getArguments().getStringArray(RECIPIENT_IDS_EXTRA)).map(RecipientId::from).collect(Collectors.toList());
|
||||
long messageId = getArguments().getLong(MESSAGE_ID_EXTRA, -1);
|
||||
String messageType = getArguments().getString(MESSAGE_TYPE_EXTRA, null);
|
||||
|
||||
|
||||
+4
-7
@@ -8,8 +8,7 @@ import androidx.annotation.WorkerThread;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.util.Util;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
@@ -28,7 +27,6 @@ import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.safety.SafetyNumberRecipient;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.signal.core.util.Util;
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
|
||||
@@ -89,11 +87,10 @@ public final class SafetyNumberChangeRepository {
|
||||
messageRecord = getMessageRecord(messageId, messageType);
|
||||
}
|
||||
|
||||
List<Recipient> recipients = Stream.of(recipientIds).map(Recipient::resolved).toList();
|
||||
List<Recipient> recipients = recipientIds.stream().map(Recipient::resolved).collect(Collectors.toList());
|
||||
|
||||
List<ChangedRecipient> changedRecipients = Stream.of(AppDependencies.getProtocolStore().aci().identities().getIdentityRecords(recipients).getIdentityRecords())
|
||||
.map(record -> new ChangedRecipient(Recipient.resolved(record.getRecipientId()), record))
|
||||
.toList();
|
||||
List<ChangedRecipient> changedRecipients = AppDependencies.getProtocolStore().aci().identities().getIdentityRecords(recipients).getIdentityRecords().stream()
|
||||
.map(record -> new ChangedRecipient(Recipient.resolved(record.getRecipientId()), record)).collect(Collectors.toList());
|
||||
|
||||
Log.d(TAG, "Safety number change state, message: " + (messageRecord != null ? messageRecord.getId() : "null") + " records: " + Util.join(changedRecipients, ","));
|
||||
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.conversation.ui.error;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
@@ -28,7 +28,7 @@ public class TrustAndVerifyResult {
|
||||
}
|
||||
|
||||
TrustAndVerifyResult(@NonNull List<ChangedRecipient> changedRecipients, @Nullable MessageRecord messageRecord, @NonNull Result result) {
|
||||
this.changedRecipients = Stream.of(changedRecipients).map(changedRecipient -> changedRecipient.getRecipient().getId()).toList();
|
||||
this.changedRecipients = changedRecipients.stream().map(changedRecipient -> changedRecipient.getRecipient().getId()).collect(Collectors.toList());
|
||||
this.messageRecord = messageRecord;
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerRepository.MentionQuery;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
@@ -40,7 +40,7 @@ public class MentionsPickerViewModel extends ViewModel {
|
||||
|
||||
LiveData<MentionQuery> mentionQuery = LiveDataUtil.combineLatest(liveQuery, fullMembers, (q, m) -> new MentionQuery(q.query, m));
|
||||
|
||||
this.mentionList = LiveDataUtil.mapAsync(mentionQuery, q -> Stream.of(mentionsPickerRepository.search(q)).<MappingModel<?>>map(MentionViewState::new).toList());
|
||||
this.mentionList = LiveDataUtil.mapAsync(mentionQuery, q -> mentionsPickerRepository.search(q).stream().<MappingModel<?>>map(MentionViewState::new).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
@NonNull LiveData<List<MappingModel<?>>> getMentionList() {
|
||||
|
||||
+53
-167
@@ -5,8 +5,6 @@
|
||||
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.content.Context
|
||||
import android.text.TextUtils
|
||||
import android.view.GestureDetector
|
||||
import android.view.GestureDetector.SimpleOnGestureListener
|
||||
import android.view.MotionEvent
|
||||
@@ -18,6 +16,7 @@ import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.bumptech.glide.RequestManager
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.toOptional
|
||||
import org.thoughtcrime.securesms.BindableConversationItem
|
||||
@@ -26,6 +25,7 @@ import org.thoughtcrime.securesms.Unbindable
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge
|
||||
import org.thoughtcrime.securesms.conversation.ConversationHeaderCallbacks
|
||||
import org.thoughtcrime.securesms.conversation.ConversationHeaderView
|
||||
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
@@ -48,20 +48,20 @@ import org.thoughtcrime.securesms.conversation.v2.items.V2ConversationItemMediaV
|
||||
import org.thoughtcrime.securesms.conversation.v2.items.V2ConversationItemTextOnlyViewHolder
|
||||
import org.thoughtcrime.securesms.conversation.v2.items.V2Payload
|
||||
import org.thoughtcrime.securesms.conversation.v2.items.bridge
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.databinding.V2ConversationItemMediaIncomingBinding
|
||||
import org.thoughtcrime.securesms.databinding.V2ConversationItemMediaOutgoingBinding
|
||||
import org.thoughtcrime.securesms.databinding.V2ConversationItemTextOnlyIncomingBinding
|
||||
import org.thoughtcrime.securesms.databinding.V2ConversationItemTextOnlyOutgoingBinding
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil
|
||||
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.ui.about.AboutSheet
|
||||
import org.thoughtcrime.securesms.util.CachedInflater
|
||||
import org.thoughtcrime.securesms.util.Projection
|
||||
import org.thoughtcrime.securesms.util.ProjectionList
|
||||
import org.thoughtcrime.securesms.util.SignalE164Util
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
|
||||
import java.util.Locale
|
||||
@@ -310,13 +310,20 @@ class ConversationAdapterV2(
|
||||
if (multiselectPart.getMessageRecord().isInMemoryMessageRecord) { return }
|
||||
|
||||
if (multiselectPart is MultiselectPart.CollapsedHead) {
|
||||
val headId = multiselectPart.conversationMessage.messageRecord.collapsedHeadId
|
||||
val totalChildCount = multiselectPart.conversationMessage.collapsedSize - 1
|
||||
val collapsedChildren: List<MultiselectPart> = mutableListOf<MultiselectPart>().apply {
|
||||
add(getConversationMessage(adapterPosition)!!.multiselectCollection.asDouble().bottomPart)
|
||||
addAll(
|
||||
(1 until multiselectPart.conversationMessage.collapsedSize).mapNotNull { i ->
|
||||
getConversationMessage(adapterPosition - i)?.multiselectCollection?.asSingle()?.singlePart
|
||||
var currentChildCount = 0
|
||||
var offset = 1
|
||||
while (currentChildCount < totalChildCount && adapterPosition - offset >= 0) {
|
||||
val child = getConversationMessage(adapterPosition - offset)
|
||||
if (child != null && child.messageRecord.collapsedHeadId == headId) {
|
||||
add(child.multiselectCollection.asSingle().singlePart)
|
||||
currentChildCount++
|
||||
}
|
||||
)
|
||||
offset++
|
||||
}
|
||||
}
|
||||
|
||||
val isSelecting = collapsedChildren.any { it !in _selected }
|
||||
@@ -558,165 +565,44 @@ class ConversationAdapterV2(
|
||||
inner class ThreadHeaderViewHolder(itemView: View) : MappingViewHolder<ThreadHeader>(itemView) {
|
||||
private val conversationBanner: ConversationHeaderView = itemView as ConversationHeaderView
|
||||
|
||||
init {
|
||||
conversationBanner.callbacks = object : ConversationHeaderCallbacks {
|
||||
override fun onSafetyTipsClicked(forGroup: Boolean) = clickListener.onShowSafetyTips(forGroup)
|
||||
|
||||
override fun onUnverifiedNameClicked(forGroup: Boolean) = clickListener.onShowUnverifiedProfileSheet(forGroup)
|
||||
|
||||
override fun onTitleClicked() {
|
||||
val recipient = conversationBanner.recipientInfo?.recipient ?: return
|
||||
if (recipient.isIndividual && !recipient.isSelf) {
|
||||
displayDialogFragment(AboutSheet.create(recipient))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onGroupSettingsClicked() {
|
||||
val recipient = conversationBanner.recipientInfo?.recipient ?: return
|
||||
context.startActivity(ConversationSettingsActivity.forGroup(context, recipient.requireGroupId()))
|
||||
}
|
||||
|
||||
override fun onShowGroupDescriptionClicked(groupName: String, description: String, linkifyWebLinks: Boolean) {
|
||||
clickListener.onShowGroupDescriptionClicked(groupName, description, linkifyWebLinks)
|
||||
}
|
||||
|
||||
override fun onAvatarTapToViewClicked() {
|
||||
val recipient = conversationBanner.recipientInfo?.recipient ?: return
|
||||
AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.IN_PROGRESS)
|
||||
SignalExecutors.BOUNDED.execute { SignalDatabase.recipients.manuallyUpdateShowAvatar(recipient.id, true) }
|
||||
if (recipient.isPushV2Group) {
|
||||
AvatarGroupsV2DownloadJob.enqueueUnblurredAvatar(recipient.requireGroupId().requireV2())
|
||||
} else {
|
||||
RetrieveProfileAvatarJob.enqueueUnblurredAvatar(recipient)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun bind(model: ThreadHeader) {
|
||||
val (recipient, groupInfo, sharedGroups, messageRequestState) = model.recipientInfo
|
||||
val isSelf = recipient.id == Recipient.self().id
|
||||
|
||||
when (model.avatarDownloadState) {
|
||||
AvatarDownloadStateCache.DownloadState.NONE,
|
||||
AvatarDownloadStateCache.DownloadState.FINISHED -> {
|
||||
conversationBanner.setAvatar(requestManager, recipient)
|
||||
}
|
||||
AvatarDownloadStateCache.DownloadState.IN_PROGRESS -> {
|
||||
conversationBanner.showProgressBar(recipient)
|
||||
}
|
||||
AvatarDownloadStateCache.DownloadState.FAILED -> {
|
||||
conversationBanner.showFailedAvatarDownload(recipient)
|
||||
}
|
||||
}
|
||||
|
||||
conversationBanner.showBackgroundBubble(recipient.hasWallpaper)
|
||||
val title: String = conversationBanner.setTitle(recipient) {
|
||||
displayDialogFragment(AboutSheet.create(recipient))
|
||||
}
|
||||
|
||||
if (recipient.isReleaseNotes) {
|
||||
conversationBanner.showReleaseNoteHeader()
|
||||
}
|
||||
|
||||
conversationBanner.setAbout(recipient)
|
||||
|
||||
if (recipient.isGroup) {
|
||||
if (!groupInfo.hasExistingContacts) {
|
||||
conversationBanner.setUnverifiedNameSubtitle(R.drawable.symbol_group_question_16, true) {
|
||||
clickListener.onShowUnverifiedProfileSheet(true)
|
||||
}
|
||||
} else {
|
||||
conversationBanner.hideUnverifiedNameSubtitle()
|
||||
}
|
||||
|
||||
if (groupInfo.fullMemberCount > 0 || groupInfo.pendingMemberCount > 0) {
|
||||
if (groupInfo.fullMemberCount == 1 && groupInfo.isMember) {
|
||||
conversationBanner.hideUnverifiedNameSubtitle()
|
||||
}
|
||||
setSubtitle(context, groupInfo.pendingMemberCount, groupInfo.fullMemberCount, groupInfo.membersPreview, groupInfo.isMember, recipient)
|
||||
} else {
|
||||
conversationBanner.hideSubtitle()
|
||||
}
|
||||
} else if (isSelf) {
|
||||
conversationBanner.setSubtitle(context.getString(R.string.ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation), R.drawable.symbol_note_compact_16, null, null)
|
||||
} else {
|
||||
if ((recipient.profileName.toString() == recipient.getDisplayName(context)) && recipient.nickname.isEmpty && !recipient.isSystemContact) {
|
||||
conversationBanner.setUnverifiedNameSubtitle(R.drawable.symbol_person_question_16, false) {
|
||||
clickListener.onShowUnverifiedProfileSheet(false)
|
||||
}
|
||||
} else {
|
||||
conversationBanner.hideUnverifiedNameSubtitle()
|
||||
}
|
||||
|
||||
val subtitle: String? = recipient.takeIf { it.shouldShowE164 }?.e164?.map { e164: String? -> SignalE164Util.prettyPrint(e164!!) }?.orElse(null)
|
||||
if (subtitle == null || subtitle == title) {
|
||||
conversationBanner.hideSubtitle()
|
||||
} else {
|
||||
conversationBanner.setSubtitle(subtitle, R.drawable.symbol_phone_compact_16, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
conversationBanner.hideButton()
|
||||
|
||||
if (messageRequestState?.isAccepted == false && !isSelf && !recipient.isGroup) {
|
||||
if (sharedGroups.size < MIN_GROUPS_THRESHOLD) {
|
||||
conversationBanner.showWarningSubtitle()
|
||||
}
|
||||
conversationBanner.setButton(context.getString(R.string.ConversationFragment_safety_tips)) {
|
||||
clickListener.onShowSafetyTips(false)
|
||||
}
|
||||
conversationBanner.setDescription(getDescription(context, sharedGroups), R.drawable.symbol_group_compact_16)
|
||||
} else if (messageRequestState?.isAccepted == false && recipient.isGroup) {
|
||||
conversationBanner.showWarningSubtitle()
|
||||
conversationBanner.setButton(context.getString(R.string.ConversationFragment_safety_tips)) {
|
||||
clickListener.onShowSafetyTips(true)
|
||||
}
|
||||
} else if ((recipient.isGroup && sharedGroups.isEmpty()) || isSelf) {
|
||||
conversationBanner.hideWarningSubtitle()
|
||||
if (TextUtils.isEmpty(groupInfo.description)) {
|
||||
conversationBanner.setLinkifyDescription(false)
|
||||
conversationBanner.hideDescription()
|
||||
} else {
|
||||
conversationBanner.setLinkifyDescription(true)
|
||||
val linkifyWebLinks = messageRequestState?.isAccepted == true
|
||||
conversationBanner.showDescription()
|
||||
|
||||
GroupDescriptionUtil.setText(
|
||||
context,
|
||||
conversationBanner.description,
|
||||
groupInfo.description,
|
||||
linkifyWebLinks
|
||||
) {
|
||||
clickListener.onShowGroupDescriptionClicked(recipient.getDisplayName(context), groupInfo.description, linkifyWebLinks)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
conversationBanner.hideWarningSubtitle()
|
||||
conversationBanner.setDescription(getDescription(context, sharedGroups), R.drawable.symbol_group_compact_16)
|
||||
}
|
||||
conversationBanner.updateOutlineBoxSize()
|
||||
}
|
||||
|
||||
private fun setSubtitle(context: Context, pendingMemberCount: Int, size: Int, members: List<Recipient>, isMember: Boolean, recipient: Recipient) {
|
||||
val names = members.map { member -> member.getDisplayName(context) }
|
||||
val otherMembers = if (size > 3) context.resources.getQuantityString(R.plurals.MessageRequestProfileView_other_members, size - 3, size - 3) else null
|
||||
val membersSubtitle = if (isMember) {
|
||||
when (names.size) {
|
||||
0 -> context.getString(R.string.MessageRequestProfileView_group_members_zero)
|
||||
1 -> context.getString(R.string.MessageRequestProfileView_group_members_one_and_you, names[0])
|
||||
2 -> context.getString(R.string.MessageRequestProfileView_group_members_two_and_you, names[0], names[1])
|
||||
else -> context.getString(R.string.MessageRequestProfileView_group_members_other, names[0], names[1], names[2], otherMembers)
|
||||
}
|
||||
} else {
|
||||
when (names.size) {
|
||||
0 -> context.getString(R.string.MessageRequestProfileView_group_members_zero)
|
||||
1 -> context.getString(R.string.MessageRequestProfileView_group_members_one, names[0])
|
||||
2 -> context.getString(R.string.MessageRequestProfileView_group_members_two, names[0], names[1])
|
||||
3 -> context.getString(R.string.MessageRequestProfileView_group_members_three, names[0], names[1], names[2])
|
||||
else -> context.getString(R.string.MessageRequestProfileView_group_members_other, names[0], names[1], names[2], otherMembers)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingMemberCount > 0) {
|
||||
val invited = context.resources.getQuantityString(R.plurals.MessageRequestProfileView_invited, pendingMemberCount, pendingMemberCount)
|
||||
val subtitle = context.getString(R.string.MessageRequestProfileView_member_names_and_invited, membersSubtitle, invited)
|
||||
conversationBanner.setSubtitle(subtitle, R.drawable.symbol_group_compact_16, otherMembers) { goToGroupSettings(recipient) }
|
||||
} else {
|
||||
conversationBanner.setSubtitle(membersSubtitle, R.drawable.symbol_group_compact_16, otherMembers) { goToGroupSettings(recipient) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDescription(context: Context, sharedGroups: List<String>): String {
|
||||
return when (sharedGroups.size) {
|
||||
0 -> context.getString(R.string.ConversationUpdateItem_no_groups_in_common_review_requests_carefully)
|
||||
1 -> context.getString(R.string.MessageRequestProfileView_member_of_one_group, sharedGroups[0])
|
||||
2 -> context.getString(R.string.MessageRequestProfileView_member_of_two_groups, sharedGroups[0], sharedGroups[1])
|
||||
3 -> context.getString(R.string.MessageRequestProfileView_member_of_many_groups, sharedGroups[0], sharedGroups[1], sharedGroups[2])
|
||||
else -> {
|
||||
val others: Int = sharedGroups.size - 2
|
||||
context.getString(
|
||||
R.string.MessageRequestProfileView_member_of_many_groups,
|
||||
sharedGroups[0],
|
||||
sharedGroups[1],
|
||||
context.resources.getQuantityString(R.plurals.MessageRequestProfileView_member_of_d_additional_groups, others, others)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun goToGroupSettings(recipient: Recipient) {
|
||||
val intent = ConversationSettingsActivity.forGroup(getContext(), recipient.requireGroupId())
|
||||
val bundle = ConversationSettingsActivity.createTransitionBundle(
|
||||
getContext(),
|
||||
conversationBanner.getViewById(R.id.message_request_avatar)
|
||||
)
|
||||
getContext().startActivity(intent, bundle)
|
||||
conversationBanner.recipientInfo = model.recipientInfo
|
||||
conversationBanner.avatarDownloadState = model.avatarDownloadState
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,20 @@ object ConversationDialogs {
|
||||
.show()
|
||||
}
|
||||
|
||||
fun displayCannotStartGroupCallDueToNoLongerAMemberDialog(context: Context) {
|
||||
MaterialAlertDialogBuilder(context).setTitle(R.string.ConversationActivity_cant_start_group_call)
|
||||
.setMessage(R.string.CallLogFragment__cant_start_call_no_longer_a_member)
|
||||
.setPositiveButton(android.R.string.ok) { d: DialogInterface, _: Int -> d.dismiss() }
|
||||
.show()
|
||||
}
|
||||
|
||||
fun displayCannotStartGroupCallDueToGroupEndedDialog(context: Context) {
|
||||
MaterialAlertDialogBuilder(context).setTitle(R.string.ConversationActivity_cant_start_group_call)
|
||||
.setMessage(R.string.conversation_activity__group_action_not_allowed_group_ended)
|
||||
.setPositiveButton(android.R.string.ok) { d: DialogInterface, _: Int -> d.dismiss() }
|
||||
.show()
|
||||
}
|
||||
|
||||
fun displayChatSessionRefreshLearnMoreDialog(context: Context) {
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setView(R.layout.decryption_failed_dialog)
|
||||
|
||||
+90
-27
@@ -11,6 +11,7 @@ import android.app.ActivityOptions
|
||||
import android.app.PendingIntent
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
@@ -21,6 +22,7 @@ import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -93,6 +95,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -105,7 +108,6 @@ import org.greenrobot.eventbus.ThreadMode
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.models.media.TransformProperties
|
||||
import org.signal.core.ui.BottomSheetUtil
|
||||
import org.signal.core.ui.contracts.OpenDocumentTreeContract
|
||||
import org.signal.core.ui.getWindowSizeClass
|
||||
import org.signal.core.ui.isSplitPane
|
||||
import org.signal.core.ui.logging.LoggingFragment
|
||||
@@ -331,6 +333,7 @@ import org.thoughtcrime.securesms.registration.ui.RegistrationActivity
|
||||
import org.thoughtcrime.securesms.revealable.ViewOnceMessageActivity
|
||||
import org.thoughtcrime.securesms.revealable.ViewOnceUtil
|
||||
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
|
||||
import org.thoughtcrime.securesms.sharing.v2.ShareActivity
|
||||
import org.thoughtcrime.securesms.sms.MessageSender
|
||||
import org.thoughtcrime.securesms.stickers.StickerEventListener
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
@@ -349,6 +352,7 @@ import org.thoughtcrime.securesms.util.DeleteDialog
|
||||
import org.thoughtcrime.securesms.util.Dialogs
|
||||
import org.thoughtcrime.securesms.util.DoubleClickDebouncer
|
||||
import org.thoughtcrime.securesms.util.DrawableUtil
|
||||
import org.thoughtcrime.securesms.util.FileProviderUtil
|
||||
import org.thoughtcrime.securesms.util.FullscreenHelper
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.MessageConstraintsUtil
|
||||
@@ -429,6 +433,16 @@ class ConversationFragment :
|
||||
|
||||
private const val ATTACHMENT_KEYBOARD_FRAGMENT_CREATOR_ID = 1
|
||||
private const val MEDIA_KEYBOARD_FRAGMENT_CREATOR_ID = 2
|
||||
|
||||
private val RECEIVE_CONTENT_MIME_TYPES = arrayOf(
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
"image/avif"
|
||||
)
|
||||
}
|
||||
|
||||
private val args: ConversationArgs by lazy {
|
||||
@@ -456,6 +470,7 @@ class ConversationFragment :
|
||||
removeTextChangedListener(composeTextEventsListener)
|
||||
setStylingChangedListener(null)
|
||||
setOnClickListener(null)
|
||||
ViewCompat.setOnReceiveContentListener(this, null, null)
|
||||
}
|
||||
|
||||
dataObserver?.let {
|
||||
@@ -479,8 +494,7 @@ class ConversationFragment :
|
||||
repository = ConversationRepository(localContext = requireContext(), isInBubble = args.conversationScreenType == ConversationScreenType.BUBBLE),
|
||||
recipientRepository = conversationRecipientRepository,
|
||||
messageRequestRepository = messageRequestRepository,
|
||||
scheduledMessagesRepository = ScheduledMessagesRepository(),
|
||||
initialChatColors = args.chatColors
|
||||
scheduledMessagesRepository = ScheduledMessagesRepository()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -551,7 +565,6 @@ class ConversationFragment :
|
||||
private lateinit var markReadHelper: MarkReadHelper
|
||||
private lateinit var giphyMp4ProjectionRecycler: GiphyMp4ProjectionRecycler
|
||||
private lateinit var addToContactsLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var plaintextExportDirectoryLauncher: ActivityResultLauncher<Uri?>
|
||||
private lateinit var conversationActivityResultContracts: ConversationActivityResultContracts
|
||||
private lateinit var scrollToPositionDelegate: ScrollToPositionDelegate
|
||||
private lateinit var adapter: ConversationAdapterV2
|
||||
@@ -685,10 +698,10 @@ class ConversationFragment :
|
||||
incognito = args.isIncognito
|
||||
)
|
||||
conversationToolbarOnScrollHelper.attach(binding.conversationItemRecycler)
|
||||
presentWallpaper(args.wallpaper)
|
||||
presentChatColors(args.chatColors)
|
||||
presentConversationTitle(viewModel.recipientSnapshot)
|
||||
presentGroupConversationSubtitle(createGroupSubtitleString(viewModel.titleViewParticipantsSnapshot))
|
||||
if (viewModel.recipientSnapshot?.isGroup == true) {
|
||||
presentGroupConversationSubtitle(createGroupSubtitleString(viewModel.titleViewParticipantsSnapshot))
|
||||
}
|
||||
presentActionBarMenu()
|
||||
presentStoryRing()
|
||||
|
||||
@@ -721,7 +734,26 @@ class ConversationFragment :
|
||||
|
||||
SpoilerAnnotation.resetRevealedSpoilers()
|
||||
|
||||
inputPanel.setMediaListener(InputPanelMediaListener())
|
||||
val mediaListener = InputPanelMediaListener()
|
||||
ViewCompat.setOnReceiveContentListener(composeText, RECEIVE_CONTENT_MIME_TYPES) { _, payload ->
|
||||
val split = payload.partition { item -> item.uri != null }
|
||||
val uriContent = split.first
|
||||
|
||||
if (uriContent != null) {
|
||||
val clip = uriContent.clip
|
||||
val mimeType = if (clip.description.mimeTypeCount > 0) {
|
||||
clip.description.getMimeType(0)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val uri = clip.getItemAt(0).uri
|
||||
if (uri != null) {
|
||||
mediaListener.onMediaSelected(uri, mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
split.second
|
||||
}
|
||||
|
||||
binding.conversationItemRecycler.addOnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
|
||||
viewModel.onChatBoundsChanged(Rect(left, top, right, bottom))
|
||||
@@ -1324,10 +1356,11 @@ class ConversationFragment :
|
||||
lifecycleScope.launch {
|
||||
viewModel
|
||||
.pinnedMessages
|
||||
.combine(viewModel.wallpaper) { messages, wallpaper -> messages to wallpaper }
|
||||
.flowWithLifecycle(viewLifecycleOwner.lifecycle)
|
||||
.flowOn(Dispatchers.Main)
|
||||
.collect {
|
||||
presentPinnedMessage(it, args.wallpaper != null)
|
||||
.collect { (messages, wallpaper) ->
|
||||
presentPinnedMessage(pinnedMessages = messages, hasWallpaper = wallpaper != null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1601,13 +1634,6 @@ class ConversationFragment :
|
||||
|
||||
private fun registerForResults() {
|
||||
addToContactsLauncher = registerForActivityResult(AddToContactsContract()) {}
|
||||
plaintextExportDirectoryLauncher = registerForActivityResult(OpenDocumentTreeContract()) { uri ->
|
||||
if (uri != null) {
|
||||
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
|
||||
viewModel.startPlaintextExport(requireContext().applicationContext, uri)
|
||||
}
|
||||
}
|
||||
conversationActivityResultContracts = ConversationActivityResultContracts(this, ActivityResultCallbacks())
|
||||
}
|
||||
|
||||
@@ -1652,7 +1678,19 @@ class ConversationFragment :
|
||||
is ConversationViewModel.PlaintextExportState.Complete -> {
|
||||
progressDialog?.dismiss()
|
||||
progressDialog = null
|
||||
toast(R.string.conversation_export__export_complete, toastDuration = Toast.LENGTH_LONG)
|
||||
|
||||
val uri = FileProviderUtil.getUriFor(requireContext(), state.zipFile)
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "application/zip"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
val chooserIntent = Intent.createChooser(shareIntent, getString(R.string.conversation_export__export_complete))
|
||||
if (Build.VERSION.SDK_INT < 34) {
|
||||
chooserIntent.putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(ComponentName(requireContext(), ShareActivity::class.java)))
|
||||
}
|
||||
startActivity(chooserIntent)
|
||||
|
||||
viewModel.clearPlaintextExportState()
|
||||
}
|
||||
|
||||
@@ -1679,8 +1717,12 @@ class ConversationFragment :
|
||||
presentConversationTitle(recipient)
|
||||
presentChatColors(recipient.chatColors)
|
||||
invalidateOptionsMenu()
|
||||
|
||||
updateMessageRequestAcceptedState(!viewModel.hasMessageRequestState)
|
||||
|
||||
recyclerViewColorizer.setChatColors(recipient.chatColors)
|
||||
if (adapter.onHasWallpaperChanged(hasWallpaper = recipient.wallpaper != null)) {
|
||||
conversationItemDecorations.hasWallpaper = recipient.wallpaper != null
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
@@ -2147,7 +2189,7 @@ class ConversationFragment :
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
requestManager = Glide.with(this),
|
||||
clickListener = ConversationItemClickListener(),
|
||||
hasWallpaper = args.wallpaper != null,
|
||||
hasWallpaper = args.hasWallpaper,
|
||||
colorizer = colorizer,
|
||||
startExpirationTimeout = viewModel::startExpirationTimeout,
|
||||
chatColorsDataProvider = viewModel::chatColorsSnapshot,
|
||||
@@ -2164,7 +2206,7 @@ class ConversationFragment :
|
||||
adapter.setPagingController(viewModel.pagingController)
|
||||
|
||||
recyclerViewColorizer = RecyclerViewColorizer(binding.conversationItemRecycler)
|
||||
recyclerViewColorizer.setChatColors(args.chatColors)
|
||||
viewModel.recipientSnapshot?.chatColors?.let { recyclerViewColorizer.setChatColors(it) }
|
||||
|
||||
binding.conversationItemRecycler.adapter = ConcatAdapter(typingIndicatorAdapter, adapter)
|
||||
multiselectItemDecoration = MultiselectItemDecoration(
|
||||
@@ -2200,8 +2242,9 @@ class ConversationFragment :
|
||||
val statusBarInset = ViewCompat.getRootWindowInsets(binding.root)?.getInsets(WindowInsetsCompat.Type.systemBars())?.top ?: 0
|
||||
threadHeaderMarginDecoration.toolbarMargin = statusBarInset + resources.getDimensionPixelSize(R.dimen.signal_m3_toolbar_height) + 16.dp
|
||||
binding.conversationItemRecycler.addItemDecoration(threadHeaderMarginDecoration)
|
||||
binding.conversationItemRecycler.addItemDecoration(ConversationHeaderPositionDecoration())
|
||||
|
||||
conversationItemDecorations = ConversationItemDecorations(hasWallpaper = args.wallpaper != null)
|
||||
conversationItemDecorations = ConversationItemDecorations(hasWallpaper = args.hasWallpaper)
|
||||
binding.conversationItemRecycler.addItemDecoration(conversationItemDecorations, 0)
|
||||
}
|
||||
|
||||
@@ -2621,7 +2664,7 @@ class ConversationFragment :
|
||||
|
||||
if (menuState.shouldShowSaveAttachmentAction()) {
|
||||
items.add(
|
||||
ActionItem(R.drawable.symbol_save_android_24, resources.getString(R.string.conversation_selection__menu_save)) {
|
||||
ActionItem(CoreUiR.drawable.symbol_save_android_24, resources.getString(R.string.conversation_selection__menu_save)) {
|
||||
handleSaveAttachment(getSelectedConversationMessage().messageRecord as MmsMessageRecord)
|
||||
finishActionMode()
|
||||
}
|
||||
@@ -3703,7 +3746,13 @@ class ConversationFragment :
|
||||
"username_edit" -> startActivity(EditProfileActivity.getIntentForUsernameEdit(requireContext()))
|
||||
"calls_tab" -> startActivity(MainActivity.clearTopAndOpenTab(requireContext(), MainNavigationListLocation.CALLS))
|
||||
"chat_folder" -> startActivity(AppSettingsActivity.chatFolders(requireContext()))
|
||||
"remote_backups" -> startActivity(AppSettingsActivity.remoteBackups(requireContext()))
|
||||
"remote_backups" -> {
|
||||
if (SignalStore.backup.areBackupsEnabled) {
|
||||
startActivity(AppSettingsActivity.remoteBackups(requireContext()))
|
||||
} else {
|
||||
startActivity(AppSettingsActivity.backupsSettings(requireContext(), launchCheckoutFlow = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3949,6 +3998,10 @@ class ConversationFragment :
|
||||
selectedConversationModel,
|
||||
object : OnHideListener {
|
||||
override fun startHide(focusedView: View?) {
|
||||
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) || activity == null || activity?.isFinishing == true) {
|
||||
return
|
||||
}
|
||||
|
||||
multiselectItemDecoration.hideShade(binding.conversationItemRecycler)
|
||||
ViewUtil.fadeOut(binding.reactionsShade, resources.getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE)
|
||||
|
||||
@@ -4285,7 +4338,17 @@ class ConversationFragment :
|
||||
}
|
||||
|
||||
override fun handleExportChat() {
|
||||
plaintextExportDirectoryLauncher.launch(null)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.ChatExportDialogs__export_chat_history_title)
|
||||
.setMessage(R.string.ChatExportDialogs__export_confirm_body)
|
||||
.setPositiveButton(R.string.ChatExportDialogs__export_with_media) { _, _ ->
|
||||
viewModel.startPlaintextExport(requireContext().applicationContext, includeMedia = true)
|
||||
}
|
||||
.setNeutralButton(R.string.ChatExportDialogs__export_without_media) { _, _ ->
|
||||
viewModel.startPlaintextExport(requireContext().applicationContext, includeMedia = false)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4933,8 +4996,8 @@ class ConversationFragment :
|
||||
}
|
||||
}
|
||||
|
||||
private inner class InputPanelMediaListener : InputPanel.MediaListener {
|
||||
override fun onMediaSelected(uri: Uri, contentType: String?) {
|
||||
private inner class InputPanelMediaListener {
|
||||
fun onMediaSelected(uri: Uri, contentType: String?) {
|
||||
if (inputPanel.inEditMessageMode()) {
|
||||
Log.i(TAG, "Disregarding media because we are in edit mode")
|
||||
} else if (MediaUtil.isGif(contentType) || MediaUtil.isImageType(contentType)) {
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import androidx.core.view.children
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.conversation.ConversationHeaderView
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Adjusts the Conversation's recycler view translationY so that the conversation header
|
||||
* is pinned to the top of the visible area when content is too short to
|
||||
* fill the screen.
|
||||
*/
|
||||
class ConversationHeaderPositionDecoration : RecyclerView.ItemDecoration() {
|
||||
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
if (parent.childCount == 0 || parent.canScrollVertically(-1) || parent.canScrollVertically(1)) {
|
||||
parent.translationY = 0f
|
||||
} else {
|
||||
val threadHeaderView: ConversationHeaderView = parent.children
|
||||
.filterIsInstance<ConversationHeaderView>()
|
||||
.firstOrNull() ?: run {
|
||||
parent.translationY = 0f
|
||||
return
|
||||
}
|
||||
|
||||
// A decorator adds the margin for the toolbar, margin is the difference of the bounds "height" and the view height
|
||||
val bounds = Rect()
|
||||
parent.getDecoratedBoundsWithMargins(threadHeaderView, bounds)
|
||||
val toolbarMargin = bounds.bottom - bounds.top - threadHeaderView.height
|
||||
|
||||
val childTop: Int = threadHeaderView.top - toolbarMargin
|
||||
parent.translationY = min(0, -childTop).toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-13
@@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -58,7 +59,6 @@ import org.thoughtcrime.securesms.banner.banners.UnauthorizedBanner
|
||||
import org.thoughtcrime.securesms.contactshare.Contact
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage
|
||||
import org.thoughtcrime.securesms.conversation.ScheduledMessagesRepository
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
|
||||
import org.thoughtcrime.securesms.conversation.plaintext.PlaintextExportRepository
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey
|
||||
@@ -101,7 +101,9 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.hasGiftBadge
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
@@ -110,7 +112,6 @@ import kotlin.time.Duration
|
||||
class ConversationViewModel(
|
||||
val threadId: Long,
|
||||
requestedStartingPosition: Int,
|
||||
initialChatColors: ChatColors,
|
||||
private val repository: ConversationRepository,
|
||||
recipientRepository: ConversationRecipientRepository,
|
||||
messageRequestRepository: MessageRequestRepository,
|
||||
@@ -158,7 +159,7 @@ class ConversationViewModel(
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
private val chatBounds: BehaviorSubject<Rect> = BehaviorSubject.create()
|
||||
private val chatColors: RxStore<ChatColorsDrawable.ChatColorsData> = RxStore(ChatColorsDrawable.ChatColorsData(initialChatColors, null))
|
||||
private val chatColors: RxStore<ChatColorsDrawable.ChatColorsData> = RxStore(ChatColorsDrawable.ChatColorsData(null, null))
|
||||
val chatColorsSnapshot: ChatColorsDrawable.ChatColorsData get() = chatColors.state
|
||||
|
||||
@Volatile
|
||||
@@ -172,6 +173,8 @@ class ConversationViewModel(
|
||||
val isPushAvailable: Boolean
|
||||
get() = recipientSnapshot?.isRegistered == true && Recipient.self().isRegistered
|
||||
|
||||
val wallpaper: Flow<ChatWallpaper?> = recipient.asFlow().map { it.wallpaper }.distinctUntilChanged()
|
||||
|
||||
val wallpaperSnapshot: ChatWallpaper?
|
||||
get() = recipientSnapshot?.wallpaper
|
||||
|
||||
@@ -216,7 +219,7 @@ class ConversationViewModel(
|
||||
private val _plaintextExportState = MutableStateFlow<PlaintextExportState>(PlaintextExportState.None)
|
||||
val plaintextExportState: StateFlow<PlaintextExportState> = _plaintextExportState
|
||||
|
||||
private val plaintextExportCancelled = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
private val plaintextExportCancelled = AtomicBoolean(false)
|
||||
|
||||
init {
|
||||
disposables += recipient
|
||||
@@ -759,10 +762,17 @@ class ConversationViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun startPlaintextExport(context: Context, directoryUri: Uri) {
|
||||
fun startPlaintextExport(context: Context, includeMedia: Boolean) {
|
||||
val recipient = recipientSnapshot ?: return
|
||||
val chatName = if (recipient.isSelf) context.getString(R.string.note_to_self) else recipient.getDisplayName(context)
|
||||
|
||||
val exportDir = File(context.externalCacheDir, "chat_exports")
|
||||
exportDir.mkdirs()
|
||||
exportDir.listFiles()?.forEach { it.delete() }
|
||||
|
||||
val sanitizedName = PlaintextExportRepository.sanitizeFileName(chatName)
|
||||
val outputFile = File(exportDir, "$sanitizedName.zip")
|
||||
|
||||
plaintextExportCancelled.set(false)
|
||||
_plaintextExportState.value = PlaintextExportState.Preparing
|
||||
|
||||
@@ -770,14 +780,19 @@ class ConversationViewModel(
|
||||
val success = PlaintextExportRepository.export(
|
||||
context = context,
|
||||
threadId = threadId,
|
||||
directoryUri = directoryUri,
|
||||
outputFile = outputFile,
|
||||
chatName = chatName,
|
||||
includeMedia = includeMedia,
|
||||
progressListener = { messagesProcessed, messageCount, attachmentsProcessed, attachmentCount ->
|
||||
val messagePercent = if (messageCount > 0) (messagesProcessed * 25) / messageCount else 25
|
||||
val attachmentPercent = if (attachmentCount > 0) (attachmentsProcessed * 75) / attachmentCount else 75
|
||||
val percent = messagePercent + attachmentPercent
|
||||
val percent = if (includeMedia) {
|
||||
val messagePercent = if (messageCount > 0) (messagesProcessed * 25) / messageCount else 25
|
||||
val attachmentPercent = if (attachmentCount > 0) (attachmentsProcessed * 75) / attachmentCount else 75
|
||||
messagePercent + attachmentPercent
|
||||
} else {
|
||||
if (messageCount > 0) (messagesProcessed * 100) / messageCount else 100
|
||||
}
|
||||
|
||||
val status = if (attachmentsProcessed > 0 || messagesProcessed >= messageCount) {
|
||||
val status = if (includeMedia && (attachmentsProcessed > 0 || messagesProcessed >= messageCount)) {
|
||||
"Exporting media ($attachmentsProcessed/$attachmentCount)..."
|
||||
} else {
|
||||
"Exporting messages ($messagesProcessed/$messageCount)..."
|
||||
@@ -789,8 +804,11 @@ class ConversationViewModel(
|
||||
)
|
||||
|
||||
_plaintextExportState.value = when {
|
||||
plaintextExportCancelled.get() -> PlaintextExportState.Cancelled
|
||||
success -> PlaintextExportState.Complete
|
||||
plaintextExportCancelled.get() -> {
|
||||
outputFile.delete()
|
||||
PlaintextExportState.Cancelled
|
||||
}
|
||||
success -> PlaintextExportState.Complete(outputFile)
|
||||
else -> PlaintextExportState.Failed
|
||||
}
|
||||
}
|
||||
@@ -814,7 +832,7 @@ class ConversationViewModel(
|
||||
data object None : PlaintextExportState
|
||||
data object Preparing : PlaintextExportState
|
||||
data class InProgress(val percent: Int, val status: String) : PlaintextExportState
|
||||
data object Complete : PlaintextExportState
|
||||
data class Complete(val zipFile: File) : PlaintextExportState
|
||||
data object Failed : PlaintextExportState
|
||||
data object Cancelled : PlaintextExportState
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user