mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-10 10:10:10 +01:00
Compare commits
306 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b98b03971 | |||
| dfbdf30535 | |||
| d567555047 | |||
| 7658f6c36c | |||
| 51bd2d51c6 | |||
| a00978d96e | |||
| b700529c3b | |||
| 4051cf739c | |||
| 6031fc9113 | |||
| 454fe86dda | |||
| 92927ec69b | |||
| 9fa587b7e4 | |||
| 552361dff4 | |||
| 78a25a6186 | |||
| 58fcc07578 | |||
| 8cd92a400c | |||
| 5d207932c9 | |||
| 7c147982c4 | |||
| bde1a94122 | |||
| 2b66d7485a | |||
| 017b902c3c | |||
| 357fbfa8aa | |||
| 0ce667f4af | |||
| c4d78243c8 | |||
| 51e12b2c76 | |||
| 4dea1d8aa1 | |||
| 89c645dea3 | |||
| cd01d5f0b7 | |||
| 8730e28282 | |||
| 82046dd55f | |||
| 76e30ab09f | |||
| f680256f1d | |||
| da590a3241 | |||
| 91f73b473f | |||
| 53023517b3 | |||
| 7f831e6806 | |||
| 77a18111e1 | |||
| 2a699a23dd | |||
| 5643ffc1a9 | |||
| 90207b7dd7 | |||
| 5b7f668251 | |||
| 798bf3ec3e | |||
| 1c77c9d3fb | |||
| dd52d78ee0 | |||
| 4b1acca119 | |||
| 195fe60927 | |||
| f427f31303 | |||
| fa19ed7ffc | |||
| e5e99d4e03 | |||
| 26d1a7ada7 | |||
| 5dd11e26e4 | |||
| 9877b13c6e | |||
| d7d0fd3622 | |||
| 2439506c05 | |||
| 6088024f76 | |||
| 9decd81cfc | |||
| f27773a4e3 | |||
| 8d8c974a19 | |||
| 1a3e81dcb0 | |||
| d5f85c0661 | |||
| 91458f2702 | |||
| 6650ffc2c6 | |||
| ab0102a372 | |||
| a797bbf850 | |||
| 3804890265 | |||
| fcdbf93626 | |||
| f1b61f8f7e | |||
| ce582249ec | |||
| b21a72153a | |||
| 2a8bd20bb0 | |||
| c30e3cc1b7 | |||
| 5fedd81921 | |||
| 24069dc42e | |||
| ff15c8417a | |||
| cbf770d3ea | |||
| 676ab1ab6f | |||
| 9cc47942f2 | |||
| 45e6e06c01 | |||
| d2243707b5 | |||
| 48cd1c1da0 | |||
| 330a5aece2 | |||
| 8c4f614d17 | |||
| f40bcb73fa | |||
| 905a6f1a6b | |||
| 8f78471849 | |||
| 82df20190d | |||
| 7f6e96a522 | |||
| eded335766 | |||
| 7e4736969c | |||
| 78940ffc17 | |||
| 086883e565 | |||
| e9cdf0368e | |||
| 7be273f461 | |||
| e6cbb0073c | |||
| 469421fcf3 | |||
| 6d6d277277 | |||
| 8a5faba985 | |||
| 7aadc208e1 | |||
| 3c68e29679 | |||
| 4756b8d70b | |||
| c2d927029a | |||
| 629b96dd20 | |||
| 01705459cf | |||
| c449f72786 | |||
| 773d6c36dc | |||
| b4bfb67a44 | |||
| 3165c854df | |||
| f5cb1b0efa | |||
| 179908fba6 | |||
| d6ec4bfbd3 | |||
| 237ac9f94a | |||
| 66f69854cf | |||
| 8f47592fc0 | |||
| 3ea7bf77e0 | |||
| 2b67b1c44f | |||
| ebccc6db30 | |||
| 98d9b12438 | |||
| 5db8463c70 | |||
| 813252989b | |||
| 0319adbce4 | |||
| de584ccb7d | |||
| bd89c7fc39 | |||
| bef4bb40ca | |||
| b57d922cdf | |||
| 8c1cc03c6f | |||
| f0109f3e6b | |||
| ed89f3a78e | |||
| faa6a1d3f0 | |||
| 969635d942 | |||
| 7665ae1464 | |||
| 9c18e3698e | |||
| df406633ff | |||
| d121f9402b | |||
| 5310c19b99 | |||
| cd92feb2b7 | |||
| 3b603f08ed | |||
| 281f062b29 | |||
| b054a7eb76 | |||
| 33b9c88ecd | |||
| 253d36ae13 | |||
| 8306f8ec5b | |||
| 69b6d7ef9a | |||
| aeeba3d2df | |||
| dfd2f7baf9 | |||
| 5de17a971d | |||
| 001896d244 | |||
| 1844b128e1 | |||
| 08623cc0c4 | |||
| f93a948169 | |||
| 76476191be | |||
| d00bb28ee4 | |||
| 453e5bede7 | |||
| c7c108bd77 | |||
| fb81574d35 | |||
| e6d3de091c | |||
| 99b8a6020d | |||
| 88b21b6113 | |||
| 256ee9b1aa | |||
| e2feaaf74c | |||
| 17def87c17 | |||
| d90e9919ae | |||
| 38baf17938 | |||
| 3f7707985f | |||
| a61072b249 | |||
| 80ff64ddd3 | |||
| 95c0467bda | |||
| ff88d259fd | |||
| 6e747019d4 | |||
| 9e7a40a63d | |||
| 38eed43046 | |||
| 4c76cb682e | |||
| c47adb7482 | |||
| 3c2ccef9a8 | |||
| fb0c4757f2 | |||
| b8b9a632b5 | |||
| 9b4a13a491 | |||
| 1cdd49721d | |||
| 8b895738c0 | |||
| 6ab3cd3390 | |||
| 11c8a726ec | |||
| 264447a6d9 | |||
| a7bb2831f8 | |||
| e05586a1c9 | |||
| 0e8dedf4d0 | |||
| 0e11a1fe3e | |||
| f1ebd2dc81 | |||
| 8ea90c8a43 | |||
| 6456dcf657 | |||
| bb151c91e9 | |||
| ce6f39ae68 | |||
| 58e8ea08c2 | |||
| 4dd74d9ab4 | |||
| 3ef3a516b3 | |||
| 518a81c7fa | |||
| f81325e7ca | |||
| cc847cb229 | |||
| 7320a0ef46 | |||
| 7c45686440 | |||
| 8b5b83e974 | |||
| a4a3861398 | |||
| 01bdaaea84 | |||
| 1f02fba696 | |||
| aeb9054a63 | |||
| bb33945a93 | |||
| 3d2ceef47f | |||
| 892e6bd853 | |||
| 78e1a407a6 | |||
| 48d766ecff | |||
| d6d3226fcd | |||
| ed4944f806 | |||
| eb2dfb3fb6 | |||
| 265f71dff3 | |||
| 01d1769e4c | |||
| 97d099c7f1 | |||
| 0a957bc97c | |||
| 5df7552506 | |||
| 75334abe0f | |||
| 8524d20de5 | |||
| 495e2e043e | |||
| dec9eb613e | |||
| d6e7030dd0 | |||
| 6e43e931b2 | |||
| 430a55f89f | |||
| d717aad03d | |||
| efd86ad2fc | |||
| b284835545 | |||
| 4dd30f4ec3 | |||
| a48938f3d8 | |||
| 01989ad6e7 | |||
| f37f67c6c0 | |||
| 36f7c60a99 | |||
| 3f067654d9 | |||
| 0ce3eab3cd | |||
| b0f7c36cc2 | |||
| 966e208be5 | |||
| a80d353e04 | |||
| 080fa88bfb | |||
| 172e3d129e | |||
| 52d5947c0a | |||
| 7334ebfce1 | |||
| 2c98bbaf7e | |||
| 5a91dba56e | |||
| 535c5a1574 | |||
| b61c54c0e2 | |||
| 5ac5d45fc6 | |||
| 79ba929e70 | |||
| 3e9146a6f5 | |||
| 0c4c280a50 | |||
| ebea499a5a | |||
| d6b39e9f0a | |||
| 787eaee6a0 | |||
| 5ecb3d8832 | |||
| b2e8666c9f | |||
| 8af41e4b2c | |||
| 5eaf1000c8 | |||
| 4ed6773983 | |||
| 0de0441f65 | |||
| 9e1b4a9a8c | |||
| bf28b90e89 | |||
| a0a962a94f | |||
| abe0b2ebca | |||
| 7b4fe7ff40 | |||
| 1ba9793943 | |||
| 14d4228e86 | |||
| 3d2c51c14b | |||
| 72d75e9cd5 | |||
| e125fa6bfb | |||
| 57574126bb | |||
| 833c81a99e | |||
| 5ca17dfe52 | |||
| 5e058bb655 | |||
| ce87b50a07 | |||
| 2ad14800d1 | |||
| f04a0533cb | |||
| 5ae51f844e | |||
| 4ce2c6ef73 | |||
| 4442f26f53 | |||
| 849fce5a89 | |||
| 482fce6a25 | |||
| e7e69ab064 | |||
| 4b768419da | |||
| 2cca01d30f | |||
| e0c69dc485 | |||
| 1dd79efdb2 | |||
| dbb3c8def9 | |||
| 562185f46d | |||
| f6c7c6de73 | |||
| 1ca3a9ca73 | |||
| c76c3f65f2 | |||
| 59c27797d6 | |||
| c5c720b1c9 | |||
| caa09c82d0 | |||
| d45f80f25d | |||
| 6a248f617a | |||
| 2959e05ea7 | |||
| 17faf56388 | |||
| f533ad1533 | |||
| 25452fefa5 | |||
| 9702728c19 | |||
| 43f19d14d8 | |||
| 467c154ea6 | |||
| d72c742ab6 | |||
| 567bf0facc | |||
| d5329d0794 | |||
| ff04e5c5c3 | |||
| e529fbd1bc |
@@ -17,7 +17,7 @@ body:
|
||||
label: "Guidelines"
|
||||
description: "Search issues here: https://github.com/signalapp/Signal-Android/issues/?q=is%3Aissue+"
|
||||
options:
|
||||
- label: I have searched searched open and closed issues for duplicates
|
||||
- label: I have searched open and closed issues for duplicates
|
||||
required: true
|
||||
- label: I am submitting a bug report for existing functionality that does not work as intended
|
||||
required: true
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Automatically keep GitHub Actions SHA-pinned to the latest commit SHAs.
|
||||
# Dependabot will update both the SHA and the inline version comment (e.g. # v6)
|
||||
# while leaving any extra documentation comments intact.
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
labels:
|
||||
- "dependencies"
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "actions/*"
|
||||
gradle-actions:
|
||||
patterns:
|
||||
- "gradle/*"
|
||||
peter-evans:
|
||||
patterns:
|
||||
- "peter-evans/*"
|
||||
usefulness:
|
||||
patterns:
|
||||
- "usefulness/*"
|
||||
@@ -1,23 +0,0 @@
|
||||
# Number of days of inactivity before an Issue or Pull Request becomes stale
|
||||
daysUntilStale: 60
|
||||
|
||||
# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
|
||||
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
|
||||
daysUntilClose: 7
|
||||
|
||||
issues:
|
||||
exemptLabels:
|
||||
- acknowledged
|
||||
|
||||
# Comment to post when marking as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
|
||||
# Comment to post when closing a stale Issue or Pull Request.
|
||||
closeComment: >
|
||||
This issue has been closed due to inactivity.
|
||||
|
||||
# Limit the number of actions per hour, from 1-30. Default is 30
|
||||
limitPerRun: 1
|
||||
@@ -16,26 +16,30 @@ jobs:
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
|
||||
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: gradle
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@v5
|
||||
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
|
||||
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew qa
|
||||
|
||||
- name: Archive reports for failed build
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
# gh api repos/actions/upload-artifact/commits/v7 --jq '.sha'
|
||||
with:
|
||||
name: reports
|
||||
path: '*/build/reports'
|
||||
|
||||
@@ -14,15 +14,17 @@ jobs:
|
||||
assemble-base:
|
||||
if: ${{ github.repository != 'signalapp/Signal-Android' }}
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
with:
|
||||
submodules: true
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- name: set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
|
||||
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
@@ -32,11 +34,13 @@ jobs:
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;${{ env.NDK_VERSION }}"
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@v5
|
||||
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
|
||||
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
|
||||
|
||||
- name: Cache base apk
|
||||
id: cache-base
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
# gh api repos/actions/cache/commits/v5 --jq '.sha'
|
||||
with:
|
||||
path: diffuse-base.apk
|
||||
key: diffuse-${{ github.event.pull_request.base.sha }}
|
||||
@@ -49,7 +53,8 @@ jobs:
|
||||
if: steps.cache-base.outputs.cache-hit != 'true'
|
||||
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-base.apk
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
with:
|
||||
submodules: true
|
||||
clean: 'false'
|
||||
@@ -61,18 +66,21 @@ jobs:
|
||||
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-new.apk
|
||||
|
||||
- id: diffuse
|
||||
uses: usefulness/diffuse-action@v1
|
||||
uses: usefulness/diffuse-action@41995fe8ff6be0a8847e63bdc5a4679c704b455c # v1
|
||||
# gh api repos/usefulness/diffuse-action/commits/v1 --jq '.sha'
|
||||
with:
|
||||
old-file-path: diffuse-base.apk
|
||||
new-file-path: diffuse-new.apk
|
||||
|
||||
- uses: peter-evans/find-comment@v2
|
||||
- uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4
|
||||
# gh api repos/peter-evans/find-comment/commits/v4 --jq '.sha'
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body-includes: Diffuse output
|
||||
|
||||
- uses: peter-evans/create-or-update-comment@v3
|
||||
- uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5
|
||||
# gh api repos/peter-evans/create-or-update-comment/commits/v5 --jq '.sha'
|
||||
with:
|
||||
body: |
|
||||
Diffuse output:
|
||||
@@ -83,7 +91,8 @@ jobs:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
# gh api repos/actions/upload-artifact/commits/v7 --jq '.sha'
|
||||
with:
|
||||
name: diffuse-output
|
||||
path: ${{ steps.diffuse.outputs.diff-file }}
|
||||
|
||||
@@ -11,7 +11,8 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
- name: Build image
|
||||
run: |
|
||||
cd reproducible-builds
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Mark stale issues and PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # daily at 02:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
|
||||
# gh api repos/actions/stale/commits/v10 --jq '.sha'
|
||||
with:
|
||||
days-before-stale: 60
|
||||
days-before-close: 7
|
||||
exempt-issue-labels: 'acknowledged'
|
||||
exempt-pr-labels: 'acknowledged'
|
||||
stale-issue-label: 'wontfix'
|
||||
stale-pr-label: 'wontfix'
|
||||
stale-issue-message: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions!
|
||||
stale-pr-message: >
|
||||
This pull request has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions!
|
||||
close-issue-message: >
|
||||
This issue has been closed due to inactivity.
|
||||
close-pr-message: >
|
||||
This pull request has been closed due to inactivity.
|
||||
operations-per-run: 150
|
||||
@@ -1,6 +1,6 @@
|
||||
# Signal Android
|
||||
|
||||
Signal is a simple, powerful, and secure messenger that uses your phone's data connection (WiFi/3G/4G/5G) to communicate securely.
|
||||
Signal is a simple, powerful, and secure messenger that uses your phone's data connection (WiFi/4G/5G) to communicate securely.
|
||||
|
||||
Millions of people use Signal every day for free and instantaneous communication anywhere in the world. Send and receive high-fidelity messages, participate in HD voice/video calls, and explore a growing set of new features that help you stay connected.
|
||||
|
||||
@@ -63,7 +63,7 @@ The form and manner of this distribution makes it eligible for export under the
|
||||
|
||||
## License
|
||||
|
||||
Copyright 2013-2025 Signal Messenger, LLC
|
||||
Copyright 2013 Signal Messenger, LLC
|
||||
|
||||
Licensed under the GNU AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ plugins {
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
|
||||
val canonicalVersionCode = 1671
|
||||
val canonicalVersionName = "8.5.0"
|
||||
val canonicalVersionCode = 1683
|
||||
val canonicalVersionName = "8.9.0"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
@@ -597,6 +597,7 @@ dependencies {
|
||||
|
||||
implementation(project(":lib:archive"))
|
||||
implementation(project(":lib:libsignal-service"))
|
||||
implementation(project(":lib:network"))
|
||||
implementation(project(":lib:paging"))
|
||||
implementation(project(":core:util"))
|
||||
implementation(project(":lib:glide"))
|
||||
@@ -678,7 +679,6 @@ dependencies {
|
||||
implementation(libs.mobilecoin)
|
||||
implementation(libs.signal.ringrtc)
|
||||
implementation(libs.leolin.shortcutbadger)
|
||||
implementation(libs.emilsjolander.stickylistheaders)
|
||||
implementation(libs.glide.glide)
|
||||
implementation(libs.roundedimageview)
|
||||
implementation(libs.materialish.progress)
|
||||
@@ -689,10 +689,6 @@ dependencies {
|
||||
implementation(libs.subsampling.scale.image.view) {
|
||||
exclude(group = "com.android.support", module = "support-annotations")
|
||||
}
|
||||
implementation(libs.android.tooltips) {
|
||||
exclude(group = "com.android.support", module = "appcompat-v7")
|
||||
}
|
||||
implementation(libs.stream)
|
||||
implementation(libs.lottie)
|
||||
implementation(libs.lottie.compose)
|
||||
implementation(libs.signal.android.database.sqlcipher)
|
||||
|
||||
@@ -26454,61 +26454,6 @@
|
||||
column="7"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Recycle"
|
||||
message="This `Cursor` should be freed up after use with `#close()`"
|
||||
errorLine1=" Cursor mmsCursor = db.query("mms", new String[] {"_id"},"
|
||||
errorLine2=" ~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
|
||||
line="298"
|
||||
column="38"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Recycle"
|
||||
message="This `Cursor` should be freed up after use with `#close()`"
|
||||
errorLine1=" Cursor partCursor = db.query("part", new String[] {"_id", "ct", "_data", "encrypted"},"
|
||||
errorLine2=" ~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
|
||||
line="310"
|
||||
column="32"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Recycle"
|
||||
message="This `Cursor` should be freed up after use with `#close()`"
|
||||
errorLine1=" Cursor threadCursor = db.query("thread", new String[] {"_id"}, null, null, null, null, null);"
|
||||
errorLine2=" ~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
|
||||
line="708"
|
||||
column="32"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Recycle"
|
||||
message="This `Cursor` should be freed up after use with `#close()`"
|
||||
errorLine1=" Cursor cursor = db.rawQuery("SELECT DISTINCT date AS date_received, status, " +"
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
|
||||
line="713"
|
||||
column="28"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Recycle"
|
||||
message="This `Cursor` should be freed up after use with `#close()`"
|
||||
errorLine1=" cursor = db.query("mms", new String[] {"_id", "network_failures"}, "network_failures IS NOT NULL", null, null, null, null);"
|
||||
errorLine2=" ~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
|
||||
line="1037"
|
||||
column="19"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="ObsoleteSdkInt"
|
||||
message="Unnecessary; SDK_INT is always >= 21"
|
||||
|
||||
+2
-2
@@ -359,8 +359,8 @@ class V2ConversationItemShapeTest {
|
||||
|
||||
override fun onViewPinnedMessage(messageId: Long) = Unit
|
||||
|
||||
override fun onExpandEvents(messageId: Long) = Unit
|
||||
override fun onExpandEvents(messageId: Long, itemView: View, collapsedSize: Int) = Unit
|
||||
|
||||
override fun onCollapseEvents(messageId: Long) = Unit
|
||||
override fun onCollapseEvents(messageId: Long, itemView: View, collapsedSize: Int) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.util.CursorUtil
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ThreadTableTest_recents {
|
||||
|
||||
@Rule
|
||||
@JvmField
|
||||
val databaseRule = SignalDatabaseRule()
|
||||
|
||||
private lateinit var recipient: Recipient
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenARecentRecipient_whenIBlockAndGetRecents_thenIDoNotExpectToSeeThatRecipient() {
|
||||
// GIVEN
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
MmsHelper.insert(recipient = recipient, threadId = threadId)
|
||||
SignalDatabase.threads.update(threadId, true)
|
||||
|
||||
// WHEN
|
||||
SignalDatabase.recipients.setBlocked(recipient.id, true)
|
||||
val results: MutableList<RecipientId> = SignalDatabase.threads.getRecentConversationList(10, false, false, false, false, false, false).use { cursor ->
|
||||
val ids = mutableListOf<RecipientId>()
|
||||
while (cursor.moveToNext()) {
|
||||
ids.add(RecipientId.from(CursorUtil.requireLong(cursor, ThreadTable.RECIPIENT_ID)))
|
||||
}
|
||||
|
||||
ids
|
||||
}
|
||||
|
||||
// THEN
|
||||
assertFalse(recipient.id in results)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -4,12 +4,12 @@ import android.app.Application
|
||||
import io.mockk.mockk
|
||||
import io.mockk.spyk
|
||||
import org.signal.core.util.billing.BillingApi
|
||||
import org.signal.network.api.ArchiveApi
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
|
||||
import org.whispersystems.signalservice.api.SignalServiceDataStore
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageSender
|
||||
import org.whispersystems.signalservice.api.account.AccountApi
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveApi
|
||||
import org.whispersystems.signalservice.api.attachment.AttachmentApi
|
||||
import org.whispersystems.signalservice.api.donations.DonationsApi
|
||||
import org.whispersystems.signalservice.api.keys.KeysApi
|
||||
|
||||
+1
-1
@@ -316,7 +316,7 @@ class DataMessageProcessorTest_polls {
|
||||
|
||||
private fun insertPoll(allowMultiple: Boolean = true): Long {
|
||||
val envelope = MessageContentFuzzer.envelope(100)
|
||||
val pollMessage = IncomingMessage(type = MessageType.NORMAL, from = alice.id, sentTimeMillis = envelope.timestamp!!, serverTimeMillis = envelope.serverTimestamp!!, receivedTimeMillis = 0, groupId = groupId)
|
||||
val pollMessage = IncomingMessage(type = MessageType.NORMAL, from = alice.id, sentTimeMillis = envelope.clientTimestamp!!, serverTimeMillis = envelope.serverTimestamp!!, receivedTimeMillis = 0, groupId = groupId)
|
||||
val messageId = SignalDatabase.messages.insertMessageInbox(pollMessage).get()
|
||||
SignalDatabase.polls.insertPoll("question?", allowMultiple, listOf("a", "b", "c"), alice.id.toLong(), messageId.messageId)
|
||||
return messageId.messageId
|
||||
|
||||
@@ -43,7 +43,7 @@ object MessageContentFuzzer {
|
||||
*/
|
||||
fun envelope(timestamp: Long, serverGuid: UUID = UUID.randomUUID()): Envelope {
|
||||
return Envelope.Builder()
|
||||
.timestamp(timestamp)
|
||||
.clientTimestamp(timestamp)
|
||||
.serverTimestamp(timestamp + 5)
|
||||
.serverGuidBinary(serverGuid.toByteArray().toByteString())
|
||||
.build()
|
||||
@@ -292,7 +292,7 @@ object MessageContentFuzzer {
|
||||
body = string()
|
||||
val quoted = quoteAble.random(random)
|
||||
quote = DataMessage.Quote.Builder().buildWith {
|
||||
id = quoted.envelope.timestamp
|
||||
id = quoted.envelope.clientTimestamp
|
||||
authorAciBinary = quoted.metadata.sourceServiceId.toByteString()
|
||||
text = quoted.content.dataMessage?.body
|
||||
attachments(quoted.content.dataMessage?.attachments ?: emptyList())
|
||||
@@ -304,7 +304,7 @@ object MessageContentFuzzer {
|
||||
if (random.nextFloat() < 0.1 && quoteAble.isNotEmpty()) {
|
||||
val quoted = quoteAble.random(random)
|
||||
quote = DataMessage.Quote.Builder().buildWith {
|
||||
id = random.nextLong(quoted.envelope.timestamp!! - 1000000, quoted.envelope.timestamp!!)
|
||||
id = random.nextLong(quoted.envelope.clientTimestamp!! - 1000000, quoted.envelope.clientTimestamp!!)
|
||||
authorAciBinary = quoted.metadata.sourceServiceId.toByteString()
|
||||
text = quoted.content.dataMessage?.body
|
||||
}
|
||||
@@ -333,7 +333,7 @@ object MessageContentFuzzer {
|
||||
emoji = emojis.random(random)
|
||||
remove = false
|
||||
targetAuthorAciBinary = reactTo.metadata.sourceServiceId.toByteString()
|
||||
targetSentTimestamp = reactTo.envelope.timestamp
|
||||
targetSentTimestamp = reactTo.envelope.clientTimestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,8 +75,8 @@ object MockProvider {
|
||||
val device = PreKeyResponseItem().apply {
|
||||
this.deviceId = deviceId
|
||||
registrationId = KeyHelper.generateRegistrationId(false)
|
||||
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
preKey = PreKeyEntity(oneTimePreKey.id, oneTimePreKey.keyPair.publicKey)
|
||||
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id.toLong(), signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
preKey = PreKeyEntity(oneTimePreKey.id.toLong(), oneTimePreKey.keyPair.publicKey)
|
||||
}
|
||||
|
||||
return PreKeyResponse().apply {
|
||||
|
||||
@@ -212,7 +212,7 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
|
||||
verb = "PUT",
|
||||
path = "/api/v1/message",
|
||||
id = Random.nextLong(),
|
||||
headers = listOf("X-Signal-Timestamp: ${this.timestamp}"),
|
||||
headers = listOf("X-Signal-Timestamp: ${this.serverTimestamp}"),
|
||||
body = this.encodeByteString()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@ object Generator {
|
||||
val serverGuid = UUID.randomUUID()
|
||||
return Envelope.Builder()
|
||||
.type(Envelope.Type.fromValue(this.type))
|
||||
.sourceDevice(1)
|
||||
.timestamp(timestamp)
|
||||
.sourceDeviceId(1)
|
||||
.clientTimestamp(timestamp)
|
||||
.serverTimestamp(timestamp + 1)
|
||||
.destinationServiceId(destination.toString())
|
||||
.destinationServiceIdBinary(destination.toByteString())
|
||||
|
||||
+2
-2
@@ -354,11 +354,11 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onExpandEvents(messageId: Long) {
|
||||
override fun onExpandEvents(messageId: Long, itemView: View, collapsedSize: Int) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onCollapseEvents(messageId: Long) {
|
||||
override fun onCollapseEvents(messageId: Long, itemView: View, collapsedSize: Int) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,7 +482,7 @@
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
|
||||
|
||||
<activity
|
||||
android:name="org.signal.mediasend.MediaSendActivity"
|
||||
android:name=".mediasend.v3.MediaSendV3Activity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -62,6 +62,7 @@ import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
|
||||
import org.thoughtcrime.securesms.jobs.BackupRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.BackupSubscriptionCheckJob;
|
||||
import org.thoughtcrime.securesms.jobs.BuildExpirationConfirmationJob;
|
||||
import org.thoughtcrime.securesms.jobs.CallingAssetsDownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.CheckKeyTransparencyJob;
|
||||
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
|
||||
@@ -102,12 +103,14 @@ import org.thoughtcrime.securesms.service.MessageBackupListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
|
||||
import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
|
||||
import org.thoughtcrime.securesms.service.webrtc.CallingAssets;
|
||||
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.DeviceProperties;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.Environment;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
|
||||
@@ -214,7 +217,6 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
.addNonBlocking(this::ensureProfileUploaded)
|
||||
.addNonBlocking(() -> AppDependencies.getExpireStoriesManager().scheduleIfNecessary())
|
||||
.addNonBlocking(BackupRepository::maybeFixAnyDanglingUploadProgress)
|
||||
.addNonBlocking(BackupRepository::maybeFixAnyDanglingLocalExportProgress)
|
||||
.addPostRender(() -> AppDependencies.getDeletedCallEventManager().scheduleIfNecessary())
|
||||
.addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this))
|
||||
.addPostRender(this::initializeExpiringMessageManager)
|
||||
@@ -227,6 +229,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
|
||||
.addPostRender(AndroidTelecomUtil::registerPhoneAccount)
|
||||
.addPostRender(() -> AppDependencies.getJobManager().add(new FontDownloaderJob()))
|
||||
.addPostRender(() -> AppDependencies.getJobManager().add(new CallingAssetsDownloadJob()))
|
||||
.addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary)
|
||||
.addPostRender(GroupV2UpdateSelfProfileKeyJob::enqueueForGroupsIfNecessary)
|
||||
.addPostRender(StoryOnboardingDownloadJob.Companion::enqueueIfNeeded)
|
||||
@@ -401,6 +404,20 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
AppDependencies.init(this, new ApplicationDependencyProvider(this));
|
||||
}
|
||||
AppForegroundObserver.begin();
|
||||
|
||||
if (Environment.USE_NEW_REGISTRATION) {
|
||||
initializeRegistrationDependencies();
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeRegistrationDependencies() {
|
||||
org.signal.registration.RegistrationDependencies.Companion.provide(
|
||||
new org.signal.registration.RegistrationDependencies(
|
||||
new org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
|
||||
new org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController(this),
|
||||
null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private void initializeFirstEverAppLaunch() {
|
||||
|
||||
@@ -148,7 +148,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void onViewPollClicked(long messageId);
|
||||
void onToggleVote(@NonNull PollRecord poll, @NonNull PollOption pollOption, Boolean isChecked);
|
||||
void onViewPinnedMessage(long messageId);
|
||||
void onExpandEvents(long messageId);
|
||||
void onCollapseEvents(long messageId);
|
||||
void onExpandEvents(long messageId, @NonNull View itemView, int collapsedSize);
|
||||
void onCollapseEvents(long messageId, @NonNull View itemView, int collapsedSize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
import androidx.transition.AutoTransition;
|
||||
import androidx.transition.TransitionManager;
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
@@ -60,10 +63,12 @@ import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContacts;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchCallbacks;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
|
||||
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
|
||||
@@ -74,6 +79,7 @@ import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.signal.core.ui.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository;
|
||||
import org.thoughtcrime.securesms.search.SearchRepository;
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameAciFetchResult;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
@@ -86,7 +92,9 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -117,7 +125,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
private OnContactSelectedListener onContactSelectedListener;
|
||||
private SwipeRefreshLayout swipeRefresh;
|
||||
private String cursorFilter;
|
||||
private RecyclerView recyclerView;
|
||||
private ContactSearchView contactSearchView;
|
||||
private RecyclerViewFastScroller fastScroller;
|
||||
private RecyclerView chipRecycler;
|
||||
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
|
||||
@@ -126,8 +134,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
private LifecycleDisposable lifecycleDisposable;
|
||||
private HeaderActionProvider headerActionProvider;
|
||||
private TextView headerActionView;
|
||||
private ContactSearchMediator contactSearchMediator;
|
||||
private ContactSearchViewModel contactSearchViewModel;
|
||||
|
||||
@Nullable private RecyclerView innerRecyclerView;
|
||||
@Nullable private LinearLayoutManager innerLayoutManager;
|
||||
@Nullable private NewConversationCallback newConversationCallback;
|
||||
@Nullable private FindByCallback findByCallback;
|
||||
@Nullable private NewCallCallback newCallCallback;
|
||||
@@ -238,7 +248,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
handleContactPermissionGranted();
|
||||
} else {
|
||||
requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
|
||||
contactSearchMediator.refresh();
|
||||
contactSearchViewModel.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,29 +256,14 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false);
|
||||
|
||||
emptyText = view.findViewById(android.R.id.empty);
|
||||
recyclerView = view.findViewById(R.id.recycler_view);
|
||||
swipeRefresh = view.findViewById(R.id.swipe_refresh);
|
||||
emptyText = view.findViewById(android.R.id.empty);
|
||||
contactSearchView = view.findViewById(R.id.recycler_view);
|
||||
swipeRefresh = view.findViewById(R.id.swipe_refresh);
|
||||
fastScroller = view.findViewById(R.id.fast_scroller);
|
||||
chipRecycler = view.findViewById(R.id.chipRecycler);
|
||||
constraintLayout = view.findViewById(R.id.container);
|
||||
headerActionView = view.findViewById(R.id.header_action);
|
||||
|
||||
final LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
|
||||
|
||||
recyclerView.setLayoutManager(layoutManager);
|
||||
recyclerView.setItemAnimator(new DefaultItemAnimator() {
|
||||
@Override
|
||||
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
recyclerView.setAlpha(1f);
|
||||
}
|
||||
});
|
||||
|
||||
contactChipViewModel = new ViewModelProvider(this).get(ContactChipViewModel.class);
|
||||
contactChipAdapter = new MappingAdapter();
|
||||
lifecycleDisposable = new LifecycleDisposable();
|
||||
@@ -283,12 +278,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
|
||||
fragmentArgs = ContactSelectionArguments.fromBundle(safeArguments(), requireActivity().getIntent());
|
||||
|
||||
if (fragmentArgs.getRecyclerPadBottom() != -1) {
|
||||
ViewUtil.setPaddingBottom(recyclerView, fragmentArgs.getRecyclerPadBottom());
|
||||
}
|
||||
|
||||
recyclerView.setClipToPadding(fragmentArgs.getRecyclerChildClipping());
|
||||
|
||||
swipeRefresh.setNestedScrollingEnabled(fragmentArgs.isRefreshable());
|
||||
swipeRefresh.setEnabled(fragmentArgs.isRefreshable());
|
||||
|
||||
@@ -302,6 +291,26 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
|
||||
currentSelection = getCurrentSelection();
|
||||
|
||||
Set<ContactSearchKey> fixedContacts = currentSelection.stream()
|
||||
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
contactSearchViewModel = new ViewModelProvider(
|
||||
this,
|
||||
new ContactSearchViewModel.Factory(
|
||||
selectionLimit,
|
||||
isMulti,
|
||||
new ContactSearchRepository(),
|
||||
false,
|
||||
new ContactSelectionListAdapter.ArbitraryRepository(),
|
||||
new SearchRepository(requireContext().getString(R.string.note_to_self)),
|
||||
new ContactSearchPagedDataSourceRepository(requireContext()),
|
||||
fixedContacts
|
||||
)
|
||||
).get(ContactSearchViewModel.class);
|
||||
|
||||
List<RecyclerView.OnScrollListener> scrollListeners = new ArrayList<>();
|
||||
|
||||
final HeaderAction headerAction;
|
||||
if (headerActionProvider != null) {
|
||||
headerAction = headerActionProvider.getHeaderAction();
|
||||
@@ -310,24 +319,20 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
headerActionView.setText(headerAction.getLabel());
|
||||
headerActionView.setCompoundDrawablesRelativeWithIntrinsicBounds(headerAction.getIcon(), 0, 0, 0);
|
||||
headerActionView.setOnClickListener(v -> headerAction.getAction().run());
|
||||
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
|
||||
scrollListeners.add(new RecyclerView.OnScrollListener() {
|
||||
|
||||
private final Rect bounds = new Rect();
|
||||
|
||||
@Override
|
||||
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
|
||||
if (hideLetterHeaders()) {
|
||||
public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) {
|
||||
if (hideLetterHeaders() || innerLayoutManager == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
int firstPosition = layoutManager.findFirstVisibleItemPosition();
|
||||
int firstPosition = innerLayoutManager.findFirstVisibleItemPosition();
|
||||
if (firstPosition == 0) {
|
||||
View firstChild = recyclerView.getChildAt(0);
|
||||
recyclerView.getDecoratedBoundsWithMargins(firstChild, bounds);
|
||||
View firstChild = rv.getChildAt(0);
|
||||
rv.getDecoratedBoundsWithMargins(firstChild, bounds);
|
||||
headerActionView.setTranslationY(bounds.top);
|
||||
}
|
||||
}
|
||||
@@ -336,13 +341,104 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
headerActionView.setEnabled(false);
|
||||
}
|
||||
|
||||
contactSearchMediator = new ContactSearchMediator(
|
||||
this,
|
||||
currentSelection.stream()
|
||||
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
|
||||
.collect(java.util.stream.Collectors.toSet()),
|
||||
selectionLimit,
|
||||
isMulti,
|
||||
scrollListeners.add(new RecyclerView.OnScrollListener() {
|
||||
@Override
|
||||
public void onScrollStateChanged(@NonNull RecyclerView rv, int newState) {
|
||||
if (newState == RecyclerView.SCROLL_STATE_DRAGGING && scrollCallback != null) {
|
||||
scrollCallback.onBeginScroll();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
float contentBottomPaddingDp = fragmentArgs.getRecyclerPadBottom() != -1
|
||||
? fragmentArgs.getRecyclerPadBottom() / getResources().getDisplayMetrics().density
|
||||
: 0f;
|
||||
|
||||
ContactSearchAdapter.AdapterFactory adapterFactory =
|
||||
(context, fc, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) ->
|
||||
new ContactSelectionListAdapter(
|
||||
context,
|
||||
fc,
|
||||
displayOptions,
|
||||
new ContactSelectionListAdapter.OnContactSelectionClick() {
|
||||
@Override
|
||||
public void onDismissFindContactsBannerClicked() {
|
||||
SignalStore.uiHints().markDismissedContactsPermissionBanner();
|
||||
contactSearchViewModel.refresh();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindContactsClicked() {
|
||||
requestContactPermissions();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRefreshContactsClicked() {
|
||||
if (onRefreshListener != null && !isRefreshing()) {
|
||||
setRefreshing(true);
|
||||
onRefreshListener.onRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNewGroupClicked() {
|
||||
newConversationCallback.onNewGroup(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByPhoneNumberClicked() {
|
||||
findByCallback.onFindByPhoneNumber();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByUsernameClicked() {
|
||||
findByCallback.onFindByUsername();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInviteToSignalClicked() {
|
||||
if (newConversationCallback != null) {
|
||||
newConversationCallback.onInvite();
|
||||
}
|
||||
|
||||
if (newCallCallback != null) {
|
||||
newCallCallback.onInvite();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
|
||||
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
|
||||
callbacks.onExpandClicked(expand);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
|
||||
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
|
||||
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
|
||||
}
|
||||
},
|
||||
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
|
||||
storyContextMenuCallbacks,
|
||||
new CallButtonClickCallbacks()
|
||||
);
|
||||
|
||||
contactSearchView.bind(
|
||||
contactSearchViewModel,
|
||||
getChildFragmentManager(),
|
||||
new ContactSearchAdapter.DisplayOptions(
|
||||
isMulti,
|
||||
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
|
||||
@@ -350,96 +446,31 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
false
|
||||
),
|
||||
this::mapStateToConfiguration,
|
||||
new ContactSearchMediator.SimpleCallbacks() {
|
||||
new ContactSearchCallbacks.Simple() {
|
||||
@Override
|
||||
public void onAdapterListCommitted(int size) {
|
||||
onLoadFinished(size);
|
||||
}
|
||||
},
|
||||
false,
|
||||
(context, fixedContacts, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) -> new ContactSelectionListAdapter(
|
||||
context,
|
||||
fixedContacts,
|
||||
displayOptions,
|
||||
new ContactSelectionListAdapter.OnContactSelectionClick() {
|
||||
@Override
|
||||
public void onDismissFindContactsBannerClicked() {
|
||||
SignalStore.uiHints().markDismissedContactsPermissionBanner();
|
||||
if (onRefreshListener != null) {
|
||||
onRefreshListener.onRefresh();
|
||||
}
|
||||
}
|
||||
Collections.singletonList(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders)),
|
||||
contentBottomPaddingDp,
|
||||
adapterFactory,
|
||||
scrollListeners,
|
||||
rv -> {
|
||||
innerRecyclerView = rv;
|
||||
innerLayoutManager = (LinearLayoutManager) rv.getLayoutManager();
|
||||
rv.setItemAnimator(new DefaultItemAnimator() {
|
||||
@Override
|
||||
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindContactsClicked() {
|
||||
requestContactPermissions();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRefreshContactsClicked() {
|
||||
if (onRefreshListener != null && !isRefreshing()) {
|
||||
setRefreshing(true);
|
||||
onRefreshListener.onRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNewGroupClicked() {
|
||||
newConversationCallback.onNewGroup(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByPhoneNumberClicked() {
|
||||
findByCallback.onFindByPhoneNumber();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFindByUsernameClicked() {
|
||||
findByCallback.onFindByUsername();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInviteToSignalClicked() {
|
||||
if (newConversationCallback != null) {
|
||||
newConversationCallback.onInvite();
|
||||
}
|
||||
|
||||
if (newCallCallback != null) {
|
||||
newCallCallback.onInvite();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
|
||||
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
|
||||
callbacks.onExpandClicked(expand);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
|
||||
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
|
||||
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
|
||||
}
|
||||
},
|
||||
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
|
||||
storyContextMenuCallbacks,
|
||||
new CallButtonClickCallbacks()
|
||||
|
||||
),
|
||||
new ContactSelectionListAdapter.ArbitraryRepository()
|
||||
@Override
|
||||
public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
contactSearchView.setAlpha(1f);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return view;
|
||||
@@ -462,30 +493,30 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
public @NonNull List<SelectedContact> getSelectedContacts() {
|
||||
if (contactSearchMediator == null) {
|
||||
if (contactSearchViewModel == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return contactSearchMediator.getSelectedContacts()
|
||||
.stream()
|
||||
.map(ContactSearchKey::requireSelectedContact)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
return contactSearchViewModel.getSelectedContacts()
|
||||
.stream()
|
||||
.map(ContactSearchKey::requireSelectedContact)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public int getSelectedContactsCount() {
|
||||
if (contactSearchMediator == null) {
|
||||
if (contactSearchViewModel == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return contactSearchMediator.getSelectedContacts().size();
|
||||
return contactSearchViewModel.getSelectedContacts().size();
|
||||
}
|
||||
|
||||
public int getTotalMemberCount() {
|
||||
if (contactSearchMediator == null) {
|
||||
if (contactSearchViewModel == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return getSelectedContactsCount() + contactSearchMediator.getFixedContactsSize();
|
||||
return getSelectedContactsCount() + contactSearchViewModel.getFixedContactsSize();
|
||||
}
|
||||
|
||||
private Set<RecipientId> getCurrentSelection() {
|
||||
@@ -501,36 +532,23 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
.request(Manifest.permission.WRITE_CONTACTS, Manifest.permission.READ_CONTACTS)
|
||||
.ifNecessary()
|
||||
.onAllGranted(() -> {
|
||||
recyclerView.setAlpha(0.5f);
|
||||
contactSearchView.setAlpha(0.5f);
|
||||
if (!TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) {
|
||||
handleContactPermissionGranted();
|
||||
} else {
|
||||
contactSearchMediator.refresh();
|
||||
contactSearchViewModel.refresh();
|
||||
if (onRefreshListener != null) {
|
||||
swipeRefresh.setRefreshing(true);
|
||||
onRefreshListener.onRefresh();
|
||||
}
|
||||
}
|
||||
})
|
||||
.onAnyDenied(() -> contactSearchMediator.refresh())
|
||||
.onAnyDenied(() -> contactSearchViewModel.refresh())
|
||||
.withPermanentDenialDialog(getString(R.string.ContactSelectionListFragment_signal_requires_the_contacts_permission_in_order_to_display_your_contacts), null, R.string.ContactSelectionListFragment_allow_access_contacts, R.string.ContactSelectionListFragment_to_find_people, getParentFragmentManager())
|
||||
.execute();
|
||||
}
|
||||
|
||||
private void initializeCursor() {
|
||||
recyclerView.addItemDecoration(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders));
|
||||
recyclerView.setAdapter(contactSearchMediator.getAdapter());
|
||||
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
|
||||
@Override
|
||||
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
|
||||
if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
|
||||
if (scrollCallback != null) {
|
||||
scrollCallback.onBeginScroll();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onSelectionChanged();
|
||||
}
|
||||
@@ -548,7 +566,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
this.resetPositionOnCommit = true;
|
||||
this.cursorFilter = filter;
|
||||
|
||||
contactSearchMediator.onFilterChanged(filter);
|
||||
contactSearchViewModel.setQuery(filter);
|
||||
}
|
||||
|
||||
public void resetQueryFilter() {
|
||||
@@ -559,7 +577,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
public void onDataRefreshed() {
|
||||
this.resetPositionOnCommit = true;
|
||||
swipeRefresh.setRefreshing(false);
|
||||
contactSearchMediator.refresh();
|
||||
contactSearchViewModel.refresh();
|
||||
}
|
||||
|
||||
public boolean hasQueryFilter() {
|
||||
@@ -575,26 +593,25 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
contactSearchMediator.clearSelection();
|
||||
contactSearchMediator.refresh();
|
||||
contactSearchViewModel.clearSelection();
|
||||
contactSearchViewModel.refresh();
|
||||
fastScroller.setVisibility(View.GONE);
|
||||
headerActionView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void onLoadFinished(int count) {
|
||||
if (resetPositionOnCommit) {
|
||||
if (resetPositionOnCommit && innerRecyclerView != null) {
|
||||
resetPositionOnCommit = false;
|
||||
recyclerView.scrollToPosition(0);
|
||||
innerRecyclerView.scrollToPosition(0);
|
||||
}
|
||||
|
||||
swipeRefresh.setVisibility(View.VISIBLE);
|
||||
|
||||
emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
|
||||
boolean useFastScroller = count > 20;
|
||||
recyclerView.setVerticalScrollBarEnabled(!useFastScroller);
|
||||
if (useFastScroller) {
|
||||
if (useFastScroller && innerRecyclerView != null) {
|
||||
fastScroller.setVisibility(View.VISIBLE);
|
||||
fastScroller.setRecyclerView(recyclerView);
|
||||
fastScroller.setRecyclerView(innerRecyclerView);
|
||||
} else {
|
||||
fastScroller.setRecyclerView(null);
|
||||
fastScroller.setVisibility(View.GONE);
|
||||
@@ -661,10 +678,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
Set<SelectedContact> toMarkSelected = contacts.stream()
|
||||
.filter(r -> !contactSearchMediator.getSelectedContacts()
|
||||
.contains(new ContactSearchKey.RecipientSearchKey(r, false)))
|
||||
.filter(r -> !contactSearchViewModel.getSelectedContacts()
|
||||
.contains(new ContactSearchKey.RecipientSearchKey(r, false)))
|
||||
.map(SelectedContact::forRecipientId)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (toMarkSelected.isEmpty()) {
|
||||
return;
|
||||
@@ -689,7 +706,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedContact.hasChatType() && !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
|
||||
if (selectedContact.hasChatType() && !contactSearchViewModel.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onBeforeContactSelected(true, Optional.empty(), null, Optional.of(selectedContact.getChatType()), allowed -> {
|
||||
if (allowed) {
|
||||
@@ -706,7 +723,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMulti || !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
|
||||
if (!isMulti || !contactSearchViewModel.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
|
||||
if (selectionHardLimitReached()) {
|
||||
if (onSelectionLimitReachedListener != null) {
|
||||
onSelectionLimitReachedListener.onHardLimitReached(selectionLimit.getHardLimit());
|
||||
@@ -773,8 +790,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
public boolean onItemLongClick(View anchorView, ContactSearchKey item) {
|
||||
if (onItemLongClickListener != null) {
|
||||
return onItemLongClickListener.onLongClick(anchorView, item, recyclerView);
|
||||
if (onItemLongClickListener != null && innerRecyclerView != null) {
|
||||
return onItemLongClickListener.onLongClick(anchorView, item, innerRecyclerView);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
@@ -794,7 +811,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
public void markContactSelected(@NonNull SelectedContact selectedContact) {
|
||||
contactSearchMediator.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey()));
|
||||
contactSearchViewModel.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey()));
|
||||
if (isMulti) {
|
||||
addChipForSelectedContact(selectedContact);
|
||||
}
|
||||
@@ -804,7 +821,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
|
||||
contactSearchMediator.setKeysNotSelected(Collections.singleton(selectedContact.toContactSearchKey()));
|
||||
contactSearchViewModel.setKeysNotSelected(Collections.singleton(selectedContact.toContactSearchKey()));
|
||||
contactChipViewModel.remove(selectedContact);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
@@ -866,8 +883,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
|
||||
AutoTransition transition = new AutoTransition();
|
||||
transition.setDuration(CHIP_GROUP_REVEAL_DURATION_MS);
|
||||
transition.excludeChildren(recyclerView, true);
|
||||
transition.excludeTarget(recyclerView, true);
|
||||
transition.excludeChildren(contactSearchView, true);
|
||||
transition.excludeTarget(contactSearchView, true);
|
||||
TransitionManager.beginDelayedTransition(constraintLayout, transition);
|
||||
|
||||
ConstraintSet constraintSet = new ConstraintSet();
|
||||
|
||||
@@ -44,7 +44,6 @@ import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
|
||||
import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
|
||||
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
|
||||
@@ -61,6 +60,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.DialogFragment
|
||||
@@ -73,7 +73,6 @@ import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import io.reactivex.rxjava3.subjects.Subject
|
||||
@@ -88,6 +87,7 @@ import org.signal.core.ui.compose.Snackbars
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.ui.isSplitPane
|
||||
import org.signal.core.ui.permissions.Permissions
|
||||
import org.signal.core.ui.rememberIsSplitPane
|
||||
import org.signal.core.util.Util
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
@@ -96,6 +96,8 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.mediasend.MediaSendActivityContract
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.CouldNotCompleteBackupRestoreSheet
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
@@ -159,8 +161,9 @@ import org.thoughtcrime.securesms.main.navigateToDetailLocation
|
||||
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
|
||||
import org.thoughtcrime.securesms.main.rememberFocusRequester
|
||||
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
|
||||
import org.thoughtcrime.securesms.mediasend.v3.mediaSendLauncher
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphone
|
||||
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphones
|
||||
@@ -269,7 +272,7 @@ class MainActivity :
|
||||
override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) }
|
||||
override val googlePayResultPublisher: Subject<GooglePayComponent.GooglePayResult> = PublishSubject.create()
|
||||
|
||||
private lateinit var mediaActivityLauncher: ActivityResultLauncher<MediaSendActivityContract.Args>
|
||||
private lateinit var mediaSendLauncher: ActivityResultLauncher<MediaSendActivityContract.Args>
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
|
||||
return motionEventRelay.offer(ev) || super.dispatchTouchEvent(ev)
|
||||
@@ -296,7 +299,7 @@ class MainActivity :
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
navigator = MainNavigator(this, mainNavigationViewModel)
|
||||
|
||||
mediaActivityLauncher = registerForActivityResult(MediaSendActivityContract()) { }
|
||||
mediaSendLauncher = mediaSendLauncher()
|
||||
|
||||
AppForegroundObserver.addListener(object : AppForegroundObserver.Listener {
|
||||
override fun onForeground() {
|
||||
@@ -342,6 +345,19 @@ class MainActivity :
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
ArchiveRestoreProgress
|
||||
.stateFlow
|
||||
.filter { it.restoreStatus == ArchiveRestoreProgressState.RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE }
|
||||
.collect {
|
||||
ArchiveRestoreProgress.clearLocalRestoreDirectoryError()
|
||||
CouldNotCompleteBackupRestoreSheet().show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
Log.i(TAG, "Local restore directory became unavailable.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
supportFragmentManager.setFragmentResultListener(
|
||||
@@ -413,15 +429,15 @@ class MainActivity :
|
||||
)
|
||||
}
|
||||
|
||||
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
|
||||
val isSplitPane = LocalResources.current.rememberIsSplitPane()
|
||||
val contentLayoutData = MainContentLayoutData.rememberContentLayoutData(mainToolbarState.mode)
|
||||
|
||||
MainContainer {
|
||||
val wrappedNavigator = rememberNavigator(windowSizeClass, contentLayoutData, maxWidth)
|
||||
val wrappedNavigator = rememberNavigator(isSplitPane, contentLayoutData, maxWidth)
|
||||
val listPaneWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
|
||||
val navigationType = NavigationType.rememberNavigationType()
|
||||
|
||||
val anchors = remember(contentLayoutData, mainToolbarState) {
|
||||
val anchors = remember(contentLayoutData, mainToolbarState, listPaneWidth, navigationType) {
|
||||
val halfPartitionWidth = contentLayoutData.partitionWidth / 2
|
||||
|
||||
val detailOffset = when {
|
||||
@@ -449,7 +465,7 @@ class MainActivity :
|
||||
anchors.indexOf(paneExpansionState.currentAnchor)
|
||||
}
|
||||
|
||||
LaunchedEffect(windowSizeClass) {
|
||||
LaunchedEffect(anchors) {
|
||||
val index = when {
|
||||
paneAnchorIndex < 0 -> 1
|
||||
paneAnchorIndex > anchors.lastIndex -> anchors.lastIndex
|
||||
@@ -462,7 +478,7 @@ class MainActivity :
|
||||
}
|
||||
}
|
||||
|
||||
val chatNavGraphState = ChatNavGraphState.remember(windowSizeClass)
|
||||
val chatNavGraphState = ChatNavGraphState.remember(isSplitPane)
|
||||
val mutableInteractionSource = remember { MutableInteractionSource() }
|
||||
MainNavigationDetailLocationEffect(mainNavigationViewModel, chatNavGraphState::writeGraphicsLayerToBitmap)
|
||||
|
||||
@@ -608,7 +624,7 @@ class MainActivity :
|
||||
onDestinationSelected = mainNavigationCallback
|
||||
)
|
||||
|
||||
if (!windowSizeClass.isSplitPane()) {
|
||||
if (!LocalResources.current.rememberIsSplitPane()) {
|
||||
Spacer(Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
@@ -624,7 +640,7 @@ class MainActivity :
|
||||
}
|
||||
},
|
||||
secondaryContent = {
|
||||
val listContainerColor = if (windowSizeClass.isSplitPane()) {
|
||||
val listContainerColor = if (isSplitPane) {
|
||||
SignalTheme.colors.colorSurface1
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
@@ -765,12 +781,12 @@ class MainActivity :
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
private fun rememberNavigator(
|
||||
windowSizeClass: WindowSizeClass,
|
||||
isSplitPane: Boolean,
|
||||
contentLayoutData: MainContentLayoutData,
|
||||
maxWidth: Dp
|
||||
): AppScaffoldNavigator<Any> {
|
||||
val scaffoldNavigator = rememberThreePaneScaffoldNavigatorDelegate(
|
||||
isSplitPane = windowSizeClass.isSplitPane(),
|
||||
isSplitPane = isSplitPane,
|
||||
horizontalPartitionSpacerSize = contentLayoutData.partitionWidth,
|
||||
defaultPanePreferredWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
|
||||
)
|
||||
@@ -784,18 +800,18 @@ class MainActivity :
|
||||
|
||||
@Composable
|
||||
private fun MainContainer(content: @Composable BoxWithConstraintsScope.() -> Unit) {
|
||||
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
|
||||
val isSplitPane = LocalResources.current.rememberIsSplitPane()
|
||||
|
||||
CompositionLocalProvider(LocalSnackbarStateConsumerRegistry provides mainNavigationViewModel.snackbarRegistry) {
|
||||
SignalTheme {
|
||||
val backgroundColor = if (!windowSizeClass.isSplitPane()) {
|
||||
val backgroundColor = if (!isSplitPane) {
|
||||
MaterialTheme.colorScheme.surface
|
||||
} else {
|
||||
SignalTheme.colors.colorSurface1
|
||||
}
|
||||
|
||||
val modifier = when {
|
||||
windowSizeClass.isSplitPane() -> {
|
||||
isSplitPane -> {
|
||||
Modifier
|
||||
.systemBarsPadding()
|
||||
.displayCutoutPadding()
|
||||
@@ -1109,7 +1125,7 @@ class MainActivity :
|
||||
if (isForQuickRestore) {
|
||||
startActivity(MediaSelectionActivity.cameraForQuickRestore(context = this@MainActivity))
|
||||
} else if (SignalStore.internal.useNewMediaActivity) {
|
||||
mediaActivityLauncher.launch(
|
||||
mediaSendLauncher.launch(
|
||||
MediaSendActivityContract.Args(
|
||||
isCameraFirst = false,
|
||||
isStory = destination == MainNavigationListLocation.STORIES
|
||||
@@ -1125,7 +1141,7 @@ class MainActivity :
|
||||
}
|
||||
}
|
||||
|
||||
if (CameraXUtil.isSupported()) {
|
||||
if (CameraXRemoteConfig.isSupported()) {
|
||||
onGranted()
|
||||
} else {
|
||||
Permissions.with(this@MainActivity)
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity;
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
|
||||
import org.thoughtcrime.securesms.util.Environment;
|
||||
import org.thoughtcrime.securesms.restore.RestoreActivity;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
@@ -134,8 +135,12 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
Intent intent = getIntentForState(applicationState);
|
||||
if (intent != null) {
|
||||
Log.d(TAG, "routeApplicationState(), intent: " + intent.getComponent());
|
||||
startActivity(intent);
|
||||
finish();
|
||||
if (applicationState == STATE_WELCOME_PUSH_SCREEN && Environment.USE_NEW_REGISTRATION) {
|
||||
startActivity(intent);
|
||||
} else {
|
||||
startActivity(intent);
|
||||
finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +178,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return STATE_ENTER_SIGNAL_PIN;
|
||||
} else if (userMustSetProfileName()) {
|
||||
return STATE_CREATE_PROFILE_NAME;
|
||||
} else if (userMustCreateSignalPin()) {
|
||||
} else if (userMustCreateSignalPin() && getClass() != CreateSvrPinActivity.class) {
|
||||
return STATE_CREATE_SIGNAL_PIN;
|
||||
} else if (EventBus.getDefault().getStickyEvent(TransferStatus.class) != null && getClass() != OldDeviceTransferActivity.class) {
|
||||
return STATE_TRANSFER_ONGOING;
|
||||
@@ -221,7 +226,11 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
}
|
||||
|
||||
private Intent getPushRegistrationIntent() {
|
||||
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
|
||||
if (Environment.USE_NEW_REGISTRATION) {
|
||||
return org.signal.registration.RegistrationActivity.createIntent(this);
|
||||
} else {
|
||||
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
|
||||
}
|
||||
}
|
||||
|
||||
private Intent getEnterSignalPinIntent() {
|
||||
|
||||
@@ -19,7 +19,7 @@ package org.thoughtcrime.securesms;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
@@ -58,7 +58,7 @@ public class PushContactSelectionActivity extends ContactSelectionActivity {
|
||||
protected final void onFinishedSelection() {
|
||||
Intent resultIntent = getIntent();
|
||||
List<SelectedContact> selectedContacts = contactsFragment.getSelectedContacts();
|
||||
List<RecipientId> recipients = Stream.of(selectedContacts).map(sc -> sc.getOrCreateRecipientId()).toList();
|
||||
List<RecipientId> recipients = selectedContacts.stream().map(sc -> sc.getOrCreateRecipientId()).collect(Collectors.toList());
|
||||
|
||||
resultIntent.putParcelableArrayListExtra(KEY_SELECTED_RECIPIENTS, new ArrayList<>(recipients));
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.thoughtcrime.securesms.apkupdate
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -53,6 +54,13 @@ object ApkUpdateInstaller {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isDownloadSuccessful(context, downloadId)) {
|
||||
Log.w(TAG, "DownloadId matches, but the download was not successful. The download may have failed due to a network issue. Clearing state and re-checking for updates.")
|
||||
SignalStore.apkUpdate.clearDownloadAttributes()
|
||||
AppDependencies.jobManager.add(ApkUpdateJob())
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMatchingDigest(context, downloadId, digest)) {
|
||||
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
|
||||
SignalStore.apkUpdate.clearDownloadAttributes()
|
||||
@@ -134,6 +142,35 @@ object ApkUpdateInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDownloadSuccessful(context: Context, downloadId: Long): Boolean {
|
||||
val query = DownloadManager.Query().setFilterById(downloadId)
|
||||
val cursor = context.getDownloadManager().query(query)
|
||||
|
||||
return cursor.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val status = cursor
|
||||
.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
.takeUnless { it == -1 }
|
||||
?.let { cursor.getInt(it) } ?: DownloadManager.STATUS_FAILED
|
||||
|
||||
if (status == DownloadManager.STATUS_SUCCESSFUL) {
|
||||
return@use true
|
||||
}
|
||||
|
||||
val reason = cursor
|
||||
.getColumnIndex(DownloadManager.COLUMN_REASON)
|
||||
.takeUnless { it == -1 }
|
||||
?.let { cursor.getInt(it) }
|
||||
|
||||
Log.w(TAG, "Download not successful. Status: $status, Reason: $reason")
|
||||
false
|
||||
} else {
|
||||
Log.w(TAG, "Download ID $downloadId not found in DownloadManager.")
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isMatchingDigest(context: Context, downloadId: Long, expectedDigest: ByteArray): Boolean {
|
||||
return try {
|
||||
FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor).use { stream ->
|
||||
|
||||
@@ -8,16 +8,19 @@ package org.thoughtcrime.securesms.attachments
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import org.signal.blurhash.BlurHashEncoder
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.mebiBytes
|
||||
import org.signal.protos.resumableuploads.ResumableUpload
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.Objects
|
||||
|
||||
/**
|
||||
@@ -32,6 +35,29 @@ object AttachmentUploadUtil {
|
||||
*/
|
||||
val FOREGROUND_LIMIT_BYTES: Long = 10.mebiBytes.inWholeBytes
|
||||
|
||||
/**
|
||||
* Computes the base64-encoded SHA-256 checksum of the ciphertext that would result from encrypting [plaintextStream]
|
||||
* with the given [key] and [iv], including padding, IV prefix, and HMAC suffix.
|
||||
*/
|
||||
fun computeCiphertextChecksum(key: ByteArray, iv: ByteArray, plaintextStream: InputStream, plaintextSize: Long): String {
|
||||
val paddedStream = PaddingInputStream(plaintextStream, plaintextSize)
|
||||
return Base64.encodeWithPadding(AttachmentCipherStreamUtil.computeCiphertextSha256(key, iv, paddedStream))
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the base64-encoded SHA-256 checksum of the raw bytes in [inputStream].
|
||||
* Used for pre-encrypted uploads where the data is already in its final form.
|
||||
*/
|
||||
fun computeRawChecksum(inputStream: InputStream): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
var read: Int
|
||||
while (inputStream.read(buffer).also { read = it } != -1) {
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
return Base64.encodeWithPadding(digest.digest())
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [SignalServiceAttachmentStream] from the provided data, which can then be provided to various upload methods.
|
||||
*/
|
||||
@@ -39,7 +65,6 @@ object AttachmentUploadUtil {
|
||||
fun buildSignalServiceAttachmentStream(
|
||||
context: Context,
|
||||
attachment: Attachment,
|
||||
uploadSpec: ResumableUpload,
|
||||
cancellationSignal: (() -> Boolean)? = null,
|
||||
progressListener: ProgressListener? = null
|
||||
): SignalServiceAttachmentStream {
|
||||
@@ -57,7 +82,6 @@ object AttachmentUploadUtil {
|
||||
.withHeight(attachment.height)
|
||||
.withUploadTimestamp(System.currentTimeMillis())
|
||||
.withCaption(attachment.caption)
|
||||
.withResumableUploadSpec(ResumableUploadSpec.from(uploadSpec))
|
||||
.withCancelationSignal(cancellationSignal)
|
||||
.withListener(progressListener)
|
||||
.withUuid(attachment.uuid)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -46,9 +46,9 @@ sealed interface FallbackAvatar {
|
||||
fun getIconBySize(size: Size): Int
|
||||
|
||||
/**
|
||||
* Local user
|
||||
* Note to Self / local user
|
||||
*/
|
||||
data class Local(override val color: AvatarColor) : Resource {
|
||||
data class NoteToSelf(override val color: AvatarColor) : Resource {
|
||||
override fun getIconBySize(size: Size): Int {
|
||||
return when (size) {
|
||||
Size.SMALL -> R.drawable.symbol_note_compact_16
|
||||
|
||||
@@ -30,7 +30,7 @@ import org.thoughtcrime.securesms.avatar.vector.VectorAvatarCreationFragment
|
||||
import org.thoughtcrime.securesms.components.ButtonStripItemView
|
||||
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
|
||||
import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
@@ -223,7 +223,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun openCameraCapture() {
|
||||
if (CameraXUtil.isSupported()) {
|
||||
if (CameraXRemoteConfig.isSupported()) {
|
||||
val intent = AvatarSelectionActivity.getIntentForCameraCapture(requireContext())
|
||||
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
|
||||
} else {
|
||||
|
||||
@@ -8,7 +8,7 @@ package org.thoughtcrime.securesms.backup
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
|
||||
val LocalBackupCreationProgress.isIdle: Boolean
|
||||
get() = idle != null || (exporting == null && transferring == null && canceled == null)
|
||||
get() = idle != null || succeeded != null || failed != null || canceled != null || (exporting == null && transferring == null)
|
||||
|
||||
fun LocalBackupCreationProgress.exportProgress(): Float {
|
||||
val exporting = exporting ?: return 0f
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
|
||||
object LocalExportProgress {
|
||||
val internalEncryptedProgress = MutableStateFlow(LocalBackupCreationProgress())
|
||||
val internalPlaintextProgress = MutableStateFlow(LocalBackupCreationProgress())
|
||||
|
||||
val encryptedProgress: StateFlow<LocalBackupCreationProgress> = internalEncryptedProgress
|
||||
val plaintextProgress: StateFlow<LocalBackupCreationProgress> = internalPlaintextProgress
|
||||
|
||||
fun setEncryptedProgress(progress: LocalBackupCreationProgress) {
|
||||
internalEncryptedProgress.value = progress
|
||||
}
|
||||
|
||||
fun setPlaintextProgress(progress: LocalBackupCreationProgress) {
|
||||
internalPlaintextProgress.value = progress
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -194,6 +198,10 @@ object ExportOddities {
|
||||
return log(sentTimestamp, "Revisions for this message contained items of a different type than the parent item. Ignoring mismatched revisions.")
|
||||
}
|
||||
|
||||
fun mismatchedRevisionAuthor(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Revisions for this message contained items with a different author than the parent item. Ignoring mismatched revisions.")
|
||||
}
|
||||
|
||||
fun outgoingMessageWasSentButTimerNotStarted(sentTimestamp: Long): String {
|
||||
return log(sentTimestamp, "Outgoing expiring message was sent, but the timer wasn't started. Setting expireStartDate to dateReceived.")
|
||||
}
|
||||
|
||||
@@ -157,6 +157,11 @@ object ArchiveRestoreProgress {
|
||||
update()
|
||||
}
|
||||
|
||||
fun clearLocalRestoreDirectoryError() {
|
||||
SignalStore.backup.localRestoreDirectoryError = false
|
||||
update()
|
||||
}
|
||||
|
||||
fun clearFinishedStatus() {
|
||||
store.update { state ->
|
||||
if (state.restoreStatus == ArchiveRestoreProgressState.RestoreStatus.FINISHED) {
|
||||
@@ -193,7 +198,11 @@ object ArchiveRestoreProgress {
|
||||
!NetworkConstraint.isMet(AppDependencies.application) -> ArchiveRestoreProgressState.RestoreStatus.WAITING_FOR_INTERNET
|
||||
!BatteryNotLowConstraint.isMet() -> ArchiveRestoreProgressState.RestoreStatus.LOW_BATTERY
|
||||
!DiskSpaceNotLowConstraint.isMet() -> ArchiveRestoreProgressState.RestoreStatus.NOT_ENOUGH_DISK_SPACE
|
||||
restoreState == RestoreState.NONE -> if (state.hasActivelyRestoredThisRun) ArchiveRestoreProgressState.RestoreStatus.FINISHED else ArchiveRestoreProgressState.RestoreStatus.NONE
|
||||
restoreState == RestoreState.NONE -> when {
|
||||
SignalStore.backup.localRestoreDirectoryError -> ArchiveRestoreProgressState.RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE
|
||||
state.hasActivelyRestoredThisRun -> ArchiveRestoreProgressState.RestoreStatus.FINISHED
|
||||
else -> ArchiveRestoreProgressState.RestoreStatus.NONE
|
||||
}
|
||||
else -> {
|
||||
val availableBytes = SignalStore.backup.spaceAvailableOnDiskBytes
|
||||
|
||||
|
||||
+2
-1
@@ -69,6 +69,7 @@ data class ArchiveRestoreProgressState(
|
||||
WAITING_FOR_INTERNET,
|
||||
WAITING_FOR_WIFI,
|
||||
NOT_ENOUGH_DISK_SPACE,
|
||||
FINISHED
|
||||
FINISHED,
|
||||
LOCAL_RESTORE_DIRECTORY_UNAVAILABLE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,14 +66,13 @@ import org.signal.libsignal.messagebackup.BackupForwardSecrecyToken
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException
|
||||
import org.signal.libsignal.zkgroup.backups.BackupLevel
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.signal.network.api.SvrBApi
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
|
||||
import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.isIdle
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository.copyAttachmentToArchive
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository.exportForDebugging
|
||||
import org.thoughtcrime.securesms.backup.v2.importer.ChatItemArchiveImporter
|
||||
@@ -116,7 +115,6 @@ import org.thoughtcrime.securesms.jobs.BackupMessagesJob
|
||||
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.CancelRestoreMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
|
||||
import org.thoughtcrime.securesms.jobs.LocalArchiveJob
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceKeysUpdateJob
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
|
||||
@@ -133,7 +131,6 @@ import org.thoughtcrime.securesms.keyvalue.KeyValueStore
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.isDecisionPending
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
@@ -165,7 +162,6 @@ import org.whispersystems.signalservice.api.link.TransferArchiveResponse
|
||||
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.whispersystems.signalservice.api.svr.SvrBApi
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
|
||||
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials
|
||||
@@ -428,6 +424,12 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
fun markOutOfRemoteStorageSpaceError() {
|
||||
if (SignalStore.backup.isNotEnoughRemoteStorageSpace) {
|
||||
return
|
||||
}
|
||||
|
||||
SignalStore.backup.markNotEnoughRemoteStorageSpace()
|
||||
|
||||
val context = AppDependencies.application
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(context, 0, AppSettingsActivity.remoteBackups(context), cancelCurrent())
|
||||
@@ -440,8 +442,6 @@ object BackupRepository {
|
||||
.build()
|
||||
|
||||
ServiceUtil.getNotificationManager(context).notify(NotificationIds.OUT_OF_REMOTE_STORAGE, notification)
|
||||
|
||||
SignalStore.backup.markNotEnoughRemoteStorageSpace()
|
||||
}
|
||||
|
||||
fun clearOutOfRemoteStorageSpaceError() {
|
||||
@@ -593,14 +593,6 @@ object BackupRepository {
|
||||
SignalStore.backup.snoozeDownloadNotifier()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun maybeFixAnyDanglingLocalExportProgress() {
|
||||
if (!SignalStore.backup.newLocalBackupProgress.isIdle && AppDependencies.jobManager.find { it.factoryKey == LocalArchiveJob.KEY }.isEmpty()) {
|
||||
Log.w(TAG, "Found stale local backup progress with no active job. Resetting to idle.")
|
||||
SignalStore.backup.newLocalBackupProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun maybeFixAnyDanglingUploadProgress() {
|
||||
if (SignalStore.account.isLinkedDevice) {
|
||||
@@ -1649,6 +1641,13 @@ object BackupRepository {
|
||||
}
|
||||
}
|
||||
|
||||
fun getMessageBackupUploadForm(backupFileSize: Long): NetworkResult<AttachmentUploadForm> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
SignalNetwork.archive.getMessageBackupUploadForm(SignalStore.account.requireAci(), credential.messageBackupAccess, backupFileSize)
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadBackupFile(destination: File, listener: ProgressListener? = null): NetworkResult<Unit> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
@@ -1688,14 +1687,13 @@ object BackupRepository {
|
||||
|
||||
/**
|
||||
* Retrieves an [AttachmentUploadForm] that can be used to upload an attachment to the transit cdn.
|
||||
* To continue the upload, use [org.whispersystems.signalservice.api.attachment.AttachmentApi.getResumableUploadSpec].
|
||||
*
|
||||
* It's important to note that in order to get this to the archive cdn, you still need to use [copyAttachmentToArchive].
|
||||
*/
|
||||
fun getAttachmentUploadForm(): NetworkResult<AttachmentUploadForm> {
|
||||
fun getAttachmentUploadForm(uploadLength: Long): NetworkResult<AttachmentUploadForm> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
SignalNetwork.archive.getMediaUploadForm(SignalStore.account.requireAci(), credential.mediaBackupAccess)
|
||||
SignalNetwork.archive.getMediaUploadForm(SignalStore.account.requireAci(), credential.mediaBackupAccess, uploadLength)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1726,10 +1724,10 @@ object BackupRepository {
|
||||
/**
|
||||
* Copies a thumbnail that has been uploaded to the transit cdn to the archive cdn.
|
||||
*/
|
||||
fun copyThumbnailToArchive(thumbnailAttachment: Attachment, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
|
||||
fun copyThumbnailToArchive(thumbnail: UploadedThumbnailInfo, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
val request = thumbnailAttachment.toArchiveMediaRequest(parentAttachment.requireThumbnailMediaName(), credential.mediaBackupAccess.backupKey)
|
||||
val request = buildArchiveMediaRequest(thumbnail.cdnNumber, thumbnail.remoteLocation, thumbnail.size, parentAttachment.requireThumbnailMediaName(), credential.mediaBackupAccess.backupKey)
|
||||
|
||||
SignalNetwork.archive.copyAttachmentToArchive(
|
||||
aci = SignalStore.account.requireAci(),
|
||||
@@ -1746,7 +1744,7 @@ object BackupRepository {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
val mediaName = attachment.requireMediaName()
|
||||
val request = attachment.toArchiveMediaRequest(mediaName, credential.mediaBackupAccess.backupKey)
|
||||
val request = buildArchiveMediaRequest(attachment.cdn.cdnNumber, attachment.remoteLocation!!, attachment.size, mediaName, credential.mediaBackupAccess.backupKey)
|
||||
SignalNetwork.archive
|
||||
.copyAttachmentToArchive(
|
||||
aci = SignalStore.account.requireAci(),
|
||||
@@ -2096,7 +2094,7 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* See [org.whispersystems.signalservice.api.archive.ArchiveApi.getSvrBAuthorization].
|
||||
* See [org.signal.network.api.ArchiveApi.getSvrBAuthorization].
|
||||
*/
|
||||
fun getSvrBAuth(): NetworkResult<AuthCredentials> {
|
||||
return initBackupAndFetchAuth()
|
||||
@@ -2197,15 +2195,15 @@ object BackupRepository {
|
||||
val profileKey: ProfileKey
|
||||
)
|
||||
|
||||
private fun Attachment.toArchiveMediaRequest(mediaName: MediaName, mediaRootBackupKey: MediaRootBackupKey): ArchiveMediaRequest {
|
||||
private fun buildArchiveMediaRequest(cdnNumber: Int, remoteLocation: String, plaintextSize: Long, mediaName: MediaName, mediaRootBackupKey: MediaRootBackupKey): ArchiveMediaRequest {
|
||||
val mediaSecrets = mediaRootBackupKey.deriveMediaSecrets(mediaName)
|
||||
|
||||
return ArchiveMediaRequest(
|
||||
sourceAttachment = ArchiveMediaRequest.SourceAttachment(
|
||||
cdn = cdn.cdnNumber,
|
||||
key = remoteLocation!!
|
||||
cdn = cdnNumber,
|
||||
key = remoteLocation
|
||||
),
|
||||
objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(size)).toInt(),
|
||||
objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(plaintextSize)).toInt(),
|
||||
mediaId = mediaSecrets.id.encode(),
|
||||
hmacKey = Base64.encodeWithPadding(mediaSecrets.macKey),
|
||||
encryptionKey = Base64.encodeWithPadding(mediaSecrets.aesKey)
|
||||
@@ -2618,3 +2616,9 @@ class ArchiveMediaItemIterator(private val cursor: Cursor) : Iterator<ArchiveMed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class UploadedThumbnailInfo(
|
||||
val cdnNumber: Int,
|
||||
val remoteLocation: String,
|
||||
val size: Long
|
||||
)
|
||||
|
||||
+13
-4
@@ -241,6 +241,10 @@ class ChatItemArchiveExporter(
|
||||
}
|
||||
|
||||
MessageTypes.isReleaseChannelDonationRequest(record.type) -> {
|
||||
if (exportState.threadIdToRecipientId[builder.chatId] != exportState.releaseNoteRecipientId) {
|
||||
Log.w(TAG, ExportSkips.donationRequestNotInReleaseNotesChat(builder.dateSent))
|
||||
continue
|
||||
}
|
||||
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.RELEASE_CHANNEL_DONATION_REQUEST)
|
||||
transformTimer.emit("simple-update")
|
||||
}
|
||||
@@ -1741,19 +1745,24 @@ private fun ChatUpdateMessage.canOnlyBeAuthoredBySelf(): Boolean {
|
||||
}
|
||||
|
||||
private fun List<ChatItem>.repairRevisions(current: ChatItem.Builder): List<ChatItem> {
|
||||
val authorFiltered = this.filter { it.authorId == current.authorId }
|
||||
if (authorFiltered.size != this.size) {
|
||||
Log.w(TAG, ExportOddities.mismatchedRevisionAuthor(current.dateSent))
|
||||
}
|
||||
|
||||
return if (current.standardMessage != null) {
|
||||
val filtered = this
|
||||
val filtered = authorFiltered
|
||||
.filter { it.standardMessage != null }
|
||||
.map { it.withDowngradeVoiceNotes() }
|
||||
|
||||
if (this.size != filtered.size) {
|
||||
if (authorFiltered.size != filtered.size) {
|
||||
Log.w(TAG, ExportOddities.mismatchedRevisionHistory(current.dateSent))
|
||||
}
|
||||
|
||||
filtered
|
||||
} else if (current.directStoryReplyMessage != null) {
|
||||
val filtered = this.filter { it.directStoryReplyMessage != null }
|
||||
if (this.size != filtered.size) {
|
||||
val filtered = authorFiltered.filter { it.directStoryReplyMessage != null }
|
||||
if (authorFiltered.size != filtered.size) {
|
||||
Log.w(TAG, ExportOddities.mismatchedRevisionHistory(current.dateSent))
|
||||
}
|
||||
filtered
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@ private fun DecryptedMember.toRemote(): Group.Member {
|
||||
userId = aciBytes,
|
||||
role = role.toRemote(),
|
||||
joinedAtVersion = joinedAtRevision,
|
||||
labelEmoji = labelEmoji,
|
||||
labelEmoji = if (labelString.isNotBlank()) labelEmoji else "",
|
||||
labelString = labelString
|
||||
)
|
||||
}
|
||||
|
||||
+14
-4
@@ -362,12 +362,13 @@ class ChatItemArchiveImporter(
|
||||
} else if (pinMessage != null) {
|
||||
followUps += { pinUpdateMessageId ->
|
||||
val targetAuthorId = importState.remoteToLocalRecipientId[pinMessage.authorId]
|
||||
if (targetAuthorId != null) {
|
||||
val targetAuthorAci = targetAuthorId?.let { recipients.getRecord(it).aci }
|
||||
if (targetAuthorId != null && targetAuthorAci != null) {
|
||||
val pinnedMessageId = SignalDatabase.messages.getMessageFor(pinMessage.targetSentTimestamp, targetAuthorId)?.id ?: -1
|
||||
val messageExtras = MessageExtras(
|
||||
pinnedMessage = PinnedMessage(
|
||||
pinnedMessageId = pinnedMessageId,
|
||||
targetAuthorAci = recipients.getRecord(targetAuthorId).aci!!.toByteString(),
|
||||
targetAuthorAci = targetAuthorAci.toByteString(),
|
||||
targetTimestamp = pinMessage.targetSentTimestamp
|
||||
)
|
||||
)
|
||||
@@ -383,6 +384,8 @@ class ChatItemArchiveImporter(
|
||||
.where("${MessageTable.ID} = ?", pinnedMessageId)
|
||||
.run()
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Pin message target author not found or has no ACI, skipping pin message extras.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -472,6 +475,7 @@ class ChatItemArchiveImporter(
|
||||
val ids = SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, listOf(longTextAttachment), emptyList())
|
||||
ids.values.firstOrNull()?.let { attachmentId ->
|
||||
SignalDatabase.attachments.setTransferState(messageRowId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_DONE)
|
||||
SignalDatabase.attachments.createRemoteKeyIfNecessary(attachmentId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -511,6 +515,7 @@ class ChatItemArchiveImporter(
|
||||
if (longTextAttachment != null) {
|
||||
attachmentMap[longTextAttachment]?.let { attachmentId ->
|
||||
SignalDatabase.attachments.setTransferState(messageRowId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_DONE)
|
||||
SignalDatabase.attachments.createRemoteKeyIfNecessary(attachmentId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,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)
|
||||
@@ -861,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
|
||||
@@ -902,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
|
||||
|
||||
+136
-23
@@ -7,16 +7,20 @@ package org.thoughtcrime.securesms.backup.v2.local
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import org.signal.archive.local.ArchivedFilesReader
|
||||
import org.signal.core.models.backup.MediaName
|
||||
import org.signal.core.util.Stopwatch
|
||||
import org.signal.core.util.androidx.DocumentFileInfo
|
||||
import org.signal.core.util.androidx.DocumentFileUtil
|
||||
import org.signal.core.util.androidx.DocumentFileUtil.OperationResult
|
||||
import org.signal.core.util.androidx.DocumentFileUtil.delete
|
||||
import org.signal.core.util.androidx.DocumentFileUtil.hasFile
|
||||
import org.signal.core.util.androidx.DocumentFileUtil.inputStream
|
||||
@@ -26,7 +30,6 @@ import org.signal.core.util.androidx.DocumentFileUtil.newFile
|
||||
import org.signal.core.util.androidx.DocumentFileUtil.outputStream
|
||||
import org.signal.core.util.androidx.DocumentFileUtil.renameTo
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
@@ -59,9 +62,18 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
|
||||
* Should likely only be called on API29+
|
||||
*/
|
||||
fun fromUri(context: Context, uri: Uri): ArchiveFileSystem? {
|
||||
val root = DocumentFile.fromTreeUri(context, uri)
|
||||
val root = DocumentFile.fromTreeUri(context, uri) ?: return null
|
||||
|
||||
if (root == null || !root.canWrite()) {
|
||||
val result = DocumentFileUtil.retryDocumentFileOperation<Unit> { attempt, maxAttempts ->
|
||||
Log.d(TAG, "canWrite() check attempt ${attempt + 1}/$maxAttempts")
|
||||
if (root.canWrite()) {
|
||||
OperationResult.Success(true)
|
||||
} else {
|
||||
OperationResult.Retry
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.isSuccess()) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -77,15 +89,28 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
|
||||
fun openForRestore(context: Context, uri: Uri): ArchiveFileSystem? {
|
||||
val root = DocumentFile.fromTreeUri(context, uri) ?: return null
|
||||
if (!root.canRead()) return null
|
||||
if (root.findFile(MAIN_DIRECTORY_NAME) == null) return null
|
||||
return openForRestore(context, root)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun openForRestore(context: Context, root: DocumentFile): ArchiveFileSystem? {
|
||||
if (root.findFile(MAIN_DIRECTORY_NAME) == null && !looksLikeSignalBackupsDirectory(root)) return null
|
||||
return try {
|
||||
ArchiveFileSystem(context, root, readOnly = true)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Unable to open backup directory for restore: $uri", e)
|
||||
Log.w(TAG, "Unable to open backup directory for restore", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if [dir] appears to be a SignalBackups directory based on its name and
|
||||
* expected internal structure (presence of the "files" subdirectory).
|
||||
*/
|
||||
private fun looksLikeSignalBackupsDirectory(dir: DocumentFile): Boolean {
|
||||
return dir.name == MAIN_DIRECTORY_NAME && dir.findFile("files") != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to create an [ArchiveFileSystem] from a regular [File].
|
||||
*
|
||||
@@ -98,6 +123,57 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
|
||||
fun openInputStream(context: Context, uri: Uri): InputStream? {
|
||||
return context.contentResolver.openInputStream(uri)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively delete the entire SignalBackups directory using parallelized SAF calls.
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun deleteAll(signalBackupsDir: DocumentFile, progressListener: AllFilesProgressListener? = null) {
|
||||
Log.i(TAG, "Deleting all backup data")
|
||||
|
||||
val units = mutableListOf<DocumentFile>()
|
||||
for (child in signalBackupsDir.listFiles()) {
|
||||
if (child.isDirectory && child.name == "files") {
|
||||
units += child.listFiles()
|
||||
} else {
|
||||
units += child
|
||||
}
|
||||
}
|
||||
|
||||
if (units.isEmpty()) {
|
||||
signalBackupsDir.delete()
|
||||
return
|
||||
}
|
||||
|
||||
val total = units.size
|
||||
val completed = AtomicInteger(0)
|
||||
val deleted = AtomicInteger(0)
|
||||
val concurrency = Runtime.getRuntime().availableProcessors().coerceAtMost(8)
|
||||
val chunkSize = ((total + concurrency - 1) / concurrency).coerceAtLeast(1)
|
||||
|
||||
runBlocking {
|
||||
coroutineScope {
|
||||
units.chunked(chunkSize).map { chunk ->
|
||||
async(Dispatchers.IO) {
|
||||
for (unit in chunk) {
|
||||
if (unit.delete()) {
|
||||
deleted.incrementAndGet()
|
||||
}
|
||||
progressListener?.onProgress(completed.incrementAndGet(), total)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
}
|
||||
|
||||
for (child in signalBackupsDir.listFiles()) {
|
||||
child.delete()
|
||||
}
|
||||
signalBackupsDir.delete()
|
||||
|
||||
Log.d(TAG, "Deleted ${deleted.get()}/$total top-level units")
|
||||
}
|
||||
}
|
||||
|
||||
private val signalBackups: DocumentFile
|
||||
@@ -105,22 +181,31 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
|
||||
/** File access to shared super-set of archive related files (e.g., media + attachments) */
|
||||
val filesFileSystem: FilesFileSystem
|
||||
|
||||
/**
|
||||
* True if this file system was opened directly from the SignalBackups directory itself (rather than its parent).
|
||||
* In this case, the URI cannot be reused as a backup destination since we lack access to the parent directory.
|
||||
*/
|
||||
val isRootedAtSignalBackups: Boolean
|
||||
|
||||
init {
|
||||
if (readOnly) {
|
||||
signalBackups = root.findFile(MAIN_DIRECTORY_NAME) ?: throw IOException("SignalBackups directory not found in $root")
|
||||
val child = root.findFile(MAIN_DIRECTORY_NAME)
|
||||
if (child != null) {
|
||||
signalBackups = child
|
||||
isRootedAtSignalBackups = false
|
||||
} else if (looksLikeSignalBackupsDirectory(root)) {
|
||||
signalBackups = root
|
||||
isRootedAtSignalBackups = true
|
||||
} else {
|
||||
throw IOException("SignalBackups directory not found in $root")
|
||||
}
|
||||
val filesDirectory = signalBackups.findFile("files") ?: throw IOException("files directory not found in $signalBackups")
|
||||
filesFileSystem = FilesFileSystem(context, filesDirectory, readOnly = true)
|
||||
} else {
|
||||
isRootedAtSignalBackups = false
|
||||
signalBackups = root.mkdirp(MAIN_DIRECTORY_NAME) ?: throw IOException("Unable to create main backups directory")
|
||||
val filesDirectory = signalBackups.mkdirp("files") ?: throw IOException("Unable to create files directory")
|
||||
filesFileSystem = FilesFileSystem(context, filesDirectory)
|
||||
|
||||
val hintFileName = context.getString(R.string.ArchiveFileSystem__select_this_folder_hint_name)
|
||||
if (!root.hasFile(hintFileName)) {
|
||||
root.createFile("text/plain", hintFileName)
|
||||
?.outputStream(context)
|
||||
?.use { out -> out.write(context.getString(R.string.ArchiveFileSystem__select_this_folder_hint_body).toByteArray()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,8 +288,14 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
|
||||
/**
|
||||
* Clean up unused files in the shared files directory leveraged across all current snapshots. A file
|
||||
* is unused if it is not referenced directly by any current snapshots.
|
||||
*
|
||||
* @param allFilesProgressListener reports progress of the enumeration phase (fast, 256 shards)
|
||||
* @param deletionProgressListener reports progress of the deletion phase (slow, potentially thousands of SAF calls). Fires from multiple threads.
|
||||
*/
|
||||
fun deleteUnusedFiles(allFilesProgressListener: AllFilesProgressListener? = null) {
|
||||
fun deleteUnusedFiles(
|
||||
allFilesProgressListener: AllFilesProgressListener? = null,
|
||||
deletionProgressListener: AllFilesProgressListener? = null
|
||||
) {
|
||||
Log.i(TAG, "Deleting unused files")
|
||||
|
||||
val allFiles: MutableMap<String, DocumentFileInfo> = filesFileSystem.allFiles(allFilesProgressListener).toMutableMap()
|
||||
@@ -218,16 +309,38 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
|
||||
}
|
||||
}
|
||||
|
||||
var deleted = 0
|
||||
allFiles
|
||||
.values
|
||||
.forEach {
|
||||
if (it.documentFile.delete()) {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
val toDelete = allFiles.values.toList()
|
||||
val total = toDelete.size
|
||||
if (total == 0) {
|
||||
Log.d(TAG, "Cleanup removed 0/0 files")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Cleanup removed $deleted/${allFiles.size} files")
|
||||
val deleted = AtomicInteger(0)
|
||||
val completed = AtomicInteger(0)
|
||||
val concurrency = Runtime.getRuntime().availableProcessors().coerceAtMost(8)
|
||||
val chunkSize = ((total + concurrency - 1) / concurrency).coerceAtLeast(1)
|
||||
|
||||
runBlocking {
|
||||
supervisorScope {
|
||||
toDelete.chunked(chunkSize).map { chunk ->
|
||||
async(Dispatchers.IO) {
|
||||
try {
|
||||
for (info in chunk) {
|
||||
if (info.documentFile.delete()) {
|
||||
deleted.incrementAndGet()
|
||||
}
|
||||
deletionProgressListener?.onProgress(completed.incrementAndGet(), total)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to clean up a chunk.", e)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Cleanup removed ${deleted.get()}/$total files")
|
||||
}
|
||||
|
||||
/** Useful metadata for a given archive snapshot */
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.local
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.archive.local.ArchivedFilesWriter
|
||||
import org.signal.archive.local.proto.FilesFrame
|
||||
@@ -20,7 +22,9 @@ import org.signal.core.util.Util
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readFully
|
||||
import org.signal.core.util.toJson
|
||||
import org.signal.libsignal.crypto.Aes256Ctr32
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
@@ -33,11 +37,6 @@ import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.Collections
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
typealias ArchiveResult = org.signal.core.util.Result<LocalArchiver.ArchiveSuccess, LocalArchiver.ArchiveFailure>
|
||||
typealias RestoreResult = org.signal.core.util.Result<LocalArchiver.RestoreSuccess, LocalArchiver.RestoreFailure>
|
||||
@@ -74,10 +73,10 @@ object LocalArchiver {
|
||||
|
||||
Log.i(TAG, "Listing all current files")
|
||||
val allFiles = filesFileSystem.allFiles { completed, total ->
|
||||
SignalStore.backup.newLocalBackupProgress = LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.INITIALIZING, frameExportCount = completed.toLong(), frameTotalCount = total.toLong()))
|
||||
LocalExportProgress.setEncryptedProgress(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.INITIALIZING, frameExportCount = completed.toLong(), frameTotalCount = total.toLong())))
|
||||
}
|
||||
stopwatch.split("files-list")
|
||||
SignalStore.backup.newLocalBackupProgress = LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.INITIALIZING))
|
||||
LocalExportProgress.setEncryptedProgress(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.INITIALIZING)))
|
||||
|
||||
val mediaNames: MutableSet<MediaName> = Collections.synchronizedSet(HashSet())
|
||||
|
||||
@@ -146,36 +145,44 @@ object LocalArchiver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a plaintext archive to the provided [zipOutputStream].
|
||||
* Export a plaintext archive to the provided [directory].
|
||||
*/
|
||||
fun exportPlaintext(
|
||||
zipOutputStream: ZipOutputStream,
|
||||
directory: DocumentFile,
|
||||
contentResolver: ContentResolver,
|
||||
includeMedia: Boolean,
|
||||
stopwatch: Stopwatch,
|
||||
cancellationSignal: () -> Boolean = { false }
|
||||
): ArchiveResult {
|
||||
try {
|
||||
zipOutputStream.putNextEntry(ZipEntry("metadata.json"))
|
||||
zipOutputStream.write(Metadata(version = VERSION, backupId = getEncryptedBackupId()).toJson().toByteArray())
|
||||
zipOutputStream.closeEntry()
|
||||
val metadataFile = directory.createFile("application/octet-stream", "metadata.json")
|
||||
?: return ArchiveResult.failure(ArchiveFailure.MetadataStream)
|
||||
contentResolver.openOutputStream(metadataFile.uri)?.use { out ->
|
||||
out.write(Metadata(version = VERSION, backupId = getEncryptedBackupId()).toJson().toByteArray())
|
||||
} ?: return ArchiveResult.failure(ArchiveFailure.MetadataStream)
|
||||
stopwatch.split("metadata")
|
||||
|
||||
zipOutputStream.putNextEntry(ZipEntry("main.jsonl"))
|
||||
val mainFile = directory.createFile("application/octet-stream", "main.jsonl")
|
||||
?: return ArchiveResult.failure(ArchiveFailure.MainStream)
|
||||
val progressListener = LocalPlaintextExportProgressListener()
|
||||
val attachments = BackupRepository.exportForLocalPlaintextArchive(
|
||||
outputStream = zipOutputStream,
|
||||
progressEmitter = progressListener,
|
||||
cancellationSignal = cancellationSignal,
|
||||
includeMedia = includeMedia
|
||||
)
|
||||
zipOutputStream.closeEntry()
|
||||
val attachments = contentResolver.openOutputStream(mainFile.uri)?.use { mainStream ->
|
||||
BackupRepository.exportForLocalPlaintextArchive(
|
||||
outputStream = mainStream,
|
||||
progressEmitter = progressListener,
|
||||
cancellationSignal = cancellationSignal,
|
||||
includeMedia = includeMedia
|
||||
)
|
||||
} ?: return ArchiveResult.failure(ArchiveFailure.MainStream)
|
||||
stopwatch.split("frames")
|
||||
|
||||
if (includeMedia) {
|
||||
val filesDir = directory.createDirectory("files")
|
||||
?: return ArchiveResult.failure(ArchiveFailure.FilesStream)
|
||||
val total = attachments.size.toLong()
|
||||
var completed = 0L
|
||||
progressListener.onAttachment(0, total)
|
||||
val writtenEntries = HashSet<String>()
|
||||
val prefixDirs = HashMap<String, DocumentFile>()
|
||||
for (attachment in attachments) {
|
||||
if (cancellationSignal()) break
|
||||
val mediaName = MediaName.forLocalBackupFilename(attachment.plaintextHash, attachment.localBackupKey.key)
|
||||
@@ -186,13 +193,21 @@ object LocalArchiver {
|
||||
?.let { ".$it" }
|
||||
?: ""
|
||||
val prefix = mediaName.name.substring(0..1)
|
||||
val entryName = "files/$prefix/${mediaName.name}$ext"
|
||||
val entryName = "$prefix/${mediaName.name}$ext"
|
||||
if (!writtenEntries.add(entryName)) continue
|
||||
zipOutputStream.putNextEntry(ZipEntry(entryName))
|
||||
SignalDatabase.attachments.getAttachmentStream(attachment).use { input ->
|
||||
StreamUtil.copy(input, zipOutputStream, false, false)
|
||||
val prefixDir = prefixDirs[prefix]
|
||||
?: filesDir.createDirectory(prefix)?.also { prefixDirs[prefix] = it }
|
||||
?: run {
|
||||
Log.w(TAG, "Unable to create prefix directory $prefix, skipping attachment ${attachment.attachmentId}")
|
||||
progressListener.onAttachment(++completed, total)
|
||||
continue
|
||||
}
|
||||
val mediaFile = prefixDir.createFile("application/octet-stream", "${mediaName.name}$ext") ?: continue
|
||||
contentResolver.openOutputStream(mediaFile.uri)?.use { out ->
|
||||
SignalDatabase.attachments.getAttachmentStream(attachment).use { input ->
|
||||
StreamUtil.copy(input, out, false, false)
|
||||
}
|
||||
}
|
||||
zipOutputStream.closeEntry()
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Unable to export attachment ${attachment.attachmentId}, skipping", e)
|
||||
}
|
||||
@@ -216,14 +231,19 @@ object LocalArchiver {
|
||||
val metadataKey = SignalStore.backup.messageBackupKey.deriveLocalBackupMetadataKey()
|
||||
val iv = Util.getSecretBytes(12)
|
||||
val backupId = SignalStore.backup.messageBackupKey.deriveBackupId(SignalStore.account.requireAci())
|
||||
|
||||
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(metadataKey, "AES"), IvParameterSpec(iv))
|
||||
val cipherText = cipher.doFinal(backupId.value)
|
||||
val cipherText = applyCipher(backupId.value, metadataKey, iv)
|
||||
|
||||
return Metadata.EncryptedBackupId(iv = iv.toByteString(), encryptedId = cipherText.toByteString())
|
||||
}
|
||||
|
||||
private fun applyCipher(input: ByteArray, metadataKey: ByteArray, iv: ByteArray): ByteArray {
|
||||
val data = input.copyOf()
|
||||
val cipher = Aes256Ctr32(metadataKey, iv, 0)
|
||||
cipher.process(data)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Import archive data from a folder on the system. Does not restore attachments.
|
||||
*/
|
||||
@@ -300,10 +320,7 @@ object LocalArchiver {
|
||||
val metadataKey = messageBackupKey.deriveLocalBackupMetadataKey()
|
||||
val iv = encryptedBackupId.iv.toByteArray()
|
||||
val backupIdCipher = encryptedBackupId.encryptedId.toByteArray()
|
||||
|
||||
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(metadataKey, "AES"), IvParameterSpec(iv))
|
||||
val plaintext = cipher.doFinal(backupIdCipher)
|
||||
val plaintext = applyCipher(backupIdCipher, metadataKey, iv)
|
||||
|
||||
return BackupId(plaintext)
|
||||
}
|
||||
@@ -387,7 +404,7 @@ object LocalArchiver {
|
||||
}
|
||||
|
||||
private fun post(progress: LocalBackupCreationProgress) {
|
||||
SignalStore.backup.newLocalBackupProgress = progress
|
||||
LocalExportProgress.setEncryptedProgress(progress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,7 +459,7 @@ object LocalArchiver {
|
||||
}
|
||||
|
||||
private fun post(progress: LocalBackupCreationProgress) {
|
||||
SignalStore.backup.newLocalPlaintextBackupProgress = progress
|
||||
LocalExportProgress.setPlaintextProgress(progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -205,7 +205,7 @@ private fun FeatureBullet(text: String) {
|
||||
modifier = Modifier.padding(vertical = 2.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_check_24),
|
||||
imageVector = ImageVector.vectorResource(id = CoreUiR.drawable.symbol_check_24),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
|
||||
/**
|
||||
* Sheet displayed when the user's backup restoration failed during media import. Generally due
|
||||
* to the files no longer being available.
|
||||
*/
|
||||
class CouldNotCompleteBackupRestoreSheet : ComposeBottomSheetDialogFragment() {
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
CouldNotCompleteBackupRestoreSheetContent(
|
||||
onOkClick = { dismiss() },
|
||||
onLearnMoreClick = {
|
||||
dismiss()
|
||||
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.backup_support_url))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CouldNotCompleteBackupRestoreSheetContent(
|
||||
onOkClick: () -> Unit = {},
|
||||
onLearnMoreClick: () -> Unit = {}
|
||||
) {
|
||||
val ok = stringResource(android.R.string.ok)
|
||||
val primaryActionButtonState: BackupAlertActionButtonState = remember(ok, onOkClick) {
|
||||
BackupAlertActionButtonState(
|
||||
label = ok,
|
||||
callback = onOkClick
|
||||
)
|
||||
}
|
||||
|
||||
val learnMore = stringResource(R.string.preferences__app_icon_learn_more)
|
||||
val secondaryActionButtonState: BackupAlertActionButtonState = remember(learnMore, onLearnMoreClick) {
|
||||
BackupAlertActionButtonState(
|
||||
label = learnMore,
|
||||
callback = onLearnMoreClick
|
||||
)
|
||||
}
|
||||
|
||||
BackupAlertBottomSheetContainer(
|
||||
icon = {
|
||||
BackupAlertIcon(iconColors = BackupsIconColors.Error)
|
||||
},
|
||||
title = stringResource(R.string.CouldNotCompleteBackupRestoreSheet__title),
|
||||
primaryActionButtonState = primaryActionButtonState,
|
||||
secondaryActionButtonState = secondaryActionButtonState
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.CouldNotCompleteBackupRestoreSheet__body_error)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.CouldNotCompleteBackupRestoreSheet__body_retry)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun CouldNotCompleteBackupRestoreSheetContentPreview() {
|
||||
Previews.BottomSheetContentPreview {
|
||||
CouldNotCompleteBackupRestoreSheetContent()
|
||||
}
|
||||
}
|
||||
+8
-5
@@ -164,10 +164,10 @@ private fun ArchiveRestoreProgressState.iconResource(): Int {
|
||||
RestoreStatus.WAITING_FOR_INTERNET,
|
||||
RestoreStatus.WAITING_FOR_WIFI,
|
||||
RestoreStatus.LOW_BATTERY -> R.drawable.symbol_backup_light
|
||||
|
||||
RestoreStatus.NOT_ENOUGH_DISK_SPACE -> R.drawable.symbol_backup_error_24
|
||||
RestoreStatus.FINISHED -> CoreUiR.drawable.symbol_check_circle_24
|
||||
RestoreStatus.NONE -> throw IllegalStateException()
|
||||
RestoreStatus.NONE,
|
||||
RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +199,8 @@ private fun ArchiveRestoreProgressState.iconColor(): Color {
|
||||
RestoreStatus.NOT_ENOUGH_DISK_SPACE -> BackupsIconColors.Warning.foreground
|
||||
|
||||
RestoreStatus.FINISHED -> BackupsIconColors.Success.foreground
|
||||
RestoreStatus.NONE -> throw IllegalStateException()
|
||||
RestoreStatus.NONE,
|
||||
RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +234,8 @@ private fun ArchiveRestoreProgressState.title(): String {
|
||||
}
|
||||
|
||||
RestoreStatus.FINISHED -> stringResource(R.string.BackupStatus__restore_complete)
|
||||
RestoreStatus.NONE -> throw IllegalStateException()
|
||||
RestoreStatus.NONE,
|
||||
RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +279,8 @@ private fun ArchiveRestoreProgressState.status(): String? {
|
||||
RestoreStatus.LOW_BATTERY -> stringResource(R.string.BackupStatus__status_device_has_low_battery)
|
||||
RestoreStatus.NOT_ENOUGH_DISK_SPACE -> null
|
||||
RestoreStatus.FINISHED -> this.totalToRestoreThisRun.toUnitString()
|
||||
RestoreStatus.NONE -> throw IllegalStateException()
|
||||
RestoreStatus.NONE,
|
||||
RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> throw IllegalStateException()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-4
@@ -10,6 +10,8 @@ import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -22,6 +24,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.exportProgress
|
||||
import org.thoughtcrime.securesms.backup.transferProgress
|
||||
@@ -32,7 +35,8 @@ import org.signal.core.ui.R as CoreUiR
|
||||
fun BackupCreationProgressRow(
|
||||
progress: LocalBackupCreationProgress,
|
||||
isRemote: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
onCancel: (() -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
@@ -42,7 +46,7 @@ fun BackupCreationProgressRow(
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
BackupCreationProgressIndicator(progress = progress)
|
||||
BackupCreationProgressIndicator(progress = progress, onCancel = onCancel)
|
||||
|
||||
Text(
|
||||
text = getProgressMessage(progress, isRemote),
|
||||
@@ -55,7 +59,8 @@ fun BackupCreationProgressRow(
|
||||
|
||||
@Composable
|
||||
private fun BackupCreationProgressIndicator(
|
||||
progress: LocalBackupCreationProgress
|
||||
progress: LocalBackupCreationProgress,
|
||||
onCancel: (() -> Unit)? = null
|
||||
) {
|
||||
val exporting = progress.exporting
|
||||
val transferring = progress.transferring
|
||||
@@ -93,6 +98,15 @@ private fun BackupCreationProgressIndicator(
|
||||
.padding(vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (onCancel != null) {
|
||||
IconButton(onClick = onCancel) {
|
||||
Icon(
|
||||
imageVector = SignalIcons.X.imageVector,
|
||||
contentDescription = "Cancel"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +238,8 @@ private fun TransferringRemotePreview() {
|
||||
mediaPhase = true
|
||||
)
|
||||
),
|
||||
isRemote = true
|
||||
isRemote = true,
|
||||
onCancel = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,8 @@ private fun progressColor(backupStatusData: ArchiveRestoreProgressState): Color
|
||||
RestoreStatus.LOW_BATTERY,
|
||||
RestoreStatus.NOT_ENOUGH_DISK_SPACE -> BackupsIconColors.Warning.foreground
|
||||
RestoreStatus.FINISHED -> BackupsIconColors.Success.foreground
|
||||
RestoreStatus.NONE -> BackupsIconColors.Normal.foreground
|
||||
RestoreStatus.NONE,
|
||||
RestoreStatus.LOCAL_RESTORE_DIRECTORY_UNAVAILABLE -> BackupsIconColors.Normal.foreground
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -142,7 +142,12 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
|
||||
composable(route = MessageBackupsStage.Route.BACKUP_KEY_EDUCATION.name) {
|
||||
MessageBackupsKeyEducationScreen(
|
||||
onNavigationClick = viewModel::goToPreviousStage,
|
||||
onNextClick = viewModel::goToNextStage
|
||||
onNextClick = viewModel::goToNextStage,
|
||||
mode = if (SignalStore.backup.newLocalBackupsEnabled) {
|
||||
MessageBackupsKeyEducationScreenMode.REMOTE_WITH_LOCAL_ENABLED
|
||||
} else {
|
||||
MessageBackupsKeyEducationScreenMode.DEFAULT
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -24,6 +24,7 @@ import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.billing.BillingPurchaseResult
|
||||
import org.signal.core.util.concurrent.SignalDispatchers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.next
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
@@ -45,7 +46,6 @@ import org.thoughtcrime.securesms.jobs.InAppPaymentPurchaseTokenJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.next
|
||||
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
+57
-13
@@ -50,9 +50,9 @@ import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
enum class MessageBackupsKeyEducationScreenMode {
|
||||
/**
|
||||
* Displayed when the user is enabling remote backups and does not have unified local backups enabled
|
||||
* Displayed when the user is enabling remote backups, or local backups without remote enabled.
|
||||
*/
|
||||
REMOTE_BACKUP_WITH_LOCAL_DISABLED,
|
||||
DEFAULT,
|
||||
|
||||
/**
|
||||
* Displayed when the user is upgrading legacy to unified local backup
|
||||
@@ -60,9 +60,14 @@ enum class MessageBackupsKeyEducationScreenMode {
|
||||
LOCAL_BACKUP_UPGRADE,
|
||||
|
||||
/**
|
||||
* Displayed when the user has unified local backup and is enabling remote backups
|
||||
* Displayed when the user has remote backups enabled and is enabling local backups
|
||||
*/
|
||||
REMOTE_BACKUP_WITH_LOCAL_ENABLED
|
||||
LOCAL_WITH_REMOTE_ENABLED,
|
||||
|
||||
/**
|
||||
* Displayed when the user has local backups enabled and is enabling remote backups
|
||||
*/
|
||||
REMOTE_WITH_LOCAL_ENABLED
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +77,7 @@ enum class MessageBackupsKeyEducationScreenMode {
|
||||
fun MessageBackupsKeyEducationScreen(
|
||||
onNavigationClick: () -> Unit = {},
|
||||
onNextClick: () -> Unit = {},
|
||||
mode: MessageBackupsKeyEducationScreenMode = MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_DISABLED
|
||||
mode: MessageBackupsKeyEducationScreenMode = MessageBackupsKeyEducationScreenMode.DEFAULT
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
@@ -105,14 +110,19 @@ fun MessageBackupsKeyEducationScreen(
|
||||
)
|
||||
|
||||
when (mode) {
|
||||
MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_DISABLED -> {
|
||||
MessageBackupsKeyEducationScreenMode.DEFAULT -> {
|
||||
RemoteBackupWithLocalDisabledInfo()
|
||||
}
|
||||
|
||||
MessageBackupsKeyEducationScreenMode.LOCAL_BACKUP_UPGRADE -> {
|
||||
LocalBackupUpgradeInfo()
|
||||
}
|
||||
MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_ENABLED -> {
|
||||
|
||||
MessageBackupsKeyEducationScreenMode.LOCAL_WITH_REMOTE_ENABLED -> {
|
||||
LocalBackupWithRemoteEnabledInfo()
|
||||
}
|
||||
|
||||
MessageBackupsKeyEducationScreenMode.REMOTE_WITH_LOCAL_ENABLED -> {
|
||||
RemoteBackupWithLocalEnabledInfo()
|
||||
}
|
||||
}
|
||||
@@ -145,9 +155,8 @@ fun MessageBackupsKeyEducationScreen(
|
||||
@Composable
|
||||
private fun getTitleText(mode: MessageBackupsKeyEducationScreenMode): String {
|
||||
return when (mode) {
|
||||
MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_DISABLED -> stringResource(R.string.MessageBackupsKeyEducationScreen__your_backup_key)
|
||||
MessageBackupsKeyEducationScreenMode.LOCAL_BACKUP_UPGRADE -> stringResource(R.string.MessageBackupsKeyEducationScreen__your_new_recovery_key)
|
||||
MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_ENABLED -> stringResource(R.string.MessageBackupsKeyEducationScreen__your_recovery_key)
|
||||
else -> stringResource(R.string.MessageBackupsKeyEducationScreen__your_recovery_key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +185,31 @@ private fun LocalBackupUpgradeInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocalBackupWithRemoteEnabledInfo() {
|
||||
val normalText = stringResource(R.string.MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description)
|
||||
val boldText = stringResource(R.string.MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold)
|
||||
|
||||
DescriptionText(
|
||||
normalText = normalText,
|
||||
boldText = boldText
|
||||
)
|
||||
|
||||
UseThisKeyToContainer {
|
||||
UseThisKeyToRow(
|
||||
icon = ImageVector.vectorResource(R.drawable.symbol_folder_24),
|
||||
text = stringResource(R.string.MessageBackupsKeyEducationScreen__restore_on_device_backup)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.padding(vertical = 16.dp))
|
||||
|
||||
UseThisKeyToRow(
|
||||
icon = ImageVector.vectorResource(CoreUiR.drawable.symbol_backup_24),
|
||||
text = stringResource(R.string.MessageBackupsKeyEducationScreen__restore_your_signal_secure_backup)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoteBackupWithLocalEnabledInfo() {
|
||||
val normalText = stringResource(R.string.MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description)
|
||||
@@ -313,10 +347,10 @@ private fun InfoRow(@DrawableRes iconId: Int, @StringRes textId: Int) {
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MessageBackupsKeyEducationScreenRemoteBackupWithLocalDisabledPreview() {
|
||||
private fun MessageBackupsKeyEducationScreenDefaultPreview() {
|
||||
Previews.Preview {
|
||||
MessageBackupsKeyEducationScreen(
|
||||
mode = MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_DISABLED
|
||||
mode = MessageBackupsKeyEducationScreenMode.DEFAULT
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -333,10 +367,20 @@ private fun MessageBackupsKeyEducationScreenLocalBackupUpgradePreview() {
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MessageBackupsKeyEducationScreenRemoteBackupWithLocalEnabledPreview() {
|
||||
private fun MessageBackupsKeyEducationScreenLocalBackupWithRemoteEnabledPreview() {
|
||||
Previews.Preview {
|
||||
MessageBackupsKeyEducationScreen(
|
||||
mode = MessageBackupsKeyEducationScreenMode.REMOTE_BACKUP_WITH_LOCAL_ENABLED
|
||||
mode = MessageBackupsKeyEducationScreenMode.LOCAL_WITH_REMOTE_ENABLED
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MessageBackupsKeyEducationScreenRemoteBackupWithLocalEnabledPreview() {
|
||||
Previews.Preview {
|
||||
MessageBackupsKeyEducationScreen(
|
||||
mode = MessageBackupsKeyEducationScreenMode.REMOTE_WITH_LOCAL_ENABLED
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ private fun Wallpaper.LinearGradient.toRemoteWallpaperPreset(): ChatStyle.Wallpa
|
||||
|
||||
private fun Wallpaper.File.toFilePointer(db: SignalDatabase, backupMode: BackupMode): FilePointer? {
|
||||
val attachmentId: AttachmentId = UriUtil.parseOrNull(this.uri)?.let { PartUriParser(it).partId } ?: return null
|
||||
val attachment = db.attachmentTable.getAttachment(attachmentId)
|
||||
val attachment = db.attachmentTable.getAttachmentWithMetadata(attachmentId)
|
||||
return attachment?.toRemoteFilePointer(backupMode = backupMode)
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -27,6 +26,7 @@ import androidx.compose.ui.Alignment.Companion.End
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
@@ -43,7 +43,7 @@ import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.ComposeDialogFragment
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.isSplitPane
|
||||
import org.signal.core.ui.rememberIsSplitPane
|
||||
import org.signal.core.util.BreakIteratorCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsViewModel
|
||||
@@ -109,7 +109,7 @@ fun EditCallLinkNameScreen(
|
||||
onNavigationClick = {
|
||||
backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed()
|
||||
},
|
||||
showNavigationIcon = !currentWindowAdaptiveInfo().windowSizeClass.isSplitPane()
|
||||
showNavigationIcon = !LocalResources.current.rememberIsSplitPane()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -15,12 +15,12 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.ShareCompat
|
||||
@@ -37,7 +37,7 @@ import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.compose.Snackbars
|
||||
import org.signal.core.ui.isSplitPane
|
||||
import org.signal.core.ui.rememberIsSplitPane
|
||||
import org.signal.core.util.Util
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.ringrtc.CallLinkState.Restrictions
|
||||
@@ -83,7 +83,7 @@ fun CallLinkDetailsScreen(
|
||||
state = state,
|
||||
showAlreadyInACall = showAlreadyInACall,
|
||||
callback = callback,
|
||||
showNavigationIcon = !currentWindowAdaptiveInfo().windowSizeClass.isSplitPane()
|
||||
showNavigationIcon = !LocalResources.current.rememberIsSplitPane()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
@@ -67,7 +68,7 @@ class CallEventCache(
|
||||
|
||||
val output = mutableListOf<CallLogRow.Call>()
|
||||
val groupCallStateMap = mutableMapOf<Long, CallLogRow.GroupCallState>()
|
||||
val canUserBeginCallMap = mutableMapOf<Long, Boolean>()
|
||||
val canUserBeginCallMap = mutableMapOf<Long, CallLogRow.CanStartCall>()
|
||||
val callLinksSeen = hashSetOf<Long>()
|
||||
|
||||
while (recordIterator.hasNext()) {
|
||||
@@ -85,7 +86,7 @@ class CallEventCache(
|
||||
private fun ListIterator<CacheRecord>.readNextCallLog(
|
||||
filterState: FilterState,
|
||||
groupCallStateMap: MutableMap<Long, CallLogRow.GroupCallState>,
|
||||
canUserBeginCallMap: MutableMap<Long, Boolean>,
|
||||
canUserBeginCallMap: MutableMap<Long, CallLogRow.CanStartCall>,
|
||||
callLinksSeen: MutableSet<Long>
|
||||
): CallLogRow.Call? {
|
||||
val parent = next()
|
||||
@@ -143,14 +144,16 @@ class CallEventCache(
|
||||
return (child.timestamp - parent.timestamp) <= 4.hours.inWholeMilliseconds
|
||||
}
|
||||
|
||||
private fun canUserBeginCall(peer: Recipient, decryptedGroup: ByteArray?): Boolean {
|
||||
return if (peer.isGroup && decryptedGroup != null) {
|
||||
private fun canUserBeginCall(peer: Recipient, decryptedGroup: ByteArray?): CallLogRow.CanStartCall {
|
||||
if (peer.isGroup && decryptedGroup != null) {
|
||||
val proto = DecryptedGroup.ADAPTER.decode(decryptedGroup)
|
||||
return proto.isAnnouncementGroup != EnabledState.ENABLED ||
|
||||
proto.members.firstOrNull() { it.aciBytes == SignalStore.account.aci?.toByteString() }?.role == Member.Role.ADMINISTRATOR
|
||||
} else {
|
||||
true
|
||||
when {
|
||||
proto.terminated -> return CallLogRow.CanStartCall.GROUP_TERMINATED
|
||||
DecryptedGroupUtil.findMemberByAci(proto.members, SignalStore.account.requireAci()).isEmpty -> return CallLogRow.CanStartCall.NOT_A_MEMBER
|
||||
proto.isAnnouncementGroup == EnabledState.ENABLED && proto.members.firstOrNull { it.aciBytes == SignalStore.account.aci?.toByteString() }?.role != Member.Role.ADMINISTRATOR -> return CallLogRow.CanStartCall.ADMIN_ONLY
|
||||
}
|
||||
}
|
||||
return CallLogRow.CanStartCall.ALLOWED
|
||||
}
|
||||
|
||||
private fun getGroupCallState(body: String?): CallLogRow.GroupCallState {
|
||||
@@ -167,7 +170,7 @@ class CallEventCache(
|
||||
children: Set<Long>,
|
||||
filterState: FilterState,
|
||||
groupCallStateCache: MutableMap<Long, CallLogRow.GroupCallState>,
|
||||
canUserBeginCallMap: MutableMap<Long, Boolean>
|
||||
canUserBeginCallMap: MutableMap<Long, CallLogRow.CanStartCall>
|
||||
): CallLogRow.Call {
|
||||
val peer = Recipient.resolved(RecipientId.from(parent.peer))
|
||||
return CallLogRow.Call(
|
||||
@@ -195,10 +198,10 @@ class CallEventCache(
|
||||
searchQuery = filterState.query,
|
||||
callLinkPeekInfo = AppDependencies.signalCallManager.peekInfoSnapshot[peer.id],
|
||||
canUserBeginCall = if (peer.isGroup) {
|
||||
if (peer.isActiveGroup) {
|
||||
canUserBeginCallMap.getOrPut(parent.peer) { canUserBeginCall(peer, parent.decryptedGroupBytes) }
|
||||
} else false
|
||||
} else true
|
||||
canUserBeginCallMap.getOrPut(parent.peer) { canUserBeginCall(peer, parent.decryptedGroupBytes) }
|
||||
} else {
|
||||
CallLogRow.CanStartCall.ALLOWED
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ class CallLogAdapter(
|
||||
binding: CallLogAdapterItemBinding,
|
||||
private val onCallLinkClicked: (CallLogRow.CallLink) -> Unit,
|
||||
private val onCallLinkLongClicked: (View, CallLogRow.CallLink) -> Boolean,
|
||||
private val onStartVideoCallClicked: (Recipient, Boolean) -> Unit
|
||||
private val onStartVideoCallClicked: (Recipient, CallLogRow.CanStartCall) -> Unit
|
||||
) : BindingViewHolder<CallLinkModel, CallLogAdapterItemBinding>(binding) {
|
||||
override fun bind(model: CallLinkModel) {
|
||||
if (payload.size == 1 && payload.contains(PAYLOAD_TIMESTAMP)) {
|
||||
@@ -280,7 +280,7 @@ class CallLogAdapter(
|
||||
}
|
||||
)
|
||||
binding.groupCallButton.setOnClickListener {
|
||||
onStartVideoCallClicked(model.callLink.recipient, true)
|
||||
onStartVideoCallClicked(model.callLink.recipient, CallLogRow.CanStartCall.ALLOWED)
|
||||
}
|
||||
binding.callType.visible = false
|
||||
binding.groupCallButton.visible = true
|
||||
@@ -288,7 +288,7 @@ class CallLogAdapter(
|
||||
binding.callType.setImageResource(R.drawable.symbol_video_24)
|
||||
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
|
||||
binding.callType.setOnClickListener {
|
||||
onStartVideoCallClicked(model.callLink.recipient, true)
|
||||
onStartVideoCallClicked(model.callLink.recipient, CallLogRow.CanStartCall.ALLOWED)
|
||||
}
|
||||
binding.callType.visible = true
|
||||
binding.groupCallButton.visible = false
|
||||
@@ -301,7 +301,7 @@ class CallLogAdapter(
|
||||
private val onCallClicked: (CallLogRow.Call) -> Unit,
|
||||
private val onCallLongClicked: (View, CallLogRow.Call) -> Boolean,
|
||||
private val onStartAudioCallClicked: (Recipient) -> Unit,
|
||||
private val onStartVideoCallClicked: (Recipient, Boolean) -> Unit
|
||||
private val onStartVideoCallClicked: (Recipient, CallLogRow.CanStartCall) -> Unit
|
||||
) : BindingViewHolder<CallModel, CallLogAdapterItemBinding>(binding) {
|
||||
override fun bind(model: CallModel) {
|
||||
itemView.setOnClickListener {
|
||||
@@ -401,7 +401,7 @@ class CallLogAdapter(
|
||||
CallTable.Type.VIDEO_CALL -> {
|
||||
binding.callType.setImageResource(R.drawable.symbol_video_24)
|
||||
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
|
||||
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer, true) }
|
||||
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer, CallLogRow.CanStartCall.ALLOWED) }
|
||||
binding.callType.visible = true
|
||||
binding.groupCallButton.visible = false
|
||||
}
|
||||
@@ -574,6 +574,6 @@ class CallLogAdapter(
|
||||
/**
|
||||
* Invoked when user presses the video icon
|
||||
*/
|
||||
fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: Boolean)
|
||||
fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: CallLogRow.CanStartCall)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.BottomSheetUtil
|
||||
import org.signal.core.ui.compose.Snackbars
|
||||
import org.signal.core.ui.getWindowSizeClass
|
||||
import org.signal.core.ui.isSplitPane
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
@@ -133,7 +132,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
val filteredCount = callLogAdapter.submitCallRows(
|
||||
data,
|
||||
selected,
|
||||
activeCallLogRowId = activeRowId.orNull().takeIf { resources.getWindowSizeClass().isSplitPane() },
|
||||
activeCallLogRowId = activeRowId.orNull().takeIf { resources.isSplitPane() },
|
||||
viewModel.callLogPeekHelper.localDeviceCallRecipientId,
|
||||
scrollToPositionDelegate::notifyListCommitted
|
||||
)
|
||||
@@ -187,7 +186,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
}
|
||||
}
|
||||
|
||||
if (!resources.getWindowSizeClass().isSplitPane()) {
|
||||
if (!resources.isSplitPane()) {
|
||||
ViewUtil.setBottomMargin(binding.bottomActionBar, ViewUtil.getNavigationBarHeight(binding.bottomActionBar))
|
||||
}
|
||||
|
||||
@@ -364,18 +363,21 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: Boolean) {
|
||||
if (canUserBeginCall) {
|
||||
CommunicationActions.startVideoCall(this, recipient) {
|
||||
mainNavigationViewModel.snackbarRegistry.emit(
|
||||
SnackbarState(
|
||||
getString(R.string.CommunicationActions__you_are_already_in_a_call),
|
||||
hostKey = MainSnackbarHostKey.MainChrome
|
||||
override fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: CallLogRow.CanStartCall) {
|
||||
when (canUserBeginCall) {
|
||||
CallLogRow.CanStartCall.ALLOWED -> {
|
||||
CommunicationActions.startVideoCall(this, recipient) {
|
||||
mainNavigationViewModel.snackbarRegistry.emit(
|
||||
SnackbarState(
|
||||
getString(R.string.CommunicationActions__you_are_already_in_a_call),
|
||||
hostKey = MainSnackbarHostKey.MainChrome
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
|
||||
CallLogRow.CanStartCall.GROUP_TERMINATED -> ConversationDialogs.displayCannotStartGroupCallDueToGroupEndedDialog(requireContext())
|
||||
CallLogRow.CanStartCall.NOT_A_MEMBER -> ConversationDialogs.displayCannotStartGroupCallDueToNoLongerAMemberDialog(requireContext())
|
||||
CallLogRow.CanStartCall.ADMIN_ONLY -> ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ sealed class CallLogRow {
|
||||
val children: Set<Long>,
|
||||
val searchQuery: String?,
|
||||
val callLinkPeekInfo: CallLinkPeekInfo?,
|
||||
val canUserBeginCall: Boolean,
|
||||
val canUserBeginCall: CanStartCall,
|
||||
override val id: Id = Id.Call(children)
|
||||
) : CallLogRow()
|
||||
|
||||
@@ -111,4 +111,11 @@ sealed class CallLogRow {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class CanStartCall {
|
||||
ALLOWED,
|
||||
ADMIN_ONLY,
|
||||
NOT_A_MEMBER,
|
||||
GROUP_TERMINATED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -413,7 +414,7 @@ private fun IssueChip(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = if (isSelected) {
|
||||
ImageVector.vectorResource(R.drawable.symbol_check_24)
|
||||
ImageVector.vectorResource(CoreUiR.drawable.symbol_check_24)
|
||||
} else {
|
||||
ImageVector.vectorResource(issue.category.icon)
|
||||
},
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package org.thoughtcrime.securesms.color;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
public class MaterialColors {
|
||||
|
||||
public static final MaterialColorList CONVERSATION_PALETTE = new MaterialColorList(new ArrayList<>(Arrays.asList(
|
||||
MaterialColor.PLUM,
|
||||
MaterialColor.CRIMSON,
|
||||
MaterialColor.VERMILLION,
|
||||
MaterialColor.VIOLET,
|
||||
MaterialColor.INDIGO,
|
||||
MaterialColor.TAUPE,
|
||||
MaterialColor.ULTRAMARINE,
|
||||
MaterialColor.BLUE,
|
||||
MaterialColor.TEAL,
|
||||
MaterialColor.FOREST,
|
||||
MaterialColor.WINTERGREEN,
|
||||
MaterialColor.BURLAP,
|
||||
MaterialColor.STEEL
|
||||
)));
|
||||
|
||||
public static class MaterialColorList {
|
||||
|
||||
private final List<MaterialColor> colors;
|
||||
|
||||
private MaterialColorList(List<MaterialColor> colors) {
|
||||
this.colors = colors;
|
||||
}
|
||||
|
||||
public MaterialColor get(int index) {
|
||||
return colors.get(index);
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return colors.size();
|
||||
}
|
||||
|
||||
public @Nullable MaterialColor getByColor(Context context, int colorValue) {
|
||||
for (MaterialColor color : colors) {
|
||||
if (color.represents(context, colorValue)) {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public @ColorInt int[] asConversationColorArray(@NonNull Context context) {
|
||||
int[] results = new int[colors.size()];
|
||||
int index = 0;
|
||||
|
||||
for (MaterialColor color : colors) {
|
||||
results[index++] = color.toConversationColor(context);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Canvas;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.Annotation;
|
||||
import android.text.Editable;
|
||||
import android.text.Selection;
|
||||
@@ -26,9 +25,6 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat;
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat;
|
||||
import androidx.core.view.inputmethod.InputContentInfoCompat;
|
||||
|
||||
import org.signal.core.util.StringUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -69,7 +65,6 @@ public class ComposeText extends EmojiEditText {
|
||||
private MentionValidatorWatcher mentionValidatorWatcher;
|
||||
private MessageSendType lastMessageSendType;
|
||||
|
||||
@Nullable private InputPanel.MediaListener mediaListener;
|
||||
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
|
||||
@Nullable private InlineQueryChangedListener inlineQueryChangedListener;
|
||||
@Nullable private StylingChangedListener stylingChangedListener;
|
||||
@@ -247,20 +242,7 @@ public class ComposeText extends EmojiEditText {
|
||||
editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
|
||||
}
|
||||
|
||||
if (mediaListener == null) {
|
||||
return inputConnection;
|
||||
}
|
||||
|
||||
if (inputConnection == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
EditorInfoCompat.setContentMimeTypes(editorInfo, new String[] { "image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif", "image/avif" });
|
||||
return InputConnectionCompat.createWrapper(inputConnection, editorInfo, new CommitContentListener(mediaListener));
|
||||
}
|
||||
|
||||
public void setMediaListener(@Nullable InputPanel.MediaListener mediaListener) {
|
||||
this.mediaListener = mediaListener;
|
||||
return inputConnection;
|
||||
}
|
||||
|
||||
public boolean hasMentions() {
|
||||
@@ -577,38 +559,6 @@ public class ComposeText extends EmojiEditText {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static class CommitContentListener implements InputConnectionCompat.OnCommitContentListener {
|
||||
|
||||
private static final String TAG = Log.tag(CommitContentListener.class);
|
||||
|
||||
private final InputPanel.MediaListener mediaListener;
|
||||
|
||||
private CommitContentListener(@NonNull InputPanel.MediaListener mediaListener) {
|
||||
this.mediaListener = mediaListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) {
|
||||
if (Build.VERSION.SDK_INT >= 25 && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
|
||||
try {
|
||||
inputContentInfo.requestPermission();
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (inputContentInfo.getDescription().getMimeTypeCount() > 0) {
|
||||
mediaListener.onMediaSelected(inputContentInfo.getContentUri(),
|
||||
inputContentInfo.getDescription().getMimeType(0));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static class QueryStart {
|
||||
public int index;
|
||||
public boolean isMentionQuery;
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.animation.ValueAnimator;
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.hardware.Camera;
|
||||
import android.net.Uri;
|
||||
import android.text.SpannableString;
|
||||
import android.text.format.DateUtils;
|
||||
import android.util.AttributeSet;
|
||||
@@ -208,10 +207,6 @@ public class InputPanel extends ConstraintLayout
|
||||
}
|
||||
}
|
||||
|
||||
public void setMediaListener(@NonNull MediaListener listener) {
|
||||
composeText.setMediaListener(listener);
|
||||
}
|
||||
|
||||
public void setQuote(@NonNull RequestManager requestManager,
|
||||
long id,
|
||||
@NonNull Recipient author,
|
||||
@@ -954,8 +949,4 @@ public class InputPanel extends ConstraintLayout
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public interface MediaListener {
|
||||
void onMediaSelected(@NonNull Uri uri, String contentType);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -152,7 +152,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
val isLtr = ViewUtil.isLtr(this)
|
||||
|
||||
val statusBar = windowInsets.top
|
||||
val navigationBar = navigationBarInsetOverride ?: if (windowInsets.bottom == 0 && Build.VERSION.SDK_INT <= 29) {
|
||||
val navigationBar = navigationBarInsetOverride ?: if (windowInsets.bottom == 0 && Build.VERSION.SDK_INT <= 29 && !ViewUtil.isGestureNavigation(resources, insets)) {
|
||||
ViewUtil.getNavigationBarHeight(resources)
|
||||
} else {
|
||||
windowInsets.bottom
|
||||
|
||||
@@ -289,8 +289,7 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
|
||||
|
||||
QuoteViewColorTheme colorTheme = getColorTheme();
|
||||
int foregroundColor = colorTheme.getForegroundColor(getContext());
|
||||
authorView.setSender(name, foregroundColor);
|
||||
authorView.setLabel(memberLabel, foregroundColor, colorTheme.getLabelBackgroundColor(getContext()));
|
||||
authorView.bind(name, foregroundColor, memberLabel, foregroundColor, colorTheme.getLabelBackgroundColor(getContext()));
|
||||
}
|
||||
|
||||
private boolean isStoryReply() {
|
||||
|
||||
@@ -16,15 +16,14 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.bumptech.glide.RequestManager;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
|
||||
import org.signal.glide.decryptableuri.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.contactshare.ContactUtil;
|
||||
import org.thoughtcrime.securesms.database.RecipientTable;
|
||||
import org.signal.glide.decryptableuri.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
|
||||
@@ -116,7 +115,7 @@ public class SharedContactView extends LinearLayout implements RecipientForeverO
|
||||
this.locale = locale;
|
||||
this.contact = contact;
|
||||
|
||||
Stream.of(activeRecipients.values()).forEach(recipient -> recipient.removeForeverObserver(this));
|
||||
activeRecipients.values().stream().forEach(recipient -> recipient.removeForeverObserver(this));
|
||||
this.activeRecipients.clear();
|
||||
|
||||
presentContact(contact);
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.annotation.Nullable;
|
||||
import androidx.annotation.Px;
|
||||
import androidx.annotation.UiThread;
|
||||
import androidx.appcompat.widget.AppCompatImageView;
|
||||
import com.google.android.material.color.MaterialColors;
|
||||
|
||||
import com.bumptech.glide.RequestBuilder;
|
||||
import com.bumptech.glide.RequestManager;
|
||||
@@ -347,6 +348,7 @@ public class ThumbnailView extends FrameLayout {
|
||||
|
||||
transferControlViewStub.setVisibility(View.GONE);
|
||||
playOverlay.setVisibility(View.GONE);
|
||||
setBackgroundColor(Color.TRANSPARENT);
|
||||
|
||||
requestManager.clear(blurHash);
|
||||
blurHash.setImageDrawable(null);
|
||||
@@ -407,6 +409,8 @@ public class ThumbnailView extends FrameLayout {
|
||||
}
|
||||
|
||||
if (this.slide != null && this.slide.getFastPreflightId() != null &&
|
||||
this.slide.isInProgress() == slide.isInProgress() &&
|
||||
image.getDrawable() != null &&
|
||||
(!slide.hasVideo() || Util.equals(this.slide.getUri(), slide.getUri())) &&
|
||||
Util.equals(this.slide.getFastPreflightId(), slide.getFastPreflightId()))
|
||||
{
|
||||
@@ -486,6 +490,12 @@ public class ThumbnailView extends FrameLayout {
|
||||
image.setImageDrawable(null);
|
||||
}
|
||||
|
||||
if (slide.getTransferState() == AttachmentTable.TRANSFER_RESTORE_OFFLOADED && slide.getDisplayUri() == null) {
|
||||
setBackgroundColor(MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurfaceVariant, Color.GRAY));
|
||||
} else {
|
||||
setBackgroundColor(Color.TRANSPARENT);
|
||||
}
|
||||
|
||||
if (!resultHandled) {
|
||||
result.set(false);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,12 @@ import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.Util;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.signal.core.util.Util;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
@@ -140,7 +139,7 @@ public class TypingStatusRepository {
|
||||
|
||||
notifier.postValue(new TypingState(new ArrayList<>(uniqueTypists), isReplacedByIncomingMessage));
|
||||
|
||||
Set<Long> activeThreads = Stream.of(typistMap.keySet()).filter(t -> !typistMap.get(t).isEmpty()).collect(Collectors.toSet());
|
||||
Set<Long> activeThreads = typistMap.keySet().stream().filter(t -> !typistMap.get(t).isEmpty()).collect(Collectors.toSet());
|
||||
threadsNotifier.postValue(activeThreads);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,14 +32,14 @@ import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* Applies Signal or System emoji to the given content based off user settings.
|
||||
* Applies Signal or System emoji to the given content based on user settings.
|
||||
*
|
||||
* Text is transformed and passed to content as an annotated string and inline content map.
|
||||
*/
|
||||
@Composable
|
||||
fun Emojifier(
|
||||
text: String,
|
||||
useSystemEmoji: Boolean = !LocalInspectionMode.current && SignalStore.settings.isPreferSystemEmoji,
|
||||
useSystemEmoji: Boolean = LocalInspectionMode.current || SignalStore.settings.isPreferSystemEmoji,
|
||||
content: @Composable (AnnotatedString, Map<String, InlineTextContent>) -> Unit = { annotatedText, inlineContent ->
|
||||
Text(
|
||||
text = annotatedText,
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@ import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import java.util.stream.Collectors;
|
||||
import com.fasterxml.jackson.databind.type.CollectionType;
|
||||
import com.fasterxml.jackson.databind.type.TypeFactory;
|
||||
|
||||
@@ -72,7 +72,7 @@ public class RecentEmojiPageModel implements EmojiPageModel {
|
||||
}
|
||||
|
||||
@Override public List<Emoji> getDisplayEmoji() {
|
||||
return Stream.of(getEmoji()).map(Emoji::new).toList();
|
||||
return getEmoji().stream().map(Emoji::new).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override public @Nullable Uri getSpriteUri() {
|
||||
|
||||
+6
-7
@@ -8,13 +8,14 @@ import android.text.Spanned;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* This wraps an Android standard {@link Annotation} so it can leverage the built in
|
||||
@@ -51,13 +52,12 @@ public final class MentionAnnotation {
|
||||
public static @NonNull List<Mention> getMentionsFromAnnotations(@Nullable CharSequence text) {
|
||||
if (text instanceof Spanned) {
|
||||
Spanned spanned = (Spanned) text;
|
||||
return Stream.of(getMentionAnnotations(spanned))
|
||||
.map(annotation -> {
|
||||
return getMentionAnnotations(spanned).stream()
|
||||
.map(annotation -> {
|
||||
int spanStart = spanned.getSpanStart(annotation);
|
||||
int spanLength = spanned.getSpanEnd(annotation) - spanStart;
|
||||
return new Mention(RecipientId.from(annotation.getValue()), spanStart, spanLength);
|
||||
})
|
||||
.toList();
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
@@ -68,7 +68,6 @@ public final class MentionAnnotation {
|
||||
|
||||
public static @NonNull List<Annotation> getMentionAnnotations(@NonNull Spanned spanned, int start, int end) {
|
||||
return Stream.of(spanned.getSpans(start, end, Annotation.class))
|
||||
.filter(MentionAnnotation::isMentionAnnotation)
|
||||
.toList();
|
||||
.filter(MentionAnnotation::isMentionAnnotation).collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -82,7 +82,8 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
|
||||
appSettingsRoute.threadIds
|
||||
)
|
||||
|
||||
AppSettingsRoute.BackupsRoute.Backups -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment()
|
||||
AppSettingsRoute.ChatsRoute.Chats -> AppSettingsFragmentDirections.actionDirectToChatsSettingsFragment()
|
||||
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}")
|
||||
@@ -214,6 +215,9 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
|
||||
@JvmOverloads
|
||||
fun remoteBackups(context: Context, forQuickRestore: Boolean = false): Intent = getIntentForStartLocation(context, AppSettingsRoute.BackupsRoute.Remote(forQuickRestore = forQuickRestore))
|
||||
|
||||
@JvmStatic
|
||||
fun chats(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChatsRoute.Chats)
|
||||
|
||||
@JvmStatic
|
||||
fun chatFolders(context: Context): Intent = getIntentForStartLocation(context, AppSettingsRoute.ChatFoldersRoute.ChatFolders)
|
||||
|
||||
@@ -229,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)
|
||||
|
||||
+3
-14
@@ -414,21 +414,10 @@ private fun AppSettingsContent(
|
||||
if (state.isPrimaryDevice) {
|
||||
item {
|
||||
Rows.TextRow(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.preferences_chats__backups),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = SignalIcons.Backup.imageVector,
|
||||
contentDescription = stringResource(R.string.preferences_chats__backups),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
icon = SignalIcons.Backup.imageVector,
|
||||
text = stringResource(R.string.preferences_chats__backups),
|
||||
onClick = {
|
||||
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups)
|
||||
callbacks.navigate(AppSettingsRoute.BackupsRoute.Backups())
|
||||
},
|
||||
onLongClick = {
|
||||
callbacks.copyRemoteBackupsSubscriberIdToClipboard()
|
||||
|
||||
+7
@@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import kotlinx.coroutines.delay
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
@@ -62,6 +63,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
import kotlin.getValue
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
@@ -76,11 +78,16 @@ class BackupsSettingsFragment : ComposeFragment() {
|
||||
private lateinit var checkoutLauncher: ActivityResultLauncher<MessageBackupTier?>
|
||||
|
||||
private val viewModel: BackupsSettingsViewModel by viewModels()
|
||||
private val args: BackupsSettingsFragmentArgs by navArgs()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
checkoutLauncher = createBackupsCheckoutLauncher {
|
||||
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_remoteBackupsSettingsFragment)
|
||||
}
|
||||
|
||||
if (savedInstanceState == null && args.launchCheckoutFlow) {
|
||||
checkoutLauncher.launch(null)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+7
-1
@@ -110,7 +110,13 @@ class LocalBackupsFragment : ComposeFragment() {
|
||||
MessageBackupsKeyEducationScreen(
|
||||
onNavigationClick = { backPressedDispatcher?.onBackPressedDispatcher?.onBackPressed() },
|
||||
onNextClick = { backstack.add(LocalBackupsNavKey.RECORD_RECOVERY_KEY) },
|
||||
mode = MessageBackupsKeyEducationScreenMode.LOCAL_BACKUP_UPGRADE
|
||||
mode = if (args.triggerUpdateFlow) {
|
||||
MessageBackupsKeyEducationScreenMode.LOCAL_BACKUP_UPGRADE
|
||||
} else if (SignalStore.backup.areBackupsEnabled) {
|
||||
MessageBackupsKeyEducationScreenMode.LOCAL_WITH_REMOTE_ENABLED
|
||||
} else {
|
||||
MessageBackupsKeyEducationScreenMode.DEFAULT
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+22
-12
@@ -141,12 +141,14 @@ internal fun LocalBackupsSettingsScreen(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Text(
|
||||
text = state.lastBackupLabel.orEmpty(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
if (state.lastBackupLabel != null) {
|
||||
Text(
|
||||
text = stringResource(R.string.BackupsSettingsFragment_last_backup_s, state.lastBackupLabel),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = callback::onCreateBackupClick
|
||||
@@ -231,7 +233,15 @@ internal fun LocalBackupsSettingsScreen(
|
||||
}
|
||||
|
||||
if (state.isDeleting) {
|
||||
Dialogs.IndeterminateProgressDialog(message = stringResource(id = R.string.BackupDialog_deleting_local_backup))
|
||||
val message = stringResource(id = R.string.BackupDialog_deleting_local_backup)
|
||||
if (state.deleteTotal > 0) {
|
||||
Dialogs.DeterminateProgressDialog(
|
||||
message = message,
|
||||
progress = { state.deleteCompleted.toFloat() / state.deleteTotal }
|
||||
)
|
||||
} else {
|
||||
Dialogs.IndeterminateProgressDialog(message = message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +280,7 @@ private fun LocalBackupsSettingsEnabledIdlePreview() {
|
||||
LocalBackupsSettingsScreen(
|
||||
state = LocalBackupsSettingsState(
|
||||
backupsEnabled = true,
|
||||
lastBackupLabel = "Last backup: 1 hour ago",
|
||||
lastBackupLabel = "1 hour ago",
|
||||
folderDisplayName = "/storage/emulated/0/Signal/Backups",
|
||||
scheduleTimeLabel = "1:00 AM",
|
||||
progress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
|
||||
@@ -287,7 +297,7 @@ private fun LocalBackupsSettingsEnabledExportingIndeterminatePreview() {
|
||||
LocalBackupsSettingsScreen(
|
||||
state = LocalBackupsSettingsState(
|
||||
backupsEnabled = true,
|
||||
lastBackupLabel = "Last backup: 1 hour ago",
|
||||
lastBackupLabel = "1 hour ago",
|
||||
folderDisplayName = "/storage/emulated/0/Signal/Backups",
|
||||
scheduleTimeLabel = "1:00 AM",
|
||||
progress = LocalBackupCreationProgress(
|
||||
@@ -306,7 +316,7 @@ private fun LocalBackupsSettingsEnabledExportingMessagesPreview() {
|
||||
LocalBackupsSettingsScreen(
|
||||
state = LocalBackupsSettingsState(
|
||||
backupsEnabled = true,
|
||||
lastBackupLabel = "Last backup: 1 hour ago",
|
||||
lastBackupLabel = "1 hour ago",
|
||||
folderDisplayName = "/storage/emulated/0/Signal/Backups",
|
||||
scheduleTimeLabel = "1:00 AM",
|
||||
progress = LocalBackupCreationProgress(
|
||||
@@ -329,7 +339,7 @@ private fun LocalBackupsSettingsEnabledTransferringPreview() {
|
||||
LocalBackupsSettingsScreen(
|
||||
state = LocalBackupsSettingsState(
|
||||
backupsEnabled = true,
|
||||
lastBackupLabel = "Last backup: 1 hour ago",
|
||||
lastBackupLabel = "1 hour ago",
|
||||
folderDisplayName = "/storage/emulated/0/Signal/Backups",
|
||||
scheduleTimeLabel = "1:00 AM",
|
||||
progress = LocalBackupCreationProgress(
|
||||
@@ -352,7 +362,7 @@ private fun LocalBackupsSettingsEnabledNonLegacyPreview() {
|
||||
LocalBackupsSettingsScreen(
|
||||
state = LocalBackupsSettingsState(
|
||||
backupsEnabled = true,
|
||||
lastBackupLabel = "Last backup: 1 hour ago",
|
||||
lastBackupLabel = "1 hour ago",
|
||||
folderDisplayName = "Signal Backups",
|
||||
scheduleTimeLabel = "1:00 AM",
|
||||
progress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
|
||||
|
||||
+3
-1
@@ -19,5 +19,7 @@ data class LocalBackupsSettingsState(
|
||||
val folderDisplayName: String? = null,
|
||||
val scheduleTimeLabel: String? = null,
|
||||
val progress: LocalBackupCreationProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle()),
|
||||
val isDeleting: Boolean = false
|
||||
val isDeleting: Boolean = false,
|
||||
val deleteCompleted: Int = 0,
|
||||
val deleteTotal: Int = 0
|
||||
)
|
||||
|
||||
+8
-5
@@ -18,6 +18,7 @@ import org.signal.core.ui.util.StorageUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.BackupPassphrase
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
@@ -74,7 +75,7 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
SignalStore.backup.newLocalBackupProgressFlow.collect { progress ->
|
||||
LocalExportProgress.encryptedProgress.collect { progress ->
|
||||
internalSettingsState.update { it.copy(progress = progress) }
|
||||
}
|
||||
}
|
||||
@@ -108,11 +109,11 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
}
|
||||
|
||||
fun onBackupStarted() {
|
||||
SignalStore.backup.newLocalBackupProgress = LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.NONE))
|
||||
LocalExportProgress.setEncryptedProgress(LocalBackupCreationProgress(exporting = LocalBackupCreationProgress.Exporting(phase = LocalBackupCreationProgress.ExportPhase.NONE)))
|
||||
}
|
||||
|
||||
fun turnOffAndDelete(context: Context) {
|
||||
internalSettingsState.update { it.copy(isDeleting = true) }
|
||||
internalSettingsState.update { it.copy(isDeleting = true, deleteCompleted = 0, deleteTotal = 0) }
|
||||
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -120,10 +121,12 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||
val path = SignalStore.backup.newLocalBackupsDirectory
|
||||
SignalStore.backup.newLocalBackupsDirectory = null
|
||||
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.QUEUE)
|
||||
BackupUtil.deleteUnifiedBackups(context, path)
|
||||
BackupUtil.deleteUnifiedBackups(context, path) { completed, total ->
|
||||
internalSettingsState.update { it.copy(deleteCompleted = completed, deleteTotal = total) }
|
||||
}
|
||||
}
|
||||
|
||||
internalSettingsState.update { it.copy(isDeleting = false) }
|
||||
internalSettingsState.update { it.copy(isDeleting = false, deleteCompleted = 0, deleteTotal = 0) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -49,7 +49,6 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
+4
-4
@@ -335,7 +335,7 @@ class ChangeNumberRepository(
|
||||
} else {
|
||||
PreKeyUtil.generateSignedPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), pniIdentity.privateKey)
|
||||
}
|
||||
devicePniSignedPreKeys[deviceId] = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
devicePniSignedPreKeys[deviceId] = SignedPreKeyEntity(signedPreKeyRecord.id.toLong(), signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
|
||||
// Last-resort kyber prekeys
|
||||
val lastResortKyberPreKeyRecord: KyberPreKeyRecord = if (deviceId == primaryDeviceId) {
|
||||
@@ -343,7 +343,7 @@ class ChangeNumberRepository(
|
||||
} else {
|
||||
PreKeyUtil.generateLastResortKyberPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), pniIdentity.privateKey)
|
||||
}
|
||||
devicePniLastResortKyberPreKeys[deviceId] = KyberPreKeyEntity(lastResortKyberPreKeyRecord.id, lastResortKyberPreKeyRecord.keyPair.publicKey, lastResortKyberPreKeyRecord.signature)
|
||||
devicePniLastResortKyberPreKeys[deviceId] = KyberPreKeyEntity(lastResortKyberPreKeyRecord.id.toLong(), lastResortKyberPreKeyRecord.keyPair.publicKey, lastResortKyberPreKeyRecord.signature)
|
||||
|
||||
// Registration Ids
|
||||
var pniRegistrationId = -1
|
||||
@@ -383,8 +383,8 @@ class ChangeNumberRepository(
|
||||
previousPni = SignalStore.account.pni!!.toByteString(),
|
||||
pniIdentityKeyPair = pniIdentity.serialize().toByteString(),
|
||||
pniRegistrationId = pniRegistrationIds[primaryDeviceId]!!,
|
||||
pniSignedPreKeyId = devicePniSignedPreKeys[primaryDeviceId]!!.keyId,
|
||||
pniLastResortKyberPreKeyId = devicePniLastResortKyberPreKeys[primaryDeviceId]!!.keyId,
|
||||
pniSignedPreKeyId = devicePniSignedPreKeys[primaryDeviceId]!!.keyId.toInt(),
|
||||
pniLastResortKyberPreKeyId = devicePniLastResortKyberPreKeys[primaryDeviceId]!!.keyId.toInt(),
|
||||
previousE164 = SignalStore.account.requireE164(),
|
||||
newE164 = newE164
|
||||
)
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Launchers
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Dialogs displayed while processing a user's decrypted chat export.
|
||||
*
|
||||
* Displayed *after* the user has confirmed via phone auth.
|
||||
*/
|
||||
@Composable
|
||||
fun ChatExportDialogs(state: ChatExportState, callbacks: ChatExportCallbacks) {
|
||||
val folderPicker = Launchers.rememberOpenDocumentTreeLauncher {
|
||||
if (it != null) {
|
||||
callbacks.onFolderSelected(it)
|
||||
} else {
|
||||
callbacks.onCancelStartExport()
|
||||
}
|
||||
}
|
||||
|
||||
when (state) {
|
||||
ChatExportState.None -> Unit
|
||||
ChatExportState.ConfirmExport -> ConfirmExportDialog(
|
||||
onConfirmExport = callbacks::onConfirmExport,
|
||||
onCancel = callbacks::onCancelStartExport
|
||||
)
|
||||
|
||||
ChatExportState.ChooseAFolder -> ChooseAFolderDialog(
|
||||
onChooseAFolder = { folderPicker.launch(null) },
|
||||
onCancel = callbacks::onCancelStartExport
|
||||
)
|
||||
|
||||
ChatExportState.Canceling -> Dialogs.IndeterminateProgressDialog(message = stringResource(R.string.ChatExportDialogs__canceling_export))
|
||||
|
||||
ChatExportState.Success -> CompleteDialog(
|
||||
onOK = callbacks::onCompletionConfirmed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmExportDialog(
|
||||
onConfirmExport: (withMedia: Boolean) -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
val body = buildAnnotatedString {
|
||||
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(stringResource(R.string.ChatExportDialogs__be_careful_warning))
|
||||
}
|
||||
|
||||
append(" ")
|
||||
append(stringResource(R.string.ChatExportDialogs__export_confirm_body))
|
||||
}
|
||||
|
||||
Dialogs.AdvancedAlertDialog(
|
||||
title = AnnotatedString(stringResource(R.string.ChatExportDialogs__export_chat_history_title)),
|
||||
body = body,
|
||||
positive = AnnotatedString(stringResource(R.string.ChatExportDialogs__export_with_media)),
|
||||
neutral = AnnotatedString(stringResource(R.string.ChatExportDialogs__export_without_media)),
|
||||
negative = AnnotatedString(stringResource(android.R.string.cancel)),
|
||||
onPositive = { onConfirmExport(true) },
|
||||
onNeutral = { onConfirmExport(false) },
|
||||
onNegative = onCancel
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChooseAFolderDialog(
|
||||
onChooseAFolder: () -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.ChatExportDialogs__choose_a_folder_title),
|
||||
body = stringResource(R.string.ChatExportDialogs__choose_a_folder_body),
|
||||
confirm = stringResource(R.string.ChatExportDialogs__choose_folder_button),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = onChooseAFolder,
|
||||
onDeny = onCancel
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompleteDialog(
|
||||
onOK: () -> Unit
|
||||
) {
|
||||
val body = buildAnnotatedString {
|
||||
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(stringResource(R.string.ChatExportDialogs__be_careful))
|
||||
}
|
||||
|
||||
append(" ")
|
||||
append(stringResource(R.string.ChatExportDialogs__complete_body))
|
||||
}
|
||||
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = AnnotatedString(stringResource(R.string.ChatExportDialogs__complete_title)),
|
||||
body = body,
|
||||
confirm = AnnotatedString(stringResource(android.R.string.ok)),
|
||||
onConfirm = onOK
|
||||
)
|
||||
}
|
||||
|
||||
enum class ChatExportState {
|
||||
None,
|
||||
ConfirmExport,
|
||||
ChooseAFolder,
|
||||
Canceling,
|
||||
Success
|
||||
}
|
||||
|
||||
interface ChatExportCallbacks {
|
||||
fun onConfirmExport(withMedia: Boolean)
|
||||
fun onFolderSelected(uri: Uri)
|
||||
fun onCancelStartExport()
|
||||
fun onCompletionConfirmed()
|
||||
|
||||
object Empty : ChatExportCallbacks {
|
||||
override fun onConfirmExport(withMedia: Boolean) = Unit
|
||||
override fun onFolderSelected(uri: Uri) = Unit
|
||||
override fun onCancelStartExport() = Unit
|
||||
override fun onCompletionConfirmed() = Unit
|
||||
}
|
||||
}
|
||||
+97
-4
@@ -1,16 +1,20 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.ComposeFragment
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
@@ -18,9 +22,14 @@ 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.Snackbars
|
||||
import org.signal.core.ui.compose.Texts
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.isIdle
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupCreationProgressRow
|
||||
import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication
|
||||
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
@@ -79,10 +88,38 @@ class ChatsSettingsFragment : ComposeFragment() {
|
||||
override fun onEnterKeySendsChanged(enabled: Boolean) {
|
||||
viewModel.setEnterKeySends(enabled)
|
||||
}
|
||||
|
||||
override fun onExportPlaintextChatHistoryClick() {
|
||||
viewModel.requestChatExportType()
|
||||
}
|
||||
|
||||
override fun onCancelInFlightExport() {
|
||||
viewModel.cancelChatExport()
|
||||
}
|
||||
|
||||
// region ChatExportCallback
|
||||
|
||||
override fun onConfirmExport(withMedia: Boolean) {
|
||||
viewModel.setExportTypeAndGoToSelectFolder(withMedia)
|
||||
}
|
||||
|
||||
override fun onFolderSelected(uri: Uri) {
|
||||
viewModel.startChatExportToFolder(uri)
|
||||
}
|
||||
|
||||
override fun onCancelStartExport() {
|
||||
viewModel.clearChatExportFlow()
|
||||
}
|
||||
|
||||
override fun onCompletionConfirmed() {
|
||||
viewModel.clearChatExportFlow()
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
}
|
||||
|
||||
private interface ChatsSettingsCallbacks {
|
||||
private interface ChatsSettingsCallbacks : ChatExportCallbacks {
|
||||
fun onNavigationClick() = Unit
|
||||
fun onGenerateLinkPreviewsChanged(enabled: Boolean) = Unit
|
||||
fun onUseAddressBookChanged(enabled: Boolean) = Unit
|
||||
@@ -91,8 +128,10 @@ private interface ChatsSettingsCallbacks {
|
||||
fun onAddOrEditFoldersClick() = Unit
|
||||
fun onUseSystemEmojiChanged(enabled: Boolean) = Unit
|
||||
fun onEnterKeySendsChanged(enabled: Boolean) = Unit
|
||||
fun onExportPlaintextChatHistoryClick() = Unit
|
||||
fun onCancelInFlightExport() = Unit
|
||||
|
||||
object Empty : ChatsSettingsCallbacks
|
||||
object Empty : ChatsSettingsCallbacks, ChatExportCallbacks by ChatExportCallbacks.Empty
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -100,10 +139,25 @@ private fun ChatsSettingsScreen(
|
||||
state: ChatsSettingsState,
|
||||
callbacks: ChatsSettingsCallbacks
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val authenticationFailedMessage = stringResource(R.string.ChatsSettingsFragment__authentication_failed)
|
||||
val plaintextBiometricsAuthentication = rememberBiometricsAuthentication(
|
||||
promptTitle = stringResource(R.string.ChatsSettingsFragment__unlock_to_export_chat_history),
|
||||
onAuthenticationFailed = {
|
||||
coroutineScope.launch {
|
||||
snackbarHostState.showSnackbar(authenticationFailedMessage)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(R.string.preferences_chats__chats),
|
||||
onNavigationClick = callbacks::onNavigationClick,
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector,
|
||||
snackbarHost = {
|
||||
Snackbars.Host(snackbarHostState)
|
||||
}
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
@@ -167,6 +221,36 @@ private fun ChatsSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isPlaintextExportEnabled) {
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
if (state.plaintextExportProgress.isIdle) {
|
||||
item(key = "export_chat_history_row") {
|
||||
Rows.TextRow(
|
||||
modifier = Modifier.animateItem(),
|
||||
text = stringResource(R.string.ChatsSettingsFragment__export_chat_history),
|
||||
label = stringResource(R.string.ChatsSettingsFragment__export_chat_history_label),
|
||||
onClick = {
|
||||
plaintextBiometricsAuthentication.withBiometricsAuthentication {
|
||||
callbacks.onExportPlaintextChatHistoryClick()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item(key = "export_chat_history_progress") {
|
||||
BackupCreationProgressRow(
|
||||
modifier = Modifier.animateItem(),
|
||||
progress = state.plaintextExportProgress,
|
||||
isRemote = false,
|
||||
onCancel = callbacks::onCancelInFlightExport
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
@@ -194,6 +278,13 @@ private fun ChatsSettingsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isPlaintextExportEnabled) {
|
||||
ChatExportDialogs(
|
||||
state = state.chatExportState,
|
||||
callbacks = callbacks
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@@ -210,7 +301,9 @@ private fun ChatsSettingsScreenPreview() {
|
||||
localBackupsEnabled = true,
|
||||
folderCount = 1,
|
||||
userUnregistered = false,
|
||||
clientDeprecated = false
|
||||
clientDeprecated = false,
|
||||
isPlaintextExportEnabled = true,
|
||||
plaintextExportProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
|
||||
),
|
||||
callbacks = ChatsSettingsCallbacks.Empty
|
||||
)
|
||||
|
||||
+8
-1
@@ -1,5 +1,8 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats
|
||||
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
|
||||
|
||||
data class ChatsSettingsState(
|
||||
val generateLinkPreviews: Boolean,
|
||||
val useAddressBook: Boolean,
|
||||
@@ -9,7 +12,11 @@ data class ChatsSettingsState(
|
||||
val localBackupsEnabled: Boolean,
|
||||
val folderCount: Int,
|
||||
val userUnregistered: Boolean,
|
||||
val clientDeprecated: Boolean
|
||||
val clientDeprecated: Boolean,
|
||||
val isPlaintextExportEnabled: Boolean,
|
||||
val plaintextExportProgress: LocalBackupCreationProgress = LocalExportProgress.plaintextProgress.value,
|
||||
val chatExportState: ChatExportState = ChatExportState.None,
|
||||
val includeMediaInExport: Boolean = false
|
||||
) {
|
||||
fun isRegisteredAndUpToDate(): Boolean {
|
||||
return !userUnregistered && !clientDeprecated
|
||||
|
||||
+46
-1
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -7,11 +8,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFoldersRepository
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.BackupUtil
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer
|
||||
|
||||
@@ -31,12 +35,53 @@ class ChatsSettingsViewModel @JvmOverloads constructor(
|
||||
localBackupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(AppDependencies.application),
|
||||
folderCount = 0,
|
||||
userUnregistered = TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application) || !SignalStore.account.isRegistered,
|
||||
clientDeprecated = SignalStore.misc.isClientDeprecated
|
||||
clientDeprecated = SignalStore.misc.isClientDeprecated,
|
||||
isPlaintextExportEnabled = RemoteConfig.localPlaintextExport,
|
||||
chatExportState = ChatExportState.None
|
||||
)
|
||||
)
|
||||
|
||||
val state: StateFlow<ChatsSettingsState> = store
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
LocalExportProgress.plaintextProgress.collect { progress ->
|
||||
store.update {
|
||||
it.copy(
|
||||
plaintextExportProgress = progress,
|
||||
chatExportState = when {
|
||||
progress.succeeded != null && it.plaintextExportProgress.succeeded == null -> ChatExportState.Success
|
||||
progress.canceled != null -> ChatExportState.None
|
||||
else -> it.chatExportState
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestChatExportType() {
|
||||
store.update { it.copy(chatExportState = ChatExportState.ConfirmExport) }
|
||||
}
|
||||
|
||||
fun setExportTypeAndGoToSelectFolder(includeMediaInExport: Boolean) {
|
||||
store.update { it.copy(chatExportState = ChatExportState.ChooseAFolder, includeMediaInExport = includeMediaInExport) }
|
||||
}
|
||||
|
||||
fun startChatExportToFolder(uri: Uri) {
|
||||
store.update { it.copy(chatExportState = ChatExportState.None) }
|
||||
LocalBackupJob.enqueuePlaintextArchive(uri.toString(), store.value.includeMediaInExport)
|
||||
}
|
||||
|
||||
fun clearChatExportFlow() {
|
||||
store.update { it.copy(chatExportState = ChatExportState.None, includeMediaInExport = false) }
|
||||
}
|
||||
|
||||
fun cancelChatExport() {
|
||||
store.update { it.copy(chatExportState = ChatExportState.Canceling) }
|
||||
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.PLAINTEXT_ARCHIVE_QUEUE)
|
||||
}
|
||||
|
||||
fun setGenerateLinkPreviewsEnabled(enabled: Boolean) {
|
||||
store.update { it.copy(generateLinkPreviews = enabled) }
|
||||
SignalStore.settings.isLinkPreviewsEnabled = enabled
|
||||
|
||||
+15
-15
@@ -52,10 +52,10 @@ import org.signal.core.ui.compose.DropdownMenus
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalIcons
|
||||
import org.signal.core.ui.compose.copied.androidx.compose.DragAndDropEvent
|
||||
import org.signal.core.ui.compose.copied.androidx.compose.DraggableItem
|
||||
import org.signal.core.ui.compose.copied.androidx.compose.dragContainer
|
||||
import org.signal.core.ui.compose.copied.androidx.compose.rememberDragDropState
|
||||
import org.signal.core.ui.compose.list.ReorderListEvent
|
||||
import org.signal.core.ui.compose.list.ReorderableItem
|
||||
import org.signal.core.ui.compose.list.rememberReorderableListState
|
||||
import org.signal.core.ui.compose.list.reorderableList
|
||||
import org.signal.core.util.toInt
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -101,11 +101,11 @@ class ChatFoldersFragment : ComposeFragment() {
|
||||
onDeleteDismissed = {
|
||||
viewModel.showDeleteDialog(false)
|
||||
},
|
||||
onDragAndDropEvent = { event ->
|
||||
onReorderListEvent = { event ->
|
||||
when (event) {
|
||||
is DragAndDropEvent.OnItemMove -> viewModel.updateItemPosition(event.fromIndex, event.toIndex)
|
||||
is DragAndDropEvent.OnItemDrop -> viewModel.saveItemPositions()
|
||||
is DragAndDropEvent.OnDragCancel -> {}
|
||||
is ReorderListEvent.ItemMoved -> viewModel.updateItemPosition(event.fromIndex, event.toIndex)
|
||||
is ReorderListEvent.ItemDropped -> viewModel.saveItemPositions()
|
||||
is ReorderListEvent.DragCanceled -> {}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -123,10 +123,10 @@ fun FoldersScreen(
|
||||
onDeleteClicked: (ChatFolderRecord) -> Unit = {},
|
||||
onDeleteConfirmed: () -> Unit = {},
|
||||
onDeleteDismissed: () -> Unit = {},
|
||||
onDragAndDropEvent: (DragAndDropEvent) -> Unit = {}
|
||||
onReorderListEvent: (ReorderListEvent) -> Unit = {}
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val dragDropState = rememberDragDropState(listState, includeHeader = true, includeFooter = true, onEvent = onDragAndDropEvent)
|
||||
val reorderableListState = rememberReorderableListState(listState, includeHeader = true, includeFooter = true, onEvent = onReorderListEvent)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!SignalStore.uiHints.hasSeenChatFoldersEducationSheet) {
|
||||
@@ -147,14 +147,14 @@ fun FoldersScreen(
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.dragContainer(
|
||||
dragDropState = dragDropState,
|
||||
modifier = Modifier.reorderableList(
|
||||
reorderableListState = reorderableListState,
|
||||
dragHandleWidth = 56.dp
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
item {
|
||||
DraggableItem(dragDropState, 0) {
|
||||
ReorderableItem(reorderableListState, 0) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.ChatFoldersFragment__organize_your_chats),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -175,7 +175,7 @@ fun FoldersScreen(
|
||||
}
|
||||
|
||||
itemsIndexed(state.folders) { index, folder ->
|
||||
DraggableItem(dragDropState, 1 + index) { isDragging ->
|
||||
ReorderableItem(reorderableListState, 1 + index) { isDragging ->
|
||||
val elevation = if (isDragging) 1.dp else 0.dp
|
||||
val isAllChats = folder.folderType == ChatFolderRecord.FolderType.ALL
|
||||
FolderRow(
|
||||
@@ -193,7 +193,7 @@ fun FoldersScreen(
|
||||
}
|
||||
|
||||
item {
|
||||
DraggableItem(dragDropState, 1 + state.folders.size) {
|
||||
ReorderableItem(reorderableListState, 1 + state.folders.size) {
|
||||
if (state.suggestedFolders.isNotEmpty()) {
|
||||
Dividers.Default()
|
||||
|
||||
|
||||
+3
@@ -142,6 +142,7 @@ private fun DataAndStorageSettingsScreen(
|
||||
labels = stringArrayResource(R.array.pref_media_download_entries),
|
||||
values = stringArrayResource(R.array.pref_media_download_values),
|
||||
selection = state.mobileAutoDownloadValues.toTypedArray(),
|
||||
noSelectionLabel = stringResource(R.string.preferences__none),
|
||||
onSelectionChanged = callbacks::onMobileDataAutoDownloadSelectionChanged
|
||||
)
|
||||
}
|
||||
@@ -152,6 +153,7 @@ private fun DataAndStorageSettingsScreen(
|
||||
labels = stringArrayResource(R.array.pref_media_download_entries),
|
||||
values = stringArrayResource(R.array.pref_media_download_values),
|
||||
selection = state.wifiAutoDownloadValues.toTypedArray(),
|
||||
noSelectionLabel = stringResource(R.string.preferences__none),
|
||||
onSelectionChanged = callbacks::onWifiDataAutoDownloadSelectionChanged
|
||||
)
|
||||
}
|
||||
@@ -162,6 +164,7 @@ private fun DataAndStorageSettingsScreen(
|
||||
labels = stringArrayResource(R.array.pref_media_download_entries),
|
||||
values = stringArrayResource(R.array.pref_media_download_values),
|
||||
selection = state.roamingAutoDownloadValues.toTypedArray(),
|
||||
noSelectionLabel = stringResource(R.string.preferences__none),
|
||||
onSelectionChanged = callbacks::onRoamingDataAutoDownloadSelectionChanged
|
||||
)
|
||||
}
|
||||
|
||||
+1
@@ -235,6 +235,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
title = DSLSettingsText.from("Collapse chat updates"),
|
||||
summary = DSLSettingsText.from("Collapses certain consecutive chat updates - cannot be undone."),
|
||||
onClick = {
|
||||
SignalStore.misc.completedCollapsedEventsMigration = false
|
||||
AppDependencies.jobManager.add(BackfillCollapsedMessageJob())
|
||||
}
|
||||
)
|
||||
|
||||
+12
@@ -73,6 +73,7 @@ import org.signal.core.util.Util
|
||||
import org.signal.core.util.getLength
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.backup.isIdle
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlertBottomSheet
|
||||
@@ -270,6 +271,10 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
},
|
||||
onTriggerLocalRestoreDirectoryError = {
|
||||
SignalStore.backup.localRestoreDirectoryError = true
|
||||
ArchiveRestoreProgress.forceUpdate()
|
||||
},
|
||||
onDisplayInitialBackupFailureSheet = {
|
||||
BackupRepository.displayInitialBackupFailureNotification()
|
||||
BackupAlertBottomSheet
|
||||
@@ -366,6 +371,7 @@ fun Screen(
|
||||
onImportEncryptedBackupFromDiskConfirmed: (aci: String, backupKey: String) -> Unit = { _, _ -> },
|
||||
onClearLocalMediaBackupState: () -> Unit = {},
|
||||
onDeleteRemoteBackup: () -> Unit = {},
|
||||
onTriggerLocalRestoreDirectoryError: () -> Unit = {},
|
||||
onDisplayInitialBackupFailureSheet: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -584,6 +590,12 @@ fun Screen(
|
||||
onClick = onClearLocalMediaBackupState
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = "Trigger local restore directory error",
|
||||
label = "Simulates the restore directory becoming inaccessible during a local backup restore.",
|
||||
onClick = onTriggerLocalRestoreDirectoryError
|
||||
)
|
||||
|
||||
Dividers.Default()
|
||||
|
||||
Rows.TextRow(
|
||||
|
||||
+3
-2
@@ -37,9 +37,11 @@ import org.signal.core.util.readNBytesOrThrow
|
||||
import org.signal.core.util.roundedString
|
||||
import org.signal.core.util.stream.LimitedInputStream
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.signal.network.api.SvrBApi
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
|
||||
import org.thoughtcrime.securesms.backup.LocalExportProgress
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveValidator
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.DebugBackupMetadata
|
||||
@@ -57,7 +59,6 @@ import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.svr.SvrBApi
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
@@ -92,7 +93,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
SignalStore.backup.newLocalPlaintextBackupProgressFlow.collect { progress ->
|
||||
LocalExportProgress.plaintextProgress.collect { progress ->
|
||||
_state.value = _state.value.copy(plaintextProgress = progress)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -16,11 +16,11 @@ import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.ByteSize
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.network.service.StorageServiceService
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageManifest
|
||||
import org.whispersystems.signalservice.api.storage.SignalStorageRecord
|
||||
import org.whispersystems.signalservice.api.storage.StorageServiceRepository
|
||||
|
||||
class InternalStorageServicePlaygroundViewModel : ViewModel() {
|
||||
|
||||
@@ -47,12 +47,12 @@ class InternalStorageServicePlaygroundViewModel : ViewModel() {
|
||||
fun onViewTabSelected() {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val repository = StorageServiceRepository(AppDependencies.storageServiceApi)
|
||||
val repository = StorageServiceService(AppDependencies.storageServiceApi)
|
||||
val storageKey = SignalStore.storageService.storageKeyForInitialDataRestore ?: SignalStore.storageService.storageKey
|
||||
|
||||
val manifest = when (val result = repository.getStorageManifest(storageKey)) {
|
||||
is StorageServiceRepository.ManifestResult.Success -> result.manifest
|
||||
is StorageServiceRepository.ManifestResult.NotFoundError -> {
|
||||
is StorageServiceService.ManifestResult.Success -> result.manifest
|
||||
is StorageServiceService.ManifestResult.NotFoundError -> {
|
||||
Log.w(TAG, "Manifest not found!")
|
||||
_oneOffEvents.value = OneOffEvent.ManifestNotFoundError
|
||||
return@withContext
|
||||
@@ -66,7 +66,7 @@ class InternalStorageServicePlaygroundViewModel : ViewModel() {
|
||||
_manifest.value = manifest
|
||||
|
||||
val records = when (val result = repository.readStorageRecords(storageKey, manifest.recordIkm, manifest.storageIds)) {
|
||||
is StorageServiceRepository.StorageRecordResult.Success -> result.records
|
||||
is StorageServiceService.StorageRecordResult.Success -> result.records
|
||||
else -> {
|
||||
Log.w(TAG, "Failed to fetch records!")
|
||||
_oneOffEvents.value = OneOffEvent.StorageRecordDecryptionError
|
||||
|
||||
+1
@@ -75,6 +75,7 @@ class AdvancedPrivacySettingsViewModel(
|
||||
viewModelScope.launch(SignalDispatchers.IO) {
|
||||
if (!enabled) {
|
||||
SignalDatabase.recipients.clearAllKeyTransparencyData()
|
||||
SignalStore.account.distinguishedHead = null
|
||||
}
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
|
||||
-1
@@ -68,7 +68,6 @@ class PhoneNumberPrivacySettingsViewModel : ViewModel() {
|
||||
private fun setDiscoverableByPhoneNumber(discoverable: Boolean) {
|
||||
SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode = if (discoverable) PhoneNumberDiscoverabilityMode.DISCOVERABLE else PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
SignalDatabase.recipients.clearSelfKeyTransparencyData()
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
AppDependencies.jobManager.startChain(RefreshAttributesJob()).then(RefreshOwnProfileJob()).enqueue()
|
||||
refresh()
|
||||
|
||||
+4
-2
@@ -63,9 +63,11 @@ sealed interface AppSettingsRoute : Parcelable {
|
||||
|
||||
@Parcelize
|
||||
sealed interface BackupsRoute : AppSettingsRoute {
|
||||
data object Backups : BackupsRoute
|
||||
data class Backups(
|
||||
val launchCheckoutFlow: Boolean = false
|
||||
) : BackupsRoute
|
||||
data class Local(val triggerUpdateFlow: Boolean = false) : BackupsRoute
|
||||
data class Remote(val backupLaterSelected: Boolean = false, val forQuickRestore: Boolean = false) : BackupsRoute
|
||||
data class Remote(val forQuickRestore: Boolean = false) : BackupsRoute
|
||||
data object DisplayKey : BackupsRoute
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -664,7 +664,7 @@ object InAppPaymentsRepository {
|
||||
timestamp = insertedAt.inWholeMilliseconds,
|
||||
error = null,
|
||||
pendingVerification = true,
|
||||
checkedVerification = data.waitForAuth!!.checkedVerification
|
||||
checkedVerification = data.waitForAuth?.checkedVerification ?: false
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -348,7 +348,7 @@ class DonateToSignalFragment :
|
||||
if (state.oneTimeDonationState.isOneTimeDonationLongRunning) {
|
||||
R.string.DonateToSignalFragment__bank_transfers_usually_take_1_business_day_to_process_onetime
|
||||
} else if (state.oneTimeDonationState.isNonVerifiedIdeal) {
|
||||
R.string.DonateToSignalFragment__your_ideal_payment_is_still_processing
|
||||
R.string.DonateToSignalFragment__your_ideal_wero_payment_is_still_processing
|
||||
} else {
|
||||
R.string.DonateToSignalFragment__your_payment_is_still_being_processed_onetime
|
||||
}
|
||||
@@ -356,7 +356,7 @@ class DonateToSignalFragment :
|
||||
if (state.monthlyDonationState.activeSubscription?.paymentMethod == ActiveSubscription.PaymentMethod.SEPA_DEBIT) {
|
||||
R.string.DonateToSignalFragment__bank_transfers_usually_take_1_business_day_to_process_monthly
|
||||
} else if (state.monthlyDonationState.nonVerifiedMonthlyDonation != null) {
|
||||
R.string.DonateToSignalFragment__your_ideal_payment_is_still_processing
|
||||
R.string.DonateToSignalFragment__your_ideal_wero_payment_is_still_processing
|
||||
} else {
|
||||
R.string.DonateToSignalFragment__your_payment_is_still_being_processed_monthly
|
||||
}
|
||||
|
||||
+12
-12
@@ -18,10 +18,10 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.NO_TINT
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.IdealWeroButton
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.PayPalButton
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
|
||||
@@ -51,6 +51,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
GooglePayButton.register(adapter)
|
||||
PayPalButton.register(adapter)
|
||||
IndeterminateLoadingCircle.register(adapter)
|
||||
IdealWeroButton.register(adapter)
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
||||
|
||||
@@ -190,17 +191,16 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
if (state.isIDEALAvailable) {
|
||||
space(16.dp)
|
||||
|
||||
tonalButton(
|
||||
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__ideal),
|
||||
icon = DSLSettingsIcon.from(R.drawable.logo_ideal, NO_TINT),
|
||||
disableOnClick = true,
|
||||
onClick = {
|
||||
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.IDEAL)
|
||||
.subscribeBy {
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
|
||||
}
|
||||
}
|
||||
customPref(
|
||||
IdealWeroButton.Model(
|
||||
onClick = {
|
||||
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.IDEAL)
|
||||
.subscribeBy {
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+44
-5
@@ -10,11 +10,13 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.R
|
||||
import java.net.URISyntaxException
|
||||
|
||||
/**
|
||||
* Encapsulates the logic for navigating a user to a deeplink from within a webview or parsing out the fallback
|
||||
@@ -30,18 +32,31 @@ object ExternalNavigationHelper {
|
||||
return false
|
||||
}
|
||||
|
||||
val intent = try {
|
||||
Intent.parseUri(url.toString(), Intent.URI_INTENT_SCHEME).sanitizeWebIntent()
|
||||
} catch (e: URISyntaxException) {
|
||||
Log.w(TAG, "Failed to parse web intent URI.", e)
|
||||
return false
|
||||
}
|
||||
|
||||
val targetLabel = resolveTargetLabel(context, intent)
|
||||
val message = if (targetLabel != null) {
|
||||
context.getString(R.string.ExternalNavigationHelper__once_payment_confirmed_in_app, targetLabel)
|
||||
} else {
|
||||
context.getString(R.string.ExternalNavigationHelper__once_this_payment_is_confirmed)
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.ExternalNavigationHelper__leave_signal_to_confirm_payment)
|
||||
.setMessage(R.string.ExternalNavigationHelper__once_this_payment_is_confirmed)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> attemptIntentLaunch(context, url, launchIntent) }
|
||||
.setMessage(message)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> attemptIntentLaunch(context, intent, launchIntent) }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun attemptIntentLaunch(context: Context, url: Uri, launchIntent: (Intent) -> Unit) {
|
||||
val intent = Intent.parseUri(url.toString(), Intent.URI_INTENT_SCHEME)
|
||||
private fun attemptIntentLaunch(context: Context, intent: Intent, launchIntent: (Intent) -> Unit) {
|
||||
try {
|
||||
launchIntent(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
@@ -50,7 +65,7 @@ object ExternalNavigationHelper {
|
||||
val fallback = intent.getStringExtra("browser_fallback_url")
|
||||
if (fallback.isNotNullOrBlank()) {
|
||||
try {
|
||||
launchIntent(Intent.parseUri(fallback, Intent.URI_INTENT_SCHEME))
|
||||
launchIntent(Intent.parseUri(fallback, Intent.URI_INTENT_SCHEME).sanitizeWebIntent())
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.w(TAG, "Failed to launch fallback URL.", e)
|
||||
toastOnActivityNotFound(context)
|
||||
@@ -59,6 +74,30 @@ object ExternalNavigationHelper {
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveTargetLabel(context: Context, intent: Intent): CharSequence? {
|
||||
val resolveInfo = context.packageManager.resolveActivity(intent, 0) ?: return null
|
||||
return resolveInfo.loadLabel(context.packageManager).toString().takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize an intent parsed from a web-originated URI to prevent targeting
|
||||
* non-exported or internal activities. This mirrors the sanitization that
|
||||
* browsers apply to intent:// URIs before dispatching them.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
fun Intent.sanitizeWebIntent(): Intent {
|
||||
component = null
|
||||
selector = null
|
||||
addCategory(Intent.CATEGORY_BROWSABLE)
|
||||
flags = flags and (
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
|
||||
).inv()
|
||||
return this
|
||||
}
|
||||
|
||||
private fun toastOnActivityNotFound(context: Context) {
|
||||
Toast.makeText(context, R.string.CommunicationActions_no_browser_found, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
+2
-2
@@ -153,7 +153,7 @@ class IdealTransferDetailsFragment : ComposeFragment(), InAppPaymentCheckoutDele
|
||||
if (state.inAppPayment!!.type.recurring) { // TODO [message-requests] -- handle backup
|
||||
val formattedMoney = FiatMoneyUtil.format(requireContext().resources, state.inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(getString(R.string.IdealTransferDetailsFragment__confirm_your_donation_with_ideal))
|
||||
.setTitle(getString(R.string.IdealTransferDetailsFragment__confirm_your_donation_with_ideal_wero))
|
||||
.setMessage(getString(R.string.IdealTransferDetailsFragment__to_setup_your_recurring_donation, formattedMoney))
|
||||
.setPositiveButton(R.string.IdealTransferDetailsFragment__continue) { _, _ ->
|
||||
continueTransfer()
|
||||
@@ -218,7 +218,7 @@ private fun IdealTransferDetailsContent(
|
||||
onDonateClick: () -> Unit
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.GatewaySelectorBottomSheet__ideal),
|
||||
title = stringResource(id = R.string.GatewaySelectorBottomSheet__ideal_wero),
|
||||
onNavigationClick = onNavigationClick,
|
||||
navigationIcon = SignalIcons.ArrowStart.imageVector
|
||||
) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user