mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-12 13:03:17 +01:00
Compare commits
413 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
643d96a896 | ||
|
|
04664d34e4 | ||
|
|
7fd5b72204 | ||
|
|
fd3a509231 | ||
|
|
9b672a520a | ||
|
|
2d3e8ef31c | ||
|
|
f1c2ee9b32 | ||
|
|
68a0cb40a6 | ||
|
|
68a50798f2 | ||
|
|
73151e8ff6 | ||
|
|
b7da4b93db | ||
|
|
bd373a3045 | ||
|
|
7c94d570cb | ||
|
|
8d8f5fb9e4 | ||
|
|
1b2cb2637f | ||
|
|
e222f96310 | ||
|
|
877a62b809 | ||
|
|
81fc99724d | ||
|
|
6e8f3d1e71 | ||
|
|
33ab25a557 | ||
|
|
c30e3664b8 | ||
|
|
bb8c7bab20 | ||
|
|
194681abb7 | ||
|
|
c56564014b | ||
|
|
c0aff46e31 | ||
|
|
f719dcca6d | ||
|
|
abd1582422 | ||
|
|
ec2565263e | ||
|
|
c1a94be9cd | ||
|
|
e303e80f17 | ||
|
|
018f6ac7aa | ||
|
|
6f9d3f02f1 | ||
|
|
9b2ccd43c8 | ||
|
|
bd078274b5 | ||
|
|
9cfb95fee7 | ||
|
|
0f18fa329d | ||
|
|
428ef554a3 | ||
|
|
8ca8e5d8f9 | ||
|
|
5634e9834d | ||
|
|
b437cb0344 | ||
|
|
3695d7a5f1 | ||
|
|
7010b19fea | ||
|
|
3f62221182 | ||
|
|
43aad90ee4 | ||
|
|
aa28668315 | ||
|
|
9ea392fb4e | ||
|
|
d0c858221e | ||
|
|
a95e695a97 | ||
|
|
8910eac6e0 | ||
|
|
e635c3030e | ||
|
|
8cbad2c3a6 | ||
|
|
45a04423b0 | ||
|
|
f3693c966a | ||
|
|
10e8c6d795 | ||
|
|
57e8684bb3 | ||
|
|
f91c400f6c | ||
|
|
40f86ed2be | ||
|
|
ca8add87c6 | ||
|
|
a9c4fcf894 | ||
|
|
6bc5b19b1e | ||
|
|
4990243a91 | ||
|
|
6922886395 | ||
|
|
ce4b7c2d7f | ||
|
|
21deb6803c | ||
|
|
873552436a | ||
|
|
8334db5273 | ||
|
|
33828439fb | ||
|
|
4f31dc36ba | ||
|
|
0a971569d9 | ||
|
|
06476c80f8 | ||
|
|
d1d73fef30 | ||
|
|
89ad213994 | ||
|
|
6969c6d6ee | ||
|
|
b82f6f83ec | ||
|
|
d3572f92f5 | ||
|
|
41126ba913 | ||
|
|
0a00413228 | ||
|
|
e810eeec58 | ||
|
|
5f0035b2d0 | ||
|
|
a20d5fd6cf | ||
|
|
191b2076c3 | ||
|
|
35779e8df3 | ||
|
|
06bec76371 | ||
|
|
ff76c4cdef | ||
|
|
42da07b763 | ||
|
|
3e69ef8acc | ||
|
|
ab48aa5766 | ||
|
|
e37d3be73a | ||
|
|
16c2609dab | ||
|
|
e4d4a5d9e0 | ||
|
|
a552a5a5bc | ||
|
|
13d48b880b | ||
|
|
6cb8c7a8a9 | ||
|
|
ae3ff21689 | ||
|
|
d3c3986100 | ||
|
|
6f3c095a95 | ||
|
|
6ee04f6574 | ||
|
|
f3922c4156 | ||
|
|
2ffc576387 | ||
|
|
583f7db554 | ||
|
|
1cffd88af2 | ||
|
|
01351125f1 | ||
|
|
19d67d1111 | ||
|
|
8cec6a8b0c | ||
|
|
e8b3d2c7aa | ||
|
|
62414e72b5 | ||
|
|
afb9b76208 | ||
|
|
4f458a022f | ||
|
|
a47e3900c1 | ||
|
|
3de17fa2d0 | ||
|
|
7abf358ac4 | ||
|
|
64d5cbce3d | ||
|
|
691ab353da | ||
|
|
b689ea62a6 | ||
|
|
17aa0365d6 | ||
|
|
3f93d4b9fc | ||
|
|
263fb9fc04 | ||
|
|
3ebafca297 | ||
|
|
316df00287 | ||
|
|
b92346d4ae | ||
|
|
7bdb5fd76c | ||
|
|
dad9980a80 | ||
|
|
21df032b04 | ||
|
|
7edebe9fa1 | ||
|
|
a398745740 | ||
|
|
4954be109c | ||
|
|
7380d4b11e | ||
|
|
d8a6f9c324 | ||
|
|
ab9057cb25 | ||
|
|
ba8ea3b54b | ||
|
|
d9aca34eee | ||
|
|
05d232beec | ||
|
|
fde0726500 | ||
|
|
bb8b987833 | ||
|
|
f066fb8ea2 | ||
|
|
7738c286c2 | ||
|
|
697670b334 | ||
|
|
4cfba86cb1 | ||
|
|
ce4e84aadc | ||
|
|
23d0152767 | ||
|
|
d714590d3f | ||
|
|
65bc1263f3 | ||
|
|
730065fc76 | ||
|
|
1e10b82769 | ||
|
|
01f477a587 | ||
|
|
76383fe1bc | ||
|
|
c61f45b88b | ||
|
|
e559198495 | ||
|
|
8676cb27ae | ||
|
|
7215ca6a28 | ||
|
|
ef11a8d98d | ||
|
|
6efd501f1c | ||
|
|
66c650e859 | ||
|
|
8141f7a5cf | ||
|
|
0fcdf61e76 | ||
|
|
fa571f14e6 | ||
|
|
583860053b | ||
|
|
ad70baf557 | ||
|
|
2b0e9783a7 | ||
|
|
c75a9b577d | ||
|
|
e8ff1a04ed | ||
|
|
a22a696722 | ||
|
|
66494fa418 | ||
|
|
9fd763fe83 | ||
|
|
1d508ad5cc | ||
|
|
c1c7f57ec0 | ||
|
|
6100160e18 | ||
|
|
e2c3db3eda | ||
|
|
6759b59507 | ||
|
|
e36844fe78 | ||
|
|
5cf937215a | ||
|
|
1b49b9bffb | ||
|
|
a3a29d5cb2 | ||
|
|
6fbfb87bd6 | ||
|
|
2bff2d3a30 | ||
|
|
a88410faaf | ||
|
|
f26b2c0b2a | ||
|
|
6f1b03eac6 | ||
|
|
9610339f38 | ||
|
|
d4ce8458a4 | ||
|
|
384cdf8610 | ||
|
|
3ee30808de | ||
|
|
78c64880f7 | ||
|
|
b99ce9cc1d | ||
|
|
41f796d809 | ||
|
|
ec504af593 | ||
|
|
60874ba57b | ||
|
|
4397b5af25 | ||
|
|
07234443c6 | ||
|
|
c027203e8c | ||
|
|
417db2341b | ||
|
|
6aa4ef95b5 | ||
|
|
6145fa213e | ||
|
|
9fa4741e49 | ||
|
|
b9d5fb54c3 | ||
|
|
c0fe156897 | ||
|
|
22cad64089 | ||
|
|
702cf6ef71 | ||
|
|
d7c3112602 | ||
|
|
d9c31a6cd6 | ||
|
|
408c288936 | ||
|
|
af6f16bdb6 | ||
|
|
055ceba398 | ||
|
|
3f81a94176 | ||
|
|
a02d2e467b | ||
|
|
414550861e | ||
|
|
afbce6f800 | ||
|
|
dda5037429 | ||
|
|
ffbebe0670 | ||
|
|
cf250b4b32 | ||
|
|
b14aea0922 | ||
|
|
d0de43a6b2 | ||
|
|
2c48d40375 | ||
|
|
803154c544 | ||
|
|
684150dc1e | ||
|
|
fdcf0a76e8 | ||
|
|
9e056e5dd0 | ||
|
|
03c68375db | ||
|
|
5d328857aa | ||
|
|
3a0dbe6e67 | ||
|
|
56b35f3767 | ||
|
|
7f0221c5c6 | ||
|
|
23050152de | ||
|
|
db65edb7df | ||
|
|
605289aca4 | ||
|
|
52e9b31554 | ||
|
|
c8e6ccc0c0 | ||
|
|
f20d929292 | ||
|
|
a9accfb074 | ||
|
|
8f2d1a2d12 | ||
|
|
ca8755c6ad | ||
|
|
dc4eb7911d | ||
|
|
eb2e0205ae | ||
|
|
7a72a9a0d7 | ||
|
|
805ccc4f7a | ||
|
|
499b186b68 | ||
|
|
c741e32824 | ||
|
|
fba4c882cb | ||
|
|
24ef853f24 | ||
|
|
9f22ba68ea | ||
|
|
d8eac87219 | ||
|
|
cf71e2cfa8 | ||
|
|
7f16d0653c | ||
|
|
61e127fabf | ||
|
|
7ffdf91ce5 | ||
|
|
4c26fe432e | ||
|
|
6c78a405bb | ||
|
|
89b0167fd2 | ||
|
|
e25133fa29 | ||
|
|
4ba77c0f9f | ||
|
|
10f376e402 | ||
|
|
7bae8b6e1b | ||
|
|
67fb9d09d4 | ||
|
|
3b40b10a77 | ||
|
|
418b486776 | ||
|
|
1f31f4a50a | ||
|
|
9b08ebcc1d | ||
|
|
aec4944c56 | ||
|
|
9a1f8af703 | ||
|
|
268b11c4e1 | ||
|
|
2e3d73f44b | ||
|
|
f477a4dae9 | ||
|
|
a41aed20e1 | ||
|
|
1ed3dbb147 | ||
|
|
fcfb9fad01 | ||
|
|
25c96a6be6 | ||
|
|
90695182f3 | ||
|
|
1c38ab18b8 | ||
|
|
7c716e5525 | ||
|
|
56a44ae65c | ||
|
|
d33aa247db | ||
|
|
63a153571d | ||
|
|
fb07e897d0 | ||
|
|
93387ec79a | ||
|
|
cd79dbbb82 | ||
|
|
7fbfc09a89 | ||
|
|
0f6bc0471c | ||
|
|
ba919d4ecc | ||
|
|
73722297cf | ||
|
|
6050a9f585 | ||
|
|
b243eee4ce | ||
|
|
a91a13cead | ||
|
|
72449fd73e | ||
|
|
6a8e82ef91 | ||
|
|
987fafff92 | ||
|
|
35ff977df9 | ||
|
|
fe2d71fca0 | ||
|
|
a12a246e87 | ||
|
|
4f387cf8d9 | ||
|
|
dae69744c2 | ||
|
|
4ad233c6d1 | ||
|
|
b4c572678c | ||
|
|
5024998a6f | ||
|
|
43cde19071 | ||
|
|
62a2f3d8ba | ||
|
|
5bc44fa586 | ||
|
|
f0b3aa66f7 | ||
|
|
ef9cd2515e | ||
|
|
4677f207e7 | ||
|
|
4c26f3258d | ||
|
|
77a3037614 | ||
|
|
9600d6f6a9 | ||
|
|
36dfa19aec | ||
|
|
09902e5d11 | ||
|
|
e84c6187b9 | ||
|
|
b5d52db57c | ||
|
|
f320cf8833 | ||
|
|
c76ca957e1 | ||
|
|
56354f6aae | ||
|
|
2bf84a5f77 | ||
|
|
8b23d9a6c4 | ||
|
|
2cae3ddf04 | ||
|
|
670b6c4c56 | ||
|
|
eceed641bf | ||
|
|
5febe6490c | ||
|
|
dca47e4cb5 | ||
|
|
c3bcba6380 | ||
|
|
cb01692a50 | ||
|
|
e90074ffef | ||
|
|
691520bc75 | ||
|
|
e6de06be6f | ||
|
|
a77079ac81 | ||
|
|
30b58fe5f4 | ||
|
|
7275b95b58 | ||
|
|
f04d46b4ed | ||
|
|
e12bbe943b | ||
|
|
7348224dc2 | ||
|
|
30c33fdd77 | ||
|
|
e7339af119 | ||
|
|
661fff7a0e | ||
|
|
c37bad0f7a | ||
|
|
7f228fc0fd | ||
|
|
14cd216668 | ||
|
|
0cb0ef977c | ||
|
|
1761529ce9 | ||
|
|
a14fc82e83 | ||
|
|
b94f5501d9 | ||
|
|
834283ba9b | ||
|
|
23190a2f6e | ||
|
|
04f4cd8edc | ||
|
|
8f02e4e1f5 | ||
|
|
db81a5be04 | ||
|
|
fe40e37da4 | ||
|
|
22a4271dfb | ||
|
|
1263b51e03 | ||
|
|
ca468047ef | ||
|
|
958c52a5b8 | ||
|
|
9b28585c59 | ||
|
|
c5c60b7214 | ||
|
|
31bcc2e2eb | ||
|
|
71ecba17fc | ||
|
|
afa5c68312 | ||
|
|
f3e715e069 | ||
|
|
df695f7611 | ||
|
|
27e1bc0854 | ||
|
|
f4371b9e96 | ||
|
|
e0633180ef | ||
|
|
32dd227ab6 | ||
|
|
0deed9d4d2 | ||
|
|
cc490f4b73 | ||
|
|
aa2075c78f | ||
|
|
b4a34599d7 | ||
|
|
8dd1d3bdeb | ||
|
|
a7d9bd944b | ||
|
|
7745ae62ea | ||
|
|
6e5b4bbc15 | ||
|
|
e3b38e6d38 | ||
|
|
25aa4f39a3 | ||
|
|
17849e20bd | ||
|
|
c022172ace | ||
|
|
eaeeb08987 | ||
|
|
1b7e4e047c | ||
|
|
d635683303 | ||
|
|
4dcbbfdd63 | ||
|
|
150bbf181d | ||
|
|
0303467c91 | ||
|
|
88da382a6f | ||
|
|
5d14166a27 | ||
|
|
d76d13f76c | ||
|
|
ad4ec23875 | ||
|
|
61df2afc32 | ||
|
|
1c6d2f7198 | ||
|
|
df8f9761b2 | ||
|
|
657c5d2bce | ||
|
|
81324c6923 | ||
|
|
269a2e2990 | ||
|
|
73b453b0d4 | ||
|
|
97604dc4c5 | ||
|
|
8e1c05ed64 | ||
|
|
231b55a956 | ||
|
|
4fcdee9fa5 | ||
|
|
6e2e5e21cc | ||
|
|
8f49323648 | ||
|
|
13f969b622 | ||
|
|
518d9b3984 | ||
|
|
8e313f8387 | ||
|
|
70c6e9e60f | ||
|
|
dcf8a82c37 | ||
|
|
f368e5b133 | ||
|
|
3ee889cb79 | ||
|
|
3e7dc79fe8 | ||
|
|
8cfd02aff2 | ||
|
|
f36efc562e | ||
|
|
67b6b109de | ||
|
|
8fd378db4e | ||
|
|
760ace93d4 | ||
|
|
125fd83afa | ||
|
|
7dcb598b66 | ||
|
|
4917e93d9f | ||
|
|
1e9115a917 | ||
|
|
7af94f60ae | ||
|
|
c3c8f8e7e6 | ||
|
|
011c85c75b |
@@ -2,3 +2,4 @@ root = true
|
||||
|
||||
[*.kt]
|
||||
indent_size = 2
|
||||
twitter_compose_allowed_composition_locals=LocalExtendedColors
|
||||
8
.github/workflows/android.yml
vendored
8
.github/workflows/android.yml
vendored
@@ -14,7 +14,7 @@ permissions:
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -24,15 +24,13 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 11
|
||||
cache: gradle
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/wrapper-validation-action@v1
|
||||
|
||||
- name: Remove Android 31 (S)
|
||||
run: $ANDROID_HOME/tools/bin/sdkmanager --uninstall "platforms;android-31"
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew qa
|
||||
run: ./gradlew qa --parallel
|
||||
|
||||
- name: Archive reports for failed build
|
||||
if: ${{ failure() }}
|
||||
|
||||
5
.idea/codeStyles/Project.xml
generated
5
.idea/codeStyles/Project.xml
generated
@@ -43,10 +43,7 @@
|
||||
</JavaCodeStyleSettings>
|
||||
<JetCodeStyleSettings>
|
||||
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
|
||||
<value>
|
||||
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
|
||||
<package name="io.ktor" alias="false" withSubpackages="true" />
|
||||
</value>
|
||||
<value />
|
||||
</option>
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
|
||||
|
||||
139
app/build.gradle
139
app/build.gradle
@@ -1,37 +1,21 @@
|
||||
import com.android.build.api.dsl.ManagedVirtualDevice
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'com.google.protobuf'
|
||||
apply plugin: 'androidx.navigation.safeargs'
|
||||
apply plugin: 'org.jlleitschuh.gradle.ktlint'
|
||||
apply from: 'translations.gradle'
|
||||
apply plugin: 'org.jetbrains.kotlin.android'
|
||||
apply plugin: 'app.cash.exhaustive'
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
apply from: 'static-ips.gradle'
|
||||
|
||||
repositories {
|
||||
maven {
|
||||
url "https://raw.githubusercontent.com/signalapp/maven/master/sqlcipher/release/"
|
||||
content {
|
||||
includeGroupByRegex "org\\.signal.*"
|
||||
}
|
||||
}
|
||||
|
||||
google()
|
||||
mavenCentral()
|
||||
mavenLocal()
|
||||
maven {
|
||||
url "https://dl.cloudsmith.io/qxAgwaeEE1vN8aLU/mobilecoin/mobilecoin/maven/"
|
||||
}
|
||||
jcenter {
|
||||
content {
|
||||
includeVersion "mobi.upod", "time-duration-picker", "1.1.3"
|
||||
}
|
||||
}
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'kotlin-android'
|
||||
id 'com.google.protobuf'
|
||||
id 'androidx.navigation.safeargs'
|
||||
id 'org.jlleitschuh.gradle.ktlint'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'app.cash.exhaustive'
|
||||
id 'kotlin-parcelize'
|
||||
id 'com.squareup.wire'
|
||||
id 'android-constants'
|
||||
id 'translations'
|
||||
}
|
||||
|
||||
apply from: 'static-ips.gradle'
|
||||
|
||||
protobuf {
|
||||
protoc {
|
||||
artifact = 'com.google.protobuf:protoc:3.18.0'
|
||||
@@ -47,13 +31,23 @@ protobuf {
|
||||
}
|
||||
}
|
||||
|
||||
ktlint {
|
||||
// Use a newer version to resolve https://github.com/JLLeitschuh/ktlint-gradle/issues/507
|
||||
version = "0.43.2"
|
||||
wire {
|
||||
kotlin {
|
||||
javaInterop = true
|
||||
}
|
||||
|
||||
sourcePath {
|
||||
srcDir 'src/main/protowire'
|
||||
}
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 1196
|
||||
def canonicalVersionName = "6.8.3"
|
||||
ktlint {
|
||||
// Use a newer version to resolve https://github.com/JLLeitschuh/ktlint-gradle/issues/507
|
||||
version = "0.47.1"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 1228
|
||||
def canonicalVersionName = "6.14.1"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -68,6 +62,9 @@ def selectableVariants = [
|
||||
'nightlyProdSpinner',
|
||||
'nightlyProdPerf',
|
||||
'nightlyProdRelease',
|
||||
'nightlyStagingRelease',
|
||||
'nightlyPnpPerf',
|
||||
'nightlyPnpRelease',
|
||||
'playProdDebug',
|
||||
'playProdSpinner',
|
||||
'playProdPerf',
|
||||
@@ -77,21 +74,25 @@ def selectableVariants = [
|
||||
'playStagingSpinner',
|
||||
'playStagingPerf',
|
||||
'playStagingInstrumentation',
|
||||
'playPnpDebug',
|
||||
'playPnpSpinner',
|
||||
'playStagingRelease',
|
||||
'websiteProdSpinner',
|
||||
'websiteProdRelease',
|
||||
]
|
||||
|
||||
android {
|
||||
buildToolsVersion BUILD_TOOL_VERSION
|
||||
compileSdkVersion COMPILE_SDK
|
||||
namespace 'org.thoughtcrime.securesms'
|
||||
|
||||
buildToolsVersion = signalBuildToolsVersion
|
||||
compileSdkVersion = signalCompileSdkVersion
|
||||
|
||||
flavorDimensions 'distribution', 'environment'
|
||||
useLibrary 'org.apache.http.legacy'
|
||||
testBuildType 'instrumentation'
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
jvmTarget = "11"
|
||||
freeCompilerArgs = ["-Xallow-result-return-type"]
|
||||
}
|
||||
|
||||
@@ -125,12 +126,6 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
checkReleaseBuilds false
|
||||
abortOnError true
|
||||
baseline file("lint-baseline.xml")
|
||||
disable "LintError"
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
test {
|
||||
@@ -144,32 +139,32 @@ android {
|
||||
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
sourceCompatibility JAVA_VERSION
|
||||
targetCompatibility JAVA_VERSION
|
||||
sourceCompatibility signalJavaVersion
|
||||
targetCompatibility signalJavaVersion
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
exclude 'LICENSE.txt'
|
||||
exclude 'LICENSE'
|
||||
exclude 'NOTICE'
|
||||
exclude 'asm-license.txt'
|
||||
exclude 'META-INF/LICENSE'
|
||||
exclude 'META-INF/NOTICE'
|
||||
exclude 'META-INF/proguard/androidx-annotations.pro'
|
||||
exclude 'libsignal_jni.dylib'
|
||||
exclude 'signal_jni.dll'
|
||||
resources {
|
||||
excludes += ['LICENSE.txt', 'LICENSE', 'NOTICE', 'asm-license.txt', 'META-INF/LICENSE', 'META-INF/LICENSE.md', 'META-INF/NOTICE', 'META-INF/LICENSE-notice.md', 'META-INF/proguard/androidx-annotations.pro', 'libsignal_jni.dylib', 'signal_jni.dll']
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
compose true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = '1.3.2'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
versionCode canonicalVersionCode * postFixSize
|
||||
versionName canonicalVersionName
|
||||
|
||||
minSdkVersion MINIMUM_SDK
|
||||
targetSdkVersion TARGET_SDK
|
||||
minSdkVersion signalMinSdkVersion
|
||||
targetSdkVersion signalTargetSdkVersion
|
||||
|
||||
multiDexEnabled true
|
||||
|
||||
@@ -203,8 +198,7 @@ android {
|
||||
buildConfigField "String[]", "SIGNAL_SFU_IPS", sfu_ips
|
||||
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
|
||||
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
|
||||
buildConfigField "String", "CDS_MRENCLAVE", "\"74778bb0f93ae1f78c26e67152bab0bbeb693cd56d1bb9b4e9244157acc58081\""
|
||||
buildConfigField "String", "CDSI_MRENCLAVE", "\"ef4787a56a154ac6d009138cac17155acd23cfe4329281252365dd7c252e7fbf\""
|
||||
buildConfigField "String", "CDSI_MRENCLAVE", "\"0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57\""
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"e18376436159cda3ad7a45d9320e382e4a497f26b0dca34d8eab0bd0139483b5\", " +
|
||||
"\"3a485adb56e2058ef7737764c738c4069dd62bc457637eafb6bbce1ce29ddb89\", " +
|
||||
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
|
||||
@@ -296,11 +290,13 @@ android {
|
||||
matchingFallbacks = ['debug']
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Spinner\""
|
||||
}
|
||||
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles = buildTypes.debug.proguardFiles
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Release\""
|
||||
}
|
||||
|
||||
perf {
|
||||
initWith debug
|
||||
isDefault false
|
||||
@@ -359,7 +355,6 @@ android {
|
||||
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api-staging.directory.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
|
||||
buildConfigField "String", "CDS_MRENCLAVE", "\"74778bb0f93ae1f78c26e67152bab0bbeb693cd56d1bb9b4e9244157acc58081\""
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"39963b736823d5780be96ab174869a9499d56d66497aa8f9b2244f777ebc366b\", " +
|
||||
"\"9dbc6855c198e04f21b5cc35df839fdcd51b53658454dfa3f817afefaffc95ef\", " +
|
||||
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
|
||||
@@ -375,6 +370,22 @@ android {
|
||||
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\""
|
||||
buildConfigField "String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\""
|
||||
}
|
||||
|
||||
pnp {
|
||||
dimension 'environment'
|
||||
|
||||
initWith staging
|
||||
applicationIdSuffix ".pnp"
|
||||
|
||||
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Pnp\""
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError true
|
||||
baseline file('lint-baseline.xml')
|
||||
checkReleaseBuilds false
|
||||
disable 'LintError'
|
||||
}
|
||||
|
||||
android.applicationVariants.all { variant ->
|
||||
@@ -421,7 +432,7 @@ dependencies {
|
||||
|
||||
implementation (libs.androidx.appcompat) {
|
||||
version {
|
||||
strictly '1.5.1'
|
||||
strictly '1.6.1'
|
||||
}
|
||||
}
|
||||
implementation libs.androidx.window.window
|
||||
@@ -429,7 +440,6 @@ dependencies {
|
||||
implementation libs.androidx.recyclerview
|
||||
implementation libs.material.material
|
||||
implementation libs.androidx.legacy.support
|
||||
implementation libs.androidx.cardview
|
||||
implementation libs.androidx.preference
|
||||
implementation libs.androidx.legacy.preference
|
||||
implementation libs.androidx.gridlayout
|
||||
@@ -506,7 +516,6 @@ dependencies {
|
||||
implementation libs.greenrobot.eventbus
|
||||
implementation libs.waitingdots
|
||||
implementation libs.google.zxing.android.integration
|
||||
implementation libs.time.duration.picker
|
||||
implementation libs.google.zxing.core
|
||||
implementation libs.google.flexbox
|
||||
implementation (libs.subsampling.scale.image.view) {
|
||||
@@ -560,6 +569,7 @@ dependencies {
|
||||
androidTestImplementation testLibs.androidx.test.ext.junit.ktx
|
||||
androidTestImplementation testLibs.mockito.android
|
||||
androidTestImplementation testLibs.mockito.kotlin
|
||||
androidTestImplementation testLibs.mockk.android
|
||||
androidTestImplementation testLibs.square.okhttp.mockserver
|
||||
|
||||
instrumentationImplementation (libs.androidx.fragment.testing) {
|
||||
@@ -577,6 +587,9 @@ dependencies {
|
||||
implementation libs.rxdogtag
|
||||
|
||||
androidTestUtil testLibs.androidx.test.orchestrator
|
||||
|
||||
implementation project(':core-ui')
|
||||
ktlintRuleset libs.ktlint.twitter.compose
|
||||
}
|
||||
|
||||
def getLastCommitTimestamp() {
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.AndroidLogger
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider
|
||||
import org.thoughtcrime.securesms.database.LogDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger
|
||||
import org.thoughtcrime.securesms.logging.PersistentLogger
|
||||
import org.thoughtcrime.securesms.testing.InMemoryLogger
|
||||
|
||||
/**
|
||||
* Application context for running instrumentation tests (aka androidTests).
|
||||
*/
|
||||
class SignalInstrumentationApplicationContext : ApplicationContext() {
|
||||
|
||||
val inMemoryLogger: InMemoryLogger = InMemoryLogger()
|
||||
|
||||
override fun initializeAppDependencies() {
|
||||
val default = ApplicationDependencyProvider(this)
|
||||
ApplicationDependencies.init(this, InstrumentationApplicationDependencyProvider(this, default))
|
||||
ApplicationDependencies.getDeadlockDetector().start()
|
||||
}
|
||||
|
||||
override fun initializeLogging() {
|
||||
persistentLogger = PersistentLogger(this)
|
||||
|
||||
Log.initialize({ true }, AndroidLogger(), persistentLogger, inMemoryLogger)
|
||||
|
||||
SignalProtocolLoggerProvider.setProvider(CustomSignalProtocolLogger())
|
||||
|
||||
SignalExecutors.UNBOUNDED.execute {
|
||||
Log.blockUntilAllWritesFinished()
|
||||
LogDatabase.getInstance(this).trimToSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.registration.VerifyAccountRepository
|
||||
import org.thoughtcrime.securesms.registration.VerifyResponseProcessor
|
||||
import org.thoughtcrime.securesms.testing.Get
|
||||
import org.thoughtcrime.securesms.testing.MockProvider
|
||||
import org.thoughtcrime.securesms.testing.Post
|
||||
import org.thoughtcrime.securesms.testing.Put
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
@@ -82,8 +83,10 @@ class ChangeNumberViewModelTest {
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
|
||||
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
|
||||
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
|
||||
Put("/v1/accounts/number") { r ->
|
||||
Put("/v2/accounts/number") { r ->
|
||||
changeNumberRequest = r.parsedRequestBody()
|
||||
MockResponse().success(MockProvider.createVerifyAccountResponse(aci, newPni))
|
||||
},
|
||||
@@ -95,6 +98,7 @@ class ChangeNumberViewModelTest {
|
||||
)
|
||||
|
||||
// WHEN
|
||||
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
|
||||
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().resultOrThrow
|
||||
|
||||
// THEN
|
||||
@@ -112,11 +116,14 @@ class ChangeNumberViewModelTest {
|
||||
val oldE164 = Recipient.self().requireE164()
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
|
||||
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
|
||||
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
|
||||
Put("/v1/accounts/number") { MockResponse().failure(500) },
|
||||
Put("/v2/accounts/number") { MockResponse().failure(500) }
|
||||
)
|
||||
|
||||
// WHEN
|
||||
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
|
||||
val processor: VerifyResponseProcessor = viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet()
|
||||
|
||||
// THEN
|
||||
@@ -142,12 +149,15 @@ class ChangeNumberViewModelTest {
|
||||
val oldE164 = Recipient.self().requireE164()
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
|
||||
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
|
||||
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
|
||||
Put("/v1/accounts/number") { MockResponse().connectionFailure() },
|
||||
Put("/v2/accounts/number") { MockResponse().connectionFailure() },
|
||||
Get("/v1/accounts/whoami") { MockResponse().success(MockProvider.createWhoAmIResponse(aci, oldPni, oldE164)) }
|
||||
)
|
||||
|
||||
// WHEN
|
||||
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
|
||||
val processor: VerifyResponseProcessor = viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet()
|
||||
|
||||
// THEN
|
||||
@@ -181,8 +191,10 @@ class ChangeNumberViewModelTest {
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
|
||||
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
|
||||
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
|
||||
Put("/v1/accounts/number") { r ->
|
||||
Put("/v2/accounts/number") { r ->
|
||||
changeNumberRequest = r.parsedRequestBody()
|
||||
MockResponse().timeout()
|
||||
},
|
||||
@@ -195,6 +207,7 @@ class ChangeNumberViewModelTest {
|
||||
)
|
||||
|
||||
// WHEN
|
||||
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
|
||||
val processor: VerifyResponseProcessor = viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet()
|
||||
|
||||
// THEN
|
||||
@@ -225,8 +238,10 @@ class ChangeNumberViewModelTest {
|
||||
MockProvider.mockGetRegistrationLockStringFlow(kbsRepository)
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
|
||||
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
|
||||
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
|
||||
Put("/v1/accounts/number") { r ->
|
||||
Put("/v2/accounts/number") { r ->
|
||||
changeNumberRequest = r.parsedRequestBody()
|
||||
if (changeNumberRequest.registrationLock.isNullOrEmpty()) {
|
||||
MockResponse().failure(423, MockProvider.lockedFailure)
|
||||
@@ -242,6 +257,7 @@ class ChangeNumberViewModelTest {
|
||||
)
|
||||
|
||||
// WHEN
|
||||
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
|
||||
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().also { processor ->
|
||||
processor.registrationLock() assertIs true
|
||||
Recipient.self().requirePni() assertIsNot newPni
|
||||
@@ -263,8 +279,10 @@ class ChangeNumberViewModelTest {
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
|
||||
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
|
||||
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
|
||||
Put("/v1/accounts/number") { r ->
|
||||
Put("/v2/accounts/number") { r ->
|
||||
changeNumberRequest = r.parsedRequestBody()
|
||||
if (changeNumberRequest.deviceMessages.isEmpty()) {
|
||||
MockResponse().failure(
|
||||
@@ -289,6 +307,7 @@ class ChangeNumberViewModelTest {
|
||||
)
|
||||
|
||||
// WHEN
|
||||
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
|
||||
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().resultOrThrow
|
||||
|
||||
// THEN
|
||||
@@ -307,7 +326,9 @@ class ChangeNumberViewModelTest {
|
||||
MockProvider.mockGetRegistrationLockStringFlow(kbsRepository)
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Put("/v1/accounts/number") { r ->
|
||||
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
|
||||
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
|
||||
Put("/v2/accounts/number") { r ->
|
||||
changeNumberRequest = r.parsedRequestBody()
|
||||
if (changeNumberRequest.registrationLock.isNullOrEmpty()) {
|
||||
MockResponse().failure(423, MockProvider.lockedFailure)
|
||||
@@ -345,6 +366,7 @@ class ChangeNumberViewModelTest {
|
||||
)
|
||||
|
||||
// WHEN
|
||||
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
|
||||
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().also { processor ->
|
||||
processor.registrationLock() assertIs true
|
||||
Recipient.self().requirePni() assertIsNot newPni
|
||||
|
||||
@@ -69,7 +69,7 @@ class ConversationItemPreviewer {
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
serverTimeMillis = System.currentTimeMillis(),
|
||||
receivedTimeMillis = System.currentTimeMillis(),
|
||||
attachments = PointerAttachment.forPointers(Optional.of(attachments)),
|
||||
attachments = PointerAttachment.forPointers(Optional.of(attachments))
|
||||
)
|
||||
|
||||
SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get()
|
||||
@@ -88,7 +88,7 @@ class ConversationItemPreviewer {
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
serverTimeMillis = System.currentTimeMillis(),
|
||||
receivedTimeMillis = System.currentTimeMillis(),
|
||||
attachments = PointerAttachment.forPointers(Optional.of(attachments)),
|
||||
attachments = PointerAttachment.forPointers(Optional.of(attachments))
|
||||
)
|
||||
|
||||
val insert = SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get()
|
||||
|
||||
@@ -64,7 +64,7 @@ class SafetyNumberChangeDialogPreviewer {
|
||||
SafetyNumberBottomSheet
|
||||
.forIdentityRecordsAndDestinations(
|
||||
identityRecords = ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(othersRecipients).identityRecords,
|
||||
destinations = listOf(ContactSearchKey.RecipientSearchKey.Story(myStoryRecipientId))
|
||||
destinations = listOf(ContactSearchKey.RecipientSearchKey(myStoryRecipientId, true))
|
||||
)
|
||||
.show(conversationActivity.supportFragmentManager)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.FlakyTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Before
|
||||
@@ -34,6 +35,7 @@ class AttachmentTableTest {
|
||||
assertEquals(attachment2.fileName, attachment.fileName)
|
||||
}
|
||||
|
||||
@FlakyTest
|
||||
@Test
|
||||
fun givenABlobAndDifferentTransformQuality_whenIInsert2AttachmentsForPreUpload_thenIExpectDifferentFileInfos() {
|
||||
val blob = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
|
||||
@@ -61,6 +63,7 @@ class AttachmentTableTest {
|
||||
assertNotEquals(attachment1Info, attachment2Info)
|
||||
}
|
||||
|
||||
@FlakyTest
|
||||
@Test
|
||||
fun givenIdenticalAttachmentsInsertedForPreUpload_whenIUpdateAttachmentDataAndSpecifyOnlyModifyThisAttachment_thenIExpectDifferentFileInfos() {
|
||||
val blob = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.delete
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.signal.storageservice.protos.groups.Member
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import java.security.SecureRandom
|
||||
import kotlin.random.Random
|
||||
|
||||
class GroupTableTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
private lateinit var groupTable: GroupTable
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
groupTable = SignalDatabase.groups
|
||||
|
||||
groupTable.writableDatabase.delete(GroupTable.TABLE_NAME).run()
|
||||
groupTable.writableDatabase.delete(GroupTable.MembershipTable.TABLE_NAME).run()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun whenICreateGroupV2_thenIExpectMemberRowsPopulated() {
|
||||
val groupId = insertPushGroup()
|
||||
|
||||
//language=sql
|
||||
val members: List<RecipientId> = groupTable.writableDatabase.query(
|
||||
"""
|
||||
SELECT ${GroupTable.MembershipTable.RECIPIENT_ID}
|
||||
FROM ${GroupTable.MembershipTable.TABLE_NAME}
|
||||
WHERE ${GroupTable.MembershipTable.GROUP_ID} = "${groupId.serialize()}"
|
||||
""".trimIndent()
|
||||
).readToList {
|
||||
RecipientId.from(it.requireLong(GroupTable.RECIPIENT_ID))
|
||||
}
|
||||
|
||||
assertEquals(2, members.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroupV2_whenIGetGroupsContainingMember_thenIExpectGroup() {
|
||||
val groupId = insertPushGroup()
|
||||
insertThread(groupId)
|
||||
|
||||
val groups = groupTable.getGroupsContainingMember(harness.others[0], false)
|
||||
|
||||
assertEquals(1, groups.size)
|
||||
assertEquals(groupId, groups[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAnMmsGroup_whenIGetMembers_thenIExpectAllMembers() {
|
||||
val groupId = insertMmsGroup()
|
||||
|
||||
val groups = groupTable.getGroupMemberIds(groupId, GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF)
|
||||
|
||||
assertEquals(2, groups.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenGroups_whenIQueryGroupsByMembership_thenIExpectBothGroups() {
|
||||
insertPushGroup()
|
||||
insertMmsGroup(members = listOf(harness.others[1]))
|
||||
|
||||
val groups = groupTable.queryGroupsByMembership(
|
||||
setOf(harness.self.id, harness.others[1]),
|
||||
includeInactive = false,
|
||||
excludeV1 = false,
|
||||
excludeMms = false
|
||||
)
|
||||
|
||||
assertEquals(2, groups.cursor?.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenGroups_whenIGetGroups_thenIExpectBothGroups() {
|
||||
insertPushGroup()
|
||||
insertMmsGroup(members = listOf(harness.others[1]))
|
||||
|
||||
val groups = groupTable.getGroups()
|
||||
|
||||
assertEquals(2, groups.cursor?.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroup_whenIGetGroup_thenIExpectGroup() {
|
||||
val v2Group = insertPushGroup()
|
||||
insertThread(v2Group)
|
||||
|
||||
val groupRecord = groupTable.getGroup(v2Group).get()
|
||||
assertEquals(setOf(harness.self.id, harness.others[0]), groupRecord.members.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroupAndARemap_whenIGetGroup_thenIExpectRemap() {
|
||||
val v2Group = insertPushGroup()
|
||||
insertThread(v2Group)
|
||||
|
||||
groupTable.writableDatabase.withinTransaction {
|
||||
RemappedRecords.getInstance().addRecipient(harness.others[0], harness.others[1])
|
||||
}
|
||||
|
||||
val groupRecord = groupTable.getGroup(v2Group).get()
|
||||
assertEquals(setOf(harness.self.id, harness.others[1]), groupRecord.members.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroupAndMember_whenIIsCurrentMember_thenIExpectTrue() {
|
||||
val v2Group = insertPushGroup()
|
||||
|
||||
val actual = groupTable.isCurrentMember(v2Group.requirePush(), harness.others[0])
|
||||
|
||||
assertTrue(actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroupAndMember_whenIRemove_thenIExpectNotAMember() {
|
||||
val v2Group = insertPushGroup()
|
||||
|
||||
groupTable.remove(v2Group, harness.others[0])
|
||||
val actual = groupTable.isCurrentMember(v2Group.requirePush(), harness.others[0])
|
||||
|
||||
assertFalse(actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroupAndNonMember_whenIIsCurrentMember_thenIExpectFalse() {
|
||||
val v2Group = insertPushGroup()
|
||||
|
||||
val actual = groupTable.isCurrentMember(v2Group.requirePush(), harness.others[1])
|
||||
|
||||
assertFalse(actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroup_whenIUpdateMembers_thenIExpectUpdatedMembers() {
|
||||
val v2Group = insertPushGroup()
|
||||
groupTable.updateMembers(v2Group, listOf(harness.self.id, harness.others[1]))
|
||||
val groupRecord = groupTable.getGroup(v2Group)
|
||||
|
||||
assertEquals(setOf(harness.self.id, harness.others[1]), groupRecord.get().members.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAnMmsGroup_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
|
||||
val members: List<RecipientId> = listOf(harness.self.id, harness.others[0])
|
||||
val other = insertMmsGroup(members + listOf(harness.others[1]))
|
||||
val mmsGroup = insertMmsGroup(members)
|
||||
val actual = groupTable.getOrCreateMmsGroupForMembers(members.toSet())
|
||||
|
||||
assertNotEquals(other, actual)
|
||||
assertEquals(mmsGroup, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMultipleMmsGroups_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
|
||||
val group1Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[1])
|
||||
val group2Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[2])
|
||||
|
||||
val group1: GroupId = insertMmsGroup(group1Members)
|
||||
val group2: GroupId = insertMmsGroup(group2Members)
|
||||
|
||||
val group1Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group1Members.toSet())
|
||||
val group2Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group2Members.toSet())
|
||||
|
||||
assertEquals(group1, group1Result)
|
||||
assertEquals(group2, group2Result)
|
||||
assertNotEquals(group1Result, group2Result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMultipleMmsGroupsWithDifferentMemberOrders_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
|
||||
val group1Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[1], harness.others[2]).shuffled()
|
||||
val group2Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[2], harness.others[3]).shuffled()
|
||||
|
||||
val group1: GroupId = insertMmsGroup(group1Members)
|
||||
val group2: GroupId = insertMmsGroup(group2Members)
|
||||
|
||||
val group1Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group1Members.shuffled().toSet())
|
||||
val group2Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group2Members.shuffled().toSet())
|
||||
|
||||
assertEquals(group1, group1Result)
|
||||
assertEquals(group2, group2Result)
|
||||
assertNotEquals(group1Result, group2Result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMmsGroupWithOneMember_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
|
||||
val groupMembers: List<RecipientId> = listOf(harness.self.id)
|
||||
val group: GroupId = insertMmsGroup(groupMembers)
|
||||
|
||||
val groupResult: GroupId = groupTable.getOrCreateMmsGroupForMembers(groupMembers.toSet())
|
||||
|
||||
assertEquals(group, groupResult)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTwoGroupsWithoutMembers_whenIQueryThem_thenIExpectEach() {
|
||||
val g1 = insertPushGroup(listOf())
|
||||
val g2 = insertPushGroup(listOf())
|
||||
|
||||
val gr1 = groupTable.getGroup(g1)
|
||||
val gr2 = groupTable.getGroup(g2)
|
||||
|
||||
assertEquals(g1, gr1.get().id)
|
||||
assertEquals(g2, gr2.get().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenASharedActiveGroupWithoutAThread_whenISearchForRecipientsWithGroupsInCommon_thenIExpectThatGroup() {
|
||||
val groupInCommon = insertPushGroup()
|
||||
val expected = Recipient.resolved(harness.others[0])
|
||||
|
||||
SignalDatabase.recipients.setProfileSharing(expected.id, false)
|
||||
|
||||
SignalDatabase.recipients.queryGroupMemberContacts("Buddy")!!.use {
|
||||
assertTrue(it.moveToFirst())
|
||||
assertEquals(1, it.count)
|
||||
assertEquals(expected.id.toLong(), it.requireLong(RecipientTable.ID))
|
||||
}
|
||||
|
||||
val groups = groupTable.getPushGroupsContainingMember(expected.id)
|
||||
assertEquals(1, groups.size)
|
||||
assertEquals(groups[0].id, groupInCommon)
|
||||
}
|
||||
|
||||
private fun insertThread(groupId: GroupId): Long {
|
||||
val groupRecipient = SignalDatabase.recipients.getByGroupId(groupId).get()
|
||||
return SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(groupRecipient))
|
||||
}
|
||||
|
||||
private fun insertMmsGroup(members: List<RecipientId> = listOf(harness.self.id, harness.others[0])): GroupId {
|
||||
val id = GroupId.createMms(SecureRandom())
|
||||
groupTable.create(
|
||||
id,
|
||||
null,
|
||||
members.apply {
|
||||
println("Creating a group with ${members.size} members")
|
||||
}
|
||||
)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
private fun insertPushGroup(
|
||||
members: List<DecryptedMember> = listOf(
|
||||
DecryptedMember.newBuilder()
|
||||
.setUuid(harness.self.requireServiceId().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
.build(),
|
||||
DecryptedMember.newBuilder()
|
||||
.setUuid(Recipient.resolved(harness.others[0]).requireServiceId().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
.build()
|
||||
)
|
||||
): GroupId {
|
||||
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
|
||||
val decryptedGroupState = DecryptedGroup.newBuilder()
|
||||
.addAllMembers(members)
|
||||
.setRevision(0)
|
||||
.build()
|
||||
|
||||
return groupTable.create(groupMasterKey, decryptedGroupState)
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ class MmsTableTest_stories {
|
||||
sentTimeMillis = 2,
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
storyType = StoryType.STORY_WITH_REPLIES
|
||||
),
|
||||
-1L
|
||||
).get().messageId
|
||||
@@ -160,7 +160,7 @@ class MmsTableTest_stories {
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
storyType = StoryType.STORY_WITH_REPLIES
|
||||
),
|
||||
-1L
|
||||
).get().messageId
|
||||
@@ -174,7 +174,7 @@ class MmsTableTest_stories {
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
storyType = StoryType.STORY_WITH_REPLIES
|
||||
),
|
||||
-1L
|
||||
).get().messageId
|
||||
@@ -219,7 +219,7 @@ class MmsTableTest_stories {
|
||||
sentTimeMillis = 200,
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
storyType = StoryType.STORY_WITH_REPLIES
|
||||
),
|
||||
-1L
|
||||
)
|
||||
|
||||
@@ -11,6 +11,11 @@ import org.signal.core.util.CursorUtil
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RecipientTableTest {
|
||||
@@ -159,4 +164,47 @@ class RecipientTableTest {
|
||||
assertNotEquals(0, results.size)
|
||||
assertFalse(blockedRecipient in results)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenARecipientWithPniAndAci_whenIMarkItUnregistered_thenIExpectItToBeSplit() {
|
||||
FeatureFlagsAccessor.forceValue(FeatureFlags.PHONE_NUMBER_PRIVACY, true)
|
||||
|
||||
val mainId = SignalDatabase.recipients.getAndPossiblyMerge(ACI_A, PNI_A, E164_A)
|
||||
|
||||
SignalDatabase.recipients.markUnregistered(mainId)
|
||||
|
||||
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
|
||||
|
||||
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
|
||||
val byPni: RecipientId = SignalDatabase.recipients.getByServiceId(PNI_A).get()
|
||||
|
||||
assertEquals(mainId, byAci)
|
||||
assertEquals(byE164, byPni)
|
||||
assertNotEquals(byAci, byE164)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenARecipientWithPniAndAci_whenISplitItForStorageSync_thenIExpectItToBeSplit() {
|
||||
FeatureFlagsAccessor.forceValue(FeatureFlags.PHONE_NUMBER_PRIVACY, true)
|
||||
|
||||
val mainId = SignalDatabase.recipients.getAndPossiblyMerge(ACI_A, PNI_A, E164_A)
|
||||
val mainRecord = SignalDatabase.recipients.getRecord(mainId)
|
||||
|
||||
SignalDatabase.recipients.splitForStorageSync(mainRecord.storageId!!)
|
||||
|
||||
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
|
||||
|
||||
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
|
||||
val byPni: RecipientId = SignalDatabase.recipients.getByServiceId(PNI_A).get()
|
||||
|
||||
assertEquals(mainId, byAci)
|
||||
assertEquals(byE164, byPni)
|
||||
assertNotEquals(byAci, byE164)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val ACI_A = ACI.from(UUID.fromString("aaaa0000-5a76-47fa-a98a-7e72c948a82e"))
|
||||
val PNI_A = PNI.from(UUID.fromString("aaaa1111-c960-4f6c-8385-671ad2ffb999"))
|
||||
const val E164_A = "+12222222222"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.database.Cursor
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.hamcrest.MatcherAssert
|
||||
import org.hamcrest.Matchers
|
||||
import org.junit.Assert
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.CursorUtil
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
@@ -23,6 +27,8 @@ import org.thoughtcrime.securesms.database.model.Mention
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -32,6 +38,9 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sms.IncomingEncryptedMessage
|
||||
import org.thoughtcrime.securesms.sms.IncomingTextMessage
|
||||
import org.thoughtcrime.securesms.util.Base64
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
@@ -46,6 +55,34 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
SignalStore.account().setE164(E164_SELF)
|
||||
SignalStore.account().setAci(ACI_SELF)
|
||||
SignalStore.account().setPni(PNI_SELF)
|
||||
FeatureFlagsAccessor.forceValue(FeatureFlags.PHONE_NUMBER_PRIVACY, true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allNonMergeTests() {
|
||||
test("e164-only insert") {
|
||||
val id = process(E164_A, null, null)
|
||||
expect(E164_A, null, null)
|
||||
|
||||
val record = SignalDatabase.recipients.getRecord(id)
|
||||
assertEquals(RecipientTable.RegisteredState.UNKNOWN, record.registered)
|
||||
}
|
||||
|
||||
test("pni-only insert") {
|
||||
val id = process(null, PNI_A, null)
|
||||
expect(null, PNI_A, null)
|
||||
|
||||
val record = SignalDatabase.recipients.getRecord(id)
|
||||
assertEquals(RecipientTable.RegisteredState.REGISTERED, record.registered)
|
||||
}
|
||||
|
||||
test("aci-only insert") {
|
||||
val id = process(null, null, ACI_A)
|
||||
expect(null, null, ACI_A)
|
||||
|
||||
val record = SignalDatabase.recipients.getRecord(id)
|
||||
assertEquals(RecipientTable.RegisteredState.REGISTERED, record.registered)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -115,24 +152,40 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expect(E164_A, null, ACI_B)
|
||||
}
|
||||
|
||||
test("e164 and pni matches, all provided, new aci") {
|
||||
test("e164 and pni matches, all provided, new aci, no pni session") {
|
||||
given(E164_A, PNI_A, null)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("e164 and pni matches, all provided, new aci, existing pni session") {
|
||||
given(E164_A, PNI_A, null, pniSession = true)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectSessionSwitchoverEvent(E164_A)
|
||||
}
|
||||
|
||||
test("e164 and aci matches, all provided, new pni") {
|
||||
given(E164_A, null, ACI_A)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("pni matches, all provided, new e164 and aci") {
|
||||
test("pni matches, all provided, new e164 and aci, no pni session") {
|
||||
given(null, PNI_A, null)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("pni matches, all provided, new e164 and aci, existing pni session") {
|
||||
given(null, PNI_A, null, pniSession = true)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectSessionSwitchoverEvent(E164_A)
|
||||
}
|
||||
|
||||
test("pni and aci matches, all provided, new e164") {
|
||||
given(null, PNI_A, ACI_A)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
@@ -172,20 +225,42 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expect(E164_A, PNI_A, null)
|
||||
}
|
||||
|
||||
test("e164 and pni matches, all provided, no existing session") {
|
||||
test("e164 matches, e164 and pni provided, pni changes, existing pni session") {
|
||||
given(E164_A, PNI_B, null, pniSession = true)
|
||||
process(E164_A, PNI_A, null)
|
||||
expect(E164_A, PNI_A, null)
|
||||
|
||||
expectSessionSwitchoverEvent(E164_A)
|
||||
}
|
||||
|
||||
test("e164 and pni matches, all provided, no pni session") {
|
||||
given(E164_A, PNI_A, null)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("pni matches, all provided, no existing session") {
|
||||
test("e164 and pni matches, all provided, existing pni session") {
|
||||
given(E164_A, PNI_A, null, pniSession = true)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectSessionSwitchoverEvent(E164_A)
|
||||
}
|
||||
|
||||
test("pni matches, all provided, no pni session") {
|
||||
given(null, PNI_A, null)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
// This test, I could go either way. We decide to change the E164 on the existing row rather than create a new one.
|
||||
// But it's an "unstable E164->PNI mapping" case, which we don't expect, so as long as there's a user-visible impact that should be fine.
|
||||
test("pni matches, all provided, existing pni session") {
|
||||
given(null, PNI_A, null, pniSession = true)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectSessionSwitchoverEvent(E164_A)
|
||||
}
|
||||
|
||||
test("pni matches, no existing pni session, changes number") {
|
||||
given(E164_B, PNI_A, null)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
@@ -194,8 +269,15 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
|
||||
// This test, I could go either way. We decide to change the E164 on the existing row rather than create a new one.
|
||||
// But it's an "unstable E164->PNI mapping" case, which we don't expect, so as long as there's a user-visible impact that should be fine.
|
||||
test("pni matches, existing pni session, changes number") {
|
||||
given(E164_B, PNI_A, null, pniSession = true)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectSessionSwitchoverEvent(E164_B)
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
|
||||
test("pni and aci matches, change number") {
|
||||
given(E164_B, PNI_A, ACI_A)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
@@ -220,7 +302,7 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
|
||||
test("steal, e164+pni & e164+pni, no aci provided, no sessions") {
|
||||
test("steal, e164+pni & e164+pni, no aci provided, no pni session") {
|
||||
given(E164_A, PNI_B, null)
|
||||
given(E164_B, PNI_A, null)
|
||||
|
||||
@@ -230,6 +312,18 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expect(E164_B, null, null)
|
||||
}
|
||||
|
||||
test("steal, e164+pni & e164+pni, no aci provided, existing pni session") {
|
||||
given(E164_A, PNI_B, null, pniSession = true)
|
||||
given(E164_B, PNI_A, null) // TODO How to handle if this user had a session? They just end up losing the PNI, meaning it would become unregistered, but it could register again later with a different PNI?
|
||||
|
||||
process(E164_A, PNI_A, null)
|
||||
|
||||
expect(E164_A, PNI_A, null)
|
||||
expect(E164_B, null, null)
|
||||
|
||||
expectSessionSwitchoverEvent(E164_A)
|
||||
}
|
||||
|
||||
test("steal, e164+pni & aci, e164 record has separate e164") {
|
||||
given(E164_B, PNI_A, null)
|
||||
given(null, null, ACI_A)
|
||||
@@ -252,6 +346,19 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
|
||||
test("steal, e164 & pni+e164, no aci provided") {
|
||||
val id1 = given(E164_A, null, null)
|
||||
val id2 = given(E164_B, PNI_A, null, pniSession = true)
|
||||
|
||||
process(E164_A, PNI_A, null)
|
||||
|
||||
expect(E164_A, PNI_A, null)
|
||||
expect(E164_B, null, null)
|
||||
|
||||
expectSessionSwitchoverEvent(id1, E164_A)
|
||||
expectSessionSwitchoverEvent(id2, E164_B)
|
||||
}
|
||||
|
||||
test("merge, e164 & pni & aci, all provided") {
|
||||
given(E164_A, null, null)
|
||||
given(null, PNI_A, null)
|
||||
@@ -262,6 +369,34 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expectDeleted()
|
||||
expectDeleted()
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectThreadMergeEvent(E164_A)
|
||||
}
|
||||
|
||||
test("merge, e164 & pni & aci, all provided, no threads") {
|
||||
given(E164_A, null, null, createThread = false)
|
||||
given(null, PNI_A, null, createThread = false)
|
||||
given(null, null, ACI_A, createThread = false)
|
||||
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectDeleted()
|
||||
expectDeleted()
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("merge, e164 & pni & aci, all provided, pni session no threads") {
|
||||
given(E164_A, null, null, createThread = false)
|
||||
given(null, PNI_A, null, createThread = true, pniSession = true)
|
||||
given(null, null, ACI_A, createThread = false)
|
||||
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectDeleted()
|
||||
expectDeleted()
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectSessionSwitchoverEvent(E164_A)
|
||||
}
|
||||
|
||||
test("merge, e164 & pni, no aci provided") {
|
||||
@@ -272,9 +407,11 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
|
||||
expect(E164_A, PNI_A, null)
|
||||
expectDeleted()
|
||||
|
||||
expectThreadMergeEvent("")
|
||||
}
|
||||
|
||||
test("merge, e164 & pni, aci provided but no aci record") {
|
||||
test("merge, e164 & pni, aci provided, no pni session") {
|
||||
given(E164_A, null, null)
|
||||
given(null, PNI_A, null)
|
||||
|
||||
@@ -282,16 +419,44 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
expectDeleted()
|
||||
|
||||
expectThreadMergeEvent("")
|
||||
}
|
||||
|
||||
test("merge, e164 & pni+e164, no aci provided") {
|
||||
test("merge, e164 & pni, aci provided, no pni session") {
|
||||
given(E164_A, null, null)
|
||||
given(E164_B, PNI_A, null)
|
||||
given(null, PNI_A, null)
|
||||
|
||||
process(E164_A, PNI_A, null)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expect(E164_A, PNI_A, null)
|
||||
expect(E164_B, null, null)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
expectDeleted()
|
||||
|
||||
expectThreadMergeEvent("")
|
||||
}
|
||||
|
||||
test("merge, e164 & pni, aci provided, existing pni session, thread merge shadows") {
|
||||
given(E164_A, null, null)
|
||||
given(null, PNI_A, null, pniSession = true)
|
||||
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
expectDeleted()
|
||||
|
||||
expectThreadMergeEvent("")
|
||||
}
|
||||
|
||||
test("merge, e164 & pni, aci provided, existing pni session, no thread merge") {
|
||||
given(E164_A, null, null, createThread = true)
|
||||
given(null, PNI_A, null, createThread = false, pniSession = true)
|
||||
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
expectDeleted()
|
||||
|
||||
expectSessionSwitchoverEvent(E164_A)
|
||||
}
|
||||
|
||||
test("merge, e164+pni & pni, no aci provided") {
|
||||
@@ -302,9 +467,11 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
|
||||
expect(E164_A, PNI_A, null)
|
||||
expectDeleted()
|
||||
|
||||
expectThreadMergeEvent("")
|
||||
}
|
||||
|
||||
test("merge, e164+pni & aci") {
|
||||
test("merge, e164+pni & aci, no pni session") {
|
||||
given(E164_A, PNI_A, null)
|
||||
given(null, null, ACI_A)
|
||||
|
||||
@@ -312,6 +479,54 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
|
||||
expectDeleted()
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectThreadMergeEvent(E164_A)
|
||||
}
|
||||
|
||||
test("merge, e164+pni & aci, pni session, thread merge shadows") {
|
||||
given(E164_A, PNI_A, null, pniSession = true)
|
||||
given(null, null, ACI_A)
|
||||
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectDeleted()
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectThreadMergeEvent(E164_A)
|
||||
}
|
||||
|
||||
test("merge, e164+pni & aci, pni session, no thread merge") {
|
||||
given(E164_A, PNI_A, null, createThread = true, pniSession = true)
|
||||
given(null, null, ACI_A, createThread = false)
|
||||
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectDeleted()
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectSessionSwitchoverEvent(E164_A)
|
||||
}
|
||||
|
||||
test("merge, e164+pni & aci, pni session, no thread merge, pni verified") {
|
||||
given(E164_A, PNI_A, null, createThread = true, pniSession = true)
|
||||
given(null, null, ACI_A, createThread = false)
|
||||
|
||||
process(E164_A, PNI_A, ACI_A, pniVerified = true)
|
||||
|
||||
expectDeleted()
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("merge, e164+pni & aci, pni session, pni verified") {
|
||||
given(E164_A, PNI_A, null, pniSession = true)
|
||||
given(null, null, ACI_A)
|
||||
|
||||
process(E164_A, PNI_A, ACI_A, pniVerified = true)
|
||||
|
||||
expectDeleted()
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectThreadMergeEvent(E164_A)
|
||||
}
|
||||
|
||||
test("merge, e164+pni & e164+pni+aci, change number") {
|
||||
@@ -324,6 +539,7 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectChangeNumberEvent()
|
||||
expectThreadMergeEvent(E164_A)
|
||||
}
|
||||
|
||||
test("merge, e164+pni & e164+aci, change number") {
|
||||
@@ -336,6 +552,7 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectChangeNumberEvent()
|
||||
expectThreadMergeEvent(E164_A)
|
||||
}
|
||||
|
||||
test("merge, e164 & aci") {
|
||||
@@ -346,6 +563,8 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
|
||||
expectDeleted()
|
||||
expect(E164_A, null, ACI_A)
|
||||
|
||||
expectThreadMergeEvent(E164_A)
|
||||
}
|
||||
|
||||
test("merge, e164 & e164+aci, change number") {
|
||||
@@ -358,6 +577,8 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expect(E164_A, null, ACI_A)
|
||||
|
||||
expectChangeNumberEvent()
|
||||
|
||||
expectThreadMergeEvent(E164_A)
|
||||
}
|
||||
|
||||
test("local user, local e164 and aci provided, changeSelf=false, leave e164 alone") {
|
||||
@@ -539,11 +760,11 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
}
|
||||
|
||||
private fun getMention(messageId: Long): MentionModel {
|
||||
SignalDatabase.rawDatabase.rawQuery("SELECT * FROM ${MentionTable.TABLE_NAME} WHERE ${MentionTable.MESSAGE_ID} = $messageId").use { cursor ->
|
||||
return SignalDatabase.rawDatabase.rawQuery("SELECT * FROM ${MentionTable.TABLE_NAME} WHERE ${MentionTable.MESSAGE_ID} = $messageId").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
return MentionModel(
|
||||
recipientId = RecipientId.from(CursorUtil.requireLong(cursor, MentionTable.RECIPIENT_ID)),
|
||||
threadId = CursorUtil.requireLong(cursor, MentionTable.THREAD_ID)
|
||||
MentionModel(
|
||||
recipientId = RecipientId.from(cursor.requireLong(MentionTable.RECIPIENT_ID)),
|
||||
threadId = cursor.requireLong(MentionTable.THREAD_ID)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -577,6 +798,14 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
if (!test.changeNumberExpected) {
|
||||
test.expectNoChangeNumberEvent()
|
||||
}
|
||||
|
||||
if (!test.threadMergeExpected) {
|
||||
test.expectNoThreadMergeEvent()
|
||||
}
|
||||
|
||||
if (!test.sessionSwitchoverExpected) {
|
||||
test.expectNoSessionSwitchoverEvent()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e.javaClass != exception) {
|
||||
val error = java.lang.AssertionError("[$name] ${e.message}")
|
||||
@@ -594,6 +823,8 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
private lateinit var outputRecipientId: RecipientId
|
||||
|
||||
var changeNumberExpected = false
|
||||
var threadMergeExpected = false
|
||||
var sessionSwitchoverExpected = false
|
||||
|
||||
init {
|
||||
// Need to delete these first to prevent foreign key crash
|
||||
@@ -616,9 +847,8 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
pni: PNI?,
|
||||
aci: ACI?,
|
||||
createThread: Boolean = true,
|
||||
sms: List<String> = emptyList(),
|
||||
mms: List<String> = emptyList()
|
||||
) {
|
||||
pniSession: Boolean = false
|
||||
): RecipientId {
|
||||
val id = insert(e164, pni, aci)
|
||||
generatedIds += id
|
||||
if (createThread) {
|
||||
@@ -626,11 +856,22 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(id))
|
||||
SignalDatabase.messages.insertMessageInbox(IncomingEncryptedMessage(IncomingTextMessage(id, 1, 0, 0, 0, "", Optional.empty(), 0, false, ""), ""))
|
||||
}
|
||||
|
||||
if (pniSession) {
|
||||
if (pni == null) {
|
||||
throw IllegalArgumentException("pniSession = true but pni is null!")
|
||||
}
|
||||
|
||||
SignalDatabase.sessions.store(pni, SignalProtocolAddress(pni.toString(), 1), SessionRecord())
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
fun process(e164: String?, pni: PNI?, aci: ACI?, changeSelf: Boolean = false) {
|
||||
outputRecipientId = SignalDatabase.recipients.getAndPossiblyMerge(serviceId = aci ?: pni, pni = pni, e164 = e164, pniVerified = false, changeSelf = changeSelf)
|
||||
fun process(e164: String?, pni: PNI?, aci: ACI?, changeSelf: Boolean = false, pniVerified: Boolean = false): RecipientId {
|
||||
outputRecipientId = SignalDatabase.recipients.getAndPossiblyMerge(serviceId = aci ?: pni, pni = pni, e164 = e164, pniVerified = pniVerified, changeSelf = changeSelf)
|
||||
generatedIds += outputRecipientId
|
||||
return outputRecipientId
|
||||
}
|
||||
|
||||
fun expect(e164: String?, pni: PNI?, aci: ACI?) {
|
||||
@@ -676,6 +917,32 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
changeNumberExpected = false
|
||||
}
|
||||
|
||||
fun expectSessionSwitchoverEvent(e164: String) {
|
||||
expectSessionSwitchoverEvent(outputRecipientId, e164)
|
||||
}
|
||||
|
||||
fun expectSessionSwitchoverEvent(recipientId: RecipientId, e164: String) {
|
||||
val event: SessionSwitchoverEvent? = getLatestSessionSwitchoverEvent(recipientId)
|
||||
assertNotNull(event)
|
||||
assertEquals(e164, event!!.e164)
|
||||
sessionSwitchoverExpected = true
|
||||
}
|
||||
|
||||
fun expectNoSessionSwitchoverEvent() {
|
||||
assertNull(getLatestSessionSwitchoverEvent(outputRecipientId))
|
||||
}
|
||||
|
||||
fun expectThreadMergeEvent(previousE164: String) {
|
||||
val event: ThreadMergeEvent? = getLatestThreadMergeEvent(outputRecipientId)
|
||||
assertNotNull(event)
|
||||
assertEquals(previousE164, event!!.previousE164)
|
||||
threadMergeExpected = true
|
||||
}
|
||||
|
||||
fun expectNoThreadMergeEvent() {
|
||||
assertNull(getLatestThreadMergeEvent(outputRecipientId))
|
||||
}
|
||||
|
||||
private fun insert(e164: String?, pni: PNI?, aci: ACI?): RecipientId {
|
||||
val serviceIdString: String? = (aci ?: pni)?.toString()
|
||||
val pniString: String? = pni?.toString()
|
||||
@@ -746,6 +1013,42 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLatestThreadMergeEvent(recipientId: RecipientId): ThreadMergeEvent? {
|
||||
return SignalDatabase.rawDatabase
|
||||
.select(MessageTable.BODY)
|
||||
.from(MessageTable.TABLE_NAME)
|
||||
.where("${MessageTable.RECIPIENT_ID} = ? AND ${MessageTable.TYPE} = ?", recipientId, MessageTypes.THREAD_MERGE_TYPE)
|
||||
.orderBy("${MessageTable.DATE_RECEIVED} DESC")
|
||||
.limit(1)
|
||||
.run()
|
||||
.use { cursor: Cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val bytes = Base64.decode(cursor.requireNonNullString(MessageTable.BODY))
|
||||
ThreadMergeEvent.parseFrom(bytes)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLatestSessionSwitchoverEvent(recipientId: RecipientId): SessionSwitchoverEvent? {
|
||||
return SignalDatabase.rawDatabase
|
||||
.select(MessageTable.BODY)
|
||||
.from(MessageTable.TABLE_NAME)
|
||||
.where("${MessageTable.RECIPIENT_ID} = ? AND ${MessageTable.TYPE} = ?", recipientId, MessageTypes.SESSION_SWITCHOVER_TYPE)
|
||||
.orderBy("${MessageTable.DATE_RECEIVED} DESC")
|
||||
.limit(1)
|
||||
.run()
|
||||
.use { cursor: Cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val bytes = Base64.decode(cursor.requireNonNullString(MessageTable.BODY))
|
||||
SessionSwitchoverEvent.parseFrom(bytes)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val ACI_A = ACI.from(UUID.fromString("aaaa0000-5a76-47fa-a98a-7e72c948a82e"))
|
||||
val ACI_B = ACI.from(UUID.fromString("bbbb0000-0b60-4a68-9cd9-ed2f8453f9ed"))
|
||||
|
||||
@@ -1,842 +0,0 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.lang.AssertionError
|
||||
import java.lang.IllegalStateException
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RecipientTableTest_processPnpTupleToChangeSet {
|
||||
|
||||
@Rule
|
||||
@JvmField
|
||||
val databaseRule = SignalDatabaseRule(deleteAllThreadsOnEachRun = false)
|
||||
|
||||
private lateinit var db: RecipientTable
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
db = SignalDatabase.recipients
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noMatch_e164Only() {
|
||||
val changeSet = db.processPnpTupleToChangeSet(E164_A, null, null, pniVerified = false)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpInsert(E164_A, null, null)
|
||||
),
|
||||
changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noMatch_e164AndPni() {
|
||||
val changeSet = db.processPnpTupleToChangeSet(E164_A, PNI_A, null, pniVerified = false)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpInsert(E164_A, PNI_A, null)
|
||||
),
|
||||
changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noMatch_aciOnly() {
|
||||
val changeSet = db.processPnpTupleToChangeSet(null, null, ACI_A, pniVerified = false)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpInsert(null, null, ACI_A)
|
||||
),
|
||||
changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalStateException::class)
|
||||
fun noMatch_noData() {
|
||||
db.processPnpTupleToChangeSet(null, null, null, pniVerified = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noMatch_allFields() {
|
||||
val changeSet = db.processPnpTupleToChangeSet(E164_A, PNI_A, ACI_A, pniVerified = false)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpInsert(E164_A, PNI_A, ACI_A)
|
||||
),
|
||||
changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullMatch() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, PNI_A, ACI_A),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyE164Matches() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, null, null),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetPni(result.id, PNI_A),
|
||||
PnpOperation.SetAci(result.id, ACI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyE164Matches_pniChanges_noAciProvided_existingPniSession() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, PNI_B, null, pniSession = true),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetPni(result.id, PNI_A),
|
||||
PnpOperation.SessionSwitchoverInsert(result.id)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyE164Matches_pniChanges_noAciProvided_noPniSession() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, PNI_B, null),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetPni(result.id, PNI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun e164AndPniMatches_noExistingSession() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, PNI_A, null),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetAci(result.id, ACI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun e164AndPniMatches_existingPniSession() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, PNI_A, null, pniSession = true),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetAci(result.id, ACI_A),
|
||||
PnpOperation.SessionSwitchoverInsert(result.id)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun e164AndAciMatches() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, null, ACI_A),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetPni(result.id, PNI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyPniMatches_noExistingSession() {
|
||||
val result = applyAndAssert(
|
||||
Input(null, PNI_A, null),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
PnpOperation.SetAci(result.id, ACI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyPniMatches_existingPniSession() {
|
||||
val result = applyAndAssert(
|
||||
Input(null, PNI_A, null, pniSession = true),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
PnpOperation.SetAci(result.id, ACI_A),
|
||||
PnpOperation.SessionSwitchoverInsert(result.id)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyPniMatches_existingPniSession_changeNumber() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_B, PNI_A, null, pniSession = true),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
PnpOperation.SetAci(result.id, ACI_A),
|
||||
PnpOperation.ChangeNumberInsert(
|
||||
recipientId = result.id,
|
||||
oldE164 = E164_B,
|
||||
newE164 = E164_A
|
||||
),
|
||||
PnpOperation.SessionSwitchoverInsert(result.id)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pniAndAciMatches() {
|
||||
val result = applyAndAssert(
|
||||
Input(null, PNI_A, ACI_A),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pniAndAciMatches_changeNumber() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_B, PNI_A, ACI_A),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
PnpOperation.ChangeNumberInsert(
|
||||
recipientId = result.id,
|
||||
oldE164 = E164_B,
|
||||
newE164 = E164_A
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyAciMatches() {
|
||||
val result = applyAndAssert(
|
||||
Input(null, null, ACI_A),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
PnpOperation.SetPni(result.id, PNI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyAciMatches_changeNumber() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_B, null, ACI_A),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
PnpOperation.SetPni(result.id, PNI_A),
|
||||
PnpOperation.ChangeNumberInsert(
|
||||
recipientId = result.id,
|
||||
oldE164 = E164_B,
|
||||
newE164 = E164_A
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164Only_pniOnly_aciOnly() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, null, null),
|
||||
Input(null, PNI_A, null),
|
||||
Input(null, null, ACI_A)
|
||||
),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.thirdId),
|
||||
operations = listOf(
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.firstId,
|
||||
secondaryId = result.secondId
|
||||
),
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.thirdId,
|
||||
secondaryId = result.firstId
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164Only_pniOnly_noAciProvided() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, null, null),
|
||||
Input(null, PNI_A, null),
|
||||
),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.firstId),
|
||||
operations = listOf(
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.firstId,
|
||||
secondaryId = result.secondId
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164Only_pniOnly_aciProvidedButNoAciRecord() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, null, null),
|
||||
Input(null, PNI_A, null),
|
||||
),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.firstId),
|
||||
operations = listOf(
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.firstId,
|
||||
secondaryId = result.secondId
|
||||
),
|
||||
PnpOperation.SetAci(
|
||||
recipientId = result.firstId,
|
||||
aci = ACI_A
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164Only_pniAndE164_noAciProvided() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, null, null),
|
||||
Input(E164_B, PNI_A, null),
|
||||
),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.firstId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.secondId),
|
||||
PnpOperation.SetPni(
|
||||
recipientId = result.firstId,
|
||||
pni = PNI_A
|
||||
),
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_pniOnly_noAciProvided() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, PNI_B, null),
|
||||
Input(null, PNI_A, null),
|
||||
),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.firstId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.firstId),
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.firstId,
|
||||
secondaryId = result.secondId
|
||||
),
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_e164AndPni_noAciProvided_noSessions() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, PNI_B, null),
|
||||
Input(E164_B, PNI_A, null),
|
||||
),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.firstId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.secondId),
|
||||
PnpOperation.SetPni(result.firstId, PNI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_e164AndPni_noAciProvided_sessionsExist() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, PNI_B, null, pniSession = true),
|
||||
Input(E164_B, PNI_A, null, pniSession = true),
|
||||
),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.firstId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.secondId),
|
||||
PnpOperation.SetPni(result.firstId, PNI_A),
|
||||
PnpOperation.SessionSwitchoverInsert(result.secondId),
|
||||
PnpOperation.SessionSwitchoverInsert(result.firstId)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_aciOnly() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, PNI_A, null),
|
||||
Input(null, null, ACI_A),
|
||||
),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.secondId),
|
||||
operations = listOf(
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.secondId,
|
||||
secondaryId = result.firstId
|
||||
),
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_aciOnly_e164RecordHasSeparateE164() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_B, PNI_A, null),
|
||||
Input(null, null, ACI_A),
|
||||
),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.secondId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.firstId),
|
||||
PnpOperation.SetPni(
|
||||
recipientId = result.secondId,
|
||||
pni = PNI_A,
|
||||
),
|
||||
PnpOperation.SetE164(
|
||||
recipientId = result.secondId,
|
||||
e164 = E164_A,
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_aciOnly_e164RecordHasSeparateE164_changeNumber() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_B, PNI_A, null),
|
||||
Input(E164_C, null, ACI_A),
|
||||
),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.secondId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.firstId),
|
||||
PnpOperation.SetPni(
|
||||
recipientId = result.secondId,
|
||||
pni = PNI_A,
|
||||
),
|
||||
PnpOperation.SetE164(
|
||||
recipientId = result.secondId,
|
||||
e164 = E164_A,
|
||||
),
|
||||
PnpOperation.ChangeNumberInsert(
|
||||
recipientId = result.secondId,
|
||||
oldE164 = E164_C,
|
||||
newE164 = E164_A
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_e164AndPniAndAci_changeNumber() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, PNI_A, null),
|
||||
Input(E164_B, PNI_B, ACI_A),
|
||||
),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.secondId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.secondId),
|
||||
PnpOperation.RemoveE164(result.secondId),
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.secondId,
|
||||
secondaryId = result.firstId
|
||||
),
|
||||
PnpOperation.ChangeNumberInsert(
|
||||
recipientId = result.secondId,
|
||||
oldE164 = E164_B,
|
||||
newE164 = E164_A
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_e164Aci_changeNumber() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, PNI_A, null),
|
||||
Input(E164_B, null, ACI_A),
|
||||
),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.secondId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemoveE164(result.secondId),
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.secondId,
|
||||
secondaryId = result.firstId
|
||||
),
|
||||
PnpOperation.ChangeNumberInsert(
|
||||
recipientId = result.secondId,
|
||||
oldE164 = E164_B,
|
||||
newE164 = E164_A
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
private fun insert(e164: String?, pni: PNI?, aci: ACI?): RecipientId {
|
||||
val id: Long = SignalDatabase.rawDatabase.insert(
|
||||
RecipientTable.TABLE_NAME,
|
||||
null,
|
||||
contentValuesOf(
|
||||
RecipientTable.PHONE to e164,
|
||||
RecipientTable.SERVICE_ID to (aci ?: pni)?.toString(),
|
||||
RecipientTable.PNI_COLUMN to pni?.toString(),
|
||||
RecipientTable.REGISTERED to RecipientTable.RegisteredState.REGISTERED.id
|
||||
)
|
||||
)
|
||||
|
||||
return RecipientId.from(id)
|
||||
}
|
||||
|
||||
private fun insertMockSessionFor(account: ServiceId, address: ServiceId) {
|
||||
SignalDatabase.rawDatabase.insert(
|
||||
SessionTable.TABLE_NAME, null,
|
||||
contentValuesOf(
|
||||
SessionTable.ACCOUNT_ID to account.toString(),
|
||||
SessionTable.ADDRESS to address.toString(),
|
||||
SessionTable.DEVICE to 1,
|
||||
SessionTable.RECORD to Util.getSecretBytes(32)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
data class Input(val e164: String?, val pni: PNI?, val aci: ACI?, val pniSession: Boolean = false, val aciSession: Boolean = false)
|
||||
data class Update(val e164: String?, val pni: PNI?, val aci: ACI?, val pniVerified: Boolean = false)
|
||||
data class Output(val e164: String?, val pni: PNI?, val aci: ACI?)
|
||||
data class PnpMatchResult(val ids: List<RecipientId>, val changeSet: PnpChangeSet) {
|
||||
val id
|
||||
get() = if (ids.size == 1) {
|
||||
ids[0]
|
||||
} else {
|
||||
throw IllegalStateException("There are multiple IDs, but you assumed 1!")
|
||||
}
|
||||
|
||||
val firstId
|
||||
get() = ids[0]
|
||||
|
||||
val secondId
|
||||
get() = ids[1]
|
||||
|
||||
val thirdId
|
||||
get() = ids[2]
|
||||
}
|
||||
|
||||
private fun applyAndAssert(input: Input, update: Update, output: Output): PnpMatchResult {
|
||||
return applyAndAssert(listOf(input), update, output)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method that will call insert your recipients, call [RecipientTable.processPnpTupleToChangeSet] with your params,
|
||||
* and then verify your output matches what you expect.
|
||||
*
|
||||
* It results the inserted ID's and changeset for additional verification.
|
||||
*
|
||||
* But basically this is here to make the tests more readable. It gives you a clear list of:
|
||||
* - input
|
||||
* - update
|
||||
* - output
|
||||
*
|
||||
* that you can spot check easily.
|
||||
*
|
||||
* Important: The output will only include records that contain fields from the input. That means
|
||||
* for:
|
||||
*
|
||||
* Input: E164_B, PNI_A, null
|
||||
* Update: E164_A, PNI_A, null
|
||||
*
|
||||
* You will get:
|
||||
* Output: E164_A, PNI_A, null
|
||||
*
|
||||
* Even though there was an update that will also result in the row (E164_B, null, null)
|
||||
*/
|
||||
private fun applyAndAssert(input: List<Input>, update: Update, output: Output): PnpMatchResult {
|
||||
val ids = input.map { insert(it.e164, it.pni, it.aci) }
|
||||
|
||||
input
|
||||
.filter { it.pniSession }
|
||||
.forEach { insertMockSessionFor(databaseRule.localAci, it.pni!!) }
|
||||
|
||||
input
|
||||
.filter { it.aciSession }
|
||||
.forEach { insertMockSessionFor(databaseRule.localAci, it.aci!!) }
|
||||
|
||||
val byE164 = update.e164?.let { db.getByE164(it).orElse(null) }
|
||||
val byPniSid = update.pni?.let { db.getByServiceId(it).orElse(null) }
|
||||
val byAciSid = update.aci?.let { db.getByServiceId(it).orElse(null) }
|
||||
|
||||
val data = PnpDataSet(
|
||||
e164 = update.e164,
|
||||
pni = update.pni,
|
||||
aci = update.aci,
|
||||
byE164 = byE164,
|
||||
byPniSid = byPniSid,
|
||||
byPniOnly = update.pni?.let { db.getByPni(it).orElse(null) },
|
||||
byAciSid = byAciSid,
|
||||
e164Record = byE164?.let { db.getRecord(it) },
|
||||
pniSidRecord = byPniSid?.let { db.getRecord(it) },
|
||||
aciSidRecord = byAciSid?.let { db.getRecord(it) }
|
||||
)
|
||||
val changeSet = db.processPnpTupleToChangeSet(update.e164, update.pni, update.aci, pniVerified = update.pniVerified)
|
||||
|
||||
val finalData = data.perform(changeSet.operations)
|
||||
|
||||
val finalRecords = setOfNotNull(finalData.e164Record, finalData.pniSidRecord, finalData.aciSidRecord)
|
||||
assertEquals("There's still multiple records in the resulting record set! $finalRecords", 1, finalRecords.size)
|
||||
|
||||
finalRecords.firstOrNull { record -> record.e164 == output.e164 && record.pni == output.pni && record.serviceId == (output.aci ?: output.pni) }
|
||||
?: throw AssertionError("Expected output was not found in the result set! Expected: $output")
|
||||
|
||||
return PnpMatchResult(
|
||||
ids = ids,
|
||||
changeSet = changeSet
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val ACI_A = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
|
||||
val ACI_B = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
|
||||
|
||||
val PNI_A = PNI.from(UUID.fromString("154b8d92-c960-4f6c-8385-671ad2ffb999"))
|
||||
val PNI_B = PNI.from(UUID.fromString("ba92b1fb-cd55-40bf-adda-c35a85375533"))
|
||||
|
||||
const val E164_A = "+12221234567"
|
||||
const val E164_B = "+13331234567"
|
||||
const val E164_C = "+14441234567"
|
||||
}
|
||||
}
|
||||
@@ -65,17 +65,17 @@ class StorySendTableTest {
|
||||
|
||||
messageId1 = MmsHelper.insert(
|
||||
recipient = distributionListRecipient1,
|
||||
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||
storyType = StoryType.STORY_WITHOUT_REPLIES
|
||||
)
|
||||
|
||||
messageId2 = MmsHelper.insert(
|
||||
recipient = distributionListRecipient2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
storyType = StoryType.STORY_WITH_REPLIES
|
||||
)
|
||||
|
||||
messageId3 = MmsHelper.insert(
|
||||
recipient = distributionListRecipient3,
|
||||
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||
storyType = StoryType.STORY_WITHOUT_REPLIES
|
||||
)
|
||||
|
||||
recipients6to15 = recipients1to10.takeLast(5) + recipients11to20.take(5)
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.dependencies
|
||||
|
||||
import android.app.Application
|
||||
import okhttp3.ConnectionSpec
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
@@ -14,15 +15,16 @@ import org.thoughtcrime.securesms.KbsEnclave
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
|
||||
import org.thoughtcrime.securesms.push.SignalServiceTrustStore
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
|
||||
import org.thoughtcrime.securesms.testing.Get
|
||||
import org.thoughtcrime.securesms.testing.Verb
|
||||
import org.thoughtcrime.securesms.testing.runSync
|
||||
import org.thoughtcrime.securesms.testing.success
|
||||
import org.thoughtcrime.securesms.util.Base64
|
||||
import org.whispersystems.signalservice.api.KeyBackupService
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager
|
||||
import org.whispersystems.signalservice.api.push.TrustStore
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalContactDiscoveryUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalKeyBackupServiceUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl
|
||||
@@ -48,6 +50,12 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
|
||||
runSync {
|
||||
webServer = MockWebServer()
|
||||
baseUrl = webServer.url("").toString()
|
||||
|
||||
addMockWebRequestHandlers(
|
||||
Get("/v1/websocket/") {
|
||||
MockResponse().success().withWebSocketUpgrade(object : WebSocketListener() {})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
webServer.setDispatcher(object : Dispatcher() {
|
||||
@@ -66,15 +74,13 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
|
||||
0 to arrayOf(SignalCdnUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
2 to arrayOf(SignalCdnUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT))
|
||||
),
|
||||
arrayOf(SignalContactDiscoveryUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
arrayOf(SignalKeyBackupServiceUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
arrayOf(SignalStorageUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
arrayOf(SignalCdsiUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
emptyList(),
|
||||
Optional.of(SignalServiceNetworkAccess.DNS),
|
||||
Optional.empty(),
|
||||
Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS),
|
||||
true
|
||||
Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS)
|
||||
)
|
||||
|
||||
serviceNetworkAccessMock = mock {
|
||||
|
||||
@@ -69,7 +69,7 @@ class PreKeysSyncJobTest {
|
||||
Put("/v2/keys/signed?identity=pni") { r ->
|
||||
pniSignedPreKey = r.parsedRequestBody()
|
||||
MockResponse().success()
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// WHEN
|
||||
@@ -107,7 +107,7 @@ class PreKeysSyncJobTest {
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v2/keys?identity=aci") { MockResponse().success(PreKeyStatus(100)) },
|
||||
Get("/v2/keys?identity=pni") { MockResponse().success(PreKeyStatus(100)) },
|
||||
Get("/v2/keys?identity=pni") { MockResponse().success(PreKeyStatus(100)) }
|
||||
)
|
||||
|
||||
// WHEN
|
||||
@@ -134,7 +134,7 @@ class PreKeysSyncJobTest {
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v2/keys?identity=aci") { MockResponse().success(PreKeyStatus(100)) },
|
||||
Put("/v2/keys/signed?identity=pni") { MockResponse().success() },
|
||||
Put("/v2/keys/signed?identity=pni") { MockResponse().success() }
|
||||
)
|
||||
|
||||
// WHEN
|
||||
@@ -173,7 +173,7 @@ class PreKeysSyncJobTest {
|
||||
Put("/v2/keys/?identity=pni") { r ->
|
||||
pniPreKeyStateRequest = r.parsedRequestBody()
|
||||
MockResponse().success()
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// WHEN
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.Get
|
||||
import org.thoughtcrime.securesms.testing.Put
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.failure
|
||||
import org.thoughtcrime.securesms.testing.success
|
||||
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse
|
||||
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
|
||||
import org.whispersystems.util.Base64UrlSafe
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
InstrumentationApplicationDependencyProvider.clearHandlers()
|
||||
SignalStore.phoneNumberPrivacy().clearUsernameOutOfSync()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoLocalUsername_whenICheckUsernameIsInSync_thenIExpectNoFailures() {
|
||||
// WHEN
|
||||
RefreshOwnProfileJob.checkUsernameIsInSync()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenLocalUsernameDoesNotMatchServerUsername_whenICheckUsernameIsInSync_thenIExpectRetry() {
|
||||
// GIVEN
|
||||
var didReserve = false
|
||||
var didConfirm = false
|
||||
val username = "hello.32"
|
||||
val serverUsername = "hello.3232"
|
||||
SignalDatabase.recipients.setUsername(harness.self.id, username)
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/accounts/whoami") { r ->
|
||||
MockResponse().success(
|
||||
WhoAmIResponse().apply {
|
||||
usernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(serverUsername))
|
||||
}
|
||||
)
|
||||
},
|
||||
Put("/v1/accounts/username_hash/reserve") { r ->
|
||||
didReserve = true
|
||||
MockResponse().success(ReserveUsernameResponse(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))))
|
||||
},
|
||||
Put("/v1/accounts/username_hash/confirm") { r ->
|
||||
didConfirm = true
|
||||
MockResponse().success()
|
||||
}
|
||||
)
|
||||
|
||||
// WHEN
|
||||
RefreshOwnProfileJob.checkUsernameIsInSync()
|
||||
|
||||
// THEN
|
||||
assertTrue(didReserve)
|
||||
assertTrue(didConfirm)
|
||||
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenLocalAndNoServer_whenICheckUsernameIsInSync_thenIExpectRetry() {
|
||||
// GIVEN
|
||||
var didReserve = false
|
||||
var didConfirm = false
|
||||
val username = "hello.32"
|
||||
SignalDatabase.recipients.setUsername(harness.self.id, username)
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/accounts/whoami") { r ->
|
||||
MockResponse().success(WhoAmIResponse())
|
||||
},
|
||||
Put("/v1/accounts/username_hash/reserve") { r ->
|
||||
didReserve = true
|
||||
MockResponse().success(ReserveUsernameResponse(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))))
|
||||
},
|
||||
Put("/v1/accounts/username_hash/confirm") { r ->
|
||||
didConfirm = true
|
||||
MockResponse().success()
|
||||
}
|
||||
)
|
||||
|
||||
// WHEN
|
||||
RefreshOwnProfileJob.checkUsernameIsInSync()
|
||||
|
||||
// THEN
|
||||
assertTrue(didReserve)
|
||||
assertTrue(didConfirm)
|
||||
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenLocalAndServerMatch_whenICheckUsernameIsInSync_thenIExpectNoRetry() {
|
||||
// GIVEN
|
||||
var didReserve = false
|
||||
var didConfirm = false
|
||||
val username = "hello.32"
|
||||
SignalDatabase.recipients.setUsername(harness.self.id, username)
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/accounts/whoami") { r ->
|
||||
MockResponse().success(
|
||||
WhoAmIResponse().apply {
|
||||
usernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))
|
||||
}
|
||||
)
|
||||
},
|
||||
Put("/v1/accounts/username_hash/reserve") { r ->
|
||||
didReserve = true
|
||||
MockResponse().success(ReserveUsernameResponse(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))))
|
||||
},
|
||||
Put("/v1/accounts/username_hash/confirm") { r ->
|
||||
didConfirm = true
|
||||
MockResponse().success()
|
||||
}
|
||||
)
|
||||
|
||||
// WHEN
|
||||
RefreshOwnProfileJob.checkUsernameIsInSync()
|
||||
|
||||
// THEN
|
||||
assertFalse(didReserve)
|
||||
assertFalse(didConfirm)
|
||||
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMismatchAndReservationFails_whenICheckUsernameIsInSync_thenIExpectNoConfirm() {
|
||||
// GIVEN
|
||||
var didReserve = false
|
||||
var didConfirm = false
|
||||
val username = "hello.32"
|
||||
SignalDatabase.recipients.setUsername(harness.self.id, username)
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/accounts/whoami") { r ->
|
||||
MockResponse().success(
|
||||
WhoAmIResponse().apply {
|
||||
usernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash("${username}23"))
|
||||
}
|
||||
)
|
||||
},
|
||||
Put("/v1/accounts/username_hash/reserve") { r ->
|
||||
didReserve = true
|
||||
MockResponse().failure(418)
|
||||
},
|
||||
Put("/v1/accounts/username_hash/confirm") { r ->
|
||||
didConfirm = true
|
||||
MockResponse().success()
|
||||
}
|
||||
)
|
||||
|
||||
// WHEN
|
||||
RefreshOwnProfileJob.checkUsernameIsInSync()
|
||||
|
||||
// THEN
|
||||
assertTrue(didReserve)
|
||||
assertFalse(didConfirm)
|
||||
assertTrue(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ abstract class MessageContentProcessorTest {
|
||||
protected fun createNormalContentTestSubject(): MessageContentProcessor {
|
||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
|
||||
return MessageContentProcessor.forNormalContent(context)
|
||||
return MessageContentProcessor.create(context)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.protocol.ecc.Curve
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.AliceClient
|
||||
import org.thoughtcrime.securesms.testing.BobClient
|
||||
import org.thoughtcrime.securesms.testing.Entry
|
||||
import org.thoughtcrime.securesms.testing.FakeClientHelpers
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.awaitFor
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import android.util.Log as AndroidLog
|
||||
|
||||
/**
|
||||
* Sends N messages from Bob to Alice to track performance of Alice's processing of messages.
|
||||
*/
|
||||
@Ignore("Ignore test in normal testing as it's a performance test with no assertions")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MessageProcessingPerformanceTest {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(MessageProcessingPerformanceTest::class.java)
|
||||
private val TIMING_TAG = "TIMING_$TAG".substring(0..23)
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
private val trustRoot: ECKeyPair = Curve.generateKeyPair()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
mockkStatic(UnidentifiedAccessUtil::class)
|
||||
every { UnidentifiedAccessUtil.getCertificateValidator() } returns FakeClientHelpers.noOpCertificateValidator
|
||||
|
||||
mockkStatic(MessageContentProcessor::class)
|
||||
every { MessageContentProcessor.create(harness.application) } returns TimingMessageContentProcessor(harness.application)
|
||||
}
|
||||
|
||||
@After
|
||||
fun after() {
|
||||
unmockkStatic(UnidentifiedAccessUtil::class)
|
||||
unmockkStatic(MessageContentProcessor::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testPerformance() {
|
||||
val aliceClient = AliceClient(
|
||||
serviceId = harness.self.requireServiceId(),
|
||||
e164 = harness.self.requireE164(),
|
||||
trustRoot = trustRoot
|
||||
)
|
||||
|
||||
val bob = Recipient.resolved(harness.others[0])
|
||||
val bobClient = BobClient(
|
||||
serviceId = bob.requireServiceId(),
|
||||
e164 = bob.requireE164(),
|
||||
identityKeyPair = harness.othersKeys[0],
|
||||
trustRoot = trustRoot,
|
||||
profileKey = ProfileKey(bob.profileKey)
|
||||
)
|
||||
|
||||
// Send message from Bob to Alice (self)
|
||||
|
||||
val firstPreKeyMessageTimestamp = System.currentTimeMillis()
|
||||
val encryptedEnvelope = bobClient.encrypt(firstPreKeyMessageTimestamp)
|
||||
|
||||
val aliceProcessFirstMessageLatch = harness
|
||||
.inMemoryLogger
|
||||
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(firstPreKeyMessageTimestamp))
|
||||
|
||||
Thread { aliceClient.process(encryptedEnvelope, System.currentTimeMillis()) }.start()
|
||||
aliceProcessFirstMessageLatch.awaitFor(15.seconds)
|
||||
|
||||
// Send message from Alice to Bob
|
||||
val aliceNow = System.currentTimeMillis()
|
||||
bobClient.decrypt(aliceClient.encrypt(aliceNow, bob), aliceNow)
|
||||
|
||||
// Build N messages from Bob to Alice
|
||||
|
||||
val messageCount = 100
|
||||
val envelopes = ArrayList<Envelope>(messageCount)
|
||||
var now = System.currentTimeMillis()
|
||||
for (i in 0..messageCount) {
|
||||
envelopes += bobClient.encrypt(now)
|
||||
now += 3
|
||||
}
|
||||
|
||||
val firstTimestamp = envelopes.first().timestamp
|
||||
val lastTimestamp = envelopes.last().timestamp
|
||||
|
||||
// Alice processes N messages
|
||||
|
||||
val aliceProcessLastMessageLatch = harness
|
||||
.inMemoryLogger
|
||||
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(lastTimestamp))
|
||||
|
||||
Thread {
|
||||
for (envelope in envelopes) {
|
||||
Log.i(TIMING_TAG, "Retrieved envelope! ${envelope.timestamp}")
|
||||
aliceClient.process(envelope, envelope.timestamp)
|
||||
}
|
||||
}.start()
|
||||
|
||||
// Wait for Alice to finish processing messages
|
||||
aliceProcessLastMessageLatch.awaitFor(1.minutes)
|
||||
harness.inMemoryLogger.flush()
|
||||
|
||||
// Process logs for timing data
|
||||
val entries = harness.inMemoryLogger.entries()
|
||||
|
||||
// Calculate decryption average
|
||||
|
||||
val decrypts = entries
|
||||
.filter { it.tag == AliceClient.TAG }
|
||||
.drop(1)
|
||||
|
||||
val totalDecryptDuration = decrypts.sumOf { it.message!!.toLong() }
|
||||
|
||||
AndroidLog.w(TAG, "Decryption: Average runtime: ${totalDecryptDuration.toFloat() / decrypts.size.toFloat()}ms")
|
||||
|
||||
// Calculate MessageContentProcessor
|
||||
|
||||
val takeLast: List<Entry> = entries.filter { it.tag == TimingMessageContentProcessor.TAG }.drop(2)
|
||||
val iterator = takeLast.iterator()
|
||||
var processCount = 0L
|
||||
var processDuration = 0L
|
||||
while (iterator.hasNext()) {
|
||||
val start = iterator.next()
|
||||
val end = iterator.next()
|
||||
processCount++
|
||||
processDuration += end.timestamp - start.timestamp
|
||||
}
|
||||
|
||||
AndroidLog.w(TAG, "MessageContentProcessor.process: Average runtime: ${processDuration.toFloat() / processCount.toFloat()}ms")
|
||||
|
||||
// Calculate messages per second from "retrieving" first message post session initialization to processing last message
|
||||
|
||||
val start = entries.first { it.message == "Retrieved envelope! $firstTimestamp" }
|
||||
val end = entries.first { it.message == TimingMessageContentProcessor.endTag(lastTimestamp) }
|
||||
|
||||
val duration = (end.timestamp - start.timestamp).toFloat() / 1000f
|
||||
val messagePerSecond = messageCount.toFloat() / duration
|
||||
|
||||
AndroidLog.w(TAG, "Processing $messageCount messages took ${duration}s or ${messagePerSecond}m/s")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import android.content.Context
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.testing.LogPredicate
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceContent
|
||||
|
||||
class TimingMessageContentProcessor(context: Context) : MessageContentProcessor(context) {
|
||||
companion object {
|
||||
val TAG = Log.tag(TimingMessageContentProcessor::class.java)
|
||||
|
||||
fun endTagPredicate(timestamp: Long): LogPredicate = { entry ->
|
||||
entry.tag == TAG && entry.message == endTag(timestamp)
|
||||
}
|
||||
|
||||
private fun startTag(timestamp: Long) = "$timestamp start"
|
||||
fun endTag(timestamp: Long) = "$timestamp end"
|
||||
}
|
||||
|
||||
override fun process(messageState: MessageState?, content: SignalServiceContent?, exceptionMetadata: ExceptionMetadata?, envelopeTimestamp: Long, smsMessageId: Long) {
|
||||
Log.d(TAG, startTag(envelopeTimestamp))
|
||||
super.process(messageState, content, exceptionMetadata, envelopeTimestamp, smsMessageId)
|
||||
Log.d(TAG, endTag(envelopeTimestamp))
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ class UsernameEditFragmentTest {
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Put("/v1/accounts/username/reserved") {
|
||||
MockResponse().success(ReserveUsernameResponse(username, "reservationToken"))
|
||||
MockResponse().success(ReserveUsernameResponse(username))
|
||||
},
|
||||
Put("/v1/accounts/username/confirm") {
|
||||
MockResponse().success()
|
||||
|
||||
@@ -27,7 +27,7 @@ class SafetyNumberBottomSheetRepositoryTest {
|
||||
@Test
|
||||
fun givenIOnlyHave1to1Destinations_whenIGetBuckets_thenIOnlyHaveContactsBucketContainingAllRecipients() {
|
||||
val recipients = harness.others
|
||||
val destinations = harness.others.map { ContactSearchKey.RecipientSearchKey.KnownRecipient(it) }
|
||||
val destinations = harness.others.map { ContactSearchKey.RecipientSearchKey(it, false) }
|
||||
|
||||
val result = subjectUnderTest.getBuckets(recipients, destinations).test()
|
||||
|
||||
@@ -42,7 +42,7 @@ class SafetyNumberBottomSheetRepositoryTest {
|
||||
fun givenIOnlyHaveASingle1to1Destination_whenIGetBuckets_thenIOnlyHaveContactsBucketContainingAllRecipients() {
|
||||
// GIVEN
|
||||
val recipients = harness.others
|
||||
val destination = harness.others.take(1).map { ContactSearchKey.RecipientSearchKey.KnownRecipient(it) }
|
||||
val destination = harness.others.take(1).map { ContactSearchKey.RecipientSearchKey(it, false) }
|
||||
|
||||
// WHEN
|
||||
val result = subjectUnderTest.getBuckets(recipients, destination).test(1)
|
||||
@@ -59,7 +59,7 @@ class SafetyNumberBottomSheetRepositoryTest {
|
||||
// GIVEN
|
||||
val distributionListMembers = harness.others.take(5)
|
||||
val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!!
|
||||
val destinationKey = ContactSearchKey.RecipientSearchKey.Story(SignalDatabase.distributionLists.getRecipientId(distributionList)!!)
|
||||
val destinationKey = ContactSearchKey.RecipientSearchKey(SignalDatabase.distributionLists.getRecipientId(distributionList)!!, true)
|
||||
|
||||
// WHEN
|
||||
val result = subjectUnderTest.getBuckets(harness.others, listOf(destinationKey)).test(1)
|
||||
@@ -82,7 +82,7 @@ class SafetyNumberBottomSheetRepositoryTest {
|
||||
val distributionListMembers = harness.others.take(5)
|
||||
val toRemove = distributionListMembers.last()
|
||||
val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!!
|
||||
val destinationKey = ContactSearchKey.RecipientSearchKey.Story(SignalDatabase.distributionLists.getRecipientId(distributionList)!!)
|
||||
val destinationKey = ContactSearchKey.RecipientSearchKey(SignalDatabase.distributionLists.getRecipientId(distributionList)!!, true)
|
||||
val testSubscriber = subjectUnderTest.getBuckets(distributionListMembers, listOf(destinationKey)).test(2)
|
||||
testScheduler.triggerActions()
|
||||
|
||||
@@ -108,7 +108,7 @@ class SafetyNumberBottomSheetRepositoryTest {
|
||||
// GIVEN
|
||||
val distributionListMembers = harness.others.take(5)
|
||||
val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!!
|
||||
val destinationKey = ContactSearchKey.RecipientSearchKey.Story(SignalDatabase.distributionLists.getRecipientId(distributionList)!!)
|
||||
val destinationKey = ContactSearchKey.RecipientSearchKey(SignalDatabase.distributionLists.getRecipientId(distributionList)!!, true)
|
||||
val testSubscriber = subjectUnderTest.getBuckets(distributionListMembers, listOf(destinationKey)).test(2)
|
||||
testScheduler.triggerActions()
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package org.thoughtcrime.securesms.storage
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.update
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.Base64
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.storage.SignalContactRecord
|
||||
import org.whispersystems.signalservice.api.storage.StorageId
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ContactRecordProcessorTest {
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
SignalStore.account().setE164(E164_SELF)
|
||||
SignalStore.account().setAci(ACI_SELF)
|
||||
SignalStore.account().setPni(PNI_SELF)
|
||||
FeatureFlagsAccessor.forceValue(FeatureFlags.PHONE_NUMBER_PRIVACY, true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun process_splitContact_normalSplit() {
|
||||
// GIVEN
|
||||
val originalId = SignalDatabase.recipients.getAndPossiblyMerge(ACI_A, PNI_A, E164_A)
|
||||
setStorageId(originalId, STORAGE_ID_A)
|
||||
|
||||
val remote1 = buildRecord(STORAGE_ID_B) {
|
||||
setServiceId(ACI_A.toString())
|
||||
setUnregisteredAtTimestamp(100)
|
||||
}
|
||||
|
||||
val remote2 = buildRecord(STORAGE_ID_C) {
|
||||
setServiceId(PNI_A.toString())
|
||||
setServicePni(PNI_A.toString())
|
||||
setServiceE164(E164_A)
|
||||
}
|
||||
|
||||
// WHEN
|
||||
val subject = ContactRecordProcessor()
|
||||
subject.process(listOf(remote1, remote2), StorageSyncHelper.KEY_GENERATOR)
|
||||
|
||||
// THEN
|
||||
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
|
||||
|
||||
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
|
||||
val byPni: RecipientId = SignalDatabase.recipients.getByServiceId(PNI_A).get()
|
||||
|
||||
assertEquals(originalId, byAci)
|
||||
assertEquals(byE164, byPni)
|
||||
assertNotEquals(byAci, byE164)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun process_splitContact_doNotSplitIfAciRecordIsRegistered() {
|
||||
// GIVEN
|
||||
val originalId = SignalDatabase.recipients.getAndPossiblyMerge(ACI_A, PNI_A, E164_A)
|
||||
setStorageId(originalId, STORAGE_ID_A)
|
||||
|
||||
val remote1 = buildRecord(STORAGE_ID_B) {
|
||||
setServiceId(ACI_A.toString())
|
||||
setUnregisteredAtTimestamp(0)
|
||||
}
|
||||
|
||||
val remote2 = buildRecord(STORAGE_ID_C) {
|
||||
setServiceId(PNI_A.toString())
|
||||
setServicePni(PNI_A.toString())
|
||||
setServiceE164(E164_A)
|
||||
}
|
||||
|
||||
// WHEN
|
||||
val subject = ContactRecordProcessor()
|
||||
subject.process(listOf(remote1, remote2), StorageSyncHelper.KEY_GENERATOR)
|
||||
|
||||
// THEN
|
||||
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
|
||||
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
|
||||
val byPni: RecipientId = SignalDatabase.recipients.getByPni(PNI_A).get()
|
||||
|
||||
assertEquals(originalId, byAci)
|
||||
assertEquals(byE164, byPni)
|
||||
assertEquals(byAci, byE164)
|
||||
}
|
||||
|
||||
private fun buildRecord(id: StorageId, applyParams: ContactRecord.Builder.() -> ContactRecord.Builder): SignalContactRecord {
|
||||
return SignalContactRecord(id, ContactRecord.getDefaultInstance().toBuilder().applyParams().build())
|
||||
}
|
||||
|
||||
private fun setStorageId(recipientId: RecipientId, storageId: StorageId) {
|
||||
SignalDatabase.rawDatabase
|
||||
.update(RecipientTable.TABLE_NAME)
|
||||
.values(RecipientTable.STORAGE_SERVICE_ID to Base64.encodeBytes(storageId.raw))
|
||||
.where("${RecipientTable.ID} = ?", recipientId)
|
||||
.run()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val ACI_A = ACI.from(UUID.fromString("aaaa0000-5a76-47fa-a98a-7e72c948a82e"))
|
||||
val ACI_B = ACI.from(UUID.fromString("bbbb0000-0b60-4a68-9cd9-ed2f8453f9ed"))
|
||||
val ACI_SELF = ACI.from(UUID.fromString("77770000-b477-4f35-a824-d92987a63641"))
|
||||
|
||||
val PNI_A = PNI.from(UUID.fromString("aaaa1111-c960-4f6c-8385-671ad2ffb999"))
|
||||
val PNI_B = PNI.from(UUID.fromString("bbbb1111-cd55-40bf-adda-c35a85375533"))
|
||||
val PNI_SELF = PNI.from(UUID.fromString("77771111-b014-41fb-bf73-05cb2ec52910"))
|
||||
|
||||
const val E164_A = "+12222222222"
|
||||
const val E164_B = "+13333333333"
|
||||
const val E164_SELF = "+10000000000"
|
||||
|
||||
val STORAGE_ID_A: StorageId = StorageId.forContact(byteArrayOf(1, 2, 3, 4))
|
||||
val STORAGE_ID_B: StorageId = StorageId.forContact(byteArrayOf(5, 6, 7, 8))
|
||||
val STORAGE_ID_C: StorageId = StorageId.forContact(byteArrayOf(9, 10, 11, 12))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.FakeClientHelpers.toEnvelope
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
|
||||
|
||||
/**
|
||||
* Welcome to Alice's Client.
|
||||
*
|
||||
* Alice represent the Android instrumentation test user. Unlike [BobClient] much less is needed here
|
||||
* as it can make use of the standard Signal Android App infrastructure.
|
||||
*/
|
||||
class AliceClient(val serviceId: ServiceId, val e164: String, val trustRoot: ECKeyPair) {
|
||||
|
||||
companion object {
|
||||
val TAG = Log.tag(AliceClient::class.java)
|
||||
}
|
||||
|
||||
private val aliceSenderCertificate = FakeClientHelpers.createCertificateFor(
|
||||
trustRoot = trustRoot,
|
||||
uuid = serviceId.uuid(),
|
||||
e164 = e164,
|
||||
deviceId = 1,
|
||||
identityKey = SignalStore.account().aciIdentityKey.publicKey.publicKey,
|
||||
expires = 31337
|
||||
)
|
||||
|
||||
fun process(envelope: Envelope, serverDeliveredTimestamp: Long) {
|
||||
val start = System.currentTimeMillis()
|
||||
ApplicationDependencies.getIncomingMessageObserver().processEnvelope(envelope, serverDeliveredTimestamp)
|
||||
val end = System.currentTimeMillis()
|
||||
Log.d(TAG, "${end - start}")
|
||||
}
|
||||
|
||||
fun encrypt(now: Long, destination: Recipient): Envelope {
|
||||
return ApplicationDependencies.getSignalServiceMessageSender().getEncryptedMessage(
|
||||
SignalServiceAddress(destination.requireServiceId(), destination.requireE164()),
|
||||
FakeClientHelpers.getTargetUnidentifiedAccess(ProfileKeyUtil.getSelfProfileKey(), ProfileKey(destination.profileKey), aliceSenderCertificate),
|
||||
1,
|
||||
FakeClientHelpers.encryptedTextMessage(now),
|
||||
false
|
||||
).toEnvelope(now, destination.requireServiceId())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import org.signal.core.util.readToSingleInt
|
||||
import org.signal.core.util.select
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.SessionBuilder
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.protocol.groups.state.SenderKeyRecord
|
||||
import org.signal.libsignal.protocol.state.IdentityKeyStore
|
||||
import org.signal.libsignal.protocol.state.PreKeyBundle
|
||||
import org.signal.libsignal.protocol.state.PreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.SessionRecord
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||
import org.signal.libsignal.protocol.util.KeyHelper
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil
|
||||
import org.thoughtcrime.securesms.database.OneTimePreKeyTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.SignedPreKeyTable
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.FakeClientHelpers.toEnvelope
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock
|
||||
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher
|
||||
import org.whispersystems.signalservice.api.crypto.SignalSessionBuilder
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
|
||||
/**
|
||||
* Welcome to Bob's Client.
|
||||
*
|
||||
* Bob is a "fake" client that can start a session with the Android instrumentation test user (Alice).
|
||||
*
|
||||
* Bob can create a new session using a prekey bundle created from Alice's prekeys, send a message, decrypt
|
||||
* a return message from Alice, and that'll start a standard Signal session with normal keys/ratcheting.
|
||||
*/
|
||||
class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair: IdentityKeyPair, val trustRoot: ECKeyPair, val profileKey: ProfileKey) {
|
||||
|
||||
private val serviceAddress = SignalServiceAddress(serviceId, e164)
|
||||
private val registrationId = KeyHelper.generateRegistrationId(false)
|
||||
private val aciStore = BobSignalServiceAccountDataStore(registrationId, identityKeyPair)
|
||||
private val senderCertificate = FakeClientHelpers.createCertificateFor(trustRoot, serviceId.uuid(), e164, 1, identityKeyPair.publicKey.publicKey, 31337)
|
||||
private val sessionLock = object : SignalSessionLock {
|
||||
private val lock = ReentrantLock()
|
||||
|
||||
override fun acquire(): SignalSessionLock.Lock {
|
||||
lock.lock()
|
||||
return SignalSessionLock.Lock { lock.unlock() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Inspired by SignalServiceMessageSender#getEncryptedMessage */
|
||||
fun encrypt(now: Long): SignalServiceProtos.Envelope {
|
||||
val envelopeContent = FakeClientHelpers.encryptedTextMessage(now)
|
||||
|
||||
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, null)
|
||||
|
||||
if (!aciStore.containsSession(getAliceProtocolAddress())) {
|
||||
val sessionBuilder = SignalSessionBuilder(sessionLock, SessionBuilder(aciStore, getAliceProtocolAddress()))
|
||||
sessionBuilder.process(getAlicePreKeyBundle())
|
||||
}
|
||||
|
||||
return cipher.encrypt(getAliceProtocolAddress(), getAliceUnidentifiedAccess(), envelopeContent)
|
||||
.toEnvelope(envelopeContent.content.get().dataMessage.timestamp, getAliceServiceId())
|
||||
}
|
||||
|
||||
fun decrypt(envelope: SignalServiceProtos.Envelope, serverDeliveredTimestamp: Long) {
|
||||
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, UnidentifiedAccessUtil.getCertificateValidator())
|
||||
cipher.decrypt(envelope, serverDeliveredTimestamp)
|
||||
}
|
||||
|
||||
private fun getAliceServiceId(): ServiceId {
|
||||
return SignalStore.account().requireAci()
|
||||
}
|
||||
|
||||
private fun getAlicePreKeyBundle(): PreKeyBundle {
|
||||
val selfPreKeyId = SignalDatabase.rawDatabase
|
||||
.select(OneTimePreKeyTable.KEY_ID)
|
||||
.from(OneTimePreKeyTable.TABLE_NAME)
|
||||
.where("${OneTimePreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
|
||||
.run()
|
||||
.readToSingleInt(-1)
|
||||
|
||||
val selfPreKeyRecord = SignalDatabase.oneTimePreKeys.get(getAliceServiceId(), selfPreKeyId)!!
|
||||
|
||||
val selfSignedPreKeyId = SignalDatabase.rawDatabase
|
||||
.select(SignedPreKeyTable.KEY_ID)
|
||||
.from(SignedPreKeyTable.TABLE_NAME)
|
||||
.where("${SignedPreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
|
||||
.run()
|
||||
.readToSingleInt(-1)
|
||||
|
||||
val selfSignedPreKeyRecord = SignalDatabase.signedPreKeys.get(getAliceServiceId(), selfSignedPreKeyId)!!
|
||||
|
||||
return PreKeyBundle(
|
||||
SignalStore.account().registrationId,
|
||||
1,
|
||||
selfPreKeyId,
|
||||
selfPreKeyRecord.keyPair.publicKey,
|
||||
selfSignedPreKeyId,
|
||||
selfSignedPreKeyRecord.keyPair.publicKey,
|
||||
selfSignedPreKeyRecord.signature,
|
||||
getAlicePublicKey()
|
||||
)
|
||||
}
|
||||
|
||||
private fun getAliceProtocolAddress(): SignalProtocolAddress {
|
||||
return SignalProtocolAddress(SignalStore.account().requireAci().toString(), 1)
|
||||
}
|
||||
|
||||
private fun getAlicePublicKey(): IdentityKey {
|
||||
return SignalStore.account().aciIdentityKey.publicKey
|
||||
}
|
||||
|
||||
private fun getAliceProfileKey(): ProfileKey {
|
||||
return ProfileKeyUtil.getSelfProfileKey()
|
||||
}
|
||||
|
||||
private fun getAliceUnidentifiedAccess(): Optional<UnidentifiedAccess> {
|
||||
return FakeClientHelpers.getTargetUnidentifiedAccess(profileKey, getAliceProfileKey(), senderCertificate)
|
||||
}
|
||||
|
||||
private class BobSignalServiceAccountDataStore(private val registrationId: Int, private val identityKeyPair: IdentityKeyPair) : SignalServiceAccountDataStore {
|
||||
private var aliceSessionRecord: SessionRecord? = null
|
||||
|
||||
override fun getIdentityKeyPair(): IdentityKeyPair = identityKeyPair
|
||||
|
||||
override fun getLocalRegistrationId(): Int = registrationId
|
||||
override fun isTrustedIdentity(address: SignalProtocolAddress?, identityKey: IdentityKey?, direction: IdentityKeyStore.Direction?): Boolean = true
|
||||
override fun loadSession(address: SignalProtocolAddress?): SessionRecord = aliceSessionRecord ?: SessionRecord()
|
||||
override fun saveIdentity(address: SignalProtocolAddress?, identityKey: IdentityKey?): Boolean = false
|
||||
override fun storeSession(address: SignalProtocolAddress?, record: SessionRecord?) { aliceSessionRecord = record }
|
||||
override fun getSubDeviceSessions(name: String?): List<Int> = emptyList()
|
||||
override fun containsSession(address: SignalProtocolAddress?): Boolean = aliceSessionRecord != null
|
||||
override fun getIdentity(address: SignalProtocolAddress?): IdentityKey = SignalStore.account().aciIdentityKey.publicKey
|
||||
|
||||
override fun loadPreKey(preKeyId: Int): PreKeyRecord = throw UnsupportedOperationException()
|
||||
override fun storePreKey(preKeyId: Int, record: PreKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun containsPreKey(preKeyId: Int): Boolean = throw UnsupportedOperationException()
|
||||
override fun removePreKey(preKeyId: Int) = throw UnsupportedOperationException()
|
||||
override fun loadExistingSessions(addresses: MutableList<SignalProtocolAddress>?): MutableList<SessionRecord> = throw UnsupportedOperationException()
|
||||
override fun deleteSession(address: SignalProtocolAddress?) = throw UnsupportedOperationException()
|
||||
override fun deleteAllSessions(name: String?) = throw UnsupportedOperationException()
|
||||
override fun loadSignedPreKey(signedPreKeyId: Int): SignedPreKeyRecord = throw UnsupportedOperationException()
|
||||
override fun loadSignedPreKeys(): MutableList<SignedPreKeyRecord> = throw UnsupportedOperationException()
|
||||
override fun storeSignedPreKey(signedPreKeyId: Int, record: SignedPreKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun containsSignedPreKey(signedPreKeyId: Int): Boolean = throw UnsupportedOperationException()
|
||||
override fun removeSignedPreKey(signedPreKeyId: Int) = throw UnsupportedOperationException()
|
||||
override fun storeSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?, record: SenderKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun loadSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?): SenderKeyRecord = throw UnsupportedOperationException()
|
||||
override fun archiveSession(address: SignalProtocolAddress?) = throw UnsupportedOperationException()
|
||||
override fun getAllAddressesWithActiveSessions(addressNames: MutableList<String>?): MutableSet<SignalProtocolAddress> = throw UnsupportedOperationException()
|
||||
override fun getSenderKeySharedWith(distributionId: DistributionId?): MutableSet<SignalProtocolAddress> = throw UnsupportedOperationException()
|
||||
override fun markSenderKeySharedWith(distributionId: DistributionId?, addresses: MutableCollection<SignalProtocolAddress>?) = throw UnsupportedOperationException()
|
||||
override fun clearSenderKeySharedWith(addresses: MutableCollection<SignalProtocolAddress>?) = throw UnsupportedOperationException()
|
||||
override fun isMultiDevice(): Boolean = throw UnsupportedOperationException()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import org.signal.libsignal.internal.Native
|
||||
import org.signal.libsignal.internal.NativeHandleGuard
|
||||
import org.signal.libsignal.metadata.certificate.CertificateValidator
|
||||
import org.signal.libsignal.metadata.certificate.SenderCertificate
|
||||
import org.signal.libsignal.metadata.certificate.ServerCertificate
|
||||
import org.signal.libsignal.protocol.ecc.Curve
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.database.model.toProtoByteString
|
||||
import org.whispersystems.signalservice.api.crypto.ContentHint
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
|
||||
import org.whispersystems.util.Base64
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
object FakeClientHelpers {
|
||||
|
||||
val noOpCertificateValidator = object : CertificateValidator(null) {
|
||||
override fun validate(certificate: SenderCertificate, validationTime: Long) = Unit
|
||||
}
|
||||
|
||||
fun createCertificateFor(trustRoot: ECKeyPair, uuid: UUID, e164: String, deviceId: Int, identityKey: ECPublicKey, expires: Long): SenderCertificate {
|
||||
val serverKey: ECKeyPair = Curve.generateKeyPair()
|
||||
NativeHandleGuard(serverKey.publicKey).use { serverPublicGuard ->
|
||||
NativeHandleGuard(trustRoot.privateKey).use { trustRootPrivateGuard ->
|
||||
val serverCertificate = ServerCertificate(Native.ServerCertificate_New(1, serverPublicGuard.nativeHandle(), trustRootPrivateGuard.nativeHandle()))
|
||||
NativeHandleGuard(identityKey).use { identityGuard ->
|
||||
NativeHandleGuard(serverCertificate).use { serverCertificateGuard ->
|
||||
NativeHandleGuard(serverKey.privateKey).use { serverPrivateGuard ->
|
||||
return SenderCertificate(Native.SenderCertificate_New(uuid.toString(), e164, deviceId, identityGuard.nativeHandle(), expires, serverCertificateGuard.nativeHandle(), serverPrivateGuard.nativeHandle()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getTargetUnidentifiedAccess(myProfileKey: ProfileKey, theirProfileKey: ProfileKey, senderCertificate: SenderCertificate): Optional<UnidentifiedAccess> {
|
||||
val selfUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(myProfileKey)
|
||||
val themUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey)
|
||||
|
||||
return UnidentifiedAccessPair(UnidentifiedAccess(selfUnidentifiedAccessKey, senderCertificate.serialized), UnidentifiedAccess(themUnidentifiedAccessKey, senderCertificate.serialized)).targetUnidentifiedAccess
|
||||
}
|
||||
|
||||
fun encryptedTextMessage(now: Long, message: String = "Test body message"): EnvelopeContent {
|
||||
val content = SignalServiceProtos.Content.newBuilder().apply {
|
||||
setDataMessage(
|
||||
SignalServiceProtos.DataMessage.newBuilder().apply {
|
||||
body = message
|
||||
timestamp = now
|
||||
}
|
||||
)
|
||||
}
|
||||
return EnvelopeContent.encrypted(content.build(), ContentHint.RESENDABLE, Optional.empty())
|
||||
}
|
||||
|
||||
fun OutgoingPushMessage.toEnvelope(timestamp: Long, destination: ServiceId): Envelope {
|
||||
return Envelope.newBuilder()
|
||||
.setType(Envelope.Type.valueOf(this.type))
|
||||
.setSourceDevice(1)
|
||||
.setTimestamp(timestamp)
|
||||
.setServerTimestamp(timestamp + 1)
|
||||
.setDestinationUuid(destination.toString())
|
||||
.setServerGuid(UUID.randomUUID().toString())
|
||||
.setContent(Base64.decode(this.content).toProtoByteString())
|
||||
.setUrgent(true)
|
||||
.setStory(false)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import java.util.concurrent.CountDownLatch
|
||||
|
||||
typealias LogPredicate = (Entry) -> Boolean
|
||||
|
||||
/**
|
||||
* Logging implementation that holds logs in memory as they are added to be retrieve at a later time by a test.
|
||||
* Can also be used for multithreaded synchronization and waiting until certain logs are emitted before continuing
|
||||
* a test.
|
||||
*/
|
||||
class InMemoryLogger : Log.Logger() {
|
||||
|
||||
private val executor = SignalExecutors.newCachedSingleThreadExecutor("inmemory-logger")
|
||||
private val predicates = mutableListOf<LogPredicate>()
|
||||
private val logEntries = mutableListOf<Entry>()
|
||||
|
||||
override fun v(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) = add(Verbose(tag, message, t, System.currentTimeMillis()))
|
||||
override fun d(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) = add(Debug(tag, message, t, System.currentTimeMillis()))
|
||||
override fun i(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) = add(Info(tag, message, t, System.currentTimeMillis()))
|
||||
override fun w(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) = add(Warn(tag, message, t, System.currentTimeMillis()))
|
||||
override fun e(tag: String, message: String?, t: Throwable?, keepLonger: Boolean) = add(Error(tag, message, t, System.currentTimeMillis()))
|
||||
|
||||
override fun flush() {
|
||||
val latch = CountDownLatch(1)
|
||||
executor.execute { latch.countDown() }
|
||||
latch.await()
|
||||
}
|
||||
|
||||
private fun add(entry: Entry) {
|
||||
executor.execute {
|
||||
logEntries += entry
|
||||
|
||||
val iterator = predicates.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val predicate = iterator.next()
|
||||
if (predicate(entry)) {
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Blocks until a snapshot of all log entries can be taken in a thread-safe way. */
|
||||
fun entries(): List<Entry> {
|
||||
val latch = CountDownLatch(1)
|
||||
var entries: List<Entry> = emptyList()
|
||||
executor.execute {
|
||||
entries = logEntries.toList()
|
||||
latch.countDown()
|
||||
}
|
||||
latch.await()
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Returns a countdown latch that'll fire at a future point when an [Entry] is received that matches the predicate. */
|
||||
fun getLockForUntil(predicate: LogPredicate): CountDownLatch {
|
||||
val latch = CountDownLatch(1)
|
||||
executor.execute {
|
||||
predicates += { entry ->
|
||||
if (predicate(entry)) {
|
||||
latch.countDown()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
return latch
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface Entry {
|
||||
val tag: String
|
||||
val message: String?
|
||||
val throwable: Throwable?
|
||||
val timestamp: Long
|
||||
}
|
||||
|
||||
data class Verbose(override val tag: String, override val message: String?, override val throwable: Throwable?, override val timestamp: Long) : Entry
|
||||
data class Debug(override val tag: String, override val message: String?, override val throwable: Throwable?, override val timestamp: Long) : Entry
|
||||
data class Info(override val tag: String, override val message: String?, override val throwable: Throwable?, override val timestamp: Long) : Entry
|
||||
data class Warn(override val tag: String, override val message: String?, override val throwable: Throwable?, override val timestamp: Long) : Entry
|
||||
data class Error(override val tag: String, override val message: String?, override val throwable: Throwable?, override val timestamp: Long) : Entry
|
||||
@@ -32,6 +32,7 @@ import org.whispersystems.signalservice.internal.push.PreKeyEntity
|
||||
import org.whispersystems.signalservice.internal.push.PreKeyResponse
|
||||
import org.whispersystems.signalservice.internal.push.PreKeyResponseItem
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataJson
|
||||
import org.whispersystems.signalservice.internal.push.SenderCertificate
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
|
||||
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
|
||||
@@ -56,6 +57,16 @@ object MockProvider {
|
||||
)
|
||||
}
|
||||
|
||||
val sessionMetadataJson = RegistrationSessionMetadataJson(
|
||||
id = "asdfasdfasdfasdf",
|
||||
nextCall = null,
|
||||
nextSms = null,
|
||||
nextVerificationAttempt = null,
|
||||
allowedToRequestCode = true,
|
||||
requestedInformation = emptyList(),
|
||||
verified = true
|
||||
)
|
||||
|
||||
fun createVerifyAccountResponse(aci: ServiceId, newPni: ServiceId): VerifyAccountResponse {
|
||||
return VerifyAccountResponse().apply {
|
||||
uuid = aci.toString()
|
||||
|
||||
@@ -17,6 +17,8 @@ class Get(path: String, responseFactory: ResponseFactory) : Verb("GET", path, re
|
||||
|
||||
class Put(path: String, responseFactory: ResponseFactory) : Verb("PUT", path, responseFactory)
|
||||
|
||||
class Post(path: String, responseFactory: ResponseFactory) : Verb("POST", path, responseFactory)
|
||||
|
||||
fun MockResponse.success(response: Any? = null): MockResponse {
|
||||
return setResponseCode(200).apply {
|
||||
if (response != null) {
|
||||
|
||||
@@ -13,7 +13,7 @@ class RxTestSchedulerRule(
|
||||
val ioTestScheduler: TestScheduler = defaultTestScheduler,
|
||||
val computationTestScheduler: TestScheduler = defaultTestScheduler,
|
||||
val singleTestScheduler: TestScheduler = defaultTestScheduler,
|
||||
val newThreadTestScheduler: TestScheduler = defaultTestScheduler,
|
||||
val newThreadTestScheduler: TestScheduler = defaultTestScheduler
|
||||
) : ExternalResource() {
|
||||
|
||||
override fun before() {
|
||||
|
||||
@@ -11,7 +11,9 @@ import androidx.test.platform.app.InstrumentationRegistry
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.thoughtcrime.securesms.SignalInstrumentationApplicationContext
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
@@ -20,7 +22,6 @@ import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -54,18 +55,23 @@ class SignalActivityRule(private val othersCount: Int = 4) : ExternalResource()
|
||||
private set
|
||||
lateinit var others: List<RecipientId>
|
||||
private set
|
||||
lateinit var othersKeys: List<IdentityKeyPair>
|
||||
|
||||
val inMemoryLogger: InMemoryLogger
|
||||
get() = (application as SignalInstrumentationApplicationContext).inMemoryLogger
|
||||
|
||||
override fun before() {
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
self = setupSelf()
|
||||
others = setupOthers()
|
||||
|
||||
val setupOthers = setupOthers()
|
||||
others = setupOthers.first
|
||||
othersKeys = setupOthers.second
|
||||
|
||||
InstrumentationApplicationDependencyProvider.clearHandlers()
|
||||
}
|
||||
|
||||
private fun setupSelf(): Recipient {
|
||||
DeviceTransferBlockingInterceptor.getInstance().blockNetwork()
|
||||
|
||||
PreferenceManager.getDefaultSharedPreferences(application).edit().putBoolean("pref_prompted_push_registration", true).commit()
|
||||
val masterSecret = MasterSecretUtil.generateMasterSecret(application, MasterSecretUtil.UNENCRYPTED_PASSPHRASE)
|
||||
MasterSecretUtil.generateAsymmetricMasterSecret(application, masterSecret)
|
||||
@@ -83,22 +89,25 @@ class SignalActivityRule(private val othersCount: Int = 4) : ExternalResource()
|
||||
registrationId = registrationRepository.registrationId,
|
||||
profileKey = registrationRepository.getProfileKey("+15555550101"),
|
||||
fcmToken = null,
|
||||
pniRegistrationId = registrationRepository.pniRegistrationId
|
||||
pniRegistrationId = registrationRepository.pniRegistrationId,
|
||||
recoveryPassword = "asdfasdfasdfasdf"
|
||||
),
|
||||
VerifyResponse(VerifyAccountResponse(UUID.randomUUID().toString(), UUID.randomUUID().toString(), false), null, null)
|
||||
VerifyResponse(VerifyAccountResponse(UUID.randomUUID().toString(), UUID.randomUUID().toString(), false), null, null),
|
||||
false
|
||||
).blockingGet()
|
||||
|
||||
ServiceResponseProcessor.DefaultProcessor(response).resultOrThrow
|
||||
|
||||
SignalStore.kbsValues().optOut()
|
||||
RegistrationUtil.maybeMarkRegistrationComplete(application)
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson"))
|
||||
|
||||
return Recipient.self()
|
||||
}
|
||||
|
||||
private fun setupOthers(): List<RecipientId> {
|
||||
private fun setupOthers(): Pair<List<RecipientId>, List<IdentityKeyPair>> {
|
||||
val others = mutableListOf<RecipientId>()
|
||||
val othersKeys = mutableListOf<IdentityKeyPair>()
|
||||
|
||||
if (othersCount !in 0 until 1000) {
|
||||
throw IllegalArgumentException("$othersCount must be between 0 and 1000")
|
||||
@@ -112,11 +121,13 @@ class SignalActivityRule(private val othersCount: Int = 4) : ExternalResource()
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true, true, true, true, true, true, true))
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
SignalDatabase.recipients.markRegistered(recipientId, aci)
|
||||
ApplicationDependencies.getProtocolStore().aci().saveIdentity(SignalProtocolAddress(aci.toString(), 0), IdentityKeyUtil.generateIdentityKeyPair().publicKey)
|
||||
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()
|
||||
ApplicationDependencies.getProtocolStore().aci().saveIdentity(SignalProtocolAddress(aci.toString(), 0), otherIdentity.publicKey)
|
||||
others += recipientId
|
||||
othersKeys += otherIdentity
|
||||
}
|
||||
|
||||
return others
|
||||
return others to othersKeys
|
||||
}
|
||||
|
||||
inline fun <reified T : Activity> launchActivity(initIntent: Intent.() -> Unit = {}): ActivityScenario<T> {
|
||||
|
||||
@@ -21,7 +21,7 @@ class TestProtos private constructor() {
|
||||
}
|
||||
|
||||
fun metadata(
|
||||
address: AddressProto = address().build(),
|
||||
address: AddressProto = address().build()
|
||||
): MetadataProto.Builder {
|
||||
return MetadataProto.newBuilder()
|
||||
.setAddress(address)
|
||||
|
||||
@@ -7,6 +7,9 @@ import org.hamcrest.Matchers.not
|
||||
import org.hamcrest.Matchers.notNullValue
|
||||
import org.hamcrest.Matchers.nullValue
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Run the given [runnable] on a new thread and wait for it to finish.
|
||||
@@ -44,3 +47,9 @@ infix fun <T : Any> T.assertIsNot(expected: T) {
|
||||
infix fun <E, T : Collection<E>> T.assertIsSize(expected: Int) {
|
||||
assertThat(this, hasSize(expected))
|
||||
}
|
||||
|
||||
fun CountDownLatch.awaitFor(duration: Duration) {
|
||||
if (!await(duration.inWholeMilliseconds, TimeUnit.MILLISECONDS)) {
|
||||
throw TimeoutException("Latch await took longer than ${duration.inWholeMilliseconds}ms")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.thoughtcrime.securesms.util;
|
||||
|
||||
/**
|
||||
* A class that allows us to inject feature flags during tests.
|
||||
*/
|
||||
public final class FeatureFlagsAccessor {
|
||||
|
||||
public static void forceValue(String key, Object value) {
|
||||
FeatureFlags.FORCED_VALUES.put(FeatureFlags.PHONE_NUMBER_PRIVACY, true);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="org.thoughtcrime.securesms">
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application
|
||||
android:usesCleartextTraffic="true"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="org.thoughtcrime.securesms">
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-sdk tools:overrideLibrary="androidx.camera.core,androidx.camera.camera2,androidx.camera.lifecycle,androidx.camera.view" />
|
||||
|
||||
@@ -98,9 +97,10 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
tools:replace="android:allowBackup"
|
||||
android:resizeableActivity="true"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupOnly="false"
|
||||
android:allowBackup="true"
|
||||
android:backupAgent=".absbackup.SignalBackupAgent"
|
||||
android:theme="@style/TextSecure.LightTheme"
|
||||
android:largeHeap="true">
|
||||
|
||||
@@ -464,7 +464,7 @@
|
||||
|
||||
<activity android:name=".registration.RegistrationNavigationActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/TextSecure.LightRegistrationTheme"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
@@ -699,6 +699,7 @@
|
||||
<service android:enabled="true" android:name=".service.webrtc.WebRtcCallService" android:foregroundServiceType="camera|microphone"/>
|
||||
<service android:enabled="true" android:exported="false" android:name=".service.KeyCachingService"/>
|
||||
<service android:enabled="true" android:name=".messages.IncomingMessageObserver$ForegroundService"/>
|
||||
<service android:enabled="true" android:name=".messages.IncomingMessageObserver$BackgroundService"/>
|
||||
<service android:name=".service.webrtc.AndroidCallConnectionService"
|
||||
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
|
||||
android:exported="true">
|
||||
@@ -814,6 +815,8 @@
|
||||
|
||||
<receiver android:name=".revealable.ViewOnceMessageManager$ViewOnceAlarm" />
|
||||
|
||||
<receiver android:name=".service.ScheduledMessageManager$ScheduledMessagesAlarm" />
|
||||
|
||||
<receiver android:name=".service.PendingRetryReceiptManager$PendingRetryReceiptAlarm" />
|
||||
|
||||
<receiver android:name=".service.TrimThreadsByDateManager$TrimThreadsByDateAlarm" />
|
||||
|
||||
@@ -4,8 +4,6 @@ import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.provider.DocumentsContract;
|
||||
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
|
||||
/**
|
||||
@@ -22,7 +20,6 @@ public class DocumentFileHelper {
|
||||
*
|
||||
* @return true if rename successful
|
||||
*/
|
||||
@RequiresApi(21)
|
||||
public static boolean renameTo(Context context, DocumentFile documentFile, String displayName) {
|
||||
if (documentFile instanceof TreeDocumentFile) {
|
||||
Log.d(TAG, "Renaming document directly");
|
||||
|
||||
@@ -24,7 +24,6 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.multidex.MultiDexApplication;
|
||||
|
||||
import com.google.android.gms.security.ProviderInstaller;
|
||||
@@ -63,6 +62,7 @@ import org.thoughtcrime.securesms.jobs.PnpInitializeDevicesJob;
|
||||
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
|
||||
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
|
||||
import org.thoughtcrime.securesms.jobs.RefreshKbsCredentialsJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob;
|
||||
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob;
|
||||
@@ -73,9 +73,9 @@ import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
|
||||
import org.thoughtcrime.securesms.logging.PersistentLogger;
|
||||
import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.SignalGlideComponents;
|
||||
import org.thoughtcrime.securesms.mms.SignalGlideModule;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
@@ -104,10 +104,10 @@ import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWra
|
||||
import java.net.SocketException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.security.Security;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.reactivex.rxjava3.core.CompletableObserver;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException;
|
||||
import io.reactivex.rxjava3.exceptions.UndeliverableException;
|
||||
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
|
||||
@@ -126,7 +126,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
|
||||
private static final String TAG = Log.tag(ApplicationContext.class);
|
||||
|
||||
private PersistentLogger persistentLogger;
|
||||
@VisibleForTesting
|
||||
protected PersistentLogger persistentLogger;
|
||||
|
||||
public static ApplicationContext getInstance(Context context) {
|
||||
return (ApplicationContext)context.getApplicationContext();
|
||||
@@ -163,16 +164,10 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addBlocking("app-dependencies", this::initializeAppDependencies)
|
||||
.addBlocking("first-launch", this::initializeFirstEverAppLaunch)
|
||||
.addBlocking("app-migrations", this::initializeApplicationMigrations)
|
||||
.addBlocking("ring-rtc", this::initializeRingRtc)
|
||||
.addBlocking("mark-registration", () -> RegistrationUtil.maybeMarkRegistrationComplete(this))
|
||||
.addBlocking("mark-registration", () -> RegistrationUtil.maybeMarkRegistrationComplete())
|
||||
.addBlocking("lifecycle-observer", () -> ApplicationDependencies.getAppForegroundObserver().addListener(this))
|
||||
.addBlocking("message-retriever", this::initializeMessageRetrieval)
|
||||
.addBlocking("dynamic-theme", () -> DynamicTheme.setDefaultDayNightMode(this))
|
||||
.addBlocking("vector-compat", () -> {
|
||||
if (Build.VERSION.SDK_INT < 21) {
|
||||
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true);
|
||||
}
|
||||
})
|
||||
.addBlocking("proxy-init", () -> {
|
||||
if (SignalStore.proxy().isProxyEnabled()) {
|
||||
Log.w(TAG, "Proxy detected. Enabling Conscrypt.setUseEngineSocketByDefault()");
|
||||
@@ -181,11 +176,14 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
})
|
||||
.addBlocking("blob-provider", this::initializeBlobProvider)
|
||||
.addBlocking("feature-flags", FeatureFlags::init)
|
||||
.addBlocking("ring-rtc", this::initializeRingRtc)
|
||||
.addBlocking("glide", () -> SignalGlideModule.setRegisterGlideComponents(new SignalGlideComponents()))
|
||||
.addNonBlocking(() -> GlideApp.get(this))
|
||||
.addNonBlocking(this::checkIsGooglePayReady)
|
||||
.addNonBlocking(this::cleanAvatarStorage)
|
||||
.addNonBlocking(this::initializeRevealableMessageManager)
|
||||
.addNonBlocking(this::initializePendingRetryReceiptManager)
|
||||
.addNonBlocking(this::initializeScheduledMessageManager)
|
||||
.addNonBlocking(this::initializeFcmCheck)
|
||||
.addNonBlocking(PreKeysSyncJob::enqueueIfNeeded)
|
||||
.addNonBlocking(this::initializePeriodicTasks)
|
||||
@@ -203,6 +201,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addPostRender(this::initializeExpiringMessageManager)
|
||||
.addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this)))
|
||||
.addPostRender(this::initializeTrimThreadsByDateManager)
|
||||
.addPostRender(RefreshKbsCredentialsJob::enqueueIfNecessary)
|
||||
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
|
||||
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
|
||||
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()))
|
||||
@@ -215,6 +214,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addPostRender(StoryOnboardingDownloadJob.Companion::enqueueIfNeeded)
|
||||
.addPostRender(PnpInitializeDevicesJob::enqueueIfNecessary)
|
||||
.addPostRender(() -> ApplicationDependencies.getExoPlayerPool().getPoolStats().getMaxUnreserved())
|
||||
.addPostRender(() -> SignalDatabase.groupCallRings().removeOldRings())
|
||||
.addPostRender(() -> ApplicationDependencies.getRecipientCache().warmUp())
|
||||
.execute();
|
||||
|
||||
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
@@ -234,7 +235,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
FeatureFlags.refreshIfNecessary();
|
||||
ApplicationDependencies.getRecipientCache().warmUp();
|
||||
RetrieveProfileJob.enqueueRoutineFetchIfNecessary(this);
|
||||
executePendingContactSync();
|
||||
KeyCachingService.onAppForegrounded(this);
|
||||
@@ -300,7 +300,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeLogging() {
|
||||
@VisibleForTesting
|
||||
protected void initializeLogging() {
|
||||
persistentLogger = new PersistentLogger(this);
|
||||
org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), persistentLogger);
|
||||
|
||||
@@ -394,6 +395,10 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
ApplicationDependencies.getPendingRetryReceiptManager().scheduleIfNecessary();
|
||||
}
|
||||
|
||||
private void initializeScheduledMessageManager() {
|
||||
ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary();
|
||||
}
|
||||
|
||||
private void initializeTrimThreadsByDateManager() {
|
||||
KeepMessagesDuration keepMessagesDuration = SignalStore.settings().getKeepMessagesDuration();
|
||||
if (keepMessagesDuration != KeepMessagesDuration.FOREVER) {
|
||||
@@ -415,7 +420,11 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
|
||||
private void initializeRingRtc() {
|
||||
try {
|
||||
CallManager.initialize(this, new RingRtcLogger());
|
||||
Map<String, String> fieldTrials = new HashMap<>();
|
||||
if (FeatureFlags.callingFieldTrialAnyAddressPortsKillSwitch()) {
|
||||
fieldTrials.put("RingRTC-AnyAddressPortsKillSwitch", "Enabled");
|
||||
}
|
||||
CallManager.initialize(this, new RingRtcLogger(), fieldTrials);
|
||||
} catch (UnsatisfiedLinkError e) {
|
||||
throw new AssertionError("Unable to load ringrtc library", e);
|
||||
}
|
||||
|
||||
@@ -72,12 +72,10 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
|
||||
setTheme(R.style.TextSecure_MediaPreview);
|
||||
setContentView(R.layout.contact_photo_preview_activity);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 21) {
|
||||
postponeEnterTransition();
|
||||
TransitionInflater inflater = TransitionInflater.from(this);
|
||||
getWindow().setSharedElementEnterTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_enter_transition_set));
|
||||
getWindow().setSharedElementReturnTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_return_transition_set));
|
||||
}
|
||||
postponeEnterTransition();
|
||||
TransitionInflater inflater = TransitionInflater.from(this);
|
||||
getWindow().setSharedElementEnterTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_enter_transition_set));
|
||||
getWindow().setSharedElementReturnTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_return_transition_set));
|
||||
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
EmojiTextView title = findViewById(R.id.title);
|
||||
@@ -122,9 +120,7 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
|
||||
@Override
|
||||
public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Bitmap> transition) {
|
||||
avatar.setImageDrawable(RoundedBitmapDrawableFactory.create(resources, resource));
|
||||
if (Build.VERSION.SDK_INT >= 21) {
|
||||
startPostponedEnterTransition();
|
||||
}
|
||||
startPostponedEnterTransition();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.ConfigurationUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
|
||||
import java.util.Objects;
|
||||
@@ -42,7 +43,7 @@ public abstract class BaseActivity extends AppCompatActivity {
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
initializeScreenshotSecurity();
|
||||
WindowUtil.initializeScreenshotSecurity(this, getWindow());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -64,14 +65,6 @@ public abstract class BaseActivity extends AppCompatActivity {
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private void initializeScreenshotSecurity() {
|
||||
if (TextSecurePreferences.isScreenSecurityEnabled(this)) {
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
|
||||
} else {
|
||||
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE);
|
||||
}
|
||||
}
|
||||
|
||||
protected void startActivitySceneTransition(Intent intent, View sharedView, String transitionName) {
|
||||
Bundle bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(this, sharedView, transitionName)
|
||||
.toBundle();
|
||||
|
||||
@@ -10,10 +10,11 @@ import androidx.lifecycle.Observer;
|
||||
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationItem;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselectable;
|
||||
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord;
|
||||
@@ -23,6 +24,7 @@ import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
@@ -106,11 +108,14 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void onInviteToSignalClicked();
|
||||
void onActivatePaymentsClicked();
|
||||
void onSendPaymentClicked(@NonNull RecipientId recipientId);
|
||||
void onScheduledIndicatorClicked(@NonNull View view, @NonNull MessageRecord messageRecord);
|
||||
|
||||
/** @return true if handled, false if you want to let the normal url handling continue */
|
||||
boolean onUrlClicked(@NonNull String url);
|
||||
|
||||
void onViewGiftBadgeClicked(@NonNull MessageRecord messageRecord);
|
||||
void onGiftBadgeRevealed(@NonNull MessageRecord messageRecord);
|
||||
|
||||
void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.biometric.BiometricPrompt.PromptInfo
|
||||
@@ -26,6 +25,14 @@ class BiometricDeviceAuthentication(
|
||||
const val TAG: String = "BiometricDeviceAuth"
|
||||
const val BIOMETRIC_AUTHENTICATORS = BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.BIOMETRIC_WEAK
|
||||
const val ALLOWED_AUTHENTICATORS = BIOMETRIC_AUTHENTICATORS or BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
|
||||
/**
|
||||
* From the docs on [BiometricManager.canAuthenticate]
|
||||
*
|
||||
* > Note that not all combinations of authenticator types are supported prior to Android 11 (API 30). Specifically, DEVICE_CREDENTIAL alone is unsupported
|
||||
* > prior to API 30, and BIOMETRIC_STRONG | DEVICE_CREDENTIAL is unsupported on API 28-29.
|
||||
*/
|
||||
private val DISALLOWED_BIOMETRIC_VERSIONS = setOf(28, 29)
|
||||
}
|
||||
|
||||
fun authenticate(context: Context, force: Boolean, showConfirmDeviceCredentialIntent: () -> Unit): Boolean {
|
||||
@@ -36,7 +43,7 @@ class BiometricDeviceAuthentication(
|
||||
return false
|
||||
}
|
||||
|
||||
return if (Build.VERSION.SDK_INT != 29 && biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS) {
|
||||
return if (!DISALLOWED_BIOMETRIC_VERSIONS.contains(Build.VERSION.SDK_INT) && biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS) {
|
||||
if (force) {
|
||||
Log.i(TAG, "Listening for biometric authentication...")
|
||||
biometricPrompt.authenticate(biometricPromptInfo)
|
||||
@@ -44,7 +51,7 @@ class BiometricDeviceAuthentication(
|
||||
Log.i(TAG, "Skipping show system biometric or device lock dialog unless forced")
|
||||
}
|
||||
true
|
||||
} else if (Build.VERSION.SDK_INT >= 21) {
|
||||
} else {
|
||||
if (force) {
|
||||
Log.i(TAG, "firing intent...")
|
||||
showConfirmDeviceCredentialIntent()
|
||||
@@ -52,9 +59,6 @@ class BiometricDeviceAuthentication(
|
||||
Log.i(TAG, "Skipping firing intent unless forced")
|
||||
}
|
||||
true
|
||||
} else {
|
||||
Log.w(TAG, "Not compatible...")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +69,6 @@ class BiometricDeviceAuthentication(
|
||||
|
||||
class BiometricDeviceLockContract : ActivityResultContract<String, Int>() {
|
||||
|
||||
@RequiresApi(api = 21)
|
||||
override fun createIntent(context: Context, input: String): Intent {
|
||||
val keyguardManager = ServiceUtil.getKeyguardManager(context)
|
||||
return keyguardManager.createConfirmDeviceCredentialIntent(input, "")
|
||||
|
||||
@@ -26,7 +26,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
@@ -71,7 +71,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
protected void onCreate(Bundle icicle, boolean ready) {
|
||||
if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) {
|
||||
boolean includeSms = Util.isDefaultSmsProvider(this) && SignalStore.misc().getSmsExportPhase().allowSmsFeatures();
|
||||
int displayMode = includeSms ? DisplayMode.FLAG_ALL : DisplayMode.FLAG_PUSH | DisplayMode.FLAG_ACTIVE_GROUPS | DisplayMode.FLAG_INACTIVE_GROUPS | DisplayMode.FLAG_SELF;
|
||||
int displayMode = includeSms ? ContactSelectionDisplayMode.FLAG_ALL : ContactSelectionDisplayMode.FLAG_PUSH | ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_SELF;
|
||||
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
|
||||
class ContactSelectionListAdapter(
|
||||
context: Context,
|
||||
fixedContacts: Set<ContactSearchKey>,
|
||||
displayCheckBox: Boolean,
|
||||
displaySmsTag: DisplaySmsTag,
|
||||
displaySecondaryInformation: DisplaySecondaryInformation,
|
||||
onClickCallbacks: OnContactSelectionClick,
|
||||
longClickCallbacks: LongClickCallbacks,
|
||||
storyContextMenuCallbacks: StoryContextMenuCallbacks
|
||||
) : ContactSearchAdapter(context, fixedContacts, displayCheckBox, displaySmsTag, displaySecondaryInformation, onClickCallbacks, longClickCallbacks, storyContextMenuCallbacks) {
|
||||
|
||||
init {
|
||||
registerFactory(NewGroupModel::class.java, LayoutFactory({ NewGroupViewHolder(it, onClickCallbacks::onNewGroupClicked) }, R.layout.contact_selection_new_group_item))
|
||||
registerFactory(InviteToSignalModel::class.java, LayoutFactory({ InviteToSignalViewHolder(it, onClickCallbacks::onInviteToSignalClicked) }, R.layout.contact_selection_invite_action_item))
|
||||
}
|
||||
|
||||
class NewGroupModel : MappingModel<NewGroupModel> {
|
||||
override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true
|
||||
}
|
||||
|
||||
class InviteToSignalModel : MappingModel<InviteToSignalModel> {
|
||||
override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
|
||||
}
|
||||
|
||||
private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<InviteToSignalModel>(itemView) {
|
||||
init {
|
||||
itemView.setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: InviteToSignalModel) = Unit
|
||||
}
|
||||
|
||||
private class NewGroupViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<NewGroupModel>(itemView) {
|
||||
init {
|
||||
itemView.setOnClickListener { onClickListener() }
|
||||
}
|
||||
|
||||
override fun bind(model: NewGroupModel) = Unit
|
||||
}
|
||||
|
||||
class ArbitraryRepository : org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository {
|
||||
|
||||
enum class ArbitraryRow(val code: String) {
|
||||
NEW_GROUP("new-group"),
|
||||
INVITE_TO_SIGNAL("invite-to-signal");
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: String) = values().first { it.code == code }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int {
|
||||
return if (query.isNullOrEmpty()) section.types.size else 0
|
||||
}
|
||||
|
||||
override fun getData(section: ContactSearchConfiguration.Section.Arbitrary, query: String?, startIndex: Int, endIndex: Int, totalSearchSize: Int): List<ContactSearchData.Arbitrary> {
|
||||
check(section.types.size == 1)
|
||||
return listOf(ContactSearchData.Arbitrary(section.types.first()))
|
||||
}
|
||||
|
||||
override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> {
|
||||
val code = ArbitraryRow.fromCode(arbitrary.type)
|
||||
return when (code) {
|
||||
ArbitraryRow.NEW_GROUP -> NewGroupModel()
|
||||
ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface OnContactSelectionClick : ClickCallbacks {
|
||||
fun onNewGroupClicked()
|
||||
fun onInviteToSignalClicked()
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import android.Manifest;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.graphics.Rect;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
@@ -40,10 +39,7 @@ import androidx.annotation.Px;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.loader.app.LoaderManager;
|
||||
import androidx.loader.content.Loader;
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
@@ -59,38 +55,36 @@ import com.pnikosis.materialishprogress.ProgressWheel;
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
|
||||
import org.thoughtcrime.securesms.contacts.AbstractContactsCursorLoader;
|
||||
import org.thoughtcrime.securesms.contacts.ContactChipViewModel;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.HeaderAction;
|
||||
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContacts;
|
||||
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.sharing.ShareContact;
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.adapter.FixedViewsAdapter;
|
||||
import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapterStickyHeader;
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter;
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -105,7 +99,6 @@ import kotlin.Unit;
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
public final class ContactSelectionListFragment extends LoggingFragment
|
||||
implements LoaderManager.LoaderCallbacks<Cursor>
|
||||
{
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(ContactSelectionListFragment.class);
|
||||
@@ -137,27 +130,23 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
private String cursorFilter;
|
||||
private RecyclerView recyclerView;
|
||||
private RecyclerViewFastScroller fastScroller;
|
||||
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
|
||||
private RecyclerView chipRecycler;
|
||||
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
|
||||
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
|
||||
private MappingAdapter contactChipAdapter;
|
||||
private ContactChipViewModel contactChipViewModel;
|
||||
private LifecycleDisposable lifecycleDisposable;
|
||||
private HeaderActionProvider headerActionProvider;
|
||||
private TextView headerActionView;
|
||||
private ContactSearchMediator contactSearchMediator;
|
||||
|
||||
@Nullable private FixedViewsAdapter headerAdapter;
|
||||
@Nullable private FixedViewsAdapter footerAdapter;
|
||||
@Nullable private ListCallback listCallback;
|
||||
@Nullable private ScrollCallback scrollCallback;
|
||||
@Nullable private OnItemLongClickListener onItemLongClickListener;
|
||||
private GlideRequests glideRequests;
|
||||
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
|
||||
private Set<RecipientId> currentSelection;
|
||||
private boolean isMulti;
|
||||
private boolean hideCount;
|
||||
private boolean canSelectSelf;
|
||||
private ListClickListener listClickListener = new ListClickListener();
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull Context context) {
|
||||
@@ -191,14 +180,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) getParentFragment();
|
||||
}
|
||||
|
||||
if (context instanceof AbstractContactsCursorLoaderFactoryProvider) {
|
||||
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context;
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof AbstractContactsCursorLoaderFactoryProvider) {
|
||||
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) getParentFragment();
|
||||
}
|
||||
|
||||
if (context instanceof HeaderActionProvider) {
|
||||
headerActionProvider = (HeaderActionProvider) context;
|
||||
}
|
||||
@@ -234,16 +215,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
if (!TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) {
|
||||
handleContactPermissionGranted();
|
||||
} else {
|
||||
LoaderManager.getInstance(this).initLoader(0, null, this);
|
||||
contactSearchMediator.refresh();
|
||||
}
|
||||
})
|
||||
.onAnyDenied(() -> {
|
||||
FragmentActivity activity = requireActivity();
|
||||
requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
|
||||
|
||||
activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
|
||||
|
||||
if (safeArguments().getBoolean(RECENTS, activity.getIntent().getBooleanExtra(RECENTS, false))) {
|
||||
LoaderManager.getInstance(this).initLoader(0, null, ContactSelectionListFragment.this);
|
||||
if (safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false))) {
|
||||
contactSearchMediator.refresh();
|
||||
} else {
|
||||
initializeNoContactsPermission();
|
||||
}
|
||||
@@ -305,7 +284,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
swipeRefresh.setNestedScrollingEnabled(isRefreshable);
|
||||
swipeRefresh.setEnabled(isRefreshable);
|
||||
|
||||
hideCount = arguments.getBoolean(HIDE_COUNT, intent.getBooleanExtra(HIDE_COUNT, false));
|
||||
selectionLimit = arguments.getParcelable(SELECTION_LIMITS);
|
||||
if (selectionLimit == null) {
|
||||
selectionLimit = intent.getParcelableExtra(SELECTION_LIMITS);
|
||||
@@ -353,6 +331,66 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
headerActionView.setEnabled(false);
|
||||
}
|
||||
|
||||
contactSearchMediator = new ContactSearchMediator(
|
||||
this,
|
||||
currentSelection.stream()
|
||||
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
|
||||
.collect(java.util.stream.Collectors.toSet()),
|
||||
selectionLimit,
|
||||
isMulti,
|
||||
ContactSearchAdapter.DisplaySmsTag.DEFAULT,
|
||||
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
|
||||
this::mapStateToConfiguration,
|
||||
new ContactSearchMediator.SimpleCallbacks() {
|
||||
@Override
|
||||
public void onAdapterListCommitted(int size) {
|
||||
onLoadFinished(size);
|
||||
}
|
||||
},
|
||||
false,
|
||||
(context, fixedContacts, displayCheckBox, displaySmsTag, displaySecondaryInformation, callbacks, longClickCallbacks, storyContextMenuCallbacks) -> new ContactSelectionListAdapter(
|
||||
context,
|
||||
fixedContacts,
|
||||
displayCheckBox,
|
||||
displaySmsTag,
|
||||
displaySecondaryInformation,
|
||||
new ContactSelectionListAdapter.OnContactSelectionClick() {
|
||||
@Override
|
||||
public void onNewGroupClicked() {
|
||||
listCallback.onNewGroup(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInviteToSignalClicked() {
|
||||
listCallback.onInvite();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
|
||||
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
|
||||
callbacks.onExpandClicked(expand);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
|
||||
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
|
||||
}
|
||||
},
|
||||
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
|
||||
storyContextMenuCallbacks
|
||||
),
|
||||
new ContactSelectionListAdapter.ArbitraryRepository()
|
||||
);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -372,27 +410,30 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
|
||||
public @NonNull List<SelectedContact> getSelectedContacts() {
|
||||
if (cursorRecyclerViewAdapter == null) {
|
||||
if (contactSearchMediator == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return cursorRecyclerViewAdapter.getSelectedContacts();
|
||||
return contactSearchMediator.getSelectedContacts()
|
||||
.stream()
|
||||
.map(ContactSearchKey::requireSelectedContact)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
public int getSelectedContactsCount() {
|
||||
if (cursorRecyclerViewAdapter == null) {
|
||||
if (contactSearchMediator == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return cursorRecyclerViewAdapter.getSelectedContactsCount();
|
||||
return contactSearchMediator.getSelectedContacts().size();
|
||||
}
|
||||
|
||||
public int getTotalMemberCount() {
|
||||
if (cursorRecyclerViewAdapter == null) {
|
||||
if (contactSearchMediator == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return cursorRecyclerViewAdapter.getSelectedContactsCount() + cursorRecyclerViewAdapter.getCurrentContactsCount();
|
||||
return getSelectedContactsCount() + contactSearchMediator.getFixedContactsSize();
|
||||
}
|
||||
|
||||
private Set<RecipientId> getCurrentSelection() {
|
||||
@@ -402,7 +443,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
|
||||
return currentSelection == null ? Collections.emptySet()
|
||||
: Collections.unmodifiableSet(Stream.of(currentSelection).collect(Collectors.toSet()));
|
||||
: Collections.unmodifiableSet(new HashSet<>(currentSelection));
|
||||
}
|
||||
|
||||
public boolean isMulti() {
|
||||
@@ -410,34 +451,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
|
||||
private void initializeCursor() {
|
||||
glideRequests = GlideApp.with(this);
|
||||
|
||||
cursorRecyclerViewAdapter = new ContactSelectionListAdapter(requireContext(),
|
||||
glideRequests,
|
||||
null,
|
||||
new ListClickListener(),
|
||||
isMulti,
|
||||
currentSelection,
|
||||
safeArguments().getInt(ContactSelectionArguments.CHECKBOX_RESOURCE, R.drawable.contact_selection_checkbox));
|
||||
|
||||
RecyclerViewConcatenateAdapterStickyHeader concatenateAdapter = new RecyclerViewConcatenateAdapterStickyHeader();
|
||||
|
||||
if (listCallback != null) {
|
||||
headerAdapter = new FixedViewsAdapter(createNewGroupItem(listCallback));
|
||||
headerAdapter.hide();
|
||||
concatenateAdapter.addAdapter(headerAdapter);
|
||||
}
|
||||
|
||||
concatenateAdapter.addAdapter(cursorRecyclerViewAdapter);
|
||||
|
||||
if (listCallback != null) {
|
||||
footerAdapter = new FixedViewsAdapter(createInviteActionView(listCallback));
|
||||
footerAdapter.hide();
|
||||
concatenateAdapter.addAdapter(footerAdapter);
|
||||
}
|
||||
|
||||
recyclerView.addItemDecoration(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders));
|
||||
recyclerView.setAdapter(concatenateAdapter);
|
||||
recyclerView.setAdapter(contactSearchMediator.getAdapter());
|
||||
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
|
||||
@Override
|
||||
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
|
||||
@@ -458,20 +473,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
return hasQueryFilter() || shouldDisplayRecents();
|
||||
}
|
||||
|
||||
private View createInviteActionView(@NonNull ListCallback listCallback) {
|
||||
View view = LayoutInflater.from(requireContext())
|
||||
.inflate(R.layout.contact_selection_invite_action_item, (ViewGroup) requireView(), false);
|
||||
view.setOnClickListener(v -> listCallback.onInvite());
|
||||
return view;
|
||||
}
|
||||
|
||||
private View createNewGroupItem(@NonNull ListCallback listCallback) {
|
||||
View view = LayoutInflater.from(requireContext())
|
||||
.inflate(R.layout.contact_selection_new_group_item, (ViewGroup) requireView(), false);
|
||||
view.setOnClickListener(v -> listCallback.onNewGroup(false));
|
||||
return view;
|
||||
}
|
||||
|
||||
private void initializeNoContactsPermission() {
|
||||
swipeRefresh.setVisibility(View.GONE);
|
||||
|
||||
@@ -496,7 +497,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
|
||||
public void setQueryFilter(String filter) {
|
||||
this.cursorFilter = filter;
|
||||
LoaderManager.getInstance(this).restartLoader(0, null, this);
|
||||
contactSearchMediator.onFilterChanged(filter);
|
||||
}
|
||||
|
||||
public void resetQueryFilter() {
|
||||
@@ -513,51 +514,21 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
cursorRecyclerViewAdapter.clearSelectedContacts();
|
||||
|
||||
if (!isDetached() && !isRemoving() && getActivity() != null && !getActivity().isFinishing()) {
|
||||
LoaderManager.getInstance(this).restartLoader(0, null, this);
|
||||
}
|
||||
contactSearchMediator.clearSelection();
|
||||
fastScroller.setVisibility(View.GONE);
|
||||
headerActionView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public void setRecyclerViewPaddingBottom(@Px int paddingBottom) {
|
||||
ViewUtil.setPaddingBottom(recyclerView, paddingBottom);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
|
||||
FragmentActivity activity = requireActivity();
|
||||
int displayMode = safeArguments().getInt(DISPLAY_MODE, activity.getIntent().getIntExtra(DISPLAY_MODE, DisplayMode.FLAG_ALL));
|
||||
boolean displayRecents = shouldDisplayRecents();
|
||||
|
||||
if (cursorFactoryProvider != null) {
|
||||
return cursorFactoryProvider.get().create();
|
||||
} else {
|
||||
return new ContactsCursorLoader.Factory(activity, displayMode, cursorFilter, displayRecents).create();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadFinished(@NonNull Loader<Cursor> loader, @Nullable Cursor data) {
|
||||
private void onLoadFinished(int count) {
|
||||
swipeRefresh.setVisibility(View.VISIBLE);
|
||||
showContactsLayout.setVisibility(View.GONE);
|
||||
|
||||
cursorRecyclerViewAdapter.changeCursor(data);
|
||||
|
||||
if (footerAdapter != null) {
|
||||
footerAdapter.show();
|
||||
}
|
||||
|
||||
if (headerAdapter != null) {
|
||||
if (TextUtils.isEmpty(cursorFilter)) {
|
||||
headerAdapter.show();
|
||||
} else {
|
||||
headerAdapter.hide();
|
||||
}
|
||||
}
|
||||
|
||||
emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
|
||||
boolean useFastScroller = data != null && data.getCount() > 20;
|
||||
boolean useFastScroller = count > 20;
|
||||
recyclerView.setVerticalScrollBarEnabled(!useFastScroller);
|
||||
if (useFastScroller) {
|
||||
fastScroller.setVisibility(View.VISIBLE);
|
||||
@@ -574,13 +545,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
|
||||
cursorRecyclerViewAdapter.changeCursor(null);
|
||||
fastScroller.setVisibility(View.GONE);
|
||||
headerActionView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private boolean shouldDisplayRecents() {
|
||||
return safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
|
||||
}
|
||||
@@ -634,20 +598,15 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
*
|
||||
* @param contacts List of the contacts to select. This will not overwrite the current selection, but append to it.
|
||||
*/
|
||||
public void markSelected(@NonNull Set<ShareContact> contacts) {
|
||||
public void markSelected(@NonNull Set<RecipientId> contacts) {
|
||||
if (contacts.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<SelectedContact> toMarkSelected = contacts.stream()
|
||||
.map(contact -> {
|
||||
if (contact.getRecipientId().isPresent()) {
|
||||
return SelectedContact.forRecipientId(contact.getRecipientId().get());
|
||||
} else {
|
||||
return SelectedContact.forPhone(null, contact.getNumber());
|
||||
}
|
||||
})
|
||||
.filter(c -> !cursorRecyclerViewAdapter.isSelectedContact(c))
|
||||
.filter(r -> !contactSearchMediator.getSelectedContacts()
|
||||
.contains(new ContactSearchKey.RecipientSearchKey(r, false)))
|
||||
.map(SelectedContact::forRecipientId)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
|
||||
if (toMarkSelected.isEmpty()) {
|
||||
@@ -657,22 +616,18 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
for (final SelectedContact selectedContact : toMarkSelected) {
|
||||
markContactSelected(selectedContact);
|
||||
}
|
||||
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount());
|
||||
}
|
||||
|
||||
private class ListClickListener implements ContactSelectionListAdapter.ItemClickListener {
|
||||
@Override
|
||||
public void onItemClick(ContactSelectionListItem contact) {
|
||||
SelectedContact selectedContact = contact.isUsernameType() ? SelectedContact.forUsername(contact.getRecipientId().orElse(null), contact.getNumber())
|
||||
: SelectedContact.forPhone(contact.getRecipientId().orElse(null), contact.getNumber());
|
||||
private class ListClickListener {
|
||||
public void onItemClick(ContactSearchKey contact) {
|
||||
SelectedContact selectedContact = contact.requireSelectedContact();
|
||||
|
||||
if (!canSelectSelf && !selectedContact.hasUsername() && Recipient.self().getId().equals(selectedContact.getOrCreateRecipientId(requireContext()))) {
|
||||
Toast.makeText(requireContext(), R.string.ContactSelectionListFragment_you_do_not_need_to_add_yourself_to_the_group, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMulti || !cursorRecyclerViewAdapter.isSelectedContact(selectedContact)) {
|
||||
if (!isMulti || !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
|
||||
if (selectionHardLimitReached()) {
|
||||
if (onSelectionLimitReachedListener != null) {
|
||||
onSelectionLimitReachedListener.onHardLimitReached(selectionLimit.getHardLimit());
|
||||
@@ -682,63 +637,58 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
return;
|
||||
}
|
||||
|
||||
if (contact.isUsernameType()) {
|
||||
if (contact instanceof ContactSearchKey.UnknownRecipientKey && ((ContactSearchKey.UnknownRecipientKey) contact).getSectionKey() == ContactSearchConfiguration.SectionKey.USERNAME) {
|
||||
String username = ((ContactSearchKey.UnknownRecipientKey) contact).getQuery();
|
||||
AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext());
|
||||
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
|
||||
return UsernameUtil.fetchAciForUsername(contact.getNumber());
|
||||
return UsernameUtil.fetchAciForUsername(username);
|
||||
}, uuid -> {
|
||||
loadingDialog.dismiss();
|
||||
if (uuid.isPresent()) {
|
||||
Recipient recipient = Recipient.externalUsername(uuid.get(), contact.getNumber());
|
||||
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
|
||||
Recipient recipient = Recipient.externalUsername(uuid.get(), username);
|
||||
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), username);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null, allowed -> {
|
||||
if (allowed) {
|
||||
markContactSelected(selected);
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
markContactSelected(selected);
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
}
|
||||
} else {
|
||||
new MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.ContactSelectionListFragment_username_not_found)
|
||||
.setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, contact.getNumber()))
|
||||
.setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, username))
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
|
||||
.show();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onBeforeContactSelected(contact.getRecipientId(), contact.getNumber(), allowed -> {
|
||||
onContactSelectedListener.onBeforeContactSelected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber(), allowed -> {
|
||||
if (allowed) {
|
||||
markContactSelected(selectedContact);
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
markContactSelected(selectedContact);
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
markContactUnselected(selectedContact);
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onContactDeselected(contact.getRecipientId(), contact.getNumber());
|
||||
onContactSelectedListener.onContactDeselected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onItemLongClick(ContactSelectionListItem item) {
|
||||
public boolean onItemLongClick(View anchorView, ContactSearchKey item) {
|
||||
if (onItemLongClickListener != null) {
|
||||
return onItemLongClickListener.onLongClick(item, recyclerView);
|
||||
return onItemLongClickListener.onLongClick(anchorView, item, recyclerView);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
@@ -758,7 +708,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
|
||||
private void markContactSelected(@NonNull SelectedContact selectedContact) {
|
||||
cursorRecyclerViewAdapter.addSelectedContact(selectedContact);
|
||||
contactSearchMediator.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey()));
|
||||
if (isMulti) {
|
||||
addChipForSelectedContact(selectedContact);
|
||||
}
|
||||
@@ -768,8 +718,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
|
||||
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
|
||||
cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact);
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
contactSearchMediator.setKeysNotSelected(Collections.singleton(selectedContact.toContactSearchKey()));
|
||||
contactChipViewModel.remove(selectedContact);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
@@ -842,6 +791,116 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
chipRecycler.smoothScrollBy(x, 0);
|
||||
}
|
||||
|
||||
private @NonNull ContactSearchConfiguration mapStateToConfiguration(@NonNull ContactSearchState contactSearchState) {
|
||||
int displayMode = safeArguments().getInt(DISPLAY_MODE, requireActivity().getIntent().getIntExtra(DISPLAY_MODE, ContactSelectionDisplayMode.FLAG_ALL));
|
||||
|
||||
boolean includeRecents = safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
|
||||
boolean includePushContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_PUSH);
|
||||
boolean includeSmsContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_SMS);
|
||||
boolean includeActiveGroups = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS);
|
||||
boolean includeInactiveGroups = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS);
|
||||
boolean includeSelf = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_SELF);
|
||||
boolean includeV1Groups = !flagSet(displayMode, ContactSelectionDisplayMode.FLAG_HIDE_GROUPS_V1);
|
||||
boolean includeNew = !flagSet(displayMode, ContactSelectionDisplayMode.FLAG_HIDE_NEW);
|
||||
boolean includeRecentsHeader = !flagSet(displayMode, ContactSelectionDisplayMode.FLAG_HIDE_RECENT_HEADER);
|
||||
boolean includeGroupsAfterContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS);
|
||||
boolean blocked = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_BLOCK);
|
||||
|
||||
ContactSearchConfiguration.TransportType transportType = resolveTransportType(includePushContacts, includeSmsContacts);
|
||||
ContactSearchConfiguration.Section.Recents.Mode mode = resolveRecentsMode(transportType, includeActiveGroups);
|
||||
ContactSearchConfiguration.NewRowMode newRowMode = resolveNewRowMode(blocked, includeActiveGroups);
|
||||
|
||||
return ContactSearchConfiguration.build(builder -> {
|
||||
builder.setQuery(contactSearchState.getQuery());
|
||||
|
||||
if (listCallback != null) {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode());
|
||||
}
|
||||
|
||||
if (transportType != null) {
|
||||
if (TextUtils.isEmpty(contactSearchState.getQuery()) && includeRecents) {
|
||||
builder.addSection(new ContactSearchConfiguration.Section.Recents(
|
||||
25,
|
||||
mode,
|
||||
includeInactiveGroups,
|
||||
includeV1Groups,
|
||||
includeSmsContacts,
|
||||
includeSelf,
|
||||
includeRecentsHeader,
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
builder.addSection(new ContactSearchConfiguration.Section.Individuals(
|
||||
includeSelf,
|
||||
transportType,
|
||||
true,
|
||||
null,
|
||||
!hideLetterHeaders()
|
||||
));
|
||||
}
|
||||
|
||||
if ((includeGroupsAfterContacts || !TextUtils.isEmpty(contactSearchState.getQuery())) && includeActiveGroups) {
|
||||
builder.addSection(new ContactSearchConfiguration.Section.Groups(
|
||||
includeSmsContacts,
|
||||
includeV1Groups,
|
||||
includeInactiveGroups,
|
||||
false,
|
||||
ContactSearchSortOrder.NATURAL,
|
||||
false,
|
||||
true,
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
if (listCallback != null) {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
|
||||
}
|
||||
|
||||
if (includeNew) {
|
||||
builder.phone(newRowMode);
|
||||
builder.username(newRowMode);
|
||||
}
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
private static @Nullable ContactSearchConfiguration.TransportType resolveTransportType(boolean includePushContacts, boolean includeSmsContacts) {
|
||||
if (includePushContacts && includeSmsContacts) {
|
||||
return ContactSearchConfiguration.TransportType.ALL;
|
||||
} else if (includePushContacts) {
|
||||
return ContactSearchConfiguration.TransportType.PUSH;
|
||||
} else if (includeSmsContacts) {
|
||||
return ContactSearchConfiguration.TransportType.SMS;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull ContactSearchConfiguration.Section.Recents.Mode resolveRecentsMode(ContactSearchConfiguration.TransportType transportType, boolean includeGroupContacts) {
|
||||
if (transportType != null && includeGroupContacts) {
|
||||
return ContactSearchConfiguration.Section.Recents.Mode.ALL;
|
||||
} else if (includeGroupContacts) {
|
||||
return ContactSearchConfiguration.Section.Recents.Mode.GROUPS;
|
||||
} else {
|
||||
return ContactSearchConfiguration.Section.Recents.Mode.INDIVIDUALS;
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull ContactSearchConfiguration.NewRowMode resolveNewRowMode(boolean isBlocked, boolean isActiveGroups) {
|
||||
if (isBlocked) {
|
||||
return ContactSearchConfiguration.NewRowMode.BLOCK;
|
||||
} else if (isActiveGroups) {
|
||||
return ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION;
|
||||
} else {
|
||||
return ContactSearchConfiguration.NewRowMode.ADD_TO_GROUP;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean flagSet(int mode, int flag) {
|
||||
return (mode & flag) > 0;
|
||||
}
|
||||
|
||||
public interface OnContactSelectedListener {
|
||||
/**
|
||||
* Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it.
|
||||
@@ -874,10 +933,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
|
||||
public interface OnItemLongClickListener {
|
||||
boolean onLongClick(ContactSelectionListItem contactSelectionListItem, RecyclerView recyclerView);
|
||||
}
|
||||
|
||||
public interface AbstractContactsCursorLoaderFactoryProvider {
|
||||
@NonNull AbstractContactsCursorLoader.Factory get();
|
||||
boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,27 +140,18 @@ public class DeviceActivity extends PassphraseRequiredActivity
|
||||
Uri uri = Uri.parse(data);
|
||||
deviceLinkFragment.setLinkClickedListener(uri, DeviceActivity.this);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 21) {
|
||||
deviceAddFragment.setSharedElementReturnTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(R.transition.fragment_shared));
|
||||
deviceAddFragment.setExitTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(android.R.transition.fade));
|
||||
deviceAddFragment.setSharedElementReturnTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(R.transition.fragment_shared));
|
||||
deviceAddFragment.setExitTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(android.R.transition.fade));
|
||||
|
||||
deviceLinkFragment.setSharedElementEnterTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(R.transition.fragment_shared));
|
||||
deviceLinkFragment.setEnterTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(android.R.transition.fade));
|
||||
deviceLinkFragment.setSharedElementEnterTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(R.transition.fragment_shared));
|
||||
deviceLinkFragment.setEnterTransition(TransitionInflater.from(DeviceActivity.this).inflateTransition(android.R.transition.fade));
|
||||
|
||||
getSupportFragmentManager().beginTransaction()
|
||||
.addToBackStack(null)
|
||||
.addSharedElement(deviceAddFragment.getDevicesImage(), "devices")
|
||||
.replace(R.id.fragment_container, deviceLinkFragment)
|
||||
.commit();
|
||||
getSupportFragmentManager().beginTransaction()
|
||||
.addToBackStack(null)
|
||||
.addSharedElement(deviceAddFragment.getDevicesImage(), "devices")
|
||||
.replace(R.id.fragment_container, deviceLinkFragment)
|
||||
.commit();
|
||||
|
||||
} else {
|
||||
getSupportFragmentManager().beginTransaction()
|
||||
.setCustomAnimations(R.anim.slide_from_bottom, R.anim.slide_to_bottom,
|
||||
R.anim.slide_from_bottom, R.anim.slide_to_bottom)
|
||||
.replace(R.id.fragment_container, deviceLinkFragment)
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -41,22 +41,19 @@ public class DeviceAddFragment extends LoggingFragment {
|
||||
this.devicesImage = container.findViewById(R.id.devices);
|
||||
ViewCompat.setTransitionName(devicesImage, "devices");
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 21) {
|
||||
container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
|
||||
@TargetApi(21)
|
||||
@Override
|
||||
public void onLayoutChange(View v, int left, int top, int right, int bottom,
|
||||
int oldLeft, int oldTop, int oldRight, int oldBottom)
|
||||
{
|
||||
v.removeOnLayoutChangeListener(this);
|
||||
container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
|
||||
@Override
|
||||
public void onLayoutChange(View v, int left, int top, int right, int bottom,
|
||||
int oldLeft, int oldTop, int oldRight, int oldBottom)
|
||||
{
|
||||
v.removeOnLayoutChangeListener(this);
|
||||
|
||||
Animator reveal = ViewAnimationUtils.createCircularReveal(v, right, bottom, 0, (int) Math.hypot(right, bottom));
|
||||
reveal.setInterpolator(new DecelerateInterpolator(2f));
|
||||
reveal.setDuration(800);
|
||||
reveal.start();
|
||||
}
|
||||
});
|
||||
}
|
||||
Animator reveal = ViewAnimationUtils.createCircularReveal(v, right, bottom, 0, (int) Math.hypot(right, bottom));
|
||||
reveal.setInterpolator(new DecelerateInterpolator(2f));
|
||||
reveal.setDuration(800);
|
||||
reveal.start();
|
||||
}
|
||||
});
|
||||
|
||||
scannerView.start(getViewLifecycleOwner(), CameraXModelBlocklist.isBlocklisted());
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
|
||||
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView.OnFilterChangedListener;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
@@ -62,7 +62,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState, boolean ready) {
|
||||
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, DisplayMode.FLAG_SMS);
|
||||
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, ContactSelectionDisplayMode.FLAG_SMS);
|
||||
getIntent().putExtra(ContactSelectionListFragment.SELECTION_LIMITS, SelectionLimits.NO_LIMITS);
|
||||
getIntent().putExtra(ContactSelectionListFragment.HIDE_COUNT, true);
|
||||
getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false);
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -14,6 +15,7 @@ import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
||||
import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment;
|
||||
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferLockedDialog;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.stories.Stories;
|
||||
@@ -37,6 +39,8 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
private VoiceNoteMediaController mediaController;
|
||||
private ConversationListTabsViewModel conversationListTabsViewModel;
|
||||
|
||||
private boolean onFirstRender = false;
|
||||
|
||||
public static @NonNull Intent clearTop(@NonNull Context context) {
|
||||
Intent intent = new Intent(context, MainActivity.class);
|
||||
|
||||
@@ -53,8 +57,23 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mediaController = new VoiceNoteMediaController(this);
|
||||
|
||||
mediaController = new VoiceNoteMediaController(this, true);
|
||||
|
||||
ConversationListTabRepository repository = new ConversationListTabRepository();
|
||||
ConversationListTabsViewModel.Factory factory = new ConversationListTabsViewModel.Factory(repository);
|
||||
@@ -98,6 +117,11 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
OldDeviceTransferLockedDialog.show(getSupportFragmentManager());
|
||||
}
|
||||
|
||||
if (SignalStore.misc().getShouldShowLinkedDevicesReminder()) {
|
||||
SignalStore.misc().setShouldShowLinkedDevicesReminder(false);
|
||||
RelinkDevicesReminderBottomSheetFragment.show(getSupportFragmentManager());
|
||||
}
|
||||
|
||||
updateTabVisibility();
|
||||
}
|
||||
|
||||
@@ -158,6 +182,10 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
}
|
||||
}
|
||||
|
||||
public void onFirstRender() {
|
||||
onFirstRender = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull VoiceNoteMediaController getVoiceNoteMediaController() {
|
||||
return mediaController;
|
||||
|
||||
@@ -20,6 +20,7 @@ import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
@@ -39,26 +40,22 @@ import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem;
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
|
||||
import org.thoughtcrime.securesms.contacts.management.ContactsManagementRepository;
|
||||
import org.thoughtcrime.securesms.contacts.management.ContactsManagementViewModel;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -180,22 +177,23 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
super.onOptionsItemSelected(item);
|
||||
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home:
|
||||
super.onBackPressed();
|
||||
return true;
|
||||
case R.id.menu_refresh:
|
||||
handleManualRefresh();
|
||||
return true;
|
||||
case R.id.menu_new_group:
|
||||
handleCreateGroup();
|
||||
return true;
|
||||
case R.id.menu_invite:
|
||||
handleInvite();
|
||||
return true;
|
||||
}
|
||||
int itemId = item.getItemId();
|
||||
|
||||
return false;
|
||||
if (itemId == android.R.id.home) {
|
||||
super.onBackPressed();
|
||||
return true;
|
||||
} else if (itemId == R.id.menu_refresh) {
|
||||
handleManualRefresh();
|
||||
return true;
|
||||
} else if (itemId == R.id.menu_new_group) {
|
||||
handleCreateGroup();
|
||||
return true;
|
||||
} else if (itemId == R.id.menu_invite) {
|
||||
handleInvite();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void handleManualRefresh() {
|
||||
@@ -233,18 +231,14 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onLongClick(ContactSelectionListItem contactSelectionListItem, RecyclerView recyclerView) {
|
||||
RecipientId recipientId = contactSelectionListItem.getRecipientId().orElse(null);
|
||||
if (recipientId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView) {
|
||||
RecipientId recipientId = contactSearchKey.requireRecipientSearchKey().getRecipientId();
|
||||
List<ActionItem> actions = generateContextualActionsForRecipient(recipientId);
|
||||
if (actions.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
new SignalContextMenu.Builder(contactSelectionListItem, (ViewGroup) contactSelectionListItem.getRootView())
|
||||
new SignalContextMenu.Builder(anchorView, (ViewGroup) anchorView.getRootView())
|
||||
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
|
||||
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
|
||||
.offsetX((int) DimensionUnit.DP.toPixels(12))
|
||||
|
||||
@@ -307,10 +307,8 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
|
||||
public Unit showConfirmDeviceCredentialIntent() {
|
||||
KeyguardManager keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE);
|
||||
Intent intent = null;
|
||||
if (Build.VERSION.SDK_INT >= 21) {
|
||||
intent = keyguardManager.createConfirmDeviceCredentialIntent(getString(R.string.PassphrasePromptActivity_unlock_signal), "");
|
||||
}
|
||||
Intent intent = keyguardManager.createConfirmDeviceCredentialIntent(getString(R.string.PassphrasePromptActivity_unlock_signal), "");
|
||||
|
||||
startActivityForResult(intent, AUTHENTICATE_REQUEST_CODE);
|
||||
return Unit.INSTANCE;
|
||||
}
|
||||
|
||||
@@ -20,20 +20,17 @@ import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity;
|
||||
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrationActivity;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
|
||||
import org.thoughtcrime.securesms.pin.PinRestoreActivity;
|
||||
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity;
|
||||
import org.thoughtcrime.securesms.profiles.username.AddAUsernameActivity;
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import java.util.Locale;
|
||||
@@ -55,7 +52,6 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
private static final int STATE_TRANSFER_ONGOING = 8;
|
||||
private static final int STATE_TRANSFER_LOCKED = 9;
|
||||
private static final int STATE_CHANGE_NUMBER_LOCK = 10;
|
||||
private static final int STATE_CREATE_USERNAME = 11;
|
||||
|
||||
private SignalServiceNetworkAccess networkAccess;
|
||||
private BroadcastReceiver clearKeyReceiver;
|
||||
@@ -160,7 +156,6 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
case STATE_TRANSFER_ONGOING: return getOldDeviceTransferIntent();
|
||||
case STATE_TRANSFER_LOCKED: return getOldDeviceTransferLockedIntent();
|
||||
case STATE_CHANGE_NUMBER_LOCK: return getChangeNumberLockIntent();
|
||||
case STATE_CREATE_USERNAME: return getCreateUsernameIntent();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -180,8 +175,6 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return STATE_CREATE_SIGNAL_PIN;
|
||||
} else if (userMustSetProfileName()) {
|
||||
return STATE_CREATE_PROFILE_NAME;
|
||||
} else if (shouldAskUserToCreateUsername()) {
|
||||
return STATE_CREATE_USERNAME;
|
||||
} else if (userMustCreateSignalPin()) {
|
||||
return STATE_CREATE_SIGNAL_PIN;
|
||||
} else if (EventBus.getDefault().getStickyEvent(TransferStatus.class) != null && getClass() != OldDeviceTransferActivity.class) {
|
||||
@@ -207,13 +200,6 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return !SignalStore.registrationValues().isRegistrationComplete() && Recipient.self().getProfileName().isEmpty();
|
||||
}
|
||||
|
||||
private boolean shouldAskUserToCreateUsername() {
|
||||
return FeatureFlags.usernames() &&
|
||||
FeatureFlags.phoneNumberPrivacy() &&
|
||||
!SignalStore.uiHints().hasSetOrSkippedUsernameCreation() &&
|
||||
SignalStore.phoneNumberPrivacy().getPhoneNumberListingMode() == PhoneNumberPrivacyValues.PhoneNumberListingMode.UNLISTED;
|
||||
}
|
||||
|
||||
private Intent getCreatePassphraseIntent() {
|
||||
return getRoutedIntent(PassphraseCreateActivity.class, getIntent());
|
||||
}
|
||||
@@ -273,10 +259,6 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return ChangeNumberLockActivity.createIntent(this);
|
||||
}
|
||||
|
||||
private Intent getCreateUsernameIntent() {
|
||||
return getRoutedIntent(AddAUsernameActivity.class, getIntent());
|
||||
}
|
||||
|
||||
private Intent getRoutedIntent(Intent destination, @Nullable Intent nextIntent) {
|
||||
if (nextIntent != null) destination.putExtra("next_intent", nextIntent);
|
||||
return destination;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.thoughtcrime.securesms.absbackup
|
||||
|
||||
/**
|
||||
* Abstracts away the implementation of pieces of data we want to hand off to various backup services.
|
||||
* Here we can control precisely which data gets backed up and more importantly, what does not.
|
||||
*/
|
||||
interface AndroidBackupItem {
|
||||
fun getKey(): String
|
||||
fun getDataForBackup(): ByteArray
|
||||
fun restoreData(data: ByteArray)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.thoughtcrime.securesms.absbackup
|
||||
|
||||
import android.app.backup.BackupAgent
|
||||
import android.app.backup.BackupDataInput
|
||||
import android.app.backup.BackupDataOutput
|
||||
import android.os.ParcelFileDescriptor
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.absbackup.backupables.KbsAuthTokens
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Uses the [Android Backup Service](https://developer.android.com/guide/topics/data/keyvaluebackup) and backs up everything in [items]
|
||||
*/
|
||||
class SignalBackupAgent : BackupAgent() {
|
||||
private val items: List<AndroidBackupItem> = listOf(
|
||||
KbsAuthTokens
|
||||
)
|
||||
|
||||
override fun onBackup(oldState: ParcelFileDescriptor?, data: BackupDataOutput, newState: ParcelFileDescriptor) {
|
||||
val contentsHash = cumulativeHashCode()
|
||||
if (oldState == null) {
|
||||
performBackup(data)
|
||||
} else {
|
||||
val hash = try {
|
||||
DataInputStream(FileInputStream(oldState.fileDescriptor)).use { it.readInt() }
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "No old state, may be first backup request or bug with not writing to newState at end.", e)
|
||||
}
|
||||
if (hash != contentsHash) {
|
||||
performBackup(data)
|
||||
}
|
||||
}
|
||||
|
||||
DataOutputStream(FileOutputStream(newState.fileDescriptor)).use { it.writeInt(contentsHash) }
|
||||
}
|
||||
|
||||
private fun performBackup(data: BackupDataOutput) {
|
||||
items.forEach {
|
||||
val backupData = it.getDataForBackup()
|
||||
data.writeEntityHeader(it.getKey(), backupData.size)
|
||||
data.writeEntityData(backupData, backupData.size)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRestore(dataInput: BackupDataInput, appVersionCode: Int, newState: ParcelFileDescriptor) {
|
||||
Log.i(TAG, "Restoring from Android Backup Service.")
|
||||
while (dataInput.readNextHeader()) {
|
||||
val buffer = ByteArray(dataInput.dataSize)
|
||||
dataInput.readEntityData(buffer, 0, dataInput.dataSize)
|
||||
items.find { dataInput.key == it.getKey() }?.restoreData(buffer)
|
||||
}
|
||||
DataOutputStream(FileOutputStream(newState.fileDescriptor)).use { it.writeInt(cumulativeHashCode()) }
|
||||
}
|
||||
|
||||
private fun cumulativeHashCode(): Int {
|
||||
return items.fold("") { acc: String, androidBackupItem: AndroidBackupItem -> acc + androidBackupItem.getDataForBackup().decodeToString() }.hashCode()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SignalBackupAgent"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.thoughtcrime.securesms.absbackup.backupables
|
||||
|
||||
import com.google.protobuf.InvalidProtocolBufferException
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.absbackup.AndroidBackupItem
|
||||
import org.thoughtcrime.securesms.absbackup.protos.KbsAuthToken
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* This backs up the not-secret KBS Auth tokens, which can be combined with a PIN to prove ownership of a phone number in order to complete the registration process.
|
||||
*/
|
||||
object KbsAuthTokens : AndroidBackupItem {
|
||||
private const val TAG = "KbsAuthTokens"
|
||||
|
||||
override fun getKey(): String {
|
||||
return TAG
|
||||
}
|
||||
|
||||
override fun getDataForBackup(): ByteArray {
|
||||
val proto = KbsAuthToken(tokens = SignalStore.kbsValues().kbsAuthTokenList)
|
||||
return proto.encode()
|
||||
}
|
||||
|
||||
override fun restoreData(data: ByteArray) {
|
||||
if (SignalStore.kbsValues().kbsAuthTokenList.isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val proto = KbsAuthToken.ADAPTER.decode(data)
|
||||
|
||||
SignalStore.kbsValues().putAuthTokenList(proto.tokens)
|
||||
} catch (e: InvalidProtocolBufferException) {
|
||||
Log.w(TAG, "Cannot restore KbsAuthToken from backup service.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ public abstract class AnimationCompleteListener implements Animator.AnimatorList
|
||||
public abstract void onAnimationEnd(Animator animation);
|
||||
|
||||
@Override
|
||||
public final void onAnimationCancel(Animator animation) {}
|
||||
public void onAnimationCancel(Animator animation) {}
|
||||
@Override
|
||||
public final void onAnimationRepeat(Animator animation) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.thoughtcrime.securesms.animation
|
||||
|
||||
import android.animation.Animator
|
||||
|
||||
abstract class AnimationStartListener : Animator.AnimatorListener {
|
||||
override fun onAnimationEnd(animation: Animator) = Unit
|
||||
override fun onAnimationCancel(animation: Animator) = Unit
|
||||
override fun onAnimationRepeat(animation: Animator) = Unit
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import android.view.ViewGroup
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.view.animation.Interpolator
|
||||
import androidx.annotation.RequiresApi
|
||||
|
||||
private const val POSITION_ON_SCREEN = "signal.circleavatartransition.positiononscreen"
|
||||
private const val WIDTH = "signal.circleavatartransition.width"
|
||||
@@ -22,7 +21,6 @@ private const val HEIGHT = "signal.circleavatartransition.height"
|
||||
/**
|
||||
* Custom transition for Circular avatars, because once you have multiple things animating stuff was getting broken and weird.
|
||||
*/
|
||||
@RequiresApi(21)
|
||||
class CircleAvatarTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
|
||||
override fun captureStartValues(transitionValues: TransitionValues) {
|
||||
captureValues(transitionValues)
|
||||
|
||||
@@ -13,7 +13,6 @@ import android.widget.ImageView;
|
||||
|
||||
import androidx.core.graphics.drawable.RoundedBitmapDrawable;
|
||||
|
||||
@TargetApi(21)
|
||||
abstract class CircleSquareImageViewTransition extends Transition {
|
||||
|
||||
private static final String CIRCLE_RATIO = "CIRCLE_RATIO";
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.util.AttributeSet;
|
||||
/**
|
||||
* Will only transition {@link android.widget.ImageView}s that contain a {@link androidx.core.graphics.drawable.RoundedBitmapDrawable}.
|
||||
*/
|
||||
@TargetApi(21)
|
||||
public final class CircleToSquareImageViewTransition extends CircleSquareImageViewTransition {
|
||||
public CircleToSquareImageViewTransition(Context context, AttributeSet attrs) {
|
||||
super(false);
|
||||
|
||||
@@ -7,11 +7,9 @@ import android.transition.Transition
|
||||
import android.transition.TransitionValues
|
||||
import android.util.AttributeSet
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.core.animation.doOnStart
|
||||
|
||||
@RequiresApi(21)
|
||||
class CrossfaderTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.util.AttributeSet;
|
||||
/**
|
||||
* Will only transition {@link android.widget.ImageView}s that contain a {@link androidx.core.graphics.drawable.RoundedBitmapDrawable}.
|
||||
*/
|
||||
@TargetApi(21)
|
||||
public final class SquareToCircleImageViewTransition extends CircleSquareImageViewTransition {
|
||||
public SquareToCircleImageViewTransition(Context context, AttributeSet attrs) {
|
||||
super(true);
|
||||
|
||||
@@ -10,7 +10,6 @@ import android.transition.TransitionValues
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.animation.addListener
|
||||
import androidx.fragment.app.FragmentContainerView
|
||||
|
||||
@@ -19,7 +18,6 @@ private const val BOUNDS = "signal.wipedowntransition.bottom"
|
||||
/**
|
||||
* WipeDownTransition will animate the bottom position of a view such that it "wipes" down the screen to a final position.
|
||||
*/
|
||||
@RequiresApi(21)
|
||||
class WipeDownTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
|
||||
override fun captureStartValues(transitionValues: TransitionValues) {
|
||||
captureValues(transitionValues)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.thoughtcrime.securesms.audio;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class AudioFileInfo {
|
||||
private final long durationUs;
|
||||
private final byte[] waveFormBytes;
|
||||
private final float[] waveForm;
|
||||
|
||||
public static @NonNull AudioFileInfo fromDatabaseProtobuf(@NonNull AudioWaveFormData audioWaveForm) {
|
||||
return new AudioFileInfo(audioWaveForm.getDurationUs(), audioWaveForm.getWaveForm().toByteArray());
|
||||
}
|
||||
|
||||
AudioFileInfo(long durationUs, byte[] waveFormBytes) {
|
||||
this.durationUs = durationUs;
|
||||
this.waveFormBytes = waveFormBytes;
|
||||
this.waveForm = new float[waveFormBytes.length];
|
||||
|
||||
for (int i = 0; i < waveFormBytes.length; i++) {
|
||||
int unsigned = waveFormBytes[i] & 0xff;
|
||||
this.waveForm[i] = unsigned / 255f;
|
||||
}
|
||||
}
|
||||
|
||||
public long getDuration(@NonNull TimeUnit timeUnit) {
|
||||
return timeUnit.convert(durationUs, TimeUnit.MICROSECONDS);
|
||||
}
|
||||
|
||||
public float[] getWaveForm() {
|
||||
return waveForm;
|
||||
}
|
||||
|
||||
public @NonNull AudioWaveFormData toDatabaseProtobuf() {
|
||||
return AudioWaveFormData.newBuilder()
|
||||
.setDurationUs(durationUs)
|
||||
.setWaveForm(ByteString.copyFrom(waveFormBytes))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -7,19 +7,20 @@ import android.os.Build;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.subjects.SingleSubject;
|
||||
|
||||
public class AudioRecorder {
|
||||
|
||||
private static final String TAG = Log.tag(AudioRecorder.class);
|
||||
@@ -27,19 +28,42 @@ public class AudioRecorder {
|
||||
private static final ExecutorService executor = SignalExecutors.newCachedSingleThreadExecutor("signal-AudioRecorder");
|
||||
|
||||
private final Context context;
|
||||
private final AudioRecordingHandler uiHandler;
|
||||
private final AudioRecorderFocusManager audioFocusManager;
|
||||
|
||||
private Recorder recorder;
|
||||
private Uri captureUri;
|
||||
|
||||
public AudioRecorder(@NonNull Context context) {
|
||||
this.context = context;
|
||||
audioFocusManager = AudioRecorderFocusManager.create(context, focusChange -> stopRecording());
|
||||
private SingleSubject<VoiceNoteDraft> recordingSubject;
|
||||
|
||||
public AudioRecorder(@NonNull Context context, @Nullable AudioRecordingHandler uiHandler) {
|
||||
this.context = context;
|
||||
this.uiHandler = uiHandler;
|
||||
|
||||
AudioManager.OnAudioFocusChangeListener onAudioFocusChangeListener;
|
||||
|
||||
if (this.uiHandler != null) {
|
||||
onAudioFocusChangeListener = focusChange -> {
|
||||
if (focusChange == AudioManager.AUDIOFOCUS_LOSS) {
|
||||
Log.i(TAG, "Audio focus change " + focusChange + " stopping recording");
|
||||
this.uiHandler.onRecordCanceled(false);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
onAudioFocusChangeListener = focusChange -> {
|
||||
if (focusChange == AudioManager.AUDIOFOCUS_LOSS) {
|
||||
Log.i(TAG, "Audio focus change " + focusChange + " stopping recording");
|
||||
stopRecording();
|
||||
}
|
||||
};
|
||||
}
|
||||
audioFocusManager = AudioRecorderFocusManager.create(context, onAudioFocusChangeListener);
|
||||
}
|
||||
|
||||
public void startRecording() {
|
||||
public @NonNull Single<VoiceNoteDraft> startRecording() {
|
||||
Log.i(TAG, "startRecording()");
|
||||
|
||||
final SingleSubject<VoiceNoteDraft> recordingSingle = SingleSubject.create();
|
||||
executor.execute(() -> {
|
||||
Log.i(TAG, "Running startRecording() + " + Thread.currentThread().getId());
|
||||
try {
|
||||
@@ -53,27 +77,29 @@ public class AudioRecorder {
|
||||
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
|
||||
.withMimeType(MediaUtil.AUDIO_AAC)
|
||||
.createForDraftAttachmentAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
|
||||
|
||||
recorder = Build.VERSION.SDK_INT >= 26 ? new MediaRecorderWrapper() : new AudioCodec();
|
||||
recorder = Build.VERSION.SDK_INT >= 26 ? new MediaRecorderWrapper() : new AudioCodec();
|
||||
int focusResult = audioFocusManager.requestAudioFocus();
|
||||
if (focusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
Log.w(TAG, "Could not gain audio focus. Received result code " + focusResult);
|
||||
}
|
||||
recorder.start(fds[1]);
|
||||
this.recordingSubject = recordingSingle;
|
||||
} catch (IOException e) {
|
||||
recordingSingle.onError(e);
|
||||
recorder = null;
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
});
|
||||
|
||||
return recordingSingle;
|
||||
}
|
||||
|
||||
public @NonNull ListenableFuture<VoiceNoteDraft> stopRecording() {
|
||||
public void stopRecording() {
|
||||
Log.i(TAG, "stopRecording()");
|
||||
|
||||
final SettableFuture<VoiceNoteDraft> future = new SettableFuture<>();
|
||||
|
||||
executor.execute(() -> {
|
||||
if (recorder == null) {
|
||||
sendToFuture(future, new IOException("MediaRecorder was never initialized successfully!"));
|
||||
Log.e(TAG, "MediaRecorder was never initialized successfully!");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,24 +108,15 @@ public class AudioRecorder {
|
||||
|
||||
try {
|
||||
long size = MediaUtil.getMediaSize(context, captureUri);
|
||||
sendToFuture(future, new VoiceNoteDraft(captureUri, size));
|
||||
recordingSubject.onSuccess(new VoiceNoteDraft(captureUri, size));
|
||||
} catch (IOException ioe) {
|
||||
Log.w(TAG, ioe);
|
||||
sendToFuture(future, ioe);
|
||||
recordingSubject.onError(ioe);
|
||||
}
|
||||
|
||||
recorder = null;
|
||||
captureUri = null;
|
||||
recordingSubject = null;
|
||||
recorder = null;
|
||||
captureUri = null;
|
||||
});
|
||||
|
||||
return future;
|
||||
}
|
||||
|
||||
private <T> void sendToFuture(final SettableFuture<T> future, final Exception exception) {
|
||||
ThreadUtil.runOnMain(() -> future.setException(exception));
|
||||
}
|
||||
|
||||
private <T> void sendToFuture(final SettableFuture<T> future, final T result) {
|
||||
ThreadUtil.runOnMain(() -> future.set(result));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.thoughtcrime.securesms.audio
|
||||
|
||||
interface AudioRecordingHandler {
|
||||
fun onRecordPressed()
|
||||
fun onRecordReleased()
|
||||
fun onRecordCanceled(byUser: Boolean)
|
||||
fun onRecordLocked()
|
||||
fun onRecordMoved(offsetX: Float, absoluteX: Float)
|
||||
fun onRecordPermissionRequired()
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
package org.thoughtcrime.securesms.audio;
|
||||
|
||||
import android.content.Context;
|
||||
import android.media.MediaCodec;
|
||||
import android.media.MediaExtractor;
|
||||
import android.media.MediaFormat;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.util.LruCache;
|
||||
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.core.util.Consumer;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
|
||||
import org.thoughtcrime.securesms.media.DecryptableUriMediaInput;
|
||||
import org.thoughtcrime.securesms.media.MediaInput;
|
||||
import org.thoughtcrime.securesms.mms.AudioSlide;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SerialExecutor;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
public final class AudioWaveForm {
|
||||
|
||||
private static final String TAG = Log.tag(AudioWaveForm.class);
|
||||
|
||||
private static final int BAR_COUNT = 46;
|
||||
private static final int SAMPLES_PER_BAR = 4;
|
||||
|
||||
private final Context context;
|
||||
private final AudioSlide slide;
|
||||
|
||||
public AudioWaveForm(@NonNull Context context, @NonNull AudioSlide slide) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.slide = slide;
|
||||
}
|
||||
|
||||
private static final LruCache<String, AudioFileInfo> WAVE_FORM_CACHE = new LruCache<>(200);
|
||||
private static final Executor AUDIO_DECODER_EXECUTOR = new SerialExecutor(SignalExecutors.BOUNDED);
|
||||
|
||||
@AnyThread
|
||||
public void getWaveForm(@NonNull Consumer<AudioFileInfo> onSuccess, @NonNull Runnable onFailure) {
|
||||
Uri uri = slide.getUri();
|
||||
Attachment attachment = slide.asAttachment();
|
||||
|
||||
if (uri == null) {
|
||||
Log.w(TAG, "No uri");
|
||||
ThreadUtil.runOnMain(onFailure);
|
||||
return;
|
||||
}
|
||||
|
||||
String cacheKey = uri.toString();
|
||||
AudioFileInfo cached = WAVE_FORM_CACHE.get(cacheKey);
|
||||
if (cached != null) {
|
||||
Log.i(TAG, "Loaded wave form from cache " + cacheKey);
|
||||
ThreadUtil.runOnMain(() -> onSuccess.accept(cached));
|
||||
return;
|
||||
}
|
||||
|
||||
AUDIO_DECODER_EXECUTOR.execute(() -> {
|
||||
AudioFileInfo cachedInExecutor = WAVE_FORM_CACHE.get(cacheKey);
|
||||
if (cachedInExecutor != null) {
|
||||
Log.i(TAG, "Loaded wave form from cache inside executor" + cacheKey);
|
||||
ThreadUtil.runOnMain(() -> onSuccess.accept(cachedInExecutor));
|
||||
return;
|
||||
}
|
||||
|
||||
AudioHash audioHash = attachment.getAudioHash();
|
||||
if (audioHash != null) {
|
||||
AudioFileInfo audioFileInfo = AudioFileInfo.fromDatabaseProtobuf(audioHash.getAudioWaveForm());
|
||||
if (audioFileInfo.waveForm.length == 0) {
|
||||
Log.w(TAG, "Recovering from a wave form generation error " + cacheKey);
|
||||
ThreadUtil.runOnMain(onFailure);
|
||||
return;
|
||||
} else if (audioFileInfo.waveForm.length != BAR_COUNT) {
|
||||
Log.w(TAG, "Wave form from database does not match bar count, regenerating " + cacheKey);
|
||||
} else {
|
||||
WAVE_FORM_CACHE.put(cacheKey, audioFileInfo);
|
||||
Log.i(TAG, "Loaded wave form from DB " + cacheKey);
|
||||
ThreadUtil.runOnMain(() -> onSuccess.accept(audioFileInfo));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (attachment instanceof DatabaseAttachment) {
|
||||
try {
|
||||
AttachmentTable attachmentDatabase = SignalDatabase.attachments();
|
||||
DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment;
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), AudioWaveFormData.getDefaultInstance());
|
||||
|
||||
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
|
||||
|
||||
AudioFileInfo fileInfo = generateWaveForm(uri);
|
||||
|
||||
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
|
||||
|
||||
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf());
|
||||
|
||||
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
|
||||
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
|
||||
} catch (Throwable e) {
|
||||
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
|
||||
ThreadUtil.runOnMain(onFailure);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
Log.i(TAG, "Not in database and not cached. Generating wave form on-the-fly.");
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
|
||||
|
||||
AudioFileInfo fileInfo = generateWaveForm(uri);
|
||||
|
||||
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
|
||||
|
||||
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
|
||||
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
|
||||
ThreadUtil.runOnMain(onFailure);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on decode sample from:
|
||||
* <p>
|
||||
* https://android.googlesource.com/platform/cts/+/jb-mr2-release/tests/tests/media/src/android/media/cts/DecoderTest.java
|
||||
*/
|
||||
@WorkerThread
|
||||
@RequiresApi(api = 23)
|
||||
private @NonNull AudioFileInfo generateWaveForm(@NonNull Uri uri) throws IOException {
|
||||
try (MediaInput dataSource = DecryptableUriMediaInput.createForUri(context, uri)) {
|
||||
long[] wave = new long[BAR_COUNT];
|
||||
int[] waveSamples = new int[BAR_COUNT];
|
||||
|
||||
MediaExtractor extractor = dataSource.createExtractor();
|
||||
|
||||
if (extractor.getTrackCount() == 0) {
|
||||
throw new IOException("No audio track");
|
||||
}
|
||||
|
||||
MediaFormat format = extractor.getTrackFormat(0);
|
||||
|
||||
if (!format.containsKey(MediaFormat.KEY_DURATION)) {
|
||||
throw new IOException("Unknown duration");
|
||||
}
|
||||
|
||||
long totalDurationUs = format.getLong(MediaFormat.KEY_DURATION);
|
||||
String mime = format.getString(MediaFormat.KEY_MIME);
|
||||
|
||||
if (!mime.startsWith("audio/")) {
|
||||
throw new IOException("Mime not audio");
|
||||
}
|
||||
|
||||
MediaCodec codec = MediaCodec.createDecoderByType(mime);
|
||||
|
||||
if (totalDurationUs == 0) {
|
||||
throw new IOException("Zero duration");
|
||||
}
|
||||
|
||||
codec.configure(format, null, null, 0);
|
||||
codec.start();
|
||||
|
||||
ByteBuffer[] codecInputBuffers = codec.getInputBuffers();
|
||||
ByteBuffer[] codecOutputBuffers = codec.getOutputBuffers();
|
||||
|
||||
extractor.selectTrack(0);
|
||||
|
||||
long kTimeOutUs = 5000;
|
||||
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
||||
boolean sawInputEOS = false;
|
||||
boolean sawOutputEOS = false;
|
||||
int noOutputCounter = 0;
|
||||
|
||||
while (!sawOutputEOS && noOutputCounter < 50) {
|
||||
noOutputCounter++;
|
||||
if (!sawInputEOS) {
|
||||
int inputBufIndex = codec.dequeueInputBuffer(kTimeOutUs);
|
||||
if (inputBufIndex >= 0) {
|
||||
ByteBuffer dstBuf = codecInputBuffers[inputBufIndex];
|
||||
int sampleSize = extractor.readSampleData(dstBuf, 0);
|
||||
long presentationTimeUs = 0;
|
||||
|
||||
if (sampleSize < 0) {
|
||||
sawInputEOS = true;
|
||||
sampleSize = 0;
|
||||
} else {
|
||||
presentationTimeUs = extractor.getSampleTime();
|
||||
}
|
||||
|
||||
codec.queueInputBuffer(
|
||||
inputBufIndex,
|
||||
0,
|
||||
sampleSize,
|
||||
presentationTimeUs,
|
||||
sawInputEOS ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0);
|
||||
|
||||
if (!sawInputEOS) {
|
||||
int barSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
|
||||
sawInputEOS = !extractor.advance();
|
||||
int nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
|
||||
while (!sawInputEOS && nextBarSampleIndex == barSampleIndex) {
|
||||
sawInputEOS = !extractor.advance();
|
||||
if (!sawInputEOS) {
|
||||
nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int outputBufferIndex;
|
||||
do {
|
||||
outputBufferIndex = codec.dequeueOutputBuffer(info, kTimeOutUs);
|
||||
if (outputBufferIndex >= 0) {
|
||||
if (info.size > 0) {
|
||||
noOutputCounter = 0;
|
||||
}
|
||||
|
||||
ByteBuffer buf = codecOutputBuffers[outputBufferIndex];
|
||||
int barIndex = (int) ((wave.length * info.presentationTimeUs) / totalDurationUs);
|
||||
long total = 0;
|
||||
for (int i = 0; i < info.size; i += 2 * 4) {
|
||||
short aShort = buf.getShort(i);
|
||||
total += Math.abs(aShort);
|
||||
}
|
||||
if (barIndex >= 0 && barIndex < wave.length) {
|
||||
wave[barIndex] += total;
|
||||
waveSamples[barIndex] += info.size / 2;
|
||||
}
|
||||
codec.releaseOutputBuffer(outputBufferIndex, false);
|
||||
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
|
||||
sawOutputEOS = true;
|
||||
}
|
||||
} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
|
||||
codecOutputBuffers = codec.getOutputBuffers();
|
||||
} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||
Log.d(TAG, "output format has changed to " + codec.getOutputFormat());
|
||||
}
|
||||
} while (outputBufferIndex >= 0);
|
||||
}
|
||||
|
||||
codec.stop();
|
||||
codec.release();
|
||||
extractor.release();
|
||||
|
||||
float[] floats = new float[BAR_COUNT];
|
||||
byte[] bytes = new byte[BAR_COUNT];
|
||||
float max = 0;
|
||||
|
||||
for (int i = 0; i < BAR_COUNT; i++) {
|
||||
if (waveSamples[i] == 0) continue;
|
||||
|
||||
floats[i] = wave[i] / (float) waveSamples[i];
|
||||
if (floats[i] > max) {
|
||||
max = floats[i];
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < BAR_COUNT; i++) {
|
||||
float normalized = floats[i] / max;
|
||||
bytes[i] = (byte) (255 * normalized);
|
||||
}
|
||||
|
||||
return new AudioFileInfo(totalDurationUs, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
public static class AudioFileInfo {
|
||||
private final long durationUs;
|
||||
private final byte[] waveFormBytes;
|
||||
private final float[] waveForm;
|
||||
|
||||
private static @NonNull AudioFileInfo fromDatabaseProtobuf(@NonNull AudioWaveFormData audioWaveForm) {
|
||||
return new AudioFileInfo(audioWaveForm.getDurationUs(), audioWaveForm.getWaveForm().toByteArray());
|
||||
}
|
||||
|
||||
private AudioFileInfo(long durationUs, byte[] waveFormBytes) {
|
||||
this.durationUs = durationUs;
|
||||
this.waveFormBytes = waveFormBytes;
|
||||
this.waveForm = new float[waveFormBytes.length];
|
||||
|
||||
for (int i = 0; i < waveFormBytes.length; i++) {
|
||||
int unsigned = waveFormBytes[i] & 0xff;
|
||||
this.waveForm[i] = unsigned / 255f;
|
||||
}
|
||||
}
|
||||
|
||||
public long getDuration(@NonNull TimeUnit timeUnit) {
|
||||
return timeUnit.convert(durationUs, TimeUnit.MICROSECONDS);
|
||||
}
|
||||
|
||||
public float[] getWaveForm() {
|
||||
return waveForm;
|
||||
}
|
||||
|
||||
private @NonNull AudioWaveFormData toDatabaseProtobuf() {
|
||||
return AudioWaveFormData.newBuilder()
|
||||
.setDurationUs(durationUs)
|
||||
.setWaveForm(ByteString.copyFrom(waveFormBytes))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package org.thoughtcrime.securesms.audio;
|
||||
|
||||
import android.content.Context;
|
||||
import android.media.MediaCodec;
|
||||
import android.media.MediaExtractor;
|
||||
import android.media.MediaFormat;
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.media.DecryptableUriMediaInput;
|
||||
import org.thoughtcrime.securesms.media.MediaInput;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
@RequiresApi(api = 23)
|
||||
public final class AudioWaveFormGenerator {
|
||||
|
||||
private static final String TAG = Log.tag(AudioWaveFormGenerator.class);
|
||||
|
||||
public static final int BAR_COUNT = 46;
|
||||
private static final int SAMPLES_PER_BAR = 4;
|
||||
|
||||
private AudioWaveFormGenerator() {}
|
||||
|
||||
/**
|
||||
* Based on decode sample from:
|
||||
* <p>
|
||||
* https://android.googlesource.com/platform/cts/+/jb-mr2-release/tests/tests/media/src/android/media/cts/DecoderTest.java
|
||||
*/
|
||||
@WorkerThread
|
||||
public static @NonNull AudioFileInfo generateWaveForm(@NonNull Context context, @NonNull Uri uri) throws IOException {
|
||||
try (MediaInput dataSource = DecryptableUriMediaInput.createForUri(context, uri)) {
|
||||
long[] wave = new long[BAR_COUNT];
|
||||
int[] waveSamples = new int[BAR_COUNT];
|
||||
|
||||
MediaExtractor extractor = dataSource.createExtractor();
|
||||
|
||||
if (extractor.getTrackCount() == 0) {
|
||||
throw new IOException("No audio track");
|
||||
}
|
||||
|
||||
MediaFormat format = extractor.getTrackFormat(0);
|
||||
|
||||
if (!format.containsKey(MediaFormat.KEY_DURATION)) {
|
||||
throw new IOException("Unknown duration");
|
||||
}
|
||||
|
||||
long totalDurationUs = format.getLong(MediaFormat.KEY_DURATION);
|
||||
String mime = format.getString(MediaFormat.KEY_MIME);
|
||||
|
||||
if (!mime.startsWith("audio/")) {
|
||||
throw new IOException("Mime not audio");
|
||||
}
|
||||
|
||||
MediaCodec codec = MediaCodec.createDecoderByType(mime);
|
||||
|
||||
if (totalDurationUs == 0) {
|
||||
throw new IOException("Zero duration");
|
||||
}
|
||||
|
||||
codec.configure(format, null, null, 0);
|
||||
codec.start();
|
||||
|
||||
extractor.selectTrack(0);
|
||||
|
||||
long kTimeOutUs = 5000;
|
||||
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
|
||||
boolean sawInputEOS = false;
|
||||
boolean sawOutputEOS = false;
|
||||
int noOutputCounter = 0;
|
||||
|
||||
while (!sawOutputEOS && noOutputCounter < 50) {
|
||||
noOutputCounter++;
|
||||
if (!sawInputEOS) {
|
||||
int inputBufIndex = codec.dequeueInputBuffer(kTimeOutUs);
|
||||
if (inputBufIndex >= 0) {
|
||||
ByteBuffer dstBuf = codec.getInputBuffer(inputBufIndex);
|
||||
int sampleSize = extractor.readSampleData(dstBuf, 0);
|
||||
long presentationTimeUs = 0;
|
||||
|
||||
if (sampleSize < 0) {
|
||||
sawInputEOS = true;
|
||||
sampleSize = 0;
|
||||
} else {
|
||||
presentationTimeUs = extractor.getSampleTime();
|
||||
}
|
||||
|
||||
codec.queueInputBuffer(
|
||||
inputBufIndex,
|
||||
0,
|
||||
sampleSize,
|
||||
presentationTimeUs,
|
||||
sawInputEOS ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0);
|
||||
|
||||
if (!sawInputEOS) {
|
||||
int barSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
|
||||
sawInputEOS = !extractor.advance();
|
||||
int nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
|
||||
while (!sawInputEOS && nextBarSampleIndex == barSampleIndex) {
|
||||
sawInputEOS = !extractor.advance();
|
||||
if (!sawInputEOS) {
|
||||
nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int outputBufferIndex;
|
||||
do {
|
||||
outputBufferIndex = codec.dequeueOutputBuffer(info, kTimeOutUs);
|
||||
if (outputBufferIndex >= 0) {
|
||||
if (info.size > 0) {
|
||||
noOutputCounter = 0;
|
||||
}
|
||||
|
||||
ByteBuffer buf = codec.getOutputBuffer(outputBufferIndex);
|
||||
int barIndex = (int) ((wave.length * info.presentationTimeUs) / totalDurationUs);
|
||||
long total = 0;
|
||||
for (int i = 0; i < info.size; i += 2 * 4) {
|
||||
short aShort = buf.getShort(i);
|
||||
total += Math.abs(aShort);
|
||||
}
|
||||
if (barIndex >= 0 && barIndex < wave.length) {
|
||||
wave[barIndex] += total;
|
||||
waveSamples[barIndex] += info.size / 2;
|
||||
}
|
||||
codec.releaseOutputBuffer(outputBufferIndex, false);
|
||||
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
|
||||
sawOutputEOS = true;
|
||||
}
|
||||
} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
||||
Log.d(TAG, "output format has changed to " + codec.getOutputFormat());
|
||||
}
|
||||
} while (outputBufferIndex >= 0);
|
||||
}
|
||||
|
||||
codec.stop();
|
||||
codec.release();
|
||||
extractor.release();
|
||||
|
||||
float[] floats = new float[BAR_COUNT];
|
||||
byte[] bytes = new byte[BAR_COUNT];
|
||||
float max = 0;
|
||||
|
||||
for (int i = 0; i < BAR_COUNT; i++) {
|
||||
if (waveSamples[i] == 0) continue;
|
||||
|
||||
floats[i] = wave[i] / (float) waveSamples[i];
|
||||
if (floats[i] > max) {
|
||||
max = floats[i];
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < BAR_COUNT; i++) {
|
||||
float normalized = floats[i] / max;
|
||||
bytes[i] = (byte) (255 * normalized);
|
||||
}
|
||||
|
||||
return new AudioFileInfo(totalDurationUs, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package org.thoughtcrime.securesms.audio
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.LruCache
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.RequiresApi
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock
|
||||
import kotlin.concurrent.read
|
||||
import kotlin.concurrent.write
|
||||
|
||||
/**
|
||||
* Uses [AudioWaveFormGenerator] to generate audio wave forms.
|
||||
*
|
||||
* Maintains an in-memory cache of recently requested wave forms.
|
||||
*/
|
||||
@RequiresApi(23)
|
||||
object AudioWaveForms {
|
||||
|
||||
private val TAG = Log.tag(AudioWaveForms::class.java)
|
||||
|
||||
private val cache = ThreadSafeLruCache(200)
|
||||
|
||||
@AnyThread
|
||||
@JvmStatic
|
||||
fun getWaveForm(context: Context, attachment: Attachment): Single<AudioFileInfo> {
|
||||
val uri = attachment.uri
|
||||
if (uri == null) {
|
||||
Log.i(TAG, "No uri")
|
||||
return Single.error(IllegalArgumentException("No uri from attachment"))
|
||||
}
|
||||
|
||||
val cacheKey = uri.toString()
|
||||
val cachedInfo = cache.get(cacheKey)
|
||||
if (cachedInfo != null) {
|
||||
Log.i(TAG, "Loaded wave form from cache $cacheKey")
|
||||
return Single.just(cachedInfo)
|
||||
}
|
||||
|
||||
val databaseCache = Single.fromCallable {
|
||||
val audioHash = attachment.audioHash
|
||||
return@fromCallable if (audioHash != null) {
|
||||
checkDatabaseCache(cacheKey, audioHash.audioWaveForm)
|
||||
} else {
|
||||
Miss
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
|
||||
val generateWaveForm: Single<CacheCheckResult> = if (attachment is DatabaseAttachment) {
|
||||
Single.fromCallable { generateWaveForm(context, uri, cacheKey, attachment.attachmentId) }
|
||||
} else {
|
||||
Single.fromCallable { generateWaveForm(context, uri, cacheKey) }
|
||||
}.subscribeOn(Schedulers.io())
|
||||
|
||||
return databaseCache
|
||||
.flatMap { r ->
|
||||
if (r is Miss) {
|
||||
generateWaveForm
|
||||
} else {
|
||||
Single.just(r)
|
||||
}
|
||||
}
|
||||
.map { r ->
|
||||
if (r is Success) {
|
||||
r.audioFileInfo
|
||||
} else {
|
||||
throw IOException("Unable to generate wave form")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkDatabaseCache(cacheKey: String, audioWaveForm: AudioWaveFormData): CacheCheckResult {
|
||||
val audioFileInfo = AudioFileInfo.fromDatabaseProtobuf(audioWaveForm)
|
||||
if (audioFileInfo.waveForm.isEmpty()) {
|
||||
Log.w(TAG, "Recovering from a wave form generation error $cacheKey")
|
||||
return Failure
|
||||
} else if (audioFileInfo.waveForm.size != AudioWaveFormGenerator.BAR_COUNT) {
|
||||
Log.w(TAG, "Wave form from database does not match bar count, regenerating $cacheKey")
|
||||
} else {
|
||||
cache.put(cacheKey, audioFileInfo)
|
||||
Log.i(TAG, "Loaded wave form from DB $cacheKey")
|
||||
return Success(audioFileInfo)
|
||||
}
|
||||
|
||||
return Miss
|
||||
}
|
||||
|
||||
private fun generateWaveForm(context: Context, uri: Uri, cacheKey: String, attachmentId: AttachmentId): CacheCheckResult {
|
||||
try {
|
||||
val startTime = System.currentTimeMillis()
|
||||
SignalDatabase.attachments.writeAudioHash(attachmentId, AudioWaveFormData.getDefaultInstance())
|
||||
|
||||
Log.i(TAG, "Starting wave form generation ($cacheKey)")
|
||||
val fileInfo: AudioFileInfo = AudioWaveFormGenerator.generateWaveForm(context, uri)
|
||||
Log.i(TAG, "Audio wave form generation time ${System.currentTimeMillis() - startTime} ms ($cacheKey)")
|
||||
|
||||
SignalDatabase.attachments.writeAudioHash(attachmentId, fileInfo.toDatabaseProtobuf())
|
||||
cache.put(cacheKey, fileInfo)
|
||||
|
||||
return Success(fileInfo)
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to create audio wave form for $cacheKey", e)
|
||||
return Failure
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateWaveForm(context: Context, uri: Uri, cacheKey: String): CacheCheckResult {
|
||||
try {
|
||||
Log.i(TAG, "Not in database and not cached. Generating wave form on-the-fly.")
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
Log.i(TAG, "Starting wave form generation ($cacheKey)")
|
||||
val fileInfo: AudioFileInfo = AudioWaveFormGenerator.generateWaveForm(context, uri)
|
||||
Log.i(TAG, "Audio wave form generation time ${System.currentTimeMillis() - startTime} ms ($cacheKey)")
|
||||
|
||||
cache.put(cacheKey, fileInfo)
|
||||
|
||||
return Success(fileInfo)
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "Failed to create audio wave form for $cacheKey", e)
|
||||
return Failure
|
||||
}
|
||||
}
|
||||
|
||||
private class ThreadSafeLruCache(maxSize: Int) {
|
||||
private val cache = LruCache<String, AudioFileInfo>(maxSize)
|
||||
private val lock = ReentrantReadWriteLock()
|
||||
|
||||
fun put(key: String, info: AudioFileInfo) {
|
||||
lock.write { cache.put(key, info) }
|
||||
}
|
||||
|
||||
fun get(key: String): AudioFileInfo? {
|
||||
return lock.read { cache.get(key) }
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CacheCheckResult
|
||||
private class Success(val audioFileInfo: AudioFileInfo) : CacheCheckResult()
|
||||
private object Failure : CacheCheckResult()
|
||||
private object Miss : CacheCheckResult()
|
||||
}
|
||||
@@ -21,7 +21,7 @@ sealed class Avatar(
|
||||
data class Text(
|
||||
val text: String,
|
||||
val color: Avatars.ColorPair,
|
||||
override val databaseId: DatabaseId,
|
||||
override val databaseId: DatabaseId
|
||||
) : Avatar(databaseId) {
|
||||
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
|
||||
return copy(databaseId = databaseId)
|
||||
@@ -35,7 +35,7 @@ sealed class Avatar(
|
||||
data class Vector(
|
||||
val key: String,
|
||||
val color: Avatars.ColorPair,
|
||||
override val databaseId: DatabaseId,
|
||||
override val databaseId: DatabaseId
|
||||
) : Avatar(databaseId) {
|
||||
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
|
||||
return copy(databaseId = databaseId)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.thoughtcrime.securesms.avatar
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
|
||||
/**
|
||||
* Utility class which encapsulates reading and writing Avatar objects to and from Bundles.
|
||||
@@ -33,7 +35,7 @@ object AvatarBundler {
|
||||
}
|
||||
|
||||
fun extractPhoto(bundle: Bundle): Avatar.Photo = Avatar.Photo(
|
||||
uri = requireNotNull(bundle.getParcelable(URI)),
|
||||
uri = requireNotNull(bundle.getParcelableCompat(URI, Uri::class.java)),
|
||||
size = bundle.getLong(SIZE),
|
||||
databaseId = bundle.getDatabaseId()
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.graphics.Canvas
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
@@ -28,8 +29,13 @@ object AvatarRenderer {
|
||||
|
||||
val DIMENSIONS = AvatarHelper.AVATAR_DIMENSIONS
|
||||
|
||||
private var typeface: Typeface? = null
|
||||
|
||||
@MainThread
|
||||
fun getTypeface(context: Context): Typeface {
|
||||
return Typeface.createFromAsset(context.assets, "fonts/Inter-Medium.otf")
|
||||
val interMedium = typeface ?: Typeface.createFromAsset(context.assets, "fonts/Inter-Medium.otf")
|
||||
typeface = interMedium
|
||||
return interMedium
|
||||
}
|
||||
|
||||
fun renderAvatar(context: Context, avatar: Avatar, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
|
||||
|
||||
@@ -74,7 +74,7 @@ object Avatars {
|
||||
"avatar_sunset" to DefaultAvatar(R.drawable.ic_avatar_sunset, "A120"),
|
||||
"avatar_surfboard" to DefaultAvatar(R.drawable.ic_avatar_surfboard, "A110"),
|
||||
"avatar_soccerball" to DefaultAvatar(R.drawable.ic_avatar_soccerball, "A130"),
|
||||
"avatar_football" to DefaultAvatar(R.drawable.ic_avatar_football, "A220"),
|
||||
"avatar_football" to DefaultAvatar(R.drawable.ic_avatar_football, "A220")
|
||||
)
|
||||
|
||||
@DrawableRes
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.Avatar
|
||||
import org.thoughtcrime.securesms.avatar.AvatarBundler
|
||||
@@ -87,8 +88,9 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
val selectedPosition = items.indexOfFirst { it.isSelected }
|
||||
|
||||
adapter.submitList(items) {
|
||||
if (selectedPosition > -1)
|
||||
if (selectedPosition > -1) {
|
||||
recycler.smoothScrollToPosition(selectedPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,10 +148,9 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
ViewUtil.hideKeyboard(requireContext(), requireView())
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == REQUEST_CODE_SELECT_IMAGE && resultCode == Activity.RESULT_OK && data != null) {
|
||||
val media: Media = requireNotNull(data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA))
|
||||
val media: Media = requireNotNull(data.getParcelableExtraCompat(AvatarSelectionActivity.EXTRA_MEDIA, Media::class.java))
|
||||
viewModel.onAvatarPhotoSelectionCompleted(media)
|
||||
} else {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
|
||||
@@ -5,7 +5,7 @@ import org.thoughtcrime.securesms.avatar.AvatarColorItem
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
|
||||
data class TextAvatarCreationState(
|
||||
val currentAvatar: Avatar.Text,
|
||||
val currentAvatar: Avatar.Text
|
||||
) {
|
||||
fun colors(): List<AvatarColorItem> = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) }
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import org.thoughtcrime.securesms.avatar.AvatarColorItem
|
||||
import org.thoughtcrime.securesms.avatar.Avatars
|
||||
|
||||
data class VectorAvatarCreationState(
|
||||
val currentAvatar: Avatar.Vector,
|
||||
val currentAvatar: Avatar.Vector
|
||||
) {
|
||||
fun colors(): List<AvatarColorItem> = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import org.signal.core.util.Conversions;
|
||||
import org.signal.core.util.StreamUtil;
|
||||
import org.signal.libsignal.protocol.kdf.HKDF;
|
||||
import org.signal.libsignal.protocol.util.ByteUtil;
|
||||
import org.thoughtcrime.securesms.backup.proto.BackupFrame;
|
||||
import org.thoughtcrime.securesms.backup.proto.Header;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -45,21 +47,21 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
|
||||
byte[] headerFrame = new byte[headerLength];
|
||||
StreamUtil.readFully(in, headerFrame);
|
||||
|
||||
BackupProtos.BackupFrame frame = BackupProtos.BackupFrame.parseFrom(headerFrame);
|
||||
BackupFrame frame = BackupFrame.ADAPTER.decode(headerFrame);
|
||||
|
||||
if (!frame.hasHeader()) {
|
||||
if (frame.header_ == null) {
|
||||
throw new IOException("Backup stream does not start with header!");
|
||||
}
|
||||
|
||||
BackupProtos.Header header = frame.getHeader();
|
||||
Header header = frame.header_;
|
||||
|
||||
this.iv = header.getIv().toByteArray();
|
||||
this.iv = header.iv.toByteArray();
|
||||
|
||||
if (iv.length != 16) {
|
||||
throw new IOException("Invalid IV length!");
|
||||
}
|
||||
|
||||
byte[] key = getBackupKey(passphrase, header.hasSalt() ? header.getSalt().toByteArray() : null);
|
||||
byte[] key = getBackupKey(passphrase, header.salt != null ? header.salt.toByteArray() : null);
|
||||
byte[] derived = HKDF.deriveSecrets(key, "Backup Export".getBytes(), 64);
|
||||
byte[][] split = ByteUtil.split(derived, 32, 32);
|
||||
|
||||
@@ -76,7 +78,7 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
|
||||
}
|
||||
}
|
||||
|
||||
BackupProtos.BackupFrame readFrame() throws IOException {
|
||||
BackupFrame readFrame() throws IOException {
|
||||
return readFrame(in);
|
||||
}
|
||||
|
||||
@@ -128,7 +130,7 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
|
||||
}
|
||||
}
|
||||
|
||||
private BackupProtos.BackupFrame readFrame(InputStream in) throws IOException {
|
||||
private BackupFrame readFrame(InputStream in) throws IOException {
|
||||
try {
|
||||
byte[] length = new byte[4];
|
||||
StreamUtil.readFully(in, length);
|
||||
@@ -151,7 +153,7 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
|
||||
|
||||
byte[] plaintext = cipher.doFinal(frame, 0, frame.length - 10);
|
||||
|
||||
return BackupProtos.BackupFrame.parseFrom(plaintext);
|
||||
return BackupFrame.ADAPTER.decode(plaintext);
|
||||
} catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ package org.thoughtcrime.securesms.backup
|
||||
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame
|
||||
import org.thoughtcrime.securesms.backup.proto.Attachment
|
||||
import org.thoughtcrime.securesms.backup.proto.Avatar
|
||||
import org.thoughtcrime.securesms.backup.proto.BackupFrame
|
||||
import org.thoughtcrime.securesms.backup.proto.Sticker
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
@@ -15,38 +18,42 @@ object BackupVerifier {
|
||||
private val TAG = Log.tag(BackupVerifier::class.java)
|
||||
|
||||
@JvmStatic
|
||||
@Throws(IOException::class)
|
||||
fun verifyFile(cipherStream: InputStream, passphrase: String, expectedCount: Long): Boolean {
|
||||
@Throws(IOException::class, FullBackupExporter.BackupCanceledException::class)
|
||||
fun verifyFile(cipherStream: InputStream, passphrase: String, expectedCount: Long, cancellationSignal: FullBackupExporter.BackupCancellationSignal): Boolean {
|
||||
val inputStream = BackupRecordInputStream(cipherStream, passphrase)
|
||||
|
||||
var count = 0L
|
||||
var frame: BackupFrame = inputStream.readFrame()
|
||||
|
||||
while (!frame.end) {
|
||||
val verified = when {
|
||||
frame.hasAttachment() -> verifyAttachment(frame.attachment, inputStream)
|
||||
frame.hasSticker() -> verifySticker(frame.sticker, inputStream)
|
||||
frame.hasAvatar() -> verifyAvatar(frame.avatar, inputStream)
|
||||
else -> true
|
||||
cipherStream.use {
|
||||
while (frame.end != true && !cancellationSignal.isCanceled) {
|
||||
val verified = when {
|
||||
frame.attachment != null -> verifyAttachment(frame.attachment!!, inputStream)
|
||||
frame.sticker != null -> verifySticker(frame.sticker!!, inputStream)
|
||||
frame.avatar != null -> verifyAvatar(frame.avatar!!, inputStream)
|
||||
else -> true
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
return false
|
||||
}
|
||||
|
||||
EventBus.getDefault().post(BackupEvent(BackupEvent.Type.PROGRESS_VERIFYING, ++count, expectedCount))
|
||||
|
||||
frame = inputStream.readFrame()
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
return false
|
||||
}
|
||||
|
||||
EventBus.getDefault().post(BackupEvent(BackupEvent.Type.PROGRESS_VERIFYING, ++count, expectedCount))
|
||||
|
||||
frame = inputStream.readFrame()
|
||||
}
|
||||
|
||||
cipherStream.close()
|
||||
if (cancellationSignal.isCanceled) {
|
||||
throw FullBackupExporter.BackupCanceledException()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun verifyAttachment(attachment: BackupProtos.Attachment, inputStream: BackupRecordInputStream): Boolean {
|
||||
private fun verifyAttachment(attachment: Attachment, inputStream: BackupRecordInputStream): Boolean {
|
||||
try {
|
||||
inputStream.readAttachmentTo(NullOutputStream, attachment.length)
|
||||
inputStream.readAttachmentTo(NullOutputStream, attachment.length ?: 0)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Bad attachment id: ${attachment.attachmentId} len: ${attachment.length}", e)
|
||||
return false
|
||||
@@ -55,9 +62,9 @@ object BackupVerifier {
|
||||
return true
|
||||
}
|
||||
|
||||
private fun verifySticker(sticker: BackupProtos.Sticker, inputStream: BackupRecordInputStream): Boolean {
|
||||
private fun verifySticker(sticker: Sticker, inputStream: BackupRecordInputStream): Boolean {
|
||||
try {
|
||||
inputStream.readAttachmentTo(NullOutputStream, sticker.length)
|
||||
inputStream.readAttachmentTo(NullOutputStream, sticker.length ?: 0)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Bad sticker id: ${sticker.rowId} len: ${sticker.length}", e)
|
||||
return false
|
||||
@@ -65,9 +72,9 @@ object BackupVerifier {
|
||||
return true
|
||||
}
|
||||
|
||||
private fun verifyAvatar(avatar: BackupProtos.Avatar, inputStream: BackupRecordInputStream): Boolean {
|
||||
private fun verifyAvatar(avatar: Avatar, inputStream: BackupRecordInputStream): Boolean {
|
||||
try {
|
||||
inputStream.readAttachmentTo(NullOutputStream, avatar.length)
|
||||
inputStream.readAttachmentTo(NullOutputStream, avatar.length ?: 0)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Bad avatar id: ${avatar.recipientId} len: ${avatar.length}", e)
|
||||
return false
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.annotation.VisibleForTesting;
|
||||
import androidx.documentfile.provider.DocumentFile;
|
||||
|
||||
import com.annimon.stream.function.Predicate;
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
@@ -26,6 +25,15 @@ import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.protocol.kdf.HKDF;
|
||||
import org.signal.libsignal.protocol.util.ByteUtil;
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
||||
import org.thoughtcrime.securesms.backup.proto.Attachment;
|
||||
import org.thoughtcrime.securesms.backup.proto.Avatar;
|
||||
import org.thoughtcrime.securesms.backup.proto.BackupFrame;
|
||||
import org.thoughtcrime.securesms.backup.proto.DatabaseVersion;
|
||||
import org.thoughtcrime.securesms.backup.proto.Header;
|
||||
import org.thoughtcrime.securesms.backup.proto.KeyValue;
|
||||
import org.thoughtcrime.securesms.backup.proto.SharedPreference;
|
||||
import org.thoughtcrime.securesms.backup.proto.SqlStatement;
|
||||
import org.thoughtcrime.securesms.backup.proto.Sticker;
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
|
||||
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
|
||||
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
|
||||
@@ -80,6 +88,8 @@ import javax.crypto.NoSuchPaddingException;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import okio.ByteString;
|
||||
|
||||
public class FullBackupExporter extends FullBackupBase {
|
||||
|
||||
private static final String TAG = Log.tag(FullBackupExporter.class);
|
||||
@@ -97,7 +107,7 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
SignedPreKeyTable.TABLE_NAME,
|
||||
OneTimePreKeyTable.TABLE_NAME,
|
||||
SessionTable.TABLE_NAME,
|
||||
SearchTable.MMS_FTS_TABLE_NAME,
|
||||
SearchTable.FTS_TABLE_NAME,
|
||||
EmojiSearchTable.TABLE_NAME,
|
||||
SenderKeyTable.TABLE_NAME,
|
||||
SenderKeySharedTable.TABLE_NAME,
|
||||
@@ -187,7 +197,7 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
stopwatch.split("table::" + table);
|
||||
}
|
||||
|
||||
for (BackupProtos.SharedPreference preference : TextSecurePreferences.getPreferencesToSaveToBackup(context)) {
|
||||
for (SharedPreference preference : TextSecurePreferences.getPreferencesToSaveToBackup(context)) {
|
||||
throwIfCanceled(cancellationSignal);
|
||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||
outputStream.write(preference);
|
||||
@@ -287,7 +297,7 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
String statement = createStatementsByTable.get(table);
|
||||
|
||||
if (statement != null) {
|
||||
outputStream.write(BackupProtos.SqlStatement.newBuilder().setStatement(statement).build());
|
||||
outputStream.write(new SqlStatement.Builder().statement(statement).build());
|
||||
} else {
|
||||
throw new IOException("Failed to find a create statement for table: " + table);
|
||||
}
|
||||
@@ -299,7 +309,7 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
String name = cursor.getString(1);
|
||||
|
||||
if (isTableAllowed(name)) {
|
||||
outputStream.write(BackupProtos.SqlStatement.newBuilder().setStatement(sql).build());
|
||||
outputStream.write(new SqlStatement.Builder().statement(sql).build());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -368,7 +378,7 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
}
|
||||
|
||||
boolean isReservedTable = table.startsWith("sqlite_");
|
||||
boolean isMmsFtsSecretTable = !table.equals(SearchTable.MMS_FTS_TABLE_NAME) && table.startsWith(SearchTable.MMS_FTS_TABLE_NAME);
|
||||
boolean isMmsFtsSecretTable = !table.equals(SearchTable.FTS_TABLE_NAME) && table.startsWith(SearchTable.FTS_TABLE_NAME);
|
||||
boolean isEmojiFtsSecretTable = !table.equals(EmojiSearchTable.TABLE_NAME) && table.startsWith(EmojiSearchTable.TABLE_NAME);
|
||||
|
||||
return !isReservedTable &&
|
||||
@@ -393,8 +403,10 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
throwIfCanceled(cancellationSignal);
|
||||
|
||||
if (predicate == null || predicate.test(cursor)) {
|
||||
StringBuilder statement = new StringBuilder(template);
|
||||
BackupProtos.SqlStatement.Builder statementBuilder = BackupProtos.SqlStatement.newBuilder();
|
||||
StringBuilder statement = new StringBuilder(template);
|
||||
SqlStatement.Builder statementBuilder = new SqlStatement.Builder();
|
||||
|
||||
statementBuilder.parameters = new ArrayList<>();
|
||||
|
||||
statement.append('(');
|
||||
|
||||
@@ -402,15 +414,15 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
statement.append('?');
|
||||
|
||||
if (cursor.getType(i) == Cursor.FIELD_TYPE_STRING) {
|
||||
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setStringParamter(cursor.getString(i)));
|
||||
statementBuilder.parameters.add(new SqlStatement.SqlParameter.Builder().stringParamter(cursor.getString(i)).build());
|
||||
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_FLOAT) {
|
||||
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setDoubleParameter(cursor.getDouble(i)));
|
||||
statementBuilder.parameters.add(new SqlStatement.SqlParameter.Builder().doubleParameter(cursor.getDouble(i)).build());
|
||||
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_INTEGER) {
|
||||
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setIntegerParameter(cursor.getLong(i)));
|
||||
statementBuilder.parameters.add(new SqlStatement.SqlParameter.Builder().integerParameter(cursor.getLong(i)).build());
|
||||
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_BLOB) {
|
||||
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setBlobParameter(ByteString.copyFrom(cursor.getBlob(i))));
|
||||
statementBuilder.parameters.add(new SqlStatement.SqlParameter.Builder().blobParameter(new ByteString(cursor.getBlob(i))).build());
|
||||
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_NULL) {
|
||||
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setNullparameter(true));
|
||||
statementBuilder.parameters.add(new SqlStatement.SqlParameter.Builder().nullparameter(true).build());
|
||||
} else {
|
||||
throw new AssertionError("unknown type?" + cursor.getType(i));
|
||||
}
|
||||
@@ -423,7 +435,7 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
statement.append(')');
|
||||
|
||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||
outputStream.write(statementBuilder.setStatement(statement.toString()).build());
|
||||
outputStream.write(statementBuilder.statement(statement.toString()).build());
|
||||
|
||||
if (postProcess != null) {
|
||||
count = postProcess.postProcess(cursor, count);
|
||||
@@ -539,29 +551,30 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
if (!dataSet.containsKey(key)) {
|
||||
continue;
|
||||
}
|
||||
BackupProtos.KeyValue.Builder builder = BackupProtos.KeyValue.newBuilder()
|
||||
.setKey(key);
|
||||
|
||||
KeyValue.Builder builder = new KeyValue.Builder()
|
||||
.key(key);
|
||||
|
||||
Class<?> type = dataSet.getType(key);
|
||||
if (type == byte[].class) {
|
||||
byte[] data = dataSet.getBlob(key, null);
|
||||
if (data != null) {
|
||||
builder.setBlobValue(ByteString.copyFrom(dataSet.getBlob(key, null)));
|
||||
builder.blobValue(new ByteString(dataSet.getBlob(key, null)));
|
||||
} else {
|
||||
Log.w(TAG, "Skipping storing null blob for key: " + key);
|
||||
}
|
||||
} else if (type == Boolean.class) {
|
||||
builder.setBooleanValue(dataSet.getBoolean(key, false));
|
||||
builder.booleanValue(dataSet.getBoolean(key, false));
|
||||
} else if (type == Float.class) {
|
||||
builder.setFloatValue(dataSet.getFloat(key, 0));
|
||||
builder.floatValue(dataSet.getFloat(key, 0));
|
||||
} else if (type == Integer.class) {
|
||||
builder.setIntegerValue(dataSet.getInteger(key, 0));
|
||||
builder.integerValue(dataSet.getInteger(key, 0));
|
||||
} else if (type == Long.class) {
|
||||
builder.setLongValue(dataSet.getLong(key, 0));
|
||||
builder.longValue(dataSet.getLong(key, 0));
|
||||
} else if (type == String.class) {
|
||||
String data = dataSet.getString(key, null);
|
||||
if (data != null) {
|
||||
builder.setStringValue(dataSet.getString(key, null));
|
||||
builder.stringValue(dataSet.getString(key, null));
|
||||
} else {
|
||||
Log.w(TAG, "Skipping storing null string for key: " + key);
|
||||
}
|
||||
@@ -631,10 +644,12 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
|
||||
mac.init(new SecretKeySpec(macKey, "HmacSHA256"));
|
||||
|
||||
byte[] header = BackupProtos.BackupFrame.newBuilder().setHeader(BackupProtos.Header.newBuilder()
|
||||
.setIv(ByteString.copyFrom(iv))
|
||||
.setSalt(ByteString.copyFrom(salt)))
|
||||
.build().toByteArray();
|
||||
byte[] header = new BackupFrame.Builder().header_(new Header.Builder()
|
||||
.iv(new okio.ByteString(iv))
|
||||
.salt(new okio.ByteString(salt))
|
||||
.build())
|
||||
.build()
|
||||
.encode();
|
||||
|
||||
outputStream.write(Conversions.intToByteArray(header.length));
|
||||
outputStream.write(header);
|
||||
@@ -643,26 +658,26 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
}
|
||||
}
|
||||
|
||||
public void write(BackupProtos.SharedPreference preference) throws IOException {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder().setPreference(preference).build());
|
||||
public void write(SharedPreference preference) throws IOException {
|
||||
write(outputStream, new BackupFrame.Builder().preference(preference).build());
|
||||
}
|
||||
|
||||
public void write(BackupProtos.KeyValue keyValue) throws IOException {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder().setKeyValue(keyValue).build());
|
||||
public void write(KeyValue keyValue) throws IOException {
|
||||
write(outputStream, new BackupFrame.Builder().keyValue(keyValue).build());
|
||||
}
|
||||
|
||||
public void write(BackupProtos.SqlStatement statement) throws IOException {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder().setStatement(statement).build());
|
||||
public void write(SqlStatement statement) throws IOException {
|
||||
write(outputStream, new BackupFrame.Builder().statement(statement).build());
|
||||
}
|
||||
|
||||
public void write(@NonNull String avatarName, @NonNull InputStream in, long size) throws IOException {
|
||||
try {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder()
|
||||
.setAvatar(BackupProtos.Avatar.newBuilder()
|
||||
.setRecipientId(avatarName)
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build());
|
||||
write(outputStream, new BackupFrame.Builder()
|
||||
.avatar(new Avatar.Builder()
|
||||
.recipientId(avatarName)
|
||||
.length(Util.toIntExact(size))
|
||||
.build())
|
||||
.build());
|
||||
} catch (ArithmeticException e) {
|
||||
Log.w(TAG, "Unable to write avatar to backup", e);
|
||||
throw new InvalidBackupStreamException();
|
||||
@@ -675,13 +690,13 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
|
||||
public void write(@NonNull AttachmentId attachmentId, @NonNull InputStream in, long size) throws IOException {
|
||||
try {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder()
|
||||
.setAttachment(BackupProtos.Attachment.newBuilder()
|
||||
.setRowId(attachmentId.getRowId())
|
||||
.setAttachmentId(attachmentId.getUniqueId())
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build());
|
||||
write(outputStream, new BackupFrame.Builder()
|
||||
.attachment(new Attachment.Builder()
|
||||
.rowId(attachmentId.getRowId())
|
||||
.attachmentId(attachmentId.getUniqueId())
|
||||
.length(Util.toIntExact(size))
|
||||
.build())
|
||||
.build());
|
||||
} catch (ArithmeticException e) {
|
||||
Log.w(TAG, "Unable to write " + attachmentId + " to backup", e);
|
||||
throw new InvalidBackupStreamException();
|
||||
@@ -694,12 +709,12 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
|
||||
public void writeSticker(long rowId, @NonNull InputStream in, long size) throws IOException {
|
||||
try {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder()
|
||||
.setSticker(BackupProtos.Sticker.newBuilder()
|
||||
.setRowId(rowId)
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build());
|
||||
write(outputStream, new BackupFrame.Builder()
|
||||
.sticker(new Sticker.Builder()
|
||||
.rowId(rowId)
|
||||
.length(Util.toIntExact(size))
|
||||
.build())
|
||||
.build());
|
||||
} catch (ArithmeticException e) {
|
||||
Log.w(TAG, "Unable to write sticker to backup", e);
|
||||
throw new InvalidBackupStreamException();
|
||||
@@ -711,13 +726,13 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
}
|
||||
|
||||
void writeDatabaseVersion(int version) throws IOException {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder()
|
||||
.setVersion(BackupProtos.DatabaseVersion.newBuilder().setVersion(version))
|
||||
.build());
|
||||
write(outputStream, new BackupFrame.Builder()
|
||||
.version(new DatabaseVersion.Builder().version(version).build())
|
||||
.build());
|
||||
}
|
||||
|
||||
void writeEnd() throws IOException {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder().setEnd(true).build());
|
||||
write(outputStream, new BackupFrame.Builder().end(true).build());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -758,12 +773,12 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
}
|
||||
}
|
||||
|
||||
private void write(@NonNull OutputStream out, @NonNull BackupProtos.BackupFrame frame) throws IOException {
|
||||
private void write(@NonNull OutputStream out, @NonNull BackupFrame frame) throws IOException {
|
||||
try {
|
||||
Conversions.intToByteArray(iv, 0, counter++);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
|
||||
|
||||
byte[] frameCiphertext = cipher.doFinal(frame.toByteArray());
|
||||
byte[] frameCiphertext = cipher.doFinal(frame.encode());
|
||||
byte[] frameMac = mac.doFinal(frameCiphertext);
|
||||
byte[] length = Conversions.intToByteArray(frameCiphertext.length + 10);
|
||||
|
||||
|
||||
@@ -11,17 +11,20 @@ import android.util.Pair;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import net.zetetic.database.sqlcipher.SQLiteConstraintException;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.signal.core.util.SqlUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.Attachment;
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame;
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion;
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference;
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement;
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.Sticker;
|
||||
import org.thoughtcrime.securesms.backup.proto.Attachment;
|
||||
import org.thoughtcrime.securesms.backup.proto.Avatar;
|
||||
import org.thoughtcrime.securesms.backup.proto.BackupFrame;
|
||||
import org.thoughtcrime.securesms.backup.proto.DatabaseVersion;
|
||||
import org.thoughtcrime.securesms.backup.proto.KeyValue;
|
||||
import org.thoughtcrime.securesms.backup.proto.SharedPreference;
|
||||
import org.thoughtcrime.securesms.backup.proto.SqlStatement;
|
||||
import org.thoughtcrime.securesms.backup.proto.Sticker;
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
|
||||
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
@@ -86,17 +89,17 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
|
||||
BackupFrame frame;
|
||||
|
||||
while (!(frame = inputStream.readFrame()).getEnd()) {
|
||||
while ((frame = inputStream.readFrame()).end != Boolean.TRUE) {
|
||||
if (count % 100 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, count, 0));
|
||||
count++;
|
||||
|
||||
if (frame.hasVersion()) processVersion(db, frame.getVersion());
|
||||
else if (frame.hasStatement()) processStatement(db, frame.getStatement());
|
||||
else if (frame.hasPreference()) processPreference(context, frame.getPreference());
|
||||
else if (frame.hasAttachment()) processAttachment(context, attachmentSecret, db, frame.getAttachment(), inputStream);
|
||||
else if (frame.hasSticker()) processSticker(context, attachmentSecret, db, frame.getSticker(), inputStream);
|
||||
else if (frame.hasAvatar()) processAvatar(context, db, frame.getAvatar(), inputStream);
|
||||
else if (frame.hasKeyValue()) processKeyValue(frame.getKeyValue());
|
||||
if (frame.version != null) processVersion(db, frame.version);
|
||||
else if (frame.statement != null) tryProcessStatement(db, frame.statement);
|
||||
else if (frame.preference != null) processPreference(context, frame.preference);
|
||||
else if (frame.attachment != null) processAttachment(context, attachmentSecret, db, frame.attachment, inputStream);
|
||||
else if (frame.sticker != null) processSticker(context, attachmentSecret, db, frame.sticker, inputStream);
|
||||
else if (frame.avatar != null) processAvatar(context, db, frame.avatar, inputStream);
|
||||
else if (frame.keyValue != null) processKeyValue(frame.keyValue);
|
||||
else count--;
|
||||
}
|
||||
|
||||
@@ -119,35 +122,65 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
}
|
||||
|
||||
private static void processVersion(@NonNull SQLiteDatabase db, DatabaseVersion version) throws IOException {
|
||||
if (version.getVersion() > db.getVersion()) {
|
||||
throw new DatabaseDowngradeException(db.getVersion(), version.getVersion());
|
||||
if (version.version == null || version.version > db.getVersion()) {
|
||||
throw new DatabaseDowngradeException(db.getVersion(), version.version != null ? version.version : -1);
|
||||
}
|
||||
|
||||
db.setVersion(version.getVersion());
|
||||
db.setVersion(version.version);
|
||||
}
|
||||
|
||||
private static void tryProcessStatement(@NonNull SQLiteDatabase db, SqlStatement statement) {
|
||||
try {
|
||||
processStatement(db, statement);
|
||||
} catch (SQLiteConstraintException e) {
|
||||
String tableName = "?";
|
||||
String statementString = statement.statement;
|
||||
|
||||
if (statementString != null && statementString.startsWith("INSERT INTO ")) {
|
||||
int nameStart = "INSERT INTO ".length();
|
||||
int nameEnd = statementString.indexOf(" ", "INSERT INTO ".length());
|
||||
|
||||
if (nameStart < statementString.length() && nameEnd > nameStart) {
|
||||
tableName = statementString.substring(nameStart, nameEnd);
|
||||
}
|
||||
}
|
||||
|
||||
if (tableName.startsWith("msl_")) {
|
||||
Log.w(TAG, "Constraint failed when inserting into " + tableName + ". Ignoring.");
|
||||
} else {
|
||||
Log.w(TAG, "Constraint failed when inserting into " + tableName + ". Throwing!");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void processStatement(@NonNull SQLiteDatabase db, SqlStatement statement) {
|
||||
boolean isForMmsFtsSecretTable = statement.getStatement().contains(SearchTable.MMS_FTS_TABLE_NAME + "_");
|
||||
boolean isForEmojiSecretTable = statement.getStatement().contains(EmojiSearchTable.TABLE_NAME + "_");
|
||||
boolean isForSqliteSecretTable = statement.getStatement().toLowerCase().startsWith("create table sqlite_");
|
||||
if (statement.statement == null) {
|
||||
Log.w(TAG, "Null statement!");
|
||||
return;
|
||||
}
|
||||
|
||||
boolean isForMmsFtsSecretTable = statement.statement.contains(SearchTable.FTS_TABLE_NAME + "_");
|
||||
boolean isForEmojiSecretTable = statement.statement.contains(EmojiSearchTable.TABLE_NAME + "_");
|
||||
boolean isForSqliteSecretTable = statement.statement.toLowerCase().startsWith("create table sqlite_");
|
||||
|
||||
if (isForMmsFtsSecretTable || isForEmojiSecretTable || isForSqliteSecretTable) {
|
||||
Log.i(TAG, "Ignoring import for statement: " + statement.getStatement());
|
||||
Log.i(TAG, "Ignoring import for statement: " + statement.statement);
|
||||
return;
|
||||
}
|
||||
|
||||
List<Object> parameters = new LinkedList<>();
|
||||
|
||||
for (SqlStatement.SqlParameter parameter : statement.getParametersList()) {
|
||||
if (parameter.hasStringParamter()) parameters.add(parameter.getStringParamter());
|
||||
else if (parameter.hasDoubleParameter()) parameters.add(parameter.getDoubleParameter());
|
||||
else if (parameter.hasIntegerParameter()) parameters.add(parameter.getIntegerParameter());
|
||||
else if (parameter.hasBlobParameter()) parameters.add(parameter.getBlobParameter().toByteArray());
|
||||
else if (parameter.hasNullparameter()) parameters.add(null);
|
||||
for (SqlStatement.SqlParameter parameter : statement.parameters) {
|
||||
if (parameter.stringParamter != null) parameters.add(parameter.stringParamter);
|
||||
else if (parameter.doubleParameter != null) parameters.add(parameter.doubleParameter);
|
||||
else if (parameter.integerParameter != null) parameters.add(parameter.integerParameter);
|
||||
else if (parameter.blobParameter != null) parameters.add(parameter.blobParameter.toByteArray());
|
||||
else if (parameter.nullparameter != null) parameters.add(null);
|
||||
}
|
||||
|
||||
if (parameters.size() > 0) db.execSQL(statement.getStatement(), parameters.toArray());
|
||||
else db.execSQL(statement.getStatement());
|
||||
if (parameters.size() > 0) db.execSQL(statement.statement, parameters.toArray());
|
||||
else db.execSQL(statement.statement);
|
||||
}
|
||||
|
||||
private static void processAttachment(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Attachment attachment, BackupRecordInputStream inputStream)
|
||||
@@ -159,12 +192,12 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
|
||||
try {
|
||||
inputStream.readAttachmentTo(output.second, attachment.getLength());
|
||||
inputStream.readAttachmentTo(output.second, attachment.length);
|
||||
|
||||
contentValues.put(AttachmentTable.DATA, dataFile.getAbsolutePath());
|
||||
contentValues.put(AttachmentTable.DATA_RANDOM, output.first);
|
||||
} catch (BackupRecordInputStream.BadMacException e) {
|
||||
Log.w(TAG, "Bad MAC for attachment " + attachment.getAttachmentId() + "! Can't restore it.", e);
|
||||
Log.w(TAG, "Bad MAC for attachment " + attachment.attachmentId + "! Can't restore it.", e);
|
||||
dataFile.delete();
|
||||
contentValues.put(AttachmentTable.DATA, (String) null);
|
||||
contentValues.put(AttachmentTable.DATA_RANDOM, (String) null);
|
||||
@@ -172,7 +205,7 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
|
||||
db.update(AttachmentTable.TABLE_NAME, contentValues,
|
||||
AttachmentTable.ROW_ID + " = ? AND " + AttachmentTable.UNIQUE_ID + " = ?",
|
||||
new String[] {String.valueOf(attachment.getRowId()), String.valueOf(attachment.getAttachmentId())});
|
||||
new String[] {String.valueOf(attachment.rowId), String.valueOf(attachment.attachmentId)});
|
||||
}
|
||||
|
||||
private static void processSticker(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Sticker sticker, BackupRecordInputStream inputStream)
|
||||
@@ -183,52 +216,57 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
|
||||
Pair<byte[], OutputStream> output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false);
|
||||
|
||||
inputStream.readAttachmentTo(output.second, sticker.getLength());
|
||||
inputStream.readAttachmentTo(output.second, sticker.length);
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(StickerTable.FILE_PATH, dataFile.getAbsolutePath());
|
||||
contentValues.put(StickerTable.FILE_LENGTH, sticker.getLength());
|
||||
contentValues.put(StickerTable.FILE_LENGTH, sticker.length);
|
||||
contentValues.put(StickerTable.FILE_RANDOM, output.first);
|
||||
|
||||
db.update(StickerTable.TABLE_NAME, contentValues,
|
||||
StickerTable._ID + " = ?",
|
||||
new String[] {String.valueOf(sticker.getRowId())});
|
||||
new String[] {String.valueOf(sticker.rowId)});
|
||||
}
|
||||
|
||||
private static void processAvatar(@NonNull Context context, @NonNull SQLiteDatabase db, @NonNull BackupProtos.Avatar avatar, @NonNull BackupRecordInputStream inputStream) throws IOException {
|
||||
if (avatar.hasRecipientId()) {
|
||||
RecipientId recipientId = RecipientId.from(avatar.getRecipientId());
|
||||
inputStream.readAttachmentTo(AvatarHelper.getOutputStream(context, recipientId, false), avatar.getLength());
|
||||
private static void processAvatar(@NonNull Context context, @NonNull SQLiteDatabase db, @NonNull Avatar avatar, @NonNull BackupRecordInputStream inputStream) throws IOException {
|
||||
if (avatar.recipientId != null) {
|
||||
RecipientId recipientId = RecipientId.from(avatar.recipientId);
|
||||
inputStream.readAttachmentTo(AvatarHelper.getOutputStream(context, recipientId, false), avatar.length);
|
||||
} else {
|
||||
if (avatar.hasName() && SqlUtil.tableExists(db, "recipient_preferences")) {
|
||||
if (avatar.name != null && SqlUtil.tableExists(db, "recipient_preferences")) {
|
||||
Log.w(TAG, "Avatar is missing a recipientId. Clearing signal_profile_avatar (legacy) so it can be fetched later.");
|
||||
db.execSQL("UPDATE recipient_preferences SET signal_profile_avatar = NULL WHERE recipient_ids = ?", new String[] { avatar.getName() });
|
||||
} else if (avatar.hasName() && SqlUtil.tableExists(db, "recipient")) {
|
||||
db.execSQL("UPDATE recipient_preferences SET signal_profile_avatar = NULL WHERE recipient_ids = ?", new String[] { avatar.name });
|
||||
} else if (avatar.name != null && SqlUtil.tableExists(db, "recipient")) {
|
||||
Log.w(TAG, "Avatar is missing a recipientId. Clearing signal_profile_avatar so it can be fetched later.");
|
||||
db.execSQL("UPDATE recipient SET signal_profile_avatar = NULL WHERE phone = ?", new String[] { avatar.getName() });
|
||||
db.execSQL("UPDATE recipient SET signal_profile_avatar = NULL WHERE phone = ?", new String[] { avatar.name });
|
||||
} else {
|
||||
Log.w(TAG, "Avatar is missing a recipientId. Skipping avatar restore.");
|
||||
}
|
||||
|
||||
inputStream.readAttachmentTo(new ByteArrayOutputStream(), avatar.getLength());
|
||||
inputStream.readAttachmentTo(new ByteArrayOutputStream(), avatar.length);
|
||||
}
|
||||
}
|
||||
|
||||
private static void processKeyValue(BackupProtos.KeyValue keyValue) {
|
||||
private static void processKeyValue(KeyValue keyValue) {
|
||||
KeyValueDataSet dataSet = new KeyValueDataSet();
|
||||
|
||||
if (keyValue.hasBlobValue()) {
|
||||
dataSet.putBlob(keyValue.getKey(), keyValue.getBlobValue().toByteArray());
|
||||
} else if (keyValue.hasBooleanValue()) {
|
||||
dataSet.putBoolean(keyValue.getKey(), keyValue.getBooleanValue());
|
||||
} else if (keyValue.hasFloatValue()) {
|
||||
dataSet.putFloat(keyValue.getKey(), keyValue.getFloatValue());
|
||||
} else if (keyValue.hasIntegerValue()) {
|
||||
dataSet.putInteger(keyValue.getKey(), keyValue.getIntegerValue());
|
||||
} else if (keyValue.hasLongValue()) {
|
||||
dataSet.putLong(keyValue.getKey(), keyValue.getLongValue());
|
||||
} else if (keyValue.hasStringValue()) {
|
||||
dataSet.putString(keyValue.getKey(), keyValue.getStringValue());
|
||||
if (keyValue.key == null) {
|
||||
Log.w(TAG, "Null preference key!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyValue.blobValue != null) {
|
||||
dataSet.putBlob(keyValue.key, keyValue.blobValue.toByteArray());
|
||||
} else if (keyValue.booleanValue != null) {
|
||||
dataSet.putBoolean(keyValue.key, keyValue.booleanValue);
|
||||
} else if (keyValue.floatValue != null) {
|
||||
dataSet.putFloat(keyValue.key, keyValue.floatValue);
|
||||
} else if (keyValue.integerValue != null) {
|
||||
dataSet.putInteger(keyValue.key, keyValue.integerValue);
|
||||
} else if (keyValue.longValue != null) {
|
||||
dataSet.putLong(keyValue.key, keyValue.longValue);
|
||||
} else if (keyValue.stringValue != null) {
|
||||
dataSet.putString(keyValue.key, keyValue.stringValue);
|
||||
} else {
|
||||
Log.i(TAG, "Unknown KeyValue backup value, skipping");
|
||||
return;
|
||||
@@ -239,25 +277,25 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
|
||||
@SuppressLint("ApplySharedPref")
|
||||
private static void processPreference(@NonNull Context context, SharedPreference preference) {
|
||||
SharedPreferences preferences = context.getSharedPreferences(preference.getFile(), 0);
|
||||
SharedPreferences preferences = context.getSharedPreferences(preference.file_, 0);
|
||||
|
||||
// Identity keys were moved from shared prefs into SignalStore. Need to handle importing backups made before the migration.
|
||||
if ("SecureSMS-Preferences".equals(preference.getFile())) {
|
||||
if ("pref_identity_public_v3".equals(preference.getKey()) && preference.hasValue()) {
|
||||
SignalStore.account().restoreLegacyIdentityPublicKeyFromBackup(preference.getValue());
|
||||
} else if ("pref_identity_private_v3".equals(preference.getKey()) && preference.hasValue()) {
|
||||
SignalStore.account().restoreLegacyIdentityPrivateKeyFromBackup(preference.getValue());
|
||||
if ("SecureSMS-Preferences".equals(preference.file_)) {
|
||||
if ("pref_identity_public_v3".equals(preference.key) && preference.value_ != null) {
|
||||
SignalStore.account().restoreLegacyIdentityPublicKeyFromBackup(preference.value_);
|
||||
} else if ("pref_identity_private_v3".equals(preference.key) && preference.value_ != null) {
|
||||
SignalStore.account().restoreLegacyIdentityPrivateKeyFromBackup(preference.value_);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (preference.hasValue()) {
|
||||
preferences.edit().putString(preference.getKey(), preference.getValue()).commit();
|
||||
} else if (preference.hasBooleanValue()) {
|
||||
preferences.edit().putBoolean(preference.getKey(), preference.getBooleanValue()).commit();
|
||||
} else if (preference.hasIsStringSetValue() && preference.getIsStringSetValue()) {
|
||||
preferences.edit().putStringSet(preference.getKey(), new HashSet<>(preference.getStringSetValueList())).commit();
|
||||
if (preference.value_ != null) {
|
||||
preferences.edit().putString(preference.key, preference.value_).commit();
|
||||
} else if (preference.booleanValue != null) {
|
||||
preferences.edit().putBoolean(preference.key, preference.booleanValue).commit();
|
||||
} else if (preference.isStringSetValue == Boolean.TRUE) {
|
||||
preferences.edit().putStringSet(preference.key, new HashSet<>(preference.stringSetValue)).commit();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.badges.gifts
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.thoughtcrime.securesms.badges.gifts.ExpiredGiftSheetConfiguration.forExpiredBadge
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
@@ -28,7 +29,7 @@ class ExpiredGiftSheet : DSLSettingsBottomSheetFragment() {
|
||||
}
|
||||
|
||||
private val badge: Badge
|
||||
get() = requireArguments().getParcelable(ARG_BADGE)!!
|
||||
get() = requireArguments().getParcelableCompat(ARG_BADGE, Badge::class.java)!!
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
ExpiredGiftSheetConfiguration.register(adapter)
|
||||
|
||||
@@ -31,7 +31,8 @@ object ExpiredGiftSheetConfiguration {
|
||||
textPref(
|
||||
title = DSLSettingsText.from(
|
||||
stringId = R.string.ExpiredGiftSheetConfiguration__your_badge_has_expired,
|
||||
DSLSettingsText.CenterModifier, DSLSettingsText.TitleLargeModifier
|
||||
DSLSettingsText.CenterModifier,
|
||||
DSLSettingsText.TitleLargeModifier
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ class GiftMessageView @JvmOverloads constructor(
|
||||
|
||||
if (isOutgoing) {
|
||||
actionView.setText(R.string.GiftMessageView__view)
|
||||
titleView.text = context.getString(R.string.GiftMessageView__donation_to_s, recipient.getDisplayName(context))
|
||||
titleView.text = context.getString(R.string.GiftMessageView__donation_on_behalf_of_s, recipient.getDisplayName(context))
|
||||
} else {
|
||||
titleView.text = context.getString(R.string.GiftMessageView__s_donated_to_signal_on, recipient.getShortDisplayName(context))
|
||||
when (giftBadge.redemptionState) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.util.getParcelableArrayListCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
@@ -79,8 +80,7 @@ class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient
|
||||
override fun onSearchInputFocused() = Unit
|
||||
|
||||
override fun setResult(bundle: Bundle) {
|
||||
val parcelableContacts: List<ContactSearchKey.ParcelableRecipientSearchKey> = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION)!!
|
||||
val contacts = parcelableContacts.map { it.asRecipientSearchKey() }
|
||||
val contacts: List<ContactSearchKey.RecipientSearchKey> = bundle.getParcelableArrayListCompat(MultiselectForwardFragment.RESULT_SELECTION, ContactSearchKey.RecipientSearchKey::class.java)!!
|
||||
|
||||
if (contacts.isNotEmpty()) {
|
||||
viewModel.setSelectedContact(contacts.first())
|
||||
|
||||
@@ -117,7 +117,7 @@ class GiftFlowViewModel(
|
||||
private fun getLoadState(
|
||||
oldState: GiftFlowState,
|
||||
giftPrices: Map<Currency, FiatMoney>? = null,
|
||||
giftBadge: Badge? = null,
|
||||
giftBadge: Badge? = null
|
||||
): GiftFlowState.Stage {
|
||||
if (oldState.stage != GiftFlowState.Stage.INIT) {
|
||||
return oldState.stage
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.os.Bundle
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.BadgePreview
|
||||
@@ -41,10 +42,10 @@ class GiftThanksSheet : DSLSettingsBottomSheetFragment() {
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private val recipientId: RecipientId
|
||||
get() = requireArguments().getParcelable(ARGS_RECIPIENT_ID)!!
|
||||
get() = requireArguments().getParcelableCompat(ARGS_RECIPIENT_ID, RecipientId::class.java)!!
|
||||
|
||||
private val badge: Badge
|
||||
get() = requireArguments().getParcelable(ARGS_BADGE)!!
|
||||
get() = requireArguments().getParcelableCompat(ARGS_BADGE, Badge::class.java)!!
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
BadgePreview.register(adapter)
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
|
||||
@@ -73,7 +74,7 @@ class ViewReceivedGiftBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private val sentFrom: RecipientId
|
||||
get() = requireArguments().getParcelable(ARG_SENT_FROM)!!
|
||||
get() = requireArguments().getParcelableCompat(ARG_SENT_FROM, RecipientId::class.java)!!
|
||||
|
||||
private val messageId: Long
|
||||
get() = requireArguments().getLong(ARG_MESSAGE_ID)
|
||||
@@ -123,7 +124,8 @@ class ViewReceivedGiftBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
}
|
||||
|
||||
errorDialog = DonationErrorDialogs.show(
|
||||
requireContext(), throwable,
|
||||
requireContext(),
|
||||
throwable,
|
||||
object : DonationErrorDialogs.DialogCallback() {
|
||||
override fun onDialogDismissed() {
|
||||
findNavController().popBackStack()
|
||||
@@ -158,7 +160,8 @@ class ViewReceivedGiftBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
charSequence = requireContext().getString(R.string.ViewReceivedGiftBottomSheet__s_made_a_donation_for_you, state.recipient.getShortDisplayName(requireContext())),
|
||||
DSLSettingsText.CenterModifier, DSLSettingsText.TitleLargeModifier
|
||||
DSLSettingsText.CenterModifier,
|
||||
DSLSettingsText.TitleLargeModifier
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.gifts.viewgift.ViewGiftRepository
|
||||
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
|
||||
@@ -41,7 +42,7 @@ class ViewSentGiftBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
}
|
||||
|
||||
private val sentTo: RecipientId
|
||||
get() = requireArguments().getParcelable(ARG_SENT_TO)!!
|
||||
get() = requireArguments().getParcelableCompat(ARG_SENT_TO, RecipientId::class.java)!!
|
||||
|
||||
private val giftBadge: GiftBadge
|
||||
get() = GiftBadge.parseFrom(requireArguments().getByteArray(ARG_GIFT_BADGE))
|
||||
@@ -66,7 +67,8 @@ class ViewSentGiftBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
stringId = R.string.ViewSentGiftBottomSheet__thanks_for_your_support,
|
||||
DSLSettingsText.CenterModifier, DSLSettingsText.TitleLargeModifier
|
||||
DSLSettingsText.CenterModifier,
|
||||
DSLSettingsText.TitleLargeModifier
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ data class Badge(
|
||||
.downsample(DownsampleStrategy.NONE)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.transform(
|
||||
BadgeSpriteTransformation(BadgeSpriteTransformation.Size.BADGE_64, model.badge.imageDensity, ThemeUtil.isDarkTheme(context)),
|
||||
BadgeSpriteTransformation(BadgeSpriteTransformation.Size.BADGE_64, model.badge.imageDensity, ThemeUtil.isDarkTheme(context))
|
||||
)
|
||||
.into(badge)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
@@ -122,9 +123,9 @@ class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStartBadge(): Badge? = requireArguments().getParcelable(ARG_START_BADGE)
|
||||
private fun getStartBadge(): Badge? = requireArguments().getParcelableCompat(ARG_START_BADGE, Badge::class.java)
|
||||
|
||||
private fun getRecipientId(): RecipientId = requireNotNull(requireArguments().getParcelable(ARG_RECIPIENT_ID))
|
||||
private fun getRecipientId(): RecipientId = requireNotNull(requireArguments().getParcelableCompat(ARG_RECIPIENT_ID, RecipientId::class.java))
|
||||
|
||||
companion object {
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import org.thoughtcrime.securesms.ContactSelectionListFragment;
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
@@ -143,11 +143,11 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, 1);
|
||||
intent.putExtra(ContactSelectionListFragment.HIDE_COUNT, true);
|
||||
intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE,
|
||||
ContactsCursorLoader.DisplayMode.FLAG_PUSH |
|
||||
ContactsCursorLoader.DisplayMode.FLAG_SMS |
|
||||
ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS |
|
||||
ContactsCursorLoader.DisplayMode.FLAG_INACTIVE_GROUPS |
|
||||
ContactsCursorLoader.DisplayMode.FLAG_BLOCK);
|
||||
ContactSelectionDisplayMode.FLAG_PUSH |
|
||||
ContactSelectionDisplayMode.FLAG_SMS |
|
||||
ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS |
|
||||
ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS |
|
||||
ContactSelectionDisplayMode.FLAG_BLOCK);
|
||||
|
||||
getSupportFragmentManager().beginTransaction()
|
||||
.replace(R.id.fragment_container, fragment, CONTACT_SELECTION_FRAGMENT)
|
||||
|
||||
@@ -27,13 +27,15 @@ public class AlbumThumbnailView extends FrameLayout {
|
||||
|
||||
private int currentSizeClass;
|
||||
|
||||
private final int[] corners = new int[4];
|
||||
|
||||
private ViewGroup albumCellContainer;
|
||||
private Stub<TransferControlView> transferControls;
|
||||
|
||||
private final SlideClickListener defaultThumbnailClickListener = (v, slide) -> {
|
||||
if (thumbnailClickListener != null) {
|
||||
thumbnailClickListener.onClick(v, slide);
|
||||
}
|
||||
if (thumbnailClickListener != null) {
|
||||
thumbnailClickListener.onClick(v, slide);
|
||||
}
|
||||
};
|
||||
|
||||
private final OnLongClickListener defaultLongClickListener = v -> this.performLongClick();
|
||||
@@ -82,6 +84,7 @@ public class AlbumThumbnailView extends FrameLayout {
|
||||
}
|
||||
|
||||
showSlides(glideRequests, slides);
|
||||
applyCorners();
|
||||
}
|
||||
|
||||
public void setCellBackgroundColor(@ColorInt int color) {
|
||||
@@ -102,6 +105,15 @@ public class AlbumThumbnailView extends FrameLayout {
|
||||
downloadClickListener = listener;
|
||||
}
|
||||
|
||||
public void setRadii(int topLeft, int topRight, int bottomRight, int bottomLeft) {
|
||||
corners[0] = topLeft;
|
||||
corners[1] = topRight;
|
||||
corners[2] = bottomRight;
|
||||
corners[3] = bottomLeft;
|
||||
|
||||
applyCorners();
|
||||
}
|
||||
|
||||
private void inflateLayout(int sizeClass) {
|
||||
albumCellContainer.removeAllViews();
|
||||
|
||||
@@ -124,6 +136,83 @@ public class AlbumThumbnailView extends FrameLayout {
|
||||
}
|
||||
}
|
||||
|
||||
private void applyCorners() {
|
||||
if (currentSizeClass < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (currentSizeClass) {
|
||||
case 2:
|
||||
applyCornersForSizeClass2();
|
||||
break;
|
||||
case 3:
|
||||
applyCornersForSizeClass3();
|
||||
break;
|
||||
case 4:
|
||||
applyCornersForSizeClass4();
|
||||
break;
|
||||
case 5:
|
||||
applyCornersForSizeClass5();
|
||||
break;
|
||||
default:
|
||||
applyCornersForManySizeClass();
|
||||
}
|
||||
}
|
||||
|
||||
private ThumbnailView[] getCells() {
|
||||
ThumbnailView one = findViewById(R.id.album_cell_1);
|
||||
ThumbnailView two = findViewById(R.id.album_cell_2);
|
||||
ThumbnailView three = findViewById(R.id.album_cell_3);
|
||||
ThumbnailView four = findViewById(R.id.album_cell_4);
|
||||
ThumbnailView five = findViewById(R.id.album_cell_5);
|
||||
|
||||
return new ThumbnailView[] { one, two, three, four, five };
|
||||
}
|
||||
|
||||
private void applyCornersForSizeClass2() {
|
||||
ThumbnailView[] cells = getCells();
|
||||
setRelativeRadii(cells[0], corners[0], 0, 0, corners[3]);
|
||||
setRelativeRadii(cells[1], 0, corners[1], corners[2], 0);
|
||||
}
|
||||
|
||||
private void applyCornersForSizeClass3() {
|
||||
ThumbnailView[] cells = getCells();
|
||||
setRelativeRadii(cells[0], corners[0], 0, 0, corners[3]);
|
||||
setRelativeRadii(cells[1], 0, corners[1], 0, 0);
|
||||
setRelativeRadii(cells[2], 0, 0, corners[2], 0);
|
||||
}
|
||||
|
||||
private void applyCornersForSizeClass4() {
|
||||
ThumbnailView[] cells = getCells();
|
||||
setRelativeRadii(cells[0], corners[0], 0, 0, 0);
|
||||
setRelativeRadii(cells[1], 0, corners[1], 0, 0);
|
||||
setRelativeRadii(cells[2], 0, 0, 0, corners[3]);
|
||||
setRelativeRadii(cells[3], 0, 0, corners[2], 0);
|
||||
}
|
||||
|
||||
private void applyCornersForSizeClass5() {
|
||||
ThumbnailView[] cells = getCells();
|
||||
setRelativeRadii(cells[0], corners[0], 0, 0, 0);
|
||||
setRelativeRadii(cells[1], 0, corners[1], 0, 0);
|
||||
setRelativeRadii(cells[2], 0, 0, 0, corners[3]);
|
||||
setRelativeRadii(cells[3], 0, 0, 0, 0);
|
||||
setRelativeRadii(cells[4], 0, 0, corners[2], 0);
|
||||
}
|
||||
|
||||
private void setRelativeRadii(@NonNull ThumbnailView cell, int topLeft, int topRight, int bottomRight, int bottomLeft) {
|
||||
boolean isLTR = getRootView().getLayoutDirection() == LAYOUT_DIRECTION_LTR;
|
||||
cell.setRadii(
|
||||
isLTR ? topLeft : topRight,
|
||||
isLTR ? topRight : topLeft,
|
||||
isLTR ? bottomRight : bottomLeft,
|
||||
isLTR ? bottomLeft : bottomRight
|
||||
);
|
||||
}
|
||||
|
||||
private void applyCornersForManySizeClass() {
|
||||
applyCornersForSizeClass5();
|
||||
}
|
||||
|
||||
private void showSlides(@NonNull GlideRequests glideRequests, @NonNull List<Slide> slides) {
|
||||
setSlide(glideRequests, slides.get(0), R.id.album_cell_1);
|
||||
setSlide(glideRequests, slides.get(1), R.id.album_cell_2);
|
||||
|
||||
@@ -28,7 +28,6 @@ public class AlertView extends LinearLayout {
|
||||
initialize(attrs);
|
||||
}
|
||||
|
||||
@TargetApi(VERSION_CODES.HONEYCOMB)
|
||||
public AlertView(final Context context, AttributeSet attrs, int defStyle) {
|
||||
super(context, attrs, defStyle);
|
||||
initialize(attrs);
|
||||
|
||||
@@ -54,7 +54,7 @@ public class AnimatingToggle extends FrameLayout {
|
||||
}
|
||||
|
||||
public void display(@Nullable View view) {
|
||||
if (view == current) return;
|
||||
if (view == current && current.getVisibility() == View.VISIBLE) return;
|
||||
if (current != null) ViewUtil.animateOut(current, outAnimation, View.GONE);
|
||||
if (view != null) ViewUtil.animateIn(view, inAnimation);
|
||||
|
||||
@@ -62,7 +62,7 @@ public class AnimatingToggle extends FrameLayout {
|
||||
}
|
||||
|
||||
public void displayQuick(@Nullable View view) {
|
||||
if (view == current) return;
|
||||
if (view == current && current.getVisibility() == View.VISIBLE) return;
|
||||
if (current != null) current.setVisibility(View.GONE);
|
||||
if (view != null) view.setVisibility(View.VISIBLE);
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.audio.AudioWaveForm;
|
||||
import org.thoughtcrime.securesms.audio.AudioWaveForms;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.events.PartProgressEvent;
|
||||
@@ -43,6 +43,9 @@ import org.thoughtcrime.securesms.mms.SlideClickListener;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
public final class AudioView extends FrameLayout {
|
||||
|
||||
private static final String TAG = Log.tag(AudioView.class);
|
||||
@@ -77,6 +80,8 @@ public final class AudioView extends FrameLayout {
|
||||
private AudioSlide audioSlide;
|
||||
private Callbacks callbacks;
|
||||
|
||||
private Disposable disposable = Disposable.disposed();
|
||||
|
||||
private final Observer<VoiceNotePlaybackState> playbackStateObserver = this::onPlaybackState;
|
||||
|
||||
public AudioView(Context context) {
|
||||
@@ -155,6 +160,7 @@ public final class AudioView extends FrameLayout {
|
||||
protected void onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow();
|
||||
EventBus.getDefault().unregister(this);
|
||||
disposable.dispose();
|
||||
}
|
||||
|
||||
public void setProgressAndPlayBackgroundTint(@ColorInt int color) {
|
||||
@@ -170,6 +176,7 @@ public final class AudioView extends FrameLayout {
|
||||
final boolean showControls,
|
||||
final boolean forceHideDuration)
|
||||
{
|
||||
this.disposable.dispose();
|
||||
this.callbacks = callbacks;
|
||||
|
||||
if (duration != null) {
|
||||
@@ -212,16 +219,19 @@ public final class AudioView extends FrameLayout {
|
||||
WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar;
|
||||
waveFormView.setColors(waveFormPlayedBarsColor, waveFormUnplayedBarsColor, waveFormThumbTint);
|
||||
if (android.os.Build.VERSION.SDK_INT >= 23) {
|
||||
new AudioWaveForm(getContext(), audio).getWaveForm(
|
||||
data -> {
|
||||
durationMillis = data.getDuration(TimeUnit.MILLISECONDS);
|
||||
updateProgress(0, 0);
|
||||
if (!forceHideDuration && duration != null) {
|
||||
duration.setVisibility(VISIBLE);
|
||||
}
|
||||
waveFormView.setWaveData(data.getWaveForm());
|
||||
},
|
||||
() -> waveFormView.setWaveMode(false));
|
||||
disposable = AudioWaveForms.getWaveForm(getContext(), audioSlide.asAttachment())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
data -> {
|
||||
durationMillis = data.getDuration(TimeUnit.MILLISECONDS);
|
||||
updateProgress(0, 0);
|
||||
if (!forceHideDuration && duration != null) {
|
||||
duration.setVisibility(VISIBLE);
|
||||
}
|
||||
waveFormView.setWaveData(data.getWaveForm());
|
||||
},
|
||||
t -> waveFormView.setWaveMode(false)
|
||||
);
|
||||
} else {
|
||||
waveFormView.setWaveMode(false);
|
||||
if (duration != null) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -58,10 +59,10 @@ public class BorderlessImageView extends FrameLayout {
|
||||
boolean showControls = slide.asAttachment().getUri() == null;
|
||||
|
||||
if (slide.hasSticker()) {
|
||||
image.setFit(new CenterInside());
|
||||
image.setScaleType(ImageView.ScaleType.FIT_CENTER);
|
||||
image.setImageResource(glideRequests, slide, showControls, false);
|
||||
} else {
|
||||
image.setFit(new CenterCrop());
|
||||
image.setScaleType(ImageView.ScaleType.CENTER_CROP);
|
||||
image.setImageResource(glideRequests, slide, showControls, false, slide.asAttachment().getWidth(), slide.asAttachment().getHeight());
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import android.graphics.Path
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.core.graphics.withClip
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
|
||||
/**
|
||||
* Adds manual clipping around the card. This ensures that software rendering
|
||||
@@ -16,7 +16,7 @@ import androidx.core.graphics.withClip
|
||||
class ClippedCardView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : CardView(context, attrs) {
|
||||
) : MaterialCardView(context, attrs) {
|
||||
|
||||
private val bounds = Rect()
|
||||
private val boundsF = RectF()
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components;
|
||||
import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Typeface;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.Annotation;
|
||||
@@ -14,8 +15,15 @@ import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextUtils.TruncateAt;
|
||||
import android.text.style.CharacterStyle;
|
||||
import android.text.style.RelativeSizeSpan;
|
||||
import android.text.style.StrikethroughSpan;
|
||||
import android.text.style.StyleSpan;
|
||||
import android.text.style.TypefaceSpan;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.ActionMode;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputConnection;
|
||||
|
||||
@@ -35,18 +43,19 @@ import org.thoughtcrime.securesms.components.mention.MentionDeleter;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionValidatorWatcher;
|
||||
import org.thoughtcrime.securesms.conversation.MessageSendType;
|
||||
import org.thoughtcrime.securesms.conversation.MessageStyler;
|
||||
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQuery;
|
||||
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryChangedListener;
|
||||
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryReplacement;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER;
|
||||
@@ -248,10 +257,6 @@ public class ComposeText extends EmojiEditText {
|
||||
editorInfo.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < 21) {
|
||||
return inputConnection;
|
||||
}
|
||||
|
||||
if (mediaListener == null) {
|
||||
return inputConnection;
|
||||
}
|
||||
@@ -280,6 +285,19 @@ public class ComposeText extends EmojiEditText {
|
||||
return MentionAnnotation.getMentionsFromAnnotations(getText());
|
||||
}
|
||||
|
||||
public boolean hasStyling() {
|
||||
CharSequence trimmed = getTextTrimmed();
|
||||
return FeatureFlags.textFormatting() && (trimmed instanceof Spanned) && MessageStyler.hasStyling((Spanned) trimmed);
|
||||
}
|
||||
|
||||
public @Nullable BodyRangeList getStyling() {
|
||||
if (FeatureFlags.textFormatting()) {
|
||||
return MessageStyler.getStyling(getTextTrimmed());
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
|
||||
setImeOptions(getImeOptions() | 16777216);
|
||||
@@ -290,6 +308,80 @@ public class ComposeText extends EmojiEditText {
|
||||
addTextChangedListener(new MentionDeleter());
|
||||
mentionValidatorWatcher = new MentionValidatorWatcher();
|
||||
addTextChangedListener(mentionValidatorWatcher);
|
||||
|
||||
if (FeatureFlags.textFormatting()) {
|
||||
setCustomSelectionActionModeCallback(new ActionMode.Callback() {
|
||||
@Override
|
||||
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
|
||||
MenuItem copy = menu.findItem(android.R.id.copy);
|
||||
MenuItem cut = menu.findItem(android.R.id.cut);
|
||||
MenuItem paste = menu.findItem(android.R.id.paste);
|
||||
int copyOrder = copy != null ? copy.getOrder() : 0;
|
||||
int cutOrder = cut != null ? cut.getOrder() : 0;
|
||||
int pasteOrder = paste != null ? paste.getOrder() : 0;
|
||||
int largestOrder = Math.max(copyOrder, Math.max(cutOrder, pasteOrder));
|
||||
|
||||
menu.add(0, R.id.edittext_bold, largestOrder, getContext().getString(R.string.TextFormatting_bold));
|
||||
menu.add(0, R.id.edittext_italic, largestOrder, getContext().getString(R.string.TextFormatting_italic));
|
||||
menu.add(0, R.id.edittext_strikethrough, largestOrder, getContext().getString(R.string.TextFormatting_strikethrough));
|
||||
menu.add(0, R.id.edittext_monospace, largestOrder, getContext().getString(R.string.TextFormatting_monospace));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
|
||||
Editable text = getText();
|
||||
|
||||
if (text == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item.getItemId() != R.id.edittext_bold &&
|
||||
item.getItemId() != R.id.edittext_italic &&
|
||||
item.getItemId() != R.id.edittext_strikethrough &&
|
||||
item.getItemId() != R.id.edittext_monospace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int start = getSelectionStart();
|
||||
int end = getSelectionEnd();
|
||||
|
||||
CharSequence charSequence = text.subSequence(start, end);
|
||||
SpannableString replacement = new SpannableString(charSequence);
|
||||
CharacterStyle style = null;
|
||||
|
||||
if (item.getItemId() == R.id.edittext_bold) {
|
||||
style = MessageStyler.boldStyle();
|
||||
} else if (item.getItemId() == R.id.edittext_italic) {
|
||||
style = MessageStyler.italicStyle();
|
||||
} else if (item.getItemId() == R.id.edittext_strikethrough) {
|
||||
style = MessageStyler.strikethroughStyle();
|
||||
} else if (item.getItemId() == R.id.edittext_monospace) {
|
||||
style = MessageStyler.monoStyle();
|
||||
}
|
||||
|
||||
if (style != null) {
|
||||
replacement.setSpan(style, 0, charSequence.length(), Spannable.SPAN_EXCLUSIVE_INCLUSIVE);
|
||||
}
|
||||
|
||||
clearComposingText();
|
||||
|
||||
text.replace(start, end, replacement);
|
||||
|
||||
mode.finish();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyActionMode(ActionMode mode) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void setHintWithChecks(@Nullable CharSequence newHint) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
@@ -36,6 +37,7 @@ import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
@@ -315,6 +317,8 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
dateView.setText(R.string.ConversationItem_click_to_approve_unencrypted);
|
||||
} else if (messageRecord.isRateLimited()) {
|
||||
dateView.setText(R.string.ConversationItem_send_paused);
|
||||
} else if (MessageRecordUtil.isScheduled(messageRecord)) {
|
||||
dateView.setText(DateUtils.getOnlyTimeString(getContext(), locale, ((MediaMmsMessageRecord) messageRecord).getScheduledDate()));
|
||||
} else {
|
||||
dateView.setText(DateUtils.getSimpleRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
|
||||
}
|
||||
@@ -392,7 +396,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
previousMessageId = newMessageId;
|
||||
|
||||
|
||||
if (messageRecord.isFailed() || messageRecord.isPendingInsecureSmsFallback()) {
|
||||
if (messageRecord.isFailed() || messageRecord.isPendingInsecureSmsFallback() || MessageRecordUtil.isScheduled(messageRecord)) {
|
||||
deliveryStatusView.setNone();
|
||||
return;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user