mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-15 07:28:30 +00:00
Compare commits
391 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
393b88fb1f | ||
|
|
639c3ef883 | ||
|
|
ad4142db1a | ||
|
|
5182987735 | ||
|
|
7f5bfc210b | ||
|
|
daf87915d6 | ||
|
|
06996540cd | ||
|
|
58ad3c746a | ||
|
|
a7ebe41570 | ||
|
|
b6cc702107 | ||
|
|
9163c0ca4d | ||
|
|
18290c1301 | ||
|
|
347abe14ae | ||
|
|
eba55755ff | ||
|
|
7043558657 | ||
|
|
3aefd3bdc6 | ||
|
|
d6eb675fd0 | ||
|
|
ae90b2ecd9 | ||
|
|
9d593bcaff | ||
|
|
62ed823e42 | ||
|
|
a53479e50d | ||
|
|
91140c41fd | ||
|
|
68f567b0b7 | ||
|
|
501e169210 | ||
|
|
09b818b048 | ||
|
|
7b3897cac6 | ||
|
|
64239962fc | ||
|
|
dac3a332d7 | ||
|
|
83bbcd0618 | ||
|
|
c7c0374c11 | ||
|
|
847f3bf08c | ||
|
|
d02c610237 | ||
|
|
8007045ca8 | ||
|
|
901b4b469d | ||
|
|
fa50696815 | ||
|
|
be035456f7 | ||
|
|
252a4afa79 | ||
|
|
f5f56536bc | ||
|
|
9e89d688f1 | ||
|
|
2bb94089f7 | ||
|
|
3fc386d4a3 | ||
|
|
3779dfd290 | ||
|
|
a5f766a333 | ||
|
|
9f40bfc645 | ||
|
|
919f03522a | ||
|
|
8aa6d0bbca | ||
|
|
4304ae2a96 | ||
|
|
b4a9189068 | ||
|
|
ec6448bd1b | ||
|
|
8c5811581e | ||
|
|
4b4d3d33b1 | ||
|
|
dd6c39f7eb | ||
|
|
b246e62504 | ||
|
|
ba08399d35 | ||
|
|
3f1bb7eac7 | ||
|
|
a2a10fb0c1 | ||
|
|
e45eabc714 | ||
|
|
138dae0484 | ||
|
|
893725e304 | ||
|
|
2cfe321274 | ||
|
|
050dcb3eb1 | ||
|
|
6ce01c6b0e | ||
|
|
d2f44fee87 | ||
|
|
1228da8665 | ||
|
|
479632d6a8 | ||
|
|
619d2997f6 | ||
|
|
c5e795b176 | ||
|
|
8b7b184224 | ||
|
|
48d26beb77 | ||
|
|
3d1895500c | ||
|
|
e442c27555 | ||
|
|
c3d61bece1 | ||
|
|
49853b2cca | ||
|
|
cd838c4bee | ||
|
|
2e50699a2d | ||
|
|
fe97c969ae | ||
|
|
c70a8d48a8 | ||
|
|
322ea97377 | ||
|
|
e3a402394f | ||
|
|
16b4b3b6b7 | ||
|
|
cd98ccbf00 | ||
|
|
eecb18b436 | ||
|
|
d13a803dcd | ||
|
|
bd03f21cdf | ||
|
|
b46d891183 | ||
|
|
54191433e0 | ||
|
|
462fcdce16 | ||
|
|
f68bb2dc88 | ||
|
|
fe70637140 | ||
|
|
1028d293a0 | ||
|
|
74c6e76808 | ||
|
|
8e880fe117 | ||
|
|
6525662071 | ||
|
|
94d07f7012 | ||
|
|
e3297ab593 | ||
|
|
3ff7f89ef6 | ||
|
|
ac1165c8fd | ||
|
|
69153cf339 | ||
|
|
852541c361 | ||
|
|
399a613c25 | ||
|
|
003c1082a9 | ||
|
|
885588db86 | ||
|
|
90a356b29d | ||
|
|
597623d23a | ||
|
|
2028afc941 | ||
|
|
915580ddd3 | ||
|
|
9432cca14a | ||
|
|
4e07ac0300 | ||
|
|
ad21c349cd | ||
|
|
383da335d8 | ||
|
|
ebdffc171e | ||
|
|
721b70b7b7 | ||
|
|
556bcda58a | ||
|
|
4cb5bd9edd | ||
|
|
193f6460b0 | ||
|
|
f8d8c8af2d | ||
|
|
efac6990c8 | ||
|
|
250ac481c8 | ||
|
|
44bfa514a5 | ||
|
|
74cedf99d8 | ||
|
|
4c81c321be | ||
|
|
d00fbcd886 | ||
|
|
416f80e745 | ||
|
|
6805826472 | ||
|
|
ce5d234186 | ||
|
|
c95c6e6ef0 | ||
|
|
904f8da8af | ||
|
|
645e9bf16a | ||
|
|
35235509ca | ||
|
|
021330a25d | ||
|
|
6613d5fccb | ||
|
|
9d6e7560f0 | ||
|
|
09e36e0ed8 | ||
|
|
8dde5ccd2e | ||
|
|
f1ed2156e3 | ||
|
|
40b9a60f6c | ||
|
|
59a135a1db | ||
|
|
0123c17e7e | ||
|
|
ac36eeb84d | ||
|
|
143b2b5bd5 | ||
|
|
6006c047d8 | ||
|
|
94d5fe3e43 | ||
|
|
e0ba8a1d60 | ||
|
|
2f8b0ff3a8 | ||
|
|
4700846fad | ||
|
|
6ddf2ab5f8 | ||
|
|
545a26ff04 | ||
|
|
f0f6b80f43 | ||
|
|
0227af199b | ||
|
|
970f5f2480 | ||
|
|
13d0d25f77 | ||
|
|
b64f3a48bf | ||
|
|
86ea3e8572 | ||
|
|
f15a67c8b2 | ||
|
|
659ae75a20 | ||
|
|
0d686b2f44 | ||
|
|
0d611cf4c9 | ||
|
|
6afeb45f43 | ||
|
|
d81616d23c | ||
|
|
6ea63f3e34 | ||
|
|
af52765821 | ||
|
|
acbab9e736 | ||
|
|
5bce2884a7 | ||
|
|
b92998be13 | ||
|
|
1339929de4 | ||
|
|
b0cd27e203 | ||
|
|
65e7c4c053 | ||
|
|
8d8519b52e | ||
|
|
9c95cfd64b | ||
|
|
b0a903b17d | ||
|
|
855b315067 | ||
|
|
aa7b61ecb1 | ||
|
|
c9795141df | ||
|
|
1aed82d5b7 | ||
|
|
752ed93b6f | ||
|
|
de3088f706 | ||
|
|
2608e9165c | ||
|
|
1e0e165eaf | ||
|
|
eff90aaa64 | ||
|
|
77078e1844 | ||
|
|
5929021166 | ||
|
|
8317e2e055 | ||
|
|
eb1cf8d62f | ||
|
|
f6ecb572b1 | ||
|
|
8b9fc30b97 | ||
|
|
d65954c26f | ||
|
|
8a0e260061 | ||
|
|
bb608dbfa7 | ||
|
|
ec5a7e1e48 | ||
|
|
6251dad6e0 | ||
|
|
3982f5a4db | ||
|
|
a8f8760a11 | ||
|
|
fb571ffdbf | ||
|
|
dc2956d05b | ||
|
|
85b19bfe23 | ||
|
|
5b04107447 | ||
|
|
7a5790a6ce | ||
|
|
9d3f4ffa08 | ||
|
|
bc2d4a0415 | ||
|
|
cc346351f7 | ||
|
|
fcc6032ee0 | ||
|
|
ecb040ce98 | ||
|
|
2f9692a1a0 | ||
|
|
042ab95738 | ||
|
|
13be8d511c | ||
|
|
7bdfec77ca | ||
|
|
bc176b8c50 | ||
|
|
68c0307b73 | ||
|
|
bc3d533b5f | ||
|
|
84bbac22cb | ||
|
|
4d6c620f51 | ||
|
|
fa7d19e474 | ||
|
|
3a7f9a1985 | ||
|
|
e8ff5b19f9 | ||
|
|
05701fde00 | ||
|
|
94d1669363 | ||
|
|
7f42f358a5 | ||
|
|
e9c3209322 | ||
|
|
006a01b7f2 | ||
|
|
9ddd24566d | ||
|
|
a3166a8c73 | ||
|
|
117c2ad5dd | ||
|
|
5e156c8576 | ||
|
|
a073785407 | ||
|
|
45ea0c0c97 | ||
|
|
43dcaacdaf | ||
|
|
aa89cd3d6c | ||
|
|
137ebd27eb | ||
|
|
6323cd0fd4 | ||
|
|
62305c6910 | ||
|
|
681d38611d | ||
|
|
38dedae8dd | ||
|
|
ac39821841 | ||
|
|
50aef6c3bc | ||
|
|
ad67d931df | ||
|
|
c9308f05ca | ||
|
|
d1ba4fa19f | ||
|
|
48f8ebd0a8 | ||
|
|
5237568bec | ||
|
|
49fcf08331 | ||
|
|
23af6e2bf9 | ||
|
|
eb44dd4318 | ||
|
|
9b527f7c6c | ||
|
|
1f95e0dd39 | ||
|
|
02ee1c794b | ||
|
|
63c00e638d | ||
|
|
a421b5c6bb | ||
|
|
42e7f5f4fc | ||
|
|
cffba71186 | ||
|
|
10141717bd | ||
|
|
eb190f5f00 | ||
|
|
0b315220ee | ||
|
|
2b94489721 | ||
|
|
7fe4816087 | ||
|
|
80bc2bdc89 | ||
|
|
8a2e15b76b | ||
|
|
c31c75d189 | ||
|
|
17216316f6 | ||
|
|
f1985cf506 | ||
|
|
5f7ce0d96d | ||
|
|
88fd8fb36b | ||
|
|
defe94c4fa | ||
|
|
2a8be22160 | ||
|
|
f48a13afc0 | ||
|
|
d1accfff82 | ||
|
|
d87b313fb3 | ||
|
|
ca8aa78705 | ||
|
|
b5987feab3 | ||
|
|
36c50d7dde | ||
|
|
dea73e808c | ||
|
|
6847e0e4da | ||
|
|
ca82a99b9a | ||
|
|
944e861594 | ||
|
|
b5da07762c | ||
|
|
ad6c89bc01 | ||
|
|
6b86a33f2a | ||
|
|
bde791e03e | ||
|
|
857306d7a3 | ||
|
|
8f5f1b8906 | ||
|
|
0358631029 | ||
|
|
de54ff304d | ||
|
|
03614b32e4 | ||
|
|
c0113436a2 | ||
|
|
71a44e1ebd | ||
|
|
c4131bb440 | ||
|
|
0dfe71ca8f | ||
|
|
dc66da0667 | ||
|
|
e3044b3c97 | ||
|
|
dd205e31a9 | ||
|
|
0ef627b864 | ||
|
|
18328079c8 | ||
|
|
114d9f21ed | ||
|
|
7fa1403cff | ||
|
|
276285ebef | ||
|
|
8053d567f2 | ||
|
|
9c48e669ee | ||
|
|
75e24ff7d5 | ||
|
|
e88db06c8b | ||
|
|
bcc11b9fbc | ||
|
|
b416c34fa8 | ||
|
|
bf83914357 | ||
|
|
e22c403b10 | ||
|
|
59af304002 | ||
|
|
480fc84b8b | ||
|
|
c8c0146fd0 | ||
|
|
3e1edfbc67 | ||
|
|
eba5c5ceeb | ||
|
|
0f72c6face | ||
|
|
b6f98521c8 | ||
|
|
32b710a3ca | ||
|
|
a9ed6b6154 | ||
|
|
9db5f6ddd2 | ||
|
|
a26377db6c | ||
|
|
f0bb74a187 | ||
|
|
b1ff5dc5ef | ||
|
|
773b116a83 | ||
|
|
765d1cc8ec | ||
|
|
fbcf6e11ef | ||
|
|
41783368bd | ||
|
|
9b98337e82 | ||
|
|
7c9cd8964f | ||
|
|
e2961a3f6f | ||
|
|
2743bec704 | ||
|
|
dd1697de41 | ||
|
|
5b18f05aa8 | ||
|
|
a681d06de5 | ||
|
|
cef839d300 | ||
|
|
82bb18e218 | ||
|
|
247c5de140 | ||
|
|
8fc358f0a5 | ||
|
|
28481e3aab | ||
|
|
bf8f603dcf | ||
|
|
c876c7847e | ||
|
|
293012c219 | ||
|
|
b9dc5cbe4f | ||
|
|
86afafac31 | ||
|
|
05326acadc | ||
|
|
80fc40bbc2 | ||
|
|
f0e6b2944a | ||
|
|
c1f96e5bd3 | ||
|
|
2d9135da93 | ||
|
|
095ae82483 | ||
|
|
1e866a1e86 | ||
|
|
6f52851222 | ||
|
|
0efccf67b8 | ||
|
|
e555802636 | ||
|
|
4b3013984e | ||
|
|
5d4fec2e73 | ||
|
|
1adcfd5abb | ||
|
|
3727a8e1df | ||
|
|
ac4db23709 | ||
|
|
e3356163bf | ||
|
|
f6aa324d41 | ||
|
|
ca5754cff3 | ||
|
|
629f5a3a3d | ||
|
|
076b47e695 | ||
|
|
92a28f7103 | ||
|
|
2a767c1e18 | ||
|
|
d3f622478f | ||
|
|
c66819449d | ||
|
|
aeec3a6f7e | ||
|
|
da3fc408f8 | ||
|
|
41e0f2193a | ||
|
|
9e9a47f0da | ||
|
|
7108d350e6 | ||
|
|
e9ae40e749 | ||
|
|
7cc4677120 | ||
|
|
ad00e7c5ab | ||
|
|
a4c30393ee | ||
|
|
2147ee77bc | ||
|
|
0cfa4774ad | ||
|
|
14f99bba24 | ||
|
|
8d53c1b384 | ||
|
|
fff74256b5 | ||
|
|
f154029eb1 | ||
|
|
7480124a59 | ||
|
|
6de816ce86 | ||
|
|
d2cc5d54fe | ||
|
|
390a03b783 | ||
|
|
4b326a9875 | ||
|
|
0c05bfd756 | ||
|
|
b8032378f6 | ||
|
|
2f4669d7eb | ||
|
|
0fb6062db3 | ||
|
|
8d0ad52c8a | ||
|
|
bc3352148b | ||
|
|
edf5ecf2d6 | ||
|
|
f145c20508 | ||
|
|
8b54cea119 | ||
|
|
2b1f71d3b6 | ||
|
|
df4c6b59cd |
@@ -18,3 +18,14 @@ ktlint_standard_statement-wrapping = disabled
|
||||
internal:ktlint-suppression = disabled
|
||||
ktlint_standard_unnecessary-parentheses-before-trailing-lambda = disabled
|
||||
ktlint_standard_value-parameter-comment = disabled
|
||||
|
||||
# Disable ktlint on generated source code, see
|
||||
# https://github.com/JLLeitschuh/ktlint-gradle/issues/746
|
||||
[**/build/generated/source/**]
|
||||
ktlint = disabled
|
||||
|
||||
[build/generated/*/main/**]
|
||||
ktlint = disabled
|
||||
|
||||
[**/build/generated-sources/**]
|
||||
ktlint = disabled
|
||||
6
.github/workflows/android.yml
vendored
6
.github/workflows/android.yml
vendored
@@ -16,19 +16,19 @@ jobs:
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: gradle
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/wrapper-validation-action@v1
|
||||
uses: gradle/wrapper-validation-action@v3
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew qa
|
||||
|
||||
2
.github/workflows/diffuse.yml
vendored
2
.github/workflows/diffuse.yml
vendored
@@ -8,7 +8,7 @@ permissions:
|
||||
pull-requests: write # to comment on PR
|
||||
|
||||
env:
|
||||
NDK_VERSION: '27.2.12479018'
|
||||
NDK_VERSION: '28.0.13004108'
|
||||
|
||||
jobs:
|
||||
assemble-base:
|
||||
|
||||
@@ -54,7 +54,7 @@ The form and manner of this distribution makes it eligible for export under the
|
||||
|
||||
## License
|
||||
|
||||
Copyright 2013-2024 Signal Messenger, LLC
|
||||
Copyright 2013-2025 Signal Messenger, LLC
|
||||
|
||||
Licensed under the GNU AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@file:Suppress("UnstableApiUsage")
|
||||
|
||||
import com.android.build.api.dsl.ManagedVirtualDevice
|
||||
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.FileInputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
@@ -20,8 +21,8 @@ plugins {
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
|
||||
val canonicalVersionCode = 1527
|
||||
val canonicalVersionName = "7.38.4"
|
||||
val canonicalVersionCode = 1541
|
||||
val canonicalVersionName = "7.41.2"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
@@ -90,6 +91,7 @@ android {
|
||||
kotlinOptions {
|
||||
jvmTarget = signalKotlinJvmTarget
|
||||
freeCompilerArgs = listOf("-Xjvm-default=all")
|
||||
suppressWarnings = true
|
||||
}
|
||||
|
||||
keystores["debug"]?.let { properties ->
|
||||
@@ -368,6 +370,7 @@ android {
|
||||
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
|
||||
buildConfigField("String", "APK_UPDATE_MANIFEST_URL", "\"${apkUpdateManifestUrl}\"")
|
||||
buildConfigField("String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\"")
|
||||
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "true")
|
||||
}
|
||||
|
||||
create("prod") {
|
||||
@@ -412,6 +415,8 @@ android {
|
||||
abortOnError = true
|
||||
baseline = file("lint-baseline.xml")
|
||||
checkReleaseBuilds = false
|
||||
ignoreWarnings = true
|
||||
quiet = true
|
||||
disable += "LintError"
|
||||
}
|
||||
|
||||
@@ -608,6 +613,7 @@ dependencies {
|
||||
testImplementation(testLibs.mockk)
|
||||
testImplementation(testFixtures(project(":libsignal-service")))
|
||||
testImplementation(testLibs.espresso.core)
|
||||
testImplementation(testLibs.kotlinx.coroutines.test)
|
||||
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
@@ -633,39 +639,25 @@ fun assertIsGitRepo() {
|
||||
fun getLastCommitTimestamp(): String {
|
||||
assertIsGitRepo()
|
||||
|
||||
ByteArrayOutputStream().use { os ->
|
||||
exec {
|
||||
executable = "git"
|
||||
args = listOf("log", "-1", "--pretty=format:%ct")
|
||||
standardOutput = os
|
||||
}
|
||||
|
||||
return os.toString() + "000"
|
||||
}
|
||||
return providers.exec {
|
||||
commandLine("git", "log", "-1", "--pretty=format:%ct")
|
||||
}.standardOutput.asText.get() + "000"
|
||||
}
|
||||
|
||||
fun getGitHash(): String {
|
||||
assertIsGitRepo()
|
||||
|
||||
val stdout = ByteArrayOutputStream()
|
||||
exec {
|
||||
commandLine = listOf("git", "rev-parse", "HEAD")
|
||||
standardOutput = stdout
|
||||
}
|
||||
|
||||
return stdout.toString().trim().substring(0, 12)
|
||||
return providers.exec {
|
||||
commandLine("git", "rev-parse", "HEAD")
|
||||
}.standardOutput.asText.get().trim().substring(0, 12)
|
||||
}
|
||||
|
||||
fun getCurrentGitTag(): String? {
|
||||
assertIsGitRepo()
|
||||
|
||||
val stdout = ByteArrayOutputStream()
|
||||
exec {
|
||||
commandLine = listOf("git", "tag", "--points-at", "HEAD")
|
||||
standardOutput = stdout
|
||||
}
|
||||
|
||||
val output: String = stdout.toString().trim()
|
||||
val output = providers.exec {
|
||||
commandLine("git", "tag", "--points-at", "HEAD")
|
||||
}.standardOutput.asText.get().trim()
|
||||
|
||||
return if (output.isNotEmpty()) {
|
||||
val tags = output.split("\n").toList()
|
||||
@@ -685,19 +677,10 @@ tasks.withType<Test>().configureEach {
|
||||
}
|
||||
}
|
||||
|
||||
project.tasks.configureEach {
|
||||
if (name.lowercase().contains("nightly") && name != "checkNightlyParams") {
|
||||
dependsOn(tasks.getByName("checkNightlyParams"))
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("checkNightlyParams") {
|
||||
doFirst {
|
||||
if (project.gradle.startParameter.taskNames.any { it.lowercase().contains("nightly") }) {
|
||||
|
||||
if (!file("${project.rootDir}/nightly-url.txt").exists()) {
|
||||
throw GradleException("Cannot find 'nightly-url.txt' for nightly build! It must exist in the root of this project and contain the location of the nightly manifest.")
|
||||
}
|
||||
gradle.taskGraph.whenReady {
|
||||
if (gradle.startParameter.taskNames.any { it.contains("nightly", ignoreCase = true) }) {
|
||||
if (!file("${project.rootDir}/nightly-url.txt").exists()) {
|
||||
throw GradleException("Missing required file: nightly-url.txt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38018
app/lint-baseline.xml
38018
app/lint-baseline.xml
File diff suppressed because one or more lines are too long
@@ -43,4 +43,5 @@
|
||||
</issue>
|
||||
|
||||
<issue id="OptionalUsedAsFieldOrParameterType" severity="ignore" />
|
||||
<issue id="SameParameterValue" severity="ignore" />
|
||||
</lint>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
@@ -23,6 +24,7 @@ import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -38,6 +40,7 @@ import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
|
||||
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
@@ -53,6 +56,10 @@ class MessageBackupsCheckoutActivityTest {
|
||||
|
||||
@get:Rule val composeTestRule = createEmptyComposeRule()
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
|
||||
@get:Rule val coroutineDispatcherRule = CoroutineDispatcherRule(testDispatcher)
|
||||
|
||||
private val purchaseResults = MutableSharedFlow<BillingPurchaseResult>()
|
||||
|
||||
@Before
|
||||
@@ -79,6 +86,8 @@ class MessageBackupsCheckoutActivityTest {
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsTypeSelectionScreen__next)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
runBlocking {
|
||||
purchaseResults.emit(
|
||||
BillingPurchaseResult.Success(
|
||||
@@ -94,6 +103,8 @@ class MessageBackupsCheckoutActivityTest {
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithTag("dialog-circular-progress-indicator").assertIsDisplayed()
|
||||
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
val iap = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)
|
||||
assertThat(iap?.state).isEqualTo(InAppPaymentTable.State.PENDING)
|
||||
|
||||
@@ -139,7 +150,7 @@ class MessageBackupsCheckoutActivityTest {
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)).performClick()
|
||||
|
||||
scenario.onActivity {
|
||||
val backupKeyString = SignalStore.account.accountEntropyPool.value.chunked(4).joinToString(" ")
|
||||
val backupKeyString = SignalStore.account.accountEntropyPool.displayValue.chunked(4).joinToString(" ")
|
||||
val clipboardManager = ContextCompat.getSystemService(context, ClipboardManager::class.java)
|
||||
assertThat(clipboardManager?.primaryClip?.getItemAt(0)?.coerceToText(context)).isEqualTo(backupKeyString)
|
||||
}
|
||||
@@ -147,12 +158,18 @@ class MessageBackupsCheckoutActivityTest {
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__next)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__next)).performClick()
|
||||
|
||||
// Key verification page
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyVerifyScreen__enter_the_backup_key_that_you_just_recorded)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("message-backups-key-verify-screen-backup-key-input-field").performTextInput(
|
||||
SignalStore.account.accountEntropyPool.displayValue
|
||||
)
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__next)).assertIsEnabled()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__next)).performClick()
|
||||
|
||||
// Key record bottom sheet
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__keep_your_key_safe)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("message-backups-key-record-screen-sheet-content")
|
||||
.performScrollToNode(hasText(context.getString(R.string.MessageBackupsKeyRecordScreen__continue)))
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__continue)).assertIsNotEnabled()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__ive_recorded_my_key)).performClick()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__continue)).assertIsEnabled()
|
||||
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__continue)).performClick()
|
||||
|
||||
@@ -162,8 +179,11 @@ class MessageBackupsCheckoutActivityTest {
|
||||
}
|
||||
|
||||
private fun launchCheckoutFlow(tier: MessageBackupTier? = null): ActivityScenario<MessageBackupsCheckoutActivity> {
|
||||
return ActivityScenario.launch(
|
||||
val scenario = ActivityScenario.launch<MessageBackupsCheckoutActivity>(
|
||||
MessageBackupsCheckoutActivity.Contract().createIntent(InstrumentationRegistry.getInstrumentation().targetContext, tier)
|
||||
)
|
||||
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
return scenario
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import io.mockk.every
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -20,15 +20,13 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.Delete
|
||||
import org.thoughtcrime.securesms.testing.Get
|
||||
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
|
||||
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.actions.RecyclerViewScrollToBottomAction
|
||||
import org.thoughtcrime.securesms.testing.success
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import java.math.BigDecimal
|
||||
@@ -118,32 +116,28 @@ class CheckoutFlowActivityTest__RecurringDonations {
|
||||
InAppPaymentsRepository.setSubscriber(subscriber)
|
||||
SignalStore.inAppPayments.setRecurringDonationCurrency(currency)
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/subscription/${subscriber.subscriberId.serialize()}") {
|
||||
MockResponse().success(
|
||||
ActiveSubscription(
|
||||
ActiveSubscription.Subscription(
|
||||
200,
|
||||
currency.currencyCode,
|
||||
BigDecimal.ONE,
|
||||
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
|
||||
true,
|
||||
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
|
||||
false,
|
||||
"active",
|
||||
"STRIPE",
|
||||
"CARD",
|
||||
false
|
||||
),
|
||||
null
|
||||
)
|
||||
AppDependencies.donationsApi.apply {
|
||||
every { getSubscription(subscriber.subscriberId) } returns NetworkResult.Success(
|
||||
ActiveSubscription(
|
||||
ActiveSubscription.Subscription(
|
||||
200,
|
||||
currency.currencyCode,
|
||||
BigDecimal.ONE,
|
||||
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
|
||||
true,
|
||||
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
|
||||
false,
|
||||
"active",
|
||||
"STRIPE",
|
||||
"CARD",
|
||||
false
|
||||
),
|
||||
null
|
||||
)
|
||||
},
|
||||
Delete("/v1/subscription/${subscriber.subscriberId.serialize()}") {
|
||||
Thread.sleep(10000)
|
||||
MockResponse().success()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
every { deleteSubscription(subscriber.subscriberId) } returns NetworkResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initialisePendingSubscription() {
|
||||
@@ -160,27 +154,25 @@ class CheckoutFlowActivityTest__RecurringDonations {
|
||||
InAppPaymentsRepository.setSubscriber(subscriber)
|
||||
SignalStore.inAppPayments.setRecurringDonationCurrency(currency)
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/subscription/${subscriber.subscriberId.serialize()}") {
|
||||
MockResponse().success(
|
||||
ActiveSubscription(
|
||||
ActiveSubscription.Subscription(
|
||||
200,
|
||||
currency.currencyCode,
|
||||
BigDecimal.ONE,
|
||||
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
|
||||
false,
|
||||
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
|
||||
false,
|
||||
"incomplete",
|
||||
"STRIPE",
|
||||
"CARD",
|
||||
false
|
||||
),
|
||||
null
|
||||
)
|
||||
AppDependencies.donationsApi.apply {
|
||||
every { getSubscription(subscriber.subscriberId) } returns NetworkResult.Success(
|
||||
ActiveSubscription(
|
||||
ActiveSubscription.Subscription(
|
||||
200,
|
||||
currency.currencyCode,
|
||||
BigDecimal.ONE,
|
||||
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
|
||||
false,
|
||||
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
|
||||
false,
|
||||
"incomplete",
|
||||
"STRIPE",
|
||||
"CARD",
|
||||
false
|
||||
),
|
||||
null
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import org.signal.core.util.update
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
import org.thoughtcrime.securesms.attachments.PointerAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getMediaName
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.MediaStream
|
||||
@@ -30,7 +29,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
|
||||
import org.whispersystems.signalservice.api.backup.MediaId
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
|
||||
@@ -734,12 +732,9 @@ class AttachmentTableTest_deduping {
|
||||
SignalDatabase.attachments.finalizeAttachmentAfterUpload(attachmentId, createUploadResult(attachmentId, uploadTimestamp))
|
||||
|
||||
val attachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
|
||||
SignalDatabase.attachments.setArchiveData(
|
||||
SignalDatabase.attachments.setArchiveCdn(
|
||||
attachmentId = attachmentId,
|
||||
archiveCdn = Cdn.CDN_3.cdnNumber,
|
||||
archiveMediaName = attachment.getMediaName().name,
|
||||
archiveThumbnailMediaId = MediaId(Util.getSecretBytes(15)).encode(),
|
||||
archiveMediaId = MediaId(Util.getSecretBytes(15)).encode()
|
||||
archiveCdn = Cdn.CDN_3.cdnNumber
|
||||
)
|
||||
}
|
||||
|
||||
@@ -861,8 +856,6 @@ class AttachmentTableTest_deduping {
|
||||
val rhsAttachment = SignalDatabase.attachments.getAttachment(rhs)!!
|
||||
|
||||
assertEquals(lhsAttachment.archiveCdn, rhsAttachment.archiveCdn)
|
||||
assertEquals(lhsAttachment.archiveMediaName, rhsAttachment.archiveMediaName)
|
||||
assertEquals(lhsAttachment.archiveMediaId, rhsAttachment.archiveMediaId)
|
||||
}
|
||||
|
||||
fun assertDoesNotHaveRemoteFields(attachmentId: AttachmentId) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.media3.common.util.Util
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
@@ -9,6 +10,7 @@ import org.junit.runner.RunWith
|
||||
import org.signal.core.util.count
|
||||
import org.signal.core.util.readToSingleInt
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchivedMediaObject
|
||||
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable.ArchiveMediaItem
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -16,6 +18,7 @@ class BackupMediaSnapshotTableTest {
|
||||
|
||||
companion object {
|
||||
private const val SEQUENCE_COUNT = 100
|
||||
private const val SEQUENCE_COUNT_WITH_THUMBNAILS = 200
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
@@ -24,7 +27,7 @@ class BackupMediaSnapshotTableTest {
|
||||
@Test
|
||||
fun givenAnEmptyTable_whenIWriteToTable_thenIExpectEmptyTable() {
|
||||
val pendingSyncTime = 1L
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveObjectSequence(), pendingSyncTime)
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveMediaItemSequence(), pendingSyncTime)
|
||||
|
||||
val count = getSyncedItemCount(pendingSyncTime)
|
||||
|
||||
@@ -34,22 +37,23 @@ class BackupMediaSnapshotTableTest {
|
||||
@Test
|
||||
fun givenAnEmptyTable_whenIWriteToTableAndCommit_thenIExpectFilledTable() {
|
||||
val pendingSyncTime = 1L
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveObjectSequence(), pendingSyncTime)
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveMediaItemSequence(), pendingSyncTime)
|
||||
SignalDatabase.backupMediaSnapshots.commitPendingRows()
|
||||
|
||||
val count = getSyncedItemCount(pendingSyncTime)
|
||||
|
||||
assertThat(count).isEqualTo(SEQUENCE_COUNT)
|
||||
assertThat(count).isEqualTo(SEQUENCE_COUNT_WITH_THUMBNAILS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAFilledTable_whenIInsertSimilarIds_thenIExpectUncommittedOverrides() {
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveObjectSequence(), 1L)
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveMediaItemSequence(), 1L)
|
||||
SignalDatabase.backupMediaSnapshots.commitPendingRows()
|
||||
|
||||
val newPendingTime = 2L
|
||||
val newObjectCount = 50
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveObjectSequence(newObjectCount), newPendingTime)
|
||||
val newObjectCountWithThumbnails = newObjectCount * 2
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveMediaItemSequence(newObjectCount), newPendingTime)
|
||||
|
||||
val count = SignalDatabase.backupMediaSnapshots.readableDatabase.count()
|
||||
.from(BackupMediaSnapshotTable.TABLE_NAME)
|
||||
@@ -57,17 +61,18 @@ class BackupMediaSnapshotTableTest {
|
||||
.run()
|
||||
.readToSingleInt(-1)
|
||||
|
||||
assertThat(count).isEqualTo(50)
|
||||
assertThat(count).isEqualTo(newObjectCountWithThumbnails)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAFilledTable_whenIInsertSimilarIdsAndCommit_thenIExpectCommittedOverrides() {
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveObjectSequence(), 1L)
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveMediaItemSequence(), 1L)
|
||||
SignalDatabase.backupMediaSnapshots.commitPendingRows()
|
||||
|
||||
val newPendingTime = 2L
|
||||
val newObjectCount = 50
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveObjectSequence(newObjectCount), newPendingTime)
|
||||
val newObjectCountWithThumbnails = newObjectCount * 2
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveMediaItemSequence(newObjectCount), newPendingTime)
|
||||
SignalDatabase.backupMediaSnapshots.commitPendingRows()
|
||||
|
||||
val count = SignalDatabase.backupMediaSnapshots.readableDatabase.count()
|
||||
@@ -78,18 +83,19 @@ class BackupMediaSnapshotTableTest {
|
||||
|
||||
val total = getTotalItemCount()
|
||||
|
||||
assertThat(count).isEqualTo(50)
|
||||
assertThat(total).isEqualTo(SEQUENCE_COUNT)
|
||||
assertThat(count).isEqualTo(newObjectCountWithThumbnails)
|
||||
assertThat(total).isEqualTo(SEQUENCE_COUNT_WITH_THUMBNAILS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAFilledTable_whenIInsertSimilarIdsAndCommitThenDelete_thenIExpectOnlyCommittedOverrides() {
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveObjectSequence(), 1L)
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveMediaItemSequence(), 1L)
|
||||
SignalDatabase.backupMediaSnapshots.commitPendingRows()
|
||||
|
||||
val newPendingTime = 2L
|
||||
val newObjectCount = 50
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveObjectSequence(newObjectCount), newPendingTime)
|
||||
val newObjectCountWithThumbnails = newObjectCount * 2
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveMediaItemSequence(newObjectCount), newPendingTime)
|
||||
SignalDatabase.backupMediaSnapshots.commitPendingRows()
|
||||
|
||||
val page = SignalDatabase.backupMediaSnapshots.getPageOfOldMediaObjects(currentSyncTime = newPendingTime, pageSize = 100)
|
||||
@@ -97,7 +103,86 @@ class BackupMediaSnapshotTableTest {
|
||||
|
||||
val total = getTotalItemCount()
|
||||
|
||||
assertThat(total).isEqualTo(50)
|
||||
assertThat(total).isEqualTo(newObjectCountWithThumbnails)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getMediaObjectsWithNonMatchingCdn_noMismatches() {
|
||||
val localData = listOf(
|
||||
createArchiveMediaItem(seed = 1, cdn = 1),
|
||||
createArchiveMediaItem(seed = 2, cdn = 2)
|
||||
)
|
||||
|
||||
val remoteData = listOf(
|
||||
createArchiveMediaObject(seed = 1, cdn = 1),
|
||||
createArchiveMediaObject(seed = 2, cdn = 2)
|
||||
)
|
||||
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(localData.asSequence(), 1L)
|
||||
SignalDatabase.backupMediaSnapshots.commitPendingRows()
|
||||
|
||||
val mismatches = SignalDatabase.backupMediaSnapshots.getMediaObjectsWithNonMatchingCdn(remoteData)
|
||||
assertThat(mismatches.size).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getMediaObjectsWithNonMatchingCdn_oneMismatch() {
|
||||
val localData = listOf(
|
||||
createArchiveMediaItem(seed = 1, cdn = 1),
|
||||
createArchiveMediaItem(seed = 2, cdn = 2)
|
||||
)
|
||||
|
||||
val remoteData = listOf(
|
||||
createArchiveMediaObject(seed = 1, cdn = 1),
|
||||
createArchiveMediaObject(seed = 2, cdn = 99)
|
||||
)
|
||||
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(localData.asSequence(), 1L)
|
||||
SignalDatabase.backupMediaSnapshots.commitPendingRows()
|
||||
|
||||
val mismatches = SignalDatabase.backupMediaSnapshots.getMediaObjectsWithNonMatchingCdn(remoteData)
|
||||
assertThat(mismatches.size).isEqualTo(1)
|
||||
assertThat(mismatches.get(0).cdn).isEqualTo(99)
|
||||
assertThat(mismatches.get(0).digest).isEqualTo(localData.get(1).digest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getMediaObjectsThatCantBeFound_allFound() {
|
||||
val localData = listOf(
|
||||
createArchiveMediaItem(seed = 1, cdn = 1),
|
||||
createArchiveMediaItem(seed = 2, cdn = 2)
|
||||
)
|
||||
|
||||
val remoteData = listOf(
|
||||
createArchiveMediaObject(seed = 1, cdn = 1),
|
||||
createArchiveMediaObject(seed = 2, cdn = 2)
|
||||
)
|
||||
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(localData.asSequence(), 1L)
|
||||
SignalDatabase.backupMediaSnapshots.commitPendingRows()
|
||||
|
||||
val notFound = SignalDatabase.backupMediaSnapshots.getMediaObjectsThatCantBeFound(remoteData)
|
||||
assertThat(notFound.size).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getMediaObjectsThatCantBeFound_oneMissing() {
|
||||
val localData = listOf(
|
||||
createArchiveMediaItem(seed = 1, cdn = 1),
|
||||
createArchiveMediaItem(seed = 2, cdn = 2)
|
||||
)
|
||||
|
||||
val remoteData = listOf(
|
||||
createArchiveMediaObject(seed = 1, cdn = 1),
|
||||
createArchiveMediaObject(seed = 3, cdn = 2)
|
||||
)
|
||||
|
||||
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(localData.asSequence(), 1L)
|
||||
SignalDatabase.backupMediaSnapshots.commitPendingRows()
|
||||
|
||||
val notFound = SignalDatabase.backupMediaSnapshots.getMediaObjectsThatCantBeFound(remoteData)
|
||||
assertThat(notFound.size).isEqualTo(1)
|
||||
assertThat(notFound.first().cdn).isEqualTo(2)
|
||||
}
|
||||
|
||||
private fun getTotalItemCount(): Int {
|
||||
@@ -112,8 +197,24 @@ class BackupMediaSnapshotTableTest {
|
||||
.readToSingleInt(-1)
|
||||
}
|
||||
|
||||
private fun generateArchiveObjectSequence(count: Int = SEQUENCE_COUNT): Sequence<ArchivedMediaObject> {
|
||||
private fun generateArchiveMediaItemSequence(count: Int = SEQUENCE_COUNT): Sequence<ArchiveMediaItem> {
|
||||
return generateSequence(0) { seed -> if (seed < (count - 1)) seed + 1 else null }
|
||||
.map { ArchivedMediaObject(mediaId = "media_id_$it", 0) }
|
||||
.map { createArchiveMediaItem(it) }
|
||||
}
|
||||
|
||||
private fun createArchiveMediaItem(seed: Int, cdn: Int = 0): ArchiveMediaItem {
|
||||
return ArchiveMediaItem(
|
||||
mediaId = "media_id_$seed",
|
||||
thumbnailMediaId = "thumbnail_media_id_$seed",
|
||||
cdn = cdn,
|
||||
digest = Util.toByteArray(seed)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createArchiveMediaObject(seed: Int, cdn: Int = 0): ArchivedMediaObject {
|
||||
return ArchivedMediaObject(
|
||||
mediaId = "media_id_$seed",
|
||||
cdn = cdn
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,26 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderId
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord
|
||||
import org.whispersystems.signalservice.api.storage.StorageId
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import java.util.UUID
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord as RemoteChatFolderRecord
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ChatFolderTablesTest {
|
||||
@@ -31,15 +41,19 @@ class ChatFolderTablesTest {
|
||||
private lateinit var folder2: ChatFolderRecord
|
||||
private lateinit var folder3: ChatFolderRecord
|
||||
|
||||
private lateinit var recipientIds: List<RecipientId>
|
||||
|
||||
private var aliceThread: Long = 0
|
||||
private var bobThread: Long = 0
|
||||
private var charlieThread: Long = 0
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
alice = harness.others[1]
|
||||
bob = harness.others[2]
|
||||
charlie = harness.others[3]
|
||||
recipientIds = createRecipients(5)
|
||||
|
||||
alice = recipientIds[0]
|
||||
bob = recipientIds[1]
|
||||
charlie = recipientIds[2]
|
||||
|
||||
aliceThread = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(alice))
|
||||
bobThread = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(bob))
|
||||
@@ -48,33 +62,40 @@ class ChatFolderTablesTest {
|
||||
folder1 = ChatFolderRecord(
|
||||
id = 2,
|
||||
name = "folder1",
|
||||
position = 1,
|
||||
position = 0,
|
||||
includedChats = listOf(aliceThread, bobThread),
|
||||
excludedChats = listOf(charlieThread),
|
||||
showUnread = true,
|
||||
showMutedChats = true,
|
||||
showIndividualChats = true,
|
||||
folderType = ChatFolderRecord.FolderType.CUSTOM
|
||||
folderType = ChatFolderRecord.FolderType.CUSTOM,
|
||||
chatFolderId = ChatFolderId.generate(),
|
||||
storageServiceId = StorageId.forChatFolder(byteArrayOf(1, 2, 3))
|
||||
)
|
||||
|
||||
folder2 = ChatFolderRecord(
|
||||
name = "folder2",
|
||||
position = 2,
|
||||
includedChats = listOf(bobThread),
|
||||
showUnread = true,
|
||||
showMutedChats = true,
|
||||
showIndividualChats = true,
|
||||
folderType = ChatFolderRecord.FolderType.INDIVIDUAL
|
||||
folderType = ChatFolderRecord.FolderType.INDIVIDUAL,
|
||||
chatFolderId = ChatFolderId.generate(),
|
||||
storageServiceId = StorageId.forChatFolder(byteArrayOf(2, 3, 4))
|
||||
)
|
||||
|
||||
folder3 = ChatFolderRecord(
|
||||
name = "folder3",
|
||||
position = 3,
|
||||
includedChats = listOf(bobThread),
|
||||
excludedChats = listOf(aliceThread, charlieThread),
|
||||
showUnread = true,
|
||||
showMutedChats = true,
|
||||
showGroupChats = true,
|
||||
isMuted = true,
|
||||
folderType = ChatFolderRecord.FolderType.GROUP
|
||||
folderType = ChatFolderRecord.FolderType.GROUP,
|
||||
chatFolderId = ChatFolderId.generate(),
|
||||
storageServiceId = StorageId.forChatFolder(byteArrayOf(3, 4, 5))
|
||||
)
|
||||
|
||||
SignalDatabase.chatFolders.writableDatabase.deleteAll(ChatFolderTables.ChatFolderTable.TABLE_NAME)
|
||||
@@ -84,7 +105,7 @@ class ChatFolderTablesTest {
|
||||
@Test
|
||||
fun givenChatFolder_whenIGetFolder_thenIExpectFolderWithChats() {
|
||||
SignalDatabase.chatFolders.createFolder(folder1)
|
||||
val actualFolders = SignalDatabase.chatFolders.getChatFolders()
|
||||
val actualFolders = SignalDatabase.chatFolders.getCurrentChatFolders()
|
||||
|
||||
assertEquals(listOf(folder1), actualFolders)
|
||||
}
|
||||
@@ -92,17 +113,16 @@ class ChatFolderTablesTest {
|
||||
@Test
|
||||
fun givenChatFolder_whenIUpdateFolder_thenIExpectUpdatedFolderWithChats() {
|
||||
SignalDatabase.chatFolders.createFolder(folder2)
|
||||
val folder = SignalDatabase.chatFolders.getChatFolders().first()
|
||||
val folder = SignalDatabase.chatFolders.getCurrentChatFolders().first()
|
||||
val updatedFolder = folder.copy(
|
||||
name = "updatedFolder2",
|
||||
position = 1,
|
||||
isMuted = true,
|
||||
includedChats = listOf(aliceThread, charlieThread),
|
||||
excludedChats = listOf(bobThread)
|
||||
)
|
||||
SignalDatabase.chatFolders.updateFolder(updatedFolder)
|
||||
|
||||
val actualFolder = SignalDatabase.chatFolders.getChatFolders().first()
|
||||
val actualFolder = SignalDatabase.chatFolders.getCurrentChatFolders().first()
|
||||
|
||||
assertEquals(updatedFolder, actualFolder)
|
||||
}
|
||||
@@ -111,11 +131,77 @@ class ChatFolderTablesTest {
|
||||
fun givenADeletedChatFolder_whenIGetFolders_thenIExpectAListWithoutThatFolder() {
|
||||
SignalDatabase.chatFolders.createFolder(folder1)
|
||||
SignalDatabase.chatFolders.createFolder(folder2)
|
||||
val folders = SignalDatabase.chatFolders.getChatFolders()
|
||||
val folders = SignalDatabase.chatFolders.getCurrentChatFolders()
|
||||
SignalDatabase.chatFolders.deleteChatFolder(folders.last())
|
||||
|
||||
val actualFolders = SignalDatabase.chatFolders.getChatFolders()
|
||||
val actualFolders = SignalDatabase.chatFolders.getCurrentChatFolders()
|
||||
|
||||
assertEquals(listOf(folder1), actualFolders)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenChatFolders_whenIUpdateTheirStorageSyncIds_thenIExpectAnUpdatedList() {
|
||||
val existingMap = SignalDatabase.chatFolders.getStorageSyncIdsMap()
|
||||
existingMap.forEach { (id, _) ->
|
||||
SignalDatabase.chatFolders.applyStorageIdUpdate(id, StorageId.forChatFolder(StorageSyncHelper.generateKey()))
|
||||
}
|
||||
val updatedMap = SignalDatabase.chatFolders.getStorageSyncIdsMap()
|
||||
|
||||
existingMap.forEach { (id, storageId) ->
|
||||
assertNotEquals(storageId, updatedMap[id])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenARemoteFolder_whenIInsertLocally_thenIExpectAListWithThatFolder() {
|
||||
val remoteRecord =
|
||||
SignalChatFolderRecord(
|
||||
folder1.storageServiceId!!,
|
||||
RemoteChatFolderRecord(
|
||||
identifier = UuidUtil.toByteArray(folder1.chatFolderId.uuid).toByteString(),
|
||||
name = folder1.name,
|
||||
position = folder1.position,
|
||||
showOnlyUnread = folder1.showUnread,
|
||||
showMutedChats = folder1.showMutedChats,
|
||||
includeAllIndividualChats = folder1.showIndividualChats,
|
||||
includeAllGroupChats = folder1.showGroupChats,
|
||||
folderType = RemoteChatFolderRecord.FolderType.CUSTOM,
|
||||
deletedAtTimestampMs = folder1.deletedTimestampMs,
|
||||
includedRecipients = listOf(
|
||||
RemoteChatFolderRecord.Recipient(RemoteChatFolderRecord.Recipient.Contact(Recipient.resolved(alice).serviceId.get().toString())),
|
||||
RemoteChatFolderRecord.Recipient(RemoteChatFolderRecord.Recipient.Contact(Recipient.resolved(bob).serviceId.get().toString()))
|
||||
),
|
||||
excludedRecipients = listOf(
|
||||
RemoteChatFolderRecord.Recipient(RemoteChatFolderRecord.Recipient.Contact(Recipient.resolved(charlie).serviceId.get().toString()))
|
||||
)
|
||||
|
||||
)
|
||||
)
|
||||
|
||||
SignalDatabase.chatFolders.insertChatFolderFromStorageSync(remoteRecord)
|
||||
val actualFolders = SignalDatabase.chatFolders.getCurrentChatFolders()
|
||||
|
||||
assertEquals(listOf(folder1), actualFolders)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenADeletedChatFolder_whenIGetPositions_thenIExpectPositionsToStillBeConsecutive() {
|
||||
SignalDatabase.chatFolders.createFolder(folder1)
|
||||
SignalDatabase.chatFolders.createFolder(folder2)
|
||||
SignalDatabase.chatFolders.createFolder(folder3)
|
||||
|
||||
val folders = SignalDatabase.chatFolders.getCurrentChatFolders()
|
||||
SignalDatabase.chatFolders.deleteChatFolder(folders[1])
|
||||
|
||||
val actualFolders = SignalDatabase.chatFolders.getCurrentChatFolders()
|
||||
actualFolders.forEachIndexed { index, folder ->
|
||||
assertEquals(folder.position, index)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRecipients(count: Int): List<RecipientId> {
|
||||
return (1..count).map {
|
||||
SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@ import org.thoughtcrime.securesms.testing.runSync
|
||||
import org.thoughtcrime.securesms.testing.success
|
||||
import org.whispersystems.signalservice.api.SignalServiceDataStore
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageSender
|
||||
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
|
||||
import org.whispersystems.signalservice.api.message.MessageApi
|
||||
import org.whispersystems.signalservice.api.push.TrustStore
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl
|
||||
@@ -92,6 +97,7 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
|
||||
networkInterceptors = emptyList(),
|
||||
dns = Optional.of(SignalServiceNetworkAccess.DNS),
|
||||
signalProxy = Optional.empty(),
|
||||
systemHttpProxy = Optional.empty(),
|
||||
zkGroupServerPublicParams = Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS),
|
||||
genericServerPublicParams = Base64.decode(BuildConfig.GENERIC_SERVER_PUBLIC_PARAMS),
|
||||
backupServerPublicParams = Base64.decode(BuildConfig.BACKUP_SERVER_PUBLIC_PARAMS),
|
||||
@@ -120,14 +126,23 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
|
||||
return recipientCache
|
||||
}
|
||||
|
||||
override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): ArchiveApi {
|
||||
return mockk()
|
||||
}
|
||||
|
||||
override fun provideDonationsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): DonationsApi {
|
||||
return mockk()
|
||||
}
|
||||
|
||||
override fun provideSignalServiceMessageSender(
|
||||
authWebSocket: SignalWebSocket.AuthenticatedWebSocket,
|
||||
unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket,
|
||||
protocolStore: SignalServiceDataStore,
|
||||
pushServiceSocket: PushServiceSocket
|
||||
pushServiceSocket: PushServiceSocket,
|
||||
attachmentApi: AttachmentApi,
|
||||
messageApi: MessageApi,
|
||||
keysApi: KeysApi
|
||||
): SignalServiceMessageSender {
|
||||
if (signalServiceMessageSender == null) {
|
||||
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(authWebSocket, unauthWebSocket, protocolStore, pushServiceSocket))
|
||||
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, attachmentApi, messageApi, keysApi))
|
||||
}
|
||||
return signalServiceMessageSender!!
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasSize
|
||||
import assertk.assertions.isEmpty
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.Before
|
||||
@@ -69,30 +68,6 @@ class InAppPaymentAuthCheckJobTest {
|
||||
assertThat(receipts).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenSuccessfulOneTimeAuthRequiredPayment_whenICheck_thenIExpectAReceipt() {
|
||||
initializeMockGetPaymentIntent(status = StripeIntentStatus.SUCCEEDED)
|
||||
|
||||
SignalDatabase.inAppPayments.insert(
|
||||
type = InAppPaymentType.ONE_TIME_DONATION,
|
||||
state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION,
|
||||
subscriberId = null,
|
||||
endOfPeriod = null,
|
||||
inAppPaymentData = InAppPaymentData(
|
||||
amount = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")).toFiatValue(),
|
||||
waitForAuth = InAppPaymentData.WaitingForAuthorizationState(
|
||||
stripeIntentId = TEST_INTENT_ID,
|
||||
stripeClientSecret = TEST_CLIENT_SECRET
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
InAppPaymentAuthCheckJob().run()
|
||||
|
||||
val receipts = SignalDatabase.donationReceipts.getReceipts(InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION)
|
||||
assertThat(receipts).hasSize(1)
|
||||
}
|
||||
|
||||
private fun initializeMockGetPaymentIntent(status: StripeIntentStatus) {
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get(TestStripePaths.getPaymentIntentPath(TEST_INTENT_ID, TEST_CLIENT_SECRET)) {
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import android.net.Uri
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSetupJobData
|
||||
import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSourceData
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
|
||||
/**
|
||||
* Core test logic for [InAppPaymentSetupJob]
|
||||
*/
|
||||
class InAppPaymentSetupJobTest {
|
||||
|
||||
@get:Rule
|
||||
val signalDatabaseRule = SignalDatabaseRule()
|
||||
|
||||
@Test
|
||||
fun givenAnInAppPaymentThatDoesntExist_whenIRun_thenIExpectFailure() {
|
||||
val testData = InAppPaymentSetupJobData(
|
||||
inAppPaymentId = 1L,
|
||||
inAppPaymentSource = InAppPaymentSourceData.Builder()
|
||||
.code(InAppPaymentSourceData.Code.CREDIT_CARD)
|
||||
.tokenData(InAppPaymentSourceData.TokenData())
|
||||
.build()
|
||||
)
|
||||
|
||||
val testJob = TestInAppPaymentSetupJob(testData)
|
||||
|
||||
val result = testJob.run()
|
||||
|
||||
assertThat(result.isFailure).isEqualTo(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAnInAppPaymentInEndState_whenIRun_thenIExpectFailure() {
|
||||
val id = insertInAppPayment(state = InAppPaymentTable.State.END)
|
||||
val testData = InAppPaymentSetupJobData(
|
||||
inAppPaymentId = id.rowId,
|
||||
inAppPaymentSource = InAppPaymentSourceData.Builder()
|
||||
.code(InAppPaymentSourceData.Code.CREDIT_CARD)
|
||||
.tokenData(InAppPaymentSourceData.TokenData())
|
||||
.build()
|
||||
)
|
||||
|
||||
val testJob = TestInAppPaymentSetupJob(testData)
|
||||
|
||||
val result = testJob.run()
|
||||
|
||||
assertThat(result.isFailure).isEqualTo(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAnInAppPaymentInRequiredActionCompletedWithoutCompletedState_whenIRun_thenIExpectFailure() {
|
||||
val id = insertInAppPayment(
|
||||
state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED
|
||||
)
|
||||
|
||||
val testData = InAppPaymentSetupJobData(
|
||||
inAppPaymentId = id.rowId,
|
||||
inAppPaymentSource = InAppPaymentSourceData.Builder()
|
||||
.code(InAppPaymentSourceData.Code.CREDIT_CARD)
|
||||
.tokenData(InAppPaymentSourceData.TokenData())
|
||||
.build()
|
||||
)
|
||||
|
||||
val testJob = TestInAppPaymentSetupJob(testData)
|
||||
|
||||
val result = testJob.run()
|
||||
|
||||
assertThat(result.isFailure).isEqualTo(true)
|
||||
assertThat(SignalDatabase.inAppPayments.getById(id)?.state).isEqualTo(InAppPaymentTable.State.END)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAStripeInAppPaymentInRequiredActionCompletedWithCompletedState_whenIRun_thenIExpectSuccess() {
|
||||
val id = insertInAppPayment(
|
||||
state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED,
|
||||
data = InAppPaymentData.Builder()
|
||||
.paymentMethodType(InAppPaymentData.PaymentMethodType.CARD)
|
||||
.stripeActionComplete(InAppPaymentData.StripeActionCompleteState())
|
||||
.build()
|
||||
)
|
||||
|
||||
val testData = InAppPaymentSetupJobData(
|
||||
inAppPaymentId = id.rowId,
|
||||
inAppPaymentSource = InAppPaymentSourceData.Builder()
|
||||
.code(InAppPaymentSourceData.Code.CREDIT_CARD)
|
||||
.build()
|
||||
)
|
||||
|
||||
val testJob = TestInAppPaymentSetupJob(testData)
|
||||
|
||||
val result = testJob.run()
|
||||
|
||||
assertThat(result.isSuccess).isEqualTo(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPayPalInAppPaymentInRequiredActionCompletedWithCompletedState_whenIRun_thenIExpectSuccess() {
|
||||
val id = insertInAppPayment(
|
||||
state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED,
|
||||
data = InAppPaymentData.Builder()
|
||||
.paymentMethodType(InAppPaymentData.PaymentMethodType.PAYPAL)
|
||||
.payPalActionComplete(InAppPaymentData.PayPalActionCompleteState())
|
||||
.build()
|
||||
)
|
||||
|
||||
val testData = InAppPaymentSetupJobData(
|
||||
inAppPaymentId = id.rowId,
|
||||
inAppPaymentSource = InAppPaymentSourceData.Builder()
|
||||
.code(InAppPaymentSourceData.Code.PAY_PAL)
|
||||
.build()
|
||||
)
|
||||
|
||||
val testJob = TestInAppPaymentSetupJob(testData)
|
||||
|
||||
val result = testJob.run()
|
||||
|
||||
assertThat(result.isSuccess).isEqualTo(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenRequiredActionComplete_whenIRun_thenIBypassPerformPreUserAction() {
|
||||
val id = insertInAppPayment(
|
||||
state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED,
|
||||
data = InAppPaymentData.Builder()
|
||||
.paymentMethodType(InAppPaymentData.PaymentMethodType.PAYPAL)
|
||||
.payPalActionComplete(InAppPaymentData.PayPalActionCompleteState())
|
||||
.build()
|
||||
)
|
||||
|
||||
val testData = InAppPaymentSetupJobData(
|
||||
inAppPaymentId = id.rowId,
|
||||
inAppPaymentSource = InAppPaymentSourceData.Builder()
|
||||
.code(InAppPaymentSourceData.Code.PAY_PAL)
|
||||
.build()
|
||||
)
|
||||
|
||||
val testJob = TestInAppPaymentSetupJob(
|
||||
data = testData,
|
||||
requiredUserAction = { error("Unexpected call to requiredUserAction") },
|
||||
postUserActionResult = {
|
||||
assertThat(SignalDatabase.inAppPayments.getById(id)?.state).isEqualTo(InAppPaymentTable.State.TRANSACTING)
|
||||
Job.Result.success()
|
||||
}
|
||||
)
|
||||
|
||||
val result = testJob.run()
|
||||
|
||||
assertThat(result.isSuccess).isEqualTo(true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenPayPalUserActionRequired_whenIRun_thenIDoNotPerformPostUserActionResult() {
|
||||
val id = insertInAppPayment(
|
||||
state = InAppPaymentTable.State.CREATED,
|
||||
data = InAppPaymentData.Builder()
|
||||
.paymentMethodType(InAppPaymentData.PaymentMethodType.PAYPAL)
|
||||
.build()
|
||||
)
|
||||
|
||||
val testData = InAppPaymentSetupJobData(
|
||||
inAppPaymentId = id.rowId,
|
||||
inAppPaymentSource = InAppPaymentSourceData.Builder()
|
||||
.code(InAppPaymentSourceData.Code.PAY_PAL)
|
||||
.build()
|
||||
)
|
||||
|
||||
val testJob = TestInAppPaymentSetupJob(
|
||||
data = testData,
|
||||
requiredUserAction = {
|
||||
InAppPaymentSetupJob.RequiredUserAction.PayPalActionRequired("", "")
|
||||
},
|
||||
postUserActionResult = {
|
||||
error("Unexpected call to postUserActionResult")
|
||||
}
|
||||
)
|
||||
|
||||
val result = testJob.run()
|
||||
|
||||
assertThat(result.isFailure).isEqualTo(true)
|
||||
|
||||
val fresh = SignalDatabase.inAppPayments.getById(id)!!
|
||||
assertThat(fresh.state).isEqualTo(InAppPaymentTable.State.REQUIRES_ACTION)
|
||||
assertThat(fresh.data.payPalRequiresAction).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenStripeUserActionRequired_whenIRun_thenIDoNotPerformPostUserActionResult() {
|
||||
val id = insertInAppPayment(
|
||||
state = InAppPaymentTable.State.CREATED,
|
||||
data = InAppPaymentData.Builder()
|
||||
.paymentMethodType(InAppPaymentData.PaymentMethodType.CARD)
|
||||
.build()
|
||||
)
|
||||
|
||||
val testData = InAppPaymentSetupJobData(
|
||||
inAppPaymentId = id.rowId,
|
||||
inAppPaymentSource = InAppPaymentSourceData.Builder()
|
||||
.code(InAppPaymentSourceData.Code.CREDIT_CARD)
|
||||
.build()
|
||||
)
|
||||
|
||||
val testJob = TestInAppPaymentSetupJob(
|
||||
data = testData,
|
||||
requiredUserAction = {
|
||||
InAppPaymentSetupJob.RequiredUserAction.StripeActionRequired(
|
||||
StripeApi.Secure3DSAction.ConfirmRequired(
|
||||
uri = Uri.EMPTY,
|
||||
returnUri = Uri.EMPTY,
|
||||
stripeIntentAccessor = StripeIntentAccessor(
|
||||
objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT,
|
||||
intentId = "",
|
||||
intentClientSecret = ""
|
||||
),
|
||||
paymentMethodId = null
|
||||
)
|
||||
)
|
||||
},
|
||||
postUserActionResult = {
|
||||
error("Unexpected call to postUserActionResult")
|
||||
}
|
||||
)
|
||||
|
||||
val result = testJob.run()
|
||||
|
||||
assertThat(result.isFailure).isEqualTo(true)
|
||||
|
||||
val fresh = SignalDatabase.inAppPayments.getById(id)!!
|
||||
assertThat(fresh.state).isEqualTo(InAppPaymentTable.State.REQUIRES_ACTION)
|
||||
assertThat(fresh.data.stripeRequiresAction).isNotNull()
|
||||
}
|
||||
|
||||
private fun insertInAppPayment(
|
||||
state: InAppPaymentTable.State = InAppPaymentTable.State.CREATED,
|
||||
data: InAppPaymentData = InAppPaymentData()
|
||||
): InAppPaymentTable.InAppPaymentId {
|
||||
return SignalDatabase.inAppPayments.insert(
|
||||
type = InAppPaymentType.ONE_TIME_DONATION,
|
||||
state = state,
|
||||
subscriberId = null,
|
||||
endOfPeriod = null,
|
||||
inAppPaymentData = data
|
||||
)
|
||||
}
|
||||
|
||||
private class TestInAppPaymentSetupJob(
|
||||
data: InAppPaymentSetupJobData,
|
||||
val requiredUserAction: () -> RequiredUserAction = {
|
||||
RequiredUserAction.StripeActionNotRequired(
|
||||
StripeApi.Secure3DSAction.NotNeeded(
|
||||
paymentMethodId = "",
|
||||
stripeIntentAccessor = StripeIntentAccessor(
|
||||
objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT,
|
||||
intentId = "",
|
||||
intentClientSecret = ""
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
val postUserActionResult: () -> Result = { Result.success() }
|
||||
) : InAppPaymentSetupJob(data, Parameters.Builder().build()) {
|
||||
override fun performPreUserAction(inAppPayment: InAppPaymentTable.InAppPayment): RequiredUserAction {
|
||||
return requiredUserAction()
|
||||
}
|
||||
|
||||
override fun performPostUserAction(inAppPayment: InAppPaymentTable.InAppPayment): Result {
|
||||
return postUserActionResult()
|
||||
}
|
||||
|
||||
override fun getFactoryKey(): String = error("Not used.")
|
||||
|
||||
override fun run(): Result {
|
||||
return performTransaction()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -677,7 +677,6 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
mmsId = this.mmsId,
|
||||
hasData = this.hasData,
|
||||
hasThumbnail = false,
|
||||
hasArchiveThumbnail = false,
|
||||
contentType = this.contentType,
|
||||
transferProgress = this.transferState,
|
||||
size = this.size,
|
||||
@@ -705,8 +704,6 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
|
||||
uploadTimestamp = this.uploadTimestamp,
|
||||
dataHash = this.dataHash,
|
||||
archiveCdn = this.archiveCdn,
|
||||
archiveMediaName = this.archiveMediaName,
|
||||
archiveMediaId = this.archiveMediaId,
|
||||
thumbnailRestoreState = this.thumbnailRestoreState,
|
||||
archiveTransferState = this.archiveTransferState,
|
||||
uuid = uuid
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.test.TestDispatcher
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.signal.core.util.concurrent.SignalDispatchers
|
||||
|
||||
/**
|
||||
* Rule that allows for injection of test dispatchers when operating with ViewModels.
|
||||
*/
|
||||
class CoroutineDispatcherRule(
|
||||
defaultDispatcher: TestDispatcher,
|
||||
mainDispatcher: TestDispatcher = defaultDispatcher,
|
||||
ioDispatcher: TestDispatcher = defaultDispatcher,
|
||||
unconfinedDispatcher: TestDispatcher = defaultDispatcher
|
||||
) : ExternalResource() {
|
||||
|
||||
private val testDispatcherProvider = TestDispatcherProvider(
|
||||
main = mainDispatcher,
|
||||
io = ioDispatcher,
|
||||
default = defaultDispatcher,
|
||||
unconfined = unconfinedDispatcher
|
||||
)
|
||||
|
||||
override fun before() {
|
||||
SignalDispatchers.setDispatcherProvider(testDispatcherProvider)
|
||||
}
|
||||
|
||||
override fun after() {
|
||||
SignalDispatchers.setDispatcherProvider()
|
||||
}
|
||||
|
||||
private class TestDispatcherProvider(
|
||||
override val main: CoroutineDispatcher,
|
||||
override val io: CoroutineDispatcher,
|
||||
override val default: CoroutineDispatcher,
|
||||
override val unconfined: CoroutineDispatcher
|
||||
) : SignalDispatchers.DispatcherProvider
|
||||
}
|
||||
@@ -6,10 +6,11 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import io.mockk.every
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
|
||||
/**
|
||||
@@ -23,29 +24,25 @@ class InAppPaymentsRule : ExternalResource() {
|
||||
}
|
||||
|
||||
private fun initialiseConfigurationResponse() {
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/subscription/configuration") {
|
||||
val assets = InstrumentationRegistry.getInstrumentation().context.resources.assets
|
||||
assets.open("inAppPaymentsTests/configuration.json").use { stream ->
|
||||
MockResponse().success(JsonUtils.fromJson(stream, SubscriptionsConfiguration::class.java))
|
||||
}
|
||||
}
|
||||
)
|
||||
val assets = InstrumentationRegistry.getInstrumentation().context.resources.assets
|
||||
val response = assets.open("inAppPaymentsTests/configuration.json").use { stream ->
|
||||
NetworkResult.Success(JsonUtils.fromJson(stream, SubscriptionsConfiguration::class.java))
|
||||
}
|
||||
|
||||
AppDependencies.donationsApi.apply {
|
||||
every { getDonationsConfiguration(any()) } returns response
|
||||
}
|
||||
}
|
||||
|
||||
private fun initialisePutSubscription() {
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Put("/v1/subscription/") {
|
||||
MockResponse().success()
|
||||
}
|
||||
)
|
||||
AppDependencies.donationsApi.apply {
|
||||
every { putSubscription(any()) } returns NetworkResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initialiseSetArchiveBackupId() {
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Put("/v1/archives/backupid") {
|
||||
MockResponse().success()
|
||||
}
|
||||
)
|
||||
AppDependencies.archiveApi.apply {
|
||||
every { triggerBackupIdReservation(any(), any(), any()) } returns NetworkResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.navGraphViewModels
|
||||
import org.signal.core.ui.Rows
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
|
||||
@@ -67,7 +67,7 @@ private fun Content(
|
||||
Scaffolds.Settings(
|
||||
title = "Conversation Test Springboard",
|
||||
onNavigationClick = onBackPressed,
|
||||
navigationIconPainter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.symbol_arrow_left_24))
|
||||
navigationIconPainter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.symbol_arrow_start_24))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(it)) {
|
||||
Rows.TextRow(
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
android:supportsRtl="true"
|
||||
android:resizeableActivity="true"
|
||||
android:fullBackupOnly="false"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
android:allowBackup="true"
|
||||
android:backupAgent=".absbackup.SignalBackupAgent"
|
||||
android:theme="@style/TextSecure.LightTheme"
|
||||
@@ -602,30 +603,44 @@
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https"
|
||||
android:host="signal.tube" />
|
||||
<data android:scheme="sgnl"
|
||||
android:host="signal.tube" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https"
|
||||
android:host="signal.me" />
|
||||
<data android:scheme="sgnl"
|
||||
android:host="signal.me" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="sgnl" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="signal.tube" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="sgnl" />
|
||||
<data android:host="signal.tube" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="signal.me" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="sgnl" />
|
||||
<data android:host="signal.me" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="signal.link" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="sgnl" />
|
||||
<data android:host="signal.link" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
@@ -880,6 +895,10 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".stickers.StickerManagementActivityV2"
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
<activity android:name=".logsubmit.SubmitDebugLogActivity"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
@@ -1055,8 +1074,10 @@
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".MainActivity"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout"
|
||||
android:windowSoftInputMode="stateUnchanged"
|
||||
android:resizeableActivity="true"
|
||||
android:exported="false"/>
|
||||
|
||||
@@ -1134,6 +1155,10 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".groups.ui.incommon.GroupsInCommonActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
<service
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
|
||||
BIN
app/src/main/assets/fonts/MonoSpecial-Regular.otf
Normal file
BIN
app/src/main/assets/fonts/MonoSpecial-Regular.otf
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,8 @@ object AppCapabilities {
|
||||
storage = storageCapable,
|
||||
deleteSync = true,
|
||||
versionedExpirationTimer = true,
|
||||
storageServiceEncryptionV2 = true
|
||||
storageServiceEncryptionV2 = true,
|
||||
attachmentBackfill = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ public final class AppInitialization {
|
||||
|
||||
TextSecurePreferences.setAppMigrationVersion(context, ApplicationMigrations.CURRENT_VERSION);
|
||||
TextSecurePreferences.setJobManagerVersion(context, JobManager.CURRENT_VERSION);
|
||||
TextSecurePreferences.setLastVersionCode(context, Util.getCanonicalVersionCode());
|
||||
TextSecurePreferences.setLastVersionCode(context, BuildConfig.VERSION_CODE);
|
||||
TextSecurePreferences.setHasSeenStickerIntroTooltip(context, true);
|
||||
SignalStore.settings().setPassphraseDisabled(true);
|
||||
TextSecurePreferences.setReadReceiptsEnabled(context, true);
|
||||
@@ -72,7 +72,7 @@ public final class AppInitialization {
|
||||
|
||||
TextSecurePreferences.setAppMigrationVersion(context, ApplicationMigrations.CURRENT_VERSION);
|
||||
TextSecurePreferences.setJobManagerVersion(context, JobManager.CURRENT_VERSION);
|
||||
TextSecurePreferences.setLastVersionCode(context, Util.getCanonicalVersionCode());
|
||||
TextSecurePreferences.setLastVersionCode(context, BuildConfig.VERSION_CODE);
|
||||
TextSecurePreferences.setHasSeenStickerIntroTooltip(context, true);
|
||||
SignalStore.settings().setPassphraseDisabled(true);
|
||||
AppDependencies.getMegaphoneRepository().onFirstEverAppLaunch();
|
||||
|
||||
@@ -38,6 +38,7 @@ import org.signal.core.util.logging.Log;
|
||||
import org.signal.core.util.logging.Scrubber;
|
||||
import org.signal.core.util.tracing.Tracer;
|
||||
import org.signal.glide.SignalGlideCodecs;
|
||||
import org.signal.libsignal.net.ChatServiceException;
|
||||
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider;
|
||||
import org.signal.ringrtc.CallManager;
|
||||
import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
|
||||
@@ -102,6 +103,7 @@ 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.ConversationUtil;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
@@ -188,6 +190,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
.addBlocking("tracer", this::initializeTracer)
|
||||
.addNonBlocking(() -> RegistrationUtil.maybeMarkRegistrationComplete())
|
||||
.addNonBlocking(() -> Glide.get(this))
|
||||
.addNonBlocking(ConversationUtil::refreshRecipientShortcuts)
|
||||
.addNonBlocking(this::cleanAvatarStorage)
|
||||
.addNonBlocking(this::initializeRevealableMessageManager)
|
||||
.addNonBlocking(this::initializePendingRetryReceiptManager)
|
||||
@@ -363,7 +366,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
|
||||
e = e.getCause();
|
||||
}
|
||||
|
||||
if (wasWrapped && (e instanceof SocketException || e instanceof InterruptedException || e instanceof InterruptedIOException)) {
|
||||
if (wasWrapped && (e instanceof SocketException || e instanceof InterruptedException || e instanceof InterruptedIOException || e instanceof ChatServiceException)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import android.graphics.drawable.Drawable;
|
||||
import android.os.Bundle;
|
||||
import android.transition.TransitionInflater;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -26,6 +25,7 @@ import com.bumptech.glide.request.RequestListener;
|
||||
import com.bumptech.glide.request.target.CustomTarget;
|
||||
import com.bumptech.glide.request.target.Target;
|
||||
import com.bumptech.glide.request.transition.Transition;
|
||||
import com.github.chrisbanes.photoview.PhotoView;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar;
|
||||
@@ -46,6 +46,12 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
|
||||
|
||||
private static final String RECIPIENT_ID_EXTRA = "recipient_id";
|
||||
|
||||
private static final int ZOOM_TRANSITION_DURATION = 300;
|
||||
|
||||
private static final float ZOOM_LEVEL_MIN = 1.0f;
|
||||
private static final float SMALL_IMAGES_ZOOM_LEVEL_MID = 3.0f;
|
||||
private static final float SMALL_IMAGES_ZOOM_LEVEL_MAX = 8.0f;
|
||||
|
||||
public static @NonNull Intent intentFromRecipientId(@NonNull Context context,
|
||||
@NonNull RecipientId recipientId)
|
||||
{
|
||||
@@ -78,7 +84,10 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
|
||||
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
EmojiTextView title = findViewById(R.id.title);
|
||||
ImageView avatar = findViewById(R.id.avatar);
|
||||
PhotoView avatar = findViewById(R.id.avatar);
|
||||
avatar.setZoomTransitionDuration(ZOOM_TRANSITION_DURATION);
|
||||
avatar.setScaleLevels(ZOOM_LEVEL_MIN, SMALL_IMAGES_ZOOM_LEVEL_MID, SMALL_IMAGES_ZOOM_LEVEL_MAX);
|
||||
|
||||
|
||||
setSupportActionBar(toolbar);
|
||||
|
||||
@@ -134,7 +143,7 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
|
||||
|
||||
FullscreenHelper fullscreenHelper = new FullscreenHelper(this);
|
||||
|
||||
findViewById(android.R.id.content).setOnClickListener(v -> fullscreenHelper.toggleUiVisibility());
|
||||
avatar.setOnClickListener(v -> fullscreenHelper.toggleUiVisibility());
|
||||
|
||||
fullscreenHelper.configureToolbarLayout(findViewById(R.id.toolbar_cutout_spacer), toolbar);
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@ public interface BindableConversationListItem extends Unbindable {
|
||||
@NonNull ThreadRecord thread,
|
||||
@NonNull RequestManager requestManager, @NonNull Locale locale,
|
||||
@NonNull Set<Long> typingThreads,
|
||||
@NonNull ConversationSet selectedConversations);
|
||||
@NonNull ConversationSet selectedConversations,
|
||||
long activeThreadId);
|
||||
|
||||
void setSelectedConversations(@NonNull ConversationSet conversations);
|
||||
void setActiveThreadId(long activeThreadId);
|
||||
void updateTypingIndicator(@NonNull Set<Long> typingThreads);
|
||||
void updateTimestamp();
|
||||
}
|
||||
|
||||
@@ -170,7 +170,6 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
ContactSelectionActivity activity = this.activity.get();
|
||||
|
||||
if (activity != null && !activity.isFinishing()) {
|
||||
activity.contactFilterView.clear();
|
||||
activity.contactsFragment.resetQueryFilter();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.ViewTreeObserver;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.signal.core.util.BundleExtensions;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.donations.StripeApi;
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar;
|
||||
import org.thoughtcrime.securesms.components.ConnectivityWarningBottomSheet;
|
||||
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment;
|
||||
import org.thoughtcrime.securesms.components.DeviceSpecificNotificationBottomSheet;
|
||||
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment;
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
||||
import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment;
|
||||
import org.thoughtcrime.securesms.conversationlist.RestoreCompleteBottomSheetDialog;
|
||||
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor;
|
||||
import org.thoughtcrime.securesms.notifications.VitalsViewModel;
|
||||
import org.thoughtcrime.securesms.stories.Stories;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTab;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.CachedInflater;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.SplashScreenUtil;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
|
||||
public class MainActivity extends PassphraseRequiredActivity implements VoiceNoteMediaControllerOwner {
|
||||
|
||||
private static final String KEY_STARTING_TAB = "STARTING_TAB";
|
||||
public static final int RESULT_CONFIG_CHANGED = Activity.RESULT_FIRST_USER + 901;
|
||||
|
||||
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
|
||||
private final MainNavigator navigator = new MainNavigator(this);
|
||||
|
||||
private VoiceNoteMediaController mediaController;
|
||||
private ConversationListTabsViewModel conversationListTabsViewModel;
|
||||
private VitalsViewModel vitalsViewModel;
|
||||
|
||||
private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable();
|
||||
|
||||
private boolean onFirstRender = false;
|
||||
|
||||
public static @NonNull Intent clearTop(@NonNull Context context) {
|
||||
Intent intent = new Intent(context, MainActivity.class);
|
||||
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK |
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
|
||||
return intent;
|
||||
}
|
||||
|
||||
public static @NonNull Intent clearTopAndOpenTab(@NonNull Context context, @NonNull ConversationListTab startingTab) {
|
||||
Intent intent = clearTop(context);
|
||||
intent.putExtra(KEY_STARTING_TAB, startingTab);
|
||||
|
||||
return intent;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState, boolean ready) {
|
||||
AppStartup.getInstance().onCriticalRenderEventStart();
|
||||
super.onCreate(savedInstanceState, ready);
|
||||
|
||||
setContentView(R.layout.main_activity);
|
||||
final View content = findViewById(android.R.id.content);
|
||||
content.getViewTreeObserver().addOnPreDrawListener(
|
||||
new ViewTreeObserver.OnPreDrawListener() {
|
||||
@Override
|
||||
public boolean onPreDraw() {
|
||||
// Use pre draw listener to delay drawing frames till conversation list is ready
|
||||
if (onFirstRender) {
|
||||
content.getViewTreeObserver().removeOnPreDrawListener(this);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
lifecycleDisposable.bindTo(this);
|
||||
|
||||
mediaController = new VoiceNoteMediaController(this, true);
|
||||
|
||||
|
||||
ConversationListTab startingTab = null;
|
||||
if (getIntent().getExtras() != null) {
|
||||
startingTab = BundleExtensions.getSerializableCompat(getIntent().getExtras(), KEY_STARTING_TAB, ConversationListTab.class);
|
||||
}
|
||||
|
||||
ConversationListTabRepository repository = new ConversationListTabRepository();
|
||||
ConversationListTabsViewModel.Factory factory = new ConversationListTabsViewModel.Factory(startingTab, repository);
|
||||
|
||||
handleDeeplinkIntent(getIntent());
|
||||
|
||||
CachedInflater.from(this).clear();
|
||||
|
||||
conversationListTabsViewModel = new ViewModelProvider(this, factory).get(ConversationListTabsViewModel.class);
|
||||
updateTabVisibility();
|
||||
|
||||
vitalsViewModel = new ViewModelProvider(this).get(VitalsViewModel.class);
|
||||
|
||||
lifecycleDisposable.add(
|
||||
vitalsViewModel
|
||||
.getVitalsState()
|
||||
.subscribe(this::presentVitalsState)
|
||||
);
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private void presentVitalsState(VitalsViewModel.State state) {
|
||||
switch (state) {
|
||||
case NONE:
|
||||
break;
|
||||
case PROMPT_SPECIFIC_BATTERY_SAVER_DIALOG:
|
||||
DeviceSpecificNotificationBottomSheet.show(getSupportFragmentManager());
|
||||
break;
|
||||
case PROMPT_GENERAL_BATTERY_SAVER_DIALOG:
|
||||
PromptBatterySaverDialogFragment.show(getSupportFragmentManager());
|
||||
break;
|
||||
case PROMPT_CONNECTIVITY_WARNING:
|
||||
ConnectivityWarningBottomSheet.show(getSupportFragmentManager());
|
||||
break;
|
||||
case PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS:
|
||||
DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.NOTIFICATIONS);
|
||||
break;
|
||||
case PROMPT_DEBUGLOGS_FOR_CRASH:
|
||||
DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.CRASH);
|
||||
break;
|
||||
case PROMPT_DEBUGLOGS_FOR_CONNECTIVITY_WARNING:
|
||||
DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.CONNECTIVITY_WARNING);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Intent getIntent() {
|
||||
return super.getIntent().setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK |
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
handleDeeplinkIntent(intent);
|
||||
|
||||
if (intent.getExtras() != null) {
|
||||
ConversationListTab startingTab = BundleExtensions.getSerializableCompat(intent.getExtras(), KEY_STARTING_TAB, ConversationListTab.class);
|
||||
if (startingTab != null) {
|
||||
switch (startingTab) {
|
||||
case CHATS -> conversationListTabsViewModel.onChatsSelected();
|
||||
case CALLS -> conversationListTabsViewModel.onCallsSelected();
|
||||
case STORIES -> {
|
||||
if (Stories.isFeatureEnabled()) {
|
||||
conversationListTabsViewModel.onStoriesSelected();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPreCreate() {
|
||||
super.onPreCreate();
|
||||
dynamicTheme.onCreate(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
dynamicTheme.onResume(this);
|
||||
|
||||
if (SignalStore.misc().getShouldShowLinkedDevicesReminder()) {
|
||||
SignalStore.misc().setShouldShowLinkedDevicesReminder(false);
|
||||
RelinkDevicesReminderBottomSheetFragment.show(getSupportFragmentManager());
|
||||
}
|
||||
|
||||
if (SignalStore.registration().isRestoringOnNewDevice()) {
|
||||
SignalStore.registration().setRestoringOnNewDevice(false);
|
||||
RestoreCompleteBottomSheetDialog.show(getSupportFragmentManager());
|
||||
} else if (SignalStore.misc().isOldDeviceTransferLocked()) {
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.OldDeviceTransferLockedDialog__complete_registration_on_your_new_device)
|
||||
.setMessage(R.string.OldDeviceTransferLockedDialog__your_signal_account_has_been_transferred_to_your_new_device)
|
||||
.setPositiveButton(R.string.OldDeviceTransferLockedDialog__done, (d, w) -> OldDeviceExitActivity.exit(this))
|
||||
.setNegativeButton(R.string.OldDeviceTransferLockedDialog__cancel_and_activate_this_device, (d, w) -> {
|
||||
SignalStore.misc().setOldDeviceTransferLocked(false);
|
||||
DeviceTransferBlockingInterceptor.getInstance().unblockNetwork();
|
||||
})
|
||||
.setCancelable(false)
|
||||
.show();
|
||||
}
|
||||
|
||||
updateTabVisibility();
|
||||
|
||||
vitalsViewModel.checkSlowNotificationHeuristics();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
SplashScreenUtil.setSplashScreenThemeIfNecessary(this, SignalStore.settings().getTheme());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (!navigator.onBackPressed()) {
|
||||
super.onBackPressed();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode == MainNavigator.REQUEST_CONFIG_CHANGES && resultCode == RESULT_CONFIG_CHANGED) {
|
||||
recreate();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateTabVisibility() {
|
||||
findViewById(R.id.conversation_list_tabs).setVisibility(View.VISIBLE);
|
||||
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorSurface2));
|
||||
}
|
||||
|
||||
public @NonNull MainNavigator getNavigator() {
|
||||
return navigator;
|
||||
}
|
||||
|
||||
private void handleDeeplinkIntent(Intent intent) {
|
||||
handleGroupLinkInIntent(intent);
|
||||
handleProxyInIntent(intent);
|
||||
handleSignalMeIntent(intent);
|
||||
handleCallLinkInIntent(intent);
|
||||
handleDonateReturnIntent(intent);
|
||||
}
|
||||
|
||||
private void handleGroupLinkInIntent(Intent intent) {
|
||||
Uri data = intent.getData();
|
||||
if (data != null) {
|
||||
CommunicationActions.handlePotentialGroupLinkUrl(this, data.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private void handleProxyInIntent(Intent intent) {
|
||||
Uri data = intent.getData();
|
||||
if (data != null) {
|
||||
CommunicationActions.handlePotentialProxyLinkUrl(this, data.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSignalMeIntent(Intent intent) {
|
||||
Uri data = intent.getData();
|
||||
if (data != null) {
|
||||
CommunicationActions.handlePotentialSignalMeUrl(this, data.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCallLinkInIntent(Intent intent) {
|
||||
Uri data = intent.getData();
|
||||
if (data != null) {
|
||||
CommunicationActions.handlePotentialCallLinkUrl(this, data.toString(), () -> {
|
||||
YouAreAlreadyInACallSnackbar.show(findViewById(android.R.id.content));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void handleDonateReturnIntent(Intent intent) {
|
||||
Uri data = intent.getData();
|
||||
if (data != null && data.toString().startsWith(StripeApi.RETURN_URL_IDEAL)) {
|
||||
startActivity(AppSettingsActivity.manageSubscriptions(this));
|
||||
}
|
||||
}
|
||||
|
||||
public void onFirstRender() {
|
||||
onFirstRender = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull VoiceNoteMediaController getVoiceNoteMediaController() {
|
||||
return mediaController;
|
||||
}
|
||||
}
|
||||
735
app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt
Normal file
735
app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt
Normal file
@@ -0,0 +1,735 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import android.widget.Toast
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.BoxWithConstraintsScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
|
||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.compose.AndroidFragment
|
||||
import androidx.fragment.compose.rememberFragmentState
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.getSerializableCompat
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
import org.thoughtcrime.securesms.calls.new.NewCallActivity
|
||||
import org.thoughtcrime.securesms.components.ConnectivityWarningBottomSheet
|
||||
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment
|
||||
import org.thoughtcrime.securesms.components.DeviceSpecificNotificationBottomSheet
|
||||
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity.Companion.manageSubscriptions
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
|
||||
import org.thoughtcrime.securesms.conversation.v2.MotionEventRelay
|
||||
import org.thoughtcrime.securesms.conversation.v2.ShareDataTimestampViewModel
|
||||
import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.conversationlist.RestoreCompleteBottomSheetDialog
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
|
||||
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
|
||||
import org.thoughtcrime.securesms.main.MainActivityListHostFragment
|
||||
import org.thoughtcrime.securesms.main.MainBottomChrome
|
||||
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
|
||||
import org.thoughtcrime.securesms.main.MainBottomChromeState
|
||||
import org.thoughtcrime.securesms.main.MainContentLayoutData
|
||||
import org.thoughtcrime.securesms.main.MainMegaphoneState
|
||||
import org.thoughtcrime.securesms.main.MainNavigationBar
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationListLocation
|
||||
import org.thoughtcrime.securesms.main.MainNavigationRail
|
||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel
|
||||
import org.thoughtcrime.securesms.main.MainToolbar
|
||||
import org.thoughtcrime.securesms.main.MainToolbarCallback
|
||||
import org.thoughtcrime.securesms.main.MainToolbarMode
|
||||
import org.thoughtcrime.securesms.main.MainToolbarViewModel
|
||||
import org.thoughtcrime.securesms.main.NavigationBarSpacerCompat
|
||||
import org.thoughtcrime.securesms.main.SnackbarState
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphone
|
||||
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphones
|
||||
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
|
||||
import org.thoughtcrime.securesms.notifications.VitalsViewModel
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameEditFragment
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver
|
||||
import org.thoughtcrime.securesms.util.AppStartup
|
||||
import org.thoughtcrime.securesms.util.CachedInflater
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.thoughtcrime.securesms.util.SplashScreenUtil
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
import org.thoughtcrime.securesms.window.AppScaffold
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
|
||||
class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, MainNavigator.NavigatorProvider {
|
||||
|
||||
companion object {
|
||||
private const val KEY_STARTING_TAB = "STARTING_TAB"
|
||||
const val RESULT_CONFIG_CHANGED = Activity.RESULT_FIRST_USER + 901
|
||||
|
||||
@JvmStatic
|
||||
fun clearTop(context: Context): Intent {
|
||||
return Intent(context, MainActivity::class.java)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun clearTopAndOpenTab(context: Context, startingTab: MainNavigationListLocation): Intent {
|
||||
return clearTop(context).putExtra(KEY_STARTING_TAB, startingTab)
|
||||
}
|
||||
}
|
||||
|
||||
private val dynamicTheme = DynamicNoActionBarTheme()
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private lateinit var mediaController: VoiceNoteMediaController
|
||||
private lateinit var navigator: MainNavigator
|
||||
|
||||
override val voiceNoteMediaController: VoiceNoteMediaController
|
||||
get() = mediaController
|
||||
|
||||
private val mainNavigationViewModel: MainNavigationViewModel by viewModel {
|
||||
val startingTab = intent.extras?.getSerializableCompat(KEY_STARTING_TAB, MainNavigationListLocation::class.java)
|
||||
MainNavigationViewModel(startingTab ?: MainNavigationListLocation.CHATS)
|
||||
}
|
||||
|
||||
private val vitalsViewModel: VitalsViewModel by viewModel {
|
||||
VitalsViewModel(application)
|
||||
}
|
||||
|
||||
private val openSettings: ActivityResultLauncher<Intent> = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_CONFIG_CHANGED) {
|
||||
recreate()
|
||||
}
|
||||
}
|
||||
|
||||
private val toolbarViewModel: MainToolbarViewModel by viewModels()
|
||||
private val toolbarCallback = ToolbarCallback()
|
||||
private val shareDataTimestampViewModel: ShareDataTimestampViewModel by viewModels()
|
||||
|
||||
private val motionEventRelay: MotionEventRelay by viewModels()
|
||||
|
||||
private var onFirstRender = false
|
||||
|
||||
private val mainBottomChromeCallback = BottomChromeCallback()
|
||||
private val megaphoneActionController = MainMegaphoneActionController()
|
||||
private val mainNavigationCallback = MainNavigationCallback()
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
|
||||
return motionEventRelay.offer(ev) || super.dispatchTouchEvent(ev)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
AppStartup.getInstance().onCriticalRenderEventStart()
|
||||
|
||||
enableEdgeToEdge(
|
||||
navigationBarStyle = if (DynamicTheme.isDarkTheme(this)) {
|
||||
SystemBarStyle.dark(0)
|
||||
} else {
|
||||
SystemBarStyle.light(0, 0)
|
||||
}
|
||||
)
|
||||
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
navigator = MainNavigator(this, mainNavigationViewModel)
|
||||
|
||||
AppForegroundObserver.addListener(object : AppForegroundObserver.Listener {
|
||||
override fun onForeground() {
|
||||
mainNavigationViewModel.getNextMegaphone()
|
||||
}
|
||||
})
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
mainNavigationViewModel.navigationEvents.collectLatest {
|
||||
when (it) {
|
||||
MainNavigationViewModel.NavigationEvent.STORY_CAMERA_FIRST -> {
|
||||
mainBottomChromeCallback.onCameraClick(MainNavigationListLocation.STORIES)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shareDataTimestampViewModel.setTimestampFromActivityCreation(savedInstanceState, intent)
|
||||
|
||||
setContent {
|
||||
val listHostState = rememberFragmentState()
|
||||
val detailLocation by mainNavigationViewModel.detailLocationRequests.collectAsStateWithLifecycle()
|
||||
val snackbar by mainNavigationViewModel.snackbar.collectAsStateWithLifecycle()
|
||||
val mainToolbarState by toolbarViewModel.state.collectAsStateWithLifecycle()
|
||||
val megaphone by mainNavigationViewModel.megaphone.collectAsStateWithLifecycle()
|
||||
val mainNavigationState by mainNavigationViewModel.mainNavigationState.collectAsStateWithLifecycle()
|
||||
|
||||
val isNavigationVisible = remember(mainToolbarState.mode) {
|
||||
mainToolbarState.mode == MainToolbarMode.FULL
|
||||
}
|
||||
|
||||
val mainBottomChromeState = remember(mainToolbarState.destination, snackbar, mainToolbarState.mode, megaphone) {
|
||||
MainBottomChromeState(
|
||||
destination = mainToolbarState.destination,
|
||||
snackbarState = snackbar,
|
||||
mainToolbarMode = mainToolbarState.mode,
|
||||
megaphoneState = MainMegaphoneState(
|
||||
megaphone = megaphone,
|
||||
mainToolbarMode = mainToolbarState.mode
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
|
||||
val contentLayoutData = MainContentLayoutData.rememberContentLayoutData()
|
||||
|
||||
MainContainer {
|
||||
val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator<Any>(
|
||||
scaffoldDirective = calculatePaneScaffoldDirective(
|
||||
currentWindowAdaptiveInfo()
|
||||
).copy(
|
||||
maxHorizontalPartitions = if (windowSizeClass.isSplitPane()) 2 else 1,
|
||||
horizontalPartitionSpacerSize = contentLayoutData.partitionWidth,
|
||||
defaultPanePreferredWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
|
||||
)
|
||||
)
|
||||
|
||||
LaunchedEffect(detailLocation) {
|
||||
if (detailLocation is MainNavigationDetailLocation.Conversation) {
|
||||
if (SignalStore.internal.largeScreenUi) {
|
||||
scaffoldNavigator.navigateTo(ThreePaneScaffoldRole.Primary, detailLocation)
|
||||
} else {
|
||||
startActivity((detailLocation as MainNavigationDetailLocation.Conversation).intent)
|
||||
}
|
||||
}
|
||||
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Empty)
|
||||
}
|
||||
|
||||
AppScaffold(
|
||||
navigator = scaffoldNavigator,
|
||||
bottomNavContent = {
|
||||
if (isNavigationVisible) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(contentLayoutData.navigationBarShape)
|
||||
.background(color = SignalTheme.colors.colorSurface2)
|
||||
) {
|
||||
MainNavigationBar(
|
||||
state = mainNavigationState,
|
||||
onDestinationSelected = mainNavigationCallback
|
||||
)
|
||||
|
||||
if (!windowSizeClass.isSplitPane()) {
|
||||
NavigationBarSpacerCompat()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
navRailContent = {
|
||||
if (isNavigationVisible) {
|
||||
MainNavigationRail(
|
||||
state = mainNavigationState,
|
||||
mainFloatingActionButtonsCallback = mainBottomChromeCallback,
|
||||
onDestinationSelected = mainNavigationCallback
|
||||
)
|
||||
}
|
||||
},
|
||||
listContent = {
|
||||
val listContainerColor = if (windowSizeClass.isMedium()) {
|
||||
SignalTheme.colors.colorSurface1
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = contentLayoutData.listPaddingStart)
|
||||
.fillMaxSize()
|
||||
.background(listContainerColor)
|
||||
.clip(contentLayoutData.shape)
|
||||
) {
|
||||
MainToolbar(
|
||||
state = mainToolbarState,
|
||||
callback = toolbarCallback
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
AndroidFragment(
|
||||
clazz = MainActivityListHostFragment::class.java,
|
||||
fragmentState = listHostState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
MainBottomChrome(
|
||||
state = mainBottomChromeState,
|
||||
callback = mainBottomChromeCallback,
|
||||
megaphoneActionController = megaphoneActionController,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
detailContent = {
|
||||
when (val destination = scaffoldNavigator.currentDestination?.contentKey) {
|
||||
is MainNavigationDetailLocation.Conversation -> {
|
||||
val fragmentState = key(destination) { rememberFragmentState() }
|
||||
AndroidFragment(
|
||||
clazz = ConversationFragment::class.java,
|
||||
fragmentState = fragmentState,
|
||||
arguments = requireNotNull(destination.intent.extras) { "Handed null Conversation intent arguments." },
|
||||
modifier = Modifier
|
||||
.padding(end = contentLayoutData.detailPaddingEnd)
|
||||
.clip(contentLayoutData.shape)
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
paneExpansionDragHandle = if (contentLayoutData.hasDragHandle()) {
|
||||
{ }
|
||||
} else null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val content: View = findViewById(android.R.id.content)
|
||||
content.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
|
||||
override fun onPreDraw(): Boolean {
|
||||
// Use pre draw listener to delay drawing frames till conversation list is ready
|
||||
return if (onFirstRender) {
|
||||
content.viewTreeObserver.removeOnPreDrawListener(this)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
lifecycleDisposable.bindTo(this)
|
||||
|
||||
mediaController = VoiceNoteMediaController(this, true)
|
||||
|
||||
handleDeepLinkIntent(intent)
|
||||
CachedInflater.from(this).clear()
|
||||
|
||||
lifecycleDisposable += vitalsViewModel.vitalsState.subscribe(this::presentVitalsState)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainContainer(content: @Composable BoxWithConstraintsScope.() -> Unit) {
|
||||
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
|
||||
|
||||
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(this)) {
|
||||
val backgroundColor = if (windowSizeClass.isCompact()) {
|
||||
MaterialTheme.colorScheme.surface
|
||||
} else {
|
||||
SignalTheme.colors.colorSurface1
|
||||
}
|
||||
|
||||
val modifier = if (windowSizeClass.isSplitPane()) {
|
||||
Modifier.systemBarsPadding().displayCutoutPadding()
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.background(color = backgroundColor)
|
||||
.then(modifier)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getIntent(): Intent {
|
||||
return super.getIntent().setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleDeepLinkIntent(intent)
|
||||
|
||||
val extras = intent.extras ?: return
|
||||
val startingTab = extras.getSerializableCompat(KEY_STARTING_TAB, MainNavigationListLocation::class.java)
|
||||
|
||||
when (startingTab) {
|
||||
MainNavigationListLocation.CHATS -> mainNavigationViewModel.onChatsSelected()
|
||||
MainNavigationListLocation.CALLS -> mainNavigationViewModel.onCallsSelected()
|
||||
MainNavigationListLocation.STORIES -> {
|
||||
if (Stories.isFeatureEnabled()) {
|
||||
mainNavigationViewModel.onStoriesSelected()
|
||||
}
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreCreate() {
|
||||
super.onPreCreate()
|
||||
dynamicTheme.onCreate(this)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
dynamicTheme.onResume(this)
|
||||
|
||||
if (SignalStore.misc.shouldShowLinkedDevicesReminder) {
|
||||
SignalStore.misc.shouldShowLinkedDevicesReminder = false
|
||||
RelinkDevicesReminderBottomSheetFragment.show(supportFragmentManager)
|
||||
}
|
||||
|
||||
if (SignalStore.registration.restoringOnNewDevice) {
|
||||
SignalStore.registration.restoringOnNewDevice = false
|
||||
RestoreCompleteBottomSheetDialog.show(supportFragmentManager)
|
||||
} else if (SignalStore.misc.isOldDeviceTransferLocked) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.OldDeviceTransferLockedDialog__complete_registration_on_your_new_device)
|
||||
.setMessage(R.string.OldDeviceTransferLockedDialog__your_signal_account_has_been_transferred_to_your_new_device)
|
||||
.setPositiveButton(R.string.OldDeviceTransferLockedDialog__done) { _, _ -> OldDeviceExitActivity.exit(this) }
|
||||
.setNegativeButton(R.string.OldDeviceTransferLockedDialog__cancel_and_activate_this_device) { _, _ ->
|
||||
SignalStore.misc.isOldDeviceTransferLocked = false
|
||||
DeviceTransferBlockingInterceptor.getInstance().unblockNetwork()
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
}
|
||||
|
||||
vitalsViewModel.checkSlowNotificationHeuristics()
|
||||
mainNavigationViewModel.refreshNavigationBarState()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
SplashScreenUtil.setSplashScreenThemeIfNecessary(this, SignalStore.settings.theme)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray, deviceId: Int) {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == MainNavigator.REQUEST_CONFIG_CHANGES && resultCode == RESULT_CONFIG_CHANGED) {
|
||||
recreate()
|
||||
}
|
||||
|
||||
if (resultCode == RESULT_OK && requestCode == CreateSvrPinActivity.REQUEST_NEW_PIN) {
|
||||
mainNavigationViewModel.setSnackbar(SnackbarState(message = getString(R.string.ConfirmKbsPinFragment__pin_created)))
|
||||
mainNavigationViewModel.onMegaphoneCompleted(Megaphones.Event.PINS_FOR_ALL)
|
||||
}
|
||||
|
||||
if (resultCode == RESULT_OK && requestCode == UsernameEditFragment.REQUEST_CODE) {
|
||||
val snackbarString = getString(R.string.ConversationListFragment_username_recovered_toast, SignalStore.account.username)
|
||||
mainNavigationViewModel.setSnackbar(
|
||||
SnackbarState(
|
||||
message = snackbarString
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFirstRender() {
|
||||
onFirstRender = true
|
||||
}
|
||||
|
||||
override fun getNavigator(): MainNavigator {
|
||||
return navigator
|
||||
}
|
||||
|
||||
private fun handleDeepLinkIntent(intent: Intent) {
|
||||
handleConversationIntent(intent)
|
||||
handleGroupLinkInIntent(intent)
|
||||
handleProxyInIntent(intent)
|
||||
handleSignalMeIntent(intent)
|
||||
handleCallLinkInIntent(intent)
|
||||
handleDonateReturnIntent(intent)
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
private fun presentVitalsState(state: VitalsViewModel.State) {
|
||||
when (state) {
|
||||
VitalsViewModel.State.NONE -> Unit
|
||||
VitalsViewModel.State.PROMPT_SPECIFIC_BATTERY_SAVER_DIALOG -> DeviceSpecificNotificationBottomSheet.show(supportFragmentManager)
|
||||
VitalsViewModel.State.PROMPT_GENERAL_BATTERY_SAVER_DIALOG -> PromptBatterySaverDialogFragment.show(supportFragmentManager)
|
||||
VitalsViewModel.State.PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS -> DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.NOTIFICATIONS)
|
||||
VitalsViewModel.State.PROMPT_DEBUGLOGS_FOR_CRASH -> DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.CRASH)
|
||||
VitalsViewModel.State.PROMPT_CONNECTIVITY_WARNING -> ConnectivityWarningBottomSheet.show(supportFragmentManager)
|
||||
VitalsViewModel.State.PROMPT_DEBUGLOGS_FOR_CONNECTIVITY_WARNING -> DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.CONNECTIVITY_WARNING)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleConversationIntent(intent: Intent) {
|
||||
if (ConversationIntents.isConversationIntent(intent)) {
|
||||
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(intent))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGroupLinkInIntent(intent: Intent) {
|
||||
intent.data?.let { data ->
|
||||
CommunicationActions.handlePotentialGroupLinkUrl(this, data.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleProxyInIntent(intent: Intent) {
|
||||
intent.data?.let { data ->
|
||||
CommunicationActions.handlePotentialProxyLinkUrl(this, data.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSignalMeIntent(intent: Intent) {
|
||||
intent.data?.let { data ->
|
||||
CommunicationActions.handlePotentialSignalMeUrl(this, data.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCallLinkInIntent(intent: Intent) {
|
||||
intent.data?.let { data ->
|
||||
CommunicationActions.handlePotentialCallLinkUrl(this, data.toString()) {
|
||||
show(findViewById(android.R.id.content))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDonateReturnIntent(intent: Intent) {
|
||||
intent.data?.let { data ->
|
||||
if (data.toString().startsWith(StripeApi.RETURN_URL_IDEAL)) {
|
||||
startActivity(manageSubscriptions(this))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class ToolbarCallback : MainToolbarCallback {
|
||||
|
||||
override fun onNewGroupClick() {
|
||||
startActivity(CreateGroupActivity.newIntent(this@MainActivity))
|
||||
}
|
||||
|
||||
override fun onClearPassphraseClick() {
|
||||
val intent = Intent(this@MainActivity, KeyCachingService::class.java)
|
||||
intent.setAction(KeyCachingService.CLEAR_KEY_ACTION)
|
||||
startService(intent)
|
||||
}
|
||||
|
||||
override fun onMarkReadClick() {
|
||||
toolbarViewModel.markAllMessagesRead()
|
||||
}
|
||||
|
||||
override fun onInviteFriendsClick() {
|
||||
val intent = Intent(this@MainActivity, InviteActivity::class.java)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun onFilterUnreadChatsClick() {
|
||||
toolbarViewModel.setChatFilter(ConversationFilter.UNREAD)
|
||||
}
|
||||
|
||||
override fun onClearUnreadChatsFilterClick() {
|
||||
toolbarViewModel.setChatFilter(ConversationFilter.OFF)
|
||||
}
|
||||
|
||||
override fun onSettingsClick() {
|
||||
openSettings.launch(AppSettingsActivity.home(this@MainActivity))
|
||||
}
|
||||
|
||||
override fun onNotificationProfileClick() {
|
||||
NotificationProfileSelectionFragment.show(supportFragmentManager)
|
||||
}
|
||||
|
||||
override fun onProxyClick() {
|
||||
startActivity(AppSettingsActivity.proxy(this@MainActivity))
|
||||
}
|
||||
|
||||
override fun onSearchClick() {
|
||||
toolbarViewModel.setToolbarMode(MainToolbarMode.SEARCH)
|
||||
}
|
||||
|
||||
override fun onClearCallHistoryClick() {
|
||||
toolbarViewModel.clearCallHistory()
|
||||
}
|
||||
|
||||
override fun onFilterMissedCallsClick() {
|
||||
toolbarViewModel.setCallLogFilter(CallLogFilter.MISSED)
|
||||
}
|
||||
|
||||
override fun onClearCallFilterClick() {
|
||||
toolbarViewModel.setCallLogFilter(CallLogFilter.ALL)
|
||||
}
|
||||
|
||||
override fun onStoryPrivacyClick() {
|
||||
startActivity(StorySettingsActivity.getIntent(this@MainActivity))
|
||||
}
|
||||
|
||||
override fun onCloseSearchClick() {
|
||||
toolbarViewModel.setToolbarMode(MainToolbarMode.FULL)
|
||||
}
|
||||
|
||||
override fun onCloseArchiveClick() {
|
||||
toolbarViewModel.emitEvent(MainToolbarViewModel.Event.Chats.CloseArchive)
|
||||
}
|
||||
|
||||
override fun onSearchQueryUpdated(query: String) {
|
||||
toolbarViewModel.setSearchQuery(query)
|
||||
}
|
||||
|
||||
override fun onNotificationProfileTooltipDismissed() {
|
||||
SignalStore.notificationProfile.hasSeenTooltip = true
|
||||
toolbarViewModel.setShowNotificationProfilesTooltip(false)
|
||||
}
|
||||
}
|
||||
|
||||
inner class BottomChromeCallback : MainBottomChromeCallback {
|
||||
override fun onNewChatClick() {
|
||||
startActivity(Intent(this@MainActivity, NewConversationActivity::class.java))
|
||||
}
|
||||
|
||||
override fun onNewCallClick() {
|
||||
startActivity(NewCallActivity.createIntent(this@MainActivity))
|
||||
}
|
||||
|
||||
override fun onCameraClick(destination: MainNavigationListLocation) {
|
||||
val onGranted = {
|
||||
startActivity(
|
||||
MediaSelectionActivity.camera(
|
||||
context = this@MainActivity,
|
||||
isStory = destination == MainNavigationListLocation.STORIES
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (CameraXUtil.isSupported()) {
|
||||
onGranted()
|
||||
} else {
|
||||
Permissions.with(this@MainActivity)
|
||||
.request(Manifest.permission.CAMERA)
|
||||
.ifNecessary()
|
||||
.withRationaleDialog(getString(R.string.CameraXFragment_allow_access_camera), getString(R.string.CameraXFragment_to_capture_photos_and_video_allow_camera), R.drawable.symbol_camera_24)
|
||||
.withPermanentDenialDialog(
|
||||
getString(R.string.CameraXFragment_signal_needs_camera_access_capture_photos),
|
||||
null,
|
||||
R.string.CameraXFragment_allow_access_camera,
|
||||
R.string.CameraXFragment_to_capture_photos_videos,
|
||||
supportFragmentManager
|
||||
)
|
||||
.onAllGranted(onGranted)
|
||||
.onAnyDenied { Toast.makeText(this@MainActivity, R.string.CameraXFragment_signal_needs_camera_access_capture_photos, Toast.LENGTH_LONG).show() }
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMegaphoneVisible(megaphone: Megaphone) {
|
||||
mainNavigationViewModel.onMegaphoneVisible(megaphone)
|
||||
}
|
||||
|
||||
override fun onSnackbarDismissed() {
|
||||
mainNavigationViewModel.setSnackbar(null)
|
||||
}
|
||||
}
|
||||
|
||||
inner class MainMegaphoneActionController : MegaphoneActionController {
|
||||
override fun onMegaphoneNavigationRequested(intent: Intent) {
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun onMegaphoneNavigationRequested(intent: Intent, requestCode: Int) {
|
||||
startActivityForResult(intent, requestCode)
|
||||
}
|
||||
|
||||
override fun onMegaphoneToastRequested(string: String) {
|
||||
mainNavigationViewModel.setSnackbar(
|
||||
SnackbarState(
|
||||
message = string
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getMegaphoneActivity(): Activity {
|
||||
return this@MainActivity
|
||||
}
|
||||
|
||||
override fun onMegaphoneSnooze(event: Megaphones.Event) {
|
||||
mainNavigationViewModel.onMegaphoneSnoozed(event)
|
||||
}
|
||||
|
||||
override fun onMegaphoneCompleted(event: Megaphones.Event) {
|
||||
mainNavigationViewModel.onMegaphoneCompleted(event)
|
||||
}
|
||||
|
||||
override fun onMegaphoneDialogFragmentRequested(dialogFragment: DialogFragment) {
|
||||
dialogFragment.show(supportFragmentManager, "megaphone_dialog")
|
||||
}
|
||||
}
|
||||
|
||||
private inner class MainNavigationCallback : (MainNavigationListLocation) -> Unit {
|
||||
override fun invoke(location: MainNavigationListLocation) {
|
||||
when (location) {
|
||||
MainNavigationListLocation.CHATS -> mainNavigationViewModel.onChatsSelected()
|
||||
MainNavigationListLocation.CALLS -> mainNavigationViewModel.onCallsSelected()
|
||||
MainNavigationListLocation.STORIES -> mainNavigationViewModel.onStoriesSelected()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,15 @@ import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
|
||||
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation;
|
||||
import org.thoughtcrime.securesms.main.MainNavigationViewModel;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
@@ -19,12 +21,14 @@ public class MainNavigator {
|
||||
|
||||
public static final int REQUEST_CONFIG_CHANGES = 901;
|
||||
|
||||
private final MainActivity activity;
|
||||
private final LifecycleDisposable lifecycleDisposable;
|
||||
private final AppCompatActivity activity;
|
||||
private final LifecycleDisposable lifecycleDisposable;
|
||||
private final MainNavigationViewModel viewModel;
|
||||
|
||||
public MainNavigator(@NonNull MainActivity activity) {
|
||||
public MainNavigator(@NonNull AppCompatActivity activity, @NonNull MainNavigationViewModel viewModel) {
|
||||
this.activity = activity;
|
||||
this.lifecycleDisposable = new LifecycleDisposable();
|
||||
this.viewModel = viewModel;
|
||||
|
||||
lifecycleDisposable.bindTo(activity);
|
||||
}
|
||||
@@ -34,21 +38,7 @@ public class MainNavigator {
|
||||
throw new IllegalArgumentException("Activity must be an instance of MainActivity!");
|
||||
}
|
||||
|
||||
return ((MainActivity) activity).getNavigator();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return True if the back pressed was handled in our own custom way, false if it should be given
|
||||
* to the system to do the default behavior.
|
||||
*/
|
||||
public boolean onBackPressed() {
|
||||
Fragment fragment = getFragmentManager().findFragmentById(R.id.fragment_container);
|
||||
|
||||
if (fragment instanceof BackHandler) {
|
||||
return ((BackHandler) fragment).onBackPressed();
|
||||
}
|
||||
|
||||
return false;
|
||||
return ((NavigatorProvider) activity).getNavigator();
|
||||
}
|
||||
|
||||
public void goToConversation(@NonNull RecipientId recipientId, long threadId, int distributionType, int startingPosition) {
|
||||
@@ -56,10 +46,7 @@ public class MainNavigator {
|
||||
.map(builder -> builder.withDistributionType(distributionType)
|
||||
.withStartingPosition(startingPosition)
|
||||
.build())
|
||||
.subscribe(intent -> {
|
||||
activity.startActivity(intent);
|
||||
activity.overridePendingTransition(R.anim.slide_from_end, R.anim.fade_scale_out);
|
||||
});
|
||||
.subscribe(intent -> viewModel.goTo(new MainNavigationDetailLocation.Conversation(intent)));
|
||||
|
||||
lifecycleDisposable.add(disposable);
|
||||
}
|
||||
@@ -88,4 +75,9 @@ public class MainNavigator {
|
||||
*/
|
||||
boolean onBackPressed();
|
||||
}
|
||||
|
||||
public interface NavigatorProvider {
|
||||
@NonNull MainNavigator getNavigator();
|
||||
void onFirstRender();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.attachments
|
||||
|
||||
import android.Manifest
|
||||
import android.widget.CheckBox
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import kotlinx.coroutines.rx3.rxCompletable
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.ui.view.AlertDialogResult
|
||||
import org.signal.core.ui.view.awaitResult
|
||||
import org.signal.core.util.concurrent.SignalDispatchers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
|
||||
import org.thoughtcrime.securesms.components.ProgressCardDialogFragmentArgs
|
||||
import org.thoughtcrime.securesms.database.MediaTable
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.util.SaveAttachmentUtil
|
||||
import org.thoughtcrime.securesms.util.SaveAttachmentUtil.SaveAttachment
|
||||
import org.thoughtcrime.securesms.util.SaveAttachmentUtil.SaveAttachmentsResult
|
||||
import org.thoughtcrime.securesms.util.StorageUtil
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
/**
|
||||
* Executes all of the steps needed to save message attachments to the device storage, including:
|
||||
* - Showing the save to storage warning/confirmation dialog.
|
||||
* - Requesting WRITE_EXTERNAL_STORAGE permission.
|
||||
* - Showing/dismissing media save progress.
|
||||
*/
|
||||
class AttachmentSaver(private val host: Host) {
|
||||
|
||||
constructor(fragment: Fragment) : this(FragmentHost(fragment))
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(AttachmentSaver::class)
|
||||
private const val PROGRESS_DIALOG_TAG = "AttachmentSaver_progress_dialog"
|
||||
}
|
||||
|
||||
suspend fun saveAttachments(record: MmsMessageRecord) {
|
||||
val attachments = record.slideDeck.slides
|
||||
.filter { it.uri != null && (it.hasImage() || it.hasVideo() || it.hasAudio() || it.hasDocument()) }
|
||||
.map { SaveAttachment(it.uri!!, it.contentType, record.dateSent, it.fileName.orNull()) }
|
||||
.toSet()
|
||||
saveAttachments(attachments)
|
||||
}
|
||||
|
||||
fun saveAttachmentsRx(attachments: Set<SaveAttachment>): Completable = rxCompletable { saveAttachments(attachments) }
|
||||
|
||||
suspend fun saveAttachments(records: Collection<MediaTable.MediaRecord>) {
|
||||
val attachments = records.mapNotNull { record ->
|
||||
val uri = record.attachment?.uri
|
||||
val contentType = record.contentType
|
||||
if (uri != null && contentType != null) {
|
||||
SaveAttachment(uri, contentType, record.date, record.attachment.fileName)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.toSet()
|
||||
saveAttachments(attachments)
|
||||
}
|
||||
|
||||
fun saveAttachmentsRx(records: Collection<MediaTable.MediaRecord>): Completable = rxCompletable { saveAttachments(records) }
|
||||
suspend fun saveAttachments(attachments: Set<SaveAttachment>) {
|
||||
if (checkIsSaveWarningAccepted(attachmentCount = attachments.size) == SaveToStorageWarningResult.ACCEPTED) {
|
||||
if (checkCanWriteToMediaStore() == RequestPermissionResult.GRANTED) {
|
||||
Log.d(TAG, "Saving ${attachments.size} attachments to device storage.")
|
||||
saveToStorage(attachments)
|
||||
} else {
|
||||
Log.d(TAG, "Cancel saving ${attachments.size} attachments: media store permission denied.")
|
||||
host.showSaveResult(SaveAttachmentsResult.WriteStoragePermissionDenied)
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Cancel saving ${attachments.size} attachments: save to storage warning denied.")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkIsSaveWarningAccepted(attachmentCount: Int): SaveToStorageWarningResult {
|
||||
if (SignalStore.uiHints.hasDismissedSaveStorageWarning()) {
|
||||
return SaveToStorageWarningResult.ACCEPTED
|
||||
}
|
||||
return host.showSaveToStorageWarning(attachmentCount)
|
||||
}
|
||||
|
||||
private suspend fun checkCanWriteToMediaStore(): RequestPermissionResult {
|
||||
if (StorageUtil.canWriteToMediaStore()) {
|
||||
return RequestPermissionResult.GRANTED
|
||||
}
|
||||
return host.requestWriteExternalStoragePermission()
|
||||
}
|
||||
|
||||
private suspend fun saveToStorage(attachments: Set<SaveAttachment>): SaveAttachmentsResult {
|
||||
host.showSaveProgress(attachmentCount = attachments.size)
|
||||
return try {
|
||||
val result = SaveAttachmentUtil.saveAttachments(attachments)
|
||||
withContext(SignalDispatchers.Main) {
|
||||
host.showSaveResult(result)
|
||||
}
|
||||
result
|
||||
} finally {
|
||||
withContext(SignalDispatchers.Main) {
|
||||
host.dismissSaveProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Host {
|
||||
suspend fun showSaveToStorageWarning(attachmentCount: Int): SaveToStorageWarningResult
|
||||
suspend fun requestWriteExternalStoragePermission(): RequestPermissionResult
|
||||
fun showSaveProgress(attachmentCount: Int)
|
||||
fun showSaveResult(result: SaveAttachmentsResult)
|
||||
fun dismissSaveProgress()
|
||||
}
|
||||
|
||||
data class FragmentHost(private val fragment: Fragment) : Host {
|
||||
|
||||
override fun showSaveResult(result: SaveAttachmentsResult) {
|
||||
Toast.makeText(fragment.requireContext(), result.getMessage(fragment.requireContext()), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
override suspend fun showSaveToStorageWarning(attachmentCount: Int): SaveToStorageWarningResult = withContext(SignalDispatchers.Main) {
|
||||
val dialog = MaterialAlertDialogBuilder(fragment.requireContext())
|
||||
.setView(R.layout.dialog_save_attachment)
|
||||
.setTitle(R.string.AttachmentSaver__save_to_phone)
|
||||
.setCancelable(true)
|
||||
.setMessage(fragment.resources.getQuantityString(R.plurals.AttachmentSaver__this_media_will_be_saved, attachmentCount, attachmentCount))
|
||||
.create()
|
||||
|
||||
val result = dialog.awaitResult(
|
||||
positiveButtonTextId = R.string.save,
|
||||
negativeButtonTextId = android.R.string.cancel
|
||||
)
|
||||
|
||||
if (result == AlertDialogResult.POSITIVE) {
|
||||
val dontShowAgainCheckbox = dialog.findViewById<CheckBox>(R.id.checkbox)!!
|
||||
if (dontShowAgainCheckbox.isChecked) {
|
||||
SignalStore.uiHints.markDismissedSaveStorageWarning()
|
||||
}
|
||||
return@withContext SaveToStorageWarningResult.ACCEPTED
|
||||
}
|
||||
return@withContext SaveToStorageWarningResult.DENIED
|
||||
}
|
||||
|
||||
override suspend fun requestWriteExternalStoragePermission(): RequestPermissionResult = withContext(SignalDispatchers.Main) {
|
||||
suspendCoroutine { continuation ->
|
||||
Permissions.with(fragment)
|
||||
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
.ifNecessary()
|
||||
.withPermanentDenialDialog(fragment.getString(R.string.AttachmentSaver__signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
|
||||
.onAnyDenied {
|
||||
Log.d(TAG, "WRITE_EXTERNAL_STORAGE permission request denied.")
|
||||
continuation.resume(RequestPermissionResult.DENIED)
|
||||
}
|
||||
.onAllGranted {
|
||||
Log.d(TAG, "WRITE_EXTERNAL_STORAGE permission request granted.")
|
||||
continuation.resume(RequestPermissionResult.GRANTED)
|
||||
}
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
|
||||
override fun showSaveProgress(attachmentCount: Int) {
|
||||
val progressMessage = fragment.resources.getQuantityString(R.plurals.ConversationFragment_saving_n_attachments_to_sd_card, attachmentCount, attachmentCount)
|
||||
|
||||
val dialog = ProgressCardDialogFragment.create().apply {
|
||||
arguments = ProgressCardDialogFragmentArgs.Builder(progressMessage).build().toBundle()
|
||||
}
|
||||
|
||||
dialog.show(fragment.parentFragmentManager, PROGRESS_DIALOG_TAG)
|
||||
}
|
||||
|
||||
override fun dismissSaveProgress() {
|
||||
val dialog = fragment.parentFragmentManager.findFragmentByTag(PROGRESS_DIALOG_TAG)
|
||||
(dialog as ProgressCardDialogFragment).dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
|
||||
enum class SaveToStorageWarningResult {
|
||||
ACCEPTED,
|
||||
DENIED
|
||||
}
|
||||
|
||||
enum class RequestPermissionResult {
|
||||
GRANTED,
|
||||
DENIED
|
||||
}
|
||||
}
|
||||
@@ -29,19 +29,12 @@ class DatabaseAttachment : Attachment {
|
||||
@JvmField
|
||||
val archiveCdn: Int
|
||||
|
||||
@JvmField
|
||||
val archiveMediaName: String?
|
||||
|
||||
@JvmField
|
||||
val archiveMediaId: String?
|
||||
|
||||
@JvmField
|
||||
val thumbnailRestoreState: AttachmentTable.ThumbnailRestoreState
|
||||
|
||||
@JvmField
|
||||
val archiveTransferState: AttachmentTable.ArchiveTransferState
|
||||
|
||||
private val hasArchiveThumbnail: Boolean
|
||||
private val hasThumbnail: Boolean
|
||||
val displayOrder: Int
|
||||
|
||||
@@ -50,7 +43,6 @@ class DatabaseAttachment : Attachment {
|
||||
mmsId: Long,
|
||||
hasData: Boolean,
|
||||
hasThumbnail: Boolean,
|
||||
hasArchiveThumbnail: Boolean,
|
||||
contentType: String?,
|
||||
transferProgress: Int,
|
||||
size: Long,
|
||||
@@ -78,8 +70,6 @@ class DatabaseAttachment : Attachment {
|
||||
uploadTimestamp: Long,
|
||||
dataHash: String?,
|
||||
archiveCdn: Int,
|
||||
archiveMediaName: String?,
|
||||
archiveMediaId: String?,
|
||||
thumbnailRestoreState: AttachmentTable.ThumbnailRestoreState,
|
||||
archiveTransferState: AttachmentTable.ArchiveTransferState,
|
||||
uuid: UUID?
|
||||
@@ -114,11 +104,8 @@ class DatabaseAttachment : Attachment {
|
||||
this.hasData = hasData
|
||||
this.dataHash = dataHash
|
||||
this.hasThumbnail = hasThumbnail
|
||||
this.hasArchiveThumbnail = hasArchiveThumbnail
|
||||
this.displayOrder = displayOrder
|
||||
this.archiveCdn = archiveCdn
|
||||
this.archiveMediaName = archiveMediaName
|
||||
this.archiveMediaId = archiveMediaId
|
||||
this.thumbnailRestoreState = thumbnailRestoreState
|
||||
this.archiveTransferState = archiveTransferState
|
||||
}
|
||||
@@ -131,9 +118,6 @@ class DatabaseAttachment : Attachment {
|
||||
mmsId = parcel.readLong()
|
||||
displayOrder = parcel.readInt()
|
||||
archiveCdn = parcel.readInt()
|
||||
archiveMediaName = parcel.readString()
|
||||
archiveMediaId = parcel.readString()
|
||||
hasArchiveThumbnail = ParcelUtil.readBoolean(parcel)
|
||||
thumbnailRestoreState = AttachmentTable.ThumbnailRestoreState.deserialize(parcel.readInt())
|
||||
archiveTransferState = AttachmentTable.ArchiveTransferState.deserialize(parcel.readInt())
|
||||
}
|
||||
@@ -147,9 +131,6 @@ class DatabaseAttachment : Attachment {
|
||||
dest.writeLong(mmsId)
|
||||
dest.writeInt(displayOrder)
|
||||
dest.writeInt(archiveCdn)
|
||||
dest.writeString(archiveMediaName)
|
||||
dest.writeString(archiveMediaId)
|
||||
ParcelUtil.writeBoolean(dest, hasArchiveThumbnail)
|
||||
dest.writeInt(thumbnailRestoreState.value)
|
||||
dest.writeInt(archiveTransferState.value)
|
||||
}
|
||||
@@ -169,7 +150,7 @@ class DatabaseAttachment : Attachment {
|
||||
}
|
||||
|
||||
override val thumbnailUri: Uri?
|
||||
get() = if (hasArchiveThumbnail) {
|
||||
get() = if (thumbnailRestoreState == AttachmentTable.ThumbnailRestoreState.FINISHED) {
|
||||
PartAuthority.getAttachmentThumbnailUri(attachmentId)
|
||||
} else {
|
||||
null
|
||||
|
||||
@@ -9,12 +9,18 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.map
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.database.model.ProfileAvatarFileDetails
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.NameUtil
|
||||
|
||||
@Composable
|
||||
fun AvatarImage(
|
||||
@@ -28,15 +34,24 @@ fun AvatarImage(
|
||||
.background(color = Color.Red, shape = CircleShape)
|
||||
)
|
||||
} else {
|
||||
val context = LocalContext.current
|
||||
val state = recipient.live().liveData.map { AvatarImageState(NameUtil.getAbbreviation(it.getDisplayName(context)), it, AvatarHelper.getAvatarFileDetails(context, it.id)) }.observeAsState().value ?: return
|
||||
|
||||
AndroidView(
|
||||
factory = ::AvatarImageView,
|
||||
modifier = modifier.background(color = Color.Transparent, shape = CircleShape)
|
||||
) {
|
||||
if (useProfile) {
|
||||
it.setAvatarUsingProfile(recipient)
|
||||
it.setAvatarUsingProfile(state.self)
|
||||
} else {
|
||||
it.setAvatar(recipient)
|
||||
it.setAvatar(state.self)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class AvatarImageState(
|
||||
val displayName: String?,
|
||||
val self: Recipient,
|
||||
val avatarFileDetails: ProfileAvatarFileDetails
|
||||
)
|
||||
|
||||
@@ -27,8 +27,8 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.TextUnitType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.avatar.AvatarRenderer
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
|
||||
@@ -5,21 +5,20 @@
|
||||
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.throttleLatest
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.max
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -28,6 +27,8 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
*/
|
||||
object ArchiveUploadProgress {
|
||||
|
||||
private val TAG = Log.tag(ArchiveUploadProgress::class)
|
||||
|
||||
private val PROGRESS_NONE = ArchiveUploadProgressState(
|
||||
state = ArchiveUploadProgressState.State.None
|
||||
)
|
||||
@@ -36,18 +37,30 @@ object ArchiveUploadProgress {
|
||||
|
||||
private var uploadProgress: ArchiveUploadProgressState = SignalStore.backup.archiveUploadState ?: PROGRESS_NONE
|
||||
|
||||
private val partialMediaProgress: MutableMap<AttachmentId, Long> = ConcurrentHashMap()
|
||||
|
||||
/**
|
||||
* Observe this to get updates on the current upload progress.
|
||||
*/
|
||||
val progress: Flow<ArchiveUploadProgressState> = _progress
|
||||
.throttleLatest(500.milliseconds)
|
||||
.throttleLatest(500.milliseconds) {
|
||||
uploadProgress.state == ArchiveUploadProgressState.State.None ||
|
||||
(uploadProgress.state == ArchiveUploadProgressState.State.UploadBackupFile && uploadProgress.backupFileUploadedBytes == 0L) ||
|
||||
(uploadProgress.state == ArchiveUploadProgressState.State.UploadMedia && uploadProgress.mediaUploadedBytes == 0L)
|
||||
}
|
||||
.map {
|
||||
if (uploadProgress.state != ArchiveUploadProgressState.State.UploadingAttachments) {
|
||||
if (uploadProgress.state != ArchiveUploadProgressState.State.UploadMedia) {
|
||||
return@map uploadProgress
|
||||
}
|
||||
|
||||
val pendingCount = SignalDatabase.attachments.getPendingArchiveUploadCount()
|
||||
if (pendingCount == uploadProgress.totalAttachments) {
|
||||
if (!SignalStore.backup.backsUpMedia) {
|
||||
Log.i(TAG, "Doesn't upload media. Done!")
|
||||
return@map PROGRESS_NONE
|
||||
}
|
||||
|
||||
val pendingMediaUploadBytes = SignalDatabase.attachments.getPendingArchiveUploadBytes() - partialMediaProgress.values.sum()
|
||||
if (pendingMediaUploadBytes <= 0) {
|
||||
Log.i(TAG, "No more pending bytes. Done!")
|
||||
return@map PROGRESS_NONE
|
||||
}
|
||||
|
||||
@@ -55,90 +68,96 @@ object ArchiveUploadProgress {
|
||||
// If we wanted the most accurate progress possible, we could maintain a new database flag that indicates whether an attachment has been flagged as part
|
||||
// of the current upload batch. However, this gets us pretty close while keeping things simple and not having to juggle extra flags, with the caveat that
|
||||
// the progress bar may occasionally be including media that is not actually referenced in the active backup file.
|
||||
val totalCount = max(uploadProgress.totalAttachments, pendingCount)
|
||||
val totalMediaUploadBytes = max(uploadProgress.mediaTotalBytes, pendingMediaUploadBytes)
|
||||
|
||||
ArchiveUploadProgressState(
|
||||
state = ArchiveUploadProgressState.State.UploadingAttachments,
|
||||
completedAttachments = totalCount - pendingCount,
|
||||
totalAttachments = totalCount
|
||||
state = ArchiveUploadProgressState.State.UploadMedia,
|
||||
mediaUploadedBytes = totalMediaUploadBytes - pendingMediaUploadBytes,
|
||||
mediaTotalBytes = totalMediaUploadBytes
|
||||
)
|
||||
}
|
||||
.onEach {
|
||||
updateState(it, notify = false)
|
||||
.onEach { updated ->
|
||||
updateState(notify = false) { updated }
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
.shareIn(scope = CoroutineScope(Dispatchers.IO), started = SharingStarted.WhileSubscribed(), replay = 1)
|
||||
|
||||
val inProgress
|
||||
get() = uploadProgress.state != ArchiveUploadProgressState.State.None
|
||||
|
||||
fun begin() {
|
||||
updateState(
|
||||
updateState {
|
||||
ArchiveUploadProgressState(
|
||||
state = ArchiveUploadProgressState.State.BackingUpMessages
|
||||
state = ArchiveUploadProgressState.State.Export
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onMessageBackupCreated() {
|
||||
updateState(
|
||||
ArchiveUploadProgressState(
|
||||
state = ArchiveUploadProgressState.State.UploadingMessages
|
||||
fun onMessageBackupCreated(backupFileSize: Long) {
|
||||
updateState {
|
||||
it.copy(
|
||||
state = ArchiveUploadProgressState.State.UploadBackupFile,
|
||||
backupFileTotalBytes = backupFileSize,
|
||||
backupFileUploadedBytes = 0
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onAttachmentsStarted(attachmentCount: Long) {
|
||||
updateState(
|
||||
ArchiveUploadProgressState(
|
||||
state = ArchiveUploadProgressState.State.UploadingAttachments,
|
||||
completedAttachments = 0,
|
||||
totalAttachments = attachmentCount
|
||||
fun onMessageBackupUploadProgress(totalBytes: Long, bytesUploaded: Long) {
|
||||
updateState {
|
||||
it.copy(
|
||||
state = ArchiveUploadProgressState.State.UploadBackupFile,
|
||||
backupFileUploadedBytes = bytesUploaded,
|
||||
backupFileTotalBytes = totalBytes
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onAttachmentFinished() {
|
||||
fun onAttachmentsStarted(totalAttachmentBytes: Long) {
|
||||
updateState {
|
||||
it.copy(
|
||||
state = ArchiveUploadProgressState.State.UploadMedia,
|
||||
mediaUploadedBytes = 0,
|
||||
mediaTotalBytes = totalAttachmentBytes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onAttachmentProgress(attachmentId: AttachmentId, bytesUploaded: Long) {
|
||||
partialMediaProgress[attachmentId] = bytesUploaded
|
||||
_progress.tryEmit(Unit)
|
||||
}
|
||||
|
||||
fun onAttachmentFinished(attachmentId: AttachmentId) {
|
||||
partialMediaProgress.remove(attachmentId)
|
||||
_progress.tryEmit(Unit)
|
||||
}
|
||||
|
||||
fun onMessageBackupFinishedEarly() {
|
||||
updateState(PROGRESS_NONE)
|
||||
updateState { PROGRESS_NONE }
|
||||
}
|
||||
|
||||
fun onValidationFailure() {
|
||||
updateState(PROGRESS_NONE)
|
||||
updateState { PROGRESS_NONE }
|
||||
}
|
||||
|
||||
fun onMainBackupFileUploadFailure() {
|
||||
updateState(PROGRESS_NONE)
|
||||
updateState { PROGRESS_NONE }
|
||||
}
|
||||
|
||||
private fun updateState(state: ArchiveUploadProgressState, notify: Boolean = true) {
|
||||
uploadProgress = state
|
||||
SignalStore.backup.archiveUploadState = state
|
||||
private fun updateState(notify: Boolean = true, transform: (ArchiveUploadProgressState) -> ArchiveUploadProgressState) {
|
||||
val newState = transform(uploadProgress)
|
||||
if (uploadProgress == newState) {
|
||||
return
|
||||
}
|
||||
|
||||
uploadProgress = newState
|
||||
SignalStore.backup.archiveUploadState = newState
|
||||
|
||||
if (notify) {
|
||||
_progress.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
class ArchiveUploadProgressListener(
|
||||
private val shouldCancel: () -> Boolean = { false }
|
||||
) : SignalServiceAttachment.ProgressListener {
|
||||
override fun onAttachmentProgress(total: Long, progress: Long) {
|
||||
updateState(
|
||||
state = ArchiveUploadProgressState(
|
||||
state = ArchiveUploadProgressState.State.UploadingMessages,
|
||||
totalAttachments = total,
|
||||
completedAttachments = progress
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun shouldCancel(): Boolean = shouldCancel()
|
||||
}
|
||||
|
||||
object ArchiveBackupProgressListener : BackupRepository.ExportProgressListener {
|
||||
override fun onAccount() {
|
||||
updatePhase(ArchiveUploadProgressState.BackupPhase.Account)
|
||||
@@ -178,17 +197,17 @@ object ArchiveUploadProgress {
|
||||
|
||||
private fun updatePhase(
|
||||
phase: ArchiveUploadProgressState.BackupPhase,
|
||||
completedObjects: Long = 0L,
|
||||
totalObjects: Long = 0L
|
||||
exportedFrames: Long = 0L,
|
||||
totalFrames: Long = 0L
|
||||
) {
|
||||
updateState(
|
||||
state = ArchiveUploadProgressState(
|
||||
state = ArchiveUploadProgressState.State.BackingUpMessages,
|
||||
updateState {
|
||||
ArchiveUploadProgressState(
|
||||
state = ArchiveUploadProgressState.State.Export,
|
||||
backupPhase = phase,
|
||||
completedAttachments = completedObjects,
|
||||
totalAttachments = totalObjects
|
||||
frameExportCount = exportedFrames,
|
||||
frameTotalCount = totalFrames
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +183,14 @@ object ImportSkips {
|
||||
return log(sentTimestamp, "Failed to find a threadId for the provided chatId. ChatId in backup: $chatId")
|
||||
}
|
||||
|
||||
fun chatFolderIdNotFound(): String {
|
||||
return log(0, "Failed to parse chatFolderId for the provided chat folder.")
|
||||
}
|
||||
|
||||
fun notificationProfileIdNotFound(): String {
|
||||
return log(0, "Failed to parse notificationProfileId for the provided notification profile.")
|
||||
}
|
||||
|
||||
private fun log(sentTimestamp: Long, message: String): String {
|
||||
return "[SKIP][$sentTimestamp] $message"
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import android.os.Environment
|
||||
import android.os.StatFs
|
||||
import androidx.annotation.Discouraged
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
@@ -30,7 +28,7 @@ import org.signal.core.util.getAllTriggerDefinitions
|
||||
import org.signal.core.util.getForeignKeyViolations
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.requireNonNullBlob
|
||||
import org.signal.core.util.stream.NonClosingOutputStream
|
||||
import org.signal.core.util.urlEncode
|
||||
import org.signal.core.util.withinTransaction
|
||||
@@ -61,6 +59,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.Recurring
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable.ArchiveMediaItem
|
||||
import org.thoughtcrime.securesms.database.KeyValueDatabase
|
||||
import org.thoughtcrime.securesms.database.SearchTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
@@ -134,10 +133,14 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
403 -> {
|
||||
Log.w(TAG, "Received status 403. The user is not in the media tier. Updating local state.", error.exception)
|
||||
SignalStore.backup.backupTier = MessageBackupTier.FREE
|
||||
SignalStore.uiHints.markHasEverEnabledRemoteBackups()
|
||||
// TODO [backup] If the user thought they were in media tier but aren't, feels like we should have a special UX flow for this?
|
||||
if (SignalStore.backup.backupTierInternalOverride != null) {
|
||||
Log.w(TAG, "Received status 403, but the internal override is set, so not doing anything.", error.exception)
|
||||
} else {
|
||||
Log.w(TAG, "Received status 403. The user is not in the media tier. Updating local state.", error.exception)
|
||||
SignalStore.backup.backupTier = MessageBackupTier.FREE
|
||||
SignalStore.uiHints.markHasEverEnabledRemoteBackups()
|
||||
// TODO [backup] If the user thought they were in media tier but aren't, feels like we should have a special UX flow for this?
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,9 +308,7 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
val paidType = try {
|
||||
withContext(Dispatchers.IO) {
|
||||
getPaidType()
|
||||
}
|
||||
getPaidType()
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to retrieve paid type.", e)
|
||||
return false
|
||||
@@ -357,6 +358,7 @@ object BackupRepository {
|
||||
|
||||
Log.d(TAG, "Disabling backups.")
|
||||
SignalStore.backup.disableBackups()
|
||||
SignalDatabase.attachments.clearAllArchiveData()
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to turn off backups.", e)
|
||||
@@ -1100,7 +1102,7 @@ object BackupRepository {
|
||||
fun copyThumbnailToArchive(thumbnailAttachment: Attachment, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
val request = thumbnailAttachment.toArchiveMediaRequest(parentAttachment.getThumbnailMediaName(), credential.mediaBackupAccess.backupKey)
|
||||
val request = thumbnailAttachment.toArchiveMediaRequest(parentAttachment.requireThumbnailMediaName(), credential.mediaBackupAccess.backupKey)
|
||||
|
||||
SignalNetwork.archive.copyAttachmentToArchive(
|
||||
aci = SignalStore.account.requireAci(),
|
||||
@@ -1116,7 +1118,7 @@ object BackupRepository {
|
||||
fun copyAttachmentToArchive(attachment: DatabaseAttachment): NetworkResult<Unit> {
|
||||
return initBackupAndFetchAuth()
|
||||
.then { credential ->
|
||||
val mediaName = attachment.getMediaName()
|
||||
val mediaName = attachment.requireMediaName()
|
||||
val request = attachment.toArchiveMediaRequest(mediaName, credential.mediaBackupAccess.backupKey)
|
||||
SignalNetwork.archive
|
||||
.copyAttachmentToArchive(
|
||||
@@ -1124,12 +1126,9 @@ object BackupRepository {
|
||||
archiveServiceAccess = credential.mediaBackupAccess,
|
||||
item = request
|
||||
)
|
||||
.map { credential to Triple(mediaName, request.mediaId, it) }
|
||||
}
|
||||
.map { (credential, triple) ->
|
||||
val (mediaName, mediaId, response) = triple
|
||||
val thumbnailId = credential.mediaBackupAccess.backupKey.deriveMediaId(attachment.getThumbnailMediaName()).encode()
|
||||
SignalDatabase.attachments.setArchiveData(attachmentId = attachment.attachmentId, archiveCdn = response.cdn, archiveMediaName = mediaName.name, archiveMediaId = mediaId, archiveThumbnailMediaId = thumbnailId)
|
||||
.map { response ->
|
||||
SignalDatabase.attachments.setArchiveCdn(attachmentId = attachment.attachmentId, archiveCdn = response.cdn)
|
||||
}
|
||||
.also { Log.i(TAG, "archiveMediaResult: $it") }
|
||||
}
|
||||
@@ -1142,7 +1141,7 @@ object BackupRepository {
|
||||
val attachmentIdToMediaName = mutableMapOf<AttachmentId, String>()
|
||||
|
||||
databaseAttachments.forEach {
|
||||
val mediaName = it.getMediaName()
|
||||
val mediaName = it.requireMediaName()
|
||||
val request = it.toArchiveMediaRequest(mediaName, credential.mediaBackupAccess.backupKey)
|
||||
requests += request
|
||||
mediaIdToAttachmentId[request.mediaId] = it.attachmentId
|
||||
@@ -1164,7 +1163,7 @@ object BackupRepository {
|
||||
val attachmentId = result.mediaIdToAttachmentId(it.mediaId)
|
||||
val mediaName = result.attachmentIdToMediaName(attachmentId)
|
||||
val thumbnailId = credential.mediaBackupAccess.backupKey.deriveMediaId(MediaName.forThumbnailFromMediaName(mediaName = mediaName)).encode()
|
||||
SignalDatabase.attachments.setArchiveData(attachmentId = attachmentId, archiveCdn = it.cdn!!, archiveMediaName = mediaName, archiveMediaId = it.mediaId, thumbnailId)
|
||||
SignalDatabase.attachments.setArchiveCdn(attachmentId = attachmentId, archiveCdn = it.cdn!!)
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -1172,12 +1171,14 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
fun deleteArchivedMedia(attachments: List<DatabaseAttachment>): NetworkResult<Unit> {
|
||||
val mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
|
||||
|
||||
val mediaToDelete = attachments
|
||||
.filter { it.archiveMediaId != null }
|
||||
.filter { it.archiveTransferState == AttachmentTable.ArchiveTransferState.FINISHED }
|
||||
.map {
|
||||
DeleteArchivedMediaRequest.ArchivedMediaObject(
|
||||
cdn = it.archiveCdn,
|
||||
mediaId = it.archiveMediaId!!
|
||||
mediaId = it.requireMediaName().toMediaId(mediaRootBackupKey).encode()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1415,7 +1416,8 @@ object BackupRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getFreeType(): MessageBackupsType.Free {
|
||||
@WorkerThread
|
||||
private fun getFreeType(): MessageBackupsType.Free {
|
||||
val config = getSubscriptionsConfiguration()
|
||||
|
||||
return MessageBackupsType.Free(
|
||||
@@ -1426,6 +1428,7 @@ object BackupRepository {
|
||||
private suspend fun getPaidType(): MessageBackupsType.Paid? {
|
||||
val config = getSubscriptionsConfiguration()
|
||||
val product = AppDependencies.billingApi.queryProduct() ?: return null
|
||||
|
||||
val backupLevelConfiguration = config.backupConfiguration.backupLevelConfigurationMap[SubscriptionsConfiguration.BACKUPS_LEVEL] ?: return null
|
||||
|
||||
return MessageBackupsType.Paid(
|
||||
@@ -1435,12 +1438,11 @@ object BackupRepository {
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getSubscriptionsConfiguration(): SubscriptionsConfiguration {
|
||||
val serviceResponse = withContext(Dispatchers.IO) {
|
||||
AppDependencies
|
||||
.donationsService
|
||||
.getDonationsConfiguration(Locale.getDefault())
|
||||
}
|
||||
@WorkerThread
|
||||
private fun getSubscriptionsConfiguration(): SubscriptionsConfiguration {
|
||||
val serviceResponse = AppDependencies
|
||||
.donationsService
|
||||
.getDonationsConfiguration(Locale.getDefault())
|
||||
|
||||
if (serviceResponse.result.isEmpty) {
|
||||
if (serviceResponse.applicationError.isPresent) {
|
||||
@@ -1537,14 +1539,6 @@ object BackupRepository {
|
||||
val profileKey: ProfileKey
|
||||
)
|
||||
|
||||
fun DatabaseAttachment.getMediaName(): MediaName {
|
||||
return MediaName.fromDigest(remoteDigest!!)
|
||||
}
|
||||
|
||||
fun DatabaseAttachment.getThumbnailMediaName(): MediaName {
|
||||
return MediaName.fromDigestForThumbnail(remoteDigest!!)
|
||||
}
|
||||
|
||||
private fun Attachment.toArchiveMediaRequest(mediaName: MediaName, mediaRootBackupKey: MediaRootBackupKey): ArchiveMediaRequest {
|
||||
val mediaSecrets = mediaRootBackupKey.deriveMediaSecrets(mediaName)
|
||||
|
||||
@@ -1645,7 +1639,7 @@ sealed class ImportResult {
|
||||
* // Cursor is closed after use block.
|
||||
* ```
|
||||
*/
|
||||
class ArchivedMediaObjectIterator(private val cursor: Cursor) : Iterator<ArchivedMediaObject> {
|
||||
class ArchiveMediaItemIterator(private val cursor: Cursor) : Iterator<ArchiveMediaItem> {
|
||||
|
||||
init {
|
||||
cursor.moveToFirst()
|
||||
@@ -1653,10 +1647,14 @@ class ArchivedMediaObjectIterator(private val cursor: Cursor) : Iterator<Archive
|
||||
|
||||
override fun hasNext(): Boolean = !cursor.isAfterLast
|
||||
|
||||
override fun next(): ArchivedMediaObject {
|
||||
val mediaId = cursor.requireNonNullString(AttachmentTable.ARCHIVE_MEDIA_ID)
|
||||
override fun next(): ArchiveMediaItem {
|
||||
val digest = cursor.requireNonNullBlob(AttachmentTable.REMOTE_DIGEST)
|
||||
val cdn = cursor.requireInt(AttachmentTable.ARCHIVE_CDN)
|
||||
|
||||
val mediaId = MediaName.fromDigest(digest).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
|
||||
val thumbnailMediaId = MediaName.fromDigestForThumbnail(digest).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
|
||||
|
||||
cursor.moveToNext()
|
||||
return ArchivedMediaObject(mediaId, cdn)
|
||||
return ArchiveMediaItem(mediaId, thumbnailMediaId, cdn, digest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
import android.text.TextUtils
|
||||
import org.signal.core.util.Base64
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.attachments.InvalidAttachmentException
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getThumbnailMediaName
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.backup.MediaName
|
||||
@@ -19,6 +17,43 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemo
|
||||
import java.io.IOException
|
||||
import java.util.Optional
|
||||
|
||||
object DatabaseAttachmentArchiveUtil {
|
||||
@JvmStatic
|
||||
fun requireMediaName(attachment: DatabaseAttachment): MediaName {
|
||||
return MediaName.fromDigest(attachment.remoteDigest!!)
|
||||
}
|
||||
|
||||
/**
|
||||
* For java, since it struggles with value classes.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun requireMediaNameAsString(attachment: DatabaseAttachment): String {
|
||||
return MediaName.fromDigest(attachment.remoteDigest!!).name
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getMediaName(attachment: DatabaseAttachment): MediaName? {
|
||||
return attachment.remoteDigest?.let { MediaName.fromDigest(it) }
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun requireThumbnailMediaName(attachment: DatabaseAttachment): MediaName {
|
||||
return MediaName.fromDigestForThumbnail(attachment.remoteDigest!!)
|
||||
}
|
||||
}
|
||||
|
||||
fun DatabaseAttachment.requireMediaName(): MediaName {
|
||||
return DatabaseAttachmentArchiveUtil.requireMediaName(this)
|
||||
}
|
||||
|
||||
fun DatabaseAttachment.getMediaName(): MediaName? {
|
||||
return DatabaseAttachmentArchiveUtil.getMediaName(this)
|
||||
}
|
||||
|
||||
fun DatabaseAttachment.requireThumbnailMediaName(): MediaName {
|
||||
return DatabaseAttachmentArchiveUtil.requireThumbnailMediaName(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a [SignalServiceAttachmentPointer] for the archived attachment of the given [DatabaseAttachment].
|
||||
*/
|
||||
@@ -39,7 +74,7 @@ fun DatabaseAttachment.createArchiveAttachmentPointer(useArchiveCdn: Boolean): S
|
||||
|
||||
val id = SignalServiceAttachmentRemoteId.Backup(
|
||||
mediaCdnPath = mediaCdnPath,
|
||||
mediaId = mediaRootBackupKey.deriveMediaId(MediaName(archiveMediaName!!)).encode()
|
||||
mediaId = this.requireMediaName().toMediaId(mediaRootBackupKey).encode()
|
||||
)
|
||||
|
||||
id to archiveCdn
|
||||
@@ -93,8 +128,8 @@ fun DatabaseAttachment.createArchiveThumbnailPointer(): SignalServiceAttachmentP
|
||||
val mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
|
||||
val mediaCdnPath = BackupRepository.getArchivedMediaCdnPath().successOrThrow()
|
||||
return try {
|
||||
val key = mediaRootBackupKey.deriveThumbnailTransitKey(getThumbnailMediaName())
|
||||
val mediaId = mediaRootBackupKey.deriveMediaId(getThumbnailMediaName()).encode()
|
||||
val key = mediaRootBackupKey.deriveThumbnailTransitKey(requireThumbnailMediaName())
|
||||
val mediaId = mediaRootBackupKey.deriveMediaId(requireThumbnailMediaName()).encode()
|
||||
SignalServiceAttachmentPointer(
|
||||
cdnNumber = archiveCdn,
|
||||
remoteId = SignalServiceAttachmentRemoteId.Backup(
|
||||
@@ -13,8 +13,13 @@ import org.thoughtcrime.securesms.backup.v2.exporters.ChatItemArchiveExporter
|
||||
import org.thoughtcrime.securesms.backup.v2.importer.ChatItemArchiveImporter
|
||||
import org.thoughtcrime.securesms.database.GroupTable
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.MessageTable.Companion.DATE_RECEIVED
|
||||
import org.thoughtcrime.securesms.database.MessageTable.Companion.EXPIRES_IN
|
||||
import org.thoughtcrime.securesms.database.MessageTable.Companion.PARENT_STORY_ID
|
||||
import org.thoughtcrime.securesms.database.MessageTable.Companion.STORY_TYPE
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
private val TAG = "MessageTableArchiveExtensions"
|
||||
|
||||
@@ -25,9 +30,10 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, medi
|
||||
val dateReceivedIndex = "message_date_received"
|
||||
writableDatabase.execSQL(
|
||||
"""CREATE INDEX $dateReceivedIndex ON ${MessageTable.TABLE_NAME} (
|
||||
${MessageTable.DATE_RECEIVED} ASC,
|
||||
${MessageTable.STORY_TYPE},
|
||||
${MessageTable.PARENT_STORY_ID},
|
||||
$DATE_RECEIVED ASC,
|
||||
$STORY_TYPE,
|
||||
$PARENT_STORY_ID,
|
||||
$EXPIRES_IN,
|
||||
${MessageTable.DATE_SENT},
|
||||
${MessageTable.DATE_SERVER},
|
||||
${MessageTable.TYPE},
|
||||
@@ -36,7 +42,6 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, medi
|
||||
${MessageTable.MESSAGE_RANGES},
|
||||
${MessageTable.FROM_RECIPIENT_ID},
|
||||
${MessageTable.TO_RECIPIENT_ID},
|
||||
${MessageTable.EXPIRES_IN},
|
||||
${MessageTable.EXPIRE_STARTED},
|
||||
${MessageTable.REMOTE_DELETED},
|
||||
${MessageTable.UNIDENTIFIED},
|
||||
@@ -61,7 +66,7 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, medi
|
||||
${MessageTable.MESSAGE_EXTRAS},
|
||||
${MessageTable.VIEW_ONCE}
|
||||
)
|
||||
WHERE ${MessageTable.STORY_TYPE} = 0 AND ${MessageTable.PARENT_STORY_ID} <= 0
|
||||
WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0
|
||||
""".trimMargin()
|
||||
)
|
||||
Log.d(TAG, "Creating index took ${System.currentTimeMillis() - startTime} ms")
|
||||
@@ -94,7 +99,7 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, medi
|
||||
.select(
|
||||
MessageTable.ID,
|
||||
MessageTable.DATE_SENT,
|
||||
MessageTable.DATE_RECEIVED,
|
||||
DATE_RECEIVED,
|
||||
MessageTable.DATE_SERVER,
|
||||
MessageTable.TYPE,
|
||||
MessageTable.THREAD_ID,
|
||||
@@ -102,7 +107,7 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, medi
|
||||
MessageTable.MESSAGE_RANGES,
|
||||
MessageTable.FROM_RECIPIENT_ID,
|
||||
MessageTable.TO_RECIPIENT_ID,
|
||||
MessageTable.EXPIRES_IN,
|
||||
EXPIRES_IN,
|
||||
MessageTable.EXPIRE_STARTED,
|
||||
MessageTable.REMOTE_DELETED,
|
||||
MessageTable.UNIDENTIFIED,
|
||||
@@ -126,12 +131,12 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, medi
|
||||
MessageTable.TYPE,
|
||||
MessageTable.MESSAGE_EXTRAS,
|
||||
MessageTable.VIEW_ONCE,
|
||||
MessageTable.PARENT_STORY_ID
|
||||
PARENT_STORY_ID
|
||||
)
|
||||
.from("${MessageTable.TABLE_NAME} INDEXED BY $dateReceivedIndex")
|
||||
.where("${MessageTable.STORY_TYPE} = 0 AND ${MessageTable.PARENT_STORY_ID} <= 0 AND ${MessageTable.DATE_RECEIVED} >= $lastSeenReceivedTime")
|
||||
.where("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND ($EXPIRES_IN == 0 OR $EXPIRES_IN > ${1.days.inWholeMilliseconds}) AND $DATE_RECEIVED >= $lastSeenReceivedTime")
|
||||
.limit(count)
|
||||
.orderBy("${MessageTable.DATE_RECEIVED} ASC")
|
||||
.orderBy("$DATE_RECEIVED ASC")
|
||||
.run()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -291,7 +291,7 @@ class ChatItemArchiveExporter(
|
||||
}
|
||||
|
||||
MessageTypes.isThreadMergeType(record.type) -> {
|
||||
builder.updateMessage = record.toRemoteThreadMergeUpdate() ?: continue
|
||||
builder.updateMessage = record.toRemoteThreadMergeUpdate()?.takeIf { exportState.recipientIdToAci.contains(builder.authorId) } ?: continue
|
||||
transformTimer.emit("thread-merge")
|
||||
}
|
||||
|
||||
@@ -563,10 +563,11 @@ private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: Recipien
|
||||
}
|
||||
|
||||
if (!MessageTypes.isExpirationTimerUpdate(record.type) && builder.expiresInMs != null && builder.expireStartDate != null) {
|
||||
val cutoffDuration = 1.days.inWholeMilliseconds
|
||||
val expiresAt = builder.expireStartDate!! + builder.expiresInMs!!
|
||||
val threshold = if (exportState.forTransfer) backupStartTime else backupStartTime + 1.days.inWholeMilliseconds
|
||||
val threshold = if (exportState.forTransfer) backupStartTime else backupStartTime + cutoffDuration
|
||||
|
||||
if (expiresAt < threshold) {
|
||||
if (expiresAt < threshold || builder.expiresInMs!! <= cutoffDuration) {
|
||||
Log.w(TAG, ExportSkips.messageExpiresTooSoon(record.dateSent))
|
||||
return null
|
||||
}
|
||||
@@ -612,7 +613,7 @@ private fun BackupMessageRecord.toRemoteSessionSwitchoverUpdate(): ChatUpdateMes
|
||||
return ChatUpdateMessage(
|
||||
sessionSwitchover = try {
|
||||
val event = SessionSwitchoverEvent.ADAPTER.decode(Base64.decodeOrThrow(this.body))
|
||||
SessionSwitchoverChatUpdate(event.e164.e164ToLong()!!)
|
||||
SessionSwitchoverChatUpdate(event.e164.e164ToLong() ?: 0)
|
||||
} catch (e: IOException) {
|
||||
SessionSwitchoverChatUpdate()
|
||||
}
|
||||
@@ -858,7 +859,7 @@ private fun LinkPreview.toRemoteLinkPreview(mediaArchiveEnabled: Boolean): org.t
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toRemoteViewOnceMessage(mediaArchiveEnabled: Boolean, reactionRecords: List<ReactionRecord>?, attachments: List<DatabaseAttachment>?): ViewOnceMessage {
|
||||
val attachment: DatabaseAttachment? = attachments?.firstOrNull()?.takeUnless { !it.hasData && it.size == 0L && it.archiveMediaId == null && it.width == 0 && it.height == 0 && it.blurHash == null }
|
||||
val attachment: DatabaseAttachment? = attachments?.firstOrNull()?.takeUnless { !it.hasData && it.size == 0L && it.remoteDigest == null && it.width == 0 && it.height == 0 && it.blurHash == null }
|
||||
|
||||
return ViewOnceMessage(
|
||||
attachment = attachment?.toRemoteMessageAttachment(mediaArchiveEnabled),
|
||||
@@ -874,21 +875,21 @@ private fun BackupMessageRecord.toRemoteContactMessage(mediaArchiveEnabled: Bool
|
||||
name = sharedContact.name.toRemote(),
|
||||
avatar = (sharedContact.avatar?.attachment as? DatabaseAttachment)?.toRemoteMessageAttachment(mediaArchiveEnabled)?.pointer,
|
||||
organization = sharedContact.organization ?: "",
|
||||
number = sharedContact.phoneNumbers.map { phone ->
|
||||
number = sharedContact.phoneNumbers.mapNotNull { phone ->
|
||||
ContactAttachment.Phone(
|
||||
value_ = phone.number,
|
||||
type = phone.type.toRemote(),
|
||||
label = phone.label ?: ""
|
||||
)
|
||||
).takeUnless { it.value_.isBlank() }
|
||||
},
|
||||
email = sharedContact.emails.map { email ->
|
||||
email = sharedContact.emails.mapNotNull { email ->
|
||||
ContactAttachment.Email(
|
||||
value_ = email.email,
|
||||
label = email.label ?: "",
|
||||
type = email.type.toRemote()
|
||||
)
|
||||
).takeUnless { it.value_.isBlank() }
|
||||
},
|
||||
address = sharedContact.postalAddresses.map { address ->
|
||||
address = sharedContact.postalAddresses.mapNotNull { address ->
|
||||
ContactAttachment.PostalAddress(
|
||||
type = address.type.toRemote(),
|
||||
label = address.label ?: "",
|
||||
@@ -899,7 +900,7 @@ private fun BackupMessageRecord.toRemoteContactMessage(mediaArchiveEnabled: Bool
|
||||
region = address.region ?: "",
|
||||
postcode = address.postalCode ?: "",
|
||||
country = address.country ?: ""
|
||||
)
|
||||
).takeUnless { it.street.isBlank() && it.pobox.isBlank() && it.neighborhood.isBlank() && it.city.isBlank() && it.region.isBlank() && it.postcode.isBlank() && it.country.isBlank() }
|
||||
}
|
||||
),
|
||||
reactions = reactionRecords.toRemote()
|
||||
@@ -1001,7 +1002,7 @@ private fun BackupMessageRecord.toRemoteStandardMessage(exportState: ExportState
|
||||
?: emptyList()
|
||||
val hasVoiceNote = messageAttachments.any { it.voiceNote }
|
||||
return StandardMessage(
|
||||
quote = this.toRemoteQuote(mediaArchiveEnabled, quotedAttachments),
|
||||
quote = this.toRemoteQuote(exportState, mediaArchiveEnabled, quotedAttachments),
|
||||
text = text.takeUnless { hasVoiceNote },
|
||||
attachments = messageAttachments.toRemoteAttachments(mediaArchiveEnabled).withFixedVoiceNotes(textPresent = text != null || longTextAttachment != null),
|
||||
linkPreview = linkPreviews.map { it.toRemoteLinkPreview(mediaArchiveEnabled) },
|
||||
@@ -1010,8 +1011,8 @@ private fun BackupMessageRecord.toRemoteStandardMessage(exportState: ExportState
|
||||
)
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toRemoteQuote(mediaArchiveEnabled: Boolean, attachments: List<DatabaseAttachment>? = null): Quote? {
|
||||
if (this.quoteTargetSentTimestamp == MessageTable.QUOTE_NOT_PRESENT_ID || this.quoteAuthor <= 0) {
|
||||
private fun BackupMessageRecord.toRemoteQuote(exportState: ExportState, mediaArchiveEnabled: Boolean, attachments: List<DatabaseAttachment>? = null): Quote? {
|
||||
if (this.quoteTargetSentTimestamp == MessageTable.QUOTE_NOT_PRESENT_ID || this.quoteAuthor <= 0 || exportState.groupRecipientIds.contains(this.quoteAuthor)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1475,10 +1476,14 @@ fun ChatItem.validateChatItem(): ChatItem? {
|
||||
|
||||
fun List<ChatItem>.repairRevisions(current: ChatItem.Builder): List<ChatItem> {
|
||||
return if (current.standardMessage != null) {
|
||||
val filtered = this.filter { it.standardMessage != null }
|
||||
val filtered = this
|
||||
.filter { it.standardMessage != null }
|
||||
.map { it.withDowngradeVoiceNotes() }
|
||||
|
||||
if (this.size != filtered.size) {
|
||||
Log.w(TAG, ExportOddities.mismatchedRevisionHistory(current.dateSent))
|
||||
}
|
||||
|
||||
filtered
|
||||
} else if (current.directStoryReplyMessage != null) {
|
||||
val filtered = this.filter { it.directStoryReplyMessage != null }
|
||||
@@ -1506,6 +1511,28 @@ private fun List<MessageAttachment>.withFixedVoiceNotes(textPresent: Boolean): L
|
||||
}
|
||||
}
|
||||
|
||||
private fun ChatItem.withDowngradeVoiceNotes(): ChatItem {
|
||||
if (this.standardMessage == null) {
|
||||
return this
|
||||
}
|
||||
|
||||
if (this.standardMessage.attachments.none { it.flag == MessageAttachment.Flag.VOICE_MESSAGE }) {
|
||||
return this
|
||||
}
|
||||
|
||||
return this.copy(
|
||||
standardMessage = this.standardMessage.copy(
|
||||
attachments = this.standardMessage.attachments.map {
|
||||
if (it.flag == MessageAttachment.Flag.VOICE_MESSAGE) {
|
||||
it.copy(flag = MessageAttachment.Flag.NONE)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun Cursor.toBackupMessageRecord(pastIds: Set<Long>, backupStartTime: Long): BackupMessageRecord? {
|
||||
val id = this.requireLong(MessageTable.ID)
|
||||
if (pastIds.contains(id)) {
|
||||
|
||||
@@ -14,6 +14,8 @@ import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireString
|
||||
import org.signal.libsignal.usernames.BaseUsernameException
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.thoughtcrime.securesms.backup.v2.ArchiveRecipient
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Contact
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Self
|
||||
@@ -69,7 +71,7 @@ class ContactArchiveExporter(private val cursor: Cursor, private val selfId: Lon
|
||||
val contactBuilder = Contact.Builder()
|
||||
.aci(aci?.rawUuid?.toByteArray()?.toByteString())
|
||||
.pni(pni?.rawUuid?.toByteArray()?.toByteString())
|
||||
.username(cursor.requireString(RecipientTable.USERNAME))
|
||||
.username(cursor.requireString(RecipientTable.USERNAME).takeIf { isValidUsername(it) })
|
||||
.e164(cursor.requireString(RecipientTable.E164)?.e164ToLong())
|
||||
.blocked(cursor.requireBoolean(RecipientTable.BLOCKED))
|
||||
.visibility(Recipient.HiddenState.deserialize(cursor.requireInt(RecipientTable.HIDDEN)).toRemote())
|
||||
@@ -144,3 +146,16 @@ private fun String.e164ToLong(): Long? {
|
||||
|
||||
return fixed.toLongOrNull()?.takeUnless { it == 0L }
|
||||
}
|
||||
|
||||
private fun isValidUsername(username: String?): Boolean {
|
||||
if (username.isNullOrBlank()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return try {
|
||||
Username(username)
|
||||
true
|
||||
} catch (e: BaseUsernameException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
package org.thoughtcrime.securesms.backup.v2.processor
|
||||
|
||||
import androidx.core.content.contentValuesOf
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.backup.v2.ExportState
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportSkips
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatFolder
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
@@ -19,6 +22,8 @@ import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderMembership
|
||||
import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderTable
|
||||
import org.thoughtcrime.securesms.database.ChatFolderTables.MembershipType
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatFolder as ChatFolderProto
|
||||
|
||||
/**
|
||||
@@ -31,7 +36,7 @@ object ChatFolderProcessor {
|
||||
fun export(db: SignalDatabase, exportState: ExportState, emitter: BackupFrameEmitter) {
|
||||
val folders = db
|
||||
.chatFoldersTable
|
||||
.getChatFolders()
|
||||
.getCurrentChatFolders()
|
||||
.sortedBy { it.position }
|
||||
|
||||
if (folders.isEmpty()) {
|
||||
@@ -66,6 +71,12 @@ object ChatFolderProcessor {
|
||||
}
|
||||
|
||||
fun import(chatFolder: ChatFolderProto, importState: ImportState) {
|
||||
val chatFolderUuid = UuidUtil.parseOrNull(chatFolder.id)
|
||||
if (chatFolderUuid == null) {
|
||||
ImportSkips.chatFolderIdNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
val chatFolderId = SignalDatabase
|
||||
.writableDatabase
|
||||
.insertInto(ChatFolderTable.TABLE_NAME)
|
||||
@@ -76,7 +87,9 @@ object ChatFolderProcessor {
|
||||
ChatFolderTable.SHOW_MUTED to chatFolder.showMutedChats,
|
||||
ChatFolderTable.SHOW_INDIVIDUAL to chatFolder.includeAllIndividualChats,
|
||||
ChatFolderTable.SHOW_GROUPS to chatFolder.includeAllGroupChats,
|
||||
ChatFolderTable.FOLDER_TYPE to chatFolder.folderType.toLocal().value
|
||||
ChatFolderTable.FOLDER_TYPE to chatFolder.folderType.toLocal().value,
|
||||
ChatFolderTable.CHAT_FOLDER_ID to chatFolderUuid.toString(),
|
||||
ChatFolderTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(StorageSyncHelper.generateKey())
|
||||
)
|
||||
.run()
|
||||
|
||||
@@ -110,7 +123,8 @@ private fun ChatFolderRecord.toBackupFrame(includedRecipientIds: List<Long>, exc
|
||||
else -> throw IllegalStateException("Only ALL or CUSTOM should be in the db")
|
||||
},
|
||||
includedRecipientIds = includedRecipientIds,
|
||||
excludedRecipientIds = excludedRecipientIds
|
||||
excludedRecipientIds = excludedRecipientIds,
|
||||
id = UuidUtil.toByteArray(this.chatFolderId.uuid).toByteString()
|
||||
)
|
||||
|
||||
return Frame(chatFolder = chatFolder)
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.processor
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.toInt
|
||||
import org.thoughtcrime.securesms.backup.v2.ExportState
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportSkips
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
|
||||
@@ -20,7 +22,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.serialize
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import java.lang.IllegalStateException
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import java.time.DayOfWeek
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.NotificationProfile as NotificationProfileProto
|
||||
|
||||
@@ -41,6 +43,12 @@ object NotificationProfileProcessor {
|
||||
}
|
||||
|
||||
fun import(profile: NotificationProfileProto, importState: ImportState) {
|
||||
val notificationProfileUuid = UuidUtil.parseOrNull(profile.id)
|
||||
if (notificationProfileUuid == null) {
|
||||
ImportSkips.notificationProfileIdNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
val profileId = SignalDatabase
|
||||
.writableDatabase
|
||||
.insertInto(NotificationProfileTable.TABLE_NAME)
|
||||
@@ -50,7 +58,8 @@ object NotificationProfileProcessor {
|
||||
NotificationProfileTable.COLOR to (AvatarColor.fromColor(profile.color) ?: AvatarColor.random()).serialize(),
|
||||
NotificationProfileTable.CREATED_AT to profile.createdAtMs,
|
||||
NotificationProfileTable.ALLOW_ALL_CALLS to profile.allowAllCalls.toInt(),
|
||||
NotificationProfileTable.ALLOW_ALL_MENTIONS to profile.allowAllMentions.toInt()
|
||||
NotificationProfileTable.ALLOW_ALL_MENTIONS to profile.allowAllMentions.toInt(),
|
||||
NotificationProfileTable.NOTIFICATION_PROFILE_ID to notificationProfileUuid.toString()
|
||||
)
|
||||
.run()
|
||||
|
||||
@@ -89,6 +98,7 @@ object NotificationProfileProcessor {
|
||||
|
||||
private fun NotificationProfile.toBackupFrame(includeRecipient: (RecipientId) -> Boolean): Frame {
|
||||
val profile = NotificationProfileProto(
|
||||
id = UuidUtil.toByteArray(this.notificationProfileId.uuid).toByteString(),
|
||||
name = this.name,
|
||||
emoji = this.emoji.takeIf { it.isNotBlank() },
|
||||
color = this.color.colorInt(),
|
||||
|
||||
@@ -28,16 +28,11 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -47,34 +42,30 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.billing.launchManageBackupsSubscription
|
||||
import org.thoughtcrime.securesms.billing.upgrade.UpgradeToPaidTierBottomSheet
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
|
||||
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.PlayStoreUtil
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
* Notifies the user of an issue with their backup.
|
||||
*/
|
||||
class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
|
||||
class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 0.75f
|
||||
|
||||
@@ -82,8 +73,12 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
|
||||
private const val ARG_ALERT = "alert"
|
||||
|
||||
@JvmStatic
|
||||
fun create(backupAlert: BackupAlert): BackupAlertBottomSheet {
|
||||
return BackupAlertBottomSheet().apply {
|
||||
fun create(backupAlert: BackupAlert): DialogFragment {
|
||||
return if (backupAlert is BackupAlert.MediaBackupsAreOff) {
|
||||
MediaBackupsAreOffBottomSheet()
|
||||
} else {
|
||||
BackupAlertBottomSheet()
|
||||
}.apply {
|
||||
arguments = bundleOf(ARG_ALERT to backupAlert)
|
||||
}
|
||||
}
|
||||
@@ -94,34 +89,20 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun UpgradeSheetContent(
|
||||
paidBackupType: MessageBackupsType.Paid,
|
||||
freeBackupType: MessageBackupsType.Free,
|
||||
isSubscribeEnabled: Boolean,
|
||||
onSubscribeClick: () -> Unit
|
||||
) {
|
||||
var pricePerMonth by remember { mutableStateOf("-") }
|
||||
val resources = LocalContext.current.resources
|
||||
|
||||
LaunchedEffect(paidBackupType.pricePerMonth) {
|
||||
pricePerMonth = FiatMoneyUtil.format(resources, paidBackupType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
}
|
||||
|
||||
val performPrimaryAction = remember(onSubscribeClick) {
|
||||
createPrimaryAction(onSubscribeClick)
|
||||
override fun SheetContent() {
|
||||
val performPrimaryAction = remember(backupAlert) {
|
||||
createPrimaryAction()
|
||||
}
|
||||
|
||||
BackupAlertSheetContent(
|
||||
backupAlert = backupAlert,
|
||||
isSubscribeEnabled = isSubscribeEnabled,
|
||||
mediaTtl = paidBackupType.mediaTtl,
|
||||
onPrimaryActionClick = performPrimaryAction,
|
||||
onSecondaryActionClick = this::performSecondaryAction
|
||||
)
|
||||
}
|
||||
|
||||
@Stable
|
||||
private fun createPrimaryAction(onSubscribeClick: () -> Unit): () -> Unit = {
|
||||
private fun createPrimaryAction(): () -> Unit = {
|
||||
when (backupAlert) {
|
||||
is BackupAlert.CouldNotCompleteBackup -> {
|
||||
BackupMessagesJob.enqueue()
|
||||
@@ -129,9 +110,7 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
|
||||
}
|
||||
|
||||
BackupAlert.FailedToRenew -> launchManageBackupsSubscription()
|
||||
is BackupAlert.MediaBackupsAreOff -> {
|
||||
onSubscribeClick()
|
||||
}
|
||||
is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.")
|
||||
|
||||
BackupAlert.MediaWillBeDeletedToday -> {
|
||||
performFullMediaDownload()
|
||||
@@ -152,7 +131,7 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
|
||||
when (backupAlert) {
|
||||
is BackupAlert.CouldNotCompleteBackup -> Unit
|
||||
BackupAlert.FailedToRenew -> Unit
|
||||
is BackupAlert.MediaBackupsAreOff -> Unit
|
||||
is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.")
|
||||
BackupAlert.MediaWillBeDeletedToday -> {
|
||||
displayLastChanceDialog()
|
||||
}
|
||||
@@ -212,11 +191,8 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackupAlertSheetContent(
|
||||
fun BackupAlertSheetContent(
|
||||
backupAlert: BackupAlert,
|
||||
pricePerMonth: String = "",
|
||||
isSubscribeEnabled: Boolean = true,
|
||||
mediaTtl: Duration,
|
||||
onPrimaryActionClick: () -> Unit = {},
|
||||
onSecondaryActionClick: () -> Unit = {}
|
||||
) {
|
||||
@@ -231,7 +207,8 @@ private fun BackupAlertSheetContent(
|
||||
Spacer(modifier = Modifier.size(26.dp))
|
||||
|
||||
when (backupAlert) {
|
||||
BackupAlert.FailedToRenew, is BackupAlert.MediaBackupsAreOff -> {
|
||||
is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.")
|
||||
BackupAlert.FailedToRenew -> {
|
||||
Box {
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.image_signal_backups),
|
||||
@@ -276,29 +253,27 @@ private fun BackupAlertSheetContent(
|
||||
)
|
||||
|
||||
BackupAlert.FailedToRenew -> PaymentProcessingBody()
|
||||
is BackupAlert.MediaBackupsAreOff -> MediaBackupsAreOffBody(backupAlert.endOfPeriodSeconds, mediaTtl)
|
||||
BackupAlert.MediaWillBeDeletedToday -> MediaWillBeDeletedTodayBody()
|
||||
is BackupAlert.DiskFull -> DiskFullBody(requiredSpace = backupAlert.requiredSpace)
|
||||
BackupAlert.BackupFailed -> BackupFailedBody()
|
||||
BackupAlert.CouldNotRedeemBackup -> CouldNotRedeemBackup()
|
||||
is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.")
|
||||
}
|
||||
|
||||
val secondaryActionResource = rememberSecondaryActionResource(backupAlert = backupAlert)
|
||||
val padBottom = if (secondaryActionResource > 0) 16.dp else 56.dp
|
||||
|
||||
Buttons.LargeTonal(
|
||||
enabled = isSubscribeEnabled,
|
||||
onClick = onPrimaryActionClick,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(bottom = padBottom)
|
||||
) {
|
||||
Text(text = primaryActionString(backupAlert = backupAlert, pricePerMonth = pricePerMonth))
|
||||
Text(text = primaryActionString(backupAlert = backupAlert))
|
||||
}
|
||||
|
||||
if (secondaryActionResource > 0) {
|
||||
TextButton(
|
||||
enabled = isSubscribeEnabled,
|
||||
onClick = onSecondaryActionClick,
|
||||
modifier = Modifier.padding(bottom = 32.dp)
|
||||
) {
|
||||
@@ -381,28 +356,6 @@ private fun PaymentProcessingBody() {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaBackupsAreOffBody(
|
||||
endOfPeriodSeconds: Long,
|
||||
mediaTtl: Duration
|
||||
) {
|
||||
val daysUntilDeletion = remember { endOfPeriodSeconds.days + mediaTtl }.inWholeDays.toInt()
|
||||
|
||||
Text(
|
||||
text = pluralStringResource(id = R.plurals.BackupAlertBottomSheet__your_backup_plan_has_expired, daysUntilDeletion, daysUntilDeletion),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.BackupAlertBottomSheet__you_can_begin_paying_for_backups_again),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 36.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaWillBeDeletedTodayBody() {
|
||||
Text(
|
||||
@@ -473,13 +426,12 @@ private fun titleString(backupAlert: BackupAlert): String {
|
||||
|
||||
@Composable
|
||||
private fun primaryActionString(
|
||||
backupAlert: BackupAlert,
|
||||
pricePerMonth: String
|
||||
backupAlert: BackupAlert
|
||||
): String {
|
||||
return when (backupAlert) {
|
||||
is BackupAlert.CouldNotCompleteBackup -> stringResource(R.string.BackupAlertBottomSheet__back_up_now)
|
||||
BackupAlert.FailedToRenew -> stringResource(R.string.BackupAlertBottomSheet__manage_subscription)
|
||||
is BackupAlert.MediaBackupsAreOff -> stringResource(R.string.BackupAlertBottomSheet__subscribe_for_s_month, pricePerMonth)
|
||||
is BackupAlert.MediaBackupsAreOff -> error("Not supported.")
|
||||
BackupAlert.MediaWillBeDeletedToday -> stringResource(R.string.BackupAlertBottomSheet__download_media_now)
|
||||
is BackupAlert.DiskFull -> stringResource(R.string.BackupAlertBottomSheet__got_it)
|
||||
is BackupAlert.BackupFailed -> stringResource(R.string.BackupAlertBottomSheet__check_for_update)
|
||||
@@ -493,7 +445,7 @@ private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int {
|
||||
when (backupAlert) {
|
||||
is BackupAlert.CouldNotCompleteBackup -> R.string.BackupAlertBottomSheet__try_later
|
||||
BackupAlert.FailedToRenew -> R.string.BackupAlertBottomSheet__not_now
|
||||
is BackupAlert.MediaBackupsAreOff -> R.string.BackupAlertBottomSheet__not_now
|
||||
is BackupAlert.MediaBackupsAreOff -> error("Not supported.")
|
||||
BackupAlert.MediaWillBeDeletedToday -> R.string.BackupAlertBottomSheet__dont_download_media
|
||||
is BackupAlert.DiskFull -> R.string.BackupAlertBottomSheet__skip_restore
|
||||
is BackupAlert.BackupFailed -> R.string.BackupAlertBottomSheet__learn_more
|
||||
@@ -507,8 +459,7 @@ private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int {
|
||||
private fun BackupAlertSheetContentPreviewGeneric() {
|
||||
Previews.BottomSheetPreview {
|
||||
BackupAlertSheetContent(
|
||||
backupAlert = BackupAlert.CouldNotCompleteBackup(daysSinceLastBackup = 7),
|
||||
mediaTtl = 60.days
|
||||
backupAlert = BackupAlert.CouldNotCompleteBackup(daysSinceLastBackup = 7)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -518,20 +469,7 @@ private fun BackupAlertSheetContentPreviewGeneric() {
|
||||
private fun BackupAlertSheetContentPreviewPayment() {
|
||||
Previews.BottomSheetPreview {
|
||||
BackupAlertSheetContent(
|
||||
backupAlert = BackupAlert.FailedToRenew,
|
||||
mediaTtl = 60.days
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BackupAlertSheetContentPreviewMedia() {
|
||||
Previews.BottomSheetPreview {
|
||||
BackupAlertSheetContent(
|
||||
backupAlert = BackupAlert.MediaBackupsAreOff(endOfPeriodSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds),
|
||||
pricePerMonth = "$2.99",
|
||||
mediaTtl = 60.days
|
||||
backupAlert = BackupAlert.FailedToRenew
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -541,8 +479,7 @@ private fun BackupAlertSheetContentPreviewMedia() {
|
||||
private fun BackupAlertSheetContentPreviewDelete() {
|
||||
Previews.BottomSheetPreview {
|
||||
BackupAlertSheetContent(
|
||||
backupAlert = BackupAlert.MediaWillBeDeletedToday,
|
||||
mediaTtl = 60.days
|
||||
backupAlert = BackupAlert.MediaWillBeDeletedToday
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -552,8 +489,7 @@ private fun BackupAlertSheetContentPreviewDelete() {
|
||||
private fun BackupAlertSheetContentPreviewDiskFull() {
|
||||
Previews.BottomSheetPreview {
|
||||
BackupAlertSheetContent(
|
||||
backupAlert = BackupAlert.DiskFull(requiredSpace = "12GB"),
|
||||
mediaTtl = 60.days
|
||||
backupAlert = BackupAlert.DiskFull(requiredSpace = "12GB")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -563,8 +499,7 @@ private fun BackupAlertSheetContentPreviewDiskFull() {
|
||||
private fun BackupAlertSheetContentPreviewBackupFailed() {
|
||||
Previews.BottomSheetPreview {
|
||||
BackupAlertSheetContent(
|
||||
backupAlert = BackupAlert.BackupFailed,
|
||||
mediaTtl = 60.days
|
||||
backupAlert = BackupAlert.BackupFailed
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -574,8 +509,7 @@ private fun BackupAlertSheetContentPreviewBackupFailed() {
|
||||
private fun BackupAlertSheetContentPreviewCouldNotRedeemBackup() {
|
||||
Previews.BottomSheetPreview {
|
||||
BackupAlertSheetContent(
|
||||
backupAlert = BackupAlert.CouldNotRedeemBackup,
|
||||
mediaTtl = 60.days
|
||||
backupAlert = BackupAlert.CouldNotRedeemBackup
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
@@ -25,7 +27,7 @@ object BackupAlertDelegate {
|
||||
BackupAlertBottomSheet.create(BackupAlert.BackupFailed).show(fragmentManager, null)
|
||||
} else if (BackupRepository.shouldDisplayCouldNotCompleteBackupSheet()) {
|
||||
BackupAlertBottomSheet.create(BackupAlert.CouldNotCompleteBackup(daysSinceLastBackup = SignalStore.backup.daysSinceLastBackup)).show(fragmentManager, null)
|
||||
} else if (BackupRepository.shouldDisplayYourMediaWillBeDeletedTodaySheet()) {
|
||||
} else if (withContext(Dispatchers.IO) { BackupRepository.shouldDisplayYourMediaWillBeDeletedTodaySheet() }) {
|
||||
BackupAlertBottomSheet.create(BackupAlert.MediaWillBeDeletedToday).show(fragmentManager, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,10 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.BundleCompat
|
||||
import org.signal.core.ui.R
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.util.gibiBytes
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.billing.upgrade.UpgradeToPaidTierBottomSheet
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class MediaBackupsAreOffBottomSheet : UpgradeToPaidTierBottomSheet() {
|
||||
|
||||
companion object {
|
||||
private const val ARG_ALERT = "alert"
|
||||
}
|
||||
|
||||
private val backupAlert: BackupAlert by lazy(LazyThreadSafetyMode.NONE) {
|
||||
BundleCompat.getParcelable(requireArguments(), ARG_ALERT, BackupAlert::class.java)!!
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun UpgradeSheetContent(
|
||||
paidBackupType: MessageBackupsType.Paid,
|
||||
freeBackupType: MessageBackupsType.Free,
|
||||
isSubscribeEnabled: Boolean,
|
||||
onSubscribeClick: () -> Unit
|
||||
) {
|
||||
SheetContent(
|
||||
backupAlert as BackupAlert.MediaBackupsAreOff,
|
||||
paidBackupType,
|
||||
isSubscribeEnabled,
|
||||
onSubscribeClick,
|
||||
onNotNowClick = { dismissAllowingStateLoss() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetContent(
|
||||
mediaBackupsAreOff: BackupAlert.MediaBackupsAreOff,
|
||||
paidBackupType: MessageBackupsType.Paid,
|
||||
isSubscribeEnabled: Boolean,
|
||||
onSubscribeClick: () -> Unit,
|
||||
onNotNowClick: () -> Unit
|
||||
) {
|
||||
val resources = LocalContext.current.resources
|
||||
val pricePerMonth = remember(paidBackupType) {
|
||||
FiatMoneyUtil.format(resources, paidBackupType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = dimensionResource(id = R.dimen.gutter))
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Spacer(modifier = Modifier.size(26.dp))
|
||||
|
||||
Box {
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(id = org.thoughtcrime.securesms.R.drawable.image_signal_backups),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.padding(2.dp)
|
||||
)
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(org.thoughtcrime.securesms.R.drawable.symbol_error_circle_fill_24),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
|
||||
val daysUntilDeletion = remember(mediaBackupsAreOff.endOfPeriodSeconds, paidBackupType.mediaTtl) {
|
||||
((System.currentTimeMillis().milliseconds - mediaBackupsAreOff.endOfPeriodSeconds.seconds) + paidBackupType.mediaTtl).inWholeDays.toInt()
|
||||
}
|
||||
|
||||
Text(
|
||||
text = pluralStringResource(id = org.thoughtcrime.securesms.R.plurals.BackupAlertBottomSheet__your_backup_plan_has_expired, daysUntilDeletion, daysUntilDeletion),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = org.thoughtcrime.securesms.R.string.BackupAlertBottomSheet__you_can_begin_paying_for_backups_again),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 36.dp)
|
||||
)
|
||||
|
||||
Buttons.LargeTonal(
|
||||
enabled = isSubscribeEnabled,
|
||||
onClick = onSubscribeClick,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(org.thoughtcrime.securesms.R.string.BackupAlertBottomSheet__subscribe_for_s_month, pricePerMonth))
|
||||
}
|
||||
|
||||
TextButton(
|
||||
enabled = isSubscribeEnabled,
|
||||
onClick = onNotNowClick,
|
||||
modifier = Modifier.padding(bottom = 32.dp)
|
||||
) {
|
||||
Text(text = stringResource(id = org.thoughtcrime.securesms.R.string.BackupAlertBottomSheet__not_now))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BackupAlertSheetContentPreviewMedia() {
|
||||
Previews.BottomSheetPreview {
|
||||
SheetContent(
|
||||
mediaBackupsAreOff = BackupAlert.MediaBackupsAreOff(endOfPeriodSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds),
|
||||
paidBackupType = MessageBackupsType.Paid(
|
||||
pricePerMonth = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")),
|
||||
mediaTtl = 30.days,
|
||||
storageAllowanceBytes = 1.gibiBytes.inWholeBytes
|
||||
),
|
||||
isSubscribeEnabled = true,
|
||||
onSubscribeClick = {},
|
||||
onNotNowClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -35,9 +35,9 @@ import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.util.ByteSize
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.kibiBytes
|
||||
@@ -282,7 +282,7 @@ sealed interface BackupStatusData {
|
||||
val bytesTotal: ByteSize = 0.bytes,
|
||||
val restoreStatus: RestoreStatus = RestoreStatus.NORMAL
|
||||
) : BackupStatusData {
|
||||
override val iconRes: Int = R.drawable.symbol_backup_light
|
||||
override val iconRes: Int = if (restoreStatus == RestoreStatus.FINISHED) R.drawable.symbol_check_circle_24 else R.drawable.symbol_backup_light
|
||||
override val iconColors: BackupsIconColors = if (restoreStatus == RestoreStatus.FINISHED) BackupsIconColors.Success else BackupsIconColors.Normal
|
||||
override val showDismissAction: Boolean = restoreStatus == RestoreStatus.FINISHED
|
||||
|
||||
@@ -311,7 +311,7 @@ sealed interface BackupStatusData {
|
||||
RestoreStatus.FINISHED -> bytesTotal.toUnitString()
|
||||
}
|
||||
|
||||
override val progress: Float = if (bytesTotal.bytes > 0) {
|
||||
override val progress: Float = if (bytesTotal.bytes > 0 && restoreStatus != RestoreStatus.FINISHED) {
|
||||
min(1f, max(0f, bytesDownloaded.bytes.toFloat() / bytesTotal.bytes.toFloat()))
|
||||
} else {
|
||||
NONE.toFloat()
|
||||
|
||||
@@ -35,9 +35,9 @@ import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withLink
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Rows
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.util.ByteSize
|
||||
import org.thoughtcrime.securesms.R
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -28,9 +28,9 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import kotlinx.coroutines.rx3.asFlowable
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.util.getSerializableCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
@@ -111,7 +111,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
|
||||
val context = LocalContext.current
|
||||
|
||||
MessageBackupsKeyRecordScreen(
|
||||
backupKey = state.accountEntropyPool.value,
|
||||
backupKey = state.accountEntropyPool.displayValue,
|
||||
onNavigationClick = viewModel::goToPreviousStage,
|
||||
onNextClick = viewModel::goToNextStage,
|
||||
onCopyToClipboardClick = {
|
||||
@@ -120,6 +120,14 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = MessageBackupsStage.Route.BACKUP_KEY_VERIFY.name) {
|
||||
MessageBackupsKeyVerifyScreen(
|
||||
backupKey = state.accountEntropyPool.displayValue,
|
||||
onNavigationClick = viewModel::goToPreviousStage,
|
||||
onNextClick = viewModel::goToNextStage
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = MessageBackupsStage.Route.TYPE_SELECTION.name) {
|
||||
MessageBackupsTypeSelectionScreen(
|
||||
stage = state.stage,
|
||||
@@ -140,19 +148,20 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
|
||||
val currentRoute = navController.currentDestination?.route
|
||||
if (currentRoute != newRoute) {
|
||||
if (currentRoute != null && MessageBackupsStage.Route.valueOf(currentRoute).isAfter(state.stage.route)) {
|
||||
navController.popBackStack()
|
||||
navController.popBackStack(newRoute, inclusive = false)
|
||||
} else {
|
||||
navController.navigate(newRoute)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.stage == MessageBackupsStage.CHECKOUT_SHEET) {
|
||||
AppDependencies.billingApi.launchBillingFlow(requireActivity())
|
||||
}
|
||||
|
||||
if (state.stage == MessageBackupsStage.COMPLETED) {
|
||||
requireActivity().setResult(Activity.RESULT_OK, MessageBackupsCheckoutActivity.createResultData())
|
||||
requireActivity().finishAfterTransition()
|
||||
when (state.stage) {
|
||||
MessageBackupsStage.CANCEL -> requireActivity().finishAfterTransition()
|
||||
MessageBackupsStage.CHECKOUT_SHEET -> AppDependencies.billingApi.launchBillingFlow(requireActivity())
|
||||
MessageBackupsStage.COMPLETED -> {
|
||||
requireActivity().setResult(Activity.RESULT_OK, MessageBackupsCheckoutActivity.createResultData())
|
||||
requireActivity().finishAfterTransition()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,8 @@
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -23,9 +21,9 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.reactive.asFlow
|
||||
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.donations.InAppPaymentType
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue
|
||||
@@ -70,7 +68,7 @@ class MessageBackupsFlowViewModel(
|
||||
check(SignalStore.backup.backupTier != MessageBackupTier.PAID) { "This screen does not support cancellation or downgrades." }
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
val result = withContext(SignalDispatchers.IO) {
|
||||
BackupRepository.triggerBackupIdReservation()
|
||||
}
|
||||
|
||||
@@ -79,19 +77,26 @@ class MessageBackupsFlowViewModel(
|
||||
internalStateFlow.update { it.copy(paymentReadyState = MessageBackupsFlowState.PaymentReadyState.READY) }
|
||||
}
|
||||
|
||||
result.runOnStatusCodeError {
|
||||
Log.d(TAG, "Failed to trigger backup id reservation. ($it)")
|
||||
result.runOnStatusCodeError { code ->
|
||||
Log.d(TAG, "Failed to trigger backup id reservation. ($code)")
|
||||
internalStateFlow.update { it.copy(paymentReadyState = MessageBackupsFlowState.PaymentReadyState.FAILED) }
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
internalStateFlow.update {
|
||||
it.copy(
|
||||
availableBackupTypes = BackupRepository.getAvailableBackupsTypes(
|
||||
val availableBackupTypes = try {
|
||||
withContext(SignalDispatchers.IO) {
|
||||
BackupRepository.getAvailableBackupsTypes(
|
||||
if (!RemoteConfig.messageBackups) emptyList() else listOf(MessageBackupTier.FREE, MessageBackupTier.PAID)
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to download available backup types.", e)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
internalStateFlow.update {
|
||||
it.copy(availableBackupTypes = availableBackupTypes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,12 +137,12 @@ class MessageBackupsFlowViewModel(
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Failed to handle purchase.", e)
|
||||
InAppPaymentsRepository.handlePipelineError(
|
||||
inAppPaymentId = id,
|
||||
donationErrorSource = DonationErrorSource.BACKUPS,
|
||||
paymentSourceType = PaymentSourceType.GooglePlayBilling,
|
||||
error = e
|
||||
)
|
||||
withContext(SignalDispatchers.IO) {
|
||||
InAppPaymentsRepository.handlePipelineError(
|
||||
inAppPaymentId = id,
|
||||
error = e
|
||||
)
|
||||
}
|
||||
|
||||
internalStateFlow.update {
|
||||
it.copy(
|
||||
@@ -160,9 +165,11 @@ class MessageBackupsFlowViewModel(
|
||||
fun goToNextStage() {
|
||||
internalStateFlow.update {
|
||||
when (it.stage) {
|
||||
MessageBackupsStage.CANCEL -> error("Unsupported state transition from terminal state CANCEL")
|
||||
MessageBackupsStage.EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_EDUCATION)
|
||||
MessageBackupsStage.BACKUP_KEY_EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_RECORD)
|
||||
MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION)
|
||||
MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_VERIFY)
|
||||
MessageBackupsStage.BACKUP_KEY_VERIFY -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION)
|
||||
MessageBackupsStage.TYPE_SELECTION -> validateTypeAndUpdateState(it)
|
||||
MessageBackupsStage.CHECKOUT_SHEET -> it.copy(stage = MessageBackupsStage.PROCESS_PAYMENT)
|
||||
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> error("This is driven by an async coroutine.")
|
||||
@@ -177,12 +184,14 @@ class MessageBackupsFlowViewModel(
|
||||
fun goToPreviousStage() {
|
||||
internalStateFlow.update {
|
||||
if (it.stage == it.startScreen) {
|
||||
it.copy(stage = MessageBackupsStage.COMPLETED)
|
||||
it.copy(stage = MessageBackupsStage.CANCEL)
|
||||
} else {
|
||||
val previousScreen = when (it.stage) {
|
||||
MessageBackupsStage.EDUCATION -> MessageBackupsStage.COMPLETED
|
||||
MessageBackupsStage.CANCEL -> error("Unsupported state transition from terminal state CANCEL")
|
||||
MessageBackupsStage.EDUCATION -> MessageBackupsStage.CANCEL
|
||||
MessageBackupsStage.BACKUP_KEY_EDUCATION -> MessageBackupsStage.EDUCATION
|
||||
MessageBackupsStage.BACKUP_KEY_RECORD -> MessageBackupsStage.BACKUP_KEY_EDUCATION
|
||||
MessageBackupsStage.BACKUP_KEY_VERIFY -> MessageBackupsStage.BACKUP_KEY_RECORD
|
||||
MessageBackupsStage.TYPE_SELECTION -> MessageBackupsStage.BACKUP_KEY_RECORD
|
||||
MessageBackupsStage.CHECKOUT_SHEET -> MessageBackupsStage.TYPE_SELECTION
|
||||
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> MessageBackupsStage.CREATING_IN_APP_PAYMENT
|
||||
@@ -218,7 +227,7 @@ class MessageBackupsFlowViewModel(
|
||||
check(state.selectedMessageBackupTier == MessageBackupTier.PAID)
|
||||
check(state.availableBackupTypes.any { it.tier == state.selectedMessageBackupTier })
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
viewModelScope.launch(SignalDispatchers.IO) {
|
||||
internalStateFlow.update { it.copy(inAppPayment = null) }
|
||||
|
||||
val paidFiat = AppDependencies.billingApi.queryProduct()!!.price
|
||||
@@ -249,25 +258,19 @@ class MessageBackupsFlowViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures we have a SubscriberId created and available for use. This is considered safe because
|
||||
* the screen this is called in is assumed to only be accessible if the user does not currently have
|
||||
* a subscription.
|
||||
*/
|
||||
@WorkerThread
|
||||
private fun ensureSubscriberIdForBackups(purchaseToken: IAPSubscriptionId.GooglePlayBillingPurchaseToken) {
|
||||
RecurringInAppPaymentRepository.ensureSubscriberId(InAppPaymentSubscriberRecord.Type.BACKUP, iapSubscriptionId = purchaseToken).blockingAwait()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a successful BillingPurchaseResult. Updates the in app payment, enqueues the appropriate job chain,
|
||||
* and handles any resulting error. Like donations, we will wait up to 10s for the completion of the job chain.
|
||||
*
|
||||
* This will always rotate the subscriber-id.
|
||||
*/
|
||||
@OptIn(FlowPreview::class)
|
||||
private suspend fun handleSuccess(result: BillingPurchaseResult.Success, inAppPaymentId: InAppPaymentTable.InAppPaymentId) {
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(SignalDispatchers.IO) {
|
||||
Log.d(TAG, "Setting purchase token data on InAppPayment and InAppPaymentSubscriber.")
|
||||
ensureSubscriberIdForBackups(IAPSubscriptionId.GooglePlayBillingPurchaseToken(result.purchaseToken))
|
||||
|
||||
val iapSubscriptionId = IAPSubscriptionId.GooglePlayBillingPurchaseToken(result.purchaseToken)
|
||||
RecurringInAppPaymentRepository.ensureSubscriberIdSync(InAppPaymentSubscriberRecord.Type.BACKUP, iapSubscriptionId = iapSubscriptionId, isRotation = true)
|
||||
|
||||
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
|
||||
SignalDatabase.inAppPayments.update(
|
||||
@@ -287,7 +290,7 @@ class MessageBackupsFlowViewModel(
|
||||
InAppPaymentPurchaseTokenJob.createJobChain(inAppPayment).enqueue()
|
||||
}
|
||||
|
||||
val terminalInAppPayment = withContext(Dispatchers.IO) {
|
||||
val terminalInAppPayment = withContext(SignalDispatchers.IO) {
|
||||
Log.d(TAG, "Awaiting completion of job chain for up to 10 seconds.")
|
||||
InAppPaymentsRepository.observeUpdates(inAppPaymentId).asFlow()
|
||||
.filter { it.state == InAppPaymentTable.State.END }
|
||||
|
||||
@@ -22,10 +22,10 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
@@ -39,7 +39,7 @@ fun MessageBackupsKeyEducationScreen(
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
|
||||
navigationIconPainter = painterResource(R.drawable.symbol_arrow_start_24),
|
||||
onNavigationClick = onNavigationClick
|
||||
) {
|
||||
Column(
|
||||
|
||||
@@ -7,59 +7,41 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import kotlin.random.Random
|
||||
import kotlin.random.nextInt
|
||||
import org.thoughtcrime.securesms.fonts.MonoTypeface
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
* Screen displaying the backup key allowing the user to write it down
|
||||
* or copy it.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MessageBackupsKeyRecordScreen(
|
||||
backupKey: String,
|
||||
@@ -67,18 +49,13 @@ fun MessageBackupsKeyRecordScreen(
|
||||
onCopyToClipboardClick: (String) -> Unit = {},
|
||||
onNextClick: () -> Unit = {}
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
val backupKeyString = remember(backupKey) {
|
||||
backupKey.chunked(4).joinToString(" ")
|
||||
}
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
|
||||
navigationIconPainter = painterResource(R.drawable.symbol_arrow_start_24),
|
||||
onNavigationClick = onNavigationClick
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
@@ -141,7 +118,7 @@ fun MessageBackupsKeyRecordScreen(
|
||||
letterSpacing = 1.44.sp,
|
||||
lineHeight = 36.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = MonoTypeface.fontFamily()
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -164,11 +141,7 @@ fun MessageBackupsKeyRecordScreen(
|
||||
.padding(bottom = 24.dp)
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
sheetState.show()
|
||||
}
|
||||
},
|
||||
onClick = onNextClick,
|
||||
modifier = Modifier.align(Alignment.BottomEnd)
|
||||
) {
|
||||
Text(
|
||||
@@ -177,111 +150,6 @@ fun MessageBackupsKeyRecordScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sheetState.isVisible) {
|
||||
ModalBottomSheet(
|
||||
dragHandle = null,
|
||||
onDismissRequest = {
|
||||
coroutineScope.launch {
|
||||
sheetState.hide()
|
||||
}
|
||||
}
|
||||
) {
|
||||
BottomSheetContent(
|
||||
onContinueClick = onNextClick,
|
||||
onSeeKeyAgainClick = {
|
||||
coroutineScope.launch {
|
||||
sheetState.hide()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomSheetContent(
|
||||
onContinueClick: () -> Unit,
|
||||
onSeeKeyAgainClick: () -> Unit
|
||||
) {
|
||||
var checked by remember { mutableStateOf(false) }
|
||||
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
|
||||
.testTag("message-backups-key-record-screen-sheet-content")
|
||||
) {
|
||||
item {
|
||||
BottomSheets.Handle()
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__keep_your_key_safe),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 30.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__signal_will_not),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 24.dp)
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.clip(shape = RoundedCornerShape(percent = 50))
|
||||
.clickable(onClick = { checked = !checked })
|
||||
) {
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = { checked = it }
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__ive_recorded_my_key),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Buttons.LargeTonal(
|
||||
enabled = checked,
|
||||
onClick = onContinueClick,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__continue))
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
TextButton(
|
||||
onClick = onSeeKeyAgainClick,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 24.dp)
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_key_again)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,15 +158,7 @@ private fun BottomSheetContent(
|
||||
private fun MessageBackupsKeyRecordScreenPreview() {
|
||||
Previews.Preview {
|
||||
MessageBackupsKeyRecordScreen(
|
||||
backupKey = (0 until 64).map { Random.nextInt(97..122).toChar() }.joinToString("")
|
||||
backupKey = (0 until 63).map { (('A'..'Z') + ('0'..'9')).random() }.joinToString("") + "0"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BottomSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
BottomSheetContent({}, {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.fonts.MonoTypeface
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.restore.BackupKeyVisualTransformation
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.restore.attachBackupKeyAutoFillHelper
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.restore.backupKeyAutoFillHelper
|
||||
import org.whispersystems.signalservice.api.AccountEntropyPool
|
||||
import kotlin.random.Random
|
||||
import kotlin.random.nextInt
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
* Prompt user to re-enter backup key (AEP) to confirm they have it still.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun MessageBackupsKeyVerifyScreen(
|
||||
backupKey: String,
|
||||
onNavigationClick: () -> Unit = {},
|
||||
onNextClick: () -> Unit = {}
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(R.string.MessageBackupsKeyVerifyScreen__confirm_your_backup_key),
|
||||
navigationIconPainter = painterResource(R.drawable.symbol_arrow_start_24),
|
||||
onNavigationClick = onNavigationClick
|
||||
) { paddingValues ->
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val visualTransform = remember { BackupKeyVisualTransformation(chunkSize = 4) }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
var enteredBackupKey by remember { mutableStateOf("") }
|
||||
var isBackupKeyValid by remember { mutableStateOf(false) }
|
||||
var showError by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(scrollState)
|
||||
.weight(weight = 1f, fill = false)
|
||||
.horizontalGutters(),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyVerifyScreen__enter_the_backup_key_that_you_just_recorded),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
val updateEnteredBackupKey = { input: String ->
|
||||
enteredBackupKey = AccountEntropyPool.removeIllegalCharacters(input).uppercase()
|
||||
isBackupKeyValid = enteredBackupKey == backupKey
|
||||
showError = !isBackupKeyValid && enteredBackupKey.length >= backupKey.length
|
||||
}
|
||||
|
||||
var requestFocus: Boolean by remember { mutableStateOf(true) }
|
||||
val autoFillHelper = backupKeyAutoFillHelper { updateEnteredBackupKey(it) }
|
||||
|
||||
TextField(
|
||||
value = enteredBackupKey,
|
||||
onValueChange = {
|
||||
updateEnteredBackupKey(it)
|
||||
autoFillHelper.onValueChanged(it)
|
||||
},
|
||||
label = {
|
||||
Text(text = stringResource(id = R.string.MessageBackupsKeyVerifyScreen__backup_key))
|
||||
},
|
||||
textStyle = LocalTextStyle.current.copy(
|
||||
fontFamily = MonoTypeface.fontFamily(),
|
||||
lineHeight = 36.sp
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Next,
|
||||
autoCorrectEnabled = false
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = {
|
||||
if (isBackupKeyValid) {
|
||||
keyboardController?.hide()
|
||||
coroutineScope.launch { sheetState.show() }
|
||||
}
|
||||
}
|
||||
),
|
||||
supportingText = { if (showError) Text(text = stringResource(R.string.MessageBackupsKeyVerifyScreen__incorrect_backup_key)) },
|
||||
isError = showError,
|
||||
minLines = 4,
|
||||
visualTransformation = visualTransform,
|
||||
modifier = Modifier
|
||||
.testTag("message-backups-key-verify-screen-backup-key-input-field")
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.attachBackupKeyAutoFillHelper(autoFillHelper)
|
||||
.onGloballyPositioned {
|
||||
if (requestFocus) {
|
||||
focusRequester.requestFocus()
|
||||
requestFocus = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp, bottom = 24.dp)
|
||||
.horizontalGutters()
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onNavigationClick
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.MessageBackupsKeyVerifyScreen__see_key_again)
|
||||
)
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
enabled = isBackupKeyValid,
|
||||
onClick = {
|
||||
coroutineScope.launch { sheetState.show() }
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.RegistrationActivity_next)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sheetState.isVisible) {
|
||||
ModalBottomSheet(
|
||||
sheetState = sheetState,
|
||||
dragHandle = null,
|
||||
onDismissRequest = {
|
||||
coroutineScope.launch {
|
||||
sheetState.hide()
|
||||
}
|
||||
}
|
||||
) {
|
||||
BottomSheetContent(
|
||||
onContinueClick = {
|
||||
coroutineScope.launch {
|
||||
sheetState.hide()
|
||||
}
|
||||
onNextClick()
|
||||
},
|
||||
onSeeKeyAgainClick = {
|
||||
coroutineScope.launch {
|
||||
sheetState.hide()
|
||||
}
|
||||
onNavigationClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomSheetContent(
|
||||
onContinueClick: () -> Unit,
|
||||
onSeeKeyAgainClick: () -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
|
||||
.testTag("message-backups-key-record-screen-sheet-content")
|
||||
) {
|
||||
item {
|
||||
BottomSheets.Handle()
|
||||
}
|
||||
|
||||
item {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.image_signal_backups_key),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(top = 26.dp)
|
||||
.size(80.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__keep_your_key_safe),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__signal_will_not),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(54.dp))
|
||||
Buttons.LargeTonal(
|
||||
onClick = onContinueClick,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__continue))
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
TextButton(
|
||||
onClick = onSeeKeyAgainClick,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 24.dp)
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_key_again)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun MessageBackupsKeyRecordScreenPreview() {
|
||||
Previews.Preview {
|
||||
MessageBackupsKeyVerifyScreen(
|
||||
backupKey = (0 until 64).map { Random.nextInt(65..90).toChar() }.joinToString("").uppercase()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BottomSheetContentPreview() {
|
||||
Previews.BottomSheetPreview {
|
||||
BottomSheetContent({}, {})
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,11 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
enum class MessageBackupsStage(
|
||||
val route: Route
|
||||
) {
|
||||
CANCEL(route = Route.CANCEL),
|
||||
EDUCATION(route = Route.EDUCATION),
|
||||
BACKUP_KEY_EDUCATION(route = Route.BACKUP_KEY_EDUCATION),
|
||||
BACKUP_KEY_RECORD(route = Route.BACKUP_KEY_RECORD),
|
||||
BACKUP_KEY_VERIFY(route = Route.BACKUP_KEY_VERIFY),
|
||||
TYPE_SELECTION(route = Route.TYPE_SELECTION),
|
||||
CREATING_IN_APP_PAYMENT(route = Route.TYPE_SELECTION),
|
||||
CHECKOUT_SHEET(route = Route.TYPE_SELECTION),
|
||||
@@ -26,9 +28,11 @@ enum class MessageBackupsStage(
|
||||
* Compose navigation route to display while in a given stage.
|
||||
*/
|
||||
enum class Route {
|
||||
CANCEL,
|
||||
EDUCATION,
|
||||
BACKUP_KEY_EDUCATION,
|
||||
BACKUP_KEY_RECORD,
|
||||
BACKUP_KEY_VERIFY,
|
||||
TYPE_SELECTION;
|
||||
|
||||
fun isAfter(other: Route): Boolean = ordinal > other.ordinal
|
||||
|
||||
@@ -18,8 +18,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,18 +44,18 @@ import androidx.compose.ui.text.withAnnotation
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols.SignalSymbol
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols.signalSymbolText
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.ByteUnit
|
||||
import java.math.BigDecimal
|
||||
@@ -82,7 +82,7 @@ fun MessageBackupsTypeSelectionScreen(
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
onNavigationClick = onNavigationClick,
|
||||
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
|
||||
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_start_24)
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -260,11 +260,10 @@ fun MessageBackupsTypeBlock(
|
||||
) {
|
||||
if (isCurrent) {
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
SignalSymbol(weight = SignalSymbols.Weight.REGULAR, glyph = SignalSymbols.Glyph.CHECKMARK)
|
||||
append(" ")
|
||||
append(stringResource(R.string.MessageBackupsTypeSelectionScreen__current_plan))
|
||||
},
|
||||
text = signalSymbolText(
|
||||
text = stringResource(R.string.MessageBackupsTypeSelectionScreen__current_plan),
|
||||
glyphStart = SignalSymbols.Glyph.CHECK
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 12.dp)
|
||||
|
||||
@@ -8,6 +8,8 @@ package org.thoughtcrime.securesms.backup.v2.util
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.emptyIfNull
|
||||
import org.signal.core.util.nullIfBlank
|
||||
import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.attachments.ArchivedAttachment
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
@@ -15,8 +17,8 @@ import org.thoughtcrime.securesms.attachments.Cdn
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.attachments.PointerAttachment
|
||||
import org.thoughtcrime.securesms.attachments.TombstoneAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getMediaName
|
||||
import org.thoughtcrime.securesms.backup.v2.ImportState
|
||||
import org.thoughtcrime.securesms.backup.v2.getMediaName
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
@@ -96,7 +98,7 @@ fun FilePointer?.toLocalAttachment(
|
||||
cdn = this.backupLocator.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
|
||||
key = this.backupLocator.key.toByteArray(),
|
||||
iv = null,
|
||||
cdnKey = this.backupLocator.transitCdnKey,
|
||||
cdnKey = this.backupLocator.transitCdnKey?.nullIfBlank(),
|
||||
archiveCdn = this.backupLocator.cdnNumber,
|
||||
archiveMediaName = this.backupLocator.mediaName,
|
||||
archiveMediaId = importState.mediaRootBackupKey.deriveMediaId(MediaName(this.backupLocator.mediaName)).encode(),
|
||||
@@ -147,14 +149,18 @@ fun DatabaseAttachment.toRemoteFilePointer(mediaArchiveEnabled: Boolean, content
|
||||
val pending = this.archiveTransferState != AttachmentTable.ArchiveTransferState.FINISHED && (this.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && this.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED)
|
||||
|
||||
if (mediaArchiveEnabled && !pending) {
|
||||
val transitCdnKey = this.remoteLocation?.nullIfBlank()
|
||||
val transitCdnNumber = this.cdn.cdnNumber.takeIf { transitCdnKey != null }
|
||||
val archiveMediaName = this.getMediaName()?.toString()
|
||||
|
||||
builder.backupLocator = FilePointer.BackupLocator(
|
||||
mediaName = this.archiveMediaName ?: this.getMediaName().toString(),
|
||||
cdnNumber = if (this.archiveMediaName != null) this.archiveCdn else Cdn.CDN_3.cdnNumber, // TODO [backup]: Update when new proto with optional cdn is landed
|
||||
mediaName = archiveMediaName.emptyIfNull(),
|
||||
cdnNumber = this.archiveCdn.takeIf { archiveMediaName != null },
|
||||
key = Base64.decode(remoteKey).toByteString(),
|
||||
size = this.size.toInt(),
|
||||
digest = this.remoteDigest.toByteString(),
|
||||
transitCdnNumber = this.cdn.cdnNumber.takeIf { this.remoteLocation != null },
|
||||
transitCdnKey = this.remoteLocation
|
||||
transitCdnNumber = transitCdnNumber,
|
||||
transitCdnKey = transitCdnKey
|
||||
)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
@@ -58,10 +57,6 @@ class GiftFlowConfirmationFragment :
|
||||
EmojiSearchFragment.Callback,
|
||||
InAppPaymentCheckoutDelegate.Callback {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(GiftFlowConfirmationFragment::class.java)
|
||||
}
|
||||
|
||||
private val viewModel: GiftFlowViewModel by viewModels(
|
||||
ownerProducer = { requireActivity() }
|
||||
)
|
||||
@@ -118,7 +113,7 @@ class GiftFlowConfirmationFragment :
|
||||
lifecycleDisposable += viewModel.insertInAppPayment().subscribe { inAppPayment ->
|
||||
findNavController().safeNavigate(
|
||||
GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToGatewaySelectorBottomSheet(
|
||||
inAppPayment
|
||||
inAppPayment.id
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -266,8 +261,7 @@ class GiftFlowConfirmationFragment :
|
||||
findNavController().safeNavigate(
|
||||
GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToStripePaymentInProgressFragment(
|
||||
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT,
|
||||
inAppPayment,
|
||||
inAppPayment.type
|
||||
inAppPayment.id
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -276,15 +270,14 @@ class GiftFlowConfirmationFragment :
|
||||
findNavController().safeNavigate(
|
||||
GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToPaypalPaymentInProgressFragment(
|
||||
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT,
|
||||
inAppPayment,
|
||||
inAppPayment.type
|
||||
inAppPayment.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToCreditCardForm(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(
|
||||
GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(inAppPayment)
|
||||
GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(inAppPayment.id)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,10 +31,10 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Texts
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Texts
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
|
||||
@@ -47,7 +47,11 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
|
||||
}
|
||||
|
||||
override fun getMaterial3OnScrollHelper(toolbar: Toolbar?): Material3OnScrollHelper? {
|
||||
return Material3OnScrollHelper(requireActivity(), scrollShadow, viewLifecycleOwner)
|
||||
return Material3OnScrollHelper(
|
||||
activity = requireActivity(),
|
||||
views = listOf(scrollShadow),
|
||||
lifecycleOwner = viewLifecycleOwner
|
||||
)
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: MappingAdapter) {
|
||||
|
||||
@@ -8,15 +8,14 @@ package org.thoughtcrime.securesms.banner
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
/**
|
||||
@@ -37,28 +36,27 @@ class BannerManager @JvmOverloads constructor(
|
||||
* Re-evaluates the [Banner]s, choosing one to render (if any) and updating the view.
|
||||
*/
|
||||
fun updateContent(composeView: ComposeView) {
|
||||
val banner: Banner<Any>? = banners.firstOrNull { it.enabled } as Banner<Any>?
|
||||
|
||||
composeView.apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setContent {
|
||||
val banner: Banner<Any>? = banners.firstOrNull { it.enabled } as Banner<Any>?
|
||||
if (banner == null) {
|
||||
onNoBannerShownListener()
|
||||
return@setContent
|
||||
}
|
||||
|
||||
val state: State<Any?> = banner.dataFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
val bannerState by state
|
||||
key(banner) {
|
||||
val bannerState by banner.dataFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
|
||||
bannerState?.let { model ->
|
||||
SignalTheme {
|
||||
Box {
|
||||
banner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
|
||||
bannerState?.let { model ->
|
||||
SignalTheme {
|
||||
Box {
|
||||
banner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onNewBannerShownListener()
|
||||
} ?: onNoBannerShownListener()
|
||||
onNewBannerShownListener()
|
||||
} ?: onNoBannerShownListener()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,12 +66,16 @@ class BannerManager @JvmOverloads constructor(
|
||||
*/
|
||||
@Composable
|
||||
fun Banner() {
|
||||
val banner by rememberUpdatedState(banners.firstOrNull { it.enabled } as Banner<Any>?)
|
||||
val banner: Banner<Any>? = banners.firstOrNull { it.enabled } as Banner<Any>?
|
||||
if (banner == null) {
|
||||
return
|
||||
}
|
||||
|
||||
banner?.let { nonNullBanner ->
|
||||
val state by nonNullBanner.dataFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
state?.let { model ->
|
||||
nonNullBanner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
|
||||
key(banner) {
|
||||
val bannerState by banner.dataFlow.collectAsStateWithLifecycle(initialValue = null)
|
||||
|
||||
bannerState?.let { model ->
|
||||
banner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,11 @@ import android.os.Build
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
|
||||
@@ -11,8 +11,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
|
||||
@@ -11,8 +11,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
|
||||
@@ -12,8 +12,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
|
||||
@@ -13,8 +13,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
|
||||
@@ -12,8 +12,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
|
||||
@@ -11,8 +11,8 @@ import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
|
||||
@@ -13,8 +13,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
|
||||
@@ -15,8 +15,8 @@ import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
|
||||
@@ -12,8 +12,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
|
||||
|
||||
@@ -13,8 +13,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
|
||||
@@ -11,8 +11,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
import org.thoughtcrime.securesms.banner.ui.compose.Action
|
||||
|
||||
@@ -34,8 +34,8 @@ import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeBlock
|
||||
|
||||
@@ -16,7 +16,7 @@ import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.rx3.asFlowable
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsFlowViewModel
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsStage
|
||||
|
||||
@@ -25,10 +25,10 @@ import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.compose.BottomSheets
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeBlock
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
@@ -38,6 +39,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
|
||||
|
||||
private BlockedUsersViewModel viewModel;
|
||||
private View container;
|
||||
|
||||
private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable();
|
||||
|
||||
@@ -57,7 +59,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
ContactFilterView contactFilterView = findViewById(R.id.contact_filter_edit_text);
|
||||
View container = findViewById(R.id.fragment_container);
|
||||
container = findViewById(R.id.fragment_container);
|
||||
|
||||
toolbar.setNavigationOnClickListener(unused -> onBackPressed());
|
||||
contactFilterView.setOnFilterChangedListener(query -> {
|
||||
@@ -99,11 +101,41 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
|
||||
final String displayName = recipientId.map(id -> Recipient.resolved(id).getDisplayName(this)).orElse(number);
|
||||
Optional<Recipient> resolvedRecipient = recipientId.map(Recipient::resolved);
|
||||
|
||||
AlertDialog confirmationDialog = new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.BlockedUsersActivity__block_user)
|
||||
.setMessage(getString(R.string.BlockedUserActivity__s_will_not_be_able_to, displayName))
|
||||
final String displayName = resolvedRecipient
|
||||
.map(r -> r.getDisplayName(this))
|
||||
.orElse(number);
|
||||
|
||||
boolean isSelf = resolvedRecipient
|
||||
.map(Recipient::isSelf)
|
||||
.orElseGet(() -> Optional.ofNullable(number)
|
||||
.map(Recipient::external)
|
||||
.map(Recipient::isSelf)
|
||||
.orElse(false));
|
||||
|
||||
if (isSelf) {
|
||||
Snackbar.make(container, getString(R.string.BlockedUsersActivity__cannot_block_yourself), Snackbar.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
|
||||
|
||||
if (resolvedRecipient.isPresent() && resolvedRecipient.get().isGroup()) {
|
||||
Recipient recipient = resolvedRecipient.get();
|
||||
if (SignalDatabase.groups().isActive(recipient.requireGroupId())) {
|
||||
builder.setTitle(getString(R.string.BlockUnblockDialog_block_and_leave_s, displayName));
|
||||
builder.setMessage(R.string.BlockUnblockDialog_you_will_no_longer_receive_messages_or_updates);
|
||||
} else {
|
||||
builder.setTitle(getString(R.string.BlockUnblockDialog_block_s, displayName));
|
||||
builder.setMessage(R.string.BlockUnblockDialog_group_members_wont_be_able_to_add_you);
|
||||
}
|
||||
} else {
|
||||
builder.setTitle(R.string.BlockedUsersActivity__block_user);
|
||||
builder.setMessage(getString(R.string.BlockedUserActivity__s_will_not_be_able_to, displayName));
|
||||
}
|
||||
|
||||
AlertDialog confirmationDialog = builder
|
||||
.setPositiveButton(R.string.BlockedUsersActivity__block, (dialog, which) -> {
|
||||
if (recipientId.isPresent()) {
|
||||
viewModel.block(recipientId.get());
|
||||
|
||||
@@ -13,7 +13,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.signal.core.ui.Snackbars
|
||||
import org.signal.core.ui.compose.Snackbars
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,8 +35,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.util.BreakIteratorCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user