Compare commits

..

95 Commits

Author SHA1 Message Date
jeffrey-signal e5e99d4e03 Bump version to 8.8.2 2026-04-17 15:21:14 -04:00
jeffrey-signal 26d1a7ada7 Update baseline profile. 2026-04-17 15:00:00 -04:00
jeffrey-signal 5dd11e26e4 Update translations and other static files. 2026-04-17 14:53:54 -04:00
Alex Hart 9877b13c6e Add ability to launch into message backups checkout.
Co-authored-by: Cody Henthorne <cody@signal.org>
2026-04-17 12:33:52 -03:00
Greyson Parrelli d7d0fd3622 Rotate backup megaphone flag. 2026-04-17 10:09:12 -04:00
Sten Tijhuis 2439506c05 Update GitHub Actions versions and pin to commit SHAs.
Closes signalapp/Signal-Android#14715
2026-04-16 19:07:34 -04:00
jeffrey-signal 6088024f76 Revert "Use existing okhttp client + package checks for web apk."
This reverts commit df406633ff.
2026-04-16 19:01:09 -04:00
jeffrey-signal 9decd81cfc Bump version to 8.8.1 2026-04-16 14:25:08 -04:00
jeffrey-signal f27773a4e3 Update baseline profile. 2026-04-16 13:27:52 -04:00
jeffrey-signal 8d8c974a19 Update translations and other static files. 2026-04-16 13:20:10 -04:00
Cody Henthorne 1a3e81dcb0 Fix bad apostrophe escaping in new safety tip strings. 2026-04-16 13:08:59 -04:00
jeffrey-signal d5f85c0661 Bump version to 8.8.0 2026-04-15 20:18:12 -04:00
jeffrey-signal 91458f2702 Update baseline profile. 2026-04-15 20:18:12 -04:00
jeffrey-signal 6650ffc2c6 Update translations and other static files. 2026-04-15 20:17:59 -04:00
Cody Henthorne ab0102a372 Do not force-apply P2P group changes unless the change adds or removes us.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-15 14:45:14 -04:00
Cody Henthorne a797bbf850 Improve web socket behaviors around keep alive and shutdown. 2026-04-15 14:45:14 -04:00
Cody Henthorne 3804890265 Stop putting e164s into SignalProtocolAddress. 2026-04-15 14:45:14 -04:00
Greyson Parrelli fcdbf93626 Improve regV5 restore flows. 2026-04-15 14:45:14 -04:00
Michelle Tang f1b61f8f7e Add test dispatcher to phone number tests. 2026-04-15 14:45:14 -04:00
Michelle Tang ce582249ec Ask for permissions on the same screen. 2026-04-15 14:45:13 -04:00
Alex Hart b21a72153a Implement proper text-entry component for large screen media send flow. 2026-04-15 14:45:13 -04:00
jeffrey-signal 2a8bd20bb0 Fix sender name/label clipping in recycled conversation items.
Resolves signalapp/Signal-Android#14646
2026-04-15 14:45:13 -04:00
jeffrey-signal c30e3cc1b7 Disable group member labels while in message request state. 2026-04-15 14:45:13 -04:00
Greyson Parrelli 5fedd81921 Convert IndividualSendJob to kotlin. 2026-04-15 14:45:12 -04:00
jeffrey-signal 24069dc42e Fix self avatar in reaction bottom sheet. 2026-04-15 14:45:12 -04:00
Cody Henthorne ff15c8417a Clear upload spec when resume location is invalid in archive upload.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-15 14:45:12 -04:00
Greyson Parrelli cbf770d3ea Convert PushSendJob to kotlin. 2026-04-15 14:45:12 -04:00
Greyson Parrelli 676ab1ab6f Merge SendJob into PushSendJob. 2026-04-15 14:45:12 -04:00
Greyson Parrelli 9cc47942f2 Add process check to VoiceNotePlaybackService MediaSession access. 2026-04-15 14:45:12 -04:00
Greyson Parrelli 45e6e06c01 Improve error handling for empty prekey bundles. 2026-04-15 14:45:11 -04:00
Michelle Tang d2243707b5 Update permissions UI. 2026-04-15 14:45:11 -04:00
Jesse Weinstein 48cd1c1da0 Convert Windows line endings to Unix format.
Closes signalapp/Signal-Android#14632
2026-04-15 14:45:11 -04:00
thomasboom 330a5aece2 Show "None" for media auto-download when all options are disabled.
Co-authored-by: jeffrey-signal <jeffrey@signal.org>

Closes signalapp/Signal-Android#14678
2026-04-15 14:45:11 -04:00
Cody Henthorne 8c4f614d17 Allow reply action on notifications for messages with media attachments. 2026-04-15 14:45:11 -04:00
Leo Heitmann Ruiz f40bcb73fa Fix duplicate word in bug report template.
Closes signalapp/Signal-Android#14695
2026-04-15 14:45:10 -04:00
Jesse Weinstein 905a6f1a6b Fix typo in comment referencing ChatTypeSearchKey.
Closes signalapp/Signal-Android#14684
2026-04-15 14:45:10 -04:00
Jesse Weinstein 8f78471849 Remove the annimon.stream library.
Resolves #14719
2026-04-15 14:45:10 -04:00
Jesse Weinstein 82df20190d Remove all remaining usages of annimon.stream. 2026-04-15 14:45:10 -04:00
Greyson Parrelli 7f6e96a522 Check DownloadManager status to properly detect errors. 2026-04-15 14:45:09 -04:00
Greyson Parrelli eded335766 Fix potential crash when saving to fallback attachment folder.
Fixes #14720
2026-04-15 14:45:09 -04:00
Michelle Tang 7e4736969c Update country selection. 2026-04-15 14:45:09 -04:00
Greyson Parrelli 78940ffc17 Switch the labs plaintext export to share a single zip. 2026-04-15 14:45:09 -04:00
Jesse Weinstein 086883e565 Convert all the toList calls to collect(Collectors.toList)
Resolves #14718
2026-04-15 14:45:06 -04:00
Michelle Tang e9cdf0368e Update phone number UI. 2026-04-15 14:43:45 -04:00
Greyson Parrelli 7be273f461 Do not interrupt actively playing voice notes when locking. 2026-04-15 14:43:45 -04:00
Jesse Weinstein e6cbb0073c Remove more usages of annimon.stream.
Resolves #14717
2026-04-15 14:43:41 -04:00
Cody Henthorne 469421fcf3 Fix message request accepted to recipient from previous backup imports. 2026-04-15 14:43:12 -04:00
Greyson Parrelli 6d6d277277 Add share and forward entries to all media context menu. 2026-04-15 14:43:12 -04:00
jeffrey-signal 8a5faba985 Rename DragAndDrop -> DragToReorder to differentiate it from Android's drag-and-drop framework. 2026-04-15 14:43:12 -04:00
Cody Henthorne 7aadc208e1 Promptly remove terminated groups from shortcuts. 2026-04-15 14:43:11 -04:00
Greyson Parrelli 3c68e29679 Update image pasting to use ViewCompat.setOnReceiveContentListener. 2026-04-15 14:43:11 -04:00
Cody Henthorne 4756b8d70b Update conversation header and message request UI. 2026-04-15 14:43:11 -04:00
Alex Hart c2d927029a Add new ImageEditor compose component and wire in crop and drawing tools. 2026-04-13 16:25:00 -04:00
jeffrey-signal 629b96dd20 Fix wallpaper ANR regression while maintaining correct incoming message bubble colors. 2026-04-13 16:25:00 -04:00
Greyson Parrelli 01705459cf Include registered state in support email. 2026-04-13 16:25:00 -04:00
Greyson Parrelli c449f72786 Allow internal shares as long as they originate from our process. 2026-04-13 16:25:00 -04:00
Alex Hart 773d6c36dc Add large-screen media send toolbars for image editing. 2026-04-13 16:25:00 -04:00
andrew-signal b4bfb67a44 Bump to libsignal v0.91.2 2026-04-13 16:25:00 -04:00
Michelle Tang 3165c854df Remove unused strings. 2026-04-13 16:25:00 -04:00
Cody Henthorne f5cb1b0efa Restrict telecom usage to API 37. 2026-04-13 16:25:00 -04:00
Greyson Parrelli 179908fba6 Update registration error strings for SMS send failures. 2026-04-13 16:25:00 -04:00
Greyson Parrelli d6ec4bfbd3 Do not show getting started after local restore. 2026-04-13 16:25:00 -04:00
Greyson Parrelli 237ac9f94a Use original message timestamp for saved attachment MediaStore metadata.
Fixes #14584
2026-04-13 16:25:00 -04:00
Greyson Parrelli 66f69854cf Expand whitespace character detection in StringUtil.isVisuallyEmpty.
Fixes #14470
2026-04-13 16:25:00 -04:00
Greyson Parrelli 8f47592fc0 Fix reconciliation error for thumbnails for quotes. 2026-04-13 16:25:00 -04:00
Greyson Parrelli 3ea7bf77e0 Add release note validation check. 2026-04-13 16:25:00 -04:00
Greyson Parrelli 2b67b1c44f Remove legacy ClassicOpenHelper. 2026-04-13 16:25:00 -04:00
Jesse Weinstein ebccc6db30 Remove a lot of dead code.
Resolves #14672
2026-04-13 16:25:00 -04:00
Greyson Parrelli 98d9b12438 Add null check in UriUtil. 2026-04-13 16:22:10 -04:00
Greyson Parrelli 5db8463c70 Improve content proxy domain matching. 2026-04-13 16:22:10 -04:00
Greyson Parrelli 813252989b Notify CallRequestController after cancel. 2026-04-13 16:22:09 -04:00
Greyson Parrelli 0319adbce4 Add null check to orientation unboxing. 2026-04-13 16:22:09 -04:00
Greyson Parrelli de584ccb7d Use message digest for faster comparison. 2026-04-13 16:22:09 -04:00
Greyson Parrelli bd89c7fc39 Add null check to pin message import. 2026-04-13 16:22:09 -04:00
Greyson Parrelli bef4bb40ca Do not swallow IOException during backup creation. 2026-04-13 16:22:08 -04:00
Jesse Weinstein b57d922cdf Remove use of annimon.stream in several places.
Resolves #14705
2026-04-13 16:22:08 -04:00
Greyson Parrelli 8c1cc03c6f Use non-deprecated libsignal network constructor. 2026-04-13 16:22:08 -04:00
jeffrey-signal f0109f3e6b Improve drag-to-reorder auto scroll behavior when dragging an item up the list. 2026-04-13 16:22:08 -04:00
Greyson Parrelli ed89f3a78e Don't connect to the websocket if we know we're unregistered. 2026-04-13 16:22:07 -04:00
Alex Hart faa6a1d3f0 Welcome screen polish. 2026-04-13 16:22:07 -04:00
Greyson Parrelli 969635d942 Add redirect validation for link previews. 2026-04-13 16:22:07 -04:00
Greyson Parrelli 7665ae1464 Verify payment address identity key against local identity store. 2026-04-13 16:22:07 -04:00
Greyson Parrelli 9c18e3698e Apply CDN restrictions to quote attachments. 2026-04-13 16:22:07 -04:00
Greyson Parrelli df406633ff Use existing okhttp client + package checks for web apk. 2026-04-13 16:22:06 -04:00
Greyson Parrelli d121f9402b Improve quote validation. 2026-04-13 16:22:06 -04:00
Greyson Parrelli 5310c19b99 Update isValidExternalUri. 2026-04-13 16:22:06 -04:00
Greyson Parrelli cd92feb2b7 Write profile avatars to temp file before renaming to final location. 2026-04-13 16:22:06 -04:00
Greyson Parrelli 3b603f08ed Add defensive size check to stream read. 2026-04-13 16:22:06 -04:00
Greyson Parrelli 281f062b29 Remove the DeprecatedPersistentBlobProvider. 2026-04-13 16:22:05 -04:00
jeffrey-signal b054a7eb76 Bump version to 8.7.3 2026-04-13 16:15:41 -04:00
jeffrey-signal 33b9c88ecd Update baseline profile. 2026-04-13 15:40:05 -04:00
jeffrey-signal 253d36ae13 Update translations and other static files. 2026-04-13 15:32:54 -04:00
Michelle Tang 8306f8ec5b Improve collapsed set selection. 2026-04-13 14:05:20 -04:00
Michelle Tang 69b6d7ef9a Fix missing gallery photos.
Resolves signalapp/Signal-Android#14709
2026-04-13 14:05:00 -04:00
Greyson Parrelli aeeba3d2df Fix NPE when there's no retryafter duration. 2026-04-12 14:20:32 -04:00
704 changed files with 25485 additions and 23026 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ body:
label: "Guidelines"
description: "Search issues here: https://github.com/signalapp/Signal-Android/issues/?q=is%3Aissue+"
options:
- label: I have searched searched open and closed issues for duplicates
- label: I have searched open and closed issues for duplicates
required: true
- label: I am submitting a bug report for existing functionality that does not work as intended
required: true
+27
View File
@@ -0,0 +1,27 @@
version: 2
updates:
# Automatically keep GitHub Actions SHA-pinned to the latest commit SHAs.
# Dependabot will update both the SHA and the inline version comment (e.g. # v6)
# while leaving any extra documentation comments intact.
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
labels:
- "dependencies"
commit-message:
prefix: "ci"
groups:
actions:
patterns:
- "actions/*"
gradle-actions:
patterns:
- "gradle/*"
peter-evans:
patterns:
- "peter-evans/*"
usefulness:
patterns:
- "usefulness/*"
+8 -4
View File
@@ -16,26 +16,30 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
- name: set up JDK 17
uses: actions/setup-java@v4
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 17
cache: gradle
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@v5
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
- name: Build with Gradle
run: ./gradlew qa
- name: Archive reports for failed build
if: ${{ failure() }}
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
# gh api repos/actions/upload-artifact/commits/v7 --jq '.sha'
with:
name: reports
path: '*/build/reports'
+19 -10
View File
@@ -14,15 +14,17 @@ jobs:
assemble-base:
if: ${{ github.repository != 'signalapp/Signal-Android' }}
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
ref: ${{ github.event.pull_request.base.sha }}
- name: set up JDK 17
uses: actions/setup-java@v3
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
with:
distribution: temurin
java-version: 17
@@ -32,11 +34,13 @@ jobs:
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;${{ env.NDK_VERSION }}"
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@v5
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
- name: Cache base apk
id: cache-base
uses: actions/cache@v4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
# gh api repos/actions/cache/commits/v5 --jq '.sha'
with:
path: diffuse-base.apk
key: diffuse-${{ github.event.pull_request.base.sha }}
@@ -49,7 +53,8 @@ jobs:
if: steps.cache-base.outputs.cache-hit != 'true'
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-base.apk
- uses: actions/checkout@v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
clean: 'false'
@@ -61,18 +66,21 @@ jobs:
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-new.apk
- id: diffuse
uses: usefulness/diffuse-action@v1
uses: usefulness/diffuse-action@41995fe8ff6be0a8847e63bdc5a4679c704b455c # v1
# gh api repos/usefulness/diffuse-action/commits/v1 --jq '.sha'
with:
old-file-path: diffuse-base.apk
new-file-path: diffuse-new.apk
- uses: peter-evans/find-comment@v2
- uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4
# gh api repos/peter-evans/find-comment/commits/v4 --jq '.sha'
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
body-includes: Diffuse output
- uses: peter-evans/create-or-update-comment@v3
- uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5
# gh api repos/peter-evans/create-or-update-comment/commits/v5 --jq '.sha'
with:
body: |
Diffuse output:
@@ -83,7 +91,8 @@ jobs:
issue-number: ${{ github.event.pull_request.number }}
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
# gh api repos/actions/upload-artifact/commits/v7 --jq '.sha'
with:
name: diffuse-output
path: ${{ steps.diffuse.outputs.diff-file }}
+2 -1
View File
@@ -11,7 +11,8 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
- name: Build image
run: |
cd reproducible-builds
+2 -1
View File
@@ -14,7 +14,8 @@ jobs:
actions: write
steps:
- uses: actions/stale@v10
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
# gh api repos/actions/stale/commits/v10 --jq '.sha'
with:
days-before-stale: 60
days-before-close: 7
+1 -1
View File
@@ -1,6 +1,6 @@
# Signal Android
Signal is a simple, powerful, and secure messenger that uses your phone's data connection (WiFi/3G/4G/5G) to communicate securely.
Signal is a simple, powerful, and secure messenger that uses your phone's data connection (WiFi/4G/5G) to communicate securely.
Millions of people use Signal every day for free and instantaneous communication anywhere in the world. Send and receive high-fidelity messages, participate in HD voice/video calls, and explore a growing set of new features that help you stay connected.
+2 -3
View File
@@ -24,8 +24,8 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1678
val canonicalVersionName = "8.7.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)
-55
View File
@@ -26454,61 +26454,6 @@
column="7"/>
</issue>
<issue
id="Recycle"
message="This `Cursor` should be freed up after use with `#close()`"
errorLine1=" Cursor mmsCursor = db.query(&quot;mms&quot;, new String[] {&quot;_id&quot;},"
errorLine2=" ~~~~~">
<location
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
line="298"
column="38"/>
</issue>
<issue
id="Recycle"
message="This `Cursor` should be freed up after use with `#close()`"
errorLine1=" Cursor partCursor = db.query(&quot;part&quot;, new String[] {&quot;_id&quot;, &quot;ct&quot;, &quot;_data&quot;, &quot;encrypted&quot;},"
errorLine2=" ~~~~~">
<location
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
line="310"
column="32"/>
</issue>
<issue
id="Recycle"
message="This `Cursor` should be freed up after use with `#close()`"
errorLine1=" Cursor threadCursor = db.query(&quot;thread&quot;, new String[] {&quot;_id&quot;}, null, null, null, null, null);"
errorLine2=" ~~~~~">
<location
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
line="708"
column="32"/>
</issue>
<issue
id="Recycle"
message="This `Cursor` should be freed up after use with `#close()`"
errorLine1=" Cursor cursor = db.rawQuery(&quot;SELECT DISTINCT date AS date_received, status, &quot; +"
errorLine2=" ~~~~~~~~">
<location
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
line="713"
column="28"/>
</issue>
<issue
id="Recycle"
message="This `Cursor` should be freed up after use with `#close()`"
errorLine1=" cursor = db.query(&quot;mms&quot;, new String[] {&quot;_id&quot;, &quot;network_failures&quot;}, &quot;network_failures IS NOT NULL&quot;, null, null, null, null);"
errorLine2=" ~~~~~">
<location
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
line="1037"
column="19"/>
</issue>
<issue
id="ObsoleteSdkInt"
message="Unnecessary; SDK_INT is always >= 21"
@@ -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())))
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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;
@@ -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"
}
@@ -241,6 +241,10 @@ class ChatItemArchiveExporter(
}
MessageTypes.isReleaseChannelDonationRequest(record.type) -> {
if (exportState.threadIdToRecipientId[builder.chatId] != exportState.releaseNoteRecipientId) {
Log.w(TAG, ExportSkips.donationRequestNotInReleaseNotesChat(builder.dateSent))
continue
}
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.RELEASE_CHANNEL_DONATION_REQUEST)
transformTimer.emit("simple-update")
}
@@ -362,12 +362,13 @@ class ChatItemArchiveImporter(
} else if (pinMessage != null) {
followUps += { pinUpdateMessageId ->
val targetAuthorId = importState.remoteToLocalRecipientId[pinMessage.authorId]
if (targetAuthorId != null) {
val targetAuthorAci = targetAuthorId?.let { recipients.getRecord(it).aci }
if (targetAuthorId != null && targetAuthorAci != null) {
val pinnedMessageId = SignalDatabase.messages.getMessageFor(pinMessage.targetSentTimestamp, targetAuthorId)?.id ?: -1
val messageExtras = MessageExtras(
pinnedMessage = PinnedMessage(
pinnedMessageId = pinnedMessageId,
targetAuthorAci = recipients.getRecord(targetAuthorId).aci!!.toByteString(),
targetAuthorAci = targetAuthorAci.toByteString(),
targetTimestamp = pinMessage.targetSentTimestamp
)
)
@@ -383,6 +384,8 @@ class ChatItemArchiveImporter(
.where("${MessageTable.ID} = ?", pinnedMessageId)
.run()
}
} else {
Log.w(TAG, "Pin message target author not found or has no ACI, skipping pin message extras.")
}
}
}
@@ -715,7 +718,7 @@ class ChatItemArchiveImporter(
when {
itemStandardMessage != null -> contentValues.addStandardMessage(itemStandardMessage)
itemRemoteDeletedMessage != null -> contentValues.put(MessageTable.DELETED_BY, fromRecipientId.toLong())
itemUpdateMessage != null -> contentValues.addUpdateMessage(itemUpdateMessage, fromRecipientId, toRecipientId)
itemUpdateMessage != null -> contentValues.addUpdateMessage(itemUpdateMessage, fromRecipientId, toRecipientId, chatRecipientId)
itemPaymentNotification != null -> contentValues.addPaymentNotification(this, chatRecipientId)
itemGiftBadge != null -> contentValues.addGiftBadge(itemGiftBadge)
itemViewOnceMessage != null -> contentValues.addViewOnce(itemViewOnceMessage)
@@ -863,7 +866,7 @@ class ChatItemArchiveImporter(
}
}
private fun ContentValues.addUpdateMessage(updateMessage: ChatUpdateMessage, fromRecipientId: RecipientId, toRecipientId: RecipientId) {
private fun ContentValues.addUpdateMessage(updateMessage: ChatUpdateMessage, fromRecipientId: RecipientId, toRecipientId: RecipientId, chatRecipientId: RecipientId) {
var typeFlags: Long = 0
val simpleUpdate = updateMessage.simpleUpdate
val expirationTimerChange = updateMessage.expirationTimerChange
@@ -904,6 +907,11 @@ class ChatItemArchiveImporter(
put(MessageTable.FROM_RECIPIENT_ID, toRecipientId.serialize())
put(MessageTable.TO_RECIPIENT_ID, fromRecipientId.serialize())
}
// directionless 1:1 message requests expect to recipient to be the other recipient not self
if (simpleUpdate.type == SimpleChatUpdate.Type.MESSAGE_REQUEST_ACCEPTED) {
put(MessageTable.TO_RECIPIENT_ID, chatRecipientId.serialize())
}
}
expirationTimerChange != null -> {
typeFlags = getAsLong(MessageTable.TYPE) or MessageTypes.EXPIRATION_TIMER_UPDATE_BIT
@@ -205,7 +205,7 @@ private fun FeatureBullet(text: String) {
modifier = Modifier.padding(vertical = 2.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_check_24),
imageVector = ImageVector.vectorResource(id = CoreUiR.drawable.symbol_check_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
@@ -24,6 +24,7 @@ import kotlinx.coroutines.withContext
import org.signal.core.util.billing.BillingPurchaseResult
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.signal.core.util.next
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
@@ -45,7 +46,6 @@ import org.thoughtcrime.securesms.jobs.InAppPaymentPurchaseTokenJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.next
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import kotlin.time.Duration.Companion.seconds
@@ -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);
}
@@ -9,7 +9,7 @@ import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.type.CollectionType;
import com.fasterxml.jackson.databind.type.TypeFactory;
@@ -72,7 +72,7 @@ public class RecentEmojiPageModel implements EmojiPageModel {
}
@Override public List<Emoji> getDisplayEmoji() {
return Stream.of(getEmoji()).map(Emoji::new).toList();
return getEmoji().stream().map(Emoji::new).collect(Collectors.toList());
}
@Override public @Nullable Uri getSpriteUri() {
@@ -8,13 +8,14 @@ import android.text.Spanned;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.Collections;
import java.util.List;
import java.util.stream.Stream;
/**
* This wraps an Android standard {@link Annotation} so it can leverage the built in
@@ -51,13 +52,12 @@ public final class MentionAnnotation {
public static @NonNull List<Mention> getMentionsFromAnnotations(@Nullable CharSequence text) {
if (text instanceof Spanned) {
Spanned spanned = (Spanned) text;
return Stream.of(getMentionAnnotations(spanned))
.map(annotation -> {
return getMentionAnnotations(spanned).stream()
.map(annotation -> {
int spanStart = spanned.getSpanStart(annotation);
int spanLength = spanned.getSpanEnd(annotation) - spanStart;
return new Mention(RecipientId.from(annotation.getValue()), spanStart, spanLength);
})
.toList();
}).collect(Collectors.toList());
}
return Collections.emptyList();
}
@@ -68,7 +68,6 @@ public final class MentionAnnotation {
public static @NonNull List<Annotation> getMentionAnnotations(@NonNull Spanned spanned, int start, int end) {
return Stream.of(spanned.getSpans(start, end, Annotation.class))
.filter(MentionAnnotation::isMentionAnnotation)
.toList();
.filter(MentionAnnotation::isMentionAnnotation).collect(Collectors.toList());
}
}
@@ -83,7 +83,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
)
AppSettingsRoute.ChatsRoute.Chats -> AppSettingsFragmentDirections.actionDirectToChatsSettingsFragment()
AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment()
is AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment().setLaunchCheckoutFlow(appSettingsRoute.launchCheckoutFlow)
AppSettingsRoute.Invite -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
AppSettingsRoute.DataAndStorageRoute.DataAndStorage -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
else -> error("Unsupported start location: ${appSettingsRoute?.javaClass?.name}")
@@ -233,7 +233,8 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
}
@JvmStatic
fun backupsSettings(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Backups)
@JvmOverloads
fun backupsSettings(context: Context, launchCheckoutFlow: Boolean = false): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Backups(launchCheckoutFlow = launchCheckoutFlow))
@JvmStatic
fun invite(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.Invite)
@@ -417,7 +417,7 @@ private fun AppSettingsContent(
icon = SignalIcons.Backup.imageVector,
text = stringResource(R.string.preferences_chats__backups),
onClick = {
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups)
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups())
},
onLongClick = {
callbacks.copyRemoteBackupsSubscriberIdToClipboard()
@@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.coroutines.delay
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeFragment
@@ -62,6 +63,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.math.BigDecimal
import java.util.Currency
import java.util.Locale
import kotlin.getValue
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.minutes
@@ -76,11 +78,16 @@ class BackupsSettingsFragment : ComposeFragment() {
private lateinit var checkoutLauncher: ActivityResultLauncher<MessageBackupTier?>
private val viewModel: BackupsSettingsViewModel by viewModels()
private val args: BackupsSettingsFragmentArgs by navArgs()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
checkoutLauncher = createBackupsCheckoutLauncher {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_remoteBackupsSettingsFragment)
}
if (savedInstanceState == null && args.launchCheckoutFlow) {
checkoutLauncher.launch(null)
}
}
@Composable
@@ -49,7 +49,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -52,10 +52,10 @@ import org.signal.core.ui.compose.DropdownMenus
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.copied.androidx.compose.DragAndDropEvent
import org.signal.core.ui.compose.copied.androidx.compose.DraggableItem
import org.signal.core.ui.compose.copied.androidx.compose.dragContainer
import org.signal.core.ui.compose.copied.androidx.compose.rememberDragDropState
import org.signal.core.ui.compose.list.ReorderListEvent
import org.signal.core.ui.compose.list.ReorderableItem
import org.signal.core.ui.compose.list.rememberReorderableListState
import org.signal.core.ui.compose.list.reorderableList
import org.signal.core.util.toInt
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -101,11 +101,11 @@ class ChatFoldersFragment : ComposeFragment() {
onDeleteDismissed = {
viewModel.showDeleteDialog(false)
},
onDragAndDropEvent = { event ->
onReorderListEvent = { event ->
when (event) {
is DragAndDropEvent.OnItemMove -> viewModel.updateItemPosition(event.fromIndex, event.toIndex)
is DragAndDropEvent.OnItemDrop -> viewModel.saveItemPositions()
is DragAndDropEvent.OnDragCancel -> {}
is ReorderListEvent.ItemMoved -> viewModel.updateItemPosition(event.fromIndex, event.toIndex)
is ReorderListEvent.ItemDropped -> viewModel.saveItemPositions()
is ReorderListEvent.DragCanceled -> {}
}
}
)
@@ -123,10 +123,10 @@ fun FoldersScreen(
onDeleteClicked: (ChatFolderRecord) -> Unit = {},
onDeleteConfirmed: () -> Unit = {},
onDeleteDismissed: () -> Unit = {},
onDragAndDropEvent: (DragAndDropEvent) -> Unit = {}
onReorderListEvent: (ReorderListEvent) -> Unit = {}
) {
val listState = rememberLazyListState()
val dragDropState = rememberDragDropState(listState, includeHeader = true, includeFooter = true, onEvent = onDragAndDropEvent)
val reorderableListState = rememberReorderableListState(listState, includeHeader = true, includeFooter = true, onEvent = onReorderListEvent)
LaunchedEffect(Unit) {
if (!SignalStore.uiHints.hasSeenChatFoldersEducationSheet) {
@@ -147,14 +147,14 @@ fun FoldersScreen(
}
LazyColumn(
modifier = Modifier.dragContainer(
dragDropState = dragDropState,
modifier = Modifier.reorderableList(
reorderableListState = reorderableListState,
dragHandleWidth = 56.dp
),
state = listState
) {
item {
DraggableItem(dragDropState, 0) {
ReorderableItem(reorderableListState, 0) {
Text(
text = stringResource(id = R.string.ChatFoldersFragment__organize_your_chats),
style = MaterialTheme.typography.bodyMedium,
@@ -175,7 +175,7 @@ fun FoldersScreen(
}
itemsIndexed(state.folders) { index, folder ->
DraggableItem(dragDropState, 1 + index) { isDragging ->
ReorderableItem(reorderableListState, 1 + index) { isDragging ->
val elevation = if (isDragging) 1.dp else 0.dp
val isAllChats = folder.folderType == ChatFolderRecord.FolderType.ALL
FolderRow(
@@ -193,7 +193,7 @@ fun FoldersScreen(
}
item {
DraggableItem(dragDropState, 1 + state.folders.size) {
ReorderableItem(reorderableListState, 1 + state.folders.size) {
if (state.suggestedFolders.isNotEmpty()) {
Dividers.Default()
@@ -142,6 +142,7 @@ private fun DataAndStorageSettingsScreen(
labels = stringArrayResource(R.array.pref_media_download_entries),
values = stringArrayResource(R.array.pref_media_download_values),
selection = state.mobileAutoDownloadValues.toTypedArray(),
noSelectionLabel = stringResource(R.string.preferences__none),
onSelectionChanged = callbacks::onMobileDataAutoDownloadSelectionChanged
)
}
@@ -152,6 +153,7 @@ private fun DataAndStorageSettingsScreen(
labels = stringArrayResource(R.array.pref_media_download_entries),
values = stringArrayResource(R.array.pref_media_download_values),
selection = state.wifiAutoDownloadValues.toTypedArray(),
noSelectionLabel = stringResource(R.string.preferences__none),
onSelectionChanged = callbacks::onWifiDataAutoDownloadSelectionChanged
)
}
@@ -162,6 +164,7 @@ private fun DataAndStorageSettingsScreen(
labels = stringArrayResource(R.array.pref_media_download_entries),
values = stringArrayResource(R.array.pref_media_download_values),
selection = state.roamingAutoDownloadValues.toTypedArray(),
noSelectionLabel = stringResource(R.string.preferences__none),
onSelectionChanged = callbacks::onRoamingDataAutoDownloadSelectionChanged
)
}
@@ -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
}
@@ -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> {
@@ -1,16 +1,15 @@
package org.thoughtcrime.securesms.components.voice;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Process;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -23,14 +22,8 @@ import androidx.media3.common.PlaybackException;
import androidx.media3.common.PlaybackParameters;
import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaController;
import androidx.media3.session.MediaSession;
import androidx.media3.session.MediaSessionService;
import androidx.media3.session.SessionToken;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
@@ -45,8 +38,6 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob;
import org.thoughtcrime.securesms.mms.PartUriParser;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.service.KeyCachingService;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
@@ -66,7 +57,6 @@ public class VoiceNotePlaybackService extends MediaSessionService {
private MediaSession mediaSession;
private VoiceNotePlayer player;
private KeyClearedReceiver keyClearedReceiver;
private VoiceNotePlayerCallback voiceNotePlayerCallback;
private final DatabaseObserver.Observer attachmentDeletionObserver = this::onAttachmentDeleted;
@@ -88,8 +78,6 @@ public class VoiceNotePlaybackService extends MediaSessionService {
mediaSession = session;
}
keyClearedReceiver = new KeyClearedReceiver(this, session.getToken());
setMediaNotificationProvider(new VoiceNoteMediaNotificationProvider(this));
setListener(new MediaSessionServiceListener());
AppDependencies.getDatabaseObserver().registerAttachmentDeletedObserver(attachmentDeletionObserver);
@@ -121,11 +109,6 @@ public class VoiceNotePlaybackService extends MediaSessionService {
mediaSession = null;
}
KeyClearedReceiver receiver = keyClearedReceiver;
if (receiver != null) {
receiver.unregister();
}
clearListener();
super.onDestroy();
}
@@ -133,6 +116,10 @@ public class VoiceNotePlaybackService extends MediaSessionService {
@Nullable
@Override
public MediaSession onGetSession(@NonNull MediaSession.ControllerInfo controllerInfo) {
if (Build.VERSION.SDK_INT >= 28 && controllerInfo.getUid() != Process.myUid()) {
Log.w(TAG, "Denying session to external caller: " + controllerInfo.getPackageName());
return null;
}
return mediaSession;
}
@@ -375,71 +362,6 @@ public class VoiceNotePlaybackService extends MediaSessionService {
}
}
/**
* Receiver to stop playback and kill the notification if user locks signal via screen lock.
* This registers itself as a receiver on the [Context] as soon as it can.
*/
private static class KeyClearedReceiver extends BroadcastReceiver {
private static final String TAG = Log.tag(KeyClearedReceiver.class);
private static final IntentFilter KEY_CLEARED_FILTER = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT);
private final Context context;
private final ListenableFuture<MediaController> controllerFuture;
private MediaController controller;
private boolean registered;
private KeyClearedReceiver(@NonNull Context context, @NonNull SessionToken token) {
this.context = context;
Log.d(TAG, "Creating media controller…");
controllerFuture = new MediaController.Builder(context, token).buildAsync();
Futures.addCallback(controllerFuture, new FutureCallback<>() {
@Override
public void onSuccess(@Nullable MediaController result) {
Log.d(TAG, "Successfully created media controller.");
controller = result;
register();
}
@Override
public void onFailure(@NonNull Throwable t) {
Log.w(TAG, "KeyClearedReceiver.onFailure", t);
}
}, ContextCompat.getMainExecutor(context));
}
void register() {
if (controller == null) {
Log.w(TAG, "Failed to register KeyClearedReceiver because MediaController was null.");
return;
}
if (!registered) {
ContextCompat.registerReceiver(context, this, KEY_CLEARED_FILTER, ContextCompat.RECEIVER_NOT_EXPORTED);
registered = true;
Log.d(TAG, "Successfully registered.");
}
}
void unregister() {
if (registered) {
context.unregisterReceiver(this);
registered = false;
}
MediaController.releaseFuture(controllerFuture);
}
@Override
public void onReceive(Context context, Intent intent) {
if (controller == null) {
Log.w(TAG, "Received broadcast but could not stop playback because MediaController was null.");
} else {
Log.i(TAG, "Received broadcast, stopping playback.");
controller.stop();
}
}
}
private static class MediaSessionServiceListener implements Listener {
@Override
public void onForegroundServiceStartNotAllowedException() {
@@ -8,7 +8,9 @@ package org.thoughtcrime.securesms.components.voice
import android.content.Context
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Process
import android.widget.Toast
import androidx.annotation.MainThread
import androidx.annotation.OptIn
@@ -94,6 +96,10 @@ class VoiceNotePlayerCallback(val context: Context, val player: VoiceNotePlayer)
private var latestUri = Uri.EMPTY
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult {
if (Build.VERSION.SDK_INT >= 28 && controller.uid != Process.myUid()) {
Log.w(TAG, "Rejecting connection from external caller: ${controller.packageName}")
return MediaSession.ConnectionResult.reject()
}
return MediaSession.ConnectionResult.accept(CUSTOM_COMMANDS, SUPPORTED_ACTIONS)
}
@@ -3,8 +3,7 @@ package org.thoughtcrime.securesms.components.webrtc;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import org.signal.core.util.SetUtil;
import org.thoughtcrime.securesms.events.CallParticipant;
@@ -68,12 +67,12 @@ public final class CallParticipantListUpdate {
public static @NonNull CallParticipantListUpdate computeDeltaUpdate(@NonNull List<CallParticipant> oldList,
@NonNull List<CallParticipant> newList)
{
Set<CallParticipantListUpdate.Wrapper> oldParticipants = Stream.of(oldList)
.filter(p -> p.getCallParticipantId().demuxId != CallParticipantId.DEFAULT_ID)
Set<CallParticipantListUpdate.Wrapper> oldParticipants = oldList.stream()
.filter(p -> p.getCallParticipantId().demuxId != CallParticipantId.DEFAULT_ID)
.map(CallParticipantListUpdate::createWrapper)
.collect(Collectors.toSet());
Set<CallParticipantListUpdate.Wrapper> newParticipants = Stream.of(newList)
.filter(p -> p.getCallParticipantId().demuxId != CallParticipantId.DEFAULT_ID)
Set<CallParticipantListUpdate.Wrapper> newParticipants = newList.stream()
.filter(p -> p.getCallParticipantId().demuxId != CallParticipantId.DEFAULT_ID)
.map(CallParticipantListUpdate::createWrapper)
.collect(Collectors.toSet());
Set<CallParticipantListUpdate.Wrapper> added = SetUtil.difference(newParticipants, oldParticipants);
@@ -4,7 +4,6 @@ import android.content.Context
import androidx.annotation.Discouraged
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import com.annimon.stream.OptionalLong
import kotlinx.collections.immutable.toImmutableList
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls.FoldableState
@@ -19,6 +18,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.ringrtc.CameraState
import org.thoughtcrime.securesms.service.webrtc.collections.ParticipantCollection
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState
import java.util.Optional
import java.util.concurrent.TimeUnit
/**
@@ -37,7 +37,7 @@ data class CallParticipantsState(
val isInPipMode: Boolean = false,
private val showVideoForOutgoing: Boolean = false,
val isViewingFocusedParticipant: Boolean = false,
val remoteDevicesCount: OptionalLong = OptionalLong.empty(),
val remoteDevicesCount: Optional<Long> = Optional.empty(),
private val foldableState: FoldableState = FoldableState.flat(),
val isInOutgoingRingingMode: Boolean = false,
val recipient: Recipient = Recipient.UNKNOWN,
@@ -87,11 +87,11 @@ data class CallParticipantsState(
return listParticipants
}
val participantCount: OptionalLong
val participantCount: Optional<Long>
get() {
val includeSelf = groupCallState == WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED
return remoteDevicesCount.map { l: Long -> l + if (includeSelf) 1L else 0L }
.or { if (includeSelf) OptionalLong.of(1L) else OptionalLong.empty() }
.or { if (includeSelf) Optional.of(1L) else Optional.empty() }
}
fun getPreJoinGroupDescription(context: Context): String? {
@@ -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() {}
}
@@ -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();
}
@@ -8,7 +8,7 @@ import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import com.annimon.stream.Stream;
import java.util.stream.Collectors;
import org.thoughtcrime.securesms.contactshare.Contact.Name;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
@@ -82,7 +82,7 @@ class ContactShareEditViewModel extends ViewModel {
}
private <E extends Selectable> List<E> trimSelectables(List<E> selectables) {
return Stream.of(selectables).filter(Selectable::isSelected).toList();
return selectables.stream().filter(Selectable::isSelected).collect(Collectors.toList());
}
@NonNull
@@ -12,7 +12,6 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
@@ -98,7 +97,7 @@ public final class ContactUtil {
return null;
}
List<Phone> mobileNumbers = Stream.of(contact.getPhoneNumbers()).filter(number -> number.getType() == Phone.Type.MOBILE).toList();
List<Phone> mobileNumbers = contact.getPhoneNumbers().stream().filter(number -> number.getType() == Phone.Type.MOBILE).collect(Collectors.toList());
if (mobileNumbers.size() > 0) {
return mobileNumbers.get(0);
}
@@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.adapter.StableIdGenerator;
import java.util.ArrayList;
import java.util.stream.Collectors;
import java.util.List;
import java.util.concurrent.TimeUnit;
@@ -75,7 +76,7 @@ class AttachmentKeyboardMediaAdapter extends RecyclerView.Adapter<AttachmentKeyb
public void setMedia(@NonNull List<Media> media, boolean addFooter) {
this.media.clear();
this.media.addAll(media.stream().map(MediaContent::new).collect(java.util.stream.Collectors.toList()));
this.media.addAll(media.stream().map(MediaContent::new).collect(Collectors.toList()));
if (addFooter) {
this.media.add(new MediaContent(true));
}
@@ -13,12 +13,9 @@ import org.signal.core.models.UriSerializer
import org.signal.core.models.media.Media
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.conversation.ConversationIntents.ConversationScreenType
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.mms.SlideFactory
import org.thoughtcrime.securesms.recipients.Recipient.Companion.resolved
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
@Serializable
@Parcelize
@@ -38,19 +35,12 @@ data class ConversationArgs(
val giftBadge: Badge?,
val shareDataTimestamp: Long,
val conversationScreenType: ConversationScreenType,
val isIncognito: Boolean = false
val isIncognito: Boolean = false,
val hasWallpaper: Boolean = false
) : Parcelable {
@IgnoredOnParcel
val draftMediaType: SlideFactory.MediaType? = SlideFactory.MediaType.from(draftContentType)
@IgnoredOnParcel
val wallpaper: ChatWallpaper?
get() = resolved(recipientId).wallpaper
@IgnoredOnParcel
val chatColors: ChatColors
get() = resolved(recipientId).chatColors
fun canInitializeFromDatabase(): Boolean {
return draftText == null && (draftMedia == null || ConversationIntents.isBubbleIntentUri(draftMedia) || ConversationIntents.isNotificationIntentUri(draftMedia)) && draftMediaType == null
}
@@ -1,327 +0,0 @@
package org.thoughtcrime.securesms.conversation;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat;
import androidx.core.view.ViewKt;
import com.bumptech.glide.RequestManager;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.conversation.colors.AvatarGradientColors;
import org.thoughtcrime.securesms.conversation.v2.data.AvatarDownloadStateCache;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.databinding.ConversationHeaderViewBinding;
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.whispersystems.signalservice.api.util.Preconditions;
public class ConversationHeaderView extends ConstraintLayout {
private static final String TAG = Log.tag(ConversationHeaderView.class);
private static final int FADE_DURATION = 150;
private static final int LOADING_DELAY = 800;
private final ConversationHeaderViewBinding binding;
private boolean inProgress = false;
private Handler handler = new Handler();
public ConversationHeaderView(Context context) {
this(context, null);
}
public ConversationHeaderView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public ConversationHeaderView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
inflate(getContext(), R.layout.conversation_header_view, this);
binding = ConversationHeaderViewBinding.bind(this);
}
public void showProgressBar(@NonNull Recipient recipient) {
if (!inProgress) {
inProgress = true;
animateAvatarLoading(recipient);
binding.messageRequestAvatarTapToView.setVisibility(GONE);
binding.messageRequestAvatarTapToView.setOnClickListener(null);
handler.postDelayed(() -> {
boolean isDownloading = AvatarDownloadStateCache.getDownloadState(recipient) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS;
binding.progressBar.setVisibility(isDownloading ? View.VISIBLE : View.GONE);
}, LOADING_DELAY);
}
}
public void hideProgressBar() {
inProgress = false;
binding.progressBar.setVisibility(View.GONE);
}
public void showFailedAvatarDownload(@NonNull Recipient recipient) {
AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.NONE);
binding.progressBar.setVisibility(View.GONE);
binding.messageRequestAvatar.setImageDrawable(AvatarGradientColors.getGradientDrawable(recipient));
}
public void setBadge(@Nullable Recipient recipient) {
if (recipient == null || recipient.isSelf()) {
binding.messageRequestBadge.setBadge(null);
} else {
binding.messageRequestBadge.setBadgeFromRecipient(recipient);
}
}
public void setAvatar(@NonNull RequestManager requestManager, @Nullable Recipient recipient) {
if (recipient == null) {
return;
}
if (AvatarDownloadStateCache.getDownloadState(recipient) != AvatarDownloadStateCache.DownloadState.IN_PROGRESS) {
binding.messageRequestAvatar.setAvatar(requestManager, recipient, false, false, true);
hideProgressBar();
}
if (recipient.getShouldBlurAvatar() && recipient.getHasAvatar()) {
binding.messageRequestAvatarTapToView.setVisibility(VISIBLE);
binding.messageRequestAvatarTapToView.setOnClickListener(v -> {
AvatarDownloadStateCache.set(recipient, AvatarDownloadStateCache.DownloadState.IN_PROGRESS);
SignalExecutors.BOUNDED.execute(() -> SignalDatabase.recipients().manuallyUpdateShowAvatar(recipient.getId(), true));
if (recipient.isPushV2Group()) {
AvatarGroupsV2DownloadJob.enqueueUnblurredAvatar(recipient.requireGroupId().requireV2());
} else {
RetrieveProfileAvatarJob.enqueueUnblurredAvatar(recipient);
}
});
} else {
binding.messageRequestAvatarTapToView.setVisibility(GONE);
binding.messageRequestAvatarTapToView.setOnClickListener(null);
}
}
public String setTitle(@NonNull Recipient recipient, @NonNull Runnable onTitleClicked) {
CharSequence title = recipient.getDisplayNameForHeadline(getContext());
if (recipient.isIndividual() && !recipient.isSelf()) {
binding.messageRequestTitle.setOnClickListener(v -> onTitleClicked.run());
} else {
binding.messageRequestTitle.setOnClickListener(null);
}
binding.messageRequestTitle.setText(title);
return title.toString();
}
public void showReleaseNoteHeader() {
binding.messageRequestInfo.setVisibility(View.GONE);
binding.releaseHeaderContainer.setVisibility(View.VISIBLE);
binding.releaseHeaderDescription1.setText(prependIcon(getContext().getString(R.string.ReleaseNotes__this_is_official_chat_period), R.drawable.symbol_official_20));
binding.releaseHeaderDescription2.setText(prependIcon(getContext().getString(R.string.ReleaseNotes__keep_up_to_date_period), R.drawable.symbol_bell_20));
}
public void setAbout(@NonNull Recipient recipient) {
String about = recipient.getCombinedAboutAndEmoji();
binding.messageRequestAbout.setText(about);
binding.messageRequestAbout.setVisibility(TextUtils.isEmpty(about) || recipient.isReleaseNotes() ? GONE : VISIBLE);
}
public void setSubtitle(@NonNull CharSequence subtitle, @DrawableRes int iconRes, @Nullable String substring, @Nullable Runnable onClick) {
if (TextUtils.isEmpty(subtitle)) {
hideSubtitle();
return;
}
if (onClick != null && substring != null) {
binding.messageRequestSubtitle.setMovementMethod(LinkMovementMethod.getInstance());
CharSequence builder = SpanUtil.clickSubstring(
subtitle,
substring,
listener -> onClick.run(),
ContextCompat.getColor(getContext(), org.signal.core.ui.R.color.signal_colorOnSurface),
true
);
binding.messageRequestSubtitle.setText(prependIcon(builder, iconRes));
} else {
binding.messageRequestSubtitle.setText(prependIcon(subtitle, iconRes));
}
binding.messageRequestSubtitle.setVisibility(View.VISIBLE);
}
public void setDescription(@Nullable CharSequence description, @DrawableRes int iconRes) {
if (TextUtils.isEmpty(description)) {
hideDescription();
return;
}
binding.messageRequestDescription.setText(prependIcon(description, iconRes));
binding.messageRequestDescription.setVisibility(View.VISIBLE);
updateOutlineVisibility();
}
public @NonNull EmojiTextView getDescription() {
return binding.messageRequestDescription;
}
public void setButton(@NonNull CharSequence button, Runnable onClick) {
binding.messageRequestButton.setText(button);
binding.messageRequestButton.setOnClickListener(v -> onClick.run());
binding.messageRequestButton.setVisibility(View.VISIBLE);
}
public void showWarningSubtitle() {
binding.messageRequestReviewCarefully.setVisibility(View.VISIBLE);
}
public void hideWarningSubtitle() {
binding.messageRequestReviewCarefully.setVisibility(View.GONE);
}
public void setUnverifiedNameSubtitle(@DrawableRes int iconRes, boolean forGroup, @NonNull Runnable onClick) {
binding.messageRequestProfileNameUnverified.setVisibility(View.VISIBLE);
binding.messageRequestProfileNameUnverified.setOnClickListener(view -> onClick.run());
String substring = forGroup ? getContext().getString(R.string.ConversationFragment_group_names)
: getContext().getString(R.string.ConversationFragment_profile_names);
String fullString = forGroup ? getContext().getString(R.string.ConversationFragment_group_names_not_verified, substring)
: getContext().getString(R.string.ConversationFragment_profile_names_not_verified, substring);
CharSequence builder = SpanUtil.underlineSubstring(fullString, substring);
binding.messageRequestProfileNameUnverified.setText(prependIcon(builder, iconRes, forGroup));
}
public void hideUnverifiedNameSubtitle() {
binding.messageRequestProfileNameUnverified.setVisibility(View.GONE);
}
public void showBackgroundBubble(boolean enabled) {
if (enabled) {
setBackgroundResource(R.drawable.wallpaper_bubble_background_18);
} else {
setBackground(null);
}
updateOutlineVisibility();
}
public void hideSubtitle() {
binding.messageRequestSubtitle.setVisibility(View.GONE);
updateOutlineVisibility();
}
public void showDescription() {
binding.messageRequestDescription.setVisibility(View.VISIBLE);
updateOutlineVisibility();
}
public void hideDescription() {
binding.messageRequestDescription.setVisibility(View.GONE);
updateOutlineVisibility();
}
public void hideButton() {
binding.messageRequestButton.setVisibility(View.GONE);
}
public void setLinkifyDescription(boolean enable) {
binding.messageRequestDescription.setMovementMethod(enable ? LongClickMovementMethod.getInstance(getContext()) : null);
}
private void animateAvatarLoading(@NonNull Recipient recipient) {
Drawable loadingProfile = AppCompatResources.getDrawable(getContext(), R.drawable.circle_profile_photo);
ObjectAnimator animator = ObjectAnimator.ofFloat(binding.messageRequestAvatar, "alpha", 1f, 0f).setDuration(FADE_DURATION);
animator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
if (AvatarDownloadStateCache.getDownloadState(recipient) == AvatarDownloadStateCache.DownloadState.IN_PROGRESS) {
binding.messageRequestAvatar.setImageDrawable(loadingProfile);
}
ObjectAnimator.ofFloat(binding.messageRequestAvatar, "alpha", 0f, 1f).setDuration(FADE_DURATION).start();
}
});
animator.start();
}
private void updateOutlineVisibility() {
if (ViewKt.isVisible(binding.messageRequestSubtitle) || ViewKt.isVisible(binding.messageRequestDescription)) {
if (getBackground() != null) {
binding.messageRequestInfoOutline.setVisibility(View.GONE);
binding.messageRequestDivider.setVisibility(View.VISIBLE);
} else {
binding.messageRequestInfoOutline.setVisibility(View.VISIBLE);
binding.messageRequestDivider.setVisibility(View.GONE);
}
} else {
binding.messageRequestInfoOutline.setVisibility(View.GONE);
binding.messageRequestDivider.setVisibility(View.GONE);
}
}
public void updateOutlineBoxSize() {
int visibleCount = 0;
for (int i = 0; i < binding.messageRequestInfo.getChildCount(); i++) {
if (ViewKt.isVisible(binding.messageRequestInfo.getChildAt(i))) {
visibleCount++;
}
}
if (getBackground() != null) {
ViewUtil.setPaddingTop(binding.messageRequestInfo, 0);
ViewUtil.setPaddingBottom(binding.messageRequestInfo, getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_header_padding));
int margin = getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_header_margin);
ViewUtil.setLeftMargin(this, margin);
ViewUtil.setRightMargin(this, margin);
}
int padding = visibleCount == 1 ? getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_header_padding) : getContext().getResources().getDimensionPixelOffset(R.dimen.conversation_header_padding_expanded);
ViewUtil.setPaddingStart(binding.messageRequestInfo, padding);
ViewUtil.setPaddingEnd(binding.messageRequestInfo, padding);
}
private @NonNull CharSequence prependIcon(@NonNull CharSequence input, @DrawableRes int iconRes) {
return prependIcon(input, iconRes, false);
}
private @NonNull CharSequence prependIcon(@NonNull CharSequence input, @DrawableRes int iconRes, boolean useIntrinsicWidth) {
Drawable drawable = ContextCompat.getDrawable(getContext(), iconRes);
Preconditions.checkNotNull(drawable);
int width = useIntrinsicWidth ? drawable.getIntrinsicWidth() : (int) DimensionUnit.SP.toPixels(16);
drawable.setBounds(0, 0, width, (int) DimensionUnit.SP.toPixels(16));
drawable.setColorFilter(ContextCompat.getColor(getContext(), org.signal.core.ui.R.color.signal_colorOnSurface), PorterDuff.Mode.SRC_ATOP);
return new SpannableStringBuilder()
.append(SpanUtil.buildCenteredImageSpan(drawable))
.append(SpanUtil.space(8, DimensionUnit.SP))
.append(input);
}
}
@@ -0,0 +1,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());
}
}
@@ -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());
}
@@ -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);
@@ -40,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();
@@ -54,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;
@@ -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();
}
}
@@ -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()
}
)
@@ -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
}
@@ -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
}
@@ -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,7 +47,7 @@ object PlaintextExportRepository {
fun export(
context: Context,
threadId: Long,
directoryUri: Uri,
outputFile: File,
chatName: String,
includeMedia: Boolean,
progressListener: ProgressListener,
@@ -55,49 +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 = if (includeMedia) {
chatDir.createDirectory("media") ?: run {
Log.w(TAG, "Could not create media directory")
return false
}
} else {
null
}
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())}")
@@ -108,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
@@ -137,42 +107,36 @@ object PlaintextExportRepository {
}
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.
if (includeMedia && mediaDir != null) {
val totalAttachments = pendingAttachments.size
var attachmentsProcessed = 0
for (pending in pendingAttachments) {
if (cancellationSignal.isCancelled()) return false
writer.flush()
zipOut.closeEntry()
if (includeMedia) {
val totalAttachments = pendingAttachments.size
var attachmentsProcessed = 0
for (pending in pendingAttachments) {
if (cancellationSignal.isCancelled()) return false
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)
}
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
eventTimer.emit("media")
}
outputStream.use { out ->
SignalDatabase.attachments.getAttachmentStream(pending.attachment.attachmentId, 0).use { input ->
input.copyTo(out)
}
}
} 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}")
@@ -370,7 +334,6 @@ object PlaintextExportRepository {
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")
@@ -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
}
@@ -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
}
@@ -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())) {
@@ -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);
@@ -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, ","));
@@ -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;
}
@@ -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() {
@@ -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
}
}
@@ -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
@@ -106,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
@@ -332,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
@@ -350,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
@@ -430,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 {
@@ -457,6 +470,7 @@ class ConversationFragment :
removeTextChangedListener(composeTextEventsListener)
setStylingChangedListener(null)
setOnClickListener(null)
ViewCompat.setOnReceiveContentListener(this, null, null)
}
dataObserver?.let {
@@ -480,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()
)
}
@@ -552,8 +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 var exportWithMedia = false
private lateinit var conversationActivityResultContracts: ConversationActivityResultContracts
private lateinit var scrollToPositionDelegate: ScrollToPositionDelegate
private lateinit var adapter: ConversationAdapterV2
@@ -687,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()
@@ -723,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))
@@ -1604,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, exportWithMedia)
}
}
conversationActivityResultContracts = ConversationActivityResultContracts(this, ActivityResultCallbacks())
}
@@ -1655,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()
}
@@ -1683,6 +1718,11 @@ class ConversationFragment :
presentChatColors(recipient.chatColors)
invalidateOptionsMenu()
updateMessageRequestAcceptedState(!viewModel.hasMessageRequestState)
recyclerViewColorizer.setChatColors(recipient.chatColors)
if (adapter.onHasWallpaperChanged(hasWallpaper = recipient.wallpaper != null)) {
conversationItemDecorations.hasWallpaper = recipient.wallpaper != null
}
}
@MainThread
@@ -2149,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,
@@ -2166,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(
@@ -2202,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)
}
@@ -2623,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()
}
@@ -3705,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))
}
}
}
}
@@ -4295,12 +4342,10 @@ class ConversationFragment :
.setTitle(R.string.ChatExportDialogs__export_chat_history_title)
.setMessage(R.string.ChatExportDialogs__export_confirm_body)
.setPositiveButton(R.string.ChatExportDialogs__export_with_media) { _, _ ->
exportWithMedia = true
plaintextExportDirectoryLauncher.launch(null)
viewModel.startPlaintextExport(requireContext().applicationContext, includeMedia = true)
}
.setNeutralButton(R.string.ChatExportDialogs__export_without_media) { _, _ ->
exportWithMedia = false
plaintextExportDirectoryLauncher.launch(null)
viewModel.startPlaintextExport(requireContext().applicationContext, includeMedia = false)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
@@ -4951,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)) {
@@ -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()
}
}
}
@@ -59,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
@@ -102,6 +101,7 @@ 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
@@ -112,7 +112,6 @@ import kotlin.time.Duration
class ConversationViewModel(
val threadId: Long,
requestedStartingPosition: Int,
initialChatColors: ChatColors,
private val repository: ConversationRepository,
recipientRepository: ConversationRecipientRepository,
messageRequestRepository: MessageRequestRepository,
@@ -160,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
@@ -763,10 +762,17 @@ class ConversationViewModel(
}
}
fun startPlaintextExport(context: Context, directoryUri: Uri, withMedia: Boolean) {
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
@@ -774,11 +780,11 @@ class ConversationViewModel(
val success = PlaintextExportRepository.export(
context = context,
threadId = threadId,
directoryUri = directoryUri,
outputFile = outputFile,
chatName = chatName,
includeMedia = withMedia,
includeMedia = includeMedia,
progressListener = { messagesProcessed, messageCount, attachmentsProcessed, attachmentCount ->
val percent = if (withMedia) {
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
@@ -786,7 +792,7 @@ class ConversationViewModel(
if (messageCount > 0) (messagesProcessed * 100) / messageCount else 100
}
val status = if (withMedia && (attachmentsProcessed > 0 || messagesProcessed >= messageCount)) {
val status = if (includeMedia && (attachmentsProcessed > 0 || messagesProcessed >= messageCount)) {
"Exporting media ($attachmentsProcessed/$attachmentCount)..."
} else {
"Exporting messages ($messagesProcessed/$messageCount)..."
@@ -798,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
}
}
@@ -823,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
}
@@ -59,10 +59,10 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.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.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.polls.Poll
@@ -154,15 +154,15 @@ private fun CreatePollScreen(
// Drag and drop
val listState = rememberLazyListState()
val dragDropState = rememberDragDropState(listState, includeHeader = true, includeFooter = true, onEvent = { event ->
val reorderableListState = rememberReorderableListState(listState, includeHeader = true, includeFooter = true, onEvent = { event ->
when (event) {
is DragAndDropEvent.OnItemMove -> {
is ReorderListEvent.ItemMoved -> {
val oldIndex = options[event.fromIndex]
options[event.fromIndex] = options[event.toIndex]
options[event.toIndex] = oldIndex
}
is DragAndDropEvent.OnItemDrop, is DragAndDropEvent.OnDragCancel -> Unit
is ReorderListEvent.ItemDropped, is ReorderListEvent.DragCanceled -> Unit
}
})
@@ -204,14 +204,14 @@ private fun CreatePollScreen(
modifier = Modifier
.fillMaxHeight()
.imePadding()
.dragContainer(
dragDropState = dragDropState,
.reorderableList(
reorderableListState = reorderableListState,
dragHandleWidth = 56.dp
),
state = listState
) {
item {
DraggableItem(dragDropState, 0) {
ReorderableItem(reorderableListState, 0) {
Text(
text = stringResource(R.string.CreatePollFragment__question),
modifier = Modifier.padding(vertical = 12.dp, horizontal = 24.dp),
@@ -246,7 +246,7 @@ private fun CreatePollScreen(
}
itemsIndexed(options) { index, option ->
DraggableItem(dragDropState, 1 + index) {
ReorderableItem(reorderableListState, 1 + index) {
TextFieldWithCountdown(
value = option,
label = { Text(text = stringResource(R.string.CreatePollFragment__option_n, index + 1)) },
@@ -273,7 +273,7 @@ private fun CreatePollScreen(
}
item {
DraggableItem(dragDropState, 1 + options.size) {
ReorderableItem(reorderableListState, 1 + options.size) {
Dividers.Default()
Rows.ToggleRow(checked = allowMultiple, text = stringResource(R.string.CreatePollFragment__allow_multiple_votes), onCheckChanged = { allowMultiple = it })
Spacer(modifier = Modifier.size(60.dp))
@@ -94,10 +94,12 @@ class DisabledInputView @JvmOverloads constructor(
setWallpaperEnabled(recipient.hasWallpaper)
setAcceptOnClickListener {
if (messageRequestState.isFewConnectionsIndividual) {
if (messageRequestState.isIndividual) {
val signalWillNever = context.getString(R.string.MessageRequestBottomView_signal_will_never)
val body = context.getString(R.string.MessageRequestBottomView_accept_request_body, signalWillNever)
MaterialAlertDialogBuilder(context)
.setTitle(R.string.MessageRequestBottomView_accept_request)
.setMessage(R.string.MessageRequestBottomView_review_requests_carefully)
.setMessage(SpanUtil.boldSubstring(body, signalWillNever))
.setPositiveButton(R.string.MessageRequestBottomView_accept) { _, _ -> listener?.onAcceptMessageRequestClicked() }
.setNegativeButton(android.R.string.cancel, null)
.show()
@@ -5,7 +5,6 @@
package org.thoughtcrime.securesms.conversation.v2
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi
@@ -15,32 +14,39 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
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.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
@@ -50,7 +56,6 @@ import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
/**
@@ -83,84 +88,196 @@ class SafetyTipsBottomSheetDialog : ComposeBottomSheetDialogFragment() {
}
}
data class SafetyTipData(
private data class SafetyTipSummary(
@DrawableRes val icon: Int,
@StringRes val titleText: Int,
@StringRes val messageText: Int
)
private data class SafetyTipDetail(
@DrawableRes val heroImage: Int,
@StringRes val titleText: Int,
@StringRes val messageText: Int
)
private val tips = listOf(
SafetyTipData(heroImage = R.drawable.safety_tip0, titleText = R.string.SafetyTips_tip0_title, messageText = R.string.SafetyTips_tip0_message),
SafetyTipData(heroImage = R.drawable.safety_tip1, titleText = R.string.SafetyTips_tip1_title, messageText = R.string.SafetyTips_tip1_message),
SafetyTipData(heroImage = R.drawable.safety_tip2, titleText = R.string.SafetyTips_tip2_title, messageText = R.string.SafetyTips_tip2_message),
SafetyTipData(heroImage = R.drawable.safety_tip3, titleText = R.string.SafetyTips_tip3_title, messageText = R.string.SafetyTips_tip3_message),
SafetyTipData(heroImage = R.drawable.safety_tip4, titleText = R.string.SafetyTips_tip4_title, messageText = R.string.SafetyTips_tip4_message)
private val summaryTips = listOf(
SafetyTipSummary(icon = R.drawable.safetytip_48_01, titleText = R.string.SafetyTips_summary_tip0_title, messageText = R.string.SafetyTips_summary_tip0_message),
SafetyTipSummary(icon = R.drawable.safetytip_48_02, titleText = R.string.SafetyTips_summary_tip1_title, messageText = R.string.SafetyTips_summary_tip1_message),
SafetyTipSummary(icon = R.drawable.safetytip_48_03, titleText = R.string.SafetyTips_summary_tip2_title, messageText = R.string.SafetyTips_summary_tip2_message)
)
private val detailTips = listOf(
SafetyTipDetail(heroImage = R.drawable.safetytip_240_01, titleText = R.string.SafetyTips_detail_tip0_title, messageText = R.string.SafetyTips_detail_tip0_message),
SafetyTipDetail(heroImage = R.drawable.safetytip_240_02, titleText = R.string.SafetyTips_detail_tip1_title, messageText = R.string.SafetyTips_detail_tip1_message),
SafetyTipDetail(heroImage = R.drawable.safetytip_240_03, titleText = R.string.SafetyTips_detail_tip2_title, messageText = R.string.SafetyTips_detail_tip2_message),
SafetyTipDetail(heroImage = R.drawable.safetytip_240_04, titleText = R.string.SafetyTips_detail_tip3_title, messageText = R.string.SafetyTips_detail_tip3_message),
SafetyTipDetail(heroImage = R.drawable.safetytip_240_05, titleText = R.string.SafetyTips_detail_tip4_title, messageText = R.string.SafetyTips_detail_tip4_message),
SafetyTipDetail(heroImage = R.drawable.safetytip_240_06, titleText = R.string.SafetyTips_detail_tip5_title, messageText = R.string.SafetyTips_detail_tip5_message)
)
@DayNightPreviews
@Composable
private fun SafetyTipsContentPreview() {
Previews.Preview {
Surface {
SafetyTipsContent()
private fun SafetyTipsContent(forGroup: Boolean = false, modifier: Modifier = Modifier) {
var showDetails by rememberSaveable { mutableStateOf(false) }
if (showDetails) {
SafetyTipsDetailContent(modifier = modifier)
} else {
SafetyTipsSummaryContent(
onViewMore = { showDetails = true },
modifier = modifier
)
}
}
@Composable
private fun SafetyTipsSummaryContent(
onViewMore: () -> Unit,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier.fillMaxWidth()
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
BottomSheets.Handle()
}
Column(
modifier = modifier
.fillMaxWidth()
.weight(weight = 1f, fill = false)
.verticalScroll(state = scrollState)
.padding(horizontal = 36.dp)
) {
Text(
text = stringResource(id = R.string.SafetyTips_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier
.padding(top = 28.dp, bottom = 34.dp)
.align(Alignment.CenterHorizontally)
)
summaryTips.forEach { tip ->
SafetyTipSummaryRow(tip)
}
Spacer(Modifier.height(8.dp))
Buttons.LargeTonal(
onClick = onViewMore,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.SafetyTips_view_more))
}
Spacer(Modifier.height(36.dp))
}
}
}
@Composable
private fun SafetyTipSummaryRow(tip: SafetyTipSummary) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 40.dp),
verticalAlignment = Alignment.Top
) {
Image(
painter = painterResource(id = tip.icon),
contentDescription = null,
modifier = Modifier.size(48.dp)
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 24.dp)
) {
Text(
text = stringResource(id = tip.titleText),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(id = tip.messageText),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SafetyTipsContent(forGroup: Boolean = false, modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
BottomSheets.Handle()
}
val size = remember { tips.size }
val pagerState = rememberPagerState(
pageCount = { size }
)
val scrollState = rememberScrollState()
private fun SafetyTipsDetailContent(modifier: Modifier = Modifier) {
val size = remember { detailTips.size }
val pagerState = rememberPagerState(pageCount = { size })
val coroutineScope = rememberCoroutineScope()
Column(
modifier = Modifier.fillMaxWidth()
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
BottomSheets.Handle()
}
Column(
modifier = modifier
.fillMaxWidth()
.weight(weight = 1f, fill = false)
.padding(top = 22.dp)
.verticalScroll(state = scrollState)
) {
Text(
text = stringResource(id = R.string.SafetyTips_title),
style = MaterialTheme.typography.headlineMedium.copy(textAlign = TextAlign.Center),
modifier = Modifier
.padding(start = 24.dp, end = 24.dp, bottom = 4.dp, top = 26.dp)
.fillMaxWidth()
)
Text(
text = if (forGroup) stringResource(id = R.string.SafetyTips_subtitle_group) else stringResource(id = R.string.SafetyTips_subtitle_individual),
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant),
modifier = Modifier
.padding(start = 36.dp, end = 36.dp)
.fillMaxWidth()
)
HorizontalPager(
state = pagerState,
beyondViewportPageCount = size,
modifier = Modifier.padding(top = 24.dp)
) {
SafetyTip(tips[it])
verticalAlignment = Alignment.Top,
modifier = Modifier.weight(weight = 1f, fill = false)
) { page ->
SafetyTipDetailPage(detailTips[page])
}
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, bottom = 36.dp, top = 16.dp)
) {
if (pagerState.currentPage > 0) {
IconButton(
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
},
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_arrow_right_24),
contentDescription = stringResource(R.string.SafetyTips_previous_tip),
modifier = Modifier.graphicsLayer(scaleX = -1f)
)
}
} else {
Spacer(Modifier.size(48.dp))
}
Row(
Modifier
.fillMaxWidth()
.padding(top = 20.dp),
horizontalArrangement = Arrangement.Center
) {
repeat(pagerState.pageCount) { iteration ->
@@ -178,103 +295,92 @@ private fun SafetyTipsContent(forGroup: Boolean = false, modifier: Modifier = Mo
)
}
}
}
Surface(
shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp,
modifier = Modifier.fillMaxWidth(),
color = SignalTheme.colors.colorSurface1,
contentColor = MaterialTheme.colorScheme.onSurface
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.padding(start = 24.dp, end = 24.dp, bottom = 36.dp, top = 24.dp)
.fillMaxWidth()
) {
val coroutineScope = rememberCoroutineScope()
TextButton(
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
},
enabled = pagerState.currentPage > 0,
modifier = Modifier
) {
Text(text = stringResource(id = R.string.SafetyTips_previous_tip))
}
Buttons.LargeTonal(
if (pagerState.currentPage < pagerState.pageCount - 1) {
IconButton(
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
},
enabled = pagerState.currentPage + 1 < pagerState.pageCount
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
) {
Text(text = stringResource(id = R.string.SafetyTips_next_tip))
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_arrow_right_24),
contentDescription = stringResource(R.string.SafetyTips_next_tip)
)
}
} else {
Spacer(Modifier.size(48.dp))
}
}
}
}
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun SafetyTipPreview() {
Previews.Preview {
Surface {
SafetyTip(tips[0])
}
}
}
@Composable
private fun SafetyTip(safetyTip: SafetyTipData) {
Surface(
shape = RoundedCornerShape(18.dp),
color = colorResource(id = R.color.safety_tip_background),
contentColor = MaterialTheme.colorScheme.onSurface,
private fun SafetyTipDetailPage(tip: SafetyTipDetail) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 36.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
Image(
painter = painterResource(id = tip.heroImage),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = colorResource(id = R.color.safety_tip_image_background),
modifier = Modifier
.padding(12.dp)
.fillMaxWidth()
) {
Image(
painter = painterResource(id = safetyTip.heroImage),
contentDescription = null,
modifier = Modifier
.padding(16.dp)
)
}
.padding(top = 16.dp, bottom = 16.dp)
.height(160.dp)
.align(Alignment.CenterHorizontally)
)
Text(
text = stringResource(id = safetyTip.titleText),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 4.dp)
)
Text(
text = stringResource(id = tip.titleText),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(id = safetyTip.messageText),
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center),
modifier = Modifier
.padding(start = 24.dp, end = 24.dp, bottom = 24.dp)
)
Text(
text = stringResource(id = tip.messageText),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
}
@DayNightPreviews
@Composable
private fun SafetyTipsSummaryPreview() {
Previews.Preview {
Surface {
SafetyTipsSummaryContent(onViewMore = {})
}
}
}
@DayNightPreviews
@Composable
private fun SafetyTipsDetailPreview() {
Previews.Preview {
Surface {
SafetyTipsDetailContent()
}
}
}
@DayNightPreviews
@Composable
private fun SafetyTipDetailPagePreview() {
Previews.Preview {
Surface {
SafetyTipDetailPage(detailTips[0])
}
}
}
@@ -1,17 +1,12 @@
package org.thoughtcrime.securesms.conversation.v2
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -37,6 +32,7 @@ import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
/**
@@ -70,7 +66,7 @@ class UnverifiedProfileNameBottomSheet : ComposeBottomSheetDialogFragment() {
}
@Composable
private fun ProfileNameSheet(forGroup: Boolean = true) {
private fun ProfileNameSheet(forGroup: Boolean = false) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
@@ -82,13 +78,13 @@ private fun ProfileNameSheet(forGroup: Boolean = true) {
val (imageVector, placeholder, text) =
if (forGroup) {
Triple(
R.drawable.symbol_group_question_55,
R.drawable.symbol_group_questionmark_bold_40,
stringResource(R.string.ConversationFragment_group_names),
stringResource(id = R.string.ProfileNameBottomSheet__group_names_on_signal, stringResource(R.string.ConversationFragment_group_names))
)
} else {
Triple(
R.drawable.symbol_person_question_40,
R.drawable.symbol_person_questionmark_bold_40,
stringResource(R.string.ConversationFragment_profile_names),
stringResource(id = R.string.ProfileNameBottomSheet__profile_names_on_signal, stringResource(R.string.ConversationFragment_profile_names))
)
@@ -97,12 +93,16 @@ private fun ProfileNameSheet(forGroup: Boolean = true) {
Icon(
imageVector = ImageVector.vectorResource(imageVector),
contentDescription = null,
tint = SignalTheme.colors.colorOnWarning,
modifier = Modifier
.padding(top = 38.dp, bottom = 24.dp)
.size(height = 56.dp, width = 72.dp)
.padding(top = 30.dp, bottom = 20.dp)
.clip(RoundedCornerShape(50))
.background(SignalTheme.colors.colorWarning)
.padding(horizontal = 18.dp, vertical = 8.dp)
.size(32.dp)
)
val annotatedText = remember {
val annotatedText = remember(text, placeholder) {
buildAnnotatedString {
val start = text.indexOf(placeholder)
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
@@ -114,43 +114,41 @@ private fun ProfileNameSheet(forGroup: Boolean = true) {
Text(
text = annotatedText,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(bottom = 20.dp)
)
BulletRow(stringResource(R.string.ProfileNameBottomSheet__signal_cant_verify))
BulletRow(stringResource(R.string.ProfileNameBottomSheet__signal_will_never_contact))
if (forGroup) {
InfoRow(stringResource(R.string.ProfileNameBottomSheet__be_cautious_of_groups))
InfoRow(stringResource(R.string.ProfileNameBottomSheet__profile_names_in_groups))
BulletRow(stringResource(R.string.ProfileNameBottomSheet__be_cautious_of_groups))
} else {
InfoRow(stringResource(R.string.ProfileNameBottomSheet__profile_names_arent_verified))
InfoRow(stringResource(R.string.ProfileNameBottomSheet__be_cautious_of_accounts))
BulletRow(stringResource(R.string.ProfileNameBottomSheet__be_cautious_of_accounts))
}
InfoRow(stringResource(R.string.ProfileNameBottomSheet__dont_share_personal))
BulletRow(stringResource(R.string.ProfileNameBottomSheet__dont_share_personal))
Spacer(Modifier.size(55.dp))
}
}
@Composable
fun InfoRow(text: String) {
private fun BulletRow(text: String) {
Row(
modifier = Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth()
.padding(start = 16.dp, bottom = 12.dp)
.padding(bottom = 12.dp),
verticalAlignment = Alignment.Top
) {
Box(
modifier = Modifier
.width(4.dp)
.padding(vertical = 5.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(10.dp))
.background(color = MaterialTheme.colorScheme.outline.copy(.4f))
Text(
text = "\u2022",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = text,
modifier = Modifier.padding(start = 12.dp),
style = MaterialTheme.typography.bodyLarge
)
}
@@ -163,3 +161,11 @@ private fun ProfileNameSheetPreview() {
ProfileNameSheet()
}
}
@DayNightPreviews
@Composable
private fun ProfileNameSheetGroupPreview() {
Previews.BottomSheetContentPreview {
ProfileNameSheet(forGroup = true)
}
}
@@ -28,7 +28,12 @@ object AvatarDownloadStateCache {
@JvmStatic
fun getDownloadState(recipient: Recipient): DownloadState {
return cache[recipient.id]?.value ?: DownloadState.NONE
return getDownloadState(recipient.id)
}
@JvmStatic
fun getDownloadState(recipientId: RecipientId): DownloadState {
return cache[recipientId]?.value ?: DownloadState.NONE
}
@JvmStatic
@@ -27,7 +27,7 @@ class SenderNameWithLabelView : AbstractComposeView {
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
init {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setViewCompositionStrategy(ViewCompositionStrategy.Default)
}
private var senderName: String by mutableStateOf("")
@@ -37,6 +37,28 @@ class SenderNameWithLabelView : AbstractComposeView {
private var memberLabel: MemberLabel? by mutableStateOf(null)
/**
* Sets sender and label state and forces fresh composition to avoid stale measurements from a previously bound item.
* Intended for use in a RecyclerView.
*/
fun bind(name: String, @ColorInt tintColor: Int, label: MemberLabel?) {
setSender(name, tintColor)
setLabel(label)
// AbstractComposeView caches measurements across RecyclerView recycling. This forces fresh composition on the
// next measure to avoid stale cached measurements during recycling that can cause sender name / label clipping.
disposeComposition()
}
/**
* Variant of [bind] with explicit label text/background colors.
*/
fun bind(name: String, @ColorInt tintColor: Int, label: MemberLabel?, @ColorInt textColor: Int, @ColorInt backgroundColor: Int) {
setSender(name, tintColor)
setLabel(label, textColor, backgroundColor)
disposeComposition()
}
fun setSender(name: String, @ColorInt tintColor: Int) {
senderName = name
senderColor = Color(tintColor)
@@ -252,7 +252,7 @@ open class V2ConversationItemTextOnlyViewHolder<Model : MappingModel<Model>>(
hasProcessedSupportedPayload = true
}
if (hasProcessedSupportedPayload) {
if (hasProcessedSupportedPayload && V2Payload.WALLPAPER !in payload) {
return
}
@@ -631,8 +631,7 @@ open class V2ConversationItemTextOnlyViewHolder<Model : MappingModel<Model>>(
val tintColor = conversationContext.getColorizer().getIncomingGroupSenderColor(context, sender)
nameWithLabelView.apply {
setSender(sender.getDisplayName(context), tintColor)
setLabel(conversationMessage.memberLabel)
bind(sender.getDisplayName(context), tintColor, conversationMessage.memberLabel)
visible = true
}
@@ -57,7 +57,6 @@ import androidx.recyclerview.widget.LinearSmoothScroller;
import androidx.recyclerview.widget.RecyclerView;
import com.airbnb.lottie.SimpleColorFilter;
import com.annimon.stream.Stream;
import com.bumptech.glide.Glide;
import com.google.android.material.animation.ArgbEvaluatorCompat;
import com.google.android.material.appbar.AppBarLayout;
@@ -1234,10 +1233,10 @@ public class ConversationListFragment extends MainFragment implements Conversati
}
private void handlePin(@NonNull Collection<Conversation> conversations) {
final Set<Long> toPin = new LinkedHashSet<>(Stream.of(conversations)
.filterNot(conversation -> conversation.getThreadRecord().isPinned())
final Set<Long> toPin = new LinkedHashSet<>(conversations.stream()
.filter(conversation -> !conversation.getThreadRecord().isPinned())
.map(conversation -> conversation.getThreadRecord().getThreadId())
.toList());
.collect(Collectors.toList()));
if (toPin.size() + viewModel.getPinnedCount() > RemoteConfig.pinnedChatLimit()) {
mainNavigationViewModel.getSnackbarRegistry().emit(new SnackbarState(
@@ -1512,9 +1511,9 @@ public class ConversationListFragment extends MainFragment implements Conversati
private void updateMultiSelectState() {
int count = viewModel.currentSelectedConversations().size();
boolean hasUnread = Stream.of(viewModel.currentSelectedConversations()).anyMatch(conversation -> !conversation.getThreadRecord().isRead());
boolean hasUnpinned = Stream.of(viewModel.currentSelectedConversations()).anyMatch(conversation -> !conversation.getThreadRecord().isPinned());
boolean hasUnmuted = Stream.of(viewModel.currentSelectedConversations()).anyMatch(conversation -> !conversation.getThreadRecord().getRecipient().live().get().isMuted());
boolean hasUnread = viewModel.currentSelectedConversations().stream().anyMatch(conversation -> !conversation.getThreadRecord().isRead());
boolean hasUnpinned = viewModel.currentSelectedConversations().stream().anyMatch(conversation -> !conversation.getThreadRecord().isPinned());
boolean hasUnmuted = viewModel.currentSelectedConversations().stream().anyMatch(conversation -> !conversation.getThreadRecord().getRecipient().live().get().isMuted());
boolean canPin = viewModel.getPinnedCount() < RemoteConfig.pinnedChatLimit();
if (mainToolbarViewModel.isInActionMode()) {
@@ -5,7 +5,6 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import org.signal.core.util.Base64;
import org.signal.core.util.logging.Log;
@@ -99,7 +98,7 @@ public class SealedSenderAccessUtil {
return Optional.ofNullable(unidentifiedAccess);
}).collect(Collectors.toList());
int unidentifiedCount = Stream.of(access).filter(Optional::isPresent).toList().size();
int unidentifiedCount = access.stream().filter(Optional::isPresent).collect(Collectors.toList()).size();
int otherCount = access.size() - unidentifiedCount;
if (log) {
@@ -142,10 +142,6 @@ public class TextSecureSessionStore implements SignalServiceSessionStore {
if (recipient.getHasPni()) {
archiveSession(new SignalProtocolAddress(recipient.requirePni().toString(), deviceId));
}
if (recipient.getHasE164()) {
archiveSession(new SignalProtocolAddress(recipient.requireE164(), deviceId));
}
}
}
@@ -164,12 +160,6 @@ public class TextSecureSessionStore implements SignalServiceSessionStore {
archiveSiblingSessions(address);
archiveSession(address);
}
if (recipient.getHasE164()) {
SignalProtocolAddress address = new SignalProtocolAddress(recipient.requireE164(), 1);
archiveSiblingSessions(address);
archiveSession(address);
}
}
}
@@ -422,26 +422,45 @@ class AttachmentTable(
}
/**
* Returns a list that has any permanently-failed thumbnails removed.
* Filters thumbnail snapshot entries down to only those that have at least one eligible attachment capable of thumbnail upload.
*/
fun filterPermanentlyFailedThumbnails(entries: Set<BackupMediaSnapshotTable.MediaEntry>): Set<BackupMediaSnapshotTable.MediaEntry> {
fun filterThumbnailsWithoutEligibleAttachment(entries: Set<BackupMediaSnapshotTable.MediaEntry>): Set<BackupMediaSnapshotTable.MediaEntry> {
if (entries.isEmpty()) {
return entries
}
val entriesByMediaName: MutableMap<String, BackupMediaSnapshotTable.MediaEntry> = entries
.associateBy { MediaName.fromPlaintextHashAndRemoteKeyForThumbnail(it.plaintextHash, it.remoteKey).name }
.toMutableMap()
val eligibleMediaNames: MutableSet<String> = mutableSetOf()
readableDatabase
.select(DATA_HASH_END, REMOTE_KEY)
.from(TABLE_NAME)
.where("$DATA_HASH_END NOT NULL AND $REMOTE_KEY NOT NULL AND $ARCHIVE_THUMBNAIL_TRANSFER_STATE = ${ArchiveTransferState.PERMANENT_FAILURE.value}")
.where(
"""
$DATA_HASH_END NOT NULL AND
$REMOTE_KEY NOT NULL AND
$DATA_FILE NOT NULL AND
$TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND
$QUOTE = 0 AND
$ARCHIVE_THUMBNAIL_TRANSFER_STATE != ${ArchiveTransferState.PERMANENT_FAILURE.value}
"""
)
.run()
.forEach { cursor ->
val hashEnd = cursor.requireNonNullString(DATA_HASH_END)
val remoteKey = cursor.requireNonNullString(REMOTE_KEY)
val thumbnailMediaName = MediaName.fromPlaintextHashAndRemoteKeyForThumbnail(Base64.decode(hashEnd), Base64.decode(remoteKey)).name
entriesByMediaName.remove(thumbnailMediaName)
if (thumbnailMediaName in entriesByMediaName) {
eligibleMediaNames += thumbnailMediaName
}
}
entriesByMediaName.keys.retainAll(eligibleMediaNames)
return entriesByMediaName.values.toSet()
}
@@ -8,8 +8,7 @@ import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import com.annimon.stream.function.Function;
import java.util.function.Function;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.MessageRecord;
@@ -117,13 +116,12 @@ public final class MentionUtil {
public static @NonNull List<Mention> bodyRangeListToMentions(@Nullable BodyRangeList bodyRanges) {
if (bodyRanges != null) {
return Stream.of(bodyRanges.ranges)
.filter(bodyRange -> bodyRange.mentionUuid != null)
.map(mention -> {
return bodyRanges.ranges.stream()
.filter(bodyRange -> bodyRange.mentionUuid != null)
.map(mention -> {
RecipientId id = Recipient.externalPush(ServiceId.parseOrThrow(mention.mentionUuid)).getId();
return new Mention(id, mention.start, mention.length);
})
.toList();
}).collect(Collectors.toList());
} else {
return Collections.emptyList();
}
@@ -3625,8 +3625,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
private fun getMessageDirectlyBefore(messageId: Long, threadId: Long, dateReceived: Long): PotentialCollapsibleMessage? {
return readableDatabase
.select(DATE_RECEIVED, TYPE, COLLAPSED_HEAD_ID, MESSAGE_EXTRAS)
.from(TABLE_NAME)
.where("$ID < ? AND $THREAD_ID = ?", messageId, threadId)
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID")
.where("$THREAD_ID = ? AND $DATE_RECEIVED < ?", threadId, dateReceived)
.orderBy("$DATE_RECEIVED DESC")
.limit(1)
.run()
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.database;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import com.mobilecoin.lib.KeyImage;
import com.mobilecoin.lib.Receipt;
import com.mobilecoin.lib.RistrettoPublic;
@@ -87,6 +86,9 @@ public final class PaymentMetaDataUtil {
}
public static byte[] receiptPublic(@NonNull PaymentMetaData paymentMetaData) {
return Stream.of(paymentMetaData.mobileCoinTxoIdentification.publicKey).single().toByteArray();
if (paymentMetaData.mobileCoinTxoIdentification.publicKey.size() != 1) {
throw new IllegalStateException("Unexpected number of public keys!");
}
return paymentMetaData.mobileCoinTxoIdentification.publicKey.get(0).toByteArray();
}
}
@@ -10,18 +10,8 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.crypto.AttachmentSecret
import org.thoughtcrime.securesms.crypto.DatabaseSecret
import org.thoughtcrime.securesms.crypto.MasterSecret
import org.thoughtcrime.securesms.database.helpers.ClassicOpenHelper
import org.thoughtcrime.securesms.database.helpers.PreKeyMigrationHelper
import org.thoughtcrime.securesms.database.helpers.SQLCipherMigrationHelper
import org.thoughtcrime.securesms.database.helpers.SessionStoreMigrationHelper
import org.thoughtcrime.securesms.database.helpers.SignalDatabaseMigrations
import org.thoughtcrime.securesms.database.model.AvatarPickerDatabase
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
import org.thoughtcrime.securesms.migrations.LegacyMigrationJob
import org.thoughtcrime.securesms.migrations.LegacyMigrationJob.DatabaseUpgradeListener
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.util.TextSecurePreferences
import java.io.File
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSQLiteDatabase
@@ -95,19 +85,6 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
// Requires FTS5
executeStatements(signalDb, SearchTable.CREATE_TABLE)
executeStatements(signalDb, SearchTable.CREATE_TRIGGERS)
if (context.getDatabasePath(ClassicOpenHelper.NAME).exists()) {
val legacyHelper = ClassicOpenHelper(context)
val legacyDb = legacyHelper.writableDatabase
SQLCipherMigrationHelper.migratePlaintext(context, legacyDb, db)
val masterSecret = KeyCachingService.getMasterSecret(context)
if (masterSecret != null) SQLCipherMigrationHelper.migrateCiphertext(context, masterSecret, legacyDb, db, null) else TextSecurePreferences.setNeedsSqlCipherMigration(context, true)
if (!PreKeyMigrationHelper.migratePreKeys(context, db)) {
PreKeysSyncJob.enqueue()
}
SessionStoreMigrationHelper.migrateSessions(context, db)
PreKeyMigrationHelper.cleanUpPreKeys(context)
}
}
@VisibleForTesting
@@ -348,36 +325,6 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
instance!!.signalWritableDatabase
}
@Deprecated("Only used for a legacy migration.")
@JvmStatic
fun onApplicationLevelUpgrade(
context: Context,
masterSecret: MasterSecret,
fromVersion: Int,
listener: DatabaseUpgradeListener?
) {
instance!!.signalWritableDatabase
var legacyOpenHelper: ClassicOpenHelper? = null
if (fromVersion < LegacyMigrationJob.ASYMMETRIC_MASTER_SECRET_FIX_VERSION) {
legacyOpenHelper = ClassicOpenHelper(context)
legacyOpenHelper.onApplicationLevelUpgrade(context, masterSecret, fromVersion, listener)
}
if (fromVersion < LegacyMigrationJob.SQLCIPHER && TextSecurePreferences.getNeedsSqlCipherMigration(context)) {
if (legacyOpenHelper == null) {
legacyOpenHelper = ClassicOpenHelper(context)
}
SQLCipherMigrationHelper.migrateCiphertext(
context,
masterSecret,
legacyOpenHelper.writableDatabase,
instance!!.rawWritableDatabase,
listener
)
}
}
@JvmStatic
fun <T> runInTransaction(block: (SignalSQLiteDatabase) -> T): T {
return instance!!.signalWritableDatabase.withinTransaction {
@@ -1,227 +0,0 @@
package org.thoughtcrime.securesms.database.helpers;
import android.content.ContentValues;
import android.content.Context;
import androidx.annotation.NonNull;
import com.fasterxml.jackson.annotation.JsonProperty;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.signal.core.util.Conversions;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.InvalidMessageException;
import org.signal.libsignal.protocol.state.PreKeyRecord;
import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
import org.thoughtcrime.securesms.database.OneTimePreKeyTable;
import org.thoughtcrime.securesms.database.SignedPreKeyTable;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.signal.core.util.Base64;
import org.thoughtcrime.securesms.util.JsonUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
public final class PreKeyMigrationHelper {
private static final String PREKEY_DIRECTORY = "prekeys";
private static final String SIGNED_PREKEY_DIRECTORY = "signed_prekeys";
private static final int PLAINTEXT_VERSION = 2;
private static final int CURRENT_VERSION_MARKER = 2;
private static final String TAG = Log.tag(PreKeyMigrationHelper.class);
public static boolean migratePreKeys(Context context, SQLiteDatabase database) {
File[] preKeyFiles = getPreKeyDirectory(context).listFiles();
boolean clean = true;
if (preKeyFiles != null) {
for (File preKeyFile : preKeyFiles) {
if (!"index.dat".equals(preKeyFile.getName())) {
try {
PreKeyRecord preKey = new PreKeyRecord(loadSerializedRecord(preKeyFile));
ContentValues contentValues = new ContentValues();
contentValues.put(OneTimePreKeyTable.KEY_ID, preKey.getId());
contentValues.put(OneTimePreKeyTable.PUBLIC_KEY, Base64.encodeWithPadding(preKey.getKeyPair().getPublicKey().serialize()));
contentValues.put(OneTimePreKeyTable.PRIVATE_KEY, Base64.encodeWithPadding(preKey.getKeyPair().getPrivateKey().serialize()));
database.insert(OneTimePreKeyTable.TABLE_NAME, null, contentValues);
Log.i(TAG, "Migrated one-time prekey: " + preKey.getId());
} catch (IOException | InvalidMessageException | InvalidKeyException e) {
Log.w(TAG, e);
clean = false;
}
}
}
}
File[] signedPreKeyFiles = getSignedPreKeyDirectory(context).listFiles();
if (signedPreKeyFiles != null) {
for (File signedPreKeyFile : signedPreKeyFiles) {
if (!"index.dat".equals(signedPreKeyFile.getName())) {
try {
SignedPreKeyRecord signedPreKey = new SignedPreKeyRecord(loadSerializedRecord(signedPreKeyFile));
ContentValues contentValues = new ContentValues();
contentValues.put(SignedPreKeyTable.KEY_ID, signedPreKey.getId());
contentValues.put(SignedPreKeyTable.PUBLIC_KEY, Base64.encodeWithPadding(signedPreKey.getKeyPair().getPublicKey().serialize()));
contentValues.put(SignedPreKeyTable.PRIVATE_KEY, Base64.encodeWithPadding(signedPreKey.getKeyPair().getPrivateKey().serialize()));
contentValues.put(SignedPreKeyTable.SIGNATURE, Base64.encodeWithPadding(signedPreKey.getSignature()));
contentValues.put(SignedPreKeyTable.TIMESTAMP, signedPreKey.getTimestamp());
database.insert(SignedPreKeyTable.TABLE_NAME, null, contentValues);
Log.i(TAG, "Migrated signed prekey: " + signedPreKey.getId());
} catch (IOException | InvalidMessageException | InvalidKeyException e) {
Log.w(TAG, e);
clean = false;
}
}
}
}
File oneTimePreKeyIndex = new File(getPreKeyDirectory(context), PreKeyIndex.FILE_NAME);
File signedPreKeyIndex = new File(getSignedPreKeyDirectory(context), SignedPreKeyIndex.FILE_NAME);
if (oneTimePreKeyIndex.exists()) {
try {
InputStreamReader reader = new InputStreamReader(new FileInputStream(oneTimePreKeyIndex));
PreKeyIndex index = JsonUtils.fromJson(reader, PreKeyIndex.class);
reader.close();
Log.i(TAG, "Setting next prekey id: " + index.nextPreKeyId);
SignalStore.account().aciPreKeys().setNextEcOneTimePreKeyId(index.nextPreKeyId);
} catch (IOException e) {
Log.w(TAG, e);
}
}
if (signedPreKeyIndex.exists()) {
try {
InputStreamReader reader = new InputStreamReader(new FileInputStream(signedPreKeyIndex));
SignedPreKeyIndex index = JsonUtils.fromJson(reader, SignedPreKeyIndex.class);
reader.close();
Log.i(TAG, "Setting next signed prekey id: " + index.nextSignedPreKeyId);
Log.i(TAG, "Setting active signed prekey id: " + index.activeSignedPreKeyId);
SignalStore.account().aciPreKeys().setNextSignedPreKeyId(index.nextSignedPreKeyId);
SignalStore.account().aciPreKeys().setActiveSignedPreKeyId(index.activeSignedPreKeyId);
} catch (IOException e) {
Log.w(TAG, e);
}
}
return clean;
}
public static void cleanUpPreKeys(@NonNull Context context) {
File preKeyDirectory = getPreKeyDirectory(context);
File[] preKeyFiles = preKeyDirectory.listFiles();
if (preKeyFiles != null) {
for (File preKeyFile : preKeyFiles) {
Log.i(TAG, "Deleting: " + preKeyFile.getAbsolutePath());
preKeyFile.delete();
}
Log.i(TAG, "Deleting: " + preKeyDirectory.getAbsolutePath());
preKeyDirectory.delete();
}
File signedPreKeyDirectory = getSignedPreKeyDirectory(context);
File[] signedPreKeyFiles = signedPreKeyDirectory.listFiles();
if (signedPreKeyFiles != null) {
for (File signedPreKeyFile : signedPreKeyFiles) {
Log.i(TAG, "Deleting: " + signedPreKeyFile.getAbsolutePath());
signedPreKeyFile.delete();
}
Log.i(TAG, "Deleting: " + signedPreKeyDirectory.getAbsolutePath());
signedPreKeyDirectory.delete();
}
}
private static byte[] loadSerializedRecord(File recordFile)
throws IOException, InvalidMessageException
{
FileInputStream fin = new FileInputStream(recordFile);
int recordVersion = readInteger(fin);
if (recordVersion > CURRENT_VERSION_MARKER) {
throw new IOException("Invalid version: " + recordVersion);
}
byte[] serializedRecord = readBlob(fin);
if (recordVersion < PLAINTEXT_VERSION) {
throw new IOException("Migration didn't happen! " + recordFile.getAbsolutePath() + ", " + recordVersion);
}
fin.close();
return serializedRecord;
}
private static File getPreKeyDirectory(Context context) {
return getRecordsDirectory(context, PREKEY_DIRECTORY);
}
private static File getSignedPreKeyDirectory(Context context) {
return getRecordsDirectory(context, SIGNED_PREKEY_DIRECTORY);
}
private static File getRecordsDirectory(Context context, String directoryName) {
File directory = new File(context.getFilesDir(), directoryName);
if (!directory.exists()) {
if (!directory.mkdirs()) {
Log.w(TAG, "PreKey directory creation failed!");
}
}
return directory;
}
private static byte[] readBlob(FileInputStream in) throws IOException {
int length = readInteger(in);
byte[] blobBytes = new byte[length];
in.read(blobBytes, 0, blobBytes.length);
return blobBytes;
}
private static int readInteger(FileInputStream in) throws IOException {
byte[] integer = new byte[4];
in.read(integer, 0, integer.length);
return Conversions.byteArrayToInt(integer);
}
private static class PreKeyIndex {
static final String FILE_NAME = "index.dat";
@JsonProperty
private int nextPreKeyId;
public PreKeyIndex() {}
}
private static class SignedPreKeyIndex {
static final String FILE_NAME = "index.dat";
@JsonProperty
private int nextSignedPreKeyId;
@JsonProperty
private int activeSignedPreKeyId = -1;
public SignedPreKeyIndex() {}
}
}
@@ -1,91 +0,0 @@
package org.thoughtcrime.securesms.database.helpers;
import android.database.Cursor;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.SQLiteDatabase;
import org.thoughtcrime.securesms.util.DelimiterUtil;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;
public class RecipientIdCleanupHelper {
private static final String TAG = Log.tag(RecipientIdCleanupHelper.class);
public static void execute(@NonNull SQLiteDatabase db) {
Log.i(TAG, "Beginning migration.");
long startTime = System.currentTimeMillis();
Pattern pattern = Pattern.compile("^[0-9\\-+]+$");
Set<String> deletionCandidates = new HashSet<>();
try (Cursor cursor = db.query("recipient", new String[] { "_id", "phone" }, "group_id IS NULL AND email IS NULL", null, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
String id = cursor.getString(cursor.getColumnIndexOrThrow("_id"));
String phone = cursor.getString(cursor.getColumnIndexOrThrow("phone"));
if (TextUtils.isEmpty(phone) || !pattern.matcher(phone).matches()) {
Log.i(TAG, "Recipient ID " + id + " has non-numeric characters and can potentially be deleted.");
if (!isIdUsed(db, "identities", "address", id) &&
!isIdUsed(db, "sessions", "address", id) &&
!isIdUsed(db, "thread", "recipient_ids", id) &&
!isIdUsed(db, "sms", "address", id) &&
!isIdUsed(db, "mms", "address", id) &&
!isIdUsed(db, "mms", "quote_author", id) &&
!isIdUsed(db, "group_receipts", "address", id) &&
!isIdUsed(db, "groups", "recipient_id", id))
{
Log.i(TAG, "Determined ID " + id + " is unused in non-group membership. Marking for potential deletion.");
deletionCandidates.add(id);
} else {
Log.i(TAG, "Found that ID " + id + " is actually used in another table.");
}
}
}
}
Set<String> deletions = findUnusedInGroupMembership(db, deletionCandidates);
for (String deletion : deletions) {
Log.i(TAG, "Deleting ID " + deletion);
db.delete("recipient", "_id = ?", new String[] { String.valueOf(deletion) });
}
Log.i(TAG, "Migration took " + (System.currentTimeMillis() - startTime) + " ms.");
}
private static boolean isIdUsed(@NonNull SQLiteDatabase db, @NonNull String tableName, @NonNull String columnName, String id) {
try (Cursor cursor = db.query(tableName, new String[] { columnName }, columnName + " = ?", new String[] { id }, null, null, null, "1")) {
boolean used = cursor != null && cursor.moveToFirst();
if (used) {
Log.i(TAG, "Recipient " + id + " was used in (" + tableName + ", " + columnName + ")");
}
return used;
}
}
private static Set<String> findUnusedInGroupMembership(@NonNull SQLiteDatabase db, Set<String> candidates) {
Set<String> unused = new HashSet<>(candidates);
try (Cursor cursor = db.rawQuery("SELECT members FROM groups", null)) {
while (cursor != null && cursor.moveToNext()) {
String serializedMembers = cursor.getString(cursor.getColumnIndexOrThrow("members"));
String[] members = DelimiterUtil.split(serializedMembers, ',');
for (String member : members) {
if (unused.remove(member)) {
Log.i(TAG, "Recipient " + member + " was found in a group membership list.");
}
}
}
}
return unused;
}
}

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