mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-11 20:43:34 +01:00
Compare commits
263 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1df628079 | ||
|
|
e72cac7db5 | ||
|
|
cbfa573d3d | ||
|
|
1b404cef34 | ||
|
|
cb66996407 | ||
|
|
96f908b068 | ||
|
|
472c8a441f | ||
|
|
1f0c56546e | ||
|
|
97f8b5988d | ||
|
|
19dc90b68b | ||
|
|
67f0ba8624 | ||
|
|
a23c27b54b | ||
|
|
34dec1aec2 | ||
|
|
4f1aa34a46 | ||
|
|
a207bf965a | ||
|
|
33457acee2 | ||
|
|
80622147ab | ||
|
|
719f5e28d0 | ||
|
|
c2830163b8 | ||
|
|
bec9b3d88c | ||
|
|
8e25719b7b | ||
|
|
d80722dba7 | ||
|
|
aa0ab2134f | ||
|
|
7ca2420287 | ||
|
|
9f1deda220 | ||
|
|
265283fea5 | ||
|
|
fc847db389 | ||
|
|
975ec47adf | ||
|
|
ecc6a7b95e | ||
|
|
6f788ee3df | ||
|
|
5080567ca9 | ||
|
|
dec1902dc7 | ||
|
|
c2ca899a7c | ||
|
|
e8ad1e8ed1 | ||
|
|
db534cd376 | ||
|
|
9a1b8c9bb2 | ||
|
|
9389ee17b6 | ||
|
|
a1bcbe9c86 | ||
|
|
f2d994c772 | ||
|
|
6164152b15 | ||
|
|
874067909d | ||
|
|
4bdea886e3 | ||
|
|
fb1ba5a13e | ||
|
|
b3f4e0a7fe | ||
|
|
4db58a27a1 | ||
|
|
1692caeab7 | ||
|
|
2718dca6ea | ||
|
|
03fb266690 | ||
|
|
bf4d727a86 | ||
|
|
47c78e3d8a | ||
|
|
382edd7157 | ||
|
|
e01574c6b4 | ||
|
|
44800cf440 | ||
|
|
b71ee8f3bc | ||
|
|
267897b133 | ||
|
|
e2aec496c5 | ||
|
|
c9c18b91d7 | ||
|
|
9b837d3f02 | ||
|
|
5344850893 | ||
|
|
d2e09607fa | ||
|
|
590b4dec12 | ||
|
|
be211547f2 | ||
|
|
7cbf269b2a | ||
|
|
99d1671a50 | ||
|
|
6f5475fc94 | ||
|
|
a5954efc62 | ||
|
|
b59fee2f6e | ||
|
|
e4f4682357 | ||
|
|
889e17e4d5 | ||
|
|
e86c1515c8 | ||
|
|
aa6fa45949 | ||
|
|
ac3196bbb3 | ||
|
|
0b47c2ae93 | ||
|
|
84296a3860 | ||
|
|
90e6dd3d7d | ||
|
|
b56207d977 | ||
|
|
34f3ae38cc | ||
|
|
13a015fa13 | ||
|
|
233ba03f73 | ||
|
|
c547553770 | ||
|
|
0a5f852c09 | ||
|
|
ddf59fb45a | ||
|
|
5a6d77bae4 | ||
|
|
ae0d6b5926 | ||
|
|
9917b5d7b4 | ||
|
|
0558d5f0b3 | ||
|
|
597cf3f576 | ||
|
|
65af5f0849 | ||
|
|
cff5df4353 | ||
|
|
855bada9b8 | ||
|
|
9802724baa | ||
|
|
14db5ce349 | ||
|
|
bb1e6ffae0 | ||
|
|
210bb23aa4 | ||
|
|
de3a6a85c9 | ||
|
|
7ef41c0169 | ||
|
|
d08f1b65d0 | ||
|
|
5de05edaa1 | ||
|
|
b556967240 | ||
|
|
80a2e1e3cc | ||
|
|
b91a2e1450 | ||
|
|
45e406013a | ||
|
|
deb53e1751 | ||
|
|
601eb967de | ||
|
|
5c03608c8f | ||
|
|
0877d6a25e | ||
|
|
83ee4c0147 | ||
|
|
c09c6587b9 | ||
|
|
6617ecdf39 | ||
|
|
b36b34b1fd | ||
|
|
d8e0baa9ee | ||
|
|
3bb4cdf46b | ||
|
|
2181e34e6a | ||
|
|
d0ca769351 | ||
|
|
a090b07b1c | ||
|
|
178f5e80e3 | ||
|
|
d7bf4f178f | ||
|
|
dd9632da5b | ||
|
|
e235ec4129 | ||
|
|
988728be3e | ||
|
|
e2d86067cc | ||
|
|
b447f98f45 | ||
|
|
3e7f63af43 | ||
|
|
fdeed850b0 | ||
|
|
5c1d4d289f | ||
|
|
d19cba049d | ||
|
|
19ed3cb9ea | ||
|
|
cbb23b3d6c | ||
|
|
3c8c04d9e5 | ||
|
|
c3b792e4cf | ||
|
|
8f6998a8f6 | ||
|
|
49f66a31ff | ||
|
|
ec34604ffc | ||
|
|
8af7c5043a | ||
|
|
de1fbcf696 | ||
|
|
c4c43ee958 | ||
|
|
a2bf15d105 | ||
|
|
393ee545c0 | ||
|
|
959bbdae6c | ||
|
|
9f474fadf4 | ||
|
|
007e8a9dca | ||
|
|
b081452bed | ||
|
|
45668e4048 | ||
|
|
f0bf0784e4 | ||
|
|
a05776551f | ||
|
|
24a875c73a | ||
|
|
f0414922be | ||
|
|
bfae20941a | ||
|
|
be47e9e928 | ||
|
|
7d627ee8be | ||
|
|
95276b0192 | ||
|
|
92978b0e3f | ||
|
|
7d7db1b60a | ||
|
|
c5c915d446 | ||
|
|
bf28dfee66 | ||
|
|
f091502949 | ||
|
|
9b0dec7ece | ||
|
|
d690a52fd7 | ||
|
|
cf0d54d04f | ||
|
|
39169784b0 | ||
|
|
8348badcd6 | ||
|
|
9114dc83d7 | ||
|
|
87608c6d3a | ||
|
|
5acbe260e9 | ||
|
|
5e31eb5565 | ||
|
|
7a241e5fb5 | ||
|
|
7e299157ec | ||
|
|
1b1001b0e9 | ||
|
|
45a91e0896 | ||
|
|
91c7e0a0ee | ||
|
|
1a1213d043 | ||
|
|
b5f6513917 | ||
|
|
befb720eda | ||
|
|
9569b6ab4a | ||
|
|
94078f8b91 | ||
|
|
537a1fa2ea | ||
|
|
d6acd5ef36 | ||
|
|
08d9aa0947 | ||
|
|
355a498b9b | ||
|
|
e4d43ade93 | ||
|
|
d254d24d77 | ||
|
|
da34f9e989 | ||
|
|
5de9653149 | ||
|
|
4de8807297 | ||
|
|
ccc08e651c | ||
|
|
fd86dd3424 | ||
|
|
89271ecce2 | ||
|
|
af3a39d64e | ||
|
|
125ff83bac | ||
|
|
33f4bb0000 | ||
|
|
dd7a2834bc | ||
|
|
b0bf077797 | ||
|
|
ef9f1e9884 | ||
|
|
5423ed1d91 | ||
|
|
28c446aa2e | ||
|
|
d0042b1f7d | ||
|
|
62933ba887 | ||
|
|
92884fb3bf | ||
|
|
ee831b0221 | ||
|
|
e96ff92029 | ||
|
|
ade72b9911 | ||
|
|
053b19846b | ||
|
|
8e5500826c | ||
|
|
2f0a528c0f | ||
|
|
840e47a2de | ||
|
|
79a4ceedf9 | ||
|
|
3daa894988 | ||
|
|
1d14a90ac3 | ||
|
|
e273f914b6 | ||
|
|
96844f046f | ||
|
|
926f5b3cdf | ||
|
|
a0031298d8 | ||
|
|
523e21f3be | ||
|
|
15254ee720 | ||
|
|
8648c74221 | ||
|
|
c0ed6b1d41 | ||
|
|
1641d501c9 | ||
|
|
2dd887cd17 | ||
|
|
373fa1faec | ||
|
|
35c5a8106d | ||
|
|
642d37edb2 | ||
|
|
35d0f1fc8c | ||
|
|
78acc485fc | ||
|
|
6e71514209 | ||
|
|
22c396067d | ||
|
|
4f03c98f60 | ||
|
|
95cb80a93a | ||
|
|
14886ce28e | ||
|
|
a641020ec0 | ||
|
|
9f622bd689 | ||
|
|
6919e352d6 | ||
|
|
fd6a2c6b10 | ||
|
|
ab34a9b027 | ||
|
|
08db07e960 | ||
|
|
b2038e4ca0 | ||
|
|
c48ea68e7e | ||
|
|
c548816daa | ||
|
|
c5028720e3 | ||
|
|
35f9437413 | ||
|
|
b2b51e63be | ||
|
|
afd6af6f57 | ||
|
|
9ba5660f5b | ||
|
|
8aefd59eaa | ||
|
|
7203228626 | ||
|
|
112f4bb281 | ||
|
|
c7fb0e2ab8 | ||
|
|
f6cd7b1f3c | ||
|
|
d40254aa69 | ||
|
|
75a13aa22a | ||
|
|
5a884d8fc8 | ||
|
|
b5dcf8e8f1 | ||
|
|
bfdedd57d1 | ||
|
|
2b021f5237 | ||
|
|
791c1ee8dd | ||
|
|
c2f953b097 | ||
|
|
4984cc8eb4 | ||
|
|
23e4856c5e | ||
|
|
e50787ae20 | ||
|
|
64e4bcf46a | ||
|
|
693a82f133 | ||
|
|
79d73c9e74 | ||
|
|
5a51544cae | ||
|
|
bf2ab74ca4 |
0
.github/stale.yml
vendored
Normal file
0
.github/stale.yml
vendored
Normal file
7
.idea/codeStyles/Project.xml
generated
7
.idea/codeStyles/Project.xml
generated
@@ -51,6 +51,13 @@
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="HTML">
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="JAVA">
|
||||
<option name="BRACE_STYLE" value="5" />
|
||||
<option name="CLASS_BRACE_STYLE" value="5" />
|
||||
|
||||
8
.idea/file.template.settings.xml
generated
Normal file
8
.idea/file.template.settings.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExportableFileTemplateSettings">
|
||||
<default_templates>
|
||||
<template name="ViewModel.kt" file-name="${NAME}ViewModel" reformat="true" live-template-enabled="false" />
|
||||
</default_templates>
|
||||
</component>
|
||||
</project>
|
||||
20
.idea/fileTemplates/ViewModel.kt
generated
Normal file
20
.idea/fileTemplates/ViewModel.kt
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
|
||||
#end
|
||||
#parse("File Header.java")
|
||||
class ${NAME}ViewModel : ViewModel() {
|
||||
|
||||
private val store = Store(${NAME}State())
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: LiveData<${NAME}State> = store.stateLiveData
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
}
|
||||
161
app/build.gradle
161
app/build.gradle
@@ -62,8 +62,8 @@ ktlint {
|
||||
version = "0.43.2"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 987
|
||||
def canonicalVersionName = "5.29.3"
|
||||
def canonicalVersionCode = 1015
|
||||
def canonicalVersionName = "5.32.12"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -75,20 +75,18 @@ def abiPostFix = ['universal' : 0,
|
||||
def keystores = [ 'debug' : loadKeystoreProperties('keystore.debug.properties') ]
|
||||
|
||||
def selectableVariants = [
|
||||
'nightlyProdFlipper',
|
||||
'nightlyProdSpinner',
|
||||
'nightlyProdPerf',
|
||||
'nightlyProdRelease',
|
||||
'playProdDebug',
|
||||
'playProdFlipper',
|
||||
'playProdSpinner',
|
||||
'playProdPerf',
|
||||
'playProdRelease',
|
||||
'playStagingDebug',
|
||||
'playStagingFlipper',
|
||||
'playStagingSpinner',
|
||||
'playStagingPerf',
|
||||
'playStagingRelease',
|
||||
'studyProdMock',
|
||||
'studyProdPerf',
|
||||
'websiteProdFlipper',
|
||||
'websiteProdSpinner',
|
||||
'websiteProdRelease',
|
||||
]
|
||||
|
||||
@@ -119,6 +117,48 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
execution 'ANDROIDX_TEST_ORCHESTRATOR'
|
||||
|
||||
unitTests {
|
||||
includeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
checkReleaseBuilds false
|
||||
abortOnError true
|
||||
baseline file("lint-baseline.xml")
|
||||
disable "LintError"
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
test {
|
||||
java.srcDirs += "$projectDir/src/testShared"
|
||||
}
|
||||
|
||||
androidTest {
|
||||
java.srcDirs += "$projectDir/src/testShared"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
sourceCompatibility JAVA_VERSION
|
||||
targetCompatibility JAVA_VERSION
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
|
||||
defaultConfig {
|
||||
versionCode canonicalVersionCode * postFixSize
|
||||
versionName canonicalVersionName
|
||||
@@ -150,10 +190,14 @@ android {
|
||||
buildConfigField "String", "CDSH_PUBLIC_KEY", "\"2fe57da347cd62431528daac5fbb290730fff684afc4cfc2ed90995f58cb3b74\""
|
||||
buildConfigField "String", "CDSH_CODE_HASH", "\"ec58c0d7561de8d5657f3a4b22a635eaa305204e9359dcc80a99dfd0c5f1cbf2\""
|
||||
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\"," +
|
||||
"\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\", " +
|
||||
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")";
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[0]"
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"0cedba03535b41b67729ce9924185f831d7767928a1d1689acb689bc079c375f\", " +
|
||||
"\"187d2739d22be65e74b65f0055e74d31310e4267e5fac2b1246cc8beba81af39\", " +
|
||||
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\")"
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[] {" +
|
||||
"new org.thoughtcrime.securesms.KbsEnclave(\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\", " +
|
||||
"\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\", " +
|
||||
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")" +
|
||||
"}"
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXQ==\""
|
||||
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
|
||||
@@ -188,45 +232,13 @@ android {
|
||||
testInstrumentationRunnerArguments clearPackageData: 'true'
|
||||
}
|
||||
|
||||
testOptions {
|
||||
execution 'ANDROIDX_TEST_ORCHESTRATOR'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
test {
|
||||
java.srcDirs += "$projectDir/src/testShared"
|
||||
}
|
||||
|
||||
androidTest {
|
||||
java.srcDirs += "$projectDir/src/testShared"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
sourceCompatibility JAVA_VERSION
|
||||
targetCompatibility JAVA_VERSION
|
||||
}
|
||||
|
||||
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 '/org/spongycastle/x509/CertPathReviewerMessages.properties'
|
||||
exclude '/org/spongycastle/x509/CertPathReviewerMessages_de.properties'
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
if (keystores['debug'] != null) {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
isDefault true
|
||||
minifyEnabled true
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'),
|
||||
'proguard/proguard-firebase-messaging.pro',
|
||||
'proguard/proguard-google-play-services.pro',
|
||||
@@ -235,7 +247,6 @@ android {
|
||||
'proguard/proguard-appcompat-v7.pro',
|
||||
'proguard/proguard-square-okhttp.pro',
|
||||
'proguard/proguard-square-okio.pro',
|
||||
'proguard/proguard-spongycastle.pro',
|
||||
'proguard/proguard-rounded-image-view.pro',
|
||||
'proguard/proguard-glide.pro',
|
||||
'proguard/proguard-shortcutbadger.pro',
|
||||
@@ -251,12 +262,12 @@ android {
|
||||
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Debug\""
|
||||
}
|
||||
flipper {
|
||||
spinner {
|
||||
initWith debug
|
||||
isDefault false
|
||||
minifyEnabled false
|
||||
matchingFallbacks = ['debug']
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Flipper\""
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Spinner\""
|
||||
}
|
||||
release {
|
||||
minifyEnabled true
|
||||
@@ -267,16 +278,10 @@ android {
|
||||
initWith debug
|
||||
isDefault false
|
||||
debuggable false
|
||||
minifyEnabled true
|
||||
matchingFallbacks = ['debug']
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Perf\""
|
||||
}
|
||||
mock {
|
||||
initWith debug
|
||||
isDefault false
|
||||
minifyEnabled false
|
||||
matchingFallbacks = ['debug']
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Mock\""
|
||||
}
|
||||
}
|
||||
|
||||
productFlavors {
|
||||
@@ -306,16 +311,6 @@ android {
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\""
|
||||
}
|
||||
|
||||
study {
|
||||
dimension 'distribution'
|
||||
|
||||
applicationIdSuffix ".study"
|
||||
ext.websiteUpdateUrl = "null"
|
||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"study\""
|
||||
}
|
||||
|
||||
prod {
|
||||
dimension 'environment'
|
||||
|
||||
@@ -337,10 +332,14 @@ android {
|
||||
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api-staging.directory.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
|
||||
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\", " +
|
||||
"\"16b94ac6d2b7f7b9d72928f36d798dbb35ed32e7bb14c42b4301ad0344b46f29\", " +
|
||||
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")"
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[0]"
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"dd6f66d397d9e8cf6ec6db238e59a7be078dd50e9715427b9c89b409ffe53f99\", " +
|
||||
"\"4200003414528c151e2dccafbc87aa6d3d66a5eb8f8c05979a6e97cb33cd493a\", " +
|
||||
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\")"
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[] {" +
|
||||
"new org.thoughtcrime.securesms.KbsEnclave(\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\", " +
|
||||
"\"16b94ac6d2b7f7b9d72928f36d798dbb35ed32e7bb14c42b4301ad0344b46f29\", " +
|
||||
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")" +
|
||||
"}"
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXQ==\""
|
||||
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
|
||||
@@ -357,6 +356,9 @@ android {
|
||||
output.versionCodeOverride = canonicalVersionCode * postFixSize + 5
|
||||
def tag = getCurrentGitTag()
|
||||
if (tag != null && tag.length() > 0) {
|
||||
if (tag.startsWith("v")) {
|
||||
tag = tag.substring(1)
|
||||
}
|
||||
output.versionNameOverride = tag
|
||||
}
|
||||
} else {
|
||||
@@ -381,19 +383,6 @@ android {
|
||||
variant.setIgnore(true)
|
||||
}
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
checkReleaseBuilds false
|
||||
abortOnError true
|
||||
baseline file("lint-baseline.xml")
|
||||
disable "LintError"
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
includeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -519,9 +508,8 @@ dependencies {
|
||||
}
|
||||
implementation libs.dnsjava
|
||||
|
||||
flipperImplementation libs.facebook.flipper
|
||||
flipperImplementation libs.facebook.soloader
|
||||
flipperImplementation libs.square.leakcanary
|
||||
spinnerImplementation project(":spinner")
|
||||
spinnerImplementation libs.square.leakcanary
|
||||
|
||||
testImplementation testLibs.junit.junit
|
||||
testImplementation testLibs.assertj.core
|
||||
@@ -536,6 +524,9 @@ dependencies {
|
||||
exclude group: 'com.google.protobuf', module: 'protobuf-java'
|
||||
}
|
||||
testImplementation testLibs.robolectric.shadows.multidex
|
||||
testImplementation (testLibs.bouncycastle.bcprov.jdk15on) {
|
||||
force = true
|
||||
}
|
||||
testImplementation testLibs.hamcrest.hamcrest
|
||||
|
||||
testImplementation(testFixtures(project(":libsignal-service")))
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* When writing tests, be very careful to call [DatabaseObserver.flush] before asserting any observer state. Internally, the observer is enqueueing tasks on
|
||||
* an executor, and failing to flush the executor will lead to incorrect/flaky tests.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DatabaseObserverTest {
|
||||
|
||||
private lateinit var db: SQLiteDatabase
|
||||
private lateinit var observer: DatabaseObserver
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
db = SignalDatabase.instance!!.signalWritableDatabase
|
||||
observer = ApplicationDependencies.getDatabaseObserver()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notifyConversationListeners_runsImmediatelyIfNotInTransaction() {
|
||||
val hasRun = AtomicBoolean(false)
|
||||
observer.registerConversationObserver(1) { hasRun.set(true) }
|
||||
observer.notifyConversationListeners(1)
|
||||
observer.flush()
|
||||
assertTrue(hasRun.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notifyConversationListeners_runsAfterSuccessIfInTransaction() {
|
||||
val hasRun = AtomicBoolean(false)
|
||||
|
||||
db.beginTransaction()
|
||||
|
||||
observer.registerConversationObserver(1) { hasRun.set(true) }
|
||||
observer.notifyConversationListeners(1)
|
||||
observer.flush()
|
||||
assertFalse(hasRun.get())
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
|
||||
observer.flush()
|
||||
|
||||
assertTrue(hasRun.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notifyConversationListeners_doesNotRunAfterFailedTransaction() {
|
||||
val hasRun = AtomicBoolean(false)
|
||||
|
||||
db.beginTransaction()
|
||||
|
||||
observer.registerConversationObserver(1) { hasRun.set(true) }
|
||||
observer.notifyConversationListeners(1)
|
||||
observer.flush()
|
||||
assertFalse(hasRun.get())
|
||||
|
||||
db.endTransaction()
|
||||
observer.flush()
|
||||
assertFalse(hasRun.get())
|
||||
|
||||
// Verifying we still don't run it even after a subsequent success
|
||||
db.beginTransaction()
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
|
||||
observer.flush()
|
||||
|
||||
assertFalse(hasRun.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notifyConversationListeners_onlyRunAfterAllTransactionsComplete() {
|
||||
val hasRun = AtomicBoolean(false)
|
||||
|
||||
db.beginTransaction()
|
||||
|
||||
observer.registerConversationObserver(1) { hasRun.set(true) }
|
||||
observer.notifyConversationListeners(1)
|
||||
observer.flush()
|
||||
assertFalse(hasRun.get())
|
||||
|
||||
db.beginTransaction()
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
observer.flush()
|
||||
assertFalse(hasRun.get())
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
|
||||
observer.flush()
|
||||
|
||||
assertTrue(hasRun.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notifyConversationListeners_runsImmediatelyIfTheTransactionIsOnAnotherThread() {
|
||||
db.beginTransaction()
|
||||
|
||||
val latch = CountDownLatch(1)
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val hasRun = AtomicBoolean(false)
|
||||
|
||||
observer.registerConversationObserver(1) { hasRun.set(true) }
|
||||
observer.notifyConversationListeners(1)
|
||||
observer.flush()
|
||||
assertTrue(hasRun.get())
|
||||
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
latch.await()
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notifyConversationListeners_runsAfterSuccessIfInTransaction_ignoreDuplicateNotifications() {
|
||||
val thread1Count = AtomicInteger(0)
|
||||
val thread2Count = AtomicInteger(0)
|
||||
|
||||
db.beginTransaction()
|
||||
|
||||
observer.registerConversationObserver(1) { thread1Count.incrementAndGet() }
|
||||
observer.registerConversationObserver(2) { thread2Count.incrementAndGet() }
|
||||
|
||||
observer.notifyConversationListeners(1)
|
||||
observer.notifyConversationListeners(2)
|
||||
observer.notifyConversationListeners(2)
|
||||
|
||||
observer.flush()
|
||||
assertEquals(0, thread1Count.get())
|
||||
assertEquals(0, thread2Count.get())
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
|
||||
observer.flush()
|
||||
assertEquals(1, thread1Count.get())
|
||||
assertEquals(1, thread2Count.get())
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.RecipientChangedNumberJob
|
||||
import org.thoughtcrime.securesms.keyvalue.AccountValues
|
||||
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet
|
||||
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
|
||||
@@ -17,6 +20,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import java.lang.IllegalArgumentException
|
||||
import java.util.UUID
|
||||
|
||||
@@ -25,10 +29,16 @@ class RecipientDatabaseTest {
|
||||
|
||||
private lateinit var recipientDatabase: RecipientDatabase
|
||||
|
||||
private val localAci = ACI.from(UUID.randomUUID())
|
||||
private val localPni = PNI.from(UUID.randomUUID())
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
recipientDatabase = SignalDatabase.recipients
|
||||
ensureDbEmpty()
|
||||
|
||||
SignalStore.account().setAci(localAci)
|
||||
SignalStore.account().setPni(localPni)
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
@@ -41,7 +51,7 @@ class RecipientDatabaseTest {
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null, true)
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertEquals(ACI_A, recipient.requireAci())
|
||||
assertEquals(ACI_A, recipient.requireServiceId())
|
||||
assertFalse(recipient.hasE164())
|
||||
}
|
||||
|
||||
@@ -51,7 +61,7 @@ class RecipientDatabaseTest {
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null, false)
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertEquals(ACI_A, recipient.requireAci())
|
||||
assertEquals(ACI_A, recipient.requireServiceId())
|
||||
assertFalse(recipient.hasE164())
|
||||
}
|
||||
|
||||
@@ -62,7 +72,7 @@ class RecipientDatabaseTest {
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertEquals(E164_A, recipient.requireE164())
|
||||
assertFalse(recipient.hasAci())
|
||||
assertFalse(recipient.hasServiceId())
|
||||
}
|
||||
|
||||
/** If all you have is an E164, you can just store that, regardless of trust level. */
|
||||
@@ -72,7 +82,7 @@ class RecipientDatabaseTest {
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertEquals(E164_A, recipient.requireE164())
|
||||
assertFalse(recipient.hasAci())
|
||||
assertFalse(recipient.hasServiceId())
|
||||
}
|
||||
|
||||
/** With high trust, you can associate an ACI-e164 pair. */
|
||||
@@ -81,7 +91,7 @@ class RecipientDatabaseTest {
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertEquals(ACI_A, recipient.requireAci())
|
||||
assertEquals(ACI_A, recipient.requireServiceId())
|
||||
assertEquals(E164_A, recipient.requireE164())
|
||||
}
|
||||
|
||||
@@ -91,7 +101,7 @@ class RecipientDatabaseTest {
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertEquals(ACI_A, recipient.requireAci())
|
||||
assertEquals(ACI_A, recipient.requireServiceId())
|
||||
assertFalse(recipient.hasE164())
|
||||
}
|
||||
|
||||
@@ -102,26 +112,26 @@ class RecipientDatabaseTest {
|
||||
/** With high trust, you can associate an e164 with an existing ACI. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164_highTrust() {
|
||||
val existingId: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_A)
|
||||
val existingId: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
}
|
||||
|
||||
/** With low trust, you cannot associate an ACI-e164 pair, and therefore cannot store the e164. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164_lowTrust() {
|
||||
val existingId: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_A)
|
||||
val existingId: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertFalse(retrievedRecipient.hasE164())
|
||||
}
|
||||
|
||||
@@ -134,7 +144,7 @@ class RecipientDatabaseTest {
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_B, retrievedRecipient.requireE164())
|
||||
}
|
||||
|
||||
@@ -147,7 +157,7 @@ class RecipientDatabaseTest {
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
}
|
||||
|
||||
@@ -164,7 +174,7 @@ class RecipientDatabaseTest {
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
}
|
||||
|
||||
@@ -177,28 +187,30 @@ class RecipientDatabaseTest {
|
||||
assertNotEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertFalse(retrievedRecipient.hasE164())
|
||||
|
||||
val existingRecipient = Recipient.resolved(existingId)
|
||||
assertEquals(E164_A, existingRecipient.requireE164())
|
||||
assertFalse(existingRecipient.hasAci())
|
||||
assertFalse(existingRecipient.hasServiceId())
|
||||
}
|
||||
|
||||
/** We never change the ACI of an existing row. New ACI = new person, regardless of trust. But high trust lets us take the e164 from the current holder. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_aciAndE164_2_highTrust() {
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
recipientDatabase.setPni(existingId, PNI_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, true)
|
||||
recipientDatabase.setPni(retrievedId, PNI_A)
|
||||
assertNotEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_B, retrievedRecipient.requireAci())
|
||||
assertEquals(ACI_B, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
|
||||
val existingRecipient = Recipient.resolved(existingId)
|
||||
assertEquals(ACI_A, existingRecipient.requireAci())
|
||||
assertEquals(ACI_A, existingRecipient.requireServiceId())
|
||||
assertFalse(existingRecipient.hasE164())
|
||||
}
|
||||
|
||||
@@ -211,11 +223,11 @@ class RecipientDatabaseTest {
|
||||
assertNotEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_B, retrievedRecipient.requireAci())
|
||||
assertEquals(ACI_B, retrievedRecipient.requireServiceId())
|
||||
assertFalse(retrievedRecipient.hasE164())
|
||||
|
||||
val existingRecipient = Recipient.resolved(existingId)
|
||||
assertEquals(ACI_A, existingRecipient.requireAci())
|
||||
assertEquals(ACI_A, existingRecipient.requireServiceId())
|
||||
assertEquals(E164_A, existingRecipient.requireE164())
|
||||
}
|
||||
|
||||
@@ -234,11 +246,11 @@ class RecipientDatabaseTest {
|
||||
assertNotEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_B, retrievedRecipient.requireAci())
|
||||
assertEquals(ACI_B, retrievedRecipient.requireServiceId())
|
||||
assertFalse(retrievedRecipient.hasE164())
|
||||
|
||||
val existingRecipient = Recipient.resolved(existingId)
|
||||
assertEquals(ACI_A, existingRecipient.requireAci())
|
||||
assertEquals(ACI_A, existingRecipient.requireServiceId())
|
||||
assertEquals(E164_A, existingRecipient.requireE164())
|
||||
}
|
||||
|
||||
@@ -255,48 +267,80 @@ class RecipientDatabaseTest {
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
}
|
||||
|
||||
/** High trust lets you merge two different users into one. You should prefer the ACI user. Not shown: merging threads, dropping e164 sessions, etc. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge_highTrust() {
|
||||
val existingAciId: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_A)
|
||||
val existingE164Id: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
||||
val changeNumberListener = ChangeNumberListener()
|
||||
changeNumberListener.enqueue()
|
||||
|
||||
val existingAciId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null, true)
|
||||
val existingE164Id: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
assertEquals(existingAciId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
|
||||
val existingE164Recipient = Recipient.resolved(existingE164Id)
|
||||
assertEquals(retrievedId, existingE164Recipient.id)
|
||||
|
||||
changeNumberListener.waitForJobManager()
|
||||
assertFalse(changeNumberListener.numberChangeWasEnqueued)
|
||||
}
|
||||
|
||||
/** Same as [getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge_highTrust], but with a number change. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge_highTrust_changedNumber() {
|
||||
val changeNumberListener = ChangeNumberListener()
|
||||
changeNumberListener.enqueue()
|
||||
|
||||
val existingAciId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, true)
|
||||
val existingE164Id: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
assertEquals(existingAciId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
|
||||
val existingE164Recipient = Recipient.resolved(existingE164Id)
|
||||
assertEquals(retrievedId, existingE164Recipient.id)
|
||||
|
||||
changeNumberListener.waitForJobManager()
|
||||
assert(changeNumberListener.numberChangeWasEnqueued)
|
||||
}
|
||||
|
||||
/** Low trust means you can’t merge. If you’re retrieving a user from the table with this data, prefer the ACI one. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_lowTrust() {
|
||||
val existingAciId: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_A)
|
||||
val existingAciId: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
|
||||
val existingE164Id: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
|
||||
assertEquals(existingAciId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertFalse(retrievedRecipient.hasE164())
|
||||
|
||||
val existingE164Recipient = Recipient.resolved(existingE164Id)
|
||||
assertEquals(E164_A, existingE164Recipient.requireE164())
|
||||
assertFalse(existingE164Recipient.hasAci())
|
||||
assertFalse(existingE164Recipient.hasServiceId())
|
||||
}
|
||||
|
||||
/** Another high trust case. No new rules here, just a more complex scenario to show how different rules interact. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_complex_highTrust() {
|
||||
val changeNumberListener = ChangeNumberListener()
|
||||
changeNumberListener.enqueue()
|
||||
|
||||
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, true)
|
||||
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, true)
|
||||
|
||||
@@ -304,12 +348,14 @@ class RecipientDatabaseTest {
|
||||
assertEquals(existingId1, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
|
||||
val existingRecipient2 = Recipient.resolved(existingId2)
|
||||
assertEquals(ACI_B, existingRecipient2.requireAci())
|
||||
assertEquals(ACI_B, existingRecipient2.requireServiceId())
|
||||
assertFalse(existingRecipient2.hasE164())
|
||||
|
||||
assert(changeNumberListener.numberChangeWasEnqueued)
|
||||
}
|
||||
|
||||
/** Another low trust case. No new rules here, just a more complex scenario to show how different rules interact. */
|
||||
@@ -322,11 +368,11 @@ class RecipientDatabaseTest {
|
||||
assertEquals(existingId1, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_B, retrievedRecipient.requireE164())
|
||||
|
||||
val existingRecipient2 = Recipient.resolved(existingId2)
|
||||
assertEquals(ACI_B, existingRecipient2.requireAci())
|
||||
assertEquals(ACI_B, existingRecipient2.requireServiceId())
|
||||
assertEquals(E164_A, existingRecipient2.requireE164())
|
||||
}
|
||||
|
||||
@@ -343,7 +389,7 @@ class RecipientDatabaseTest {
|
||||
assertEquals(existingId1, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
|
||||
assertFalse(recipientDatabase.getByE164(E164_B).isPresent)
|
||||
@@ -368,14 +414,71 @@ class RecipientDatabaseTest {
|
||||
assertEquals(existingId2, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertFalse(retrievedRecipient.hasE164())
|
||||
|
||||
val recipientWithId1 = Recipient.resolved(existingId1)
|
||||
assertEquals(ACI_B, recipientWithId1.requireAci())
|
||||
assertEquals(ACI_B, recipientWithId1.requireServiceId())
|
||||
assertEquals(E164_A, recipientWithId1.requireE164())
|
||||
}
|
||||
|
||||
/** This is a case where normally we'd update the E164 of a user, but here the changeSelf flag is disabled, so we shouldn't. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciBelongsToLocalUser_highTrust_changeSelfFalse() {
|
||||
val dataSet = KeyValueDataSet().apply {
|
||||
putString(AccountValues.KEY_E164, E164_A)
|
||||
putString(AccountValues.KEY_ACI, ACI_A.toString())
|
||||
}
|
||||
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
|
||||
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, highTrust = true, changeSelf = false)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
}
|
||||
|
||||
/** This is a case where we're changing our own number, and it's allowed because changeSelf = true. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciBelongsToLocalUser_highTrust_changeSelfTrue() {
|
||||
val dataSet = KeyValueDataSet().apply {
|
||||
putString(AccountValues.KEY_E164, E164_A)
|
||||
putString(AccountValues.KEY_ACI, ACI_A.toString())
|
||||
}
|
||||
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
|
||||
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, highTrust = true, changeSelf = true)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_B, retrievedRecipient.requireE164())
|
||||
}
|
||||
|
||||
/** Verifying a case where a change number job is expected to be enqueued. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_highTrust_changedNumber() {
|
||||
val changeNumberListener = ChangeNumberListener()
|
||||
changeNumberListener.enqueue()
|
||||
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, true)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_B, retrievedRecipient.requireE164())
|
||||
|
||||
changeNumberListener.waitForJobManager()
|
||||
assert(changeNumberListener.numberChangeWasEnqueued)
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
// Misc
|
||||
// ==============================================================
|
||||
@@ -400,18 +503,18 @@ class RecipientDatabaseTest {
|
||||
@Test
|
||||
fun createByUuidSanityCheck() {
|
||||
// GIVEN one recipient
|
||||
val recipientId: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_A)
|
||||
val recipientId: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
|
||||
|
||||
// WHEN I retrieve one by UUID
|
||||
val possible: Optional<RecipientId> = recipientDatabase.getByAci(ACI_A)
|
||||
val possible: Optional<RecipientId> = recipientDatabase.getByServiceId(ACI_A)
|
||||
|
||||
// THEN I get it back, and it has the properties I expect
|
||||
assertTrue(possible.isPresent)
|
||||
assertEquals(recipientId, possible.get())
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertTrue(recipient.aci.isPresent)
|
||||
assertEquals(ACI_A, recipient.aci.get())
|
||||
assertTrue(recipient.serviceId.isPresent)
|
||||
assertEquals(ACI_A, recipient.serviceId.get())
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
@@ -426,10 +529,31 @@ class RecipientDatabaseTest {
|
||||
}
|
||||
}
|
||||
|
||||
private class ChangeNumberListener {
|
||||
|
||||
var numberChangeWasEnqueued = false
|
||||
private set
|
||||
|
||||
fun waitForJobManager() {
|
||||
ApplicationDependencies.getJobManager().flush()
|
||||
ThreadUtil.sleep(500)
|
||||
}
|
||||
|
||||
fun enqueue() {
|
||||
ApplicationDependencies.getJobManager().addListener(
|
||||
{ job -> job.factoryKey == RecipientChangedNumberJob.KEY },
|
||||
{ _, _ -> numberChangeWasEnqueued = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ 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.groups.GroupId
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
@@ -33,6 +34,7 @@ import org.whispersystems.libsignal.SignalProtocolAddress
|
||||
import org.whispersystems.libsignal.state.SessionRecord
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import java.util.UUID
|
||||
|
||||
@@ -51,6 +53,9 @@ class RecipientDatabaseTest_merges {
|
||||
private lateinit var reactionDatabase: ReactionDatabase
|
||||
private lateinit var notificationProfileDatabase: NotificationProfileDatabase
|
||||
|
||||
private val localAci = ACI.from(UUID.randomUUID())
|
||||
private val localPni = PNI.from(UUID.randomUUID())
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
recipientDatabase = SignalDatabase.recipients
|
||||
@@ -65,6 +70,9 @@ class RecipientDatabaseTest_merges {
|
||||
reactionDatabase = SignalDatabase.reactions
|
||||
notificationProfileDatabase = SignalDatabase.notificationProfiles
|
||||
|
||||
SignalStore.account().setAci(localAci)
|
||||
SignalStore.account().setPni(localPni)
|
||||
|
||||
ensureDbEmpty()
|
||||
}
|
||||
|
||||
@@ -72,9 +80,9 @@ class RecipientDatabaseTest_merges {
|
||||
@Test
|
||||
fun getAndPossiblyMerge_general() {
|
||||
// Setup
|
||||
val recipientIdAci: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_A)
|
||||
val recipientIdAci: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
|
||||
val recipientIdE164: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
||||
val recipientIdAciB: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_B)
|
||||
val recipientIdAciB: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_B)
|
||||
|
||||
val smsId1: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 0, body = "0")).get().messageId
|
||||
val smsId2: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdE164, time = 1, body = "1")).get().messageId
|
||||
@@ -99,7 +107,7 @@ class RecipientDatabaseTest_merges {
|
||||
identityDatabase.saveIdentity(ACI_A.toString(), recipientIdAci, identityKeyAci, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
|
||||
identityDatabase.saveIdentity(E164_A, recipientIdE164, identityKeyE164, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
|
||||
|
||||
sessionDatabase.store(SignalProtocolAddress(ACI_A.toString(), 1), SessionRecord())
|
||||
sessionDatabase.store(localAci, SignalProtocolAddress(ACI_A.toString(), 1), SessionRecord())
|
||||
|
||||
reactionDatabase.addReaction(MessageId(smsId1, false), ReactionRecord("a", recipientIdAci, 1, 1))
|
||||
reactionDatabase.addReaction(MessageId(mmsId1, true), ReactionRecord("b", recipientIdE164, 1, 1))
|
||||
@@ -119,7 +127,7 @@ class RecipientDatabaseTest_merges {
|
||||
|
||||
// Recipient validation
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
|
||||
val existingE164Recipient = Recipient.resolved(recipientIdE164)
|
||||
@@ -175,7 +183,7 @@ class RecipientDatabaseTest_merges {
|
||||
assertNull(identityDatabase.getIdentityStoreRecord(E164_A))
|
||||
|
||||
// Session validation
|
||||
assertNotNull(sessionDatabase.load(SignalProtocolAddress(ACI_A.toString(), 1)))
|
||||
assertNotNull(sessionDatabase.load(localAci, SignalProtocolAddress(ACI_A.toString(), 1)))
|
||||
|
||||
// Reaction validation
|
||||
val reactionsSms: List<ReactionRecord> = reactionDatabase.getReactions(MessageId(smsId1, false))
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import junit.framework.Assert.assertFalse
|
||||
import junit.framework.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* These are tests for the wrapper we wrote around SQLCipherDatabase, not the stock or SQLCipher one.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SQLiteDatabaseTest {
|
||||
|
||||
private lateinit var db: SQLiteDatabase
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
db = SignalDatabase.instance!!.signalWritableDatabase
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runPostSuccessfulTransaction_runsImmediatelyIfNotInTransaction() {
|
||||
val hasRun = AtomicBoolean(false)
|
||||
db.runPostSuccessfulTransaction { hasRun.set(true) }
|
||||
assertTrue(hasRun.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runPostSuccessfulTransaction_runsAfterSuccessIfInTransaction() {
|
||||
val hasRun = AtomicBoolean(false)
|
||||
|
||||
db.beginTransaction()
|
||||
|
||||
db.runPostSuccessfulTransaction { hasRun.set(true) }
|
||||
assertFalse(hasRun.get())
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
|
||||
assertTrue(hasRun.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runPostSuccessfulTransaction_doesNotRunAfterFailedTransaction() {
|
||||
val hasRun = AtomicBoolean(false)
|
||||
|
||||
db.beginTransaction()
|
||||
|
||||
db.runPostSuccessfulTransaction { hasRun.set(true) }
|
||||
assertFalse(hasRun.get())
|
||||
|
||||
db.endTransaction()
|
||||
|
||||
assertFalse(hasRun.get())
|
||||
|
||||
// Verifying we still don't run it even after a subsequent success
|
||||
db.beginTransaction()
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
|
||||
assertFalse(hasRun.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runPostSuccessfulTransaction_onlyRunAfterAllTransactionsComplete() {
|
||||
val hasRun = AtomicBoolean(false)
|
||||
|
||||
db.beginTransaction()
|
||||
|
||||
db.runPostSuccessfulTransaction { hasRun.set(true) }
|
||||
assertFalse(hasRun.get())
|
||||
|
||||
db.beginTransaction()
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
assertFalse(hasRun.get())
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
|
||||
assertTrue(hasRun.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runPostSuccessfulTransaction_runsImmediatelyIfTheTransactionIsOnAnotherThread() {
|
||||
db.beginTransaction()
|
||||
|
||||
val latch = CountDownLatch(1)
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val hasRun = AtomicBoolean(false)
|
||||
db.runPostSuccessfulTransaction { hasRun.set(true) }
|
||||
assertTrue(hasRun.get())
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
latch.await()
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runPostSuccessfulTransaction_runsAfterSuccessIfInTransaction_ignoreDuplicates() {
|
||||
val hasRun1 = AtomicBoolean(false)
|
||||
val hasRun2 = AtomicBoolean(false)
|
||||
|
||||
db.beginTransaction()
|
||||
|
||||
db.runPostSuccessfulTransaction("key") { hasRun1.set(true) }
|
||||
db.runPostSuccessfulTransaction("key") { hasRun2.set(true) }
|
||||
assertFalse(hasRun1.get())
|
||||
assertFalse(hasRun2.get())
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
|
||||
assertTrue(hasRun1.get())
|
||||
assertFalse(hasRun2.get())
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.facebook.flipper.plugins.databases.DatabaseDescriptor;
|
||||
import com.facebook.flipper.plugins.databases.DatabaseDriver;
|
||||
|
||||
import net.zetetic.database.DatabaseUtils;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
import net.zetetic.database.sqlcipher.SQLiteStatement;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.Hex;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A lot of this code is taken from {@link com.facebook.flipper.plugins.databases.impl.SqliteDatabaseDriver}
|
||||
* and made to work with SqlCipher. Unfortunately I couldn't use it directly, nor subclass it.
|
||||
*/
|
||||
public class FlipperSqlCipherAdapter extends DatabaseDriver<FlipperSqlCipherAdapter.Descriptor> {
|
||||
|
||||
private static final String TAG = Log.tag(FlipperSqlCipherAdapter.class);
|
||||
|
||||
public FlipperSqlCipherAdapter(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Descriptor> getDatabases() {
|
||||
try {
|
||||
SignalDatabaseOpenHelper mainOpenHelper = Objects.requireNonNull(SignalDatabase.getInstance());
|
||||
SignalDatabaseOpenHelper keyValueOpenHelper = KeyValueDatabase.getInstance((Application) getContext());
|
||||
SignalDatabaseOpenHelper megaphoneOpenHelper = MegaphoneDatabase.getInstance((Application) getContext());
|
||||
SignalDatabaseOpenHelper jobManagerOpenHelper = JobDatabase.getInstance((Application) getContext());
|
||||
SignalDatabaseOpenHelper metricsOpenHelper = LocalMetricsDatabase.getInstance((Application) getContext());
|
||||
|
||||
return Arrays.asList(new Descriptor(mainOpenHelper),
|
||||
new Descriptor(keyValueOpenHelper),
|
||||
new Descriptor(megaphoneOpenHelper),
|
||||
new Descriptor(jobManagerOpenHelper),
|
||||
new Descriptor(metricsOpenHelper));
|
||||
} catch (Exception e) {
|
||||
Log.i(TAG, "Unable to use reflection to access raw database.", e);
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getTableNames(Descriptor descriptor) {
|
||||
SQLiteDatabase db = descriptor.getReadable();
|
||||
List<String> tableNames = new ArrayList<>();
|
||||
|
||||
try (Cursor cursor = db.rawQuery("SELECT name FROM sqlite_master WHERE type IN (?, ?)", new String[] { "table", "view" })) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
tableNames.add(cursor.getString(0));
|
||||
}
|
||||
}
|
||||
|
||||
return tableNames;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DatabaseGetTableDataResponse getTableData(Descriptor descriptor, String table, String order, boolean reverse, int start, int count) {
|
||||
SQLiteDatabase db = descriptor.getReadable();
|
||||
|
||||
long total = DatabaseUtils.queryNumEntries(db, table);
|
||||
String orderBy = order != null ? order + (reverse ? " DESC" : " ASC") : null;
|
||||
String limitBy = start + ", " + count;
|
||||
|
||||
try (Cursor cursor = db.query(table, null, null, null, null, null, orderBy, limitBy)) {
|
||||
String[] columnNames = cursor.getColumnNames();
|
||||
List<List<Object>> rows = cursorToList(cursor);
|
||||
|
||||
return new DatabaseGetTableDataResponse(Arrays.asList(columnNames), rows, start, rows.size(), total);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public DatabaseGetTableStructureResponse getTableStructure(Descriptor descriptor, String table) {
|
||||
SQLiteDatabase db = descriptor.getReadable();
|
||||
|
||||
Map<String, String> foreignKeyValues = new HashMap<>();
|
||||
|
||||
try(Cursor cursor = db.rawQuery("PRAGMA foreign_key_list(" + table + ")", null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
String from = cursor.getString(cursor.getColumnIndex("from"));
|
||||
String to = cursor.getString(cursor.getColumnIndex("to"));
|
||||
String tableName = cursor.getString(cursor.getColumnIndex("table")) + "(" + to + ")";
|
||||
|
||||
foreignKeyValues.put(from, tableName);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
List<String> structureColumns = Arrays.asList("column_name", "data_type", "nullable", "default", "primary_key", "foreign_key");
|
||||
List<List<Object>> structureValues = new ArrayList<>();
|
||||
|
||||
try (Cursor cursor = db.rawQuery("PRAGMA table_info(" + table + ")", null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
String columnName = cursor.getString(cursor.getColumnIndex("name"));
|
||||
String foreignKey = foreignKeyValues.containsKey(columnName) ? foreignKeyValues.get(columnName) : null;
|
||||
|
||||
structureValues.add(Arrays.asList(columnName,
|
||||
cursor.getString(cursor.getColumnIndex("type")),
|
||||
cursor.getInt(cursor.getColumnIndex("notnull")) == 0,
|
||||
getObjectFromColumnIndex(cursor, cursor.getColumnIndex("dflt_value")),
|
||||
cursor.getInt(cursor.getColumnIndex("pk")) == 1,
|
||||
foreignKey));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
List<String> indexesColumns = Arrays.asList("index_name", "unique", "indexed_column_name");
|
||||
List<List<Object>> indexesValues = new ArrayList<>();
|
||||
|
||||
try (Cursor indexesCursor = db.rawQuery("PRAGMA index_list(" + table + ")", null)) {
|
||||
List<String> indexedColumnNames = new ArrayList<>();
|
||||
String indexName = indexesCursor.getString(indexesCursor.getColumnIndex("name"));
|
||||
|
||||
try(Cursor indexInfoCursor = db.rawQuery("PRAGMA index_info(" + indexName + ")", null)) {
|
||||
while (indexInfoCursor.moveToNext()) {
|
||||
indexedColumnNames.add(indexInfoCursor.getString(indexInfoCursor.getColumnIndex("name")));
|
||||
}
|
||||
}
|
||||
|
||||
indexesValues.add(Arrays.asList(indexName,
|
||||
indexesCursor.getInt(indexesCursor.getColumnIndex("unique")) == 1,
|
||||
TextUtils.join(",", indexedColumnNames)));
|
||||
|
||||
}
|
||||
|
||||
return new DatabaseGetTableStructureResponse(structureColumns, structureValues, indexesColumns, indexesValues);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DatabaseGetTableInfoResponse getTableInfo(Descriptor databaseDescriptor, String table) {
|
||||
SQLiteDatabase db = databaseDescriptor.getReadable();
|
||||
|
||||
try (Cursor cursor = db.rawQuery("SELECT sql FROM sqlite_master WHERE name = ?", new String[] { table })) {
|
||||
cursor.moveToFirst();
|
||||
return new DatabaseGetTableInfoResponse(cursor.getString(cursor.getColumnIndex("sql")));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public DatabaseExecuteSqlResponse executeSQL(Descriptor descriptor, String query) {
|
||||
SQLiteDatabase db = descriptor.getWritable();
|
||||
|
||||
String firstWordUpperCase = getFirstWord(query).toUpperCase();
|
||||
|
||||
switch (firstWordUpperCase) {
|
||||
case "UPDATE":
|
||||
case "DELETE":
|
||||
return executeUpdateDelete(db, query);
|
||||
case "INSERT":
|
||||
return executeInsert(db, query);
|
||||
case "SELECT":
|
||||
case "PRAGMA":
|
||||
case "EXPLAIN":
|
||||
return executeSelect(db, query);
|
||||
default:
|
||||
return executeRawQuery(db, query);
|
||||
}
|
||||
}
|
||||
|
||||
private static String getFirstWord(String s) {
|
||||
s = s.trim();
|
||||
int firstSpace = s.indexOf(' ');
|
||||
return firstSpace >= 0 ? s.substring(0, firstSpace) : s;
|
||||
}
|
||||
|
||||
private static DatabaseExecuteSqlResponse executeUpdateDelete(SQLiteDatabase database, String query) {
|
||||
SQLiteStatement statement = database.compileStatement(query);
|
||||
int count = statement.executeUpdateDelete();
|
||||
|
||||
return DatabaseExecuteSqlResponse.successfulUpdateDelete(count);
|
||||
}
|
||||
|
||||
private static DatabaseExecuteSqlResponse executeInsert(SQLiteDatabase database, String query) {
|
||||
SQLiteStatement statement = database.compileStatement(query);
|
||||
long insertedId = statement.executeInsert();
|
||||
|
||||
return DatabaseExecuteSqlResponse.successfulInsert(insertedId);
|
||||
}
|
||||
|
||||
private static DatabaseExecuteSqlResponse executeSelect(SQLiteDatabase database, String query) {
|
||||
try (Cursor cursor = database.rawQuery(query, null)) {
|
||||
String[] columnNames = cursor.getColumnNames();
|
||||
List<List<Object>> rows = cursorToList(cursor);
|
||||
|
||||
return DatabaseExecuteSqlResponse.successfulSelect(Arrays.asList(columnNames), rows);
|
||||
}
|
||||
}
|
||||
|
||||
private static DatabaseExecuteSqlResponse executeRawQuery(SQLiteDatabase database, String query) {
|
||||
database.execSQL(query);
|
||||
return DatabaseExecuteSqlResponse.successfulRawQuery();
|
||||
}
|
||||
|
||||
private static @NonNull List<List<Object>> cursorToList(Cursor cursor) {
|
||||
List<List<Object>> rows = new ArrayList<>();
|
||||
int numColumns = cursor.getColumnCount();
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
List<Object> values = new ArrayList<>(numColumns);
|
||||
|
||||
for (int column = 0; column < numColumns; column++) {
|
||||
values.add(getObjectFromColumnIndex(cursor, column));
|
||||
}
|
||||
|
||||
rows.add(values);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static @Nullable Object getObjectFromColumnIndex(Cursor cursor, int column) {
|
||||
switch (cursor.getType(column)) {
|
||||
case Cursor.FIELD_TYPE_NULL:
|
||||
return null;
|
||||
case Cursor.FIELD_TYPE_INTEGER:
|
||||
return cursor.getLong(column);
|
||||
case Cursor.FIELD_TYPE_FLOAT:
|
||||
return cursor.getDouble(column);
|
||||
case Cursor.FIELD_TYPE_BLOB:
|
||||
byte[] blob = cursor.getBlob(column);
|
||||
String bytes = blob != null ? "(blob) " + Hex.toStringCondensed(Arrays.copyOf(blob, Math.min(blob.length, 32))) : null;
|
||||
if (bytes != null && bytes.length() == 32 && blob.length > 32) {
|
||||
bytes += "...";
|
||||
}
|
||||
return bytes;
|
||||
case Cursor.FIELD_TYPE_STRING:
|
||||
default:
|
||||
return cursor.getString(column);
|
||||
}
|
||||
}
|
||||
|
||||
static class Descriptor implements DatabaseDescriptor {
|
||||
private final SignalDatabaseOpenHelper sqlCipherOpenHelper;
|
||||
|
||||
Descriptor(@NonNull SignalDatabaseOpenHelper sqlCipherOpenHelper) {
|
||||
this.sqlCipherOpenHelper = sqlCipherOpenHelper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() {
|
||||
return sqlCipherOpenHelper.getDatabaseName();
|
||||
}
|
||||
|
||||
public @NonNull SQLiteDatabase getReadable() {
|
||||
return sqlCipherOpenHelper.getSqlCipherDatabase();
|
||||
}
|
||||
|
||||
public @NonNull SQLiteDatabase getWritable() {
|
||||
return sqlCipherOpenHelper.getSqlCipherDatabase();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,8 +308,6 @@
|
||||
android:allowEmbedded="true"
|
||||
android:resizeableActivity="true" />
|
||||
|
||||
<activity android:name=".longmessage.LongMessageActivity" />
|
||||
|
||||
<activity android:name=".conversation.ConversationPopupActivity"
|
||||
android:windowSoftInputMode="stateVisible"
|
||||
android:launchMode="singleTask"
|
||||
@@ -318,12 +316,6 @@
|
||||
android:theme="@style/TextSecure.LightTheme.Popup"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" />
|
||||
|
||||
<activity android:name=".messagedetails.MessageDetailsActivity"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
<activity android:name=".groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar" />
|
||||
@@ -428,7 +420,7 @@
|
||||
<activity android:name=".registration.RegistrationNavigationActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/TextSecure.LightRegistrationTheme"
|
||||
android:windowSoftInputMode="stateUnchanged"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".revealable.ViewOnceMessageActivity"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.whispersystems.signalservice.api.account.AccountAttributes;
|
||||
|
||||
public final class AppCapabilities {
|
||||
@@ -13,12 +12,13 @@ public final class AppCapabilities {
|
||||
private static final boolean GV1_MIGRATION = true;
|
||||
private static final boolean ANNOUNCEMENT_GROUPS = true;
|
||||
private static final boolean SENDER_KEY = true;
|
||||
private static final boolean CHANGE_NUMBER = true;
|
||||
|
||||
/**
|
||||
* @param storageCapable Whether or not the user can use storage service. This is another way of
|
||||
* asking if the user has set a Signal PIN or not.
|
||||
*/
|
||||
public static AccountAttributes.Capabilities getCapabilities(boolean storageCapable) {
|
||||
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, FeatureFlags.changeNumber());
|
||||
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +52,11 @@ public final class AppInitialization {
|
||||
Log.i(TAG, "onPostBackupRestore()");
|
||||
|
||||
ApplicationDependencies.getMegaphoneRepository().onFirstEverAppLaunch();
|
||||
SignalStore.onPostBackupRestore();
|
||||
SignalStore.onFirstEverAppLaunch();
|
||||
SignalStore.onboarding().clearAll();
|
||||
TextSecurePreferences.onPostBackupRestore(context);
|
||||
TextSecurePreferences.setPasswordDisabled(context, true);
|
||||
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.ZOZO.getPackId(), BlessedPacks.ZOZO.getPackKey(), false));
|
||||
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.BANDIT.getPackId(), BlessedPacks.BANDIT.getPackKey(), false));
|
||||
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.DAY_BY_DAY.getPackId(), BlessedPacks.DAY_BY_DAY.getPackKey(), false));
|
||||
|
||||
@@ -35,6 +35,8 @@ import org.signal.core.util.logging.AndroidLogger;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.core.util.tracing.Tracer;
|
||||
import org.signal.glide.SignalGlideCodecs;
|
||||
import org.thoughtcrime.securesms.emoji.JumboEmoji;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveReleaseChannelJob;
|
||||
import org.thoughtcrime.securesms.mms.SignalGlideModule;
|
||||
import org.signal.ringrtc.CallManager;
|
||||
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
|
||||
@@ -174,7 +176,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addNonBlocking(this::initializeRevealableMessageManager)
|
||||
.addNonBlocking(this::initializePendingRetryReceiptManager)
|
||||
.addNonBlocking(this::initializeFcmCheck)
|
||||
.addNonBlocking(this::initializeSignedPreKeyCheck)
|
||||
.addNonBlocking(CreateSignedPreKeyJob::enqueueIfNeeded)
|
||||
.addNonBlocking(this::initializePeriodicTasks)
|
||||
.addNonBlocking(this::initializeCircumvention)
|
||||
.addNonBlocking(this::initializePendingMessages)
|
||||
@@ -192,6 +194,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
|
||||
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
|
||||
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()))
|
||||
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
|
||||
.addPostRender(RetrieveReleaseChannelJob::enqueue)
|
||||
.execute();
|
||||
|
||||
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
@@ -348,12 +352,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeSignedPreKeyCheck() {
|
||||
if (!TextSecurePreferences.isSignedPreKeyRegistered(this)) {
|
||||
ApplicationDependencies.getJobManager().add(new CreateSignedPreKeyJob(this));
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeExpiringMessageManager() {
|
||||
ApplicationDependencies.getExpiringMessageManager().checkSchedule();
|
||||
}
|
||||
|
||||
@@ -92,6 +92,8 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void onInMemoryMessageClicked(@NonNull InMemoryMessageRecord messageRecord);
|
||||
void onViewGroupDescriptionChange(@Nullable GroupId groupId, @NonNull String description, boolean isMessageRequestAccepted);
|
||||
void onChangeNumberUpdateContact(@NonNull Recipient recipient);
|
||||
void onCallToAction(@NonNull String action);
|
||||
void onDonateClicked();
|
||||
|
||||
/** @return true if handled, false if you want to let the normal url handling continue */
|
||||
boolean onUrlClicked(@NonNull String url);
|
||||
|
||||
@@ -76,6 +76,11 @@ public final class BlockUnblockDialog {
|
||||
builder.setPositiveButton(R.string.RecipientPreferenceActivity_block, ((dialog, which) -> onBlock.run()));
|
||||
builder.setNegativeButton(android.R.string.cancel, null);
|
||||
}
|
||||
} else if (recipient.isReleaseNotes()) {
|
||||
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_s, recipient.getDisplayName(context)));
|
||||
builder.setMessage(R.string.BlockUnblockDialog_block_getting_signal_updates_and_news);
|
||||
builder.setPositiveButton(R.string.BlockUnblockDialog_block, ((dialog, which) -> onBlock.run()));
|
||||
builder.setNegativeButton(android.R.string.cancel, null);
|
||||
} else {
|
||||
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_s, recipient.getDisplayName(context)));
|
||||
builder.setMessage(R.string.BlockUnblockDialog_blocked_people_wont_be_able_to_call_you_or_send_you_messages);
|
||||
@@ -115,6 +120,12 @@ public final class BlockUnblockDialog {
|
||||
builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run()));
|
||||
builder.setNegativeButton(android.R.string.cancel, null);
|
||||
}
|
||||
} else if (recipient.isReleaseNotes()) {
|
||||
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_unblock_s, recipient.getDisplayName(context)));
|
||||
builder.setMessage(R.string.BlockUnblockDialog_resume_getting_signal_updates_and_news);
|
||||
|
||||
builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run()));
|
||||
builder.setNegativeButton(android.R.string.cancel, null);
|
||||
} else {
|
||||
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_unblock_s, recipient.getDisplayName(context)));
|
||||
builder.setMessage(R.string.BlockUnblockDialog_you_will_be_able_to_call_and_message_each_other);
|
||||
|
||||
@@ -571,11 +571,11 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext());
|
||||
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
|
||||
return UsernameUtil.fetchAciForUsername(requireContext(), contact.getNumber());
|
||||
return UsernameUtil.fetchAciForUsername(contact.getNumber());
|
||||
}, uuid -> {
|
||||
loadingDialog.dismiss();
|
||||
if (uuid.isPresent()) {
|
||||
Recipient recipient = Recipient.externalUsername(requireContext(), uuid.get(), contact.getNumber());
|
||||
Recipient recipient = Recipient.externalUsername(uuid.get(), contact.getNumber());
|
||||
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
@@ -746,7 +746,11 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
return;
|
||||
}
|
||||
|
||||
TransitionManager.beginDelayedTransition(constraintLayout, new AutoTransition().setDuration(CHIP_GROUP_REVEAL_DURATION_MS));
|
||||
AutoTransition transition = new AutoTransition();
|
||||
transition.setDuration(CHIP_GROUP_REVEAL_DURATION_MS);
|
||||
transition.excludeChildren(recyclerView, true);
|
||||
transition.excludeTarget(recyclerView, true);
|
||||
TransitionManager.beginDelayedTransition(constraintLayout, transition);
|
||||
|
||||
ConstraintSet constraintSet = new ConstraintSet();
|
||||
constraintSet.clone(constraintLayout);
|
||||
|
||||
@@ -21,9 +21,11 @@ import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.qr.ScanListener;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
@@ -186,12 +188,13 @@ public class DeviceActivity extends PassphraseRequiredActivity
|
||||
return BAD_CODE;
|
||||
}
|
||||
|
||||
ECPublicKey publicKey = Curve.decodePoint(Base64.decode(publicKeyEncoded), 0);
|
||||
IdentityKeyPair identityKeyPair = IdentityKeyUtil.getIdentityKeyPair(context);
|
||||
Optional<byte[]> profileKey = Optional.of(ProfileKeyUtil.getProfileKey(getContext()));
|
||||
ECPublicKey publicKey = Curve.decodePoint(Base64.decode(publicKeyEncoded), 0);
|
||||
IdentityKeyPair aciIdentityKeyPair = SignalStore.account().getAciIdentityKey();
|
||||
IdentityKeyPair pniIdentityKeyPair = SignalStore.account().getPniIdentityKey();
|
||||
ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey();
|
||||
|
||||
TextSecurePreferences.setMultiDevice(DeviceActivity.this, true);
|
||||
accountManager.addDevice(ephemeralId, publicKey, identityKeyPair, profileKey, verificationCode);
|
||||
accountManager.addDevice(ephemeralId, publicKey, aciIdentityKeyPair, pniIdentityKeyPair, profileKey, verificationCode);
|
||||
|
||||
return SUCCESS;
|
||||
} catch (NotFoundException e) {
|
||||
|
||||
@@ -21,13 +21,11 @@ import android.annotation.SuppressLint;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.database.ContentObserver;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
@@ -557,9 +555,15 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
|
||||
int mediaPosition = Objects.requireNonNull(data.second);
|
||||
|
||||
CursorPagerAdapter adapter = new CursorPagerAdapter(getSupportFragmentManager(),this, cursor, mediaPosition, leftIsRecent);
|
||||
mediaPager.setAdapter(adapter);
|
||||
adapter.setActive(true);
|
||||
CursorPagerAdapter oldAdapter = (CursorPagerAdapter) mediaPager.getAdapter();
|
||||
if (oldAdapter == null) {
|
||||
CursorPagerAdapter adapter = new CursorPagerAdapter(getSupportFragmentManager(), this, cursor, mediaPosition, leftIsRecent);
|
||||
mediaPager.setAdapter(adapter);
|
||||
adapter.setActive(true);
|
||||
} else {
|
||||
oldAdapter.setCursor(cursor, mediaPosition);
|
||||
oldAdapter.setActive(true);
|
||||
}
|
||||
|
||||
viewModel.setCursor(this, cursor, leftIsRecent);
|
||||
|
||||
@@ -715,10 +719,10 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
private final Map<Integer, MediaPreviewFragment> mediaFragments = new HashMap<>();
|
||||
|
||||
private final Context context;
|
||||
private final Cursor cursor;
|
||||
private final boolean leftIsRecent;
|
||||
|
||||
private boolean active;
|
||||
private Cursor cursor;
|
||||
private int autoPlayPosition;
|
||||
|
||||
CursorPagerAdapter(@NonNull FragmentManager fragmentManager,
|
||||
@@ -739,6 +743,11 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void setCursor(@NonNull Cursor cursor, int autoPlayPosition) {
|
||||
this.cursor = cursor;
|
||||
this.autoPlayPosition = autoPlayPosition;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
if (!active) return 0;
|
||||
|
||||
@@ -67,7 +67,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
} else {
|
||||
Log.i(TAG, "[onContactSelected] Maybe creating a new recipient.");
|
||||
|
||||
if (SignalStore.account().isRegistered() && NetworkConstraint.isMet(this)) {
|
||||
if (SignalStore.account().isRegistered() && NetworkConstraint.isMet(getApplication())) {
|
||||
Log.i(TAG, "[onContactSelected] Doing contact refresh.");
|
||||
|
||||
AlertDialog progress = SimpleProgressDialog.show(this);
|
||||
@@ -75,7 +75,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
SimpleTask.run(getLifecycle(), () -> {
|
||||
Recipient resolved = Recipient.external(this, number);
|
||||
|
||||
if (!resolved.isRegistered() || !resolved.hasAci()) {
|
||||
if (!resolved.isRegistered() || !resolved.hasServiceId()) {
|
||||
Log.i(TAG, "[onContactSelected] Not registered or no UUID. Doing a directory refresh.");
|
||||
try {
|
||||
DirectoryHelper.refreshDirectoryFor(this, resolved, false);
|
||||
|
||||
@@ -22,6 +22,7 @@ import android.os.Bundle;
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecret;
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.VersionTracker;
|
||||
|
||||
/**
|
||||
@@ -61,7 +62,8 @@ public class PassphraseCreateActivity extends PassphraseActivity {
|
||||
passphrase);
|
||||
|
||||
MasterSecretUtil.generateAsymmetricMasterSecret(PassphraseCreateActivity.this, masterSecret);
|
||||
IdentityKeyUtil.generateIdentityKeys(PassphraseCreateActivity.this);
|
||||
SignalStore.account().generateAciIdentityKey();
|
||||
SignalStore.account().generatePniIdentityKey();
|
||||
VersionTracker.updateLastSeenVersion(PassphraseCreateActivity.this);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -52,7 +52,7 @@ public class AudioRecorder {
|
||||
.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 && FeatureFlags.voiceNoteRecordingV2() ? new MediaRecorderWrapper() : new AudioCodec();
|
||||
recorder = Build.VERSION.SDK_INT >= 26 ? new MediaRecorderWrapper() : new AudioCodec();
|
||||
recorder.start(fds[1]);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
|
||||
@@ -143,11 +143,10 @@ object Avatars {
|
||||
)
|
||||
|
||||
data class ColorPair(
|
||||
val backgroundAvatarColor: AvatarColor,
|
||||
val foregroundAvatarColor: ForegroundColor
|
||||
@ColorInt val backgroundColor: Int,
|
||||
@ColorInt val foregroundColor: Int,
|
||||
val code: String
|
||||
) {
|
||||
@ColorInt val backgroundColor: Int = backgroundAvatarColor.colorInt()
|
||||
@ColorInt val foregroundColor: Int = foregroundAvatarColor.colorInt
|
||||
val code: String = backgroundAvatarColor.serialize()
|
||||
constructor(backgroundAvatarColor: AvatarColor, foregroundAvatarColor: ForegroundColor) : this(backgroundAvatarColor.colorInt(), foregroundAvatarColor.colorInt, backgroundAvatarColor.serialize())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,4 +244,8 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
}
|
||||
.execute()
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,12 @@ import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
|
||||
import org.thoughtcrime.securesms.database.KeyValueDatabase;
|
||||
import org.thoughtcrime.securesms.database.MentionDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsColumns;
|
||||
import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase;
|
||||
import org.thoughtcrime.securesms.database.PendingRetryReceiptDatabase;
|
||||
import org.thoughtcrime.securesms.database.ReactionDatabase;
|
||||
import org.thoughtcrime.securesms.database.SearchDatabase;
|
||||
import org.thoughtcrime.securesms.database.SenderKeyDatabase;
|
||||
import org.thoughtcrime.securesms.database.SenderKeySharedDatabase;
|
||||
@@ -39,10 +41,13 @@ import org.thoughtcrime.securesms.database.SignedPreKeyDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.StickerDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.AvatarPickerDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.CursorUtil;
|
||||
import org.thoughtcrime.securesms.util.SetUtil;
|
||||
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
@@ -158,13 +163,17 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
for (String table : tables) {
|
||||
throwIfCanceled(cancellationSignal);
|
||||
if (table.equals(MmsDatabase.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringMmsMessage, null, count, estimatedCount, cancellationSignal);
|
||||
count = exportTable(table, input, outputStream, cursor -> isNonExpiringMmsMessage(cursor) && isNotReleaseChannel(cursor), null, count, estimatedCount, cancellationSignal);
|
||||
} else if (table.equals(SmsDatabase.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringSmsMessage, null, count, estimatedCount, cancellationSignal);
|
||||
count = exportTable(table, input, outputStream, cursor -> isNonExpiringSmsMessage(cursor) && isNotReleaseChannel(cursor), null, count, estimatedCount, cancellationSignal);
|
||||
} else if (table.equals(ReactionDatabase.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, new MessageId(CursorUtil.requireLong(cursor, ReactionDatabase.MESSAGE_ID), CursorUtil.requireBoolean(cursor, ReactionDatabase.IS_MMS))), null, count, estimatedCount, cancellationSignal);
|
||||
} else if (table.equals(MentionDatabase.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessageAndNotReleaseChannel(input, CursorUtil.requireLong(cursor, MentionDatabase.MESSAGE_ID)), null, count, estimatedCount, cancellationSignal);
|
||||
} else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count, estimatedCount, cancellationSignal);
|
||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessageAndNotReleaseChannel(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count, estimatedCount, cancellationSignal);
|
||||
} else if (table.equals(AttachmentDatabase.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), (cursor, innerCount) -> exportAttachment(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal);
|
||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessageAndNotReleaseChannel(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), (cursor, innerCount) -> exportAttachment(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal);
|
||||
} else if (table.equals(StickerDatabase.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, cursor -> true, (cursor, innerCount) -> exportSticker(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal);
|
||||
} else if (!BLACKLISTED_TABLES.contains(table) && !table.startsWith("sqlite_")) {
|
||||
@@ -173,12 +182,6 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
stopwatch.split("table::" + table);
|
||||
}
|
||||
|
||||
for (BackupProtos.SharedPreference preference : IdentityKeyUtil.getBackupRecord(context)) {
|
||||
throwIfCanceled(cancellationSignal);
|
||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||
outputStream.write(preference);
|
||||
}
|
||||
|
||||
for (BackupProtos.SharedPreference preference : TextSecurePreferences.getPreferencesToSaveToBackup(context)) {
|
||||
throwIfCanceled(cancellationSignal);
|
||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||
@@ -439,7 +442,12 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
|
||||
Class<?> type = dataSet.getType(key);
|
||||
if (type == byte[].class) {
|
||||
builder.setBlobValue(ByteString.copyFrom(dataSet.getBlob(key, null)));
|
||||
byte[] data = dataSet.getBlob(key, null);
|
||||
if (data != null) {
|
||||
builder.setBlobValue(ByteString.copyFrom(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));
|
||||
} else if (type == Float.class) {
|
||||
@@ -449,7 +457,12 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
} else if (type == Long.class) {
|
||||
builder.setLongValue(dataSet.getLong(key, 0));
|
||||
} else if (type == String.class) {
|
||||
builder.setStringValue(dataSet.getString(key, null));
|
||||
String data = dataSet.getString(key, null);
|
||||
if (data != null) {
|
||||
builder.setStringValue(dataSet.getString(key, null));
|
||||
} else {
|
||||
Log.w(TAG, "Skipping storing null string for key: " + key);
|
||||
}
|
||||
} else {
|
||||
throw new AssertionError("Unknown type: " + type);
|
||||
}
|
||||
@@ -470,21 +483,46 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0;
|
||||
}
|
||||
|
||||
private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, long mmsId) {
|
||||
String[] columns = new String[] { MmsDatabase.EXPIRES_IN, MmsDatabase.VIEW_ONCE};
|
||||
String where = MmsDatabase.ID + " = ?";
|
||||
String[] args = new String[] { String.valueOf(mmsId) };
|
||||
private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, @NonNull MessageId messageId) {
|
||||
if (messageId.isMms()) {
|
||||
return isForNonExpiringMmsMessageAndNotReleaseChannel(db, messageId.getId());
|
||||
} else {
|
||||
return isForNonExpiringSmsMessage(db, messageId.getId());
|
||||
}
|
||||
}
|
||||
|
||||
try (Cursor mmsCursor = db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null)) {
|
||||
if (mmsCursor != null && mmsCursor.moveToFirst()) {
|
||||
return mmsCursor.getLong(mmsCursor.getColumnIndexOrThrow(MmsDatabase.EXPIRES_IN)) == 0 &&
|
||||
mmsCursor.getLong(mmsCursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) == 0;
|
||||
private static boolean isForNonExpiringSmsMessage(@NonNull SQLiteDatabase db, long smsId) {
|
||||
String[] columns = new String[] { SmsDatabase.EXPIRES_IN };
|
||||
String where = SmsDatabase.ID + " = ?";
|
||||
String[] args = new String[] { String.valueOf(smsId) };
|
||||
|
||||
try (Cursor cursor = db.query(SmsDatabase.TABLE_NAME, columns, where, args, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return isNonExpiringSmsMessage(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isForNonExpiringMmsMessageAndNotReleaseChannel(@NonNull SQLiteDatabase db, long mmsId) {
|
||||
String[] columns = new String[] { MmsDatabase.RECIPIENT_ID, MmsDatabase.EXPIRES_IN, MmsDatabase.VIEW_ONCE};
|
||||
String where = MmsDatabase.ID + " = ?";
|
||||
String[] args = new String[] { String.valueOf(mmsId) };
|
||||
|
||||
try (Cursor mmsCursor = db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null)) {
|
||||
if (mmsCursor != null && mmsCursor.moveToFirst()) {
|
||||
return isNonExpiringMmsMessage(mmsCursor) && isNotReleaseChannel(mmsCursor);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isNotReleaseChannel(Cursor cursor) {
|
||||
RecipientId releaseChannel = SignalStore.releaseChannelValues().getReleaseChannelRecipientId();
|
||||
return releaseChannel == null || cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.RECIPIENT_ID)) != releaseChannel.toLong();
|
||||
}
|
||||
|
||||
private static class BackupFrameOutputStream extends BackupStream {
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.database.SearchDatabase;
|
||||
import org.thoughtcrime.securesms.database.StickerDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.BackupUtil;
|
||||
@@ -207,7 +208,7 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
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), avatar.getLength());
|
||||
inputStream.readAttachmentTo(AvatarHelper.getOutputStream(context, recipientId, false), avatar.getLength());
|
||||
} else {
|
||||
if (avatar.hasName() && SqlUtil.tableExists(db, "recipient_preferences")) {
|
||||
Log.w(TAG, "Avatar is missing a recipientId. Clearing signal_profile_avatar (legacy) so it can be fetched later.");
|
||||
@@ -250,6 +251,17 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
private static void processPreference(@NonNull Context context, SharedPreference preference) {
|
||||
SharedPreferences preferences = context.getSharedPreferences(preference.getFile(), 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());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (preference.hasValue()) {
|
||||
preferences.edit().putString(preference.getKey(), preference.getValue()).commit();
|
||||
} else if (preference.hasBooleanValue()) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.thoughtcrime.securesms.badges.self.expired
|
||||
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.SplashImage
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
|
||||
class CantProcessSubscriptionPaymentBottomSheetDialogFragment : DSLSettingsBottomSheetFragment() {
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
SplashImage.register(adapter)
|
||||
adapter.submitList(getConfiguration().toMappingModelList())
|
||||
}
|
||||
|
||||
private fun getConfiguration(): DSLConfiguration {
|
||||
return configure {
|
||||
customPref(SplashImage.Model(R.drawable.ic_card_process))
|
||||
|
||||
sectionHeaderPref(
|
||||
title = DSLSettingsText.from(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__cant_process_subscription_payment, DSLSettingsText.CenterModifier)
|
||||
)
|
||||
|
||||
textPref(
|
||||
summary = DSLSettingsText.from(
|
||||
requireContext().getString(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__were_having_trouble),
|
||||
DSLSettingsText.LearnMoreModifier(ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)) {
|
||||
CommunicationActions.openBrowserLink(requireContext(), requireContext().getString(R.string.donation_decline_code_error_url))
|
||||
},
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(android.R.string.ok)
|
||||
) {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__dont_show_this_again)
|
||||
) {
|
||||
SignalStore.donationsValues().showCantProcessDialog = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
@@ -27,9 +28,13 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
|
||||
}
|
||||
|
||||
private fun getConfiguration(): DSLConfiguration {
|
||||
val badge: Badge = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments()).badge
|
||||
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
|
||||
val badge: Badge = args.badge
|
||||
val cancellationReason: UnexpectedSubscriptionCancellation? = UnexpectedSubscriptionCancellation.fromStatus(args.cancelationReason)
|
||||
val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer()
|
||||
|
||||
val inactive = cancellationReason == UnexpectedSubscriptionCancellation.INACTIVE
|
||||
|
||||
return configure {
|
||||
customPref(ExpiredBadge.Model(badge))
|
||||
|
||||
@@ -50,8 +55,10 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
|
||||
DSLSettingsText.from(
|
||||
if (badge.isBoost()) {
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_boost_badge_has_expired)
|
||||
} else if (inactive) {
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_sustainer_subscription_was_automatically, badge.name)
|
||||
} else {
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_sustainer, badge.name)
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_sustainer_subscription_was_canceled)
|
||||
},
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
@@ -109,8 +116,8 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun show(badge: Badge, fragmentManager: FragmentManager) {
|
||||
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.Builder(badge).build()
|
||||
fun show(badge: Badge, cancellationReason: UnexpectedSubscriptionCancellation?, fragmentManager: FragmentManager) {
|
||||
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.Builder(badge, cancellationReason?.status).build()
|
||||
val fragment = ExpiredBadgeBottomSheetDialogFragment()
|
||||
fragment.arguments = args.toBundle()
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class BadgesOverviewFragment : DSLSettingsFragment(
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
Badge.register(adapter) { badge, _, isFaded ->
|
||||
if (badge.isExpired() || isFaded) {
|
||||
findNavController().safeNavigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge))
|
||||
findNavController().safeNavigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge, null))
|
||||
} else {
|
||||
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package org.thoughtcrime.securesms.blocked;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
@@ -15,6 +12,7 @@ import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.BlockUnblockDialog;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
||||
@@ -74,24 +72,9 @@ public class BlockedUsersFragment extends Fragment {
|
||||
}
|
||||
|
||||
private void handleRecipientClicked(@NonNull Recipient recipient) {
|
||||
AlertDialog confirmationDialog = new AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.BlockedUsersActivity__unblock_user)
|
||||
.setMessage(getString(R.string.BlockedUsersActivity__do_you_want_to_unblock_s, recipient.getDisplayName(requireContext())))
|
||||
.setPositiveButton(R.string.BlockedUsersActivity__unblock, (dialog, which) -> {
|
||||
viewModel.unblock(recipient.getId());
|
||||
dialog.dismiss();
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
|
||||
dialog.dismiss();
|
||||
})
|
||||
.setCancelable(true)
|
||||
.create();
|
||||
|
||||
confirmationDialog.setOnShowListener(dialog -> {
|
||||
confirmationDialog.getButton(DialogInterface.BUTTON_POSITIVE).setTextColor(Color.RED);
|
||||
BlockUnblockDialog.showUnblockFor(requireContext(), getViewLifecycleOwner().getLifecycle(), recipient, () -> {
|
||||
viewModel.unblock(recipient.getId());
|
||||
});
|
||||
|
||||
confirmationDialog.show();
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components;
|
||||
import android.content.Context;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.PorterDuffColorFilter;
|
||||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
@@ -18,6 +17,7 @@ import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.SimpleEmojiTextView;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
@@ -44,7 +44,7 @@ public class FromTextView extends SimpleEmojiTextView {
|
||||
}
|
||||
|
||||
public void setText(Recipient recipient, boolean read, @Nullable String suffix) {
|
||||
setText(recipient, recipient.getDisplayName(getContext()), read, suffix);
|
||||
setText(recipient, recipient.getDisplayNameOrUsername(getContext()), read, suffix);
|
||||
}
|
||||
|
||||
public void setText(Recipient recipient, @Nullable CharSequence fromString, boolean read, @Nullable String suffix) {
|
||||
@@ -62,11 +62,19 @@ public class FromTextView extends SimpleEmojiTextView {
|
||||
builder.append(suffix);
|
||||
}
|
||||
|
||||
if (recipient.isReleaseNotes()) {
|
||||
Drawable official = ContextUtil.requireDrawable(getContext(), R.drawable.ic_official_20);
|
||||
official.setBounds(0, 0, ViewUtil.dpToPx(20), ViewUtil.dpToPx(20));
|
||||
|
||||
builder.append(" ")
|
||||
.append(SpanUtil.buildCenteredImageSpan(official));
|
||||
}
|
||||
|
||||
setText(builder);
|
||||
|
||||
if (recipient.isBlocked()) setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_block_grey600_18dp, 0, 0, 0);
|
||||
else if (recipient.isMuted()) setCompoundDrawablesRelativeWithIntrinsicBounds(getMuted(), null, null, null);
|
||||
else setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0);
|
||||
else setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
private Drawable getMuted() {
|
||||
|
||||
@@ -35,7 +35,11 @@ public abstract class FullScreenDialogFragment extends DialogFragment {
|
||||
View view = inflater.inflate(R.layout.full_screen_dialog_fragment, container, false);
|
||||
inflater.inflate(getDialogLayoutResource(), view.findViewById(R.id.full_screen_dialog_content), true);
|
||||
toolbar = view.findViewById(R.id.full_screen_dialog_toolbar);
|
||||
toolbar.setTitle(getTitle());
|
||||
|
||||
if (getTitle() != -1) {
|
||||
toolbar.setTitle(getTitle());
|
||||
}
|
||||
|
||||
toolbar.setNavigationOnClickListener(v -> onNavigateUp());
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -354,13 +354,13 @@ public class InputPanel extends LinearLayout
|
||||
slideToCancel.display();
|
||||
|
||||
if (emojiVisible) {
|
||||
ViewUtil.fadeOut(mediaKeyboard, FADE_TIME, View.INVISIBLE);
|
||||
fadeOut(mediaKeyboard);
|
||||
}
|
||||
|
||||
ViewUtil.fadeOut(composeText, FADE_TIME, View.INVISIBLE);
|
||||
ViewUtil.fadeOut(quickCameraToggle, FADE_TIME, View.INVISIBLE);
|
||||
ViewUtil.fadeOut(quickAudioToggle, FADE_TIME, View.INVISIBLE);
|
||||
buttonToggle.animate().alpha(0).setDuration(FADE_TIME).start();
|
||||
fadeOut(composeText);
|
||||
fadeOut(quickCameraToggle);
|
||||
fadeOut(quickAudioToggle);
|
||||
fadeOut(buttonToggle);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -401,7 +401,7 @@ public class InputPanel extends LinearLayout
|
||||
public void onRecordLocked() {
|
||||
slideToCancel.hide();
|
||||
recordLockCancel.setVisibility(View.VISIBLE);
|
||||
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
|
||||
fadeIn(buttonToggle);
|
||||
if (listener != null) listener.onRecorderLocked();
|
||||
}
|
||||
|
||||
@@ -488,36 +488,33 @@ public class InputPanel extends LinearLayout
|
||||
|
||||
private void hideNormalComposeViews() {
|
||||
if (emojiVisible) {
|
||||
Animation animation = mediaKeyboard.getAnimation();
|
||||
if (animation != null) {
|
||||
animation.cancel();
|
||||
}
|
||||
|
||||
mediaKeyboard.setVisibility(View.INVISIBLE);
|
||||
mediaKeyboard.animate().cancel();
|
||||
mediaKeyboard.setAlpha(0f);
|
||||
}
|
||||
|
||||
for (Animation animation : Arrays.asList(composeText.getAnimation(), quickCameraToggle.getAnimation(), quickAudioToggle.getAnimation())) {
|
||||
if (animation != null) {
|
||||
animation.cancel();
|
||||
}
|
||||
for (View view : Arrays.asList(composeText, quickCameraToggle, quickAudioToggle, buttonToggle)) {
|
||||
view.animate().cancel();
|
||||
view.setAlpha(0f);
|
||||
}
|
||||
|
||||
buttonToggle.animate().cancel();
|
||||
|
||||
composeText.setVisibility(View.INVISIBLE);
|
||||
quickCameraToggle.setVisibility(View.INVISIBLE);
|
||||
quickAudioToggle.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
|
||||
private void fadeInNormalComposeViews() {
|
||||
if (emojiVisible) {
|
||||
ViewUtil.fadeIn(mediaKeyboard, FADE_TIME);
|
||||
fadeIn(mediaKeyboard);
|
||||
}
|
||||
|
||||
ViewUtil.fadeIn(composeText, FADE_TIME);
|
||||
ViewUtil.fadeIn(quickCameraToggle, FADE_TIME);
|
||||
ViewUtil.fadeIn(quickAudioToggle, FADE_TIME);
|
||||
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
|
||||
fadeIn(composeText);
|
||||
fadeIn(quickCameraToggle);
|
||||
fadeIn(quickAudioToggle);
|
||||
fadeIn(buttonToggle);
|
||||
}
|
||||
|
||||
private void fadeIn(@NonNull View v) {
|
||||
v.animate().alpha(1).setDuration(FADE_TIME).start();
|
||||
}
|
||||
|
||||
private void fadeOut(@NonNull View v) {
|
||||
v.animate().alpha(0).setDuration(FADE_TIME).start();
|
||||
}
|
||||
|
||||
private void updateVisibility() {
|
||||
|
||||
@@ -16,7 +16,6 @@ import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import kotlin.jvm.functions.Function1;
|
||||
import kotlin.jvm.functions.Function2;
|
||||
|
||||
/**
|
||||
@@ -121,7 +120,11 @@ public final class RotatableGradientDrawable extends Drawable {
|
||||
public void draw(Canvas canvas) {
|
||||
int save = canvas.save();
|
||||
canvas.rotate(degrees, getBounds().width() / 2f, getBounds().height() / 2f);
|
||||
canvas.drawRect(fillRect, fillPaint);
|
||||
|
||||
int height = fillRect.height();
|
||||
int width = fillRect.width();
|
||||
canvas.drawRect(fillRect.left - width, fillRect.top - height, fillRect.right + width, fillRect.bottom + height, fillPaint);
|
||||
|
||||
canvas.restoreToCount(save);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class Emoji {
|
||||
|
||||
private final List<String> variations;
|
||||
private final List<String> rawVariations;
|
||||
|
||||
public Emoji(String... variations) {
|
||||
this.variations = Arrays.asList(variations);
|
||||
this(Arrays.asList(variations), Collections.emptyList());
|
||||
}
|
||||
|
||||
public Emoji(List<String> variations) {
|
||||
this(variations, Collections.emptyList());
|
||||
}
|
||||
|
||||
public Emoji(List<String> variations, List<String> rawVariations) {
|
||||
this.variations = variations;
|
||||
this.rawVariations = rawVariations;
|
||||
}
|
||||
|
||||
public String getValue() {
|
||||
@@ -26,4 +35,11 @@ public class Emoji {
|
||||
public boolean hasMultipleVariations() {
|
||||
return variations.size() > 1;
|
||||
}
|
||||
|
||||
public @Nullable String getRawVariation(int variationIndex) {
|
||||
if (rawVariations != null && variationIndex >= 0 && variationIndex < rawVariations.size()) {
|
||||
return rawVariations.get(variationIndex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,8 +149,8 @@ public class EmojiProvider {
|
||||
throw new IllegalStateException("Unexpected subclass " + loadResult.getClass());
|
||||
}
|
||||
|
||||
if (jumboEmoji) {
|
||||
JumboEmoji.LoadResult result = JumboEmoji.loadJumboEmoji(context, drawInfo.getRawEmoji());
|
||||
if (jumboEmoji && drawInfo.getJumboSheet() != null) {
|
||||
JumboEmoji.LoadResult result = JumboEmoji.loadJumboEmoji(context, drawInfo);
|
||||
if (result instanceof JumboEmoji.LoadResult.Immediate) {
|
||||
ThreadUtil.runOnMain(() -> {
|
||||
jumboLoaded.set(true);
|
||||
@@ -171,7 +171,11 @@ public class EmojiProvider {
|
||||
|
||||
@Override
|
||||
public void onFailure(ExecutionException exception) {
|
||||
Log.d(TAG, "Failed to load jumbo emoji bitmap resource", exception);
|
||||
if (exception.getCause() instanceof JumboEmoji.CannotAutoDownload) {
|
||||
Log.i(TAG, "Download restrictions are preventing jumbomoji use");
|
||||
} else {
|
||||
Log.d(TAG, "Failed to load jumbo emoji bitmap resource", exception);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -200,15 +204,19 @@ public class EmojiProvider {
|
||||
|
||||
Bitmap bitmap = null;
|
||||
|
||||
if (jumboEmoji) {
|
||||
JumboEmoji.LoadResult result = JumboEmoji.loadJumboEmoji(context, drawInfo.getRawEmoji());
|
||||
if (jumboEmoji && drawInfo.getJumboSheet() != null) {
|
||||
JumboEmoji.LoadResult result = JumboEmoji.loadJumboEmoji(context, drawInfo);
|
||||
if (result instanceof JumboEmoji.LoadResult.Immediate) {
|
||||
bitmap = ((JumboEmoji.LoadResult.Immediate) result).getBitmap();
|
||||
} else if (result instanceof JumboEmoji.LoadResult.Async) {
|
||||
try {
|
||||
bitmap = ((JumboEmoji.LoadResult.Async) result).getTask().get(10, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException | ExecutionException | TimeoutException exception) {
|
||||
Log.d(TAG, "Failed to load jumbo emoji bitmap resource", exception);
|
||||
if (exception.getCause() instanceof JumboEmoji.CannotAutoDownload) {
|
||||
Log.i(TAG, "Download restrictions are preventing jumbomoji use");
|
||||
} else {
|
||||
Log.d(TAG, "Failed to load jumbo emoji bitmap resource", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import android.text.TextDirectionHeuristic;
|
||||
import android.text.TextDirectionHeuristics;
|
||||
import android.text.TextUtils;
|
||||
import android.text.method.TransformationMethod;
|
||||
import android.text.style.MetricAffectingSpan;
|
||||
import android.text.style.CharacterStyle;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.TypedValue;
|
||||
import android.view.ViewGroup;
|
||||
@@ -23,24 +23,29 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.AppCompatTextView;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.view.ViewKt;
|
||||
import androidx.core.widget.TextViewCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
|
||||
import org.thoughtcrime.securesms.emoji.JumboEmoji;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import kotlin.Unit;
|
||||
|
||||
|
||||
public class EmojiTextView extends AppCompatTextView {
|
||||
|
||||
private final boolean scaleEmojis;
|
||||
|
||||
private static final char ELLIPSIS = '…';
|
||||
private static final char ELLIPSIS = '…';
|
||||
private static final float JUMBOMOJI_SCALE = 0.8f;
|
||||
|
||||
private boolean forceCustom;
|
||||
private CharSequence previousText;
|
||||
@@ -110,13 +115,13 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
public void setText(@Nullable CharSequence text, BufferType type) {
|
||||
EmojiParser.CandidateList candidates = isInEditMode() ? null : EmojiProvider.getCandidates(text);
|
||||
|
||||
if (scaleEmojis && candidates != null && candidates.allEmojis) {
|
||||
if (scaleEmojis && candidates != null && candidates.allEmojis && (candidates.hasJumboForAll() || JumboEmoji.canDownloadJumbo(getContext()))) {
|
||||
int emojis = candidates.size();
|
||||
float scale = 1.0f;
|
||||
|
||||
if (emojis <= 5) scale += 0.9f;
|
||||
if (emojis <= 4) scale += 0.9f;
|
||||
if (emojis <= 2) scale += 0.9f;
|
||||
if (emojis <= 5) scale += JUMBOMOJI_SCALE;
|
||||
if (emojis <= 4) scale += JUMBOMOJI_SCALE;
|
||||
if (emojis <= 2) scale += JUMBOMOJI_SCALE;
|
||||
|
||||
isJumbomoji = scale > 1.0f;
|
||||
super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize * scale);
|
||||
@@ -181,16 +186,16 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting from API 30, there can be a rounding error in text layout when a non-default font
|
||||
* scale is used. This causes a line break to be inserted where there shouldn't be one. Force the
|
||||
* width to be larger to work around this problem.
|
||||
* Starting from API 30, there can be a rounding error in text layout when a non-zero letter
|
||||
* spacing is used. This causes a line break to be inserted where there shouldn't be one. Force
|
||||
* the width to be larger to work around this problem.
|
||||
* https://issuetracker.google.com/issues/173574230
|
||||
*
|
||||
* @param widthMeasureSpec the original measure spec passed to {@link #onMeasure(int, int)}
|
||||
* @return the measure spec with the workaround, or the original one.
|
||||
*/
|
||||
private int applyWidthMeasureRoundingFix(int widthMeasureSpec) {
|
||||
if (Build.VERSION.SDK_INT >= 30 && Math.abs(getResources().getConfiguration().fontScale - 1f) > 0.01f) {
|
||||
if (Build.VERSION.SDK_INT >= 30 && getLetterSpacing() > 0) {
|
||||
CharSequence text = getText();
|
||||
if (text != null) {
|
||||
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
|
||||
@@ -213,7 +218,7 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ((Spanned) text).nextSpanTransition(-1, text.length(), MetricAffectingSpan.class) != text.length();
|
||||
return ((Spanned) text).nextSpanTransition(-1, text.length(), CharacterStyle.class) != text.length();
|
||||
}
|
||||
|
||||
public int getLastLineWidth() {
|
||||
@@ -269,12 +274,7 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
}
|
||||
|
||||
private void ellipsizeEmojiTextForMaxLines() {
|
||||
post(() -> {
|
||||
if (getLayout() == null) {
|
||||
ellipsizeEmojiTextForMaxLines();
|
||||
return;
|
||||
}
|
||||
|
||||
Runnable ellipsize = () -> {
|
||||
int maxLines = TextViewCompat.getMaxLines(EmojiTextView.this);
|
||||
if (maxLines <= 0 && maxLength < 0) {
|
||||
return;
|
||||
@@ -282,10 +282,11 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
|
||||
int lineCount = getLineCount();
|
||||
if (lineCount > maxLines) {
|
||||
int overflowStart = getLayout().getLineStart(maxLines - 1);
|
||||
CharSequence overflow = getText().subSequence(overflowStart, getText().length());
|
||||
float adjust = overflowText != null ? getPaint().measureText(overflowText, 0, overflowText.length()) : 0f;
|
||||
CharSequence ellipsized = TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END);
|
||||
int overflowStart = getLayout().getLineStart(maxLines - 1);
|
||||
int overflowEnd = getLayout().getLineEnd(maxLines - 1);
|
||||
CharSequence overflow = getText().subSequence(overflowStart, overflowEnd);
|
||||
float adjust = overflowText != null ? getPaint().measureText(overflowText, 0, overflowText.length()) : 0f;
|
||||
CharSequence ellipsized = TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END);
|
||||
|
||||
SpannableStringBuilder newContent = new SpannableStringBuilder();
|
||||
newContent.append(getText().subSequence(0, overflowStart))
|
||||
@@ -297,7 +298,16 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
|
||||
super.setText(emojified, BufferType.SPANNABLE);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (getLayout() != null) {
|
||||
ellipsize.run();
|
||||
} else {
|
||||
ViewKt.doOnPreDraw(this, view -> {
|
||||
ellipsize.run();
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private boolean unchanged(CharSequence text, CharSequence overflowText, BufferType bufferType) {
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
package org.thoughtcrime.securesms.components.emoji.parsing
|
||||
|
||||
import org.thoughtcrime.securesms.emoji.EmojiPage
|
||||
import org.thoughtcrime.securesms.util.Hex
|
||||
import java.nio.charset.Charset
|
||||
|
||||
data class EmojiDrawInfo(val page: EmojiPage, val index: Int, private val emoji: String) {
|
||||
val rawEmoji: String
|
||||
get() {
|
||||
val emojiBytes: ByteArray = emoji.toByteArray(Charset.forName("UTF-16"))
|
||||
return Hex.toStringCondensed(emojiBytes.slice(2 until emojiBytes.size).toByteArray())
|
||||
}
|
||||
}
|
||||
data class EmojiDrawInfo(val page: EmojiPage, val index: Int, private val emoji: String, val rawEmoji: String?, val jumboSheet: String?)
|
||||
|
||||
@@ -24,6 +24,8 @@ package org.thoughtcrime.securesms.components.emoji.parsing;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.emoji.JumboEmoji;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
@@ -127,6 +129,15 @@ public class EmojiParser {
|
||||
return list.size();
|
||||
}
|
||||
|
||||
public boolean hasJumboForAll() {
|
||||
for (Candidate candidate : list) {
|
||||
if (!JumboEmoji.hasJumboEmoji(candidate.drawInfo)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Iterator<Candidate> iterator() {
|
||||
return list.iterator();
|
||||
|
||||
@@ -9,7 +9,7 @@ import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
|
||||
import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore;
|
||||
import org.thoughtcrime.securesms.crypto.storage.SignalIdentityKeyStore;
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
@@ -40,7 +40,7 @@ public class UntrustedSendDialog extends AlertDialog.Builder implements DialogIn
|
||||
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which) {
|
||||
final TextSecureIdentityKeyStore identityStore = ApplicationDependencies.getIdentityStore();
|
||||
final SignalIdentityKeyStore identityStore = ApplicationDependencies.getProtocolStore().aci().identities();
|
||||
|
||||
SimpleTask.run(() -> {
|
||||
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
|
||||
|
||||
@@ -43,9 +43,9 @@ public class UnverifiedSendDialog extends AlertDialog.Builder implements DialogI
|
||||
SimpleTask.run(() -> {
|
||||
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
|
||||
for (IdentityRecord identityRecord : untrustedRecords) {
|
||||
ApplicationDependencies.getIdentityStore().setVerified(identityRecord.getRecipientId(),
|
||||
identityRecord.getIdentityKey(),
|
||||
IdentityDatabase.VerifiedStatus.DEFAULT);
|
||||
ApplicationDependencies.getProtocolStore().aci().identities().setVerified(identityRecord.getRecipientId(),
|
||||
identityRecord.getIdentityKey(),
|
||||
IdentityDatabase.VerifiedStatus.DEFAULT);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package org.thoughtcrime.securesms.components.menu
|
||||
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
|
||||
/**
|
||||
* Handles the setup and display of actions shown in a context menu.
|
||||
*/
|
||||
class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
|
||||
|
||||
private val mappingAdapter = MappingAdapter().apply {
|
||||
registerFactory(DisplayItem::class.java, LayoutFactory({ ItemViewHolder(it, onItemClick) }, R.layout.signal_context_menu_item))
|
||||
}
|
||||
|
||||
init {
|
||||
recyclerView.apply {
|
||||
adapter = mappingAdapter
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
itemAnimator = null
|
||||
}
|
||||
}
|
||||
|
||||
fun setItems(items: List<ActionItem>) {
|
||||
mappingAdapter.submitList(items.toAdapterItems())
|
||||
}
|
||||
|
||||
private fun List<ActionItem>.toAdapterItems(): List<DisplayItem> {
|
||||
return this.mapIndexed { index, item ->
|
||||
val displayType: DisplayType = when {
|
||||
this.size == 1 -> DisplayType.ONLY
|
||||
index == 0 -> DisplayType.TOP
|
||||
index == this.size - 1 -> DisplayType.BOTTOM
|
||||
else -> DisplayType.MIDDLE
|
||||
}
|
||||
|
||||
DisplayItem(item, displayType)
|
||||
}
|
||||
}
|
||||
|
||||
private data class DisplayItem(
|
||||
val item: ActionItem,
|
||||
val displayType: DisplayType
|
||||
) : MappingModel<DisplayItem> {
|
||||
override fun areItemsTheSame(newItem: DisplayItem): Boolean {
|
||||
return this == newItem
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: DisplayItem): Boolean {
|
||||
return this == newItem
|
||||
}
|
||||
}
|
||||
|
||||
private enum class DisplayType {
|
||||
TOP, BOTTOM, MIDDLE, ONLY
|
||||
}
|
||||
|
||||
private class ItemViewHolder(
|
||||
itemView: View,
|
||||
private val onItemClick: () -> Unit,
|
||||
) : MappingViewHolder<DisplayItem>(itemView) {
|
||||
val icon: ImageView = itemView.findViewById(R.id.signal_context_menu_item_icon)
|
||||
val title: TextView = itemView.findViewById(R.id.signal_context_menu_item_title)
|
||||
|
||||
override fun bind(model: DisplayItem) {
|
||||
icon.setImageResource(model.item.iconRes)
|
||||
title.text = model.item.title
|
||||
itemView.setOnClickListener {
|
||||
model.item.action.run()
|
||||
onItemClick()
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 21) {
|
||||
when (model.displayType) {
|
||||
DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_top)
|
||||
DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_bottom)
|
||||
DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_middle)
|
||||
DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_only)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,18 +6,10 @@ import android.os.Build
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.PopupWindow
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.Factory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
|
||||
/**
|
||||
* A custom context menu that will show next to an anchor view and display several options. Basically a PopupMenu with custom UI and positioning rules.
|
||||
@@ -42,9 +34,10 @@ class SignalContextMenu private constructor(
|
||||
|
||||
val context: Context = anchor.context
|
||||
|
||||
val mappingAdapter = MappingAdapter().apply {
|
||||
registerFactory(DisplayItem::class.java, ItemViewHolderFactory())
|
||||
}
|
||||
private val contextMenuList = ContextMenuList(
|
||||
recyclerView = contentView.findViewById(R.id.signal_context_menu_list),
|
||||
onItemClick = { dismiss() },
|
||||
)
|
||||
|
||||
init {
|
||||
setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.signal_context_menu_background))
|
||||
@@ -59,13 +52,7 @@ class SignalContextMenu private constructor(
|
||||
elevation = 20f
|
||||
}
|
||||
|
||||
contentView.findViewById<RecyclerView>(R.id.signal_context_menu_list).apply {
|
||||
adapter = mappingAdapter
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
itemAnimator = null
|
||||
}
|
||||
|
||||
mappingAdapter.submitList(items.toAdapterItems())
|
||||
contextMenuList.setItems(items)
|
||||
}
|
||||
|
||||
private fun show() {
|
||||
@@ -97,7 +84,7 @@ class SignalContextMenu private constructor(
|
||||
offsetY = baseOffsetY
|
||||
} else if (menuTopBound > screenTopBound) {
|
||||
offsetY = -(anchorRect.height() + contentView.measuredHeight + baseOffsetY)
|
||||
mappingAdapter.submitList(items.reversed().toAdapterItems())
|
||||
contextMenuList.setItems(items.reversed())
|
||||
} else {
|
||||
offsetY = -((anchorRect.height() / 2) + (contentView.measuredHeight / 2) + baseOffsetY)
|
||||
}
|
||||
@@ -122,65 +109,6 @@ class SignalContextMenu private constructor(
|
||||
showAsDropDown(anchor, offsetX, offsetY)
|
||||
}
|
||||
|
||||
private fun List<ActionItem>.toAdapterItems(): List<DisplayItem> {
|
||||
return this.mapIndexed { index, item ->
|
||||
val displayType: DisplayType = when {
|
||||
this.size == 1 -> DisplayType.ONLY
|
||||
index == 0 -> DisplayType.TOP
|
||||
index == this.size - 1 -> DisplayType.BOTTOM
|
||||
else -> DisplayType.MIDDLE
|
||||
}
|
||||
|
||||
DisplayItem(item, displayType)
|
||||
}
|
||||
}
|
||||
|
||||
private data class DisplayItem(
|
||||
val item: ActionItem,
|
||||
val displayType: DisplayType
|
||||
) : MappingModel<DisplayItem> {
|
||||
override fun areItemsTheSame(newItem: DisplayItem): Boolean {
|
||||
return this == newItem
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: DisplayItem): Boolean {
|
||||
return this == newItem
|
||||
}
|
||||
}
|
||||
|
||||
private enum class DisplayType {
|
||||
TOP, BOTTOM, MIDDLE, ONLY
|
||||
}
|
||||
|
||||
private inner class ItemViewHolder(itemView: View) : MappingViewHolder<DisplayItem>(itemView) {
|
||||
val icon: ImageView = itemView.findViewById(R.id.signal_context_menu_item_icon)
|
||||
val title: TextView = itemView.findViewById(R.id.signal_context_menu_item_title)
|
||||
|
||||
override fun bind(model: DisplayItem) {
|
||||
icon.setImageResource(model.item.iconRes)
|
||||
title.text = model.item.title
|
||||
itemView.setOnClickListener {
|
||||
model.item.action.run()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 21) {
|
||||
when (model.displayType) {
|
||||
DisplayType.TOP -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_top)
|
||||
DisplayType.BOTTOM -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_bottom)
|
||||
DisplayType.MIDDLE -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_middle)
|
||||
DisplayType.ONLY -> itemView.setBackgroundResource(R.drawable.signal_context_menu_item_background_only)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ItemViewHolderFactory : Factory<DisplayItem> {
|
||||
override fun createViewHolder(parent: ViewGroup): MappingViewHolder<DisplayItem> {
|
||||
return ItemViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.signal_context_menu_item, parent, false))
|
||||
}
|
||||
}
|
||||
|
||||
enum class HorizontalPosition {
|
||||
START, END
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ abstract class DSLSettingsFragment(
|
||||
@StringRes private val titleId: Int = -1,
|
||||
@MenuRes private val menuId: Int = -1,
|
||||
@LayoutRes layoutId: Int = R.layout.dsl_settings_fragment,
|
||||
val layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) }
|
||||
protected var layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) }
|
||||
) : Fragment(layoutId) {
|
||||
|
||||
private var recyclerView: RecyclerView? = null
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.components.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.StyleRes
|
||||
@@ -81,4 +82,17 @@ sealed class DSLSettingsText {
|
||||
return SpanUtil.bold(charSequence)
|
||||
}
|
||||
}
|
||||
|
||||
class LearnMoreModifier(
|
||||
@ColorInt private val learnMoreColor: Int,
|
||||
val onClick: () -> Unit
|
||||
) : Modifier {
|
||||
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
|
||||
return SpannableStringBuilder(charSequence).append(" ").append(
|
||||
SpanUtil.learnMore(context, learnMoreColor) {
|
||||
onClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.navigation.NavDirections
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import io.reactivex.rxjava3.subjects.Subject
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
@@ -15,6 +16,8 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.DonationP
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SettingsValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService
|
||||
import org.thoughtcrime.securesms.util.CachedInflater
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
@@ -24,6 +27,7 @@ private const val START_LOCATION = "app.settings.start.location"
|
||||
private const val START_ARGUMENTS = "app.settings.start.arguments"
|
||||
private const val NOTIFICATION_CATEGORY = "android.intent.category.NOTIFICATION_PREFERENCES"
|
||||
private const val STATE_WAS_CONFIGURATION_UPDATED = "app.settings.state.configuration.updated"
|
||||
private const val EXTRA_PERFORM_ACTION_ON_CREATE = "extra_perform_action_on_create"
|
||||
|
||||
class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
|
||||
|
||||
@@ -82,6 +86,17 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
|
||||
startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
when (intent.getStringExtra(EXTRA_PERFORM_ACTION_ON_CREATE)) {
|
||||
ACTION_CHANGE_NUMBER_SUCCESS -> {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setMessage(getString(R.string.ChangeNumber__your_phone_number_has_changed_to_s, PhoneNumberFormatter.prettyPrint(Recipient.self().requireE164())))
|
||||
.setPositiveButton(R.string.ChangeNumber__okay, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
@@ -109,8 +124,14 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_CHANGE_NUMBER_SUCCESS = "action_change_number_success"
|
||||
|
||||
@JvmStatic
|
||||
fun home(context: Context): Intent = getIntentForStartLocation(context, StartLocation.HOME)
|
||||
@JvmOverloads
|
||||
fun home(context: Context, action: String? = null): Intent {
|
||||
return getIntentForStartLocation(context, StartLocation.HOME)
|
||||
.putExtra(EXTRA_PERFORM_ACTION_ON_CREATE, action)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun backups(context: Context): Intent = getIntentForStartLocation(context, StartLocation.BACKUPS)
|
||||
|
||||
@@ -107,7 +107,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
|
||||
|
||||
sectionHeaderPref(R.string.AccountSettingsFragment__account)
|
||||
|
||||
if (FeatureFlags.changeNumber() && Recipient.self().changeNumberCapability == Recipient.Capability.SUPPORTED) {
|
||||
if (FeatureFlags.changeNumber() && Recipient.self().changeNumberCapability == Recipient.Capability.SUPPORTED && SignalStore.account().isRegistered) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.AccountSettingsFragment__change_phone_number),
|
||||
onClick = {
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import java.util.Objects
|
||||
|
||||
private val TAG: String = Log.tag(ChangeNumberLockActivity::class.java)
|
||||
@@ -57,7 +58,7 @@ class ChangeNumberLockActivity : PassphraseRequiredActivity() {
|
||||
Single.just(false)
|
||||
} else {
|
||||
Log.i(TAG, "Local (${SignalStore.account().e164}) and remote (${whoAmI.number}) numbers do not match, updating local.")
|
||||
changeNumberRepository.changeLocalNumber(whoAmI.number)
|
||||
changeNumberRepository.changeLocalNumber(whoAmI.number, PNI.parseOrThrow(whoAmI.pni))
|
||||
.map { true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,17 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.pin.KbsRepository
|
||||
import org.thoughtcrime.securesms.pin.KeyBackupSystemWrongPinException
|
||||
import org.thoughtcrime.securesms.pin.TokenData
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.whispersystems.signalservice.api.KbsPinData
|
||||
import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
|
||||
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
|
||||
private val TAG: String = Log.tag(ChangeNumberRepository::class.java)
|
||||
|
||||
@@ -61,10 +65,26 @@ class ChangeNumberRepository(private val context: Context) {
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun changeLocalNumber(e164: String): Single<Unit> {
|
||||
fun changeLocalNumber(e164: String, pni: PNI): Single<Unit> {
|
||||
val oldStorageId: ByteArray? = Recipient.self().storageServiceId
|
||||
SignalDatabase.recipients.updateSelfPhone(e164)
|
||||
val newStorageId: ByteArray? = Recipient.self().storageServiceId
|
||||
|
||||
if (MessageDigest.isEqual(oldStorageId, newStorageId)) {
|
||||
Log.w(TAG, "Self storage id was not rotated, attempting to rotate again")
|
||||
SignalDatabase.recipients.rotateStorageId(Recipient.self().id)
|
||||
Recipient.self().live().refresh()
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
val secondAttemptStorageId: ByteArray? = Recipient.self().storageServiceId
|
||||
if (MessageDigest.isEqual(oldStorageId, secondAttemptStorageId)) {
|
||||
Log.w(TAG, "Second attempt also failed to rotate storage id")
|
||||
}
|
||||
}
|
||||
|
||||
SignalDatabase.recipients.setPni(Recipient.self().id, pni)
|
||||
|
||||
SignalStore.account().setE164(e164)
|
||||
SignalStore.account().setPni(pni)
|
||||
|
||||
ApplicationDependencies.closeConnections()
|
||||
ApplicationDependencies.getIncomingMessageObserver()
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.registration.fragments.CaptchaFragment
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Helpers for various aspects of the change number flow.
|
||||
@@ -36,7 +34,7 @@ object ChangeNumberUtil {
|
||||
}
|
||||
|
||||
fun Fragment.changeNumberSuccess() {
|
||||
findNavController().safeNavigate(R.id.action_pop_app_settings_change_number)
|
||||
Toast.makeText(requireContext(), R.string.ChangeNumber__your_phone_number_has_been_changed, Toast.LENGTH_SHORT).show()
|
||||
requireActivity().finish()
|
||||
requireActivity().startActivity(AppSettingsActivity.home(requireContext(), AppSettingsActivity.ACTION_CHANGE_NUMBER_SUCCESS))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.registration.VerifyProcessor
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
|
||||
import java.util.Objects
|
||||
@@ -145,7 +146,7 @@ class ChangeNumberViewModel(
|
||||
|
||||
@WorkerThread
|
||||
override fun onVerifySuccess(processor: VerifyAccountResponseProcessor): Single<VerifyAccountResponseProcessor> {
|
||||
return changeNumberRepository.changeLocalNumber(number.e164Number)
|
||||
return changeNumberRepository.changeLocalNumber(number.e164Number, PNI.parseOrThrow(processor.result.pni))
|
||||
.map { processor }
|
||||
.onErrorReturn { t ->
|
||||
Log.w(TAG, "Error attempting to change local number", t)
|
||||
@@ -154,7 +155,7 @@ class ChangeNumberViewModel(
|
||||
}
|
||||
|
||||
override fun onVerifySuccessWithRegistrationLock(processor: VerifyCodeWithRegistrationLockResponseProcessor, pin: String): Single<VerifyCodeWithRegistrationLockResponseProcessor> {
|
||||
return changeNumberRepository.changeLocalNumber(number.e164Number)
|
||||
return changeNumberRepository.changeLocalNumber(number.e164Number, PNI.parseOrThrow(processor.result.verifyAccountResponse.pni))
|
||||
.map { processor }
|
||||
.onErrorReturn { t ->
|
||||
Log.w(TAG, "Error attempting to change local number", t)
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
|
||||
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
|
||||
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
|
||||
import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveReleaseChannelJob
|
||||
import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob
|
||||
import org.thoughtcrime.securesms.jobs.StorageForcePushJob
|
||||
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
|
||||
@@ -31,6 +32,7 @@ import org.thoughtcrime.securesms.payments.DataExportUtil
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask
|
||||
import kotlin.math.max
|
||||
|
||||
class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__internal_preferences) {
|
||||
|
||||
@@ -333,9 +335,9 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
if (FeatureFlags.donorBadges() && SignalStore.donationsValues().getSubscriber() != null) {
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.preferences__internal_badges)
|
||||
|
||||
clickPref(
|
||||
@@ -345,6 +347,25 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.preferences__internal_release_channel)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_fetch_release_channel),
|
||||
onClick = {
|
||||
SignalStore.releaseChannelValues().previousManifestMd5 = ByteArray(0)
|
||||
RetrieveReleaseChannelJob.enqueue(force = true)
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_release_channel_set_last_version),
|
||||
onClick = {
|
||||
SignalStore.releaseChannelValues().highestVersionNoteReceived = max(SignalStore.releaseChannelValues().highestVersionNoteReceived - 10, 0)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.Intent
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.media.Ringtone
|
||||
import android.media.RingtoneManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
@@ -17,6 +18,7 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.preference.PreferenceManager
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
@@ -36,6 +38,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
private const val MESSAGE_SOUND_SELECT: Int = 1
|
||||
private const val CALL_RINGTONE_SELECT: Int = 2
|
||||
private val TAG = Log.tag(NotificationsSettingsFragment::class.java)
|
||||
|
||||
class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__notifications) {
|
||||
|
||||
@@ -248,9 +251,14 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
|
||||
return if (TextUtils.isEmpty(uri.toString())) {
|
||||
getString(R.string.preferences__silent)
|
||||
} else {
|
||||
val tone = RingtoneUtil.getRingtone(requireContext(), uri)
|
||||
val tone: Ringtone? = RingtoneUtil.getRingtone(requireContext(), uri)
|
||||
if (tone != null) {
|
||||
tone.getTitle(requireContext()) ?: getString(R.string.NotificationsSettingsFragment__unknown_ringtone)
|
||||
try {
|
||||
tone.getTitle(requireContext()) ?: getString(R.string.NotificationsSettingsFragment__unknown_ringtone)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Unable to get title for ringtone", e)
|
||||
return getString(R.string.NotificationsSettingsFragment__unknown_ringtone)
|
||||
}
|
||||
} else {
|
||||
getString(R.string.preferences__default)
|
||||
}
|
||||
|
||||
@@ -103,13 +103,13 @@ class ExpireTimerSettingsFragment : DSLSettingsFragment(
|
||||
val values: Array<Int> = resources.getIntArray(R.array.ExpireTimerSettingsFragment__values).toTypedArray()
|
||||
|
||||
var hasCustomValue = true
|
||||
labels.zip(values).forEach { (label, value) ->
|
||||
labels.zip(values).forEach { (label, seconds) ->
|
||||
radioPref(
|
||||
title = DSLSettingsText.from(label),
|
||||
isChecked = state.currentTimer == value,
|
||||
onClick = { viewModel.select(value) }
|
||||
isChecked = state.currentTimer == seconds,
|
||||
onClick = { viewModel.select(seconds) }
|
||||
)
|
||||
hasCustomValue = hasCustomValue && state.currentTimer != value
|
||||
hasCustomValue = hasCustomValue && state.currentTimer != seconds
|
||||
}
|
||||
|
||||
radioPref(
|
||||
|
||||
@@ -8,10 +8,12 @@ import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import org.thoughtcrime.securesms.groups.GroupChangeException
|
||||
import org.thoughtcrime.securesms.groups.GroupManager
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.OutgoingExpirationUpdateMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sms.MessageSender
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import java.io.IOException
|
||||
|
||||
private val TAG: String = Log.tag(ExpireTimerSettingsRepository::class.java)
|
||||
@@ -44,6 +46,15 @@ class ExpireTimerSettingsRepository(val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun setUniversalExpireTimerSeconds(newExpirationTime: Int, onDone: () -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
SignalStore.settings().universalExpireTimer = newExpirationTime
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
onDone.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun getThreadId(recipientId: RecipientId): Long {
|
||||
val threadDatabase: ThreadDatabase = SignalDatabase.threads
|
||||
|
||||
@@ -45,8 +45,9 @@ class ExpireTimerSettingsViewModel(val config: Config, private val repository: E
|
||||
} else if (config.forResultMode) {
|
||||
store.update { it.copy(saveState = ProcessState.Success(userSetTimer)) }
|
||||
} else {
|
||||
SignalStore.settings().universalExpireTimer = userSetTimer
|
||||
store.update { it.copy(saveState = ProcessState.Success(userSetTimer)) }
|
||||
repository.setUniversalExpireTimerSeconds(userSetTimer) {
|
||||
store.update { it.copy(saveState = ProcessState.Success(userSetTimer)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,7 @@ import org.thoughtcrime.securesms.badges.models.Badge
|
||||
* Events that can arise from use of the donations apis.
|
||||
*/
|
||||
sealed class DonationEvent {
|
||||
class GooglePayUnavailableError(val throwable: Throwable) : DonationEvent()
|
||||
object RequestTokenSuccess : DonationEvent()
|
||||
class RequestTokenError(val throwable: Throwable) : DonationEvent()
|
||||
class PaymentConfirmationError(val throwable: Throwable) : DonationEvent()
|
||||
class PaymentConfirmationSuccess(val badge: Badge) : DonationEvent()
|
||||
class SubscriptionCancellationFailed(val throwable: Throwable) : DonationEvent()
|
||||
object SubscriptionCancelled : DonationEvent()
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
class DonationExceptions {
|
||||
class SetupFailed(reason: Throwable) : Exception(reason)
|
||||
object TimedOutWaitingForTokenRedemption : Exception()
|
||||
object RedemptionFailed : Exception()
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import org.signal.donations.GooglePayApi
|
||||
import org.signal.donations.GooglePayPaymentSource
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
@@ -56,8 +58,6 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION)
|
||||
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient())
|
||||
|
||||
fun isGooglePayAvailable(): Completable = googlePayApi.queryIsReadyToPay()
|
||||
|
||||
fun scheduleSyncForAccountRecordChange() {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
scheduleSyncForAccountRecordChangeSync()
|
||||
@@ -88,13 +88,13 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||
fun continuePayment(price: FiatMoney, paymentData: PaymentData): Completable {
|
||||
Log.d(TAG, "Creating payment intent for $price...", true)
|
||||
return stripeApi.createPaymentIntent(price, application.getString(R.string.Boost__thank_you_for_your_donation))
|
||||
.onErrorResumeNext { Single.error(DonationExceptions.SetupFailed(it)) }
|
||||
.onErrorResumeNext { Single.error(DonationError.getPaymentSetupError(DonationErrorSource.BOOST, it)) }
|
||||
.flatMapCompletable { result ->
|
||||
Log.d(TAG, "Created payment intent for $price.", true)
|
||||
when (result) {
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationExceptions.SetupFailed(Exception("Boost amount is too small")))
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationExceptions.SetupFailed(Exception("Boost amount is too large")))
|
||||
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationExceptions.SetupFailed(Exception("Boost currency is not supported")))
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Completable.error(DonationError.boostAmountTooSmall())
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Completable.error(DonationError.boostAmountTooLarge())
|
||||
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Completable.error(DonationError.invalidCurrencyForBoost())
|
||||
is StripeApi.CreatePaymentIntentResult.Success -> confirmPayment(paymentData, result.paymentIntent)
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,10 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||
|
||||
private fun confirmPayment(paymentData: PaymentData, paymentIntent: StripeApi.PaymentIntent): Completable {
|
||||
Log.d(TAG, "Confirming payment intent...", true)
|
||||
val confirmPayment = stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), paymentIntent)
|
||||
val confirmPayment = stripeApi.confirmPaymentIntent(GooglePayPaymentSource(paymentData), paymentIntent).onErrorResumeNext {
|
||||
Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.BOOST, it))
|
||||
}
|
||||
|
||||
val waitOnRedemption = Completable.create {
|
||||
Log.d(TAG, "Confirmed payment intent.", true)
|
||||
|
||||
@@ -164,20 +167,20 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||
}
|
||||
JobTracker.JobState.FAILURE -> {
|
||||
Log.d(TAG, "Boost request response job chain failed permanently.", true)
|
||||
it.onError(DonationExceptions.RedemptionFailed)
|
||||
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST))
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Boost request response job chain ignored due to in-progress jobs.", true)
|
||||
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Boost redemption timed out waiting for job completion.", true)
|
||||
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST))
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
Log.d(TAG, "Boost redemption job interrupted", e, true)
|
||||
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.BOOST))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,20 +239,20 @@ class DonationPaymentRepository(activity: Activity) : StripeApi.PaymentIntentFet
|
||||
}
|
||||
JobTracker.JobState.FAILURE -> {
|
||||
Log.d(TAG, "Subscription request response job chain failed permanently.", true)
|
||||
it.onError(DonationExceptions.RedemptionFailed)
|
||||
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION))
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Subscription request response job chain ignored due to in-progress jobs.", true)
|
||||
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Subscription request response job timed out.", true)
|
||||
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
Log.w(TAG, "Subscription request response interrupted.", e, true)
|
||||
it.onError(DonationExceptions.TimedOutWaitingForTokenRedemption)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
|
||||
}
|
||||
}
|
||||
}.doOnError {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.boost
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
@@ -10,6 +11,7 @@ import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.airbnb.lottie.LottieAnimationView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -22,20 +24,22 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFrag
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationExceptions
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.Progress
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyboard.findListener
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil.requireCoordinatorLayout
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.Projection
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
@@ -63,6 +67,8 @@ class BoostFragment : DSLSettingsBottomSheetFragment(
|
||||
private lateinit var processingDonationPaymentDialog: AlertDialog
|
||||
private lateinit var donationPaymentComponent: DonationPaymentComponent
|
||||
|
||||
private var errorDialog: DialogInterface? = null
|
||||
|
||||
private val sayThanks: CharSequence by lazy {
|
||||
SpannableStringBuilder(requireContext().getString(R.string.BoostFragment__say_thanks_and_earn, 30))
|
||||
.append(" ")
|
||||
@@ -74,7 +80,7 @@ class BoostFragment : DSLSettingsBottomSheetFragment(
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
donationPaymentComponent = findListener()!!
|
||||
donationPaymentComponent = requireListener()
|
||||
viewModel.refresh()
|
||||
|
||||
CurrencySelection.register(adapter)
|
||||
@@ -118,10 +124,7 @@ class BoostFragment : DSLSettingsBottomSheetFragment(
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe { event: DonationEvent ->
|
||||
when (event) {
|
||||
is DonationEvent.GooglePayUnavailableError -> Unit
|
||||
is DonationEvent.PaymentConfirmationError -> onPaymentError(event.throwable)
|
||||
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(event.badge)
|
||||
is DonationEvent.RequestTokenError -> onPaymentError(DonationExceptions.SetupFailed(event.throwable))
|
||||
DonationEvent.RequestTokenSuccess -> Log.i(TAG, "Successfully got request token from Google Pay")
|
||||
DonationEvent.SubscriptionCancelled -> Unit
|
||||
is DonationEvent.SubscriptionCancellationFailed -> Unit
|
||||
@@ -130,6 +133,13 @@ class BoostFragment : DSLSettingsBottomSheetFragment(
|
||||
lifecycleDisposable += donationPaymentComponent.googlePayResultPublisher.subscribe {
|
||||
viewModel.onActivityResult(it.requestCode, it.resultCode, it.data)
|
||||
}
|
||||
|
||||
lifecycleDisposable += DonationError
|
||||
.getErrorsForSource(DonationErrorSource.BOOST)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { donationError ->
|
||||
onPaymentError(donationError)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
@@ -240,37 +250,21 @@ class BoostFragment : DSLSettingsBottomSheetFragment(
|
||||
}
|
||||
|
||||
private fun onPaymentError(throwable: Throwable?) {
|
||||
if (throwable is DonationExceptions.TimedOutWaitingForTokenRedemption) {
|
||||
Log.w(TAG, "Timed out while redeeming token", throwable, true)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__still_processing)
|
||||
.setMessage(R.string.DonationsErrors__your_payment_is_still)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
.show()
|
||||
} else if (throwable is DonationExceptions.SetupFailed) {
|
||||
Log.w(TAG, "Error occurred while processing payment", throwable, true)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__error_processing_payment)
|
||||
.setMessage(R.string.DonationsErrors__your_payment)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
Log.w(TAG, "Error occurred while trying to redeem token", throwable, true)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__couldnt_add_badge)
|
||||
.setMessage(R.string.DonationsErrors__your_badge_could_not)
|
||||
.setPositiveButton(R.string.Subscription__contact_support) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
.show()
|
||||
Log.w(TAG, "onPaymentError", throwable, true)
|
||||
|
||||
if (errorDialog != null) {
|
||||
Log.i(TAG, "Already displaying an error dialog. Skipping.")
|
||||
return
|
||||
}
|
||||
|
||||
errorDialog = DonationErrorDialogs.show(
|
||||
requireContext(), throwable,
|
||||
object : DonationErrorDialogs.DialogCallback() {
|
||||
override fun onDialogDismissed() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun startAnimationAboveSelectedBoost(view: View) {
|
||||
|
||||
@@ -18,12 +18,14 @@ import org.signal.donations.GooglePayApi
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.InternetConnectionObserver
|
||||
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
|
||||
import org.thoughtcrime.securesms.util.StringUtil
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import java.lang.NumberFormatException
|
||||
import java.math.BigDecimal
|
||||
import java.text.DecimalFormat
|
||||
import java.text.DecimalFormatSymbols
|
||||
@@ -108,11 +110,6 @@ class BoostViewModel(
|
||||
}
|
||||
)
|
||||
|
||||
disposables += donationPaymentRepository.isGooglePayAvailable().subscribeBy(
|
||||
onComplete = { store.update { it.copy(isGooglePayAvailable = true) } },
|
||||
onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) }
|
||||
)
|
||||
|
||||
disposables += currencyObservable.subscribeBy { currency ->
|
||||
store.update {
|
||||
it.copy(
|
||||
@@ -146,7 +143,13 @@ class BoostViewModel(
|
||||
donationPaymentRepository.continuePayment(boost.price, paymentData).subscribeBy(
|
||||
onError = { throwable ->
|
||||
store.update { it.copy(stage = BoostState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
|
||||
val donationError: DonationError = if (throwable is DonationError) {
|
||||
throwable
|
||||
} else {
|
||||
Log.w(TAG, "Failed to complete payment or redemption", throwable, true)
|
||||
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST)
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
},
|
||||
onComplete = {
|
||||
store.update { it.copy(stage = BoostState.Stage.READY) }
|
||||
@@ -160,7 +163,7 @@ class BoostViewModel(
|
||||
|
||||
override fun onError(googlePayException: GooglePayApi.GooglePayException) {
|
||||
store.update { it.copy(stage = BoostState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenError(googlePayException))
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), DonationError.getGooglePayRequestTokenError(DonationErrorSource.BOOST, googlePayException))
|
||||
}
|
||||
|
||||
override fun onCancelled() {
|
||||
|
||||
@@ -7,7 +7,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFrag
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.keyboard.findListener
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -25,7 +25,7 @@ class SetCurrencyFragment : DSLSettingsBottomSheetFragment() {
|
||||
)
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
donationPaymentComponent = findListener()!!
|
||||
donationPaymentComponent = requireListener()
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
|
||||
|
||||
import android.content.Context
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import io.reactivex.rxjava3.subjects.Subject
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.StripeDeclineCode
|
||||
import org.signal.donations.StripeError
|
||||
|
||||
sealed class DonationError(val source: DonationErrorSource, cause: Throwable) : Exception(cause) {
|
||||
|
||||
/**
|
||||
* Google Pay errors, which happen well before a user would ever be charged.
|
||||
*/
|
||||
sealed class GooglePayError(source: DonationErrorSource, cause: Throwable) : DonationError(source, cause) {
|
||||
class NotAvailableError(source: DonationErrorSource, cause: Throwable) : GooglePayError(source, cause)
|
||||
class RequestTokenError(source: DonationErrorSource, cause: Throwable) : GooglePayError(source, cause)
|
||||
}
|
||||
|
||||
/**
|
||||
* Boost validation errors, which occur before the user could be charged.
|
||||
*/
|
||||
sealed class BoostError(message: String) : DonationError(DonationErrorSource.BOOST, Exception(message)) {
|
||||
object AmountTooSmallError : BoostError("Amount is too small")
|
||||
object AmountTooLargeError : BoostError("Amount is too large")
|
||||
object InvalidCurrencyError : BoostError("Currency is not supported")
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe setup errors, which occur before the user could be charged. These are either
|
||||
* payment processing handed to Stripe from the CC company (in the case of a Boost payment
|
||||
* intent confirmation error) or other generic error from Stripe.
|
||||
*/
|
||||
sealed class PaymentSetupError(source: DonationErrorSource, cause: Throwable) : DonationError(source, cause) {
|
||||
/**
|
||||
* Payment setup failed in some generic fashion.
|
||||
*/
|
||||
class GenericError(source: DonationErrorSource, cause: Throwable) : PaymentSetupError(source, cause)
|
||||
|
||||
/**
|
||||
* Payment setup failed in some way, which we are told about by Stripe.
|
||||
*/
|
||||
class CodedError(source: DonationErrorSource, cause: Throwable, val errorCode: String) : PaymentSetupError(source, cause)
|
||||
|
||||
/**
|
||||
* Payment failed by the credit card processor, with a specific reason told to us by Stripe.
|
||||
*/
|
||||
class DeclinedError(source: DonationErrorSource, cause: Throwable, val declineCode: StripeDeclineCode) : PaymentSetupError(source, cause)
|
||||
}
|
||||
|
||||
/**
|
||||
* Errors that can be thrown after we submit a payment to Stripe. It is
|
||||
* assumed that at this point, anything we submit *could* happen, so we can no
|
||||
* longer safely assume a user has not been charged. Payment errors explicitly
|
||||
* originate from Signal service.
|
||||
*/
|
||||
sealed class PaymentProcessingError(source: DonationErrorSource, cause: Throwable) : DonationError(source, cause) {
|
||||
class GenericError(source: DonationErrorSource) : DonationError(source, Exception("Generic Payment Error"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Errors that can occur during the badge redemption process.
|
||||
*/
|
||||
sealed class BadgeRedemptionError(source: DonationErrorSource, cause: Throwable) : DonationError(source, cause) {
|
||||
/**
|
||||
* Timeout elapsed while the user was waiting for badge redemption to complete. This is not an indication that
|
||||
* redemption failed, just that it is taking longer than we can reasonably show a spinner.
|
||||
*/
|
||||
class TimeoutWaitingForTokenError(source: DonationErrorSource) : BadgeRedemptionError(source, Exception("Timed out waiting for badge redemption to complete."))
|
||||
|
||||
/**
|
||||
* Some generic error not otherwise accounted for occurred during the redemption process.
|
||||
*/
|
||||
class GenericError(source: DonationErrorSource) : BadgeRedemptionError(source, Exception("Failed to add badge to account."))
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = Log.tag(DonationError::class.java)
|
||||
|
||||
private val donationErrorSubjectSourceMap: Map<DonationErrorSource, Subject<DonationError>> = DonationErrorSource.values().associate { source ->
|
||||
source to PublishSubject.create()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getErrorsForSource(donationErrorSource: DonationErrorSource): Observable<DonationError> {
|
||||
return donationErrorSubjectSourceMap[donationErrorSource]!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a given donation error, which will either pipe it out to an appropriate subject
|
||||
* or, if the subject has no observers, post it as a notification.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun routeDonationError(context: Context, error: DonationError) {
|
||||
val subject: Subject<DonationError> = donationErrorSubjectSourceMap[error.source]!!
|
||||
when {
|
||||
subject.hasObservers() -> {
|
||||
Log.i(TAG, "Routing donation error to subject ${error.source} dialog", error)
|
||||
subject.onNext(error)
|
||||
}
|
||||
else -> {
|
||||
Log.i(TAG, "Routing donation error to subject ${error.source} notification", error)
|
||||
DonationErrorNotifications.displayErrorNotification(context, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getGooglePayRequestTokenError(source: DonationErrorSource, throwable: Throwable): DonationError {
|
||||
return GooglePayError.RequestTokenError(source, throwable)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a throwable into a payment setup error. This should only be used when
|
||||
* handling errors handed back via the Stripe API, when we know for sure that no
|
||||
* charge has occurred.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getPaymentSetupError(source: DonationErrorSource, throwable: Throwable): DonationError {
|
||||
return if (throwable is StripeError.PostError) {
|
||||
val declineCode: StripeDeclineCode? = throwable.declineCode
|
||||
val errorCode: String? = throwable.errorCode
|
||||
|
||||
when {
|
||||
declineCode != null -> PaymentSetupError.DeclinedError(source, throwable, declineCode)
|
||||
errorCode != null -> PaymentSetupError.CodedError(source, throwable, errorCode)
|
||||
else -> PaymentSetupError.GenericError(source, throwable)
|
||||
}
|
||||
} else {
|
||||
PaymentSetupError.GenericError(source, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun boostAmountTooSmall(): DonationError = BoostError.AmountTooSmallError
|
||||
|
||||
@JvmStatic
|
||||
fun boostAmountTooLarge(): DonationError = BoostError.AmountTooLargeError
|
||||
|
||||
@JvmStatic
|
||||
fun invalidCurrencyForBoost(): DonationError = BoostError.InvalidCurrencyError
|
||||
|
||||
@JvmStatic
|
||||
fun timeoutWaitingForToken(source: DonationErrorSource): DonationError = BadgeRedemptionError.TimeoutWaitingForTokenError(source)
|
||||
|
||||
@JvmStatic
|
||||
fun genericBadgeRedemptionFailure(source: DonationErrorSource): DonationError = BadgeRedemptionError.GenericError(source)
|
||||
|
||||
@JvmStatic
|
||||
fun genericPaymentFailure(source: DonationErrorSource): DonationError = PaymentProcessingError.GenericError(source)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
|
||||
/**
|
||||
* Donation Error Dialogs.
|
||||
*/
|
||||
object DonationErrorDialogs {
|
||||
/**
|
||||
* Displays a dialog, and returns a handle to it for dismissal.
|
||||
*/
|
||||
fun show(context: Context, throwable: Throwable?, callback: DialogCallback): DialogInterface {
|
||||
val builder = MaterialAlertDialogBuilder(context)
|
||||
|
||||
builder.setOnDismissListener { callback.onDialogDismissed() }
|
||||
|
||||
val params = DonationErrorParams.create(context, throwable, callback)
|
||||
|
||||
if (params.title != null) {
|
||||
builder.setTitle(params.title)
|
||||
}
|
||||
|
||||
if (params.message != null) {
|
||||
builder.setMessage(params.message)
|
||||
}
|
||||
|
||||
if (params.positiveAction != null) {
|
||||
builder.setPositiveButton(params.positiveAction.label) { _, _ -> params.positiveAction.action() }
|
||||
}
|
||||
|
||||
if (params.negativeAction != null) {
|
||||
builder.setNegativeButton(params.negativeAction.label) { _, _ -> params.negativeAction.action() }
|
||||
}
|
||||
|
||||
return builder.show()
|
||||
}
|
||||
|
||||
open class DialogCallback : DonationErrorParams.Callback<Unit> {
|
||||
|
||||
override fun onCancel(context: Context): DonationErrorParams.ErrorAction<Unit>? {
|
||||
return DonationErrorParams.ErrorAction(
|
||||
label = android.R.string.cancel,
|
||||
action = {}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onOk(context: Context): DonationErrorParams.ErrorAction<Unit>? {
|
||||
return DonationErrorParams.ErrorAction(
|
||||
label = android.R.string.ok,
|
||||
action = {}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onGoToGooglePay(context: Context): DonationErrorParams.ErrorAction<Unit>? {
|
||||
return DonationErrorParams.ErrorAction(
|
||||
label = R.string.DeclineCode__go_to_google_pay,
|
||||
action = {
|
||||
CommunicationActions.openBrowserLink(context, context.getString(R.string.google_pay_url))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onLearnMore(context: Context): DonationErrorParams.ErrorAction<Unit>? {
|
||||
return DonationErrorParams.ErrorAction(
|
||||
label = R.string.DeclineCode__learn_more,
|
||||
action = {
|
||||
CommunicationActions.openBrowserLink(context, context.getString(R.string.donation_decline_code_error_url))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onContactSupport(context: Context): DonationErrorParams.ErrorAction<Unit> {
|
||||
return DonationErrorParams.ErrorAction(
|
||||
label = R.string.Subscription__contact_support,
|
||||
action = {
|
||||
context.startActivity(AppSettingsActivity.help(context, HelpFragment.DONATION_INDEX))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
open fun onDialogDismissed() = Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.notifications.NotificationIds
|
||||
|
||||
/**
|
||||
* Donation-related push notifications.
|
||||
*/
|
||||
object DonationErrorNotifications {
|
||||
fun displayErrorNotification(context: Context, donationError: DonationError) {
|
||||
val parameters = DonationErrorParams.create(context, donationError, NotificationCallback)
|
||||
val notification = NotificationCompat.Builder(context, NotificationChannels.FAILURES)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(context.getString(parameters.title))
|
||||
.setContentText(context.getString(parameters.message)).apply {
|
||||
if (parameters.positiveAction != null) {
|
||||
addAction(context, parameters.positiveAction)
|
||||
}
|
||||
|
||||
if (parameters.negativeAction != null) {
|
||||
addAction(context, parameters.negativeAction)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
NotificationManagerCompat
|
||||
.from(context)
|
||||
.notify(NotificationIds.DONOR_BADGE_FAILURE, notification)
|
||||
}
|
||||
|
||||
private fun NotificationCompat.Builder.addAction(context: Context, errorAction: DonationErrorParams.ErrorAction<PendingIntent>) {
|
||||
addAction(
|
||||
NotificationCompat.Action.Builder(
|
||||
null,
|
||||
context.getString(errorAction.label),
|
||||
errorAction.action.invoke()
|
||||
).build()
|
||||
)
|
||||
}
|
||||
|
||||
private object NotificationCallback : DonationErrorParams.Callback<PendingIntent> {
|
||||
|
||||
override fun onCancel(context: Context): DonationErrorParams.ErrorAction<PendingIntent>? = null
|
||||
|
||||
override fun onOk(context: Context): DonationErrorParams.ErrorAction<PendingIntent>? = null
|
||||
|
||||
override fun onLearnMore(context: Context): DonationErrorParams.ErrorAction<PendingIntent> {
|
||||
return createAction(
|
||||
context = context,
|
||||
label = R.string.DeclineCode__learn_more,
|
||||
actionIntent = Intent(Intent.ACTION_VIEW, Uri.parse(context.getString(R.string.donation_decline_code_error_url)))
|
||||
)
|
||||
}
|
||||
|
||||
override fun onGoToGooglePay(context: Context): DonationErrorParams.ErrorAction<PendingIntent> {
|
||||
return createAction(
|
||||
context = context,
|
||||
label = R.string.DeclineCode__go_to_google_pay,
|
||||
actionIntent = Intent(Intent.ACTION_VIEW, Uri.parse(context.getString(R.string.google_pay_url)))
|
||||
)
|
||||
}
|
||||
|
||||
override fun onContactSupport(context: Context): DonationErrorParams.ErrorAction<PendingIntent> {
|
||||
return createAction(
|
||||
context = context,
|
||||
label = R.string.Subscription__contact_support,
|
||||
actionIntent = AppSettingsActivity.help(context, HelpFragment.DONATION_INDEX)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createAction(
|
||||
context: Context,
|
||||
label: Int,
|
||||
actionIntent: Intent
|
||||
): DonationErrorParams.ErrorAction<PendingIntent> {
|
||||
return DonationErrorParams.ErrorAction(
|
||||
label = label,
|
||||
action = {
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
actionIntent,
|
||||
if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_ONE_SHOT else 0
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import org.signal.donations.StripeDeclineCode
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
class DonationErrorParams<V> private constructor(
|
||||
@StringRes val title: Int,
|
||||
@StringRes val message: Int,
|
||||
val positiveAction: ErrorAction<V>?,
|
||||
val negativeAction: ErrorAction<V>?
|
||||
) {
|
||||
class ErrorAction<V>(
|
||||
@StringRes val label: Int,
|
||||
val action: () -> V
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun <V> create(
|
||||
context: Context,
|
||||
throwable: Throwable?,
|
||||
callback: Callback<V>
|
||||
): DonationErrorParams<V> {
|
||||
return when (throwable) {
|
||||
is DonationError.PaymentSetupError.DeclinedError -> getDeclinedErrorParams(context, throwable, callback)
|
||||
is DonationError.PaymentSetupError -> DonationErrorParams(
|
||||
title = R.string.DonationsErrors__error_processing_payment,
|
||||
message = R.string.DonationsErrors__your_payment,
|
||||
positiveAction = callback.onOk(context),
|
||||
negativeAction = null
|
||||
)
|
||||
is DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError -> DonationErrorParams(
|
||||
title = R.string.DonationsErrors__still_processing,
|
||||
message = R.string.DonationsErrors__your_payment_is_still,
|
||||
positiveAction = callback.onOk(context),
|
||||
negativeAction = null
|
||||
)
|
||||
else -> DonationErrorParams(
|
||||
title = R.string.DonationsErrors__couldnt_add_badge,
|
||||
message = R.string.DonationsErrors__your_badge_could_not,
|
||||
positiveAction = callback.onContactSupport(context),
|
||||
negativeAction = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <V> getDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.DeclinedError, callback: Callback<V>): DonationErrorParams<V> {
|
||||
return when (declinedError.declineCode) {
|
||||
is StripeDeclineCode.Known -> when (declinedError.declineCode.code) {
|
||||
StripeDeclineCode.Code.APPROVE_WITH_ID -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again)
|
||||
StripeDeclineCode.Code.CALL_ISSUER -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again_if_the_problem)
|
||||
StripeDeclineCode.Code.CARD_NOT_SUPPORTED -> getLearnMoreParams(context, callback, R.string.DeclineCode__your_card_does_not_support_this_type_of_purchase)
|
||||
StripeDeclineCode.Code.EXPIRED_CARD -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__your_card_has_expired)
|
||||
StripeDeclineCode.Code.INCORRECT_NUMBER -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__your_card_number_is_incorrect)
|
||||
StripeDeclineCode.Code.INCORRECT_CVC -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__your_cards_cvc_number_is_incorrect)
|
||||
StripeDeclineCode.Code.INSUFFICIENT_FUNDS -> getLearnMoreParams(context, callback, R.string.DeclineCode__your_card_does_not_have_sufficient_funds)
|
||||
StripeDeclineCode.Code.INVALID_CVC -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__your_cards_cvc_number_is_incorrect)
|
||||
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__the_expiration_month)
|
||||
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__the_expiration_year)
|
||||
StripeDeclineCode.Code.INVALID_NUMBER -> getGoToGooglePayParams(context, callback, R.string.DeclineCode__your_card_number_is_incorrect)
|
||||
StripeDeclineCode.Code.ISSUER_NOT_AVAILABLE -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_completing_the_payment_again)
|
||||
StripeDeclineCode.Code.PROCESSING_ERROR -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_again)
|
||||
StripeDeclineCode.Code.REENTER_TRANSACTION -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_again)
|
||||
else -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank)
|
||||
}
|
||||
else -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <V> getLearnMoreParams(context: Context, callback: Callback<V>, message: Int): DonationErrorParams<V> {
|
||||
return DonationErrorParams(
|
||||
title = R.string.DonationsErrors__error_processing_payment,
|
||||
message = message,
|
||||
positiveAction = callback.onOk(context),
|
||||
negativeAction = callback.onLearnMore(context)
|
||||
)
|
||||
}
|
||||
|
||||
private fun <V> getGoToGooglePayParams(context: Context, callback: Callback<V>, message: Int): DonationErrorParams<V> {
|
||||
return DonationErrorParams(
|
||||
title = R.string.DonationsErrors__error_processing_payment,
|
||||
message = message,
|
||||
positiveAction = callback.onGoToGooglePay(context),
|
||||
negativeAction = callback.onCancel(context)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback<V> {
|
||||
fun onOk(context: Context): ErrorAction<V>?
|
||||
fun onCancel(context: Context): ErrorAction<V>?
|
||||
fun onLearnMore(context: Context): ErrorAction<V>?
|
||||
fun onContactSupport(context: Context): ErrorAction<V>?
|
||||
fun onGoToGooglePay(context: Context): ErrorAction<V>?
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
|
||||
|
||||
enum class DonationErrorSource(private val code: String) {
|
||||
BOOST("boost"),
|
||||
SUBSCRIPTION("subscription"),
|
||||
KEEP_ALIVE("keep-alive"),
|
||||
UNKNOWN("unknown");
|
||||
|
||||
fun serialize(): String = code
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun deserialize(code: String): DonationErrorSource {
|
||||
return values().firstOrNull { it.code == code } ?: UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
|
||||
|
||||
/**
|
||||
* Error states that can occur if we detect that a user's subscription has been cancelled and the manual
|
||||
* cancellation flag is not set.
|
||||
*/
|
||||
enum class UnexpectedSubscriptionCancellation(val status: String) {
|
||||
PAST_DUE("past_due"),
|
||||
CANCELED("canceled"),
|
||||
UNPAID("unpaid"),
|
||||
INACTIVE("user-was-inactive");
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun fromStatus(status: String?): UnexpectedSubscriptionCancellation? {
|
||||
return values().firstOrNull { it.status == status }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.subscribe
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.graphics.Color
|
||||
import android.text.SpannableStringBuilder
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
@@ -8,6 +9,7 @@ import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
@@ -20,22 +22,22 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationExceptions
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.Progress
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.keyboard.findListener
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import java.util.Calendar
|
||||
import java.util.Currency
|
||||
@@ -63,6 +65,8 @@ class SubscribeFragment : DSLSettingsFragment(
|
||||
private lateinit var processingDonationPaymentDialog: AlertDialog
|
||||
private lateinit var donationPaymentComponent: DonationPaymentComponent
|
||||
|
||||
private var errorDialog: DialogInterface? = null
|
||||
|
||||
private val viewModel: SubscribeViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
SubscribeViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()), donationPaymentComponent.donationPaymentRepository, FETCH_SUBSCRIPTION_TOKEN_REQUEST_CODE)
|
||||
@@ -75,7 +79,7 @@ class SubscribeFragment : DSLSettingsFragment(
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
donationPaymentComponent = findListener()!!
|
||||
donationPaymentComponent = requireListener()
|
||||
viewModel.refresh()
|
||||
|
||||
BadgePreview.register(adapter)
|
||||
@@ -97,10 +101,7 @@ class SubscribeFragment : DSLSettingsFragment(
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe {
|
||||
when (it) {
|
||||
is DonationEvent.GooglePayUnavailableError -> Unit
|
||||
is DonationEvent.PaymentConfirmationError -> onPaymentError(it.throwable)
|
||||
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed(it.badge)
|
||||
is DonationEvent.RequestTokenError -> onPaymentError(DonationExceptions.SetupFailed(it.throwable))
|
||||
DonationEvent.RequestTokenSuccess -> Log.w(TAG, "Successfully got request token from Google Pay")
|
||||
DonationEvent.SubscriptionCancelled -> onSubscriptionCancelled()
|
||||
is DonationEvent.SubscriptionCancellationFailed -> onSubscriptionFailedToCancel(it.throwable)
|
||||
@@ -109,6 +110,13 @@ class SubscribeFragment : DSLSettingsFragment(
|
||||
lifecycleDisposable += donationPaymentComponent.googlePayResultPublisher.subscribe {
|
||||
viewModel.onActivityResult(it.requestCode, it.resultCode, it.data)
|
||||
}
|
||||
|
||||
lifecycleDisposable += DonationError
|
||||
.getErrorsForSource(DonationErrorSource.SUBSCRIPTION)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { donationError ->
|
||||
onPaymentError(donationError)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
@@ -277,49 +285,21 @@ class SubscribeFragment : DSLSettingsFragment(
|
||||
}
|
||||
|
||||
private fun onPaymentError(throwable: Throwable?) {
|
||||
if (throwable is DonationExceptions.TimedOutWaitingForTokenRedemption) {
|
||||
Log.w(TAG, "Timeout occurred while redeeming token", throwable, true)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__still_processing)
|
||||
.setMessage(R.string.DonationsErrors__your_payment_is_still)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
requireActivity().finish()
|
||||
requireActivity().startActivity(AppSettingsActivity.manageSubscriptions(requireContext()))
|
||||
}
|
||||
.show()
|
||||
} else if (throwable is DonationExceptions.SetupFailed) {
|
||||
Log.w(TAG, "Error occurred while processing payment", throwable, true)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__error_processing_payment)
|
||||
.setMessage(R.string.DonationsErrors__your_payment)
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
} else if (SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt) {
|
||||
Log.w(TAG, "Stripe failed to process payment", throwable, true)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__error_processing_payment)
|
||||
.setMessage(R.string.DonationsErrors__your_badge_could_not_be_added)
|
||||
.setPositiveButton(R.string.Subscription__contact_support) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
requireActivity().finish()
|
||||
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
Log.w(TAG, "Error occurred while trying to redeem token", throwable, true)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__couldnt_add_badge)
|
||||
.setMessage(R.string.DonationsErrors__your_badge_could_not)
|
||||
.setPositiveButton(R.string.Subscription__contact_support) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
requireActivity().finish()
|
||||
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
|
||||
}
|
||||
.show()
|
||||
Log.w(TAG, "onPaymentError", throwable, true)
|
||||
|
||||
if (errorDialog != null) {
|
||||
Log.i(TAG, "Already displaying an error dialog. Skipping.")
|
||||
return
|
||||
}
|
||||
|
||||
errorDialog = DonationErrorDialogs.show(
|
||||
requireContext(), throwable,
|
||||
object : DonationErrorDialogs.DialogCallback() {
|
||||
override fun onDialogDismissed() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun onSubscriptionCancelled() {
|
||||
|
||||
@@ -18,9 +18,11 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationExceptions
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdate
|
||||
@@ -129,11 +131,6 @@ class SubscribeViewModel(
|
||||
onError = this::handleSubscriptionDataLoadFailure
|
||||
)
|
||||
|
||||
disposables += donationPaymentRepository.isGooglePayAvailable().subscribeBy(
|
||||
onComplete = { store.update { it.copy(isGooglePayAvailable = true) } },
|
||||
onError = { eventPublisher.onNext(DonationEvent.GooglePayUnavailableError(it)) }
|
||||
)
|
||||
|
||||
disposables += currency.subscribe { selection ->
|
||||
store.update { it.copy(currencySelection = selection) }
|
||||
}
|
||||
@@ -170,6 +167,7 @@ class SubscribeViewModel(
|
||||
SignalStore.donationsValues().setLastEndOfPeriod(0L)
|
||||
SignalStore.donationsValues().clearLevelOperations()
|
||||
SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt = false
|
||||
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = null
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue()
|
||||
}
|
||||
} else {
|
||||
@@ -186,6 +184,7 @@ class SubscribeViewModel(
|
||||
SignalStore.donationsValues().setLastEndOfPeriod(0L)
|
||||
SignalStore.donationsValues().clearLevelOperations()
|
||||
SignalStore.donationsValues().markUserManuallyCancelled()
|
||||
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = null
|
||||
refreshActiveSubscription()
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue()
|
||||
donationPaymentRepository.scheduleSyncForAccountRecordChange()
|
||||
@@ -222,13 +221,20 @@ class SubscribeViewModel(
|
||||
val setup = ensureSubscriberId
|
||||
.andThen(cancelActiveSubscriptionIfNecessary())
|
||||
.andThen(continueSetup)
|
||||
.onErrorResumeNext { Completable.error(DonationExceptions.SetupFailed(it)) }
|
||||
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it)) }
|
||||
|
||||
setup.andThen(setLevel).subscribeBy(
|
||||
onError = { throwable ->
|
||||
refreshActiveSubscription()
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
|
||||
|
||||
val donationError: DonationError = if (throwable is DonationError) {
|
||||
throwable
|
||||
} else {
|
||||
Log.w(TAG, "Failed to complete payment or redemption", throwable, true)
|
||||
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
},
|
||||
onComplete = {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
@@ -242,7 +248,7 @@ class SubscribeViewModel(
|
||||
|
||||
override fun onError(googlePayException: GooglePayApi.GooglePayException) {
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenError(googlePayException))
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), DonationError.getGooglePayRequestTokenError(DonationErrorSource.SUBSCRIPTION, googlePayException))
|
||||
}
|
||||
|
||||
override fun onCancelled() {
|
||||
@@ -262,7 +268,13 @@ class SubscribeViewModel(
|
||||
},
|
||||
onError = { throwable ->
|
||||
store.update { it.copy(stage = SubscribeState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationError(throwable))
|
||||
val donationError: DonationError = if (throwable is DonationError) {
|
||||
throwable
|
||||
} else {
|
||||
Log.w(TAG, "Failed to complete payment or redemption", throwable, true)
|
||||
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.doOnPreDraw
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import app.cash.exhaustive.Exhaustive
|
||||
import com.google.android.flexbox.FlexboxLayoutManager
|
||||
@@ -92,8 +93,7 @@ private const val REQUEST_CODE_RETURN_FROM_MEDIA = 4
|
||||
|
||||
class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
layoutId = R.layout.conversation_settings_fragment,
|
||||
menuId = R.menu.conversation_settings,
|
||||
layoutManagerProducer = Badges::createLayoutManagerForGridWithBadges
|
||||
menuId = R.menu.conversation_settings
|
||||
) {
|
||||
|
||||
private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) }
|
||||
@@ -151,6 +151,11 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
toolbarTitle = view.findViewById(R.id.toolbar_title)
|
||||
toolbarBackground = view.findViewById(R.id.toolbar_background)
|
||||
|
||||
val args: ConversationSettingsFragmentArgs = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
|
||||
if (args.recipientId != null) {
|
||||
layoutManagerProducer = Badges::createLayoutManagerForGridWithBadges
|
||||
}
|
||||
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
}
|
||||
|
||||
@@ -393,28 +398,32 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
enabled = it.canEditGroupAttributes
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__disappearing_messages),
|
||||
summary = summary,
|
||||
icon = DSLSettingsIcon.from(icon),
|
||||
isEnabled = enabled,
|
||||
onClick = {
|
||||
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToAppSettingsExpireTimer()
|
||||
.setInitialValue(state.disappearingMessagesLifespan)
|
||||
.setRecipientId(state.recipient.id)
|
||||
.setForResultMode(false)
|
||||
if (!state.recipient.isReleaseNotes) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__disappearing_messages),
|
||||
summary = summary,
|
||||
icon = DSLSettingsIcon.from(icon),
|
||||
isEnabled = enabled,
|
||||
onClick = {
|
||||
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToAppSettingsExpireTimer()
|
||||
.setInitialValue(state.disappearingMessagesLifespan)
|
||||
.setRecipientId(state.recipient.id)
|
||||
.setForResultMode(false)
|
||||
|
||||
navController.safeNavigate(action)
|
||||
}
|
||||
)
|
||||
navController.safeNavigate(action)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__chat_color_and_wallpaper),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_color_24),
|
||||
onClick = {
|
||||
startActivity(ChatWallpaperActivity.createIntent(requireContext(), state.recipient.id))
|
||||
}
|
||||
)
|
||||
if (!state.recipient.isReleaseNotes) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__chat_color_and_wallpaper),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_color_24),
|
||||
onClick = {
|
||||
startActivity(ChatWallpaperActivity.createIntent(requireContext(), state.recipient.id))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!state.recipient.isSelf) {
|
||||
clickPref(
|
||||
@@ -507,7 +516,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
)
|
||||
}
|
||||
|
||||
if (recipientSettingsState.selfHasGroups) {
|
||||
if (recipientSettingsState.selfHasGroups && !state.recipient.isReleaseNotes) {
|
||||
|
||||
dividerPref()
|
||||
|
||||
@@ -758,9 +767,14 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
private val rect = Rect()
|
||||
|
||||
override fun getAnimationState(recyclerView: RecyclerView): AnimationState {
|
||||
val layoutManager = recyclerView.layoutManager as FlexboxLayoutManager
|
||||
val layoutManager = recyclerView.layoutManager!!
|
||||
val firstVisibleItemPosition = if (layoutManager is FlexboxLayoutManager) {
|
||||
layoutManager.findFirstVisibleItemPosition()
|
||||
} else {
|
||||
(layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
|
||||
}
|
||||
|
||||
return if (layoutManager.findFirstVisibleItemPosition() == 0) {
|
||||
return if (firstVisibleItemPosition == 0) {
|
||||
val firstChild = requireNotNull(layoutManager.getChildAt(0))
|
||||
firstChild.getLocalVisibleRect(rect)
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ class ConversationSettingsRepository(
|
||||
|
||||
fun getIdentity(recipientId: RecipientId, consumer: (IdentityRecord?) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
consumer(ApplicationDependencies.getIdentityStore().getIdentityRecord(recipientId).orNull())
|
||||
consumer(ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecord(recipientId).orNull())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,8 +130,8 @@ sealed class ConversationSettingsViewModel(
|
||||
state.copy(
|
||||
recipient = recipient,
|
||||
buttonStripState = ButtonStripPreference.State(
|
||||
isVideoAvailable = recipient.registered == RecipientDatabase.RegisteredState.REGISTERED && !recipient.isSelf,
|
||||
isAudioAvailable = !recipient.isGroup && !recipient.isSelf,
|
||||
isVideoAvailable = recipient.registered == RecipientDatabase.RegisteredState.REGISTERED && !recipient.isSelf && !recipient.isBlocked && !recipient.isReleaseNotes,
|
||||
isAudioAvailable = !recipient.isGroup && !recipient.isSelf && !recipient.isBlocked && !recipient.isReleaseNotes,
|
||||
isAudioSecure = recipient.registered == RecipientDatabase.RegisteredState.REGISTERED,
|
||||
isMuted = recipient.isMuted,
|
||||
isMuteAvailable = !recipient.isSelf,
|
||||
@@ -141,7 +141,7 @@ sealed class ConversationSettingsViewModel(
|
||||
canModifyBlockedState = !recipient.isSelf && RecipientUtil.isBlockable(recipient),
|
||||
specificSettingsState = state.requireRecipientSettingsState().copy(
|
||||
contactLinkState = when {
|
||||
recipient.isSelf -> ContactLinkState.NONE
|
||||
recipient.isSelf || recipient.isReleaseNotes -> ContactLinkState.NONE
|
||||
recipient.isSystemContact -> ContactLinkState.OPEN
|
||||
else -> ContactLinkState.ADD
|
||||
}
|
||||
@@ -240,11 +240,12 @@ sealed class ConversationSettingsViewModel(
|
||||
private val liveGroup = LiveGroup(groupId)
|
||||
|
||||
init {
|
||||
store.update(liveGroup.groupRecipient) { recipient, state ->
|
||||
val recipientAndIsActive = LiveDataUtil.combineLatest(liveGroup.groupRecipient, liveGroup.isActive) { r, a -> r to a }
|
||||
store.update(recipientAndIsActive) { (recipient, isActive), state ->
|
||||
state.copy(
|
||||
recipient = recipient,
|
||||
buttonStripState = ButtonStripPreference.State(
|
||||
isVideoAvailable = recipient.isPushV2Group,
|
||||
isVideoAvailable = recipient.isPushV2Group && !recipient.isBlocked && isActive,
|
||||
isAudioAvailable = false,
|
||||
isAudioSecure = recipient.isPushV2Group,
|
||||
isMuted = recipient.isMuted,
|
||||
|
||||
@@ -26,8 +26,7 @@ import org.thoughtcrime.securesms.util.Hex
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.util.Objects
|
||||
|
||||
/**
|
||||
@@ -61,18 +60,11 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
|
||||
)
|
||||
|
||||
if (!recipient.isGroup) {
|
||||
val aci = recipient.aci.transform(ACI::toString).or("null")
|
||||
val serviceId = recipient.serviceId.transform(ServiceId::toString).or("null")
|
||||
longClickPref(
|
||||
title = DSLSettingsText.from("ACI"),
|
||||
summary = DSLSettingsText.from(aci),
|
||||
onLongClick = { copyToClipboard(aci) }
|
||||
)
|
||||
|
||||
val pni = recipient.pni.transform(PNI::toString).or("null")
|
||||
longClickPref(
|
||||
title = DSLSettingsText.from("PNI"),
|
||||
summary = DSLSettingsText.from(pni),
|
||||
onLongClick = { copyToClipboard(pni) }
|
||||
title = DSLSettingsText.from("ServiceId"),
|
||||
summary = DSLSettingsText.from(serviceId),
|
||||
onLongClick = { copyToClipboard(serviceId) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -153,11 +145,8 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
|
||||
.setTitle("Are you sure?")
|
||||
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
if (recipient.hasAci()) {
|
||||
SignalDatabase.sessions.deleteAllFor(recipient.requireAci().toString())
|
||||
}
|
||||
if (recipient.hasE164()) {
|
||||
SignalDatabase.sessions.deleteAllFor(recipient.requireE164())
|
||||
if (recipient.hasServiceId()) {
|
||||
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account().requireAci(), addressName = recipient.requireServiceId().toString())
|
||||
}
|
||||
}
|
||||
.show()
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
@@ -9,7 +10,9 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.ContextUtil
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
@@ -25,8 +28,8 @@ object BioTextPreference {
|
||||
}
|
||||
|
||||
abstract class BioTextPreferenceModel<T : BioTextPreferenceModel<T>> : PreferenceModel<T>() {
|
||||
abstract fun getHeadlineText(context: Context): String
|
||||
abstract fun getSubhead1Text(): String?
|
||||
abstract fun getHeadlineText(context: Context): CharSequence
|
||||
abstract fun getSubhead1Text(context: Context): String?
|
||||
abstract fun getSubhead2Text(): String?
|
||||
}
|
||||
|
||||
@@ -34,9 +37,24 @@ object BioTextPreference {
|
||||
private val recipient: Recipient,
|
||||
) : BioTextPreferenceModel<RecipientModel>() {
|
||||
|
||||
override fun getHeadlineText(context: Context): String = recipient.getDisplayNameOrUsername(context)
|
||||
override fun getHeadlineText(context: Context): CharSequence {
|
||||
val name = recipient.getDisplayNameOrUsername(context)
|
||||
return if (recipient.isReleaseNotes) {
|
||||
SpannableStringBuilder(name).apply {
|
||||
SpanUtil.appendCenteredImageSpan(this, ContextUtil.requireDrawable(context, R.drawable.ic_official_28), 28, 28)
|
||||
}
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSubhead1Text(): String? = recipient.combinedAboutAndEmoji
|
||||
override fun getSubhead1Text(context: Context): String? {
|
||||
return if (recipient.isReleaseNotes) {
|
||||
context.getString(R.string.ReleaseNotes__signal_release_notes_and_news)
|
||||
} else {
|
||||
recipient.combinedAboutAndEmoji
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSubhead2Text(): String? = recipient.e164.transform(PhoneNumberFormatter::prettyPrint).orNull()
|
||||
|
||||
@@ -53,9 +71,9 @@ object BioTextPreference {
|
||||
val groupTitle: String,
|
||||
val groupMembershipDescription: String?
|
||||
) : BioTextPreferenceModel<GroupModel>() {
|
||||
override fun getHeadlineText(context: Context): String = groupTitle
|
||||
override fun getHeadlineText(context: Context): CharSequence = groupTitle
|
||||
|
||||
override fun getSubhead1Text(): String? = groupMembershipDescription
|
||||
override fun getSubhead1Text(context: Context): String? = groupMembershipDescription
|
||||
|
||||
override fun getSubhead2Text(): String? = null
|
||||
|
||||
@@ -79,7 +97,7 @@ object BioTextPreference {
|
||||
override fun bind(model: T) {
|
||||
headline.text = model.getHeadlineText(context)
|
||||
|
||||
model.getSubhead1Text().let {
|
||||
model.getSubhead1Text(context).let {
|
||||
subhead1.text = it
|
||||
subhead1.visibility = if (it == null) View.GONE else View.VISIBLE
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.util.RingtoneUtil
|
||||
import java.lang.NullPointerException
|
||||
|
||||
private val TAG = Log.tag(CustomNotificationsSettingsFragment::class.java)
|
||||
|
||||
@@ -149,6 +148,9 @@ class CustomNotificationsSettingsFragment : DSLSettingsFragment(R.string.CustomN
|
||||
} catch (e: NullPointerException) {
|
||||
Log.w(TAG, "Could not get correct title for ringtone.", e)
|
||||
context.getString(R.string.CustomNotificationsDialogFragment__unknown)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Could not get correct title for ringtone.", e)
|
||||
context.getString(R.string.CustomNotificationsDialogFragment__unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.thoughtcrime.securesms.components.settings.models
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.annotation.DrawableRes
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
|
||||
/**
|
||||
* Renders a single image, horizontally centered.
|
||||
*/
|
||||
object SplashImage {
|
||||
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.splash_image))
|
||||
}
|
||||
|
||||
class Model(@DrawableRes val splashImageResId: Int) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return newItem.splashImageResId == splashImageResId
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val splashImageView: ImageView = itemView as ImageView
|
||||
|
||||
override fun bind(model: Model) {
|
||||
splashImageView.setImageResource(model.splashImageResId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import android.support.v4.media.session.PlaybackStateCompat;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.lifecycle.DefaultLifecycleObserver;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.LiveData;
|
||||
@@ -50,7 +51,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
private static final String TAG = Log.tag(VoiceNoteMediaController.class);
|
||||
|
||||
private MediaBrowserCompat mediaBrowser;
|
||||
private AppCompatActivity activity;
|
||||
private FragmentActivity activity;
|
||||
private ProgressEventHandler progressEventHandler;
|
||||
private MutableLiveData<VoiceNotePlaybackState> voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE);
|
||||
private LiveData<Optional<VoiceNotePlayerView.State>> voiceNotePlayerViewState;
|
||||
@@ -58,7 +59,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
|
||||
private final MediaControllerCompatCallback mediaControllerCompatCallback = new MediaControllerCompatCallback();
|
||||
|
||||
public VoiceNoteMediaController(@NonNull AppCompatActivity activity) {
|
||||
public VoiceNoteMediaController(@NonNull FragmentActivity activity) {
|
||||
this.activity = activity;
|
||||
this.mediaBrowser = new MediaBrowserCompat(activity,
|
||||
new ComponentName(activity, VoiceNotePlaybackService.class),
|
||||
@@ -190,6 +191,27 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the Media service to resume playback of a given audio slide. If the audio slide is not
|
||||
* currently paused, playback will be started from the beginning.
|
||||
*
|
||||
* @param audioSlideUri The Uri of the desired audio slide
|
||||
* @param messageId The Message id of the given audio slide
|
||||
*/
|
||||
public void resumePlayback(@NonNull Uri audioSlideUri, long messageId) {
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
getMediaController().getTransportControls().play();
|
||||
} else {
|
||||
Bundle extras = new Bundle();
|
||||
extras.putLong(EXTRA_MESSAGE_ID, messageId);
|
||||
extras.putLong(EXTRA_THREAD_ID, -1L);
|
||||
extras.putDouble(EXTRA_PROGRESS, 0.0);
|
||||
extras.putBoolean(EXTRA_PLAY_SINGLE, true);
|
||||
|
||||
getMediaController().getTransportControls().playFromUri(audioSlideUri, extras);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses playback if the given audio slide is playing.
|
||||
*
|
||||
|
||||
@@ -10,7 +10,7 @@ import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
@@ -25,7 +25,7 @@ private const val PROXIMITY_THRESHOLD = 5f
|
||||
* Manages the WakeLock while a VoiceNote is playing back in the target activity.
|
||||
*/
|
||||
class VoiceNoteProximityWakeLockManager(
|
||||
private val activity: AppCompatActivity,
|
||||
private val activity: FragmentActivity,
|
||||
private val mediaController: MediaControllerCompat
|
||||
) : DefaultLifecycleObserver {
|
||||
|
||||
|
||||
@@ -127,10 +127,10 @@ data class CallParticipantsState(
|
||||
fun getIncomingRingingGroupDescription(context: Context): String? {
|
||||
if (callState == WebRtcViewModel.State.CALL_INCOMING &&
|
||||
groupCallState == WebRtcViewModel.GroupCallState.RINGING &&
|
||||
ringerRecipient.hasAci()
|
||||
ringerRecipient.hasServiceId()
|
||||
) {
|
||||
val ringerName = ringerRecipient.getShortDisplayName(context)
|
||||
val membersWithoutYouOrRinger: List<GroupMemberEntry.FullMember> = groupMembers.filterNot { it.member.isSelf || ringerRecipient.requireAci() == it.member.aci.orNull() }
|
||||
val membersWithoutYouOrRinger: List<GroupMemberEntry.FullMember> = groupMembers.filterNot { it.member.isSelf || ringerRecipient.requireServiceId() == it.member.serviceId.orNull() }
|
||||
|
||||
return when (membersWithoutYouOrRinger.size) {
|
||||
0 -> context.getString(R.string.WebRtcCallView__s_is_calling_you, ringerName)
|
||||
|
||||
@@ -35,7 +35,7 @@ class WebRtcCallRepository {
|
||||
recipients = Collections.singletonList(recipient);
|
||||
}
|
||||
|
||||
consumer.accept(ApplicationDependencies.getIdentityStore().getIdentityRecords(recipients));
|
||||
consumer.accept(ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(recipients));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +96,8 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
private PictureInPictureGestureHelper pictureInPictureGestureHelper;
|
||||
private ImageView hangup;
|
||||
private TextView hangupLabel;
|
||||
private View answerWithAudio;
|
||||
private View answerWithAudioLabel;
|
||||
private View answerWithoutVideo;
|
||||
private View answerWithoutVideoLabel;
|
||||
private View topGradient;
|
||||
private View footerGradient;
|
||||
private View startCallControls;
|
||||
@@ -178,8 +178,8 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
ringToggleLabel = findViewById(R.id.call_screen_audio_ring_toggle_label);
|
||||
hangup = findViewById(R.id.call_screen_end_call);
|
||||
hangupLabel = findViewById(R.id.call_screen_end_call_label);
|
||||
answerWithAudio = findViewById(R.id.call_screen_answer_with_audio);
|
||||
answerWithAudioLabel = findViewById(R.id.call_screen_answer_with_audio_label);
|
||||
answerWithoutVideo = findViewById(R.id.call_screen_answer_without_video);
|
||||
answerWithoutVideoLabel = findViewById(R.id.call_screen_answer_without_video_label);
|
||||
topGradient = findViewById(R.id.call_screen_header_gradient);
|
||||
footerGradient = findViewById(R.id.call_screen_footer_gradient);
|
||||
startCallControls = findViewById(R.id.call_screen_start_call_controls);
|
||||
@@ -255,7 +255,7 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
decline.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onDenyCallPressed));
|
||||
|
||||
answer.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallPressed));
|
||||
answerWithAudio.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallWithVoiceOnlyPressed));
|
||||
answerWithoutVideo.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallWithVoiceOnlyPressed));
|
||||
|
||||
pictureInPictureGestureHelper = PictureInPictureGestureHelper.applyTo(smallLocalRenderFrame);
|
||||
pictureInPictureExpansionHelper = new PictureInPictureExpansionHelper();
|
||||
@@ -286,7 +286,7 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
|
||||
rotatableControls.add(hangup);
|
||||
rotatableControls.add(answer);
|
||||
rotatableControls.add(answerWithAudio);
|
||||
rotatableControls.add(answerWithoutVideo);
|
||||
rotatableControls.add(audioToggle);
|
||||
rotatableControls.add(micToggle);
|
||||
rotatableControls.add(videoToggle);
|
||||
@@ -590,19 +590,19 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
if (webRtcControls.displayIncomingCallButtons()) {
|
||||
visibleViewSet.addAll(incomingCallViews);
|
||||
|
||||
incomingRingStatus.setText(webRtcControls.displayAnswerWithAudio() ? R.string.WebRtcCallView__signal_call : R.string.WebRtcCallView__signal_video_call);
|
||||
incomingRingStatus.setText(webRtcControls.displayAnswerWithoutVideo() ? R.string.WebRtcCallView__signal_video_call: R.string.WebRtcCallView__signal_call);
|
||||
|
||||
answer.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer));
|
||||
}
|
||||
|
||||
if (webRtcControls.displayAnswerWithAudio()) {
|
||||
visibleViewSet.add(answerWithAudio);
|
||||
visibleViewSet.add(answerWithAudioLabel);
|
||||
if (webRtcControls.displayAnswerWithoutVideo()) {
|
||||
visibleViewSet.add(answerWithoutVideo);
|
||||
visibleViewSet.add(answerWithoutVideoLabel);
|
||||
|
||||
answer.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer_with_video));
|
||||
}
|
||||
|
||||
if (!webRtcControls.displayIncomingCallButtons() && !webRtcControls.displayAnswerWithAudio()){
|
||||
if (!webRtcControls.displayIncomingCallButtons()){
|
||||
incomingRingStatus.setVisibility(GONE);
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ public final class WebRtcControls {
|
||||
return isOngoing();
|
||||
}
|
||||
|
||||
boolean displayAnswerWithAudio() {
|
||||
boolean displayAnswerWithoutVideo() {
|
||||
return isIncoming() && isRemoteVideoEnabled;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import android.text.TextUtils;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
|
||||
@@ -101,9 +100,9 @@ public class ContactRepository {
|
||||
}));
|
||||
}};
|
||||
|
||||
public ContactRepository(@NonNull Context context) {
|
||||
public ContactRepository(@NonNull Context context, @NonNull String noteToSelfTitle) {
|
||||
this.recipientDatabase = SignalDatabase.recipients();
|
||||
this.noteToSelfTitle = context.getString(R.string.note_to_self);
|
||||
this.noteToSelfTitle = noteToSelfTitle;
|
||||
this.context = context.getApplicationContext();
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
|
||||
|
||||
this.mode = mode;
|
||||
this.recents = recents;
|
||||
this.contactRepository = new ContactRepository(context);
|
||||
this.contactRepository = new ContactRepository(context, context.getString(R.string.note_to_self));
|
||||
}
|
||||
|
||||
protected final List<Cursor> getUnfilteredResults() {
|
||||
|
||||
@@ -65,10 +65,6 @@ public class ProfileContactPhoto implements ContactPhoto {
|
||||
}
|
||||
|
||||
private long getFileLastModified() {
|
||||
if (!recipient.isSelf()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return AvatarHelper.getLastModified(ApplicationDependencies.getApplication(), recipient.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,12 +23,12 @@ import org.thoughtcrime.securesms.BuildConfig;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.contacts.ContactAccessor;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsDatabase;
|
||||
import org.thoughtcrime.securesms.crypto.SessionUtil;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.BulkOperationsHandle;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.whispersystems.libsignal.SignalProtocolAddress;
|
||||
import org.whispersystems.signalservice.api.push.ACI;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
@@ -54,6 +54,7 @@ import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
|
||||
import org.whispersystems.signalservice.api.services.ProfileService;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
@@ -111,9 +112,9 @@ public class DirectoryHelper {
|
||||
RecipientDatabase recipientDatabase = SignalDatabase.recipients();
|
||||
|
||||
for (Recipient recipient : recipients) {
|
||||
if (recipient.hasAci() && !recipient.hasE164()) {
|
||||
if (ApplicationDependencies.getSignalServiceAccountManager().isIdentifierRegistered(recipient.requireAci())) {
|
||||
recipientDatabase.markRegistered(recipient.getId(), recipient.requireAci());
|
||||
if (recipient.hasServiceId() && !recipient.hasE164()) {
|
||||
if (ApplicationDependencies.getSignalServiceAccountManager().isIdentifierRegistered(recipient.requireServiceId())) {
|
||||
recipientDatabase.markRegistered(recipient.getId(), recipient.requireServiceId());
|
||||
} else {
|
||||
recipientDatabase.markUnregistered(recipient.getId());
|
||||
}
|
||||
@@ -135,11 +136,11 @@ public class DirectoryHelper {
|
||||
RegisteredState originalRegisteredState = recipient.resolve().getRegistered();
|
||||
RegisteredState newRegisteredState;
|
||||
|
||||
if (recipient.hasAci() && !recipient.hasE164()) {
|
||||
boolean isRegistered = ApplicationDependencies.getSignalServiceAccountManager().isIdentifierRegistered(recipient.requireAci());
|
||||
if (recipient.hasServiceId() && !recipient.hasE164()) {
|
||||
boolean isRegistered = ApplicationDependencies.getSignalServiceAccountManager().isIdentifierRegistered(recipient.requireServiceId());
|
||||
stopwatch.split("aci-network");
|
||||
if (isRegistered) {
|
||||
boolean idChanged = recipientDatabase.markRegistered(recipient.getId(), recipient.requireAci());
|
||||
boolean idChanged = recipientDatabase.markRegistered(recipient.getId(), recipient.requireServiceId());
|
||||
if (idChanged) {
|
||||
Log.w(TAG, "ID changed during refresh by UUID.");
|
||||
}
|
||||
@@ -172,14 +173,14 @@ public class DirectoryHelper {
|
||||
if (aci != null) {
|
||||
boolean idChanged = recipientDatabase.markRegistered(recipient.getId(), aci);
|
||||
if (idChanged) {
|
||||
recipient = Recipient.resolved(recipientDatabase.getByAci(aci).get());
|
||||
recipient = Recipient.resolved(recipientDatabase.getByServiceId(aci).get());
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Registered number set had a null ACI!");
|
||||
}
|
||||
} else if (recipient.hasAci() && recipient.isRegistered() && hasCommunicatedWith(context, recipient)) {
|
||||
if (ApplicationDependencies.getSignalServiceAccountManager().isIdentifierRegistered(recipient.requireAci())) {
|
||||
recipientDatabase.markRegistered(recipient.getId(), recipient.requireAci());
|
||||
} else if (recipient.hasServiceId() && recipient.isRegistered() && hasCommunicatedWith(recipient)) {
|
||||
if (ApplicationDependencies.getSignalServiceAccountManager().isIdentifierRegistered(recipient.requireServiceId())) {
|
||||
recipientDatabase.markRegistered(recipient.getId(), recipient.requireServiceId());
|
||||
} else {
|
||||
recipientDatabase.markUnregistered(recipient.getId());
|
||||
}
|
||||
@@ -464,9 +465,9 @@ public class DirectoryHelper {
|
||||
|
||||
for (RecipientId newUser: newUsers) {
|
||||
Recipient recipient = Recipient.resolved(newUser);
|
||||
if (!SessionUtil.hasSession(recipient.getId()) &&
|
||||
!recipient.isSelf() &&
|
||||
recipient.hasAUserSetDisplayName(context))
|
||||
if (!recipient.isSelf() &&
|
||||
recipient.hasAUserSetDisplayName(context) &&
|
||||
!hasSession(recipient.getId()))
|
||||
{
|
||||
IncomingJoinedMessage message = new IncomingJoinedMessage(recipient.getId());
|
||||
Optional<InsertResult> insertResult = SignalDatabase.sms().insertMessageInbox(message);
|
||||
@@ -483,6 +484,19 @@ public class DirectoryHelper {
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean hasSession(@NonNull RecipientId id) {
|
||||
Recipient recipient = Recipient.resolved(id);
|
||||
|
||||
if (!recipient.hasServiceId()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SignalProtocolAddress protocolAddress = Recipient.resolved(id).requireServiceId().toProtocolAddress(SignalServiceAddress.DEFAULT_DEVICE_ID);
|
||||
|
||||
return ApplicationDependencies.getProtocolStore().aci().containsSession(protocolAddress) ||
|
||||
ApplicationDependencies.getProtocolStore().pni().containsSession(protocolAddress);
|
||||
}
|
||||
|
||||
private static Set<String> sanitizeNumbers(@NonNull Set<String> numbers) {
|
||||
return Stream.of(numbers).filter(number -> {
|
||||
try {
|
||||
@@ -503,8 +517,8 @@ public class DirectoryHelper {
|
||||
List<Recipient> possiblyUnlisted = Stream.of(inactiveIds)
|
||||
.map(Recipient::resolved)
|
||||
.filter(Recipient::isRegistered)
|
||||
.filter(Recipient::hasAci)
|
||||
.filter(r -> hasCommunicatedWith(context, r))
|
||||
.filter(Recipient::hasServiceId)
|
||||
.filter(DirectoryHelper::hasCommunicatedWith)
|
||||
.toList();
|
||||
|
||||
ProfileService profileService = new ProfileService(ApplicationDependencies.getGroupsV2Operations().getProfileOperations(),
|
||||
@@ -537,10 +551,10 @@ public class DirectoryHelper {
|
||||
.blockingGet();
|
||||
}
|
||||
|
||||
private static boolean hasCommunicatedWith(@NonNull Context context, @NonNull Recipient recipient) {
|
||||
return SignalDatabase.threads().hasThread(recipient.getId()) ||
|
||||
(recipient.hasAci() && SignalDatabase.sessions().hasSessionFor(recipient.requireAci().toString())) ||
|
||||
(recipient.hasE164() && SignalDatabase.sessions().hasSessionFor(recipient.requireE164()));
|
||||
private static boolean hasCommunicatedWith(@NonNull Recipient recipient) {
|
||||
ACI localAci = SignalStore.account().requireAci();
|
||||
|
||||
return SignalDatabase.threads().hasThread(recipient.getId()) || (recipient.hasServiceId() && SignalDatabase.sessions().hasSessionFor(localAci, recipient.requireServiceId().toString()));
|
||||
}
|
||||
|
||||
static class DirectoryResult {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
|
||||
/**
|
||||
* Activity which encapsulates a conversation for a Bubble window.
|
||||
*
|
||||
@@ -9,7 +12,11 @@ package org.thoughtcrime.securesms.conversation;
|
||||
*/
|
||||
public class BubbleConversationActivity extends ConversationActivity {
|
||||
@Override
|
||||
protected boolean isInBubble() {
|
||||
public boolean isInBubble() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInitializeToolbar(@NonNull Toolbar toolbar) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import io.reactivex.rxjava3.subjects.Subject
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.HidingLinearLayout
|
||||
import org.thoughtcrime.securesms.components.reminder.ReminderView
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture
|
||||
import org.thoughtcrime.securesms.util.views.Stub
|
||||
|
||||
open class ConversationActivity : PassphraseRequiredActivity(), ConversationParentFragment.Callback, DonationPaymentComponent {
|
||||
|
||||
private lateinit var fragment: ConversationParentFragment
|
||||
|
||||
private val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme()
|
||||
override fun onPreCreate() {
|
||||
dynamicTheme.onCreate(this)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
setContentView(R.layout.conversation_parent_fragment_container)
|
||||
|
||||
fragment = supportFragmentManager.findFragmentById(R.id.fragment_container) as ConversationParentFragment
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
fragment.onNewIntent(intent)
|
||||
}
|
||||
|
||||
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
|
||||
return fragment.dispatchTouchEvent(ev) || super.dispatchTouchEvent(ev)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
dynamicTheme.onResume(this)
|
||||
}
|
||||
|
||||
override fun onInitializeToolbar(toolbar: Toolbar) {
|
||||
toolbar.navigationIcon = AppCompatResources.getDrawable(this, R.drawable.ic_arrow_left_24)
|
||||
toolbar.setNavigationOnClickListener { finish() }
|
||||
}
|
||||
|
||||
fun saveDraft(): ListenableFuture<Long> {
|
||||
return fragment.saveDraft()
|
||||
}
|
||||
|
||||
fun getRecipient(): Recipient {
|
||||
return fragment.recipient
|
||||
}
|
||||
|
||||
fun getTitleView(): View {
|
||||
return fragment.titleView
|
||||
}
|
||||
|
||||
fun getComposeText(): View {
|
||||
return fragment.composeText
|
||||
}
|
||||
|
||||
fun getQuickAttachmentToggle(): HidingLinearLayout {
|
||||
return fragment.quickAttachmentToggle
|
||||
}
|
||||
|
||||
fun getReminderView(): Stub<ReminderView> {
|
||||
return fragment.reminderView
|
||||
}
|
||||
|
||||
override val donationPaymentRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
|
||||
override val googlePayResultPublisher: Subject<DonationPaymentComponent.GooglePayResult> = PublishSubject.create()
|
||||
}
|
||||
@@ -18,6 +18,7 @@ package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
@@ -39,6 +40,7 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.paging.PagingController;
|
||||
@@ -401,7 +403,9 @@ public class ConversationAdapter
|
||||
}
|
||||
|
||||
void onBindLastSeenViewHolder(StickyHeaderViewHolder viewHolder, int position) {
|
||||
viewHolder.setText(viewHolder.itemView.getContext().getResources().getQuantityString(R.plurals.ConversationAdapter_n_unread_messages, (position + 1), (position + 1)));
|
||||
int messagePosition = isTypingViewEnabled ? position - 1 : position;
|
||||
int count = messagePosition + 1;
|
||||
viewHolder.setText(viewHolder.itemView.getContext().getResources().getQuantityString(R.plurals.ConversationAdapter_n_unread_messages, count, count));
|
||||
|
||||
if (hasWallpaper) {
|
||||
viewHolder.setBackgroundRes(R.drawable.wallpaper_bubble_background_8);
|
||||
@@ -704,6 +708,11 @@ public class ConversationAdapter
|
||||
return getBindable().canPlayContent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldProjectContent() {
|
||||
return getBindable().shouldProjectContent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull ProjectionList getColorizerProjections(@NonNull ViewGroup coordinateRoot) {
|
||||
return getBindable().getColorizerProjections(coordinateRoot);
|
||||
@@ -778,7 +787,23 @@ public class ConversationAdapter
|
||||
}
|
||||
|
||||
public static class FooterViewHolder extends HeaderFooterViewHolder {
|
||||
FooterViewHolder(@NonNull View itemView) { super(itemView); }
|
||||
FooterViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
setPaddingTop();
|
||||
}
|
||||
|
||||
@Override
|
||||
void bind(@Nullable View view) {
|
||||
super.bind(view);
|
||||
setPaddingTop();
|
||||
}
|
||||
|
||||
private void setPaddingTop() {
|
||||
if (Build.VERSION.SDK_INT <= 23) {
|
||||
int addToPadding = ViewUtil.getStatusBarHeight(itemView) + (int) ThemeUtil.getThemedDimen(itemView.getContext(), android.R.attr.actionBarSize);
|
||||
ViewUtil.setPaddingTop(itemView, itemView.getPaddingTop() + addToPadding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class HeaderViewHolder extends HeaderFooterViewHolder {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
@@ -20,7 +21,9 @@ import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
|
||||
public class ConversationBannerView extends ConstraintLayout {
|
||||
|
||||
@@ -78,11 +81,23 @@ public class ConversationBannerView extends ConstraintLayout {
|
||||
}
|
||||
}
|
||||
|
||||
public void setTitle(@Nullable CharSequence title) {
|
||||
public String setTitle(@NonNull Recipient recipient) {
|
||||
SpannableStringBuilder title = new SpannableStringBuilder(recipient.isSelf() ? getContext().getString(R.string.note_to_self) : recipient.getDisplayNameOrUsername(getContext()));
|
||||
if (recipient.isReleaseNotes()) {
|
||||
SpanUtil.appendCenteredImageSpan(title, ContextUtil.requireDrawable(getContext(), R.drawable.ic_official_28), 28, 28);
|
||||
}
|
||||
contactTitle.setText(title);
|
||||
return title.toString();
|
||||
}
|
||||
|
||||
public void setAbout(@Nullable String about) {
|
||||
public void setAbout(@NonNull Recipient recipient) {
|
||||
String about;
|
||||
if (recipient.isReleaseNotes()) {
|
||||
about = getContext().getString(R.string.ReleaseNotes__signal_release_notes_and_news);
|
||||
} else {
|
||||
about = recipient.getCombinedAboutAndEmoji();
|
||||
}
|
||||
|
||||
contactAbout.setText(about);
|
||||
contactAbout.setVisibility(TextUtils.isEmpty(about) ? GONE : VISIBLE);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.PopupWindow
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.ContextMenuList
|
||||
|
||||
/**
|
||||
* The context menu shown after long pressing a message in ConversationActivity.
|
||||
*/
|
||||
class ConversationContextMenu(private val anchor: View, items: List<ActionItem>) : PopupWindow(
|
||||
LayoutInflater.from(anchor.context).inflate(R.layout.signal_context_menu, null),
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
) {
|
||||
|
||||
val context: Context = anchor.context
|
||||
|
||||
private val contextMenuList = ContextMenuList(
|
||||
recyclerView = contentView.findViewById(R.id.signal_context_menu_list),
|
||||
onItemClick = { dismiss() },
|
||||
)
|
||||
|
||||
init {
|
||||
setBackgroundDrawable(ContextCompat.getDrawable(context, R.drawable.signal_context_menu_background))
|
||||
animationStyle = R.style.ConversationContextMenuAnimation
|
||||
|
||||
isFocusable = false
|
||||
isOutsideTouchable = true
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 21) {
|
||||
elevation = 20f
|
||||
}
|
||||
|
||||
setTouchInterceptor { _, event ->
|
||||
event.action == MotionEvent.ACTION_OUTSIDE
|
||||
}
|
||||
|
||||
contextMenuList.setItems(items)
|
||||
|
||||
contentView.measure(
|
||||
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
|
||||
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
||||
)
|
||||
}
|
||||
|
||||
fun getMaxWidth(): Int = contentView.measuredWidth
|
||||
fun getMaxHeight(): Int = contentView.measuredHeight
|
||||
|
||||
fun show(offsetX: Int, offsetY: Int) {
|
||||
showAsDropDown(anchor, offsetX, offsetY, Gravity.TOP or Gravity.START)
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,10 @@ import android.animation.Animator;
|
||||
import android.animation.LayoutTransition;
|
||||
import android.animation.ValueAnimator;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Rect;
|
||||
import android.net.Uri;
|
||||
@@ -58,7 +58,8 @@ import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.ViewKt;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.recyclerview.widget.RecyclerView.OnScrollListener;
|
||||
@@ -72,7 +73,6 @@ import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.ConversationScrollToView;
|
||||
import org.thoughtcrime.securesms.components.ConversationTypingView;
|
||||
@@ -122,8 +122,8 @@ import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.longmessage.LongMessageActivity;
|
||||
import org.thoughtcrime.securesms.messagedetails.MessageDetailsActivity;
|
||||
import org.thoughtcrime.securesms.longmessage.LongMessageFragment;
|
||||
import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment;
|
||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestState;
|
||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
@@ -150,6 +150,7 @@ import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity;
|
||||
import org.thoughtcrime.securesms.util.CachedInflater;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.HtmlUtil;
|
||||
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||
import org.thoughtcrime.securesms.util.RemoteDeleteUtil;
|
||||
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
@@ -182,7 +183,6 @@ import java.util.Set;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
import kotlin.Unit;
|
||||
import kotlin.jvm.functions.Function1;
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
public class ConversationFragment extends LoggingFragment implements MultiselectForwardFragment.Callback {
|
||||
@@ -198,7 +198,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
|
||||
private LiveRecipient recipient;
|
||||
private long threadId;
|
||||
private boolean isReacting;
|
||||
private ActionMode actionMode;
|
||||
private Locale locale;
|
||||
private FrameLayout videoContainer;
|
||||
@@ -253,7 +252,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
@Override
|
||||
public void onCreate(Bundle icicle) {
|
||||
super.onCreate(icicle);
|
||||
this.locale = (Locale) getArguments().getSerializable(PassphraseRequiredActivity.LOCALE_EXTRA);
|
||||
this.locale = Locale.getDefault();
|
||||
startupStopwatch = new Stopwatch("conversation-open");
|
||||
SignalLocalMetrics.ConversationOpen.start();
|
||||
}
|
||||
@@ -323,9 +322,9 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
|
||||
giphyMp4ProjectionRecycler = initializeGiphyMp4();
|
||||
|
||||
this.groupViewModel = ViewModelProviders.of(requireActivity(), new ConversationGroupViewModel.Factory()).get(ConversationGroupViewModel.class);
|
||||
this.messageCountsViewModel = ViewModelProviders.of(requireActivity()).get(MessageCountsViewModel.class);
|
||||
this.conversationViewModel = ViewModelProviders.of(requireActivity(), new ConversationViewModel.Factory()).get(ConversationViewModel.class);
|
||||
this.groupViewModel = new ViewModelProvider(getParentFragment(), new ConversationGroupViewModel.Factory()).get(ConversationGroupViewModel.class);
|
||||
this.messageCountsViewModel = new ViewModelProvider(getParentFragment()).get(MessageCountsViewModel.class);
|
||||
this.conversationViewModel = new ViewModelProvider(getParentFragment(), new ConversationViewModel.Factory()).get(ConversationViewModel.class);
|
||||
|
||||
conversationViewModel.getChatColors().observe(getViewLifecycleOwner(), recyclerViewColorizer::setChatColors);
|
||||
conversationViewModel.getMessages().observe(getViewLifecycleOwner(), messages -> {
|
||||
@@ -381,6 +380,13 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
|
||||
conversationViewModel.getActiveNotificationProfile().observe(getViewLifecycleOwner(), this::updateNotificationProfileStatus);
|
||||
|
||||
initializeScrollButtonAnimations();
|
||||
initializeResources();
|
||||
initializeMessageRequestViewModel();
|
||||
initializeListAdapter();
|
||||
|
||||
conversationViewModel.getSearchQuery().observe(getViewLifecycleOwner(), this::onSearchQueryUpdated);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -394,7 +400,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
|
||||
GiphyMp4PlaybackController.attach(list, callback, maxPlayback);
|
||||
list.addItemDecoration(new GiphyMp4ItemDecoration(callback, translationY -> {
|
||||
reactionsShade.setTranslationY(translationY);
|
||||
reactionsShade.setTranslationY(translationY + list.getHeight());
|
||||
return Unit.INSTANCE;
|
||||
}), 0);
|
||||
|
||||
@@ -414,21 +420,9 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityCreated(Bundle bundle) {
|
||||
super.onActivityCreated(bundle);
|
||||
|
||||
Log.d(TAG, "[onActivityCreated]");
|
||||
|
||||
initializeScrollButtonAnimations();
|
||||
initializeResources();
|
||||
initializeMessageRequestViewModel();
|
||||
initializeListAdapter();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(Activity activity) {
|
||||
super.onAttach(activity);
|
||||
this.listener = (ConversationFragmentListener)activity;
|
||||
public void onAttach(Context context) {
|
||||
super.onAttach(context);
|
||||
this.listener = (ConversationFragmentListener) getParentFragment();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -525,7 +519,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
private void initializeMessageRequestViewModel() {
|
||||
MessageRequestViewModel.Factory factory = new MessageRequestViewModel.Factory(requireContext());
|
||||
|
||||
messageRequestViewModel = ViewModelProviders.of(requireActivity(), factory).get(MessageRequestViewModel.class);
|
||||
messageRequestViewModel = new ViewModelProvider(requireParentFragment(), factory).get(MessageRequestViewModel.class);
|
||||
messageRequestViewModel.setConversationInfo(recipient.getId(), threadId);
|
||||
|
||||
listener.onMessageRequest(messageRequestViewModel);
|
||||
@@ -559,9 +553,8 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
conversationBanner.setAvatar(GlideApp.with(context), recipient);
|
||||
conversationBanner.showBackgroundBubble(recipient.hasWallpaper());
|
||||
|
||||
String title = isSelf ? context.getString(R.string.note_to_self) : recipient.getDisplayNameOrUsername(context);
|
||||
conversationBanner.setTitle(title);
|
||||
conversationBanner.setAbout(recipient.getCombinedAboutAndEmoji());
|
||||
String title = conversationBanner.setTitle(recipient);
|
||||
conversationBanner.setAbout(recipient);
|
||||
|
||||
if (recipient.isGroup()) {
|
||||
if (pendingMemberCount > 0) {
|
||||
@@ -775,7 +768,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
}
|
||||
|
||||
if (menuState.shouldShowSaveAttachmentAction()) {
|
||||
items.add(new ActionItem(R.drawable.ic_save_24, getResources().getString(R.string.conversation_selection__menu_save), () -> {
|
||||
items.add(new ActionItem(R.drawable.ic_save_24_tinted, getResources().getString(R.string.conversation_selection__menu_save), () -> {
|
||||
handleSaveAttachment((MediaMmsMessageRecord) getSelectedConversationMessage().getMessageRecord());
|
||||
actionMode.finish();
|
||||
}));
|
||||
@@ -817,19 +810,21 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
ViewUtil.animateIn(bottomActionBar, bottomActionBar.getEnterAnimation());
|
||||
listener.onBottomActionBarVisibilityChanged(View.VISIBLE);
|
||||
|
||||
ViewKt.doOnPreDraw(bottomActionBar, new Function1<View, Unit>() {
|
||||
@Override public Unit invoke(View view) {
|
||||
if (view.getHeight() == 0 && view.getVisibility() == View.VISIBLE) {
|
||||
ViewKt.doOnPreDraw(bottomActionBar, this);
|
||||
return Unit.INSTANCE;
|
||||
bottomActionBar.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
|
||||
@Override
|
||||
public boolean onPreDraw() {
|
||||
if (bottomActionBar.getHeight() == 0 && bottomActionBar.getVisibility() == View.VISIBLE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int bottomPadding = view.getHeight() + (int) DimensionUnit.DP.toPixels(18);
|
||||
bottomActionBar.getViewTreeObserver().removeOnPreDrawListener(this);
|
||||
|
||||
int bottomPadding = bottomActionBar.getHeight() + (int) DimensionUnit.DP.toPixels(18);
|
||||
list.setPadding(list.getPaddingLeft(), list.getPaddingTop(), list.getPaddingRight(), bottomPadding);
|
||||
|
||||
list.scrollBy(0, -(bottomPadding - additionalScrollOffset));
|
||||
|
||||
return Unit.INSTANCE;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -905,7 +900,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
}
|
||||
|
||||
inlineDateDecoration = new StickyHeaderDecoration(adapter, false, false, ConversationAdapter.HEADER_TYPE_INLINE_DATE);
|
||||
list.addItemDecoration(inlineDateDecoration);
|
||||
list.addItemDecoration(inlineDateDecoration, 0);
|
||||
}
|
||||
|
||||
public void setLastSeen(long lastSeen) {
|
||||
@@ -914,7 +909,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
}
|
||||
|
||||
lastSeenDecoration = new LastSeenHeader(getListAdapter(), lastSeen);
|
||||
list.addItemDecoration(lastSeenDecoration);
|
||||
list.addItemDecoration(lastSeenDecoration, 0);
|
||||
}
|
||||
|
||||
private void handleCopyMessage(final Set<MultiselectPart> multiselectParts) {
|
||||
@@ -1010,7 +1005,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
}
|
||||
|
||||
private void handleDisplayDetails(ConversationMessage message) {
|
||||
startActivity(MessageDetailsActivity.getIntentForMessageDetails(requireContext(), message.getMessageRecord(), recipient.getId(), threadId));
|
||||
MessageDetailsFragment.create(message.getMessageRecord(), recipient.getId()).show(getChildFragmentManager(), null);
|
||||
}
|
||||
|
||||
private void handleForwardMessageParts(Set<MultiselectPart> multiselectParts) {
|
||||
@@ -1033,11 +1028,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
}
|
||||
|
||||
private void handleReplyMessage(final ConversationMessage message) {
|
||||
if (getActivity() != null) {
|
||||
//noinspection ConstantConditions
|
||||
((AppCompatActivity) getActivity()).getSupportActionBar().collapseActionView();
|
||||
}
|
||||
|
||||
listener.handleReplyMessage(message);
|
||||
}
|
||||
|
||||
@@ -1173,7 +1163,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
return getListAdapter().isTypingViewEnabled();
|
||||
}
|
||||
|
||||
public void onSearchQueryUpdated(@Nullable String query) {
|
||||
private void onSearchQueryUpdated(@Nullable String query) {
|
||||
if (getListAdapter() != null) {
|
||||
getListAdapter().onSearchQueryUpdated(query);
|
||||
}
|
||||
@@ -1319,23 +1309,26 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
|
||||
|
||||
public interface ConversationFragmentListener extends VoiceNoteMediaControllerOwner {
|
||||
void setThreadId(long threadId);
|
||||
void handleReplyMessage(ConversationMessage conversationMessage);
|
||||
void onMessageActionToolbarOpened();
|
||||
void onBottomActionBarVisibilityChanged(int visibility);
|
||||
void onForwardClicked();
|
||||
void onMessageRequest(@NonNull MessageRequestViewModel viewModel);
|
||||
void handleReaction(@NonNull ConversationMessage conversationMessage,
|
||||
@NonNull Toolbar.OnMenuItemClickListener toolbarListener,
|
||||
@NonNull ConversationReactionOverlay.OnHideListener onHideListener);
|
||||
void onCursorChanged();
|
||||
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
|
||||
void onVoiceNotePause(@NonNull Uri uri);
|
||||
void onVoiceNotePlay(@NonNull Uri uri, long messageId, double progress);
|
||||
void onVoiceNoteSeekTo(@NonNull Uri uri, double progress);
|
||||
void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed);
|
||||
void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
|
||||
void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
|
||||
boolean isKeyboardOpen();
|
||||
void setThreadId(long threadId);
|
||||
void handleReplyMessage(ConversationMessage conversationMessage);
|
||||
void onMessageActionToolbarOpened();
|
||||
void onBottomActionBarVisibilityChanged(int visibility);
|
||||
void onForwardClicked();
|
||||
void onMessageRequest(@NonNull MessageRequestViewModel viewModel);
|
||||
void handleReaction(@NonNull ConversationMessage conversationMessage,
|
||||
@NonNull ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener,
|
||||
@NonNull SelectedConversationModel selectedConversationModel,
|
||||
@NonNull ConversationReactionOverlay.OnHideListener onHideListener);
|
||||
void onCursorChanged();
|
||||
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
|
||||
void onVoiceNotePause(@NonNull Uri uri);
|
||||
void onVoiceNotePlay(@NonNull Uri uri, long messageId, double progress);
|
||||
void onVoiceNoteResume(@NonNull Uri uri, long messageId);
|
||||
void onVoiceNoteSeekTo(@NonNull Uri uri, double progress);
|
||||
void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed);
|
||||
void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
|
||||
void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
|
||||
}
|
||||
|
||||
private class ConversationScrollListener extends OnScrollListener {
|
||||
@@ -1442,16 +1435,91 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
multiselectItemDecoration.setFocusedItem(new MultiselectPart.Message(item.getConversationMessage()));
|
||||
list.invalidateItemDecorations();
|
||||
|
||||
isReacting = true;
|
||||
reactionsShade.setVisibility(View.VISIBLE);
|
||||
list.setLayoutFrozen(true);
|
||||
listener.handleReaction(item.getConversationMessage(), new ReactionsToolbarListener(item.getConversationMessage()), () -> {
|
||||
isReacting = false;
|
||||
reactionsShade.setVisibility(View.GONE);
|
||||
list.setLayoutFrozen(false);
|
||||
WindowUtil.setLightStatusBarFromTheme(requireActivity());
|
||||
clearFocusedItem();
|
||||
});
|
||||
|
||||
if (itemView instanceof ConversationItem) {
|
||||
Uri audioUri = getAudioUriForLongClick(messageRecord);
|
||||
if (audioUri != null) {
|
||||
listener.onVoiceNotePause(audioUri);
|
||||
}
|
||||
|
||||
Bitmap videoBitmap = null;
|
||||
int childAdapterPosition = list.getChildAdapterPosition(itemView);
|
||||
|
||||
GiphyMp4ProjectionPlayerHolder mp4Holder = null;
|
||||
if (childAdapterPosition != RecyclerView.NO_POSITION) {
|
||||
mp4Holder = giphyMp4ProjectionRecycler.getCurrentHolder(childAdapterPosition);
|
||||
if (mp4Holder != null && mp4Holder.isVisible()) {
|
||||
mp4Holder.pause();
|
||||
videoBitmap = mp4Holder.getBitmap();
|
||||
mp4Holder.hide();
|
||||
} else {
|
||||
mp4Holder = null;
|
||||
}
|
||||
}
|
||||
final GiphyMp4ProjectionPlayerHolder finalMp4Holder = mp4Holder;
|
||||
|
||||
ConversationItem conversationItem = (ConversationItem) itemView;
|
||||
Bitmap bitmap = ConversationItemSelection.snapshotView(conversationItem, list, messageRecord, videoBitmap);
|
||||
|
||||
View focusedView = listener.isKeyboardOpen() ? conversationItem.getRootView().findFocus() : null;
|
||||
|
||||
final ConversationItemBodyBubble bodyBubble = conversationItem.bodyBubble;
|
||||
SelectedConversationModel selectedConversationModel = new SelectedConversationModel(bitmap,
|
||||
itemView.getX(),
|
||||
itemView.getY() + list.getTranslationY(),
|
||||
bodyBubble.getX(),
|
||||
bodyBubble.getY(),
|
||||
bodyBubble.getWidth(),
|
||||
audioUri,
|
||||
messageRecord.isOutgoing(),
|
||||
focusedView);
|
||||
|
||||
bodyBubble.setVisibility(View.INVISIBLE);
|
||||
conversationItem.reactionsView.setVisibility(View.INVISIBLE);
|
||||
|
||||
ViewUtil.hideKeyboard(requireContext(), conversationItem);
|
||||
|
||||
boolean showScrollButtons = conversationViewModel.getShowScrollButtons();
|
||||
if (showScrollButtons) {
|
||||
conversationViewModel.setShowScrollButtons(false);
|
||||
}
|
||||
|
||||
listener.handleReaction(item.getConversationMessage(),
|
||||
new ReactionsToolbarListener(item.getConversationMessage()),
|
||||
selectedConversationModel,
|
||||
new ConversationReactionOverlay.OnHideListener() {
|
||||
@Override public void startHide() {
|
||||
multiselectItemDecoration.hideShade(list);
|
||||
ViewUtil.fadeOut(reactionsShade, getResources().getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE);
|
||||
}
|
||||
|
||||
@Override public void onHide() {
|
||||
list.setLayoutFrozen(false);
|
||||
|
||||
if (selectedConversationModel.getAudioUri() != null) {
|
||||
listener.onVoiceNoteResume(selectedConversationModel.getAudioUri(), messageRecord.getId());
|
||||
}
|
||||
|
||||
WindowUtil.setLightStatusBarFromTheme(requireActivity());
|
||||
WindowUtil.setLightNavigationBarFromTheme(requireActivity());
|
||||
clearFocusedItem();
|
||||
|
||||
if (finalMp4Holder != null) {
|
||||
finalMp4Holder.show();
|
||||
finalMp4Holder.resume();
|
||||
}
|
||||
|
||||
bodyBubble.setVisibility(View.VISIBLE);
|
||||
conversationItem.reactionsView.setVisibility(View.VISIBLE);
|
||||
|
||||
if (showScrollButtons) {
|
||||
conversationViewModel.setShowScrollButtons(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
clearFocusedItem();
|
||||
((ConversationAdapter) list.getAdapter()).toggleSelection(item);
|
||||
@@ -1461,6 +1529,20 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable private Uri getAudioUriForLongClick(@NonNull MessageRecord messageRecord) {
|
||||
VoiceNotePlaybackState playbackState = listener.getVoiceNoteMediaController().getVoiceNotePlaybackState().getValue();
|
||||
if (playbackState == null || !playbackState.isPlaying()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!MessageRecordUtil.hasAudio(messageRecord) || !messageRecord.isMms()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Uri messageUri = ((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide().getUri();
|
||||
return playbackState.getUri().equals(messageUri) ? messageUri : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onQuoteClicked(MmsMessageRecord messageRecord) {
|
||||
if (messageRecord.getQuote() == null) {
|
||||
@@ -1493,7 +1575,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
@Override
|
||||
public void onMoreTextClicked(@NonNull RecipientId conversationRecipientId, long messageId, boolean isMms) {
|
||||
if (getContext() != null && getActivity() != null) {
|
||||
startActivity(LongMessageActivity.getIntent(getContext(), conversationRecipientId, messageId, isMms));
|
||||
LongMessageFragment.create(messageId, isMms).show(getChildFragmentManager(), null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1594,16 +1676,16 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
|
||||
@Override
|
||||
public void onReactionClicked(@NonNull MultiselectPart multiselectPart, long messageId, boolean isMms) {
|
||||
if (getContext() == null) return;
|
||||
if (getParentFragment() == null) return;
|
||||
|
||||
ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(requireFragmentManager(), null);
|
||||
ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(getParentFragmentManager(), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId) {
|
||||
if (getContext() == null) return;
|
||||
if (getParentFragment() == null) return;
|
||||
|
||||
RecipientBottomSheetDialogFragment.create(recipientId, groupId).show(requireFragmentManager(), "BOTTOM");
|
||||
RecipientBottomSheetDialogFragment.create(recipientId, groupId).show(getParentFragmentManager(), "BOTTOM");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1659,7 +1741,11 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
|
||||
@Override
|
||||
public void onGroupMigrationLearnMoreClicked(@NonNull GroupMigrationMembershipChange membershipChange) {
|
||||
GroupsV1MigrationInfoBottomSheetDialogFragment.show(requireFragmentManager(), membershipChange);
|
||||
if (getParentFragment() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
GroupsV1MigrationInfoBottomSheetDialogFragment.show(getParentFragmentManager(), membershipChange);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1693,7 +1779,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
.setView(R.layout.safety_number_changed_learn_more_dialog)
|
||||
.setPositiveButton(R.string.ConversationFragment_verify, (d, w) -> {
|
||||
SimpleTask.run(getLifecycle(), () -> {
|
||||
return ApplicationDependencies.getIdentityStore().getIdentityRecord(recipient.getId());
|
||||
return ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecord(recipient.getId());
|
||||
}, identityRecord -> {
|
||||
if (identityRecord.isPresent()) {
|
||||
startActivity(VerifyIdentityActivity.newIntent(requireContext(), identityRecord.get()));
|
||||
@@ -1763,6 +1849,25 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
public void onChangeNumberUpdateContact(@NonNull Recipient recipient) {
|
||||
startActivity(RecipientExporter.export(recipient).asAddContactIntent());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCallToAction(@NonNull String action) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDonateClicked() {
|
||||
if (SignalStore.donationsValues().isLikelyASustainer()) {
|
||||
NavHostFragment navHostFragment = NavHostFragment.create(R.navigation.boosts);
|
||||
|
||||
requireActivity().getSupportFragmentManager()
|
||||
.beginTransaction()
|
||||
.add(navHostFragment, "boost_nav")
|
||||
.commitNow();
|
||||
} else {
|
||||
startActivity(AppSettingsActivity.subscriptions(requireContext()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void refreshList() {
|
||||
@@ -1875,7 +1980,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
}
|
||||
}
|
||||
|
||||
private class ReactionsToolbarListener implements Toolbar.OnMenuItemClickListener {
|
||||
private class ReactionsToolbarListener implements ConversationReactionOverlay.OnActionSelectedListener {
|
||||
|
||||
private final ConversationMessage conversationMessage;
|
||||
|
||||
@@ -1884,16 +1989,32 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onMenuItemClick(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case R.id.action_info: handleDisplayDetails(conversationMessage); return true;
|
||||
case R.id.action_delete: handleDeleteMessages(conversationMessage.getMultiselectCollection().toSet()); return true;
|
||||
case R.id.action_copy: handleCopyMessage(conversationMessage.getMultiselectCollection().toSet()); return true;
|
||||
case R.id.action_reply: handleReplyMessage(conversationMessage); return true;
|
||||
case R.id.action_multiselect: handleEnterMultiSelect(conversationMessage); return true;
|
||||
case R.id.action_forward: handleForwardMessageParts(conversationMessage.getMultiselectCollection().toSet()); return true;
|
||||
case R.id.action_download: handleSaveAttachment((MediaMmsMessageRecord) conversationMessage.getMessageRecord()); return true;
|
||||
default: return false;
|
||||
public void onActionSelected(@NonNull ConversationReactionOverlay.Action action) {
|
||||
switch (action) {
|
||||
case REPLY:
|
||||
handleReplyMessage(conversationMessage);
|
||||
break;
|
||||
case FORWARD:
|
||||
handleForwardMessageParts(conversationMessage.getMultiselectCollection().toSet());
|
||||
break;
|
||||
case RESEND:
|
||||
handleResendMessage(conversationMessage.getMessageRecord());
|
||||
break;
|
||||
case DOWNLOAD:
|
||||
handleSaveAttachment((MediaMmsMessageRecord) conversationMessage.getMessageRecord());
|
||||
break;
|
||||
case COPY:
|
||||
handleCopyMessage(conversationMessage.getMultiselectCollection().toSet());
|
||||
break;
|
||||
case MULTISELECT:
|
||||
handleEnterMultiSelect(conversationMessage);
|
||||
break;
|
||||
case VIEW_INFO:
|
||||
handleDisplayDetails(conversationMessage);
|
||||
break;
|
||||
case DELETE:
|
||||
handleDeleteMessages(conversationMessage.getMultiselectCollection().toSet());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import android.view.MotionEvent;
|
||||
import android.view.TouchDelegate;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
@@ -122,6 +123,7 @@ import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan;
|
||||
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
|
||||
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||
import org.thoughtcrime.securesms.util.PlaceholderURLSpan;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
import org.thoughtcrime.securesms.util.ProjectionList;
|
||||
import org.thoughtcrime.securesms.util.SearchUtil;
|
||||
@@ -142,6 +144,7 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* A view that displays an individual conversation item within a conversation
|
||||
@@ -160,6 +163,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
private static final Rect SWIPE_RECT = new Rect();
|
||||
|
||||
public static final float LONG_PRESS_SCALE_FACTOR = 0.95f;
|
||||
private static final int SHRINK_BUBBLE_DELAY_MILLIS = 100;
|
||||
private static final long MAX_CLUSTERING_TIME_DIFF = TimeUnit.MINUTES.toMillis(3);
|
||||
|
||||
private ConversationMessage conversationMessage;
|
||||
private MessageRecord messageRecord;
|
||||
private Optional<MessageRecord> nextMessageRecord;
|
||||
@@ -196,6 +203,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
private Stub<LinkPreviewView> linkPreviewStub;
|
||||
private Stub<BorderlessImageView> stickerStub;
|
||||
private Stub<ViewOnceMessageView> revealableStub;
|
||||
private Stub<Button> callToActionStub;
|
||||
private @Nullable EventListener eventListener;
|
||||
|
||||
private int defaultBubbleColor;
|
||||
@@ -224,6 +232,25 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
private float lastYDownRelativeToThis;
|
||||
private ProjectionList colorizerProjections = new ProjectionList(3);
|
||||
|
||||
private final Runnable shrinkBubble = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
bodyBubble.animate()
|
||||
.scaleX(LONG_PRESS_SCALE_FACTOR)
|
||||
.scaleY(LONG_PRESS_SCALE_FACTOR)
|
||||
.setUpdateListener(animation -> {
|
||||
View parent = (View) getParent();
|
||||
if (parent != null) {
|
||||
parent.invalidate();
|
||||
}
|
||||
});
|
||||
|
||||
reactionsView.animate()
|
||||
.scaleX(LONG_PRESS_SCALE_FACTOR)
|
||||
.scaleY(LONG_PRESS_SCALE_FACTOR);
|
||||
}
|
||||
};
|
||||
|
||||
public ConversationItem(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
@@ -259,6 +286,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
this.linkPreviewStub = new Stub<>(findViewById(R.id.link_preview_stub));
|
||||
this.stickerStub = new Stub<>(findViewById(R.id.sticker_view_stub));
|
||||
this.revealableStub = new Stub<>(findViewById(R.id.revealable_view_stub));
|
||||
this.callToActionStub = ViewUtil.findStubById(this, R.id.conversation_item_call_to_action_stub);
|
||||
this.groupSenderHolder = findViewById(R.id.group_sender_holder);
|
||||
this.quoteView = findViewById(R.id.quote_view);
|
||||
this.reply = findViewById(R.id.reply_icon_wrapper);
|
||||
@@ -343,6 +371,27 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
setGroupAuthorColor(messageRecord, hasWallpaper, colorizer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchTouchEvent(MotionEvent ev) {
|
||||
switch (ev.getAction()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
getHandler().postDelayed(shrinkBubble, SHRINK_BUBBLE_DELAY_MILLIS);
|
||||
break;
|
||||
case MotionEvent.ACTION_UP:
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
getHandler().removeCallbacks(shrinkBubble);
|
||||
bodyBubble.animate()
|
||||
.scaleX(1.0f)
|
||||
.scaleY(1.0f);
|
||||
reactionsView.animate()
|
||||
.scaleX(1.0f)
|
||||
.scaleY(1.0f);
|
||||
break;
|
||||
}
|
||||
|
||||
return super.dispatchTouchEvent(ev);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onInterceptTouchEvent(MotionEvent ev) {
|
||||
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
|
||||
@@ -407,6 +456,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
!hasAudio(messageRecord) &&
|
||||
isFooterVisible(messageRecord, nextMessageRecord, groupThread) &&
|
||||
!bodyText.isJumbomoji() &&
|
||||
conversationMessage.getBottomButton() == null &&
|
||||
!StringUtil.hasMixedTextDirection(bodyText.getText()) &&
|
||||
bodyText.getLastLineWidth() > 0)
|
||||
{
|
||||
TextView dateView = footer.getDateView();
|
||||
@@ -513,6 +564,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
availableWidth = bodyBubble.getMeasuredWidth() - bodyBubble.getPaddingLeft() - bodyBubble.getPaddingRight();
|
||||
}
|
||||
|
||||
availableWidth = Math.min(availableWidth, getMaxBubbleWidth());
|
||||
|
||||
availableWidth -= ViewUtil.getLeftMargin(forView) + ViewUtil.getRightMargin(forView);
|
||||
|
||||
return availableWidth;
|
||||
@@ -884,6 +937,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
bodyText.setText(StringUtil.trim(styledText));
|
||||
bodyText.setVisibility(View.VISIBLE);
|
||||
|
||||
if (conversationMessage.getBottomButton() != null) {
|
||||
callToActionStub.get().setVisibility(View.VISIBLE);
|
||||
callToActionStub.get().setText(conversationMessage.getBottomButton().getLabel());
|
||||
callToActionStub.get().setOnClickListener(v -> {
|
||||
if (eventListener != null) {
|
||||
eventListener.onCallToAction(conversationMessage.getBottomButton().getAction());
|
||||
}
|
||||
});
|
||||
} else if (callToActionStub.resolved()) {
|
||||
callToActionStub.get().setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1288,6 +1353,19 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
}
|
||||
|
||||
if (conversationMessage.hasStyleLinks()) {
|
||||
for (PlaceholderURLSpan placeholder : messageBody.getSpans(0, messageBody.length(), PlaceholderURLSpan.class)) {
|
||||
int start = messageBody.getSpanStart(placeholder);
|
||||
int end = messageBody.getSpanEnd(placeholder);
|
||||
URLSpan span = new InterceptableLongClickCopyLinkSpan(placeholder.getValue(),
|
||||
urlClickListener,
|
||||
ContextCompat.getColor(getContext(), R.color.signal_accent_primary),
|
||||
false);
|
||||
|
||||
messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
List<Annotation> mentionAnnotations = MentionAnnotation.getMentionAnnotations(messageBody);
|
||||
for (Annotation annotation : mentionAnnotations) {
|
||||
messageBody.setSpan(new MentionClickableSpan(RecipientId.from(annotation.getValue())), messageBody.getSpanStart(annotation), messageBody.getSpanEnd(annotation), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
@@ -1485,7 +1563,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
contactPhotoHolder.setVisibility(VISIBLE);
|
||||
|
||||
if (!previous.isPresent() || previous.get().isUpdate() || !current.getRecipient().equals(previous.get().getRecipient()) ||
|
||||
!DateUtils.isSameDay(previous.get().getTimestamp(), current.getTimestamp()))
|
||||
!DateUtils.isSameDay(previous.get().getTimestamp(), current.getTimestamp()) || !isWithinClusteringTime(current, previous.get()))
|
||||
{
|
||||
groupSenderHolder.setVisibility(VISIBLE);
|
||||
|
||||
@@ -1499,7 +1577,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
groupSenderHolder.setVisibility(GONE);
|
||||
}
|
||||
|
||||
if (!next.isPresent() || next.get().isUpdate() || !current.getRecipient().equals(next.get().getRecipient())) {
|
||||
if (!next.isPresent() || next.get().isUpdate() || !current.getRecipient().equals(next.get().getRecipient()) || !isWithinClusteringTime(current, next.get())) {
|
||||
contactPhoto.setVisibility(VISIBLE);
|
||||
badgeImageView.setVisibility(VISIBLE);
|
||||
} else {
|
||||
@@ -1599,20 +1677,21 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
private boolean isStartOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, boolean isGroupThread) {
|
||||
if (isGroupThread) {
|
||||
return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) ||
|
||||
!current.getRecipient().equals(previous.get().getRecipient());
|
||||
!current.getRecipient().equals(previous.get().getRecipient()) || !isWithinClusteringTime(current, previous.get());
|
||||
} else {
|
||||
return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) ||
|
||||
current.isOutgoing() != previous.get().isOutgoing();
|
||||
current.isOutgoing() != previous.get().isOutgoing() || previous.get().isSecure() != current.isSecure() || !isWithinClusteringTime(current, previous.get());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isEndOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
|
||||
if (isGroupThread) {
|
||||
return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) ||
|
||||
!current.getRecipient().equals(next.get().getRecipient()) || !current.getReactions().isEmpty();
|
||||
!current.getRecipient().equals(next.get().getRecipient()) || !current.getReactions().isEmpty() || !isWithinClusteringTime(current, next.get());
|
||||
} else {
|
||||
return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) ||
|
||||
current.isOutgoing() != next.get().isOutgoing() || !current.getReactions().isEmpty();
|
||||
current.isOutgoing() != next.get().isOutgoing() || !current.getReactions().isEmpty() || next.get().isSecure() != current.isSecure() ||
|
||||
!isWithinClusteringTime(current, next.get());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1627,6 +1706,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
current.isFailed() || current.isRateLimited() || differentTimestamps || isEndOfMessageCluster(current, next, isGroupThread);
|
||||
}
|
||||
|
||||
private static boolean isWithinClusteringTime(@NonNull MessageRecord lhs, @NonNull MessageRecord rhs) {
|
||||
long timeDiff = Math.abs(lhs.getDateSent() - rhs.getDateSent());
|
||||
return timeDiff <= MAX_CLUSTERING_TIME_DIFF;
|
||||
}
|
||||
|
||||
private void setMessageSpacing(@NonNull Context context, @NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
|
||||
int spacingTop = readDimen(context, R.dimen.conversation_vertical_message_spacing_collapse);
|
||||
int spacingBottom = spacingTop;
|
||||
@@ -1735,7 +1819,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
@Override
|
||||
public @NonNull Projection getGiphyMp4PlayableProjection(@NonNull ViewGroup recyclerView) {
|
||||
if (mediaThumbnailStub != null && mediaThumbnailStub.isResolvable()) {
|
||||
return Projection.relativeToParent(recyclerView, mediaThumbnailStub.require(), mediaThumbnailStub.require().getCorners())
|
||||
ConversationItemThumbnail thumbnail = mediaThumbnailStub.require();
|
||||
return Projection.relativeToParent(recyclerView, thumbnail, thumbnail.getCorners())
|
||||
.scale(bodyBubble.getScaleX())
|
||||
.translateX(Util.halfOffsetFromScale(thumbnail.getWidth(), bodyBubble.getScaleX()))
|
||||
.translateY(Util.halfOffsetFromScale(thumbnail.getHeight(), bodyBubble.getScaleY()))
|
||||
.translateY(getTranslationY())
|
||||
.translateX(bodyBubble.getTranslationX())
|
||||
.translateX(getTranslationX());
|
||||
@@ -1752,6 +1840,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
return mediaThumbnailStub != null && mediaThumbnailStub.isResolvable() && canPlayContent;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldProjectContent() {
|
||||
return canPlayContent() && bodyBubble.getVisibility() == VISIBLE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull ProjectionList getColorizerProjections(@NonNull ViewGroup coordinateRoot) {
|
||||
colorizerProjections.clear();
|
||||
@@ -1759,34 +1852,70 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (messageRecord.isOutgoing() &&
|
||||
!hasNoBubble(messageRecord) &&
|
||||
!messageRecord.isRemoteDelete() &&
|
||||
bodyBubbleCorners != null)
|
||||
bodyBubbleCorners != null &&
|
||||
bodyBubble.getVisibility() == VISIBLE)
|
||||
{
|
||||
Projection bodyBubbleToRoot = Projection.relativeToParent(coordinateRoot, bodyBubble, bodyBubbleCorners).translateX(bodyBubble.getTranslationX());
|
||||
Projection videoToBubble = bodyBubble.getVideoPlayerProjection();
|
||||
|
||||
float translationX = Util.halfOffsetFromScale(bodyBubble.getWidth(), bodyBubble.getScaleX());
|
||||
float translationY = Util.halfOffsetFromScale(bodyBubble.getHeight(), bodyBubble.getScaleY());
|
||||
|
||||
if (videoToBubble != null) {
|
||||
Projection videoToRoot = Projection.translateFromDescendantToParentCoords(videoToBubble, bodyBubble, coordinateRoot);
|
||||
colorizerProjections.addAll(Projection.getCapAndTail(bodyBubbleToRoot, videoToRoot));
|
||||
|
||||
List<Projection> projections = Projection.getCapAndTail(bodyBubbleToRoot, videoToRoot);
|
||||
if (!projections.isEmpty()) {
|
||||
projections.get(0)
|
||||
.scale(bodyBubble.getScaleX())
|
||||
.translateX(translationX)
|
||||
.translateY(translationY);
|
||||
projections.get(1)
|
||||
.scale(bodyBubble.getScaleX())
|
||||
.translateX(translationX)
|
||||
.translateY(-translationY);
|
||||
}
|
||||
|
||||
colorizerProjections.addAll(projections);
|
||||
} else {
|
||||
colorizerProjections.add(bodyBubbleToRoot);
|
||||
colorizerProjections.add(
|
||||
bodyBubbleToRoot.scale(bodyBubble.getScaleX())
|
||||
.translateX(translationX)
|
||||
.translateY(translationY)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (messageRecord.isOutgoing() &&
|
||||
hasNoBubble(messageRecord) &&
|
||||
hasWallpaper)
|
||||
hasWallpaper &&
|
||||
bodyBubble.getVisibility() == VISIBLE)
|
||||
{
|
||||
Projection footerProjection = getActiveFooter(messageRecord).getProjection(coordinateRoot);
|
||||
ConversationItemFooter footer = getActiveFooter(messageRecord);
|
||||
Projection footerProjection = footer.getProjection(coordinateRoot);
|
||||
if (footerProjection != null) {
|
||||
colorizerProjections.add(footerProjection.translateX(bodyBubble.getTranslationX()));
|
||||
colorizerProjections.add(
|
||||
footerProjection.translateX(bodyBubble.getTranslationX())
|
||||
.scale(bodyBubble.getScaleX())
|
||||
.translateX(Util.halfOffsetFromScale(footer.getWidth(), bodyBubble.getScaleX()))
|
||||
.translateY(-Util.halfOffsetFromScale(footer.getHeight(), bodyBubble.getScaleY()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!messageRecord.isOutgoing() &&
|
||||
hasQuote(messageRecord) &&
|
||||
quoteView != null)
|
||||
quoteView != null &&
|
||||
bodyBubble.getVisibility() == VISIBLE)
|
||||
{
|
||||
bodyBubble.setQuoteViewProjection(quoteView.getProjection(bodyBubble));
|
||||
colorizerProjections.add(quoteView.getProjection(coordinateRoot).translateX(bodyBubble.getTranslationX() + this.getTranslationX()));
|
||||
|
||||
float bubbleOffsetFromScale = Util.halfOffsetFromScale(bodyBubble.getHeight(), bodyBubble.getScaleY());
|
||||
Projection cProj = quoteView.getProjection(coordinateRoot)
|
||||
.translateX(bodyBubble.getTranslationX() + this.getTranslationX() + Util.halfOffsetFromScale(quoteView.getWidth(), bodyBubble.getScaleX()))
|
||||
.translateY(bubbleOffsetFromScale - quoteView.getY() + (quoteView.getY() * bodyBubble.getScaleY()))
|
||||
.scale(bodyBubble.getScaleX());
|
||||
colorizerProjections.add(cProj);
|
||||
}
|
||||
|
||||
for (int i = 0; i < colorizerProjections.size(); i++) {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Path
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.graphics.applyCanvas
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.withClip
|
||||
import androidx.core.graphics.withTranslation
|
||||
import androidx.core.view.children
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.util.hasNoBubble
|
||||
|
||||
object ConversationItemSelection {
|
||||
|
||||
@JvmStatic
|
||||
fun snapshotView(
|
||||
conversationItem: ConversationItem,
|
||||
list: RecyclerView,
|
||||
messageRecord: MessageRecord,
|
||||
videoBitmap: Bitmap?,
|
||||
): Bitmap {
|
||||
val isOutgoing = messageRecord.isOutgoing
|
||||
val hasNoBubble = messageRecord.hasNoBubble(conversationItem.context)
|
||||
|
||||
return snapshotMessage(
|
||||
conversationItem = conversationItem,
|
||||
list = list,
|
||||
videoBitmap = videoBitmap,
|
||||
drawConversationItem = !isOutgoing || hasNoBubble,
|
||||
hasReaction = messageRecord.reactions.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun snapshotMessage(
|
||||
conversationItem: ConversationItem,
|
||||
list: RecyclerView,
|
||||
videoBitmap: Bitmap?,
|
||||
drawConversationItem: Boolean,
|
||||
hasReaction: Boolean,
|
||||
): Bitmap {
|
||||
val bodyBubble = conversationItem.bodyBubble
|
||||
val reactionsView = conversationItem.reactionsView
|
||||
|
||||
val originalScale = bodyBubble.scaleX
|
||||
bodyBubble.scaleX = 1.0f
|
||||
bodyBubble.scaleY = 1.0f
|
||||
|
||||
val projections = conversationItem.getColorizerProjections(list)
|
||||
|
||||
val path = Path()
|
||||
|
||||
val xTranslation = -conversationItem.x - bodyBubble.x
|
||||
val yTranslation = -conversationItem.y - bodyBubble.y
|
||||
|
||||
val mp4Projection = conversationItem.getGiphyMp4PlayableProjection(list)
|
||||
var scaledVideoBitmap = videoBitmap
|
||||
if (videoBitmap != null) {
|
||||
scaledVideoBitmap = Bitmap.createScaledBitmap(
|
||||
videoBitmap,
|
||||
(videoBitmap.width / originalScale).toInt(),
|
||||
(videoBitmap.height / originalScale).toInt(),
|
||||
true
|
||||
)
|
||||
|
||||
mp4Projection.translateX(xTranslation)
|
||||
mp4Projection.translateY(yTranslation)
|
||||
mp4Projection.applyToPath(path)
|
||||
}
|
||||
|
||||
projections.use {
|
||||
it.forEach { p ->
|
||||
p.translateX(xTranslation)
|
||||
p.translateY(yTranslation)
|
||||
p.applyToPath(path)
|
||||
}
|
||||
}
|
||||
|
||||
conversationItem.destroyAllDrawingCaches()
|
||||
|
||||
var bitmapHeight = bodyBubble.height
|
||||
if (hasReaction) {
|
||||
bitmapHeight += (reactionsView.height - DimensionUnit.DP.toPixels(4f)).toInt()
|
||||
}
|
||||
return createBitmap(bodyBubble.width, bitmapHeight).applyCanvas {
|
||||
if (drawConversationItem) {
|
||||
bodyBubble.draw(this)
|
||||
}
|
||||
|
||||
withClip(path) {
|
||||
withTranslation(x = xTranslation, y = yTranslation) {
|
||||
list.draw(this)
|
||||
|
||||
if (scaledVideoBitmap != null) {
|
||||
drawBitmap(scaledVideoBitmap, mp4Projection.x - xTranslation, mp4Projection.y - yTranslation, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
withTranslation(
|
||||
x = reactionsView.x - bodyBubble.x,
|
||||
y = reactionsView.y - bodyBubble.y
|
||||
) {
|
||||
reactionsView.draw(this)
|
||||
}
|
||||
}.also {
|
||||
mp4Projection.release()
|
||||
bodyBubble.scaleX = originalScale
|
||||
bodyBubble.scaleY = originalScale
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ViewGroup.destroyAllDrawingCaches() {
|
||||
children.forEach {
|
||||
it.destroyDrawingCache()
|
||||
|
||||
if (it is ViewGroup) {
|
||||
it.destroyAllDrawingCaches()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.database.MentionUtil;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Collections;
|
||||
@@ -26,10 +27,11 @@ import java.util.List;
|
||||
* for various presentations.
|
||||
*/
|
||||
public class ConversationMessage {
|
||||
@NonNull private final MessageRecord messageRecord;
|
||||
@NonNull private final List<Mention> mentions;
|
||||
@Nullable private final SpannableString body;
|
||||
@NonNull private final MultiselectCollection multiselectCollection;
|
||||
@NonNull private final MessageRecord messageRecord;
|
||||
@NonNull private final List<Mention> mentions;
|
||||
@Nullable private final SpannableString body;
|
||||
@NonNull private final MultiselectCollection multiselectCollection;
|
||||
@NonNull private final MessageStyler.Result styleResult;
|
||||
|
||||
private ConversationMessage(@NonNull MessageRecord messageRecord) {
|
||||
this(messageRecord, null, null);
|
||||
@@ -40,13 +42,26 @@ public class ConversationMessage {
|
||||
@Nullable List<Mention> mentions)
|
||||
{
|
||||
this.messageRecord = messageRecord;
|
||||
this.body = body != null ? SpannableString.valueOf(body) : null;
|
||||
this.mentions = mentions != null ? mentions : Collections.emptyList();
|
||||
|
||||
if (body != null) {
|
||||
this.body = SpannableString.valueOf(body);
|
||||
} else if (messageRecord.hasMessageRanges()) {
|
||||
this.body = SpannableString.valueOf(messageRecord.getBody());
|
||||
} else {
|
||||
this.body = null;
|
||||
}
|
||||
|
||||
if (!this.mentions.isEmpty() && this.body != null) {
|
||||
MentionAnnotation.setMentionAnnotations(this.body, this.mentions);
|
||||
}
|
||||
|
||||
if (this.body != null && messageRecord.hasMessageRanges()) {
|
||||
styleResult = MessageStyler.style(messageRecord.requireMessageRanges(), this.body);
|
||||
} else {
|
||||
styleResult = MessageStyler.Result.none();
|
||||
}
|
||||
|
||||
multiselectCollection = Multiselect.getParts(this);
|
||||
}
|
||||
|
||||
@@ -86,6 +101,14 @@ public class ConversationMessage {
|
||||
return (body != null) ? body : messageRecord.getDisplayBody(context);
|
||||
}
|
||||
|
||||
public boolean hasStyleLinks() {
|
||||
return styleResult.getHasStyleLinks();
|
||||
}
|
||||
|
||||
public @Nullable BodyRangeList.BodyRange.Button getBottomButton() {
|
||||
return styleResult.getBottomButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory providing multiple ways of creating {@link ConversationMessage}s.
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.app.ActivityOptionsCompat;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -47,15 +48,14 @@ public class ConversationPopupActivity extends ConversationActivity {
|
||||
else getWindow().setLayout((int) (width * .7), (int) (height * .75));
|
||||
|
||||
super.onCreate(bundle, ready);
|
||||
|
||||
titleView.setOnClickListener(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
composeText.requestFocus();
|
||||
quickAttachmentToggle.disable();
|
||||
getTitleView().setOnClickListener(null);
|
||||
getComposeText().requestFocus();
|
||||
getQuickAttachmentToggle().disable();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -101,21 +101,20 @@ public class ConversationPopupActivity extends ConversationActivity {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initializeActionBar() {
|
||||
super.initializeActionBar();
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(false);
|
||||
public void onInitializeToolbar(Toolbar toolbar) {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void sendComplete(long threadId) {
|
||||
super.sendComplete(threadId);
|
||||
public void onSendComplete(long threadId) {
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateReminders() {
|
||||
if (reminderView.resolved()) {
|
||||
reminderView.get().setVisibility(View.GONE);
|
||||
public boolean onUpdateReminders() {
|
||||
if (getReminderView().resolved()) {
|
||||
getReminderView().get().setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.graphics.PointF;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
@@ -24,7 +23,7 @@ final class ConversationReactionDelegate {
|
||||
private final PointF lastSeenDownPoint = new PointF();
|
||||
|
||||
private ConversationReactionOverlay.OnReactionSelectedListener onReactionSelectedListener;
|
||||
private Toolbar.OnMenuItemClickListener onToolbarItemClickedListener;
|
||||
private ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener;
|
||||
private ConversationReactionOverlay.OnHideListener onHideListener;
|
||||
|
||||
ConversationReactionDelegate(@NonNull Stub<ConversationReactionOverlay> overlayStub) {
|
||||
@@ -38,9 +37,10 @@ final class ConversationReactionDelegate {
|
||||
void show(@NonNull Activity activity,
|
||||
@NonNull Recipient conversationRecipient,
|
||||
@NonNull ConversationMessage conversationMessage,
|
||||
boolean isNonAdminInAnnouncementGroup)
|
||||
boolean isNonAdminInAnnouncementGroup,
|
||||
@NonNull SelectedConversationModel selectedConversationModel)
|
||||
{
|
||||
resolveOverlay().show(activity, conversationRecipient, conversationMessage, lastSeenDownPoint, isNonAdminInAnnouncementGroup);
|
||||
resolveOverlay().show(activity, conversationRecipient, conversationMessage, lastSeenDownPoint, isNonAdminInAnnouncementGroup, selectedConversationModel);
|
||||
}
|
||||
|
||||
void hide() {
|
||||
@@ -59,11 +59,11 @@ final class ConversationReactionDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
void setOnToolbarItemClickedListener(@NonNull Toolbar.OnMenuItemClickListener onToolbarItemClickedListener) {
|
||||
this.onToolbarItemClickedListener = onToolbarItemClickedListener;
|
||||
void setOnActionSelectedListener(@NonNull ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener) {
|
||||
this.onActionSelectedListener = onActionSelectedListener;
|
||||
|
||||
if (overlayStub.resolved()) {
|
||||
overlayStub.get().setOnToolbarItemClickedListener(onToolbarItemClickedListener);
|
||||
overlayStub.get().setOnActionSelectedListener(onActionSelectedListener);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ final class ConversationReactionDelegate {
|
||||
overlay.requestFitSystemWindows();
|
||||
|
||||
overlay.setOnHideListener(onHideListener);
|
||||
overlay.setOnToolbarItemClickedListener(onToolbarItemClickedListener);
|
||||
overlay.setOnActionSelectedListener(onActionSelectedListener);
|
||||
overlay.setOnReactionSelectedListener(onReactionSelectedListener);
|
||||
|
||||
return overlay;
|
||||
|
||||
@@ -2,34 +2,42 @@ package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.AnimatorSet;
|
||||
import android.animation.ObjectAnimator;
|
||||
import android.animation.ValueAnimator;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.PointF;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.os.Build;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.HapticFeedbackConstants;
|
||||
import android.view.MenuItem;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.Window;
|
||||
import android.view.animation.DecelerateInterpolator;
|
||||
import android.view.animation.Interpolator;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.view.ViewKt;
|
||||
import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiUtil;
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
@@ -39,10 +47,12 @@ import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
import kotlin.Unit;
|
||||
|
||||
public final class ConversationReactionOverlay extends FrameLayout {
|
||||
|
||||
private static final Interpolator INTERPOLATOR = new DecelerateInterpolator();
|
||||
|
||||
@@ -54,45 +64,45 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
private final Boundary verticalScrubBoundary = new Boundary();
|
||||
private final PointF deadzoneTouchPoint = new PointF();
|
||||
|
||||
private Activity activity;
|
||||
private Recipient conversationRecipient;
|
||||
private MessageRecord messageRecord;
|
||||
private OverlayState overlayState = OverlayState.HIDDEN;
|
||||
private boolean isNonAdminInAnnouncementGroup;
|
||||
private Activity activity;
|
||||
private Recipient conversationRecipient;
|
||||
private MessageRecord messageRecord;
|
||||
private SelectedConversationModel selectedConversationModel;
|
||||
private OverlayState overlayState = OverlayState.HIDDEN;
|
||||
private boolean isNonAdminInAnnouncementGroup;
|
||||
|
||||
private boolean downIsOurs;
|
||||
private boolean isToolbarTouch;
|
||||
private int selected = -1;
|
||||
private int customEmojiIndex;
|
||||
private int originalStatusBarColor;
|
||||
private int originalNavigationBarColor;
|
||||
|
||||
private View dropdownAnchor;
|
||||
private View toolbarShade;
|
||||
private View inputShade;
|
||||
private View conversationItem;
|
||||
private View backgroundView;
|
||||
private ConstraintLayout foregroundView;
|
||||
private View selectedView;
|
||||
private EmojiImageView[] emojiViews;
|
||||
private Toolbar toolbar;
|
||||
|
||||
private ConversationContextMenu contextMenu;
|
||||
|
||||
private float touchDownDeadZoneSize;
|
||||
private float distanceFromTouchDownPointToTopOfScrubberDeadZone;
|
||||
private float distanceFromTouchDownPointToBottomOfScrubberDeadZone;
|
||||
private int scrubberDistanceFromTouchDown;
|
||||
private int scrubberHeight;
|
||||
private int scrubberWidth;
|
||||
private int actionBarHeight;
|
||||
private int selectedVerticalTranslation;
|
||||
private int scrubberHorizontalMargin;
|
||||
private int animationEmojiStartDelayFactor;
|
||||
private int statusBarHeight;
|
||||
private int bottomNavigationBarHeight;
|
||||
|
||||
private OnReactionSelectedListener onReactionSelectedListener;
|
||||
private Toolbar.OnMenuItemClickListener onToolbarItemClickedListener;
|
||||
private OnActionSelectedListener onActionSelectedListener;
|
||||
private OnHideListener onHideListener;
|
||||
|
||||
private AnimatorSet revealAnimatorSet = new AnimatorSet();
|
||||
private AnimatorSet revealMaskAnimatorSet = new AnimatorSet();
|
||||
private AnimatorSet hideAnimatorSet = new AnimatorSet();
|
||||
private AnimatorSet hideAllButMaskAnimatorSet = new AnimatorSet();
|
||||
private AnimatorSet hideMaskAnimatorSet = new AnimatorSet();
|
||||
private AnimatorSet revealAnimatorSet = new AnimatorSet();
|
||||
private AnimatorSet hideAnimatorSet = new AnimatorSet();
|
||||
|
||||
public ConversationReactionOverlay(@NonNull Context context) {
|
||||
super(context);
|
||||
@@ -106,13 +116,13 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
protected void onFinishInflate() {
|
||||
super.onFinishInflate();
|
||||
|
||||
backgroundView = findViewById(R.id.conversation_reaction_scrubber_background);
|
||||
foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground);
|
||||
selectedView = findViewById(R.id.conversation_reaction_current_selection_indicator);
|
||||
toolbar = findViewById(R.id.conversation_reaction_toolbar);
|
||||
|
||||
toolbar.setOnMenuItemClickListener(this::handleToolbarItemClicked);
|
||||
toolbar.setNavigationOnClickListener(view -> hide());
|
||||
dropdownAnchor = findViewById(R.id.dropdown_anchor);
|
||||
toolbarShade = findViewById(R.id.toolbar_shade);
|
||||
inputShade = findViewById(R.id.input_shade);
|
||||
conversationItem = findViewById(R.id.conversation_item);
|
||||
backgroundView = findViewById(R.id.conversation_reaction_scrubber_background);
|
||||
foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground);
|
||||
selectedView = findViewById(R.id.conversation_reaction_current_selection_indicator);
|
||||
|
||||
emojiViews = new EmojiImageView[] { findViewById(R.id.reaction_1),
|
||||
findViewById(R.id.reaction_2),
|
||||
@@ -124,16 +134,12 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
|
||||
customEmojiIndex = emojiViews.length - 1;
|
||||
|
||||
distanceFromTouchDownPointToTopOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_top);
|
||||
distanceFromTouchDownPointToBottomOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom);
|
||||
|
||||
touchDownDeadZoneSize = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size);
|
||||
scrubberDistanceFromTouchDown = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrubber_distance);
|
||||
scrubberHeight = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrubber_height);
|
||||
scrubberWidth = getResources().getDimensionPixelOffset(R.dimen.reaction_scrubber_width);
|
||||
actionBarHeight = (int) ThemeUtil.getThemedDimen(getContext(), R.attr.actionBarSize);
|
||||
selectedVerticalTranslation = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation);
|
||||
scrubberHorizontalMargin = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin);
|
||||
touchDownDeadZoneSize = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_touch_deadzone_size);
|
||||
scrubberWidth = getResources().getDimensionPixelOffset(R.dimen.reaction_scrubber_width);
|
||||
selectedVerticalTranslation = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_vertical_translation);
|
||||
scrubberHorizontalMargin = getResources().getDimensionPixelOffset(R.dimen.conversation_reaction_scrub_horizontal_margin);
|
||||
|
||||
animationEmojiStartDelayFactor = getResources().getInteger(R.integer.reaction_scrubber_emoji_reveal_duration_start_delay_factor);
|
||||
|
||||
@@ -144,7 +150,8 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
@NonNull Recipient conversationRecipient,
|
||||
@NonNull ConversationMessage conversationMessage,
|
||||
@NonNull PointF lastSeenDownPoint,
|
||||
boolean isNonAdminInAnnouncementGroup)
|
||||
boolean isNonAdminInAnnouncementGroup,
|
||||
@NonNull SelectedConversationModel selectedConversationModel)
|
||||
{
|
||||
if (overlayState != OverlayState.HIDDEN) {
|
||||
return;
|
||||
@@ -152,77 +159,325 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
|
||||
this.messageRecord = conversationMessage.getMessageRecord();
|
||||
this.conversationRecipient = conversationRecipient;
|
||||
this.selectedConversationModel = selectedConversationModel;
|
||||
this.isNonAdminInAnnouncementGroup = isNonAdminInAnnouncementGroup;
|
||||
overlayState = OverlayState.UNINITAILIZED;
|
||||
selected = -1;
|
||||
|
||||
setupToolbarMenuItems(conversationMessage);
|
||||
setupSelectedEmoji();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 21) {
|
||||
View statusBarBackground = activity.findViewById(android.R.id.statusBarBackground);
|
||||
statusBarHeight = statusBarBackground == null ? 0 : statusBarBackground.getHeight();
|
||||
|
||||
View navigationBarBackground = activity.findViewById(android.R.id.navigationBarBackground);
|
||||
bottomNavigationBarHeight = navigationBarBackground == null ? 0 : navigationBarBackground.getHeight();
|
||||
} else {
|
||||
statusBarHeight = ViewUtil.getStatusBarHeight(this);
|
||||
statusBarHeight = ViewUtil.getStatusBarHeight(this);
|
||||
bottomNavigationBarHeight = ViewUtil.getNavigationBarHeight(this);
|
||||
}
|
||||
|
||||
final float scrubberTranslationY = Math.max(-scrubberDistanceFromTouchDown + actionBarHeight,
|
||||
lastSeenDownPoint.y - scrubberHeight - scrubberDistanceFromTouchDown - statusBarHeight);
|
||||
boolean isLandscape = getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
|
||||
if (isLandscape) {
|
||||
bottomNavigationBarHeight = 0;
|
||||
}
|
||||
|
||||
final float halfWidth = scrubberWidth / 2f + scrubberHorizontalMargin;
|
||||
final float screenWidth = getResources().getDisplayMetrics().widthPixels;
|
||||
final float downX = ViewUtil.isLtr(this) ? lastSeenDownPoint.x : screenWidth - lastSeenDownPoint.x;
|
||||
final float scrubberTranslationX = Util.clamp(downX - halfWidth,
|
||||
scrubberHorizontalMargin,
|
||||
screenWidth + scrubberHorizontalMargin - halfWidth * 2) * (ViewUtil.isLtr(this) ? 1 : -1);
|
||||
toolbarShade.setVisibility(VISIBLE);
|
||||
toolbarShade.setAlpha(1f);
|
||||
|
||||
backgroundView.setTranslationX(scrubberTranslationX);
|
||||
backgroundView.setTranslationY(scrubberTranslationY);
|
||||
inputShade.setVisibility(VISIBLE);
|
||||
inputShade.setAlpha(1f);
|
||||
|
||||
foregroundView.setTranslationX(scrubberTranslationX);
|
||||
foregroundView.setTranslationY(scrubberTranslationY);
|
||||
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
|
||||
|
||||
verticalScrubBoundary.update(lastSeenDownPoint.y - distanceFromTouchDownPointToTopOfScrubberDeadZone,
|
||||
lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone);
|
||||
conversationItem.setLayoutParams(new LayoutParams(conversationItemSnapshot.getWidth(), conversationItemSnapshot.getHeight()));
|
||||
conversationItem.setBackground(new BitmapDrawable(getResources(), conversationItemSnapshot));
|
||||
|
||||
hideAnimatorSet.end();
|
||||
toolbar.setVisibility(VISIBLE);
|
||||
setVisibility(View.VISIBLE);
|
||||
revealAnimatorSet.start();
|
||||
boolean isMessageOnLeft = selectedConversationModel.isOutgoing() ^ ViewUtil.isLtr(this);
|
||||
|
||||
conversationItem.setScaleX(ConversationItem.LONG_PRESS_SCALE_FACTOR);
|
||||
conversationItem.setScaleY(ConversationItem.LONG_PRESS_SCALE_FACTOR);
|
||||
|
||||
setVisibility(View.INVISIBLE);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 21) {
|
||||
this.activity = activity;
|
||||
originalStatusBarColor = activity.getWindow().getStatusBarColor();
|
||||
WindowUtil.setStatusBarColor(activity.getWindow(), ContextCompat.getColor(getContext(), R.color.action_mode_status_bar));
|
||||
updateSystemUiOnShow(activity);
|
||||
}
|
||||
|
||||
if (!ThemeUtil.isDarkTheme(getContext())) {
|
||||
WindowUtil.setLightStatusBar(activity.getWindow());
|
||||
ViewKt.doOnLayout(this, v -> {
|
||||
showAfterLayout(activity, conversationMessage, lastSeenDownPoint, isMessageOnLeft);
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
private void showAfterLayout(@NonNull Activity activity,
|
||||
@NonNull ConversationMessage conversationMessage,
|
||||
@NonNull PointF lastSeenDownPoint,
|
||||
boolean isMessageOnLeft) {
|
||||
updateToolbarShade(activity);
|
||||
updateInputShade(activity);
|
||||
|
||||
contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(conversationMessage));
|
||||
|
||||
conversationItem.setX(selectedConversationModel.getBubbleX());
|
||||
conversationItem.setY(selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() - statusBarHeight);
|
||||
|
||||
Bitmap conversationItemSnapshot = selectedConversationModel.getBitmap();
|
||||
boolean isWideLayout = contextMenu.getMaxWidth() + scrubberWidth < getWidth();
|
||||
|
||||
int overlayHeight = getHeight() - bottomNavigationBarHeight;
|
||||
int bubbleWidth = selectedConversationModel.getBubbleWidth();
|
||||
|
||||
float endX = selectedConversationModel.getBubbleX();
|
||||
float endY = conversationItem.getY();
|
||||
float endApparentTop = endY;
|
||||
float endScale = 1f;
|
||||
|
||||
float menuPadding = DimensionUnit.DP.toPixels(12f);
|
||||
float reactionBarTopPadding = DimensionUnit.DP.toPixels(32f);
|
||||
int reactionBarHeight = backgroundView.getHeight();
|
||||
|
||||
float reactionBarBackgroundY;
|
||||
|
||||
if (isWideLayout) {
|
||||
boolean everythingFitsVertically = reactionBarHeight + menuPadding + reactionBarTopPadding + conversationItemSnapshot.getHeight() < overlayHeight;
|
||||
if (everythingFitsVertically) {
|
||||
boolean reactionBarFitsAboveItem = conversationItem.getY() > reactionBarHeight + menuPadding + reactionBarTopPadding;
|
||||
|
||||
if (reactionBarFitsAboveItem) {
|
||||
reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
|
||||
} else {
|
||||
endY = reactionBarHeight + menuPadding + reactionBarTopPadding;
|
||||
reactionBarBackgroundY = reactionBarTopPadding;
|
||||
}
|
||||
} else {
|
||||
float spaceAvailableForItem = overlayHeight - reactionBarHeight - menuPadding - reactionBarTopPadding;
|
||||
|
||||
endScale = spaceAvailableForItem / conversationItem.getHeight();
|
||||
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
|
||||
endY = reactionBarHeight + menuPadding + reactionBarTopPadding - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
|
||||
reactionBarBackgroundY = reactionBarTopPadding;
|
||||
}
|
||||
} else {
|
||||
float reactionBarOffset = DimensionUnit.DP.toPixels(48);
|
||||
float spaceForReactionBar = Math.max(reactionBarHeight + reactionBarOffset - conversationItemSnapshot.getHeight(), 0);
|
||||
boolean everythingFitsVertically = contextMenu.getMaxHeight() + conversationItemSnapshot.getHeight() + menuPadding + spaceForReactionBar < overlayHeight;
|
||||
|
||||
if (everythingFitsVertically) {
|
||||
float bubbleBottom = selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
|
||||
boolean menuFitsBelowItem = bubbleBottom + menuPadding + contextMenu.getMaxHeight() <= overlayHeight + statusBarHeight;
|
||||
|
||||
if (menuFitsBelowItem) {
|
||||
if (conversationItem.getY() < 0) {
|
||||
endY = 0;
|
||||
}
|
||||
float contextMenuTop = endY + conversationItemSnapshot.getHeight();
|
||||
reactionBarBackgroundY = getReactionBarOffsetForTouch(lastSeenDownPoint, contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
|
||||
|
||||
if (reactionBarBackgroundY <= reactionBarTopPadding) {
|
||||
endY = backgroundView.getHeight() + menuPadding + reactionBarTopPadding;
|
||||
}
|
||||
} else {
|
||||
endY = overlayHeight - contextMenu.getMaxHeight() - menuPadding - conversationItemSnapshot.getHeight();
|
||||
|
||||
float contextMenuTop = endY + conversationItemSnapshot.getHeight();
|
||||
reactionBarBackgroundY = getReactionBarOffsetForTouch(lastSeenDownPoint, contextMenuTop, menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
|
||||
}
|
||||
|
||||
endApparentTop = endY;
|
||||
} else if (reactionBarOffset + reactionBarHeight + contextMenu.getMaxHeight() + menuPadding < overlayHeight) {
|
||||
float spaceAvailableForItem = (float) overlayHeight - contextMenu.getMaxHeight() - menuPadding - spaceForReactionBar;
|
||||
|
||||
endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
|
||||
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
|
||||
endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
|
||||
|
||||
float contextMenuTop = endY + (conversationItemSnapshot.getHeight() * endScale);
|
||||
reactionBarBackgroundY = getReactionBarOffsetForTouch(lastSeenDownPoint, contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY);
|
||||
endApparentTop = endY + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale);
|
||||
} else {
|
||||
contextMenu.setHeight(contextMenu.getMaxHeight() / 2);
|
||||
|
||||
int menuHeight = contextMenu.getHeight();
|
||||
boolean fitsVertically = menuHeight + conversationItem.getHeight() + menuPadding * 2 + reactionBarHeight + reactionBarTopPadding < overlayHeight;
|
||||
|
||||
if (fitsVertically) {
|
||||
float bubbleBottom = selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() + conversationItemSnapshot.getHeight();
|
||||
boolean menuFitsBelowItem = bubbleBottom + menuPadding + menuHeight <= overlayHeight + statusBarHeight;
|
||||
|
||||
if (menuFitsBelowItem) {
|
||||
reactionBarBackgroundY = conversationItem.getY() - menuPadding - reactionBarHeight;
|
||||
|
||||
if (reactionBarBackgroundY < reactionBarTopPadding) {
|
||||
endY = reactionBarTopPadding + reactionBarHeight + menuPadding;
|
||||
reactionBarBackgroundY = reactionBarTopPadding;
|
||||
}
|
||||
} else {
|
||||
endY = overlayHeight - menuHeight - menuPadding - conversationItemSnapshot.getHeight();
|
||||
reactionBarBackgroundY = endY - reactionBarHeight - menuPadding;
|
||||
}
|
||||
endApparentTop = endY;
|
||||
} else {
|
||||
float spaceAvailableForItem = (float) overlayHeight - menuHeight - menuPadding * 2 - reactionBarHeight - reactionBarTopPadding;
|
||||
|
||||
endScale = spaceAvailableForItem / conversationItemSnapshot.getHeight();
|
||||
endX += Util.halfOffsetFromScale(conversationItemSnapshot.getWidth(), endScale) * (isMessageOnLeft ? -1 : 1);
|
||||
endY = reactionBarHeight - Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale) + menuPadding + reactionBarTopPadding;
|
||||
reactionBarBackgroundY = reactionBarTopPadding;
|
||||
endApparentTop = reactionBarHeight + menuPadding + reactionBarTopPadding;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reactionBarBackgroundY = Math.max(reactionBarBackgroundY, -statusBarHeight);
|
||||
|
||||
hideAnimatorSet.end();
|
||||
setVisibility(View.VISIBLE);
|
||||
|
||||
float scrubberX;
|
||||
if (isMessageOnLeft) {
|
||||
scrubberX = scrubberHorizontalMargin;
|
||||
} else {
|
||||
scrubberX = getWidth() - scrubberWidth - scrubberHorizontalMargin;
|
||||
}
|
||||
|
||||
foregroundView.setX(scrubberX);
|
||||
foregroundView.setY(reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.getHeight() / 2f);
|
||||
|
||||
backgroundView.setX(scrubberX);
|
||||
backgroundView.setY(reactionBarBackgroundY);
|
||||
|
||||
verticalScrubBoundary.update(reactionBarBackgroundY,
|
||||
lastSeenDownPoint.y + distanceFromTouchDownPointToBottomOfScrubberDeadZone);
|
||||
|
||||
updateBoundsOnLayoutChanged();
|
||||
|
||||
revealAnimatorSet.start();
|
||||
|
||||
if (isWideLayout) {
|
||||
float scrubberRight = scrubberX + scrubberWidth;
|
||||
float offsetX = isMessageOnLeft ? scrubberRight + menuPadding : scrubberX - contextMenu.getMaxWidth() - menuPadding;
|
||||
contextMenu.show((int) offsetX, (int) Math.min(backgroundView.getY(), overlayHeight - contextMenu.getMaxHeight()));
|
||||
} else {
|
||||
float contentX = selectedConversationModel.getBubbleX();
|
||||
float offsetX = isMessageOnLeft ? contentX : -contextMenu.getMaxWidth() + contentX + bubbleWidth;
|
||||
|
||||
float menuTop = endApparentTop + (conversationItemSnapshot.getHeight() * endScale);
|
||||
contextMenu.show((int) offsetX, (int) (menuTop + menuPadding));
|
||||
}
|
||||
|
||||
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
|
||||
|
||||
conversationItem.animate()
|
||||
.x(endX)
|
||||
.y(endY)
|
||||
.scaleX(endScale)
|
||||
.scaleY(endScale)
|
||||
.setDuration(revealDuration);
|
||||
}
|
||||
|
||||
private float getReactionBarOffsetForTouch(@NonNull PointF touchPoint,
|
||||
float contextMenuTop,
|
||||
float contextMenuPadding,
|
||||
float reactionBarOffset,
|
||||
int reactionBarHeight,
|
||||
float spaceNeededBetweenTopOfScreenAndTopOfReactionBar,
|
||||
float messageTop)
|
||||
{
|
||||
float adjustedTouchY = touchPoint.y - statusBarHeight;
|
||||
float reactionStartingPoint = Math.min(adjustedTouchY, contextMenuTop);
|
||||
|
||||
float spaceBetweenTopOfMessageAndTopOfContextMenu = Math.abs(messageTop - contextMenuTop);
|
||||
|
||||
if (spaceBetweenTopOfMessageAndTopOfContextMenu < DimensionUnit.DP.toPixels(150)) {
|
||||
float offsetToMakeReactionBarOffsetMatchMenuPadding = reactionBarOffset - contextMenuPadding;
|
||||
reactionStartingPoint = messageTop + offsetToMakeReactionBarOffsetMatchMenuPadding;
|
||||
}
|
||||
|
||||
return Math.max(reactionStartingPoint - reactionBarOffset - reactionBarHeight, spaceNeededBetweenTopOfScreenAndTopOfReactionBar);
|
||||
}
|
||||
|
||||
private void updateToolbarShade(@NonNull Activity activity) {
|
||||
View toolbar = activity.findViewById(R.id.toolbar);
|
||||
View bannerContainer = activity.findViewById(R.id.conversation_banner_container);
|
||||
|
||||
LayoutParams layoutParams = (LayoutParams) toolbarShade.getLayoutParams();
|
||||
layoutParams.height = toolbar.getHeight() + bannerContainer.getHeight();
|
||||
toolbarShade.setLayoutParams(layoutParams);
|
||||
}
|
||||
|
||||
private void updateInputShade(@NonNull Activity activity) {
|
||||
LayoutParams layoutParams = (LayoutParams) inputShade.getLayoutParams();
|
||||
layoutParams.bottomMargin = bottomNavigationBarHeight;
|
||||
layoutParams.height = getInputPanelHeight(activity);
|
||||
inputShade.setLayoutParams(layoutParams);
|
||||
}
|
||||
|
||||
private int getInputPanelHeight(@NonNull Activity activity) {
|
||||
View bottomPanel = activity.findViewById(R.id.conversation_activity_panel_parent);
|
||||
View emojiDrawer = activity.findViewById(R.id.emoji_drawer);
|
||||
|
||||
return bottomPanel.getHeight() + (emojiDrawer != null && emojiDrawer.getVisibility() == VISIBLE ? emojiDrawer.getHeight() : 0);
|
||||
}
|
||||
|
||||
@RequiresApi(api = 21)
|
||||
private void updateSystemUiOnShow(@NonNull Activity activity) {
|
||||
Window window = activity.getWindow();
|
||||
int barColor = ContextCompat.getColor(getContext(), R.color.conversation_item_selected_system_ui);
|
||||
|
||||
originalStatusBarColor = window.getStatusBarColor();
|
||||
WindowUtil.setStatusBarColor(window, barColor);
|
||||
|
||||
originalNavigationBarColor = window.getNavigationBarColor();
|
||||
WindowUtil.setNavigationBarColor(window, barColor);
|
||||
|
||||
if (!ThemeUtil.isDarkTheme(getContext())) {
|
||||
WindowUtil.clearLightStatusBar(window);
|
||||
WindowUtil.clearLightNavigationBar(window);
|
||||
}
|
||||
}
|
||||
|
||||
public void hide() {
|
||||
hideInternal(hideAnimatorSet, onHideListener);
|
||||
hideInternal(onHideListener);
|
||||
}
|
||||
|
||||
public void hideForReactWithAny() {
|
||||
hideInternal(hideAnimatorSet, null);
|
||||
hideInternal(onHideListener);
|
||||
}
|
||||
|
||||
private void hideInternal(@NonNull AnimatorSet hideAnimatorSet, @Nullable OnHideListener onHideListener) {
|
||||
private void hideInternal(@Nullable OnHideListener onHideListener) {
|
||||
overlayState = OverlayState.HIDDEN;
|
||||
|
||||
revealAnimatorSet.end();
|
||||
hideAnimatorSet.start();
|
||||
AnimatorSet animatorSet = newHideAnimatorSet();
|
||||
hideAnimatorSet = animatorSet;
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 21 && activity != null) {
|
||||
WindowUtil.setStatusBarColor(activity.getWindow(), originalStatusBarColor);
|
||||
WindowUtil.clearLightStatusBar(activity.getWindow());
|
||||
activity = null;
|
||||
}
|
||||
revealAnimatorSet.end();
|
||||
animatorSet.start();
|
||||
|
||||
if (onHideListener != null) {
|
||||
onHideListener.onHide();
|
||||
onHideListener.startHide();
|
||||
}
|
||||
|
||||
if (selectedConversationModel.getFocusedView() != null) {
|
||||
ViewUtil.focusAndShowKeyboard(selectedConversationModel.getFocusedView());
|
||||
}
|
||||
|
||||
animatorSet.addListener(new AnimationCompleteListener() {
|
||||
@Override public void onAnimationEnd(Animator animation) {
|
||||
animatorSet.removeListener(this);
|
||||
|
||||
toolbarShade.setVisibility(INVISIBLE);
|
||||
inputShade.setVisibility(INVISIBLE);
|
||||
|
||||
if (onHideListener != null) {
|
||||
onHideListener.onHide();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (contextMenu != null) {
|
||||
contextMenu.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +493,10 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
protected void onLayout(boolean changed, int l, int t, int r, int b) {
|
||||
super.onLayout(changed, l, t, r, b);
|
||||
|
||||
updateBoundsOnLayoutChanged();
|
||||
}
|
||||
|
||||
private void updateBoundsOnLayoutChanged() {
|
||||
backgroundView.getGlobalVisibleRect(emojiStripViewBounds);
|
||||
emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect);
|
||||
emojiStripViewBounds.left = getStart(emojiViewGlobalRect);
|
||||
@@ -300,24 +559,10 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
}
|
||||
}
|
||||
|
||||
if (isToolbarTouch) {
|
||||
if (motionEvent.getAction() == MotionEvent.ACTION_CANCEL || motionEvent.getAction() == MotionEvent.ACTION_UP) {
|
||||
isToolbarTouch = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (motionEvent.getAction()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
selected = getSelectedIndexViaDownEvent(motionEvent);
|
||||
|
||||
if (selected == -1) {
|
||||
if (motionEvent.getY() < toolbar.getHeight() + statusBarHeight) {
|
||||
isToolbarTouch = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
deadzoneTouchPoint.set(motionEvent.getX(), motionEvent.getY());
|
||||
overlayState = OverlayState.DEADZONE;
|
||||
downIsOurs = true;
|
||||
@@ -439,7 +684,7 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
}
|
||||
|
||||
private void handleUpEvent() {
|
||||
if (selected != -1 && onReactionSelectedListener != null) {
|
||||
if (selected != -1 && onReactionSelectedListener != null && backgroundView.getVisibility() == View.VISIBLE) {
|
||||
if (selected == customEmojiIndex) {
|
||||
onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].getTag() != null);
|
||||
} else {
|
||||
@@ -454,8 +699,8 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
this.onReactionSelectedListener = onReactionSelectedListener;
|
||||
}
|
||||
|
||||
public void setOnToolbarItemClickedListener(@Nullable Toolbar.OnMenuItemClickListener onToolbarItemClickedListener) {
|
||||
this.onToolbarItemClickedListener = onToolbarItemClickedListener;
|
||||
public void setOnActionSelectedListener(@Nullable OnActionSelectedListener onActionSelectedListener) {
|
||||
this.onActionSelectedListener = onActionSelectedListener;
|
||||
}
|
||||
|
||||
public void setOnHideListener(@Nullable OnHideListener onHideListener) {
|
||||
@@ -474,29 +719,69 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private void setupToolbarMenuItems(@NonNull ConversationMessage conversationMessage) {
|
||||
private @NonNull List<ActionItem> getMenuActionItems(@NonNull ConversationMessage conversationMessage) {
|
||||
MenuState menuState = MenuState.getMenuState(conversationRecipient, conversationMessage.getMultiselectCollection().toSet(), false, isNonAdminInAnnouncementGroup);
|
||||
|
||||
toolbar.getMenu().findItem(R.id.action_copy).setVisible(menuState.shouldShowCopyAction());
|
||||
toolbar.getMenu().findItem(R.id.action_download).setVisible(menuState.shouldShowSaveAttachmentAction());
|
||||
toolbar.getMenu().findItem(R.id.action_forward).setVisible(menuState.shouldShowForwardAction());
|
||||
toolbar.getMenu().findItem(R.id.action_reply).setVisible(menuState.shouldShowReplyAction());
|
||||
}
|
||||
List<ActionItem> items = new ArrayList<>();
|
||||
|
||||
private boolean handleToolbarItemClicked(@NonNull MenuItem menuItem) {
|
||||
|
||||
hide();
|
||||
|
||||
if (onToolbarItemClickedListener == null) {
|
||||
return false;
|
||||
if (menuState.shouldShowReplyAction()) {
|
||||
items.add(new ActionItem(R.drawable.ic_reply_24_tinted, getResources().getString(R.string.conversation_selection__menu_reply), () -> handleActionItemClicked(Action.REPLY)));
|
||||
}
|
||||
|
||||
return onToolbarItemClickedListener.onMenuItemClick(menuItem);
|
||||
if (menuState.shouldShowForwardAction()) {
|
||||
items.add(new ActionItem(R.drawable.ic_forward_24_tinted, getResources().getString(R.string.conversation_selection__menu_forward), () -> handleActionItemClicked(Action.FORWARD)));
|
||||
}
|
||||
|
||||
if (menuState.shouldShowResendAction()) {
|
||||
items.add(new ActionItem(R.drawable.ic_retry_24, getResources().getString(R.string.conversation_selection__menu_resend_message), () -> handleActionItemClicked(Action.RESEND)));
|
||||
}
|
||||
|
||||
if (menuState.shouldShowSaveAttachmentAction()) {
|
||||
items.add(new ActionItem(R.drawable.ic_save_24_tinted, getResources().getString(R.string.conversation_selection__menu_save), () -> handleActionItemClicked(Action.DOWNLOAD)));
|
||||
}
|
||||
|
||||
if (menuState.shouldShowCopyAction()) {
|
||||
items.add(new ActionItem(R.drawable.ic_copy_24_tinted, getResources().getString(R.string.conversation_selection__menu_copy), () -> handleActionItemClicked(Action.COPY)));
|
||||
}
|
||||
|
||||
items.add(new ActionItem(R.drawable.ic_select_24_tinted, getResources().getString(R.string.conversation_selection__menu_multi_select), () -> handleActionItemClicked(Action.MULTISELECT)));
|
||||
|
||||
if (menuState.shouldShowDetailsAction()) {
|
||||
items.add(new ActionItem(R.drawable.ic_info_tinted_24, getResources().getString(R.string.conversation_selection__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO)));
|
||||
}
|
||||
|
||||
backgroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE);
|
||||
foregroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE);
|
||||
|
||||
items.add(new ActionItem(R.drawable.ic_delete_tinted_24, getResources().getString(R.string.conversation_selection__menu_delete), () -> handleActionItemClicked(Action.DELETE)));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private void handleActionItemClicked(@NonNull Action action) {
|
||||
hideInternal(new OnHideListener() {
|
||||
@Override public void startHide() {
|
||||
if (onHideListener != null) {
|
||||
onHideListener.startHide();
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void onHide() {
|
||||
if (onHideListener != null) {
|
||||
onHideListener.onHide();
|
||||
}
|
||||
|
||||
if (onActionSelectedListener != null) {
|
||||
onActionSelectedListener.onActionSelected(action);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void initAnimators() {
|
||||
|
||||
int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
|
||||
int revealDuration = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_duration);
|
||||
int revealOffset = getContext().getResources().getInteger(R.integer.reaction_scrubber_reveal_offset);
|
||||
|
||||
List<Animator> reveals = Stream.of(emojiViews)
|
||||
.mapIndexed((idx, v) -> {
|
||||
@@ -507,81 +792,126 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
})
|
||||
.toList();
|
||||
|
||||
Animator overlayRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
|
||||
overlayRevealAnim.setDuration(duration);
|
||||
reveals.add(overlayRevealAnim);
|
||||
|
||||
Animator backgroundRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
|
||||
backgroundRevealAnim.setTarget(backgroundView);
|
||||
backgroundRevealAnim.setDuration(duration);
|
||||
backgroundRevealAnim.setDuration(revealDuration);
|
||||
backgroundRevealAnim.setStartDelay(revealOffset);
|
||||
reveals.add(backgroundRevealAnim);
|
||||
|
||||
Animator selectedRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
|
||||
selectedRevealAnim.setTarget(selectedView);
|
||||
selectedRevealAnim.setDuration(duration);
|
||||
backgroundRevealAnim.setDuration(revealDuration);
|
||||
backgroundRevealAnim.setStartDelay(revealOffset);
|
||||
reveals.add(selectedRevealAnim);
|
||||
|
||||
Animator toolbarRevealAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_in);
|
||||
toolbarRevealAnim.setTarget(toolbar);
|
||||
toolbarRevealAnim.setDuration(duration);
|
||||
reveals.add(toolbarRevealAnim);
|
||||
|
||||
revealAnimatorSet.setInterpolator(INTERPOLATOR);
|
||||
revealAnimatorSet.playTogether(reveals);
|
||||
}
|
||||
|
||||
revealMaskAnimatorSet.setInterpolator(INTERPOLATOR);
|
||||
revealMaskAnimatorSet.playTogether(overlayRevealAnim);
|
||||
private @NonNull AnimatorSet newHideAnimatorSet() {
|
||||
AnimatorSet set = new AnimatorSet();
|
||||
|
||||
List<Animator> hides = Stream.of(emojiViews)
|
||||
.mapIndexed((idx, v) -> {
|
||||
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);
|
||||
anim.setTarget(v);
|
||||
anim.setStartDelay(idx * animationEmojiStartDelayFactor);
|
||||
return anim;
|
||||
})
|
||||
.toList();
|
||||
|
||||
Animator overlayHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
|
||||
overlayHideAnim.setDuration(duration);
|
||||
|
||||
Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
|
||||
backgroundHideAnim.setTarget(backgroundView);
|
||||
backgroundHideAnim.setDuration(duration);
|
||||
hides.add(backgroundHideAnim);
|
||||
|
||||
Animator selectedHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
|
||||
selectedHideAnim.setTarget(selectedView);
|
||||
selectedHideAnim.setDuration(duration);
|
||||
hides.add(selectedHideAnim);
|
||||
|
||||
Animator toolbarHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
|
||||
toolbarHideAnim.setTarget(toolbar);
|
||||
toolbarHideAnim.setDuration(duration);
|
||||
hides.add(toolbarHideAnim);
|
||||
|
||||
AnimationCompleteListener hideListener = new AnimationCompleteListener() {
|
||||
set.addListener(new AnimationCompleteListener() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
setVisibility(View.GONE);
|
||||
}
|
||||
};
|
||||
});
|
||||
set.setInterpolator(INTERPOLATOR);
|
||||
|
||||
List<Animator> hideAllAnimators = new LinkedList<>(hides);
|
||||
hideAllAnimators.add(overlayHideAnim);
|
||||
set.playTogether(newHideAnimators());
|
||||
|
||||
hideAnimatorSet.addListener(hideListener);
|
||||
hideAnimatorSet.setInterpolator(INTERPOLATOR);
|
||||
hideAnimatorSet.playTogether(hideAllAnimators);
|
||||
return set;
|
||||
}
|
||||
|
||||
hideAllButMaskAnimatorSet.setInterpolator(INTERPOLATOR);
|
||||
hideAllButMaskAnimatorSet.playTogether(hides);
|
||||
private @NonNull List<Animator> newHideAnimators() {
|
||||
int duration = getContext().getResources().getInteger(R.integer.reaction_scrubber_hide_duration);
|
||||
|
||||
hideMaskAnimatorSet.addListener(hideListener);
|
||||
hideMaskAnimatorSet.setInterpolator(INTERPOLATOR);
|
||||
hideMaskAnimatorSet.playTogether(overlayHideAnim);
|
||||
List<Animator> animators = new ArrayList<>(Stream.of(emojiViews)
|
||||
.mapIndexed((idx, v) -> {
|
||||
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);
|
||||
anim.setTarget(v);
|
||||
return anim;
|
||||
})
|
||||
.toList());
|
||||
|
||||
Animator overlayHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
|
||||
overlayHideAnim.setDuration(duration);
|
||||
animators.add(overlayHideAnim);
|
||||
|
||||
Animator backgroundHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
|
||||
backgroundHideAnim.setTarget(backgroundView);
|
||||
backgroundHideAnim.setDuration(duration);
|
||||
animators.add(backgroundHideAnim);
|
||||
|
||||
Animator selectedHideAnim = AnimatorInflaterCompat.loadAnimator(getContext(), android.R.animator.fade_out);
|
||||
selectedHideAnim.setTarget(selectedView);
|
||||
selectedHideAnim.setDuration(duration);
|
||||
animators.add(selectedHideAnim);
|
||||
|
||||
ObjectAnimator itemScaleXAnim = new ObjectAnimator();
|
||||
itemScaleXAnim.setProperty(View.SCALE_X);
|
||||
itemScaleXAnim.setFloatValues(1f);
|
||||
itemScaleXAnim.setTarget(conversationItem);
|
||||
itemScaleXAnim.setDuration(duration);
|
||||
animators.add(itemScaleXAnim);
|
||||
|
||||
ObjectAnimator itemScaleYAnim = new ObjectAnimator();
|
||||
itemScaleYAnim.setProperty(View.SCALE_Y);
|
||||
itemScaleYAnim.setFloatValues(1f);
|
||||
itemScaleYAnim.setTarget(conversationItem);
|
||||
itemScaleYAnim.setDuration(duration);
|
||||
animators.add(itemScaleYAnim);
|
||||
|
||||
ObjectAnimator itemXAnim = new ObjectAnimator();
|
||||
itemXAnim.setProperty(View.X);
|
||||
itemXAnim.setFloatValues(selectedConversationModel.getBubbleX());
|
||||
itemXAnim.setTarget(conversationItem);
|
||||
itemXAnim.setDuration(duration);
|
||||
animators.add(itemXAnim);
|
||||
|
||||
ObjectAnimator itemYAnim = new ObjectAnimator();
|
||||
itemYAnim.setProperty(View.Y);
|
||||
itemYAnim.setFloatValues(selectedConversationModel.getItemY() + selectedConversationModel.getBubbleY() - statusBarHeight);
|
||||
itemYAnim.setTarget(conversationItem);
|
||||
itemYAnim.setDuration(duration);
|
||||
animators.add(itemYAnim);
|
||||
|
||||
ObjectAnimator toolbarShadeAnim = new ObjectAnimator();
|
||||
toolbarShadeAnim.setProperty(View.ALPHA);
|
||||
toolbarShadeAnim.setFloatValues(0f);
|
||||
toolbarShadeAnim.setTarget(toolbarShade);
|
||||
toolbarShadeAnim.setDuration(duration);
|
||||
animators.add(toolbarShadeAnim);
|
||||
|
||||
ObjectAnimator inputShadeAnim = new ObjectAnimator();
|
||||
inputShadeAnim.setProperty(View.ALPHA);
|
||||
inputShadeAnim.setFloatValues(0f);
|
||||
inputShadeAnim.setTarget(inputShade);
|
||||
inputShadeAnim.setDuration(duration);
|
||||
animators.add(inputShadeAnim);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 21 && activity != null) {
|
||||
ValueAnimator statusBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalStatusBarColor);
|
||||
statusBarAnim.setDuration(duration);
|
||||
statusBarAnim.addUpdateListener(animation -> {
|
||||
WindowUtil.setStatusBarColor(activity.getWindow(), (int) animation.getAnimatedValue());
|
||||
});
|
||||
animators.add(statusBarAnim);
|
||||
|
||||
ValueAnimator navigationBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalNavigationBarColor);
|
||||
navigationBarAnim.setDuration(duration);
|
||||
navigationBarAnim.addUpdateListener(animation -> {
|
||||
WindowUtil.setNavigationBarColor(activity.getWindow(), (int) animation.getAnimatedValue());
|
||||
});
|
||||
animators.add(navigationBarAnim);
|
||||
}
|
||||
|
||||
return animators;
|
||||
}
|
||||
|
||||
public interface OnHideListener {
|
||||
void startHide();
|
||||
void onHide();
|
||||
}
|
||||
|
||||
@@ -590,6 +920,10 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
void onCustomReactionSelected(@NonNull MessageRecord messageRecord, boolean hasAddedCustomEmoji);
|
||||
}
|
||||
|
||||
public interface OnActionSelectedListener {
|
||||
void onActionSelected(@NonNull Action action);
|
||||
}
|
||||
|
||||
private static class Boundary {
|
||||
private float min;
|
||||
private float max;
|
||||
@@ -621,4 +955,15 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
SCRUB,
|
||||
TAP
|
||||
}
|
||||
|
||||
public enum Action {
|
||||
REPLY,
|
||||
FORWARD,
|
||||
RESEND,
|
||||
DOWNLOAD,
|
||||
COPY,
|
||||
MULTISELECT,
|
||||
VIEW_INFO,
|
||||
DELETE,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user