mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-12 13:03:17 +01:00
Compare commits
273 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1f19e9d8a | ||
|
|
5464edf639 | ||
|
|
179c3790e6 | ||
|
|
cfae9753a3 | ||
|
|
61a4a3b322 | ||
|
|
c16bf65a80 | ||
|
|
16ea1912b4 | ||
|
|
54012cb33a | ||
|
|
459c5c0a55 | ||
|
|
4216b56443 | ||
|
|
d7b79314d9 | ||
|
|
a340b13f65 | ||
|
|
72f6b15dba | ||
|
|
64dbb77e63 | ||
|
|
af10b0e4f6 | ||
|
|
6f15c16a42 | ||
|
|
86158027d7 | ||
|
|
50369890f7 | ||
|
|
b8dea25aef | ||
|
|
64e9324aa0 | ||
|
|
20f8c69b07 | ||
|
|
dd1a15c249 | ||
|
|
8b24498fa7 | ||
|
|
3673fa4908 | ||
|
|
960c1df5e7 | ||
|
|
8c3c7c18ad | ||
|
|
b96a5af133 | ||
|
|
d0d4008100 | ||
|
|
17a6fcafa1 | ||
|
|
fb75440769 | ||
|
|
fe39b5e4e2 | ||
|
|
62b142cdeb | ||
|
|
ffce7213b4 | ||
|
|
4205934806 | ||
|
|
7aab86643a | ||
|
|
1bb0c55d88 | ||
|
|
d22ac9ee00 | ||
|
|
80a7db2511 | ||
|
|
e0fb102572 | ||
|
|
8d1a16dcd6 | ||
|
|
0b4bbd5db2 | ||
|
|
78b714e019 | ||
|
|
5022d81d9a | ||
|
|
deacf28d77 | ||
|
|
5e8d324860 | ||
|
|
3554f82ea3 | ||
|
|
888a40a5c4 | ||
|
|
363953a0a4 | ||
|
|
e599d9b14e | ||
|
|
a33be1fad3 | ||
|
|
b6528e843e | ||
|
|
10c31e6591 | ||
|
|
8fba64cb8f | ||
|
|
c2fd08ca80 | ||
|
|
940bf0603e | ||
|
|
4d8a3dafe0 | ||
|
|
d237bb0136 | ||
|
|
d42dfd3edd | ||
|
|
ab4f17d55f | ||
|
|
07968febe8 | ||
|
|
67ff0892d5 | ||
|
|
f1ee168657 | ||
|
|
5fef60c2b0 | ||
|
|
4afffc7dd3 | ||
|
|
7e6346a694 | ||
|
|
abf22eff44 | ||
|
|
3fa3b93c85 | ||
|
|
549ef9dabc | ||
|
|
59c75663b1 | ||
|
|
820a5bc363 | ||
|
|
1b9cf631be | ||
|
|
b4f2208bae | ||
|
|
f4bcfca323 | ||
|
|
f93a9a0f22 | ||
|
|
e5652197eb | ||
|
|
4a102d44cb | ||
|
|
c837840e04 | ||
|
|
b280ff7495 | ||
|
|
f6d8dcf6fd | ||
|
|
fa0661f58a | ||
|
|
e2fe137b05 | ||
|
|
b434e955ac | ||
|
|
d74b302edb | ||
|
|
0200430346 | ||
|
|
d70ebc2398 | ||
|
|
2b606a2dec | ||
|
|
9c7f2250b9 | ||
|
|
c2ee621f64 | ||
|
|
b2cdb46c84 | ||
|
|
6d150aa5cb | ||
|
|
62ece66f36 | ||
|
|
628cd3896c | ||
|
|
f10418face | ||
|
|
ca9a629804 | ||
|
|
bb30535afb | ||
|
|
624f863da4 | ||
|
|
b55a9f253e | ||
|
|
5b9ef5b6b6 | ||
|
|
e7c8ecbd31 | ||
|
|
592dfec8db | ||
|
|
23ebccc041 | ||
|
|
036bd51298 | ||
|
|
6d9a66cc41 | ||
|
|
1923b84a01 | ||
|
|
9924e293c9 | ||
|
|
490d3549e2 | ||
|
|
45d2a5d0b6 | ||
|
|
4d3929948c | ||
|
|
56ea09431f | ||
|
|
a53a5f4685 | ||
|
|
52f3ff5ff6 | ||
|
|
7150783848 | ||
|
|
c03d3520d6 | ||
|
|
d2e19c5129 | ||
|
|
a829165f2d | ||
|
|
f2707d053d | ||
|
|
2a4ccf69b2 | ||
|
|
818356dfed | ||
|
|
49d6743cbb | ||
|
|
9ed80d46b6 | ||
|
|
c2f5a6390e | ||
|
|
f2a7824168 | ||
|
|
3439861f74 | ||
|
|
06ee096746 | ||
|
|
6230a7553d | ||
|
|
e17b07bb12 | ||
|
|
ea2d4e9206 | ||
|
|
2fffc86a5a | ||
|
|
1bb0af72ee | ||
|
|
23a58ac064 | ||
|
|
af9d16852e | ||
|
|
ee47c1ea10 | ||
|
|
2b318152fa | ||
|
|
10a363248e | ||
|
|
11d4bde18a | ||
|
|
b88b992cb6 | ||
|
|
6e3e1b56fb | ||
|
|
7dfda2598d | ||
|
|
853862c475 | ||
|
|
5627bb6bed | ||
|
|
b646e69b6b | ||
|
|
632aeed00b | ||
|
|
3f5b4bad62 | ||
|
|
7bba4ed820 | ||
|
|
e22ff1bbfe | ||
|
|
ab66567db6 | ||
|
|
a763e1729c | ||
|
|
6aac250990 | ||
|
|
a749b97707 | ||
|
|
f966b23f3a | ||
|
|
763025d19b | ||
|
|
0bf2ae6075 | ||
|
|
71f947484e | ||
|
|
5160164111 | ||
|
|
7501e029ab | ||
|
|
a678555d8d | ||
|
|
7ce2991b0f | ||
|
|
befa396e82 | ||
|
|
fb69fc5af2 | ||
|
|
b540b5813e | ||
|
|
feb74d90f6 | ||
|
|
a0de2577e8 | ||
|
|
dbc5112ada | ||
|
|
9f8335810c | ||
|
|
c54e2388ce | ||
|
|
a8a7019411 | ||
|
|
098da3c3dd | ||
|
|
71b5645801 | ||
|
|
f5d9fbe91c | ||
|
|
420e15c179 | ||
|
|
74619f6f8d | ||
|
|
1355a4a28d | ||
|
|
97c34b889a | ||
|
|
0b0c54d874 | ||
|
|
1005be006f | ||
|
|
8db113a19b | ||
|
|
075df8a26d | ||
|
|
38cf3f40e1 | ||
|
|
4a0abbbee7 | ||
|
|
15f1201a76 | ||
|
|
b152723ed2 | ||
|
|
84a2832a65 | ||
|
|
8037494f7a | ||
|
|
97c1ace020 | ||
|
|
64457b0235 | ||
|
|
67ef831681 | ||
|
|
1fd6aae3d9 | ||
|
|
61810cc977 | ||
|
|
59401e18ed | ||
|
|
30eff93fa1 | ||
|
|
7c5bae3b53 | ||
|
|
ee16e4236e | ||
|
|
30e9cf9dc8 | ||
|
|
ac5d0bf8a3 | ||
|
|
923eb05e59 | ||
|
|
8f59e51445 | ||
|
|
766733617e | ||
|
|
d77744c562 | ||
|
|
0d6db1305e | ||
|
|
61c2e59f41 | ||
|
|
47dd7adf4b | ||
|
|
016736c455 | ||
|
|
6d3924ba43 | ||
|
|
428f963243 | ||
|
|
dd871b64ea | ||
|
|
38863f618a | ||
|
|
8023285b9d | ||
|
|
1aa7175006 | ||
|
|
1222c30738 | ||
|
|
0c8e62add9 | ||
|
|
eb1d06b4a6 | ||
|
|
d58c3292d7 | ||
|
|
4320d26a3d | ||
|
|
3ca4e33d94 | ||
|
|
19e726a630 | ||
|
|
96dddef271 | ||
|
|
34a228f85e | ||
|
|
213d996168 | ||
|
|
5a159ce01f | ||
|
|
fed9c64113 | ||
|
|
2d835581a5 | ||
|
|
c8f1ebdf4c | ||
|
|
98e3530acd | ||
|
|
1a5b216dd5 | ||
|
|
ae98d5e3bd | ||
|
|
750825b3c3 | ||
|
|
8c255256c9 | ||
|
|
19626361ec | ||
|
|
df4bd1fa4a | ||
|
|
62bf5abd8d | ||
|
|
cd9ec9f346 | ||
|
|
cf7d5b3481 | ||
|
|
12f9ac3aa4 | ||
|
|
4519cdb49c | ||
|
|
d20b6f355c | ||
|
|
70e64003f9 | ||
|
|
0a4644e743 | ||
|
|
c428d23d8b | ||
|
|
d6b189badc | ||
|
|
6e899391c0 | ||
|
|
e0acbcc32d | ||
|
|
95fb9ea117 | ||
|
|
e80b7cf0a2 | ||
|
|
5e70c06075 | ||
|
|
1413b74f76 | ||
|
|
bf0548e802 | ||
|
|
b7e1863526 | ||
|
|
f189188563 | ||
|
|
2f52664820 | ||
|
|
5f6fa73be9 | ||
|
|
b7ec913cb9 | ||
|
|
ebef4b079c | ||
|
|
a81e5c4e6b | ||
|
|
b0733dcd51 | ||
|
|
e9bd35619d | ||
|
|
6528b34152 | ||
|
|
b60c02e0c7 | ||
|
|
a0792d166b | ||
|
|
fcf36c4bc0 | ||
|
|
e5b617cd16 | ||
|
|
0acefb4521 | ||
|
|
111c8367a9 | ||
|
|
ead8f209b6 | ||
|
|
96333b616b | ||
|
|
5698e0deda | ||
|
|
df2ddebf6c | ||
|
|
71ab7528e7 | ||
|
|
b4e459d831 | ||
|
|
a57d3fdf3f | ||
|
|
2c207873be | ||
|
|
fc8385113f | ||
|
|
95d7d26f11 | ||
|
|
43a13964bd |
710
app/build.gradle
710
app/build.gradle
@@ -1,710 +0,0 @@
|
||||
import com.android.build.api.dsl.ManagedVirtualDevice
|
||||
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'kotlin-android'
|
||||
id 'androidx.navigation.safeargs'
|
||||
id 'org.jlleitschuh.gradle.ktlint'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'app.cash.exhaustive'
|
||||
id 'kotlin-parcelize'
|
||||
id 'com.squareup.wire'
|
||||
id 'translations'
|
||||
id 'licenses'
|
||||
}
|
||||
|
||||
apply from: 'static-ips.gradle'
|
||||
|
||||
wire {
|
||||
kotlin {
|
||||
javaInterop = true
|
||||
}
|
||||
|
||||
sourcePath {
|
||||
srcDir 'src/main/protowire'
|
||||
}
|
||||
|
||||
protoPath {
|
||||
srcDir "${project.rootDir}/libsignal-service/src/main/protowire"
|
||||
}
|
||||
}
|
||||
|
||||
ktlint {
|
||||
version = "0.49.1"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 1357
|
||||
def canonicalVersionName = "6.40.1"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
'armeabi-v7a' : 1,
|
||||
'arm64-v8a' : 2,
|
||||
'x86' : 3,
|
||||
'x86_64' : 4]
|
||||
|
||||
def keystores = [ 'debug' : loadKeystoreProperties('keystore.debug.properties') ]
|
||||
|
||||
def selectableVariants = [
|
||||
'nightlyProdSpinner',
|
||||
'nightlyProdPerf',
|
||||
'nightlyProdRelease',
|
||||
'nightlyStagingRelease',
|
||||
'nightlyPnpPerf',
|
||||
'nightlyPnpRelease',
|
||||
'playProdDebug',
|
||||
'playProdSpinner',
|
||||
'playProdCanary',
|
||||
'playProdPerf',
|
||||
'playProdBenchmark',
|
||||
'playProdInstrumentation',
|
||||
'playProdRelease',
|
||||
'playStagingDebug',
|
||||
'playStagingCanary',
|
||||
'playStagingSpinner',
|
||||
'playStagingPerf',
|
||||
'playStagingInstrumentation',
|
||||
'playPnpDebug',
|
||||
'playPnpSpinner',
|
||||
'playStagingRelease',
|
||||
'websiteProdSpinner',
|
||||
'websiteProdRelease',
|
||||
]
|
||||
|
||||
android {
|
||||
namespace 'org.thoughtcrime.securesms'
|
||||
|
||||
buildToolsVersion = signalBuildToolsVersion
|
||||
compileSdkVersion = signalCompileSdkVersion
|
||||
|
||||
flavorDimensions 'distribution', 'environment'
|
||||
useLibrary 'org.apache.http.legacy'
|
||||
testBuildType 'instrumentation'
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = signalKotlinJvmTarget
|
||||
freeCompilerArgs = ["-Xallow-result-return-type"]
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (keystores.debug != null) {
|
||||
debug {
|
||||
storeFile file("${project.rootDir}/${keystores.debug.storeFile}")
|
||||
storePassword keystores.debug.storePassword
|
||||
keyAlias keystores.debug.keyAlias
|
||||
keyPassword keystores.debug.keyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
execution 'ANDROIDX_TEST_ORCHESTRATOR'
|
||||
|
||||
unitTests {
|
||||
includeAndroidResources = true
|
||||
}
|
||||
|
||||
managedDevices {
|
||||
devices {
|
||||
pixel3api30 (ManagedVirtualDevice) {
|
||||
device = "Pixel 3"
|
||||
apiLevel = 30
|
||||
systemImageSource = "google-atd"
|
||||
require64Bit = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
sourceSets {
|
||||
test {
|
||||
java.srcDirs += "$projectDir/src/testShared"
|
||||
}
|
||||
|
||||
androidTest {
|
||||
java.srcDirs += "$projectDir/src/testShared"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
sourceCompatibility signalJavaVersion
|
||||
targetCompatibility signalJavaVersion
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += ['LICENSE.txt', 'LICENSE', 'NOTICE', 'asm-license.txt', 'META-INF/LICENSE', 'META-INF/LICENSE.md', 'META-INF/NOTICE', 'META-INF/LICENSE-notice.md', 'META-INF/proguard/androidx-annotations.pro', 'libsignal_jni.dylib', 'signal_jni.dll']
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
compose true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = '1.4.4'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
versionCode canonicalVersionCode * postFixSize
|
||||
versionName canonicalVersionName
|
||||
|
||||
minSdkVersion signalMinSdkVersion
|
||||
targetSdkVersion signalTargetSdkVersion
|
||||
|
||||
multiDexEnabled true
|
||||
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
project.ext.set("archivesBaseName", "Signal")
|
||||
|
||||
manifestPlaceholders = [mapsKey:"AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"]
|
||||
|
||||
buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L"
|
||||
buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
|
||||
buildConfigField "String", "SIGNAL_URL", "\"https://chat.signal.org\""
|
||||
buildConfigField "String", "STORAGE_URL", "\"https://storage.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN3_URL", "\"https://cdn3.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SVR2_URL", "\"https://svr2.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_STAGING_SFU_URL", "\"https://sfu.staging.voip.signal.org\""
|
||||
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_NAMES", "new String[]{\"Test\", \"Staging\", \"Development\"}"
|
||||
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_URLS", "new String[]{\"https://sfu.test.voip.signal.org\", \"https://sfu.staging.voip.signal.org\", \"https://sfu.staging.test.voip.signal.org\"}"
|
||||
buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
|
||||
buildConfigField "int", "CONTENT_PROXY_PORT", "443"
|
||||
buildConfigField "String[]", "SIGNAL_SERVICE_IPS", service_ips
|
||||
buildConfigField "String[]", "SIGNAL_STORAGE_IPS", storage_ips
|
||||
buildConfigField "String[]", "SIGNAL_CDN_IPS", cdn_ips
|
||||
buildConfigField "String[]", "SIGNAL_CDN2_IPS", cdn2_ips
|
||||
buildConfigField "String[]", "SIGNAL_CDN3_IPS", cdn3_ips
|
||||
buildConfigField "String[]", "SIGNAL_SFU_IPS", sfu_ips
|
||||
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
|
||||
buildConfigField "String[]", "SIGNAL_CDSI_IPS", cdsi_ips
|
||||
buildConfigField "String[]", "SIGNAL_SVR2_IPS", svr2_ips
|
||||
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
|
||||
buildConfigField "String", "CDSI_MRENCLAVE", "\"0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57\""
|
||||
buildConfigField "String", "SVR2_MRENCLAVE", "\"6ee1042f9e20f880326686dd4ba50c25359f01e9f733eeba4382bca001d45094\""
|
||||
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+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P\""
|
||||
buildConfigField "String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\""
|
||||
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
|
||||
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
|
||||
buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\""
|
||||
buildConfigField "String", "GIPHY_API_KEY", "\"3o6ZsYH6U6Eri53TXy\""
|
||||
buildConfigField "String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/registration/generate.html\""
|
||||
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/challenge/generate.html\""
|
||||
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"unset\""
|
||||
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"unset\""
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"unset\""
|
||||
buildConfigField "String", "BADGE_STATIC_ROOT", "\"https://updates2.signal.org/static/badges/\""
|
||||
buildConfigField "String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\""
|
||||
buildConfigField "boolean", "TRACING_ENABLED", "false"
|
||||
|
||||
ndk {
|
||||
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
|
||||
}
|
||||
resourceConfigurations += []
|
||||
|
||||
|
||||
splits {
|
||||
abi {
|
||||
enable !project.hasProperty('generateBaselineProfile')
|
||||
reset()
|
||||
include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
|
||||
universalApk true
|
||||
}
|
||||
}
|
||||
|
||||
testInstrumentationRunner "org.thoughtcrime.securesms.testing.SignalTestRunner"
|
||||
testInstrumentationRunnerArguments clearPackageData: 'true'
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
if (keystores['debug'] != null) {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
isDefault true
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'),
|
||||
'proguard/proguard-firebase-messaging.pro',
|
||||
'proguard/proguard-google-play-services.pro',
|
||||
'proguard/proguard-jackson.pro',
|
||||
'proguard/proguard-sqlite.pro',
|
||||
'proguard/proguard-appcompat-v7.pro',
|
||||
'proguard/proguard-square-okhttp.pro',
|
||||
'proguard/proguard-square-okio.pro',
|
||||
'proguard/proguard-rounded-image-view.pro',
|
||||
'proguard/proguard-glide.pro',
|
||||
'proguard/proguard-shortcutbadger.pro',
|
||||
'proguard/proguard-retrofit.pro',
|
||||
'proguard/proguard-webrtc.pro',
|
||||
'proguard/proguard-klinker.pro',
|
||||
'proguard/proguard-mobilecoin.pro',
|
||||
'proguard/proguard-retrolambda.pro',
|
||||
'proguard/proguard-okhttp.pro',
|
||||
'proguard/proguard-ez-vcard.pro',
|
||||
'proguard/proguard.cfg'
|
||||
testProguardFiles 'proguard/proguard-automation.pro',
|
||||
'proguard/proguard.cfg'
|
||||
|
||||
manifestPlaceholders = [mapsKey:getMapsKey()]
|
||||
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Debug\""
|
||||
}
|
||||
|
||||
instrumentation {
|
||||
initWith debug
|
||||
isDefault false
|
||||
minifyEnabled false
|
||||
matchingFallbacks = ['debug']
|
||||
applicationIdSuffix ".instrumentation"
|
||||
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Instrumentation\""
|
||||
}
|
||||
|
||||
spinner {
|
||||
initWith debug
|
||||
isDefault false
|
||||
minifyEnabled false
|
||||
matchingFallbacks = ['debug']
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Spinner\""
|
||||
}
|
||||
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles = buildTypes.debug.proguardFiles
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Release\""
|
||||
}
|
||||
|
||||
perf {
|
||||
initWith debug
|
||||
isDefault false
|
||||
debuggable false
|
||||
minifyEnabled true
|
||||
matchingFallbacks = ['debug']
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Perf\""
|
||||
buildConfigField "boolean", "TRACING_ENABLED", "true"
|
||||
}
|
||||
|
||||
benchmark {
|
||||
initWith debug
|
||||
isDefault false
|
||||
debuggable false
|
||||
minifyEnabled true
|
||||
matchingFallbacks = ['debug']
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Benchmark\""
|
||||
buildConfigField "boolean", "TRACING_ENABLED", "true"
|
||||
}
|
||||
|
||||
canary {
|
||||
initWith debug
|
||||
isDefault false
|
||||
minifyEnabled false
|
||||
matchingFallbacks = ['debug']
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Canary\""
|
||||
}
|
||||
}
|
||||
|
||||
productFlavors {
|
||||
play {
|
||||
dimension 'distribution'
|
||||
isDefault true
|
||||
buildConfigField "boolean", "MANAGES_APP_UPDATES", "false"
|
||||
buildConfigField "String", "APK_UPDATE_MANIFEST_URL", "null"
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"play\""
|
||||
}
|
||||
|
||||
website {
|
||||
dimension 'distribution'
|
||||
buildConfigField "boolean", "MANAGES_APP_UPDATES", "true"
|
||||
buildConfigField "String", "APK_UPDATE_MANIFEST_URL", "\"https://updates.signal.org/android/latest.json\""
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"website\""
|
||||
}
|
||||
|
||||
nightly {
|
||||
def apkUpdateManifestUrl = "<unset>"
|
||||
if (file("${project.rootDir}/nightly-url.txt").exists()) {
|
||||
apkUpdateManifestUrl = file("${project.rootDir}/nightly-url.txt").text.trim()
|
||||
}
|
||||
dimension 'distribution'
|
||||
versionNameSuffix "-nightly-untagged-${getDateSuffix()}"
|
||||
buildConfigField "boolean", "MANAGES_APP_UPDATES", "true"
|
||||
buildConfigField "String", "APK_UPDATE_MANIFEST_URL", "\"${apkUpdateManifestUrl}\""
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\""
|
||||
}
|
||||
|
||||
prod {
|
||||
dimension 'environment'
|
||||
|
||||
isDefault true
|
||||
|
||||
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"mainnet\""
|
||||
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Prod\""
|
||||
}
|
||||
|
||||
staging {
|
||||
dimension 'environment'
|
||||
|
||||
applicationIdSuffix ".staging"
|
||||
|
||||
buildConfigField "String", "SIGNAL_URL", "\"https://chat.staging.signal.org\""
|
||||
buildConfigField "String", "STORAGE_URL", "\"https://storage-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN3_URL", "\"https://cdn3-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\""
|
||||
buildConfigField "String", "SVR2_MRENCLAVE", "\"a8a261420a6bb9b61aa25bf8a79e8bd20d7652531feb3381cbffd446d270be95\""
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUj\""
|
||||
buildConfigField "String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\""
|
||||
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
|
||||
buildConfigField "String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/staging/registration/generate.html\""
|
||||
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\""
|
||||
|
||||
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\""
|
||||
buildConfigField "String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\""
|
||||
}
|
||||
|
||||
pnp {
|
||||
dimension 'environment'
|
||||
|
||||
initWith staging
|
||||
applicationIdSuffix ".pnp"
|
||||
|
||||
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Pnp\""
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError true
|
||||
baseline file('lint-baseline.xml')
|
||||
checkReleaseBuilds false
|
||||
disable 'LintError'
|
||||
}
|
||||
|
||||
android.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (output.baseName.contains('nightly')) {
|
||||
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
|
||||
output.outputFileName = output.outputFileName.replace(".apk", "-${output.versionNameOverride}.apk")
|
||||
} else {
|
||||
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
|
||||
}
|
||||
} else {
|
||||
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
|
||||
def abiName = output.getFilter("ABI") ?: 'universal'
|
||||
def postFix = abiPostFix.get(abiName, 0)
|
||||
|
||||
if (postFix >= postFixSize) throw new AssertionError("postFix is too large")
|
||||
|
||||
output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android.variantFilter { variant ->
|
||||
def distribution = variant.getFlavors().get(0).name
|
||||
def environment = variant.getFlavors().get(1).name
|
||||
def buildType = variant.buildType.name
|
||||
def fullName = distribution + environment.capitalize() + buildType.capitalize()
|
||||
|
||||
if (!selectableVariants.contains(fullName)) {
|
||||
variant.setIgnore(true)
|
||||
}
|
||||
}
|
||||
|
||||
android.buildTypes.each {
|
||||
if (it.name != 'release') {
|
||||
sourceSets.findByName(it.name).java.srcDirs += "$projectDir/src/debug/java"
|
||||
} else {
|
||||
sourceSets.findByName(it.name).java.srcDirs += "$projectDir/src/release/java"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation libs.androidx.fragment.ktx
|
||||
lintChecks project(':lintchecks')
|
||||
|
||||
coreLibraryDesugaring libs.android.tools.desugar
|
||||
|
||||
implementation (libs.androidx.appcompat) {
|
||||
version {
|
||||
strictly '1.6.1'
|
||||
}
|
||||
}
|
||||
implementation libs.androidx.window.window
|
||||
implementation libs.androidx.window.java
|
||||
implementation libs.androidx.recyclerview
|
||||
implementation libs.material.material
|
||||
implementation libs.androidx.legacy.support
|
||||
implementation libs.androidx.preference
|
||||
implementation libs.androidx.legacy.preference
|
||||
implementation libs.androidx.gridlayout
|
||||
implementation libs.androidx.exifinterface
|
||||
implementation libs.androidx.compose.rxjava3
|
||||
implementation libs.androidx.compose.runtime.livedata
|
||||
implementation libs.androidx.constraintlayout
|
||||
implementation libs.androidx.multidex
|
||||
implementation libs.androidx.navigation.fragment.ktx
|
||||
implementation libs.androidx.navigation.ui.ktx
|
||||
implementation libs.androidx.lifecycle.viewmodel.ktx
|
||||
implementation libs.androidx.lifecycle.livedata.ktx
|
||||
implementation libs.androidx.lifecycle.process
|
||||
implementation libs.androidx.lifecycle.viewmodel.savedstate
|
||||
implementation libs.androidx.lifecycle.common.java8
|
||||
implementation libs.androidx.lifecycle.reactivestreams.ktx
|
||||
implementation libs.androidx.camera.core
|
||||
implementation libs.androidx.camera.camera2
|
||||
implementation libs.androidx.camera.lifecycle
|
||||
implementation libs.androidx.camera.view
|
||||
implementation libs.androidx.concurrent.futures
|
||||
implementation libs.androidx.autofill
|
||||
implementation libs.androidx.biometric
|
||||
implementation libs.androidx.sharetarget
|
||||
implementation libs.androidx.profileinstaller
|
||||
implementation libs.androidx.asynclayoutinflater
|
||||
implementation libs.androidx.asynclayoutinflater.appcompat
|
||||
|
||||
implementation (libs.firebase.messaging) {
|
||||
exclude group: 'com.google.firebase', module: 'firebase-core'
|
||||
exclude group: 'com.google.firebase', module: 'firebase-analytics'
|
||||
exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
|
||||
}
|
||||
|
||||
implementation libs.google.play.services.maps
|
||||
implementation libs.google.play.services.auth
|
||||
|
||||
implementation libs.bundles.media3
|
||||
|
||||
implementation libs.conscrypt.android
|
||||
implementation libs.signal.aesgcmprovider
|
||||
|
||||
implementation project(':libsignal-service')
|
||||
implementation project(':paging')
|
||||
implementation project(':core-util')
|
||||
implementation project(':glide-config')
|
||||
implementation project(':video')
|
||||
implementation project(':device-transfer')
|
||||
implementation project(':image-editor')
|
||||
implementation project(':donations')
|
||||
implementation project(':contacts')
|
||||
implementation project(':qr')
|
||||
implementation project(':sms-exporter')
|
||||
implementation project(':sticky-header-grid')
|
||||
implementation project(':photoview')
|
||||
implementation project(':glide-webp')
|
||||
|
||||
implementation libs.libsignal.android
|
||||
|
||||
implementation libs.mobilecoin
|
||||
|
||||
implementation libs.signal.ringrtc
|
||||
|
||||
implementation libs.leolin.shortcutbadger
|
||||
implementation libs.emilsjolander.stickylistheaders
|
||||
implementation libs.apache.httpclient.android
|
||||
implementation libs.glide.glide
|
||||
implementation libs.roundedimageview
|
||||
implementation libs.materialish.progress
|
||||
implementation libs.greenrobot.eventbus
|
||||
implementation libs.google.zxing.android.integration
|
||||
implementation libs.google.zxing.core
|
||||
implementation libs.google.flexbox
|
||||
implementation (libs.subsampling.scale.image.view) {
|
||||
exclude group: 'com.android.support', module: 'support-annotations'
|
||||
}
|
||||
implementation (libs.android.tooltips) {
|
||||
exclude group: 'com.android.support', module: 'appcompat-v7'
|
||||
}
|
||||
implementation (libs.android.smsmms) {
|
||||
exclude group: 'com.squareup.okhttp', module: 'okhttp'
|
||||
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
|
||||
}
|
||||
implementation libs.stream
|
||||
|
||||
implementation libs.lottie
|
||||
|
||||
implementation libs.signal.android.database.sqlcipher
|
||||
implementation libs.androidx.sqlite
|
||||
|
||||
implementation (libs.google.ez.vcard) {
|
||||
exclude group: 'com.fasterxml.jackson.core'
|
||||
exclude group: 'org.freemarker'
|
||||
}
|
||||
implementation libs.dnsjava
|
||||
implementation libs.kotlinx.collections.immutable
|
||||
implementation libs.accompanist.permissions
|
||||
|
||||
spinnerImplementation project(":spinner")
|
||||
|
||||
canaryImplementation libs.square.leakcanary
|
||||
|
||||
testImplementation testLibs.junit.junit
|
||||
testImplementation testLibs.assertj.core
|
||||
testImplementation testLibs.mockito.core
|
||||
testImplementation testLibs.mockito.kotlin
|
||||
|
||||
testImplementation testLibs.androidx.test.core
|
||||
testImplementation (testLibs.robolectric.robolectric) {
|
||||
exclude group: 'com.google.protobuf', module: 'protobuf-java'
|
||||
}
|
||||
testImplementation testLibs.robolectric.shadows.multidex
|
||||
testImplementation (testLibs.bouncycastle.bcprov.jdk15on) { version { strictly "1.70" } } // Used by roboelectric
|
||||
testImplementation (testLibs.bouncycastle.bcpkix.jdk15on) { version { strictly "1.70" } } // Used by roboelectric
|
||||
testImplementation testLibs.conscrypt.openjdk.uber // Used by robolectric
|
||||
testImplementation testLibs.hamcrest.hamcrest
|
||||
testImplementation testLibs.mockk
|
||||
|
||||
testImplementation(testFixtures(project(":libsignal-service")))
|
||||
|
||||
androidTestImplementation testLibs.androidx.test.ext.junit
|
||||
androidTestImplementation testLibs.espresso.core
|
||||
androidTestImplementation testLibs.androidx.test.core
|
||||
androidTestImplementation testLibs.androidx.test.core.ktx
|
||||
androidTestImplementation testLibs.androidx.test.ext.junit.ktx
|
||||
androidTestImplementation testLibs.mockito.android
|
||||
androidTestImplementation testLibs.mockito.kotlin
|
||||
androidTestImplementation testLibs.mockk.android
|
||||
androidTestImplementation testLibs.square.okhttp.mockserver
|
||||
|
||||
instrumentationImplementation (libs.androidx.fragment.testing) {
|
||||
exclude group: 'androidx.test', module: 'core'
|
||||
}
|
||||
|
||||
testImplementation testLibs.espresso.core
|
||||
|
||||
implementation libs.kotlin.stdlib.jdk8
|
||||
implementation libs.kotlin.reflect
|
||||
implementation libs.jackson.module.kotlin
|
||||
|
||||
implementation libs.rxjava3.rxandroid
|
||||
implementation libs.rxjava3.rxkotlin
|
||||
implementation libs.rxdogtag
|
||||
|
||||
androidTestUtil testLibs.androidx.test.orchestrator
|
||||
|
||||
implementation project(':core-ui')
|
||||
ktlintRuleset libs.ktlint.twitter.compose
|
||||
}
|
||||
|
||||
def getLastCommitTimestamp() {
|
||||
if (!(new File('.git').exists())) {
|
||||
return System.currentTimeMillis().toString()
|
||||
}
|
||||
|
||||
new ByteArrayOutputStream().withStream { os ->
|
||||
exec {
|
||||
executable = 'git'
|
||||
args = ['log', '-1', '--pretty=format:%ct']
|
||||
standardOutput = os
|
||||
}
|
||||
|
||||
return os.toString() + "000"
|
||||
}
|
||||
}
|
||||
|
||||
def getGitHash() {
|
||||
if (!(new File('.git').exists())) {
|
||||
throw new IllegalStateException("Must be a git repository to guarantee reproducible builds! (git hash is part of APK)")
|
||||
}
|
||||
|
||||
def stdout = new ByteArrayOutputStream()
|
||||
exec {
|
||||
commandLine 'git', 'rev-parse', 'HEAD'
|
||||
standardOutput = stdout
|
||||
}
|
||||
return stdout.toString().trim().substring(0, 12)
|
||||
}
|
||||
|
||||
def getCurrentGitTag() {
|
||||
if (!(new File('.git').exists())) {
|
||||
throw new IllegalStateException("Must be a git repository to guarantee reproducible builds! (git hash is part of APK)")
|
||||
}
|
||||
|
||||
def stdout = new ByteArrayOutputStream()
|
||||
exec {
|
||||
commandLine 'git', 'tag', '--points-at', 'HEAD'
|
||||
standardOutput = stdout
|
||||
}
|
||||
|
||||
def output = stdout.toString().trim()
|
||||
|
||||
if (output != null && output.size() > 0) {
|
||||
def tags = output.split('\n').toList()
|
||||
return tags.stream().filter(t -> t.contains('nightly')).findFirst().orElse(tags.get(0))
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(Test) {
|
||||
testLogging {
|
||||
events "failed"
|
||||
exceptionFormat "full"
|
||||
showCauses true
|
||||
showExceptions true
|
||||
showStackTraces true
|
||||
}
|
||||
}
|
||||
|
||||
project.tasks.configureEach { task ->
|
||||
if (task.name.toLowerCase().contains("nightly") && task.name != 'checkNightlyParams') {
|
||||
task.dependsOn checkNightlyParams
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('checkNightlyParams') {
|
||||
doFirst {
|
||||
if (project.gradle.startParameter.taskNames.any { it.toLowerCase().contains("nightly") }) {
|
||||
|
||||
if (!file("${project.rootDir}/nightly-url.txt").exists()) {
|
||||
throw new GradleException("Cannot fine 'nightly-url.txt' for nightly build! It must exist in the root of this project and contain the location of the nightly manifest.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def loadKeystoreProperties(filename) {
|
||||
def keystorePropertiesFile = file("${project.rootDir}/${filename}")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
def keystoreProperties = new Properties()
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
return keystoreProperties
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
static def getDateSuffix() {
|
||||
def date = new Date()
|
||||
def formattedDate = date.format('yyyy-MM-dd-HH:mm')
|
||||
return formattedDate
|
||||
}
|
||||
|
||||
def getMapsKey() {
|
||||
def mapKey = file("${project.rootDir}/maps.key")
|
||||
if (mapKey.exists()) {
|
||||
return mapKey.readLines()[0]
|
||||
}
|
||||
return "AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"
|
||||
}
|
||||
743
app/build.gradle.kts
Normal file
743
app/build.gradle.kts
Normal file
@@ -0,0 +1,743 @@
|
||||
import com.android.build.api.dsl.ManagedVirtualDevice
|
||||
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.FileInputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
id("androidx.navigation.safeargs")
|
||||
id("org.jlleitschuh.gradle.ktlint")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("app.cash.exhaustive")
|
||||
id("kotlin-parcelize")
|
||||
id("com.squareup.wire")
|
||||
id("translations")
|
||||
id("licenses")
|
||||
}
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
|
||||
val canonicalVersionCode = 1374
|
||||
val canonicalVersionName = "6.44.0"
|
||||
|
||||
val postFixSize = 100
|
||||
val abiPostFix: Map<String, Int> = mapOf(
|
||||
"universal" to 0,
|
||||
"armeabi-v7a" to 1,
|
||||
"arm64-v8a" to 2,
|
||||
"x86" to 3,
|
||||
"x86_64" to 4
|
||||
)
|
||||
|
||||
val keystores: Map<String, Properties?> = mapOf("debug" to loadKeystoreProperties("keystore.debug.properties"))
|
||||
|
||||
val selectableVariants = listOf(
|
||||
"nightlyProdSpinner",
|
||||
"nightlyProdPerf",
|
||||
"nightlyProdRelease",
|
||||
"nightlyStagingRelease",
|
||||
"nightlyPnpPerf",
|
||||
"nightlyPnpRelease",
|
||||
"playProdDebug",
|
||||
"playProdSpinner",
|
||||
"playProdCanary",
|
||||
"playProdPerf",
|
||||
"playProdBenchmark",
|
||||
"playProdInstrumentation",
|
||||
"playProdRelease",
|
||||
"playStagingDebug",
|
||||
"playStagingCanary",
|
||||
"playStagingSpinner",
|
||||
"playStagingPerf",
|
||||
"playStagingInstrumentation",
|
||||
"playPnpDebug",
|
||||
"playPnpSpinner",
|
||||
"playStagingRelease",
|
||||
"websiteProdSpinner",
|
||||
"websiteProdRelease"
|
||||
)
|
||||
|
||||
val signalBuildToolsVersion: String by rootProject.extra
|
||||
val signalCompileSdkVersion: String by rootProject.extra
|
||||
val signalTargetSdkVersion: Int by rootProject.extra
|
||||
val signalMinSdkVersion: Int by rootProject.extra
|
||||
val signalJavaVersion: JavaVersion by rootProject.extra
|
||||
val signalKotlinJvmTarget: String by rootProject.extra
|
||||
|
||||
wire {
|
||||
kotlin {
|
||||
javaInterop = true
|
||||
}
|
||||
|
||||
sourcePath {
|
||||
srcDir("src/main/protowire")
|
||||
}
|
||||
|
||||
protoPath {
|
||||
srcDir("${project.rootDir}/libsignal-service/src/main/protowire")
|
||||
}
|
||||
}
|
||||
|
||||
ktlint {
|
||||
version.set("0.49.1")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.thoughtcrime.securesms"
|
||||
|
||||
buildToolsVersion = signalBuildToolsVersion
|
||||
compileSdkVersion = signalCompileSdkVersion
|
||||
|
||||
flavorDimensions += listOf("distribution", "environment")
|
||||
useLibrary("org.apache.http.legacy")
|
||||
testBuildType = "instrumentation"
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = signalKotlinJvmTarget
|
||||
freeCompilerArgs = listOf("-Xallow-result-return-type")
|
||||
}
|
||||
|
||||
keystores["debug"]?.let { properties ->
|
||||
signingConfigs.getByName("debug").apply {
|
||||
storeFile = file("${project.rootDir}/${properties.getProperty("storeFile")}")
|
||||
storePassword = properties.getProperty("storePassword")
|
||||
keyAlias = properties.getProperty("keyAlias")
|
||||
keyPassword = properties.getProperty("keyPassword")
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
execution = "ANDROIDX_TEST_ORCHESTRATOR"
|
||||
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
|
||||
managedDevices {
|
||||
devices {
|
||||
create<ManagedVirtualDevice>("pixel3api30") {
|
||||
device = "Pixel 3"
|
||||
apiLevel = 30
|
||||
systemImageSource = "google-atd"
|
||||
require64Bit = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("test") {
|
||||
java.srcDir("$projectDir/src/testShared")
|
||||
}
|
||||
|
||||
getByName("androidTest") {
|
||||
java.srcDir("$projectDir/src/testShared")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = signalJavaVersion
|
||||
targetCompatibility = signalJavaVersion
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += setOf("LICENSE.txt", "LICENSE", "NOTICE", "asm-license.txt", "META-INF/LICENSE", "META-INF/LICENSE.md", "META-INF/NOTICE", "META-INF/LICENSE-notice.md", "META-INF/proguard/androidx-annotations.pro", "libsignal_jni.dylib", "signal_jni.dll")
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.4.4"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
versionCode = canonicalVersionCode * postFixSize
|
||||
versionName = canonicalVersionName
|
||||
|
||||
minSdkVersion(signalMinSdkVersion)
|
||||
targetSdkVersion(signalTargetSdkVersion)
|
||||
|
||||
multiDexEnabled = true
|
||||
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
project.ext.set("archivesBaseName", "Signal")
|
||||
|
||||
manifestPlaceholders["mapsKey"] = "AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"
|
||||
|
||||
buildConfigField("long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L")
|
||||
buildConfigField("String", "GIT_HASH", "\"${getGitHash()}\"")
|
||||
buildConfigField("String", "SIGNAL_URL", "\"https://chat.signal.org\"")
|
||||
buildConfigField("String", "STORAGE_URL", "\"https://storage.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_CDN2_URL", "\"https://cdn2.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_CDN3_URL", "\"https://cdn3.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_CDSI_URL", "\"https://cdsi.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_SVR2_URL", "\"https://svr2.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_STAGING_SFU_URL", "\"https://sfu.staging.voip.signal.org\"")
|
||||
buildConfigField("String[]", "SIGNAL_SFU_INTERNAL_NAMES", "new String[]{\"Test\", \"Staging\", \"Development\"}")
|
||||
buildConfigField("String[]", "SIGNAL_SFU_INTERNAL_URLS", "new String[]{\"https://sfu.test.voip.signal.org\", \"https://sfu.staging.voip.signal.org\", \"https://sfu.staging.test.voip.signal.org\"}")
|
||||
buildConfigField("String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\"")
|
||||
buildConfigField("int", "CONTENT_PROXY_PORT", "443")
|
||||
buildConfigField("String[]", "SIGNAL_SERVICE_IPS", rootProject.extra["service_ips"] as String)
|
||||
buildConfigField("String[]", "SIGNAL_STORAGE_IPS", rootProject.extra["storage_ips"] as String)
|
||||
buildConfigField("String[]", "SIGNAL_CDN_IPS", rootProject.extra["cdn_ips"] as String)
|
||||
buildConfigField("String[]", "SIGNAL_CDN2_IPS", rootProject.extra["cdn2_ips"] as String)
|
||||
buildConfigField("String[]", "SIGNAL_CDN3_IPS", rootProject.extra["cdn3_ips"] as String)
|
||||
buildConfigField("String[]", "SIGNAL_SFU_IPS", rootProject.extra["sfu_ips"] as String)
|
||||
buildConfigField("String[]", "SIGNAL_CONTENT_PROXY_IPS", rootProject.extra["content_proxy_ips"] as String)
|
||||
buildConfigField("String[]", "SIGNAL_CDSI_IPS", rootProject.extra["cdsi_ips"] as String)
|
||||
buildConfigField("String[]", "SIGNAL_SVR2_IPS", rootProject.extra["svr2_ips"] as String)
|
||||
buildConfigField("String", "SIGNAL_AGENT", "\"OWA\"")
|
||||
buildConfigField("String", "CDSI_MRENCLAVE", "\"0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE_DEPRECATED", "\"6ee1042f9e20f880326686dd4ba50c25359f01e9f733eeba4382bca001d45094\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE", "\"a6622ad4656e1abcd0bc0ff17c229477747d2ded0495c4ebee7ed35c1789fa97\"")
|
||||
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+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P+NameAZYOD12qRkxosQQP5uux6B2nRyZ7sAV54DgFyLiRcq1FvwKw2EPQdk4HDoePrO/RNUbyNddnM/mMgj4FW65xCoT1LmjrIjsv/Ggdlx46ueczhMgtBunx1/w8k8V+l8LVZ8gAT6wkU5J+DPQalQguMg12Jzug3q4TbdHiGCmD9EunCwOmsLuLJkz6EcSYXtrlDEnAM+hicw7iergYLLlMXpfTdGxJCWJmP4zqUFeTTmsmhsjGBt7NiEB/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0I=\"")
|
||||
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\"")
|
||||
buildConfigField("String", "BACKUP_SERVER_PUBLIC_PARAMS", "\"AJwNSU55fsFCbgaxGRD11wO1juAs8Yr5GF8FPlGzzvdJJIKH5/4CC7ZJSOe3yL2vturVaRU2Cx0n751Vt8wkj1bozK3CBV1UokxV09GWf+hdVImLGjXGYLLhnI1J2TWEe7iWHyb553EEnRb5oxr9n3lUbNAJuRmFM7hrr0Al0F0wrDD4S8lo2mGaXe0MJCOM166F8oYRQqpFeEHfiLnxA1O8ZLh7vMdv4g9jI5phpRBTsJ5IjiJrWeP0zdIGHEssUeprDZ9OUJ14m0v61eYJMKsf59Bn+mAT2a7YfB+Don9O\"")
|
||||
buildConfigField("String[]", "LANGUAGES", "new String[]{ ${languageList().map { "\"$it\"" }.joinToString(separator = ", ")} }")
|
||||
buildConfigField("int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode")
|
||||
buildConfigField("String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\"")
|
||||
buildConfigField("String", "GIPHY_API_KEY", "\"3o6ZsYH6U6Eri53TXy\"")
|
||||
buildConfigField("String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/registration/generate.html\"")
|
||||
buildConfigField("String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/challenge/generate.html\"")
|
||||
|
||||
buildConfigField("String", "BUILD_DISTRIBUTION_TYPE", "\"unset\"")
|
||||
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"unset\"")
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"unset\"")
|
||||
buildConfigField("String", "BADGE_STATIC_ROOT", "\"https://updates2.signal.org/static/badges/\"")
|
||||
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\"")
|
||||
buildConfigField("boolean", "TRACING_ENABLED", "false")
|
||||
|
||||
ndk {
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
}
|
||||
resourceConfigurations += listOf()
|
||||
|
||||
splits {
|
||||
abi {
|
||||
isEnable = !project.hasProperty("generateBaselineProfile")
|
||||
reset()
|
||||
include("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
isUniversalApk = true
|
||||
}
|
||||
}
|
||||
|
||||
testInstrumentationRunner = "org.thoughtcrime.securesms.testing.SignalTestRunner"
|
||||
testInstrumentationRunnerArguments["clearPackageData"] = "true"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
if (keystores["debug"] != null) {
|
||||
signingConfig = signingConfigs["debug"]
|
||||
}
|
||||
isDefault = true
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android.txt"),
|
||||
"proguard/proguard-firebase-messaging.pro",
|
||||
"proguard/proguard-google-play-services.pro",
|
||||
"proguard/proguard-jackson.pro",
|
||||
"proguard/proguard-sqlite.pro",
|
||||
"proguard/proguard-appcompat-v7.pro",
|
||||
"proguard/proguard-square-okhttp.pro",
|
||||
"proguard/proguard-square-okio.pro",
|
||||
"proguard/proguard-rounded-image-view.pro",
|
||||
"proguard/proguard-glide.pro",
|
||||
"proguard/proguard-shortcutbadger.pro",
|
||||
"proguard/proguard-retrofit.pro",
|
||||
"proguard/proguard-webrtc.pro",
|
||||
"proguard/proguard-klinker.pro",
|
||||
"proguard/proguard-mobilecoin.pro",
|
||||
"proguard/proguard-retrolambda.pro",
|
||||
"proguard/proguard-okhttp.pro",
|
||||
"proguard/proguard-ez-vcard.pro",
|
||||
"proguard/proguard.cfg"
|
||||
)
|
||||
testProguardFiles(
|
||||
"proguard/proguard-automation.pro",
|
||||
"proguard/proguard.cfg"
|
||||
)
|
||||
|
||||
manifestPlaceholders["mapsKey"] = getMapsKey()
|
||||
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Debug\"")
|
||||
}
|
||||
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(*buildTypes["debug"].proguardFiles.toTypedArray())
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Release\"")
|
||||
}
|
||||
|
||||
create("instrumentation") {
|
||||
initWith(getByName("debug"))
|
||||
isDefault = false
|
||||
isMinifyEnabled = false
|
||||
matchingFallbacks += "debug"
|
||||
applicationIdSuffix = ".instrumentation"
|
||||
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Instrumentation\"")
|
||||
}
|
||||
|
||||
create("spinner") {
|
||||
initWith(getByName("debug"))
|
||||
isDefault = false
|
||||
isMinifyEnabled = false
|
||||
matchingFallbacks += "debug"
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Spinner\"")
|
||||
}
|
||||
|
||||
create("perf") {
|
||||
initWith(getByName("debug"))
|
||||
isDefault = false
|
||||
isDebuggable = false
|
||||
isMinifyEnabled = true
|
||||
matchingFallbacks += "debug"
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Perf\"")
|
||||
buildConfigField("boolean", "TRACING_ENABLED", "true")
|
||||
}
|
||||
|
||||
create("benchmark") {
|
||||
initWith(getByName("debug"))
|
||||
isDefault = false
|
||||
isDebuggable = false
|
||||
isMinifyEnabled = true
|
||||
matchingFallbacks += "debug"
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Benchmark\"")
|
||||
buildConfigField("boolean", "TRACING_ENABLED", "true")
|
||||
}
|
||||
|
||||
create("canary") {
|
||||
initWith(getByName("debug"))
|
||||
isDefault = false
|
||||
isMinifyEnabled = false
|
||||
matchingFallbacks += "debug"
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Canary\"")
|
||||
}
|
||||
}
|
||||
|
||||
productFlavors {
|
||||
create("play") {
|
||||
dimension = "distribution"
|
||||
isDefault = true
|
||||
buildConfigField("boolean", "MANAGES_APP_UPDATES", "false")
|
||||
buildConfigField("String", "APK_UPDATE_MANIFEST_URL", "null")
|
||||
buildConfigField("String", "BUILD_DISTRIBUTION_TYPE", "\"play\"")
|
||||
}
|
||||
|
||||
create("website") {
|
||||
dimension = "distribution"
|
||||
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
|
||||
buildConfigField("String", "APK_UPDATE_MANIFEST_URL", "\"https://updates.signal.org/android/latest.json\"")
|
||||
buildConfigField("String", "BUILD_DISTRIBUTION_TYPE", "\"website\"")
|
||||
}
|
||||
|
||||
create("nightly") {
|
||||
val apkUpdateManifestUrl = if (file("${project.rootDir}/nightly-url.txt").exists()) {
|
||||
file("${project.rootDir}/nightly-url.txt").readText().trim()
|
||||
} else {
|
||||
"<unset>"
|
||||
}
|
||||
|
||||
dimension = "distribution"
|
||||
versionNameSuffix = "-nightly-untagged-${getDateSuffix()}"
|
||||
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
|
||||
buildConfigField("String", "APK_UPDATE_MANIFEST_URL", "\"${apkUpdateManifestUrl}\"")
|
||||
buildConfigField("String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\"")
|
||||
}
|
||||
|
||||
create("prod") {
|
||||
dimension = "environment"
|
||||
|
||||
isDefault = true
|
||||
|
||||
buildConfigField("String", "MOBILE_COIN_ENVIRONMENT", "\"mainnet\"")
|
||||
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Prod\"")
|
||||
}
|
||||
|
||||
create("staging") {
|
||||
dimension = "environment"
|
||||
|
||||
applicationIdSuffix = ".staging"
|
||||
|
||||
buildConfigField("String", "SIGNAL_URL", "\"https://chat.staging.signal.org\"")
|
||||
buildConfigField("String", "STORAGE_URL", "\"https://storage-staging.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_CDN_URL", "\"https://cdn-staging.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_CDN3_URL", "\"https://cdn3-staging.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE_DEPRECATED", "\"a8a261420a6bb9b61aa25bf8a79e8bd20d7652531feb3381cbffd446d270be95\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE", "\"acb1973aa0bbbd14b3b4e06f145497d948fd4a98efc500fcce363b3b743ec482\"")
|
||||
buildConfigField("String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\"")
|
||||
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCM=\"")
|
||||
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\"")
|
||||
buildConfigField("String", "BACKUP_SERVER_PUBLIC_PARAMS", "\"AHYrGb9IfugAAJiPKp+mdXUx+OL9zBolPYHYQz6GI1gWjpEu5me3zVNSvmYY4zWboZHif+HG1sDHSuvwFd0QszSwuSF4X4kRP3fJREdTZ5MCR0n55zUppTwfHRW2S4sdQ0JGz7YDQIJCufYSKh0pGNEHL6hv79Agrdnr4momr3oXdnkpVBIp3HWAQ6IbXQVSG18X36GaicI1vdT0UFmTwU2KTneluC2eyL9c5ff8PcmiS+YcLzh0OKYQXB5ZfQ06d6DiINvDQLy75zcfUOniLAj0lGJiHxGczin/RXisKSR8\"")
|
||||
buildConfigField("String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\"")
|
||||
buildConfigField("String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/staging/registration/generate.html\"")
|
||||
buildConfigField("String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\"")
|
||||
|
||||
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\"")
|
||||
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\"")
|
||||
}
|
||||
|
||||
create("pnp") {
|
||||
dimension = "environment"
|
||||
|
||||
initWith(getByName("staging"))
|
||||
applicationIdSuffix = ".pnp"
|
||||
|
||||
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Pnp\"")
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError = true
|
||||
baseline = file("lint-baseline.xml")
|
||||
checkReleaseBuilds = false
|
||||
disable += "LintError"
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
val variant = this
|
||||
|
||||
variant.outputs
|
||||
.map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
|
||||
.forEach { output ->
|
||||
if (output.baseName.contains("nightly")) {
|
||||
output.versionCodeOverride = canonicalVersionCode * postFixSize + 5
|
||||
var tag = getCurrentGitTag()
|
||||
if (!tag.isNullOrEmpty()) {
|
||||
if (tag.startsWith("v")) {
|
||||
tag = tag.substring(1)
|
||||
}
|
||||
output.versionNameOverride = tag
|
||||
output.outputFileName = output.outputFileName.replace(".apk", "-${output.versionNameOverride}.apk")
|
||||
} else {
|
||||
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
|
||||
}
|
||||
} else {
|
||||
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
|
||||
|
||||
val abiName: String = output.getFilter("ABI") ?: "universal"
|
||||
val postFix: Int = abiPostFix[abiName]!!
|
||||
|
||||
if (postFix >= postFixSize) {
|
||||
throw AssertionError("postFix is too large")
|
||||
}
|
||||
|
||||
output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android.variantFilter {
|
||||
val distribution: String = flavors[0].name
|
||||
val environment: String = flavors[1].name
|
||||
val buildType: String = buildType.name
|
||||
val fullName: String = distribution + environment.capitalize() + buildType.capitalize()
|
||||
|
||||
if (!selectableVariants.contains(fullName)) {
|
||||
ignore = true
|
||||
}
|
||||
}
|
||||
|
||||
android.buildTypes.forEach {
|
||||
val path: String = if (it.name == "release") {
|
||||
"$projectDir/src/release/java"
|
||||
} else {
|
||||
"$projectDir/src/debug/java"
|
||||
}
|
||||
|
||||
sourceSets.findByName(it.name)!!.java.srcDir(path)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
lintChecks(project(":lintchecks"))
|
||||
ktlintRuleset(libs.ktlint.twitter.compose)
|
||||
coreLibraryDesugaring(libs.android.tools.desugar)
|
||||
|
||||
implementation(project(":libsignal-service"))
|
||||
implementation(project(":paging"))
|
||||
implementation(project(":core-util"))
|
||||
implementation(project(":glide-config"))
|
||||
implementation(project(":video"))
|
||||
implementation(project(":device-transfer"))
|
||||
implementation(project(":image-editor"))
|
||||
implementation(project(":donations"))
|
||||
implementation(project(":contacts"))
|
||||
implementation(project(":qr"))
|
||||
implementation(project(":sms-exporter"))
|
||||
implementation(project(":sticky-header-grid"))
|
||||
implementation(project(":photoview"))
|
||||
implementation(project(":glide-webp"))
|
||||
implementation(project(":core-ui"))
|
||||
|
||||
implementation(libs.androidx.fragment.ktx)
|
||||
implementation(libs.androidx.appcompat) {
|
||||
version {
|
||||
strictly("1.6.1")
|
||||
}
|
||||
}
|
||||
implementation(libs.androidx.window.window)
|
||||
implementation(libs.androidx.window.java)
|
||||
implementation(libs.androidx.recyclerview)
|
||||
implementation(libs.material.material)
|
||||
implementation(libs.androidx.legacy.support)
|
||||
implementation(libs.androidx.preference)
|
||||
implementation(libs.androidx.legacy.preference)
|
||||
implementation(libs.androidx.gridlayout)
|
||||
implementation(libs.androidx.exifinterface)
|
||||
implementation(libs.androidx.compose.rxjava3)
|
||||
implementation(libs.androidx.compose.runtime.livedata)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.androidx.multidex)
|
||||
implementation(libs.androidx.navigation.fragment.ktx)
|
||||
implementation(libs.androidx.navigation.ui.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.androidx.lifecycle.livedata.ktx)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
|
||||
implementation(libs.androidx.lifecycle.common.java8)
|
||||
implementation(libs.androidx.lifecycle.reactivestreams.ktx)
|
||||
implementation(libs.androidx.camera.core)
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.view)
|
||||
implementation(libs.androidx.concurrent.futures)
|
||||
implementation(libs.androidx.autofill)
|
||||
implementation(libs.androidx.biometric)
|
||||
implementation(libs.androidx.sharetarget)
|
||||
implementation(libs.androidx.profileinstaller)
|
||||
implementation(libs.androidx.asynclayoutinflater)
|
||||
implementation(libs.androidx.asynclayoutinflater.appcompat)
|
||||
implementation(libs.firebase.messaging) {
|
||||
exclude(group = "com.google.firebase", module = "firebase-core")
|
||||
exclude(group = "com.google.firebase", module = "firebase-analytics")
|
||||
exclude(group = "com.google.firebase", module = "firebase-measurement-connector")
|
||||
}
|
||||
implementation(libs.google.play.services.maps)
|
||||
implementation(libs.google.play.services.auth)
|
||||
implementation(libs.bundles.media3)
|
||||
implementation(libs.conscrypt.android)
|
||||
implementation(libs.signal.aesgcmprovider)
|
||||
implementation(libs.libsignal.android)
|
||||
implementation(libs.mobilecoin)
|
||||
implementation(libs.signal.ringrtc)
|
||||
implementation(libs.leolin.shortcutbadger)
|
||||
implementation(libs.emilsjolander.stickylistheaders)
|
||||
implementation(libs.apache.httpclient.android)
|
||||
implementation(libs.glide.glide)
|
||||
implementation(libs.roundedimageview)
|
||||
implementation(libs.materialish.progress)
|
||||
implementation(libs.greenrobot.eventbus)
|
||||
implementation(libs.google.zxing.android.integration)
|
||||
implementation(libs.google.zxing.core)
|
||||
implementation(libs.google.flexbox)
|
||||
implementation(libs.subsampling.scale.image.view) {
|
||||
exclude(group = "com.android.support", module = "support-annotations")
|
||||
}
|
||||
implementation(libs.android.tooltips) {
|
||||
exclude(group = "com.android.support", module = "appcompat-v7")
|
||||
}
|
||||
implementation(libs.android.smsmms) {
|
||||
exclude(group = "com.squareup.okhttp", module = "okhttp")
|
||||
exclude(group = "com.squareup.okhttp", module = "okhttp-urlconnection")
|
||||
}
|
||||
implementation(libs.stream)
|
||||
implementation(libs.lottie)
|
||||
implementation(libs.signal.android.database.sqlcipher)
|
||||
implementation(libs.androidx.sqlite)
|
||||
implementation(libs.google.ez.vcard) {
|
||||
exclude(group = "com.fasterxml.jackson.core")
|
||||
exclude(group = "org.freemarker")
|
||||
}
|
||||
implementation(libs.dnsjava)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
implementation(libs.accompanist.permissions)
|
||||
implementation(libs.kotlin.stdlib.jdk8)
|
||||
implementation(libs.kotlin.reflect)
|
||||
implementation(libs.jackson.module.kotlin)
|
||||
implementation(libs.rxjava3.rxandroid)
|
||||
implementation(libs.rxjava3.rxkotlin)
|
||||
implementation(libs.rxdogtag)
|
||||
|
||||
"spinnerImplementation"(project(":spinner"))
|
||||
|
||||
"canaryImplementation"(libs.square.leakcanary)
|
||||
|
||||
"instrumentationImplementation"(libs.androidx.fragment.testing) {
|
||||
exclude(group = "androidx.test", module = "core")
|
||||
}
|
||||
|
||||
testImplementation(testLibs.junit.junit)
|
||||
testImplementation(testLibs.assertj.core)
|
||||
testImplementation(testLibs.mockito.core)
|
||||
testImplementation(testLibs.mockito.kotlin)
|
||||
testImplementation(testLibs.androidx.test.core)
|
||||
testImplementation(testLibs.robolectric.robolectric) {
|
||||
exclude(group = "com.google.protobuf", module = "protobuf-java")
|
||||
}
|
||||
testImplementation(testLibs.robolectric.shadows.multidex)
|
||||
testImplementation(testLibs.bouncycastle.bcprov.jdk15on) {
|
||||
version {
|
||||
strictly("1.70")
|
||||
}
|
||||
}
|
||||
testImplementation(testLibs.bouncycastle.bcpkix.jdk15on) {
|
||||
version {
|
||||
strictly("1.70")
|
||||
}
|
||||
}
|
||||
testImplementation(testLibs.conscrypt.openjdk.uber)
|
||||
testImplementation(testLibs.hamcrest.hamcrest)
|
||||
testImplementation(testLibs.mockk)
|
||||
testImplementation(testFixtures(project(":libsignal-service")))
|
||||
testImplementation(testLibs.espresso.core)
|
||||
|
||||
androidTestImplementation(testLibs.androidx.test.ext.junit)
|
||||
androidTestImplementation(testLibs.espresso.core)
|
||||
androidTestImplementation(testLibs.androidx.test.core)
|
||||
androidTestImplementation(testLibs.androidx.test.core.ktx)
|
||||
androidTestImplementation(testLibs.androidx.test.ext.junit.ktx)
|
||||
androidTestImplementation(testLibs.mockito.android)
|
||||
androidTestImplementation(testLibs.mockito.kotlin)
|
||||
androidTestImplementation(testLibs.mockk.android)
|
||||
androidTestImplementation(testLibs.square.okhttp.mockserver)
|
||||
|
||||
androidTestUtil(testLibs.androidx.test.orchestrator)
|
||||
}
|
||||
|
||||
fun assertIsGitRepo() {
|
||||
if (!file("${project.rootDir}/.git").exists()) {
|
||||
throw IllegalStateException("Must be a git repository to guarantee reproducible builds! (git hash is part of APK)")
|
||||
}
|
||||
}
|
||||
|
||||
fun getLastCommitTimestamp(): String {
|
||||
assertIsGitRepo()
|
||||
|
||||
ByteArrayOutputStream().use { os ->
|
||||
exec {
|
||||
executable = "git"
|
||||
args = listOf("log", "-1", "--pretty=format:%ct")
|
||||
standardOutput = os
|
||||
}
|
||||
|
||||
return os.toString() + "000"
|
||||
}
|
||||
}
|
||||
|
||||
fun getGitHash(): String {
|
||||
assertIsGitRepo()
|
||||
|
||||
val stdout = ByteArrayOutputStream()
|
||||
exec {
|
||||
commandLine = listOf("git", "rev-parse", "HEAD")
|
||||
standardOutput = stdout
|
||||
}
|
||||
|
||||
return stdout.toString().trim().substring(0, 12)
|
||||
}
|
||||
|
||||
fun getCurrentGitTag(): String? {
|
||||
assertIsGitRepo()
|
||||
|
||||
val stdout = ByteArrayOutputStream()
|
||||
exec {
|
||||
commandLine = listOf("git", "tag", "--points-at", "HEAD")
|
||||
standardOutput = stdout
|
||||
}
|
||||
|
||||
val output: String = stdout.toString().trim()
|
||||
|
||||
return if (output.isNotEmpty()) {
|
||||
val tags = output.split("\n").toList()
|
||||
tags.firstOrNull { it.contains("nightly") } ?: tags[0]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
testLogging {
|
||||
events("failed")
|
||||
exceptionFormat = TestExceptionFormat.FULL
|
||||
showCauses = true
|
||||
showExceptions = true
|
||||
showStackTraces = true
|
||||
}
|
||||
}
|
||||
|
||||
project.tasks.configureEach {
|
||||
if (name.lowercase().contains("nightly") && name != "checkNightlyParams") {
|
||||
dependsOn(tasks.getByName("checkNightlyParams"))
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("checkNightlyParams") {
|
||||
doFirst {
|
||||
if (project.gradle.startParameter.taskNames.any { it.lowercase().contains("nightly") }) {
|
||||
|
||||
if (!file("${project.rootDir}/nightly-url.txt").exists()) {
|
||||
throw GradleException("Cannot find 'nightly-url.txt' for nightly build! It must exist in the root of this project and contain the location of the nightly manifest.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadKeystoreProperties(filename: String): Properties? {
|
||||
val keystorePropertiesFile = file("${project.rootDir}/$filename")
|
||||
|
||||
return if (keystorePropertiesFile.exists()) {
|
||||
val keystoreProperties = Properties()
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
keystoreProperties
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getDateSuffix(): String {
|
||||
return SimpleDateFormat("yyyy-MM-dd-HH:mm").format(Date())
|
||||
}
|
||||
|
||||
fun getMapsKey(): String {
|
||||
val mapKey = file("${project.rootDir}/maps.key")
|
||||
|
||||
return if (mapKey.exists()) {
|
||||
mapKey.readLines()[0]
|
||||
} else {
|
||||
"AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"
|
||||
}
|
||||
}
|
||||
|
||||
fun Project.languageList(): List<String> {
|
||||
return fileTree("src/main/res") { include("**/strings.xml") }
|
||||
.map { stringFile -> stringFile.parentFile.name }
|
||||
.map { valuesFolderName -> valuesFolderName.replace("values-", "") }
|
||||
.filter { valuesFolderName -> valuesFolderName != "values" }
|
||||
.map { languageCode -> languageCode.replace("-r", "_") }
|
||||
.distinct() + "en"
|
||||
}
|
||||
|
||||
fun String.capitalize(): String {
|
||||
return this.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import androidx.core.content.contentValuesOf
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.readToSingleObject
|
||||
import org.signal.core.util.requireBlob
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.toInt
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.database.EmojiSearchTable
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.MessageTypes
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.subscription.Subscriber
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
|
||||
typealias DatabaseData = Map<String, List<Map<String, Any?>>>
|
||||
|
||||
class BackupTest {
|
||||
companion object {
|
||||
val SELF_ACI = ACI.from(UUID.fromString("77770000-b477-4f35-a824-d92987a63641"))
|
||||
val SELF_PNI = PNI.from(UUID.fromString("77771111-b014-41fb-bf73-05cb2ec52910"))
|
||||
const val SELF_E164 = "+10000000000"
|
||||
val SELF_PROFILE_KEY = ProfileKey(Random.nextBytes(32))
|
||||
|
||||
val ALICE_ACI = ACI.from(UUID.fromString("aaaa0000-5a76-47fa-a98a-7e72c948a82e"))
|
||||
val ALICE_PNI = PNI.from(UUID.fromString("aaaa1111-c960-4f6c-8385-671ad2ffb999"))
|
||||
val ALICE_E164 = "+12222222222"
|
||||
|
||||
/** Columns that we don't need to check equality of */
|
||||
private val IGNORED_COLUMNS: Map<String, Set<String>> = mapOf(
|
||||
RecipientTable.TABLE_NAME to setOf(RecipientTable.STORAGE_SERVICE_ID),
|
||||
MessageTable.TABLE_NAME to setOf(MessageTable.FROM_DEVICE_ID)
|
||||
)
|
||||
|
||||
/** Tables we don't need to check equality of */
|
||||
private val IGNORED_TABLES: Set<String> = setOf(
|
||||
EmojiSearchTable.TABLE_NAME,
|
||||
"sqlite_sequence",
|
||||
"message_fts_data",
|
||||
"message_fts_idx",
|
||||
"message_fts_docsize"
|
||||
)
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
SignalStore.account().setE164(SELF_E164)
|
||||
SignalStore.account().setAci(SELF_ACI)
|
||||
SignalStore.account().setPni(SELF_PNI)
|
||||
SignalStore.account().generateAciIdentityKeyIfNecessary()
|
||||
SignalStore.account().generatePniIdentityKeyIfNecessary()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyDatabase() {
|
||||
backupTest { }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noteToSelf() {
|
||||
backupTest {
|
||||
individualChat(aci = SELF_ACI, givenName = "Note to Self") {
|
||||
standardMessage(outgoing = true, body = "A")
|
||||
standardMessage(outgoing = true, body = "B")
|
||||
standardMessage(outgoing = true, body = "C")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun individualChat() {
|
||||
backupTest {
|
||||
individualChat(aci = ALICE_ACI, givenName = "Alice") {
|
||||
val m1 = standardMessage(outgoing = true, body = "Outgoing 1")
|
||||
val m2 = standardMessage(outgoing = false, body = "Incoming 1", read = true)
|
||||
standardMessage(outgoing = true, body = "Outgoing 2", quotes = m2)
|
||||
standardMessage(outgoing = false, body = "Incoming 2", quotes = m1, quoteTargetMissing = true, read = false)
|
||||
standardMessage(outgoing = true, body = "Outgoing 3, with mention", randomMention = true)
|
||||
standardMessage(outgoing = false, body = "Incoming 3, with style", read = false, randomStyling = true)
|
||||
remoteDeletedMessage(outgoing = true)
|
||||
remoteDeletedMessage(outgoing = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun individualRecipients() {
|
||||
backupTest {
|
||||
// Comprehensive example
|
||||
individualRecipient(
|
||||
aci = ALICE_ACI,
|
||||
pni = ALICE_PNI,
|
||||
e164 = ALICE_E164,
|
||||
givenName = "Alice",
|
||||
familyName = "Smith",
|
||||
username = "alice.99",
|
||||
hidden = false,
|
||||
registeredState = RecipientTable.RegisteredState.REGISTERED,
|
||||
profileKey = ProfileKey(Random.nextBytes(32)),
|
||||
profileSharing = true,
|
||||
hideStory = false
|
||||
)
|
||||
|
||||
// Trying to get coverage of all the various values
|
||||
individualRecipient(aci = ACI.from(UUID.randomUUID()), registeredState = RecipientTable.RegisteredState.NOT_REGISTERED)
|
||||
individualRecipient(aci = ACI.from(UUID.randomUUID()), registeredState = RecipientTable.RegisteredState.UNKNOWN)
|
||||
individualRecipient(pni = PNI.from(UUID.randomUUID()))
|
||||
individualRecipient(e164 = "+15551234567")
|
||||
individualRecipient(aci = ACI.from(UUID.randomUUID()), givenName = "Bob")
|
||||
individualRecipient(aci = ACI.from(UUID.randomUUID()), familyName = "Smith")
|
||||
individualRecipient(aci = ACI.from(UUID.randomUUID()), profileSharing = false)
|
||||
individualRecipient(aci = ACI.from(UUID.randomUUID()), hideStory = true)
|
||||
individualRecipient(aci = ACI.from(UUID.randomUUID()), hidden = true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun individualCallLogs() {
|
||||
backupTest {
|
||||
val aliceId = individualRecipient(
|
||||
aci = ALICE_ACI,
|
||||
pni = ALICE_PNI,
|
||||
e164 = ALICE_E164,
|
||||
givenName = "Alice",
|
||||
familyName = "Smith",
|
||||
username = "alice.99",
|
||||
hidden = false,
|
||||
registeredState = RecipientTable.RegisteredState.REGISTERED,
|
||||
profileKey = ProfileKey(Random.nextBytes(32)),
|
||||
profileSharing = true,
|
||||
hideStory = false
|
||||
)
|
||||
insertOneToOneCallVariations(1, 1, aliceId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertOneToOneCallVariations(callId: Long, timestamp: Long, id: RecipientId): Long {
|
||||
val directions = arrayOf(CallTable.Direction.INCOMING, CallTable.Direction.OUTGOING)
|
||||
val callTypes = arrayOf(CallTable.Type.AUDIO_CALL, CallTable.Type.VIDEO_CALL)
|
||||
val events = arrayOf(
|
||||
CallTable.Event.MISSED,
|
||||
CallTable.Event.OUTGOING_RING,
|
||||
CallTable.Event.ONGOING,
|
||||
CallTable.Event.ACCEPTED,
|
||||
CallTable.Event.NOT_ACCEPTED
|
||||
)
|
||||
var callTimestamp: Long = timestamp
|
||||
var currentCallId = callId
|
||||
for (direction in directions) {
|
||||
for (event in events) {
|
||||
for (type in callTypes) {
|
||||
insertOneToOneCall(callId = currentCallId, callTimestamp, id, type, direction, event)
|
||||
callTimestamp++
|
||||
currentCallId++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return currentCallId
|
||||
}
|
||||
|
||||
private fun insertOneToOneCall(callId: Long, timestamp: Long, peer: RecipientId, type: CallTable.Type, direction: CallTable.Direction, event: CallTable.Event) {
|
||||
val messageType: Long = CallTable.Call.getMessageType(type, direction, event)
|
||||
|
||||
SignalDatabase.rawDatabase.withinTransaction {
|
||||
val recipient = Recipient.resolved(peer)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
val outgoing = direction == CallTable.Direction.OUTGOING
|
||||
|
||||
val messageValues = contentValuesOf(
|
||||
MessageTable.FROM_RECIPIENT_ID to if (outgoing) Recipient.self().id.serialize() else peer.serialize(),
|
||||
MessageTable.FROM_DEVICE_ID to 1,
|
||||
MessageTable.TO_RECIPIENT_ID to if (outgoing) peer.serialize() else Recipient.self().id.serialize(),
|
||||
MessageTable.DATE_RECEIVED to timestamp,
|
||||
MessageTable.DATE_SENT to timestamp,
|
||||
MessageTable.READ to 1,
|
||||
MessageTable.TYPE to messageType,
|
||||
MessageTable.THREAD_ID to threadId
|
||||
)
|
||||
|
||||
val messageId = SignalDatabase.rawDatabase.insert(MessageTable.TABLE_NAME, null, messageValues)
|
||||
|
||||
val values = contentValuesOf(
|
||||
CallTable.CALL_ID to callId,
|
||||
CallTable.MESSAGE_ID to messageId,
|
||||
CallTable.PEER to peer.serialize(),
|
||||
CallTable.TYPE to CallTable.Type.serialize(type),
|
||||
CallTable.DIRECTION to CallTable.Direction.serialize(direction),
|
||||
CallTable.EVENT to CallTable.Event.serialize(event),
|
||||
CallTable.TIMESTAMP to timestamp
|
||||
)
|
||||
|
||||
SignalDatabase.rawDatabase.insert(CallTable.TABLE_NAME, null, values)
|
||||
|
||||
SignalDatabase.threads.update(threadId, true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accountData() {
|
||||
val context = ApplicationDependencies.getApplication()
|
||||
|
||||
backupTest(validateKeyValue = true) {
|
||||
val self = Recipient.self()
|
||||
|
||||
// TODO note-to-self archived
|
||||
// TODO note-to-self unread
|
||||
|
||||
SignalStore.account().setAci(SELF_ACI)
|
||||
SignalStore.account().setPni(SELF_PNI)
|
||||
SignalStore.account().setE164(SELF_E164)
|
||||
SignalStore.account().generateAciIdentityKeyIfNecessary()
|
||||
SignalStore.account().generatePniIdentityKeyIfNecessary()
|
||||
|
||||
SignalDatabase.recipients.setProfileKey(self.id, ProfileKey(Random.nextBytes(32)))
|
||||
SignalDatabase.recipients.setProfileName(self.id, ProfileName.fromParts("Peter", "Parker"))
|
||||
SignalDatabase.recipients.setProfileAvatar(self.id, "https://example.com/")
|
||||
|
||||
SignalStore.donationsValues().markUserManuallyCancelled()
|
||||
SignalStore.donationsValues().setSubscriber(Subscriber(SubscriberId.generate(), "USD"))
|
||||
SignalStore.donationsValues().setDisplayBadgesOnProfile(false)
|
||||
|
||||
SignalStore.phoneNumberPrivacy().phoneNumberListingMode = PhoneNumberPrivacyValues.PhoneNumberListingMode.UNLISTED
|
||||
SignalStore.phoneNumberPrivacy().phoneNumberSharingMode = PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY
|
||||
|
||||
SignalStore.settings().isLinkPreviewsEnabled = false
|
||||
SignalStore.settings().isPreferSystemContactPhotos = true
|
||||
SignalStore.settings().universalExpireTimer = 42
|
||||
SignalStore.settings().setKeepMutedChatsArchived(true)
|
||||
|
||||
SignalStore.storyValues().viewedReceiptsEnabled = false
|
||||
SignalStore.storyValues().userHasViewedOnboardingStory = true
|
||||
SignalStore.storyValues().isFeatureDisabled = false
|
||||
SignalStore.storyValues().userHasBeenNotifiedAboutStories = true
|
||||
SignalStore.storyValues().userHasSeenGroupStoryEducationSheet = true
|
||||
|
||||
SignalStore.emojiValues().reactions = listOf("a", "b", "c")
|
||||
|
||||
TextSecurePreferences.setTypingIndicatorsEnabled(context, false)
|
||||
TextSecurePreferences.setReadReceiptsEnabled(context, false)
|
||||
TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, true)
|
||||
}
|
||||
|
||||
// Have to check TextSecurePreferences ourselves, since they're not in a database
|
||||
TextSecurePreferences.isTypingIndicatorsEnabled(context) assertIs false
|
||||
TextSecurePreferences.isReadReceiptsEnabled(context) assertIs false
|
||||
TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context) assertIs true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the database, then executes your setup code, then compares snapshots of the database
|
||||
* before an after an import to ensure that no data was lost/changed.
|
||||
*
|
||||
* @param validateKeyValue If true, this will also validate the KeyValueDatabase. You only want to do this if you
|
||||
* intend on setting most of the values. Otherwise stuff tends to not match since values are lazily written.
|
||||
*/
|
||||
private fun backupTest(validateKeyValue: Boolean = false, content: () -> Unit) {
|
||||
// Under normal circumstances, My Story ends up being the first recipient in the table, and is added automatically.
|
||||
// This screws with the tests by offsetting all the recipientIds in the initial state.
|
||||
// Easiest way to get around this is to make the DB a true clean slate by clearing everything.
|
||||
// (We only really need to clear Recipient/dlists, but doing everything to be consistent.)
|
||||
SignalDatabase.distributionLists.clearAllDataForBackupRestore()
|
||||
SignalDatabase.recipients.clearAllDataForBackupRestore()
|
||||
SignalDatabase.messages.clearAllDataForBackupRestore()
|
||||
SignalDatabase.threads.clearAllDataForBackupRestore()
|
||||
|
||||
// Again, for comparison purposes, because we always import self first, we want to ensure it's the first item
|
||||
// in the table when we export.
|
||||
individualRecipient(
|
||||
aci = SELF_ACI,
|
||||
pni = SELF_PNI,
|
||||
e164 = SELF_E164,
|
||||
profileKey = SELF_PROFILE_KEY,
|
||||
profileSharing = true
|
||||
)
|
||||
|
||||
content()
|
||||
|
||||
val startingMainData: DatabaseData = SignalDatabase.rawDatabase.readAllContents()
|
||||
val startingKeyValueData: DatabaseData = if (validateKeyValue) SignalDatabase.rawDatabase.readAllContents() else emptyMap()
|
||||
|
||||
val exported: ByteArray = BackupRepository.export()
|
||||
BackupRepository.import(length = exported.size.toLong(), inputStreamFactory = { ByteArrayInputStream(exported) }, selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, SELF_PROFILE_KEY))
|
||||
|
||||
val endingData: DatabaseData = SignalDatabase.rawDatabase.readAllContents()
|
||||
val endingKeyValueData: DatabaseData = if (validateKeyValue) SignalDatabase.rawDatabase.readAllContents() else emptyMap()
|
||||
|
||||
assertDatabaseMatches(startingMainData, endingData)
|
||||
assertDatabaseMatches(startingKeyValueData, endingKeyValueData)
|
||||
}
|
||||
|
||||
private fun individualChat(aci: ACI, givenName: String, familyName: String? = null, init: IndividualChatCreator.() -> Unit) {
|
||||
val recipientId = individualRecipient(aci = aci, givenName = givenName, familyName = familyName, profileSharing = true)
|
||||
|
||||
val threadId: Long = SignalDatabase.threads.getOrCreateThreadIdFor(recipientId, false)
|
||||
|
||||
IndividualChatCreator(SignalDatabase.rawDatabase, recipientId, threadId).init()
|
||||
|
||||
SignalDatabase.threads.update(threadId, false)
|
||||
}
|
||||
|
||||
private fun individualRecipient(
|
||||
aci: ACI? = null,
|
||||
pni: PNI? = null,
|
||||
e164: String? = null,
|
||||
givenName: String? = null,
|
||||
familyName: String? = null,
|
||||
username: String? = null,
|
||||
hidden: Boolean = false,
|
||||
registeredState: RecipientTable.RegisteredState = RecipientTable.RegisteredState.UNKNOWN,
|
||||
profileKey: ProfileKey? = null,
|
||||
profileSharing: Boolean = false,
|
||||
hideStory: Boolean = false
|
||||
): RecipientId {
|
||||
check(aci != null || pni != null || e164 != null)
|
||||
|
||||
val recipientId = SignalDatabase.recipients.getAndPossiblyMerge(aci, pni, e164, pniVerified = true, changeSelf = true)
|
||||
|
||||
if (givenName != null || familyName != null) {
|
||||
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts(givenName, familyName))
|
||||
}
|
||||
|
||||
if (username != null) {
|
||||
SignalDatabase.recipients.setUsername(recipientId, username)
|
||||
}
|
||||
|
||||
if (registeredState == RecipientTable.RegisteredState.REGISTERED) {
|
||||
SignalDatabase.recipients.markRegistered(recipientId, aci ?: pni!!)
|
||||
} else if (registeredState == RecipientTable.RegisteredState.NOT_REGISTERED) {
|
||||
SignalDatabase.recipients.markUnregistered(recipientId)
|
||||
}
|
||||
|
||||
if (profileKey != null) {
|
||||
SignalDatabase.recipients.setProfileKey(recipientId, profileKey)
|
||||
}
|
||||
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, profileSharing)
|
||||
SignalDatabase.recipients.setHideStory(recipientId, hideStory)
|
||||
|
||||
if (hidden) {
|
||||
SignalDatabase.recipients.markHidden(recipientId)
|
||||
}
|
||||
|
||||
return recipientId
|
||||
}
|
||||
|
||||
private inner class IndividualChatCreator(
|
||||
private val db: SQLiteDatabase,
|
||||
private val recipientId: RecipientId,
|
||||
private val threadId: Long
|
||||
) {
|
||||
fun standardMessage(
|
||||
outgoing: Boolean,
|
||||
sentTimestamp: Long = System.currentTimeMillis(),
|
||||
receivedTimestamp: Long = if (outgoing) sentTimestamp else sentTimestamp + 1,
|
||||
serverTimestamp: Long = sentTimestamp,
|
||||
body: String? = null,
|
||||
read: Boolean = true,
|
||||
quotes: Long? = null,
|
||||
quoteTargetMissing: Boolean = false,
|
||||
randomMention: Boolean = false,
|
||||
randomStyling: Boolean = false
|
||||
): Long {
|
||||
return db.insertMessage(
|
||||
from = if (outgoing) Recipient.self().id else recipientId,
|
||||
to = if (outgoing) recipientId else Recipient.self().id,
|
||||
outgoing = outgoing,
|
||||
threadId = threadId,
|
||||
sentTimestamp = sentTimestamp,
|
||||
receivedTimestamp = receivedTimestamp,
|
||||
serverTimestamp = serverTimestamp,
|
||||
body = body,
|
||||
read = read,
|
||||
quotes = quotes,
|
||||
quoteTargetMissing = quoteTargetMissing,
|
||||
randomMention = randomMention,
|
||||
randomStyling = randomStyling
|
||||
)
|
||||
}
|
||||
|
||||
fun remoteDeletedMessage(
|
||||
outgoing: Boolean,
|
||||
sentTimestamp: Long = System.currentTimeMillis(),
|
||||
receivedTimestamp: Long = if (outgoing) sentTimestamp else sentTimestamp + 1,
|
||||
serverTimestamp: Long = sentTimestamp
|
||||
): Long {
|
||||
return db.insertMessage(
|
||||
from = if (outgoing) Recipient.self().id else recipientId,
|
||||
to = if (outgoing) recipientId else Recipient.self().id,
|
||||
outgoing = outgoing,
|
||||
threadId = threadId,
|
||||
sentTimestamp = sentTimestamp,
|
||||
receivedTimestamp = receivedTimestamp,
|
||||
serverTimestamp = serverTimestamp,
|
||||
remoteDeleted = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.insertMessage(
|
||||
from: RecipientId,
|
||||
to: RecipientId,
|
||||
outgoing: Boolean,
|
||||
threadId: Long,
|
||||
sentTimestamp: Long = System.currentTimeMillis(),
|
||||
receivedTimestamp: Long = if (outgoing) sentTimestamp else sentTimestamp + 1,
|
||||
serverTimestamp: Long = sentTimestamp,
|
||||
body: String? = null,
|
||||
read: Boolean = true,
|
||||
quotes: Long? = null,
|
||||
quoteTargetMissing: Boolean = false,
|
||||
randomMention: Boolean = false,
|
||||
randomStyling: Boolean = false,
|
||||
remoteDeleted: Boolean = false
|
||||
): Long {
|
||||
val type = if (outgoing) {
|
||||
MessageTypes.BASE_SENT_TYPE
|
||||
} else {
|
||||
MessageTypes.BASE_INBOX_TYPE
|
||||
} or MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT
|
||||
|
||||
val contentValues = ContentValues()
|
||||
contentValues.put(MessageTable.DATE_SENT, sentTimestamp)
|
||||
contentValues.put(MessageTable.DATE_RECEIVED, receivedTimestamp)
|
||||
contentValues.put(MessageTable.FROM_RECIPIENT_ID, from.serialize())
|
||||
contentValues.put(MessageTable.TO_RECIPIENT_ID, to.serialize())
|
||||
contentValues.put(MessageTable.THREAD_ID, threadId)
|
||||
contentValues.put(MessageTable.BODY, body)
|
||||
contentValues.put(MessageTable.TYPE, type)
|
||||
contentValues.put(MessageTable.READ, if (read) 1 else 0)
|
||||
|
||||
if (!outgoing) {
|
||||
contentValues.put(MessageTable.DATE_SERVER, serverTimestamp)
|
||||
}
|
||||
|
||||
if (remoteDeleted) {
|
||||
contentValues.put(MessageTable.REMOTE_DELETED, 1)
|
||||
return this
|
||||
.insertInto(MessageTable.TABLE_NAME)
|
||||
.values(contentValues)
|
||||
.run()
|
||||
}
|
||||
|
||||
if (quotes != null) {
|
||||
val quoteDetails = this.getQuoteDetailsFor(quotes)
|
||||
contentValues.put(MessageTable.QUOTE_ID, if (quoteTargetMissing) MessageTable.QUOTE_TARGET_MISSING_ID else quoteDetails.quotedSentTimestamp)
|
||||
contentValues.put(MessageTable.QUOTE_AUTHOR, quoteDetails.authorId.serialize())
|
||||
contentValues.put(MessageTable.QUOTE_BODY, quoteDetails.body)
|
||||
contentValues.put(MessageTable.QUOTE_BODY_RANGES, quoteDetails.bodyRanges)
|
||||
contentValues.put(MessageTable.QUOTE_TYPE, quoteDetails.type)
|
||||
contentValues.put(MessageTable.QUOTE_MISSING, quoteTargetMissing.toInt())
|
||||
}
|
||||
|
||||
if (body != null && (randomMention || randomStyling)) {
|
||||
val ranges: MutableList<BodyRangeList.BodyRange> = mutableListOf()
|
||||
|
||||
if (randomMention) {
|
||||
ranges += BodyRangeList.BodyRange(
|
||||
start = 0,
|
||||
length = Random.nextInt(body.length),
|
||||
mentionUuid = if (outgoing) Recipient.resolved(to).requireAci().toString() else Recipient.resolved(from).requireAci().toString()
|
||||
)
|
||||
}
|
||||
|
||||
if (randomStyling) {
|
||||
ranges += BodyRangeList.BodyRange(
|
||||
start = 0,
|
||||
length = Random.nextInt(body.length),
|
||||
style = BodyRangeList.BodyRange.Style.fromValue(Random.nextInt(BodyRangeList.BodyRange.Style.values().size))
|
||||
)
|
||||
}
|
||||
|
||||
contentValues.put(MessageTable.MESSAGE_RANGES, BodyRangeList(ranges = ranges).encode())
|
||||
}
|
||||
|
||||
return this
|
||||
.insertInto(MessageTable.TABLE_NAME)
|
||||
.values(contentValues)
|
||||
.run()
|
||||
}
|
||||
|
||||
private fun assertDatabaseMatches(expected: DatabaseData, actual: DatabaseData) {
|
||||
assert(expected.keys.size == actual.keys.size) { "Mismatched table count! Expected: ${expected.keys} || Actual: ${actual.keys}" }
|
||||
assert(expected.keys.containsAll(actual.keys)) { "Table names differ! Expected: ${expected.keys} || Actual: ${actual.keys}" }
|
||||
|
||||
val tablesToCheck = expected.keys.filter { !IGNORED_TABLES.contains(it) }
|
||||
|
||||
for (table in tablesToCheck) {
|
||||
val expectedTable: List<Map<String, Any?>> = expected[table]!!
|
||||
val actualTable: List<Map<String, Any?>> = actual[table]!!
|
||||
|
||||
assert(expectedTable.size == actualTable.size) { "Mismatched number of rows for table '$table'! Expected: ${expectedTable.size} || Actual: ${actualTable.size}\n $actualTable" }
|
||||
|
||||
val expectedFiltered: List<Map<String, Any?>> = expectedTable.withoutExcludedColumns(IGNORED_COLUMNS[table])
|
||||
val actualFiltered: List<Map<String, Any?>> = actualTable.withoutExcludedColumns(IGNORED_COLUMNS[table])
|
||||
|
||||
assert(contentEquals(expectedFiltered, actualFiltered)) { "Data did not match for table '$table'!\n${prettyDiff(expectedFiltered, actualFiltered)}" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun contentEquals(expectedRows: List<Map<String, Any?>>, actualRows: List<Map<String, Any?>>): Boolean {
|
||||
if (expectedRows == actualRows) {
|
||||
return true
|
||||
}
|
||||
|
||||
assert(expectedRows.size == actualRows.size)
|
||||
|
||||
for (i in expectedRows.indices) {
|
||||
val expectedRow = expectedRows[i]
|
||||
val actualRow = actualRows[i]
|
||||
|
||||
for (key in expectedRow.keys) {
|
||||
val expectedValue = expectedRow[key]
|
||||
val actualValue = actualRow[key]
|
||||
|
||||
if (!contentEquals(expectedValue, actualValue)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun contentEquals(lhs: Any?, rhs: Any?): Boolean {
|
||||
return if (lhs is ByteArray && rhs is ByteArray) {
|
||||
lhs.contentEquals(rhs)
|
||||
} else {
|
||||
lhs == rhs
|
||||
}
|
||||
}
|
||||
|
||||
private fun prettyDiff(expectedRows: List<Map<String, Any?>>, actualRows: List<Map<String, Any?>>): String {
|
||||
val builder = StringBuilder()
|
||||
|
||||
assert(expectedRows.size == actualRows.size)
|
||||
|
||||
for (i in expectedRows.indices) {
|
||||
val expectedRow = expectedRows[i]
|
||||
val actualRow = actualRows[i]
|
||||
var describedRow = false
|
||||
|
||||
for (key in expectedRow.keys) {
|
||||
val expectedValue = expectedRow[key]
|
||||
val actualValue = actualRow[key]
|
||||
|
||||
if (!contentEquals(expectedValue, actualValue)) {
|
||||
if (!describedRow) {
|
||||
builder.append("-- ROW ${i + 1}\n")
|
||||
describedRow = true
|
||||
}
|
||||
builder.append(" [$key] Expected: ${expectedValue.prettyPrint()} || Actual: ${actualValue.prettyPrint()} \n")
|
||||
}
|
||||
}
|
||||
|
||||
if (describedRow) {
|
||||
builder.append("\n")
|
||||
builder.append("Expected: $expectedRow\n")
|
||||
builder.append("Actual: $actualRow\n")
|
||||
}
|
||||
}
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
private fun Any?.prettyPrint(): String {
|
||||
return when (this) {
|
||||
is ByteArray -> "Bytes(${Hex.toString(this)})"
|
||||
else -> this.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<Map<String, Any?>>.withoutExcludedColumns(ignored: Set<String>?): List<Map<String, Any?>> {
|
||||
return if (ignored != null) {
|
||||
this.map { row ->
|
||||
row.filterKeys { !ignored.contains(it) }
|
||||
}
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.getQuoteDetailsFor(messageId: Long): QuoteDetails {
|
||||
return this
|
||||
.select(
|
||||
MessageTable.DATE_SENT,
|
||||
MessageTable.FROM_RECIPIENT_ID,
|
||||
MessageTable.BODY,
|
||||
MessageTable.MESSAGE_RANGES
|
||||
)
|
||||
.from(MessageTable.TABLE_NAME)
|
||||
.where("${MessageTable.ID} = ?", messageId)
|
||||
.run()
|
||||
.readToSingleObject { cursor ->
|
||||
QuoteDetails(
|
||||
quotedSentTimestamp = cursor.requireLong(MessageTable.DATE_SENT),
|
||||
authorId = RecipientId.from(cursor.requireLong(MessageTable.FROM_RECIPIENT_ID)),
|
||||
body = cursor.requireString(MessageTable.BODY),
|
||||
bodyRanges = cursor.requireBlob(MessageTable.MESSAGE_RANGES),
|
||||
type = QuoteModel.Type.NORMAL.code
|
||||
)
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.readAllContents(): DatabaseData {
|
||||
return SqlUtil.getAllTables(this).associateWith { table -> this.getAllTableData(table) }
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.getAllTableData(table: String): List<Map<String, Any?>> {
|
||||
return this
|
||||
.select()
|
||||
.from(table)
|
||||
.run()
|
||||
.readToList { cursor ->
|
||||
val map: MutableMap<String, Any?> = mutableMapOf()
|
||||
|
||||
for (i in 0 until cursor.columnCount) {
|
||||
val column = cursor.getColumnName(i)
|
||||
|
||||
when (cursor.getType(i)) {
|
||||
Cursor.FIELD_TYPE_INTEGER -> map[column] = cursor.getInt(i)
|
||||
Cursor.FIELD_TYPE_FLOAT -> map[column] = cursor.getFloat(i)
|
||||
Cursor.FIELD_TYPE_STRING -> map[column] = cursor.getString(i)
|
||||
Cursor.FIELD_TYPE_BLOB -> map[column] = cursor.getBlob(i)
|
||||
Cursor.FIELD_TYPE_NULL -> map[column] = null
|
||||
}
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
}
|
||||
|
||||
private data class QuoteDetails(
|
||||
val quotedSentTimestamp: Long,
|
||||
val authorId: RecipientId,
|
||||
val body: String?,
|
||||
val bodyRanges: ByteArray?,
|
||||
val type: Int
|
||||
)
|
||||
}
|
||||
@@ -61,8 +61,8 @@ class AttachmentTableTest {
|
||||
false
|
||||
)
|
||||
|
||||
val attachment1Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment.attachmentId, AttachmentTable.DATA)
|
||||
val attachment2Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment2.attachmentId, AttachmentTable.DATA)
|
||||
val attachment1Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment.attachmentId, AttachmentTable.DATA_FILE)
|
||||
val attachment2Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment2.attachmentId, AttachmentTable.DATA_FILE)
|
||||
|
||||
assertNotEquals(attachment1Info, attachment2Info)
|
||||
}
|
||||
@@ -89,8 +89,8 @@ class AttachmentTableTest {
|
||||
true
|
||||
)
|
||||
|
||||
val attachment1Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment.attachmentId, AttachmentTable.DATA)
|
||||
val attachment2Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment2.attachmentId, AttachmentTable.DATA)
|
||||
val attachment1Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment.attachmentId, AttachmentTable.DATA_FILE)
|
||||
val attachment2Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment2.attachmentId, AttachmentTable.DATA_FILE)
|
||||
|
||||
assertNotEquals(attachment1Info, attachment2Info)
|
||||
}
|
||||
@@ -124,9 +124,9 @@ class AttachmentTableTest {
|
||||
SignalDatabase.attachments.updateAttachmentData(standardDatabaseAttachment, createMediaStream(compressedData), false)
|
||||
|
||||
// THEN
|
||||
val previousInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(previousDatabaseAttachmentId, AttachmentTable.DATA)!!
|
||||
val standardInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(standardDatabaseAttachment.attachmentId, AttachmentTable.DATA)!!
|
||||
val highInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(highDatabaseAttachment.attachmentId, AttachmentTable.DATA)!!
|
||||
val previousInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(previousDatabaseAttachmentId, AttachmentTable.DATA_FILE)!!
|
||||
val standardInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(standardDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!!
|
||||
val highInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(highDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!!
|
||||
|
||||
assertNotEquals(standardInfo, highInfo)
|
||||
standardInfo.file assertIs previousInfo.file
|
||||
@@ -158,9 +158,9 @@ class AttachmentTableTest {
|
||||
val secondHighDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(secondHighQualityPreUpload)
|
||||
|
||||
// THEN
|
||||
val standardInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(standardDatabaseAttachment.attachmentId, AttachmentTable.DATA)!!
|
||||
val highInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(highDatabaseAttachment.attachmentId, AttachmentTable.DATA)!!
|
||||
val secondHighInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(secondHighDatabaseAttachment.attachmentId, AttachmentTable.DATA)!!
|
||||
val standardInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(standardDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!!
|
||||
val highInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(highDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!!
|
||||
val secondHighInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(secondHighDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!!
|
||||
|
||||
highInfo.file assertIsNot standardInfo.file
|
||||
secondHighInfo.file assertIs highInfo.file
|
||||
|
||||
@@ -214,6 +214,175 @@ class CallTableTest {
|
||||
assertEquals(CallTable.Event.JOINED, acceptedCall?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAnOutgoingRingCall_whenIAcceptedOutgoingGroupCall_thenIExpectOutgoingRing() {
|
||||
val callId = 1L
|
||||
SignalDatabase.calls.insertAcceptedGroupCall(
|
||||
callId = callId,
|
||||
recipientId = groupRecipientId,
|
||||
direction = CallTable.Direction.OUTGOING,
|
||||
timestamp = 1
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.OUTGOING_RING, call?.event)
|
||||
|
||||
SignalDatabase.calls.acceptOutgoingGroupCall(
|
||||
call!!
|
||||
)
|
||||
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
assertEquals(CallTable.Event.OUTGOING_RING, acceptedCall?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenARingingCall_whenIAcceptedOutgoingGroupCall_thenIExpectAccepted() {
|
||||
val callId = 1L
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
ringId = callId,
|
||||
groupRecipientId = groupRecipientId,
|
||||
ringerRecipient = harness.others[1],
|
||||
dateReceived = System.currentTimeMillis(),
|
||||
ringState = CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.RINGING, call?.event)
|
||||
|
||||
SignalDatabase.calls.acceptOutgoingGroupCall(
|
||||
call!!
|
||||
)
|
||||
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAMissedCall_whenIAcceptedOutgoingGroupCall_thenIExpectAccepted() {
|
||||
val callId = 1L
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
ringId = callId,
|
||||
groupRecipientId = groupRecipientId,
|
||||
ringerRecipient = harness.others[1],
|
||||
dateReceived = System.currentTimeMillis(),
|
||||
ringState = CallManager.RingUpdate.EXPIRED_REQUEST
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.MISSED, call?.event)
|
||||
|
||||
SignalDatabase.calls.acceptOutgoingGroupCall(
|
||||
call!!
|
||||
)
|
||||
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenADeclinedCall_whenIAcceptedOutgoingGroupCall_thenIExpectAccepted() {
|
||||
val callId = 1L
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
ringId = callId,
|
||||
groupRecipientId = groupRecipientId,
|
||||
ringerRecipient = harness.others[1],
|
||||
dateReceived = System.currentTimeMillis(),
|
||||
ringState = CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.DECLINED, call?.event)
|
||||
|
||||
SignalDatabase.calls.acceptOutgoingGroupCall(
|
||||
call!!
|
||||
)
|
||||
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAnAcceptedCall_whenIAcceptedOutgoingGroupCall_thenIExpectAccepted() {
|
||||
val callId = 1L
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
|
||||
ringId = callId,
|
||||
groupRecipientId = groupRecipientId,
|
||||
ringerRecipient = harness.others[1],
|
||||
dateReceived = System.currentTimeMillis(),
|
||||
ringState = CallManager.RingUpdate.REQUESTED
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.RINGING, call?.event)
|
||||
|
||||
SignalDatabase.calls.acceptIncomingGroupCall(
|
||||
call!!
|
||||
)
|
||||
|
||||
SignalDatabase.calls.acceptOutgoingGroupCall(
|
||||
SignalDatabase.calls.getCallById(callId, groupRecipientId)!!
|
||||
)
|
||||
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGenericGroupCall_whenIAcceptedOutgoingGroupCall_thenIExpectOutgoingRing() {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = groupRecipientId,
|
||||
sender = harness.others[1],
|
||||
timestamp = System.currentTimeMillis(),
|
||||
peekGroupCallEraId = "aaa",
|
||||
peekJoinedUuids = emptyList(),
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
|
||||
|
||||
SignalDatabase.calls.acceptOutgoingGroupCall(
|
||||
call!!
|
||||
)
|
||||
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
assertEquals(CallTable.Event.OUTGOING_RING, acceptedCall?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAJoinedGroupCall_whenIAcceptedOutgoingGroupCall_thenIExpectOutgoingRing() {
|
||||
val era = "aaa"
|
||||
val callId = CallId.fromEra(era).longValue()
|
||||
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
|
||||
groupRecipientId = groupRecipientId,
|
||||
sender = harness.others[1],
|
||||
timestamp = System.currentTimeMillis(),
|
||||
peekGroupCallEraId = "aaa",
|
||||
peekJoinedUuids = emptyList(),
|
||||
isCallFull = false
|
||||
)
|
||||
|
||||
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
assertNotNull(call)
|
||||
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
|
||||
|
||||
SignalDatabase.calls.acceptIncomingGroupCall(
|
||||
call!!
|
||||
)
|
||||
|
||||
SignalDatabase.calls.acceptOutgoingGroupCall(SignalDatabase.calls.getCallById(callId, groupRecipientId)!!)
|
||||
val acceptedCall = SignalDatabase.calls.getCallById(callId, groupRecipientId)
|
||||
assertEquals(CallTable.Event.OUTGOING_RING, acceptedCall?.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoPriorCallEvent_whenIReceiveAGroupCallUpdateMessage_thenIExpectAGenericGroupCall() {
|
||||
val era = "aaa"
|
||||
|
||||
@@ -80,7 +80,8 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
|
||||
dns = Optional.of(SignalServiceNetworkAccess.DNS),
|
||||
signalProxy = Optional.empty(),
|
||||
zkGroupServerPublicParams = Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS),
|
||||
genericServerPublicParams = Base64.decode(BuildConfig.GENERIC_SERVER_PUBLIC_PARAMS)
|
||||
genericServerPublicParams = Base64.decode(BuildConfig.GENERIC_SERVER_PUBLIC_PARAMS),
|
||||
backupServerPublicParams = Base64.decode(BuildConfig.BACKUP_SERVER_PUBLIC_PARAMS)
|
||||
)
|
||||
|
||||
serviceNetworkAccessMock = mock {
|
||||
|
||||
@@ -39,7 +39,6 @@ class EditMessageSyncProcessorTest {
|
||||
)
|
||||
|
||||
private val IGNORE_ATTACHMENT_COLUMNS = listOf(
|
||||
AttachmentTable.UNIQUE_ID,
|
||||
AttachmentTable.TRANSFER_FILE
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.assertIsNotNull
|
||||
import org.thoughtcrime.securesms.testing.assertIsNull
|
||||
import org.thoughtcrime.securesms.testing.success
|
||||
import org.whispersystems.signalservice.api.util.Usernames
|
||||
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -96,7 +97,7 @@ class UsernameEditFragmentTest {
|
||||
fun testNicknameUpdateHappyPath() {
|
||||
val nickname = "Spiderman"
|
||||
val discriminator = "4578"
|
||||
val username = "$nickname${UsernameState.DELIMITER}$discriminator"
|
||||
val username = "$nickname${Usernames.DELIMITER}$discriminator"
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Put("/v1/accounts/username/reserved") {
|
||||
|
||||
@@ -165,7 +165,7 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
override fun storeSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?, record: SenderKeyRecord?) = throw UnsupportedOperationException()
|
||||
override fun loadSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?): SenderKeyRecord = throw UnsupportedOperationException()
|
||||
override fun archiveSession(address: SignalProtocolAddress?) = throw UnsupportedOperationException()
|
||||
override fun getAllAddressesWithActiveSessions(addressNames: MutableList<String>?): MutableSet<SignalProtocolAddress> = throw UnsupportedOperationException()
|
||||
override fun getAllAddressesWithActiveSessions(addressNames: MutableList<String>?): MutableMap<SignalProtocolAddress, SessionRecord> = throw UnsupportedOperationException()
|
||||
override fun getSenderKeySharedWith(distributionId: DistributionId?): MutableSet<SignalProtocolAddress> = throw UnsupportedOperationException()
|
||||
override fun markSenderKeySharedWith(distributionId: DistributionId?, addresses: MutableCollection<SignalProtocolAddress>?) = throw UnsupportedOperationException()
|
||||
override fun clearSenderKeySharedWith(addresses: MutableCollection<SignalProtocolAddress>?) = throw UnsupportedOperationException()
|
||||
|
||||
@@ -10,7 +10,7 @@ import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.IncomingTextOnly
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.OutgoingTextOnly
|
||||
import org.thoughtcrime.securesms.database.MessageTypes
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck
|
||||
@@ -78,7 +78,7 @@ class ConversationElementGenerator {
|
||||
|
||||
val isIncoming = random.nextBoolean()
|
||||
|
||||
val record = MediaMmsMessageRecord(
|
||||
val record = MmsMessageRecord(
|
||||
messageId,
|
||||
if (isIncoming) Recipient.UNKNOWN else Recipient.self(),
|
||||
0,
|
||||
@@ -86,7 +86,7 @@ class ConversationElementGenerator {
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
1,
|
||||
true,
|
||||
1,
|
||||
testMessage,
|
||||
SlideDeck(),
|
||||
@@ -97,7 +97,7 @@ class ConversationElementGenerator {
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
1,
|
||||
true,
|
||||
null,
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
@@ -106,7 +106,7 @@ class ConversationElementGenerator {
|
||||
false,
|
||||
false,
|
||||
now,
|
||||
1,
|
||||
true,
|
||||
now,
|
||||
null,
|
||||
StoryType.NONE,
|
||||
@@ -117,7 +117,8 @@ class ConversationElementGenerator {
|
||||
-1,
|
||||
null,
|
||||
null,
|
||||
0
|
||||
0,
|
||||
false
|
||||
)
|
||||
|
||||
val conversationMessage = ConversationMessageFactory.createWithUnresolvedData(
|
||||
|
||||
@@ -594,6 +594,13 @@
|
||||
android:host="signal.group"/>
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="signaldonations.org" android:pathPrefix="/stripe/return/ideal"/>
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -999,7 +1006,7 @@
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".contacts.TurnOffContactJoinedNotificationsActivity"
|
||||
android:theme="@style/Theme.AppCompat.Dialog.Alert"
|
||||
android:theme="@style/TextSecure.DialogActivity"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".contactshare.ContactShareEditActivity"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,13 @@
|
||||
package com.google.android.material.bottomsheet
|
||||
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* Manually adjust the nested scrolling child for a given [BottomSheetBehavior].
|
||||
*/
|
||||
object BottomSheetBehaviorHack {
|
||||
fun setNestedScrollingChild(behavior: BottomSheetBehavior<FrameLayout>, view: View) {
|
||||
fun <T : View> setNestedScrollingChild(behavior: BottomSheetBehavior<T>, view: View) {
|
||||
behavior.nestedScrollingChildRef = WeakReference(view)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.whispersystems.signalservice.api.account.AccountAttributes
|
||||
|
||||
object AppCapabilities {
|
||||
@@ -17,7 +16,7 @@ object AppCapabilities {
|
||||
changeNumber = true,
|
||||
stories = true,
|
||||
giftBadges = true,
|
||||
pni = FeatureFlags.phoneNumberPrivacy(),
|
||||
pni = true,
|
||||
paymentActivation = true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import org.signal.core.util.concurrent.AnrDetector;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.AndroidLogger;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.core.util.logging.Scrubber;
|
||||
import org.signal.core.util.tracing.Tracer;
|
||||
import org.signal.glide.SignalGlideCodecs;
|
||||
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider;
|
||||
@@ -56,6 +57,7 @@ import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.ExternalLaunchDonationJob;
|
||||
import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.FontDownloaderJob;
|
||||
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob;
|
||||
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.PnpInitializeDevicesJob;
|
||||
@@ -159,9 +161,9 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addBlocking("rx-init", this::initializeRx)
|
||||
.addBlocking("event-bus", () -> EventBus.builder().logNoSubscriberMessages(false).installDefaultEventBus())
|
||||
.addBlocking("app-dependencies", this::initializeAppDependencies)
|
||||
.addBlocking("scrubber", () -> Scrubber.setIdentifierHmacKeyProvider(() -> SignalStore.svr().getOrCreateMasterKey().deriveLoggingKey()))
|
||||
.addBlocking("first-launch", this::initializeFirstEverAppLaunch)
|
||||
.addBlocking("app-migrations", this::initializeApplicationMigrations)
|
||||
.addBlocking("mark-registration", () -> RegistrationUtil.maybeMarkRegistrationComplete())
|
||||
.addBlocking("lifecycle-observer", () -> ApplicationDependencies.getAppForegroundObserver().addListener(this))
|
||||
.addBlocking("message-retriever", this::initializeMessageRetrieval)
|
||||
.addBlocking("dynamic-theme", () -> DynamicTheme.setDefaultDayNightMode(this))
|
||||
@@ -175,6 +177,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addBlocking("feature-flags", FeatureFlags::init)
|
||||
.addBlocking("ring-rtc", this::initializeRingRtc)
|
||||
.addBlocking("glide", () -> SignalGlideModule.setRegisterGlideComponents(new SignalGlideComponents()))
|
||||
.addNonBlocking(() -> RegistrationUtil.maybeMarkRegistrationComplete())
|
||||
.addNonBlocking(() -> GlideApp.get(this))
|
||||
.addNonBlocking(this::cleanAvatarStorage)
|
||||
.addNonBlocking(this::initializeRevealableMessageManager)
|
||||
@@ -212,6 +215,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addPostRender(() -> ApplicationDependencies.getExoPlayerPool().getPoolStats().getMaxUnreserved())
|
||||
.addPostRender(() -> ApplicationDependencies.getRecipientCache().warmUp())
|
||||
.addPostRender(AccountConsistencyWorkerJob::enqueueIfNecessary)
|
||||
.addPostRender(GroupRingCleanupJob::enqueue)
|
||||
.execute();
|
||||
|
||||
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
@@ -234,7 +238,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
FeatureFlags.refreshIfNecessary();
|
||||
RetrieveProfileJob.enqueueRoutineFetchIfNecessary(this);
|
||||
RetrieveProfileJob.enqueueRoutineFetchIfNecessary();
|
||||
executePendingContactSync();
|
||||
KeyCachingService.onAppForegrounded(this);
|
||||
ApplicationDependencies.getShakeToReport().enable();
|
||||
@@ -426,6 +430,9 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
if (FeatureFlags.callingFieldTrialAnyAddressPortsKillSwitch()) {
|
||||
fieldTrials.put("RingRTC-AnyAddressPortsKillSwitch", "Enabled");
|
||||
}
|
||||
if (!SignalStore.internalValues().callingDisableLBRed()) {
|
||||
fieldTrials.put("RingRTC-Audio-LBRed-For-Opus", "Enabled,bitrate_pri:22000");
|
||||
}
|
||||
CallManager.initialize(this, new RingRtcLogger(), fieldTrials);
|
||||
} catch (UnsatisfiedLinkError e) {
|
||||
throw new AssertionError("Unable to load ringrtc library", e);
|
||||
|
||||
@@ -20,4 +20,5 @@ public interface BindableConversationListItem extends Unbindable {
|
||||
|
||||
void setSelectedConversations(@NonNull ConversationSet conversations);
|
||||
void updateTypingIndicator(@NonNull Set<Long> typingThreads);
|
||||
void updateTimestamp();
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ public class DeviceActivity extends PassphraseRequiredActivity
|
||||
ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey();
|
||||
|
||||
TextSecurePreferences.setMultiDevice(DeviceActivity.this, true);
|
||||
accountManager.addDevice(ephemeralId, publicKey, aciIdentityKeyPair, pniIdentityKeyPair, profileKey, verificationCode);
|
||||
accountManager.addDevice(ephemeralId, publicKey, aciIdentityKeyPair, pniIdentityKeyPair, profileKey, SignalStore.svr().getOrCreateMasterKey(), verificationCode);
|
||||
|
||||
return SUCCESS;
|
||||
} catch (NotFoundException e) {
|
||||
|
||||
@@ -17,8 +17,11 @@ import androidx.lifecycle.ViewModelProvider;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.donations.StripeApi;
|
||||
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment;
|
||||
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment;
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
||||
import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment;
|
||||
@@ -89,10 +92,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
ConversationListTabRepository repository = new ConversationListTabRepository();
|
||||
ConversationListTabsViewModel.Factory factory = new ConversationListTabsViewModel.Factory(repository);
|
||||
|
||||
handleGroupLinkInIntent(getIntent());
|
||||
handleProxyInIntent(getIntent());
|
||||
handleSignalMeIntent(getIntent());
|
||||
handleCallLinkInIntent(getIntent());
|
||||
handleDeeplinkIntent(getIntent());
|
||||
|
||||
CachedInflater.from(this).clear();
|
||||
|
||||
@@ -134,10 +134,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
handleGroupLinkInIntent(intent);
|
||||
handleProxyInIntent(intent);
|
||||
handleSignalMeIntent(intent);
|
||||
handleCallLinkInIntent(intent);
|
||||
handleDeeplinkIntent(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -203,6 +200,14 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
return navigator;
|
||||
}
|
||||
|
||||
private void handleDeeplinkIntent(Intent intent) {
|
||||
handleGroupLinkInIntent(intent);
|
||||
handleProxyInIntent(intent);
|
||||
handleSignalMeIntent(intent);
|
||||
handleCallLinkInIntent(intent);
|
||||
handleDonateReturnIntent(intent);
|
||||
}
|
||||
|
||||
private void handleGroupLinkInIntent(Intent intent) {
|
||||
Uri data = intent.getData();
|
||||
if (data != null) {
|
||||
@@ -231,6 +236,13 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
}
|
||||
}
|
||||
|
||||
private void handleDonateReturnIntent(Intent intent) {
|
||||
Uri data = intent.getData();
|
||||
if (data != null && data.toString().startsWith(StripeApi.RETURN_URL_IDEAL)) {
|
||||
startActivity(AppSettingsActivity.manageSubscriptions(this));
|
||||
}
|
||||
}
|
||||
|
||||
public void onFirstRender() {
|
||||
onFirstRender = true;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -313,7 +312,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
}
|
||||
|
||||
private @Nullable ActionItem createRemoveActionItem(@NonNull Recipient recipient) {
|
||||
if (!FeatureFlags.hideContacts() || recipient.isSelf() || recipient.isGroup()) {
|
||||
if (recipient.isSelf() || recipient.isGroup()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,12 +53,12 @@ import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.thoughtcrime.securesms.components.TooltipPopup;
|
||||
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallLinkInfoSheet;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallLinkProfileKeySender;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallOverflowPopupWindow;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallStateUpdatePopupWindow;
|
||||
@@ -73,6 +73,8 @@ import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WifiToCellularPopupWindow;
|
||||
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoController;
|
||||
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel;
|
||||
import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog;
|
||||
import org.thoughtcrime.securesms.components.webrtc.requests.CallLinkIncomingRequestSheet;
|
||||
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
|
||||
@@ -80,7 +82,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.events.WebRtcViewModel;
|
||||
import org.thoughtcrime.securesms.messagerequests.CalleeMustAcceptMessageRequestActivity;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet;
|
||||
@@ -95,6 +97,7 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.VibrateUtil;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState;
|
||||
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
|
||||
@@ -112,7 +115,7 @@ import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
|
||||
|
||||
public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChangeDialog.Callback {
|
||||
public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChangeDialog.Callback, ReactWithAnyEmojiBottomSheetDialogFragment.Callback {
|
||||
|
||||
private static final String TAG = Log.tag(WebRtcCallActivity.class);
|
||||
|
||||
@@ -137,13 +140,16 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
private CallParticipantsListUpdatePopupWindow participantUpdateWindow;
|
||||
private CallStateUpdatePopupWindow callStateUpdatePopupWindow;
|
||||
private CallOverflowPopupWindow callOverflowPopupWindow;
|
||||
private WifiToCellularPopupWindow wifiToCellularPopupWindow;
|
||||
private DeviceOrientationMonitor deviceOrientationMonitor;
|
||||
|
||||
private FullscreenHelper fullscreenHelper;
|
||||
private WebRtcCallView callScreen;
|
||||
private TooltipPopup videoTooltip;
|
||||
private TooltipPopup switchCameraTooltip;
|
||||
private WebRtcCallViewModel viewModel;
|
||||
private ControlsAndInfoViewModel controlsAndInfoViewModel;
|
||||
private boolean enableVideoIfAvailable;
|
||||
private boolean hasWarnedAboutBluetooth;
|
||||
private WindowLayoutInfoConsumer windowLayoutInfoConsumer;
|
||||
@@ -152,6 +158,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
private PictureInPictureParams.Builder pipBuilderParams;
|
||||
private LifecycleDisposable lifecycleDisposable;
|
||||
private long lastCallLinkDisconnectDialogShowTime;
|
||||
private ControlsAndInfoController controlsAndInfo;
|
||||
|
||||
private Disposable ephemeralStateDisposable = Disposable.empty();
|
||||
|
||||
@@ -161,7 +168,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
super.attachBaseContext(newBase);
|
||||
}
|
||||
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
@SuppressLint({ "SourceLockedOrientationActivity", "MissingInflatedId" })
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
Log.i(TAG, "onCreate(" + getIntent().getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false) + ")");
|
||||
@@ -189,6 +196,16 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
initializeViewModel(isLandscapeEnabled);
|
||||
initializePictureInPictureParams();
|
||||
|
||||
controlsAndInfo = new ControlsAndInfoController(this, callScreen, callOverflowPopupWindow, viewModel, controlsAndInfoViewModel);
|
||||
controlsAndInfo.addVisibilityListener(new FadeCallback());
|
||||
|
||||
fullscreenHelper.showAndHideWithSystemUI(getWindow(),
|
||||
findViewById(R.id.call_screen_header_gradient),
|
||||
findViewById(R.id.webrtc_call_view_toolbar_text),
|
||||
findViewById(R.id.webrtc_call_view_toolbar_no_text));
|
||||
|
||||
lifecycleDisposable.add(controlsAndInfo);
|
||||
|
||||
logIntent(getIntent());
|
||||
|
||||
if (ANSWER_VIDEO_ACTION.equals(getIntent().getAction())) {
|
||||
@@ -210,6 +227,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
requestNewSizesThrottle = new ThrottledDebouncer(TimeUnit.SECONDS.toMillis(1));
|
||||
|
||||
initializePendingParticipantFragmentListener();
|
||||
|
||||
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_dark_colorSurface));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -419,6 +438,13 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
participantUpdateWindow = new CallParticipantsListUpdatePopupWindow(callScreen);
|
||||
callStateUpdatePopupWindow = new CallStateUpdatePopupWindow(callScreen);
|
||||
wifiToCellularPopupWindow = new WifiToCellularPopupWindow(callScreen);
|
||||
callOverflowPopupWindow = new CallOverflowPopupWindow(this, callScreen, () -> {
|
||||
CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot();
|
||||
if (state == null) {
|
||||
return false;
|
||||
}
|
||||
return state.getLocalParticipant().isHandRaised();
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeViewModel(boolean isLandscapeEnabled) {
|
||||
@@ -431,7 +457,10 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
viewModel.setIsLandscapeEnabled(isLandscapeEnabled);
|
||||
viewModel.setIsInPipMode(isInPipMode());
|
||||
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
|
||||
viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls);
|
||||
viewModel.getWebRtcControls().observe(this, controls -> {
|
||||
callScreen.setWebRtcControls(controls);
|
||||
controlsAndInfo.updateControls(controls);
|
||||
});
|
||||
viewModel.getEvents().observe(this, this::handleViewModelEvent);
|
||||
|
||||
lifecycleDisposable.add(viewModel.getInCallstatus().subscribe(this::handleInCallStatus));
|
||||
@@ -475,6 +504,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
.subscribe(callScreen::updatePendingParticipantsList);
|
||||
|
||||
lifecycleDisposable.add(disposable);
|
||||
|
||||
controlsAndInfoViewModel = new ViewModelProvider(this).get(ControlsAndInfoViewModel.class);
|
||||
}
|
||||
|
||||
private void initializePictureInPictureParams() {
|
||||
@@ -522,7 +553,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
.setText(R.string.WebRtcCallActivity__tap_here_to_turn_on_your_video)
|
||||
.setOnDismissListener(() -> viewModel.onDismissedVideoTooltip())
|
||||
.show(TooltipPopup.POSITION_ABOVE);
|
||||
return;
|
||||
}
|
||||
} else if (event instanceof WebRtcCallViewModel.Event.DismissVideoTooltip) {
|
||||
if (videoTooltip != null) {
|
||||
@@ -531,6 +561,20 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
} else if (event instanceof WebRtcCallViewModel.Event.ShowWifiToCellularPopup) {
|
||||
wifiToCellularPopupWindow.show();
|
||||
} else if (event instanceof WebRtcCallViewModel.Event.ShowSwitchCameraTooltip) {
|
||||
if (switchCameraTooltip == null) {
|
||||
switchCameraTooltip = TooltipPopup.forTarget(callScreen.getSwitchCameraTooltipTarget())
|
||||
.setBackgroundTint(ContextCompat.getColor(this, R.color.core_ultramarine))
|
||||
.setTextColor(ContextCompat.getColor(this, R.color.core_white))
|
||||
.setText(R.string.WebRtcCallActivity__flip_camera_tooltip)
|
||||
.setOnDismissListener(() -> viewModel.onDismissedSwitchCameraTooltip())
|
||||
.show(TooltipPopup.POSITION_ABOVE);
|
||||
}
|
||||
} else if (event instanceof WebRtcCallViewModel.Event.DismissSwitchCameraTooltip) {
|
||||
if (switchCameraTooltip != null) {
|
||||
switchCameraTooltip.dismiss();
|
||||
switchCameraTooltip = null;
|
||||
}
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unknown event: " + event);
|
||||
}
|
||||
@@ -825,6 +869,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
viewModel.setRecipient(event.getRecipient());
|
||||
callScreen.setRecipient(event.getRecipient());
|
||||
controlsAndInfoViewModel.setRecipient(event.getRecipient());
|
||||
|
||||
switch (event.getState()) {
|
||||
case CALL_PRE_JOIN:
|
||||
@@ -912,7 +957,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
private void handleCallPreJoin(@NonNull WebRtcViewModel event) {
|
||||
if (event.getGroupState().isNotIdle()) {
|
||||
callScreen.setStatusFromGroupCallState(event.getGroupState());
|
||||
callScreen.setRingGroup(event.shouldRingGroup());
|
||||
|
||||
if (event.shouldRingGroup() && event.areRemoteDevicesInCall()) {
|
||||
@@ -933,6 +977,15 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
MessageSender.onMessageSent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReactWithAnyEmojiDialogDismissed() { /* no-op */ }
|
||||
|
||||
@Override
|
||||
public void onReactWithAnyEmojiSelected(@NonNull String emoji) {
|
||||
ApplicationDependencies.getSignalCallManager().react(emoji);
|
||||
callOverflowPopupWindow.dismiss();
|
||||
}
|
||||
|
||||
private final class ControlsListener implements WebRtcCallView.ControlsListener {
|
||||
|
||||
@Override
|
||||
@@ -946,22 +999,13 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onControlsFadeOut() {
|
||||
if (videoTooltip != null) {
|
||||
videoTooltip.dismiss();
|
||||
public void toggleControls() {
|
||||
WebRtcControls controlState = viewModel.getWebRtcControls().getValue();
|
||||
if (controlState != null && !controlState.displayIncomingCallButtons()) {
|
||||
controlsAndInfo.toggleControls();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showSystemUI() {
|
||||
fullscreenHelper.showSystemUI();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hideSystemUI() {
|
||||
fullscreenHelper.hideSystemUI();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput) {
|
||||
maybeDisplaySpeakerphonePopup(audioOutput);
|
||||
@@ -1022,6 +1066,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
handleAnswerWithAudio();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOverflowClicked() {
|
||||
controlsAndInfo.toggleOverflowPopup();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAcceptCallPressed() {
|
||||
if (viewModel.isAnswerWithVideoAvailable()) {
|
||||
@@ -1039,6 +1088,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
@Override
|
||||
public void onLocalPictureInPictureClicked() {
|
||||
viewModel.onLocalPictureInPictureClicked();
|
||||
controlsAndInfo.restartHideControlsTimer();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1055,13 +1105,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
@Override
|
||||
public void onCallInfoClicked() {
|
||||
LiveRecipient liveRecipient = viewModel.getRecipient();
|
||||
|
||||
if (liveRecipient.get().isCallLink()) {
|
||||
CallLinkInfoSheet.show(getSupportFragmentManager(), liveRecipient.get().requireCallLinkRoomId());
|
||||
} else {
|
||||
CallParticipantsListDialog.show(getSupportFragmentManager());
|
||||
}
|
||||
controlsAndInfo.showCallInfo();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1124,4 +1168,20 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class FadeCallback implements ControlsAndInfoController.BottomSheetVisibilityListener {
|
||||
|
||||
@Override
|
||||
public void onShown() {
|
||||
fullscreenHelper.showSystemUI();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onHidden() {
|
||||
fullscreenHelper.hideSystemUI();
|
||||
if (videoTooltip != null) {
|
||||
videoTooltip.dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.animation;
|
||||
|
||||
import android.graphics.Point;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.animation.Animation;
|
||||
@@ -16,6 +17,10 @@ public class ResizeAnimation extends Animation {
|
||||
private int startWidth;
|
||||
private int startHeight;
|
||||
|
||||
public ResizeAnimation(@NonNull View target, @NonNull Point dimension) {
|
||||
this(target, dimension.x, dimension.y);
|
||||
}
|
||||
|
||||
public ResizeAnimation(@NonNull View target, int targetWidthPx, int targetHeightPx) {
|
||||
this.target = target;
|
||||
this.targetWidthPx = targetWidthPx;
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.signal.core.util.StreamUtil
|
||||
import org.signal.core.util.getDownloadManager
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.ApkUpdateJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.FileUtils
|
||||
@@ -38,7 +39,9 @@ object ApkUpdateInstaller {
|
||||
*/
|
||||
fun installOrPromptForInstall(context: Context, downloadId: Long, userInitiated: Boolean) {
|
||||
if (downloadId != SignalStore.apkUpdate().downloadId) {
|
||||
Log.w(TAG, "DownloadId doesn't match the one we're waiting for! We likely have newer data. Ignoring.")
|
||||
Log.w(TAG, "DownloadId doesn't match the one we're waiting for (current: $downloadId, expected: ${SignalStore.apkUpdate().downloadId})! We likely have newer data. Ignoring.")
|
||||
ApkUpdateNotifications.dismissInstallPrompt(context)
|
||||
ApplicationDependencies.getJobManager().add(ApkUpdateJob())
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ object ApkUpdateNotifications {
|
||||
*/
|
||||
@SuppressLint("LaunchActivityFromNotification")
|
||||
fun showInstallPrompt(context: Context, downloadId: Long) {
|
||||
Log.d(TAG, "Showing install prompt. DownloadId: $downloadId")
|
||||
ServiceUtil.getNotificationManager(context).cancel(NotificationIds.APK_UPDATE_FAILED_INSTALL)
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
1,
|
||||
@@ -37,7 +40,7 @@ object ApkUpdateNotifications {
|
||||
action = ApkUpdateNotificationReceiver.ACTION_INITIATE_INSTALL
|
||||
putExtra(ApkUpdateNotificationReceiver.EXTRA_DOWNLOAD_ID, downloadId)
|
||||
},
|
||||
PendingIntentFlags.immutable()
|
||||
PendingIntentFlags.updateCurrent()
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_UPDATES)
|
||||
@@ -52,7 +55,15 @@ object ApkUpdateNotifications {
|
||||
ServiceUtil.getNotificationManager(context).notify(NotificationIds.APK_UPDATE_PROMPT_INSTALL, notification)
|
||||
}
|
||||
|
||||
fun dismissInstallPrompt(context: Context) {
|
||||
Log.d(TAG, "Dismissing install prompt.")
|
||||
ServiceUtil.getNotificationManager(context).cancel(NotificationIds.APK_UPDATE_PROMPT_INSTALL)
|
||||
}
|
||||
|
||||
fun showInstallFailed(context: Context, reason: FailureReason) {
|
||||
Log.d(TAG, "Showing failed notification. Reason: $reason")
|
||||
ServiceUtil.getNotificationManager(context).cancel(NotificationIds.APK_UPDATE_PROMPT_INSTALL)
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
|
||||
@@ -36,9 +36,13 @@ class ApkUpdatePackageInstallerReceiver : BroadcastReceiver() {
|
||||
|
||||
when (statusCode) {
|
||||
PackageInstaller.STATUS_SUCCESS -> {
|
||||
Log.i(TAG, "Update installed successfully!")
|
||||
SignalStore.apkUpdate().lastApkUploadTime = SignalStore.apkUpdate().pendingApkUploadTime
|
||||
ApkUpdateNotifications.showAutoUpdateSuccess(context)
|
||||
if (SignalStore.apkUpdate().lastApkUploadTime != SignalStore.apkUpdate().pendingApkUploadTime) {
|
||||
Log.i(TAG, "Update installed successfully! Updating our lastApkUploadTime to ${SignalStore.apkUpdate().pendingApkUploadTime}")
|
||||
SignalStore.apkUpdate().lastApkUploadTime = SignalStore.apkUpdate().pendingApkUploadTime
|
||||
ApkUpdateNotifications.showAutoUpdateSuccess(context)
|
||||
} else {
|
||||
Log.i(TAG, "Spurious 'success' notification?")
|
||||
}
|
||||
}
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> handlePendingUserAction(context, userInitiated, intent!!)
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.ABORTED)
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.attachments;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.os.ParcelCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.audio.AudioHash;
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public abstract class Attachment implements Parcelable {
|
||||
|
||||
@NonNull
|
||||
private final String contentType;
|
||||
private final int transferState;
|
||||
private final long size;
|
||||
|
||||
@Nullable
|
||||
private final String fileName;
|
||||
|
||||
private final int cdnNumber;
|
||||
|
||||
@Nullable
|
||||
private final String location;
|
||||
|
||||
@Nullable
|
||||
private final String key;
|
||||
|
||||
@Nullable
|
||||
private final String relay;
|
||||
|
||||
@Nullable
|
||||
private final byte[] digest;
|
||||
|
||||
@Nullable
|
||||
private final byte[] incrementalDigest;
|
||||
|
||||
@Nullable
|
||||
private final String fastPreflightId;
|
||||
|
||||
private final boolean voiceNote;
|
||||
private final boolean borderless;
|
||||
private final boolean videoGif;
|
||||
private final int width;
|
||||
private final int height;
|
||||
private final boolean quote;
|
||||
private final long uploadTimestamp;
|
||||
private final int incrementalMacChunkSize;
|
||||
|
||||
@Nullable
|
||||
private final String caption;
|
||||
|
||||
@Nullable
|
||||
private final StickerLocator stickerLocator;
|
||||
|
||||
@Nullable
|
||||
private final BlurHash blurHash;
|
||||
|
||||
@Nullable
|
||||
private final AudioHash audioHash;
|
||||
|
||||
@NonNull
|
||||
private final TransformProperties transformProperties;
|
||||
|
||||
public Attachment(@NonNull String contentType,
|
||||
int transferState,
|
||||
long size,
|
||||
@Nullable String fileName,
|
||||
int cdnNumber,
|
||||
@Nullable String location,
|
||||
@Nullable String key,
|
||||
@Nullable String relay,
|
||||
@Nullable byte[] digest,
|
||||
@Nullable byte[] incrementalDigest,
|
||||
@Nullable String fastPreflightId,
|
||||
boolean voiceNote,
|
||||
boolean borderless,
|
||||
boolean videoGif,
|
||||
int width,
|
||||
int height,
|
||||
int incrementalMacChunkSize,
|
||||
boolean quote,
|
||||
long uploadTimestamp,
|
||||
@Nullable String caption,
|
||||
@Nullable StickerLocator stickerLocator,
|
||||
@Nullable BlurHash blurHash,
|
||||
@Nullable AudioHash audioHash,
|
||||
@Nullable TransformProperties transformProperties)
|
||||
{
|
||||
this.contentType = contentType;
|
||||
this.transferState = transferState;
|
||||
this.size = size;
|
||||
this.fileName = fileName;
|
||||
this.cdnNumber = cdnNumber;
|
||||
this.location = location;
|
||||
this.key = key;
|
||||
this.relay = relay;
|
||||
this.digest = digest;
|
||||
this.incrementalDigest = incrementalDigest;
|
||||
this.fastPreflightId = fastPreflightId;
|
||||
this.voiceNote = voiceNote;
|
||||
this.borderless = borderless;
|
||||
this.videoGif = videoGif;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.incrementalMacChunkSize = incrementalMacChunkSize;
|
||||
this.quote = quote;
|
||||
this.uploadTimestamp = uploadTimestamp;
|
||||
this.stickerLocator = stickerLocator;
|
||||
this.caption = caption;
|
||||
this.blurHash = blurHash;
|
||||
this.audioHash = audioHash;
|
||||
this.transformProperties = transformProperties != null ? transformProperties : TransformProperties.empty();
|
||||
}
|
||||
|
||||
protected Attachment(Parcel in) {
|
||||
this.contentType = Objects.requireNonNull(in.readString());
|
||||
this.transferState = in.readInt();
|
||||
this.size = in.readLong();
|
||||
this.fileName = in.readString();
|
||||
this.cdnNumber = in.readInt();
|
||||
this.location = in.readString();
|
||||
this.key = in.readString();
|
||||
this.relay = in.readString();
|
||||
this.digest = ParcelUtil.readByteArray(in);
|
||||
this.incrementalDigest = ParcelUtil.readByteArray(in);
|
||||
this.fastPreflightId = in.readString();
|
||||
this.voiceNote = ParcelUtil.readBoolean(in);
|
||||
this.borderless = ParcelUtil.readBoolean(in);
|
||||
this.videoGif = ParcelUtil.readBoolean(in);
|
||||
this.width = in.readInt();
|
||||
this.height = in.readInt();
|
||||
this.incrementalMacChunkSize = in.readInt();
|
||||
this.quote = ParcelUtil.readBoolean(in);
|
||||
this.uploadTimestamp = in.readLong();
|
||||
this.stickerLocator = ParcelCompat.readParcelable(in, StickerLocator.class.getClassLoader(), StickerLocator.class);
|
||||
this.caption = in.readString();
|
||||
this.blurHash = ParcelCompat.readParcelable(in, BlurHash.class.getClassLoader(), BlurHash.class);
|
||||
this.audioHash = ParcelCompat.readParcelable(in, AudioHash.class.getClassLoader(), AudioHash.class);
|
||||
this.transformProperties = Objects.requireNonNull(ParcelCompat.readParcelable(in, TransformProperties.class.getClassLoader(), TransformProperties.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
||||
AttachmentCreator.writeSubclass(dest, this);
|
||||
dest.writeString(contentType);
|
||||
dest.writeInt(transferState);
|
||||
dest.writeLong(size);
|
||||
dest.writeString(fileName);
|
||||
dest.writeInt(cdnNumber);
|
||||
dest.writeString(location);
|
||||
dest.writeString(key);
|
||||
dest.writeString(relay);
|
||||
ParcelUtil.writeByteArray(dest, digest);
|
||||
ParcelUtil.writeByteArray(dest, incrementalDigest);
|
||||
dest.writeString(fastPreflightId);
|
||||
ParcelUtil.writeBoolean(dest, voiceNote);
|
||||
ParcelUtil.writeBoolean(dest, borderless);
|
||||
ParcelUtil.writeBoolean(dest, videoGif);
|
||||
dest.writeInt(width);
|
||||
dest.writeInt(height);
|
||||
dest.writeInt(incrementalMacChunkSize);
|
||||
ParcelUtil.writeBoolean(dest, quote);
|
||||
dest.writeLong(uploadTimestamp);
|
||||
dest.writeParcelable(stickerLocator, 0);
|
||||
dest.writeString(caption);
|
||||
dest.writeParcelable(blurHash, 0);
|
||||
dest.writeParcelable(audioHash, 0);
|
||||
dest.writeParcelable(transformProperties, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static final Creator<Attachment> CREATOR = AttachmentCreator.INSTANCE;
|
||||
|
||||
@Nullable
|
||||
public abstract Uri getUri();
|
||||
|
||||
public abstract @Nullable Uri getPublicUri();
|
||||
|
||||
public int getTransferState() {
|
||||
return transferState;
|
||||
}
|
||||
|
||||
public boolean isInProgress() {
|
||||
return transferState != AttachmentTable.TRANSFER_PROGRESS_DONE &&
|
||||
transferState != AttachmentTable.TRANSFER_PROGRESS_FAILED &&
|
||||
transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE;
|
||||
}
|
||||
|
||||
public boolean isPermanentlyFailed() {
|
||||
return transferState == AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE;
|
||||
}
|
||||
|
||||
public long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
public int getCdnNumber() {
|
||||
return cdnNumber;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getLocation() {
|
||||
return location;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getRelay() {
|
||||
return relay;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public byte[] getDigest() {
|
||||
return digest;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public byte[] getIncrementalDigest() {
|
||||
if (incrementalDigest != null && incrementalDigest.length > 0) {
|
||||
return incrementalDigest;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getFastPreflightId() {
|
||||
return fastPreflightId;
|
||||
}
|
||||
|
||||
public boolean isVoiceNote() {
|
||||
return voiceNote;
|
||||
}
|
||||
|
||||
public boolean isBorderless() {
|
||||
return borderless;
|
||||
}
|
||||
|
||||
public boolean isVideoGif() {
|
||||
return videoGif;
|
||||
}
|
||||
|
||||
public int getWidth() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public int getHeight() {
|
||||
return height;
|
||||
}
|
||||
|
||||
public int getIncrementalMacChunkSize() {
|
||||
return incrementalMacChunkSize;
|
||||
}
|
||||
|
||||
public boolean isQuote() {
|
||||
return quote;
|
||||
}
|
||||
|
||||
public long getUploadTimestamp() {
|
||||
return uploadTimestamp;
|
||||
}
|
||||
|
||||
public boolean isSticker() {
|
||||
return stickerLocator != null;
|
||||
}
|
||||
|
||||
public @Nullable StickerLocator getSticker() {
|
||||
return stickerLocator;
|
||||
}
|
||||
|
||||
public @Nullable BlurHash getBlurHash() {
|
||||
return blurHash;
|
||||
}
|
||||
|
||||
public @Nullable AudioHash getAudioHash() {
|
||||
return audioHash;
|
||||
}
|
||||
|
||||
public @Nullable String getCaption() {
|
||||
return caption;
|
||||
}
|
||||
|
||||
public @NonNull TransformProperties getTransformProperties() {
|
||||
return transformProperties;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.thoughtcrime.securesms.attachments
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import androidx.core.os.ParcelCompat
|
||||
import org.thoughtcrime.securesms.audio.AudioHash
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil
|
||||
|
||||
/**
|
||||
* Note: We have to use our own Parcelable implementation because we need to do custom stuff to preserve
|
||||
* subclass information.
|
||||
*/
|
||||
abstract class Attachment(
|
||||
@JvmField
|
||||
val contentType: String,
|
||||
@JvmField
|
||||
val transferState: Int,
|
||||
@JvmField
|
||||
val size: Long,
|
||||
@JvmField
|
||||
val fileName: String?,
|
||||
@JvmField
|
||||
val cdnNumber: Int,
|
||||
@JvmField
|
||||
val remoteLocation: String?,
|
||||
@JvmField
|
||||
val remoteKey: String?,
|
||||
@JvmField
|
||||
val remoteDigest: ByteArray?,
|
||||
@JvmField
|
||||
val incrementalDigest: ByteArray?,
|
||||
@JvmField
|
||||
val fastPreflightId: String?,
|
||||
@JvmField
|
||||
val voiceNote: Boolean,
|
||||
@JvmField
|
||||
val borderless: Boolean,
|
||||
@JvmField
|
||||
val videoGif: Boolean,
|
||||
@JvmField
|
||||
val width: Int,
|
||||
@JvmField
|
||||
val height: Int,
|
||||
@JvmField
|
||||
val incrementalMacChunkSize: Int,
|
||||
@JvmField
|
||||
val quote: Boolean,
|
||||
@JvmField
|
||||
val uploadTimestamp: Long,
|
||||
@JvmField
|
||||
val caption: String?,
|
||||
@JvmField
|
||||
val stickerLocator: StickerLocator?,
|
||||
@JvmField
|
||||
val blurHash: BlurHash?,
|
||||
@JvmField
|
||||
val audioHash: AudioHash?,
|
||||
@JvmField
|
||||
val transformProperties: TransformProperties?
|
||||
) : Parcelable {
|
||||
|
||||
abstract val uri: Uri?
|
||||
abstract val publicUri: Uri?
|
||||
|
||||
protected constructor(parcel: Parcel) : this(
|
||||
contentType = parcel.readString()!!,
|
||||
transferState = parcel.readInt(),
|
||||
size = parcel.readLong(),
|
||||
fileName = parcel.readString(),
|
||||
cdnNumber = parcel.readInt(),
|
||||
remoteLocation = parcel.readString(),
|
||||
remoteKey = parcel.readString(),
|
||||
remoteDigest = ParcelUtil.readByteArray(parcel),
|
||||
incrementalDigest = ParcelUtil.readByteArray(parcel),
|
||||
fastPreflightId = parcel.readString(),
|
||||
voiceNote = ParcelUtil.readBoolean(parcel),
|
||||
borderless = ParcelUtil.readBoolean(parcel),
|
||||
videoGif = ParcelUtil.readBoolean(parcel),
|
||||
width = parcel.readInt(),
|
||||
height = parcel.readInt(),
|
||||
incrementalMacChunkSize = parcel.readInt(),
|
||||
quote = ParcelUtil.readBoolean(parcel),
|
||||
uploadTimestamp = parcel.readLong(),
|
||||
caption = parcel.readString(),
|
||||
stickerLocator = ParcelCompat.readParcelable(parcel, StickerLocator::class.java.classLoader, StickerLocator::class.java),
|
||||
blurHash = ParcelCompat.readParcelable(parcel, BlurHash::class.java.classLoader, BlurHash::class.java),
|
||||
audioHash = ParcelCompat.readParcelable(parcel, AudioHash::class.java.classLoader, AudioHash::class.java),
|
||||
transformProperties = ParcelCompat.readParcelable(parcel, TransformProperties::class.java.classLoader, TransformProperties::class.java)
|
||||
)
|
||||
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
AttachmentCreator.writeSubclass(dest, this)
|
||||
dest.writeString(contentType)
|
||||
dest.writeInt(transferState)
|
||||
dest.writeLong(size)
|
||||
dest.writeString(fileName)
|
||||
dest.writeInt(cdnNumber)
|
||||
dest.writeString(remoteLocation)
|
||||
dest.writeString(remoteKey)
|
||||
ParcelUtil.writeByteArray(dest, remoteDigest)
|
||||
ParcelUtil.writeByteArray(dest, incrementalDigest)
|
||||
dest.writeString(fastPreflightId)
|
||||
ParcelUtil.writeBoolean(dest, voiceNote)
|
||||
ParcelUtil.writeBoolean(dest, borderless)
|
||||
ParcelUtil.writeBoolean(dest, videoGif)
|
||||
dest.writeInt(width)
|
||||
dest.writeInt(height)
|
||||
dest.writeInt(incrementalMacChunkSize)
|
||||
ParcelUtil.writeBoolean(dest, quote)
|
||||
dest.writeLong(uploadTimestamp)
|
||||
dest.writeString(caption)
|
||||
dest.writeParcelable(stickerLocator, 0)
|
||||
dest.writeParcelable(blurHash, 0)
|
||||
dest.writeParcelable(audioHash, 0)
|
||||
dest.writeParcelable(transformProperties, 0)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
val isInProgress: Boolean
|
||||
get() = transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && transferState != AttachmentTable.TRANSFER_PROGRESS_FAILED && transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE
|
||||
|
||||
val isPermanentlyFailed: Boolean
|
||||
get() = transferState == AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE
|
||||
|
||||
val isSticker: Boolean
|
||||
get() = stickerLocator != null
|
||||
|
||||
fun getIncrementalDigest(): ByteArray? {
|
||||
return if (incrementalDigest != null && incrementalDigest.size > 0) {
|
||||
incrementalDigest
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val CREATOR: Parcelable.Creator<Attachment> = AttachmentCreator
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import android.os.Parcelable
|
||||
object AttachmentCreator : Parcelable.Creator<Attachment> {
|
||||
enum class Subclass(val clazz: Class<out Attachment>, val code: String) {
|
||||
DATABASE(DatabaseAttachment::class.java, "database"),
|
||||
MMS_NOTIFICATION(MmsNotificationAttachment::class.java, "mms_notification"),
|
||||
POINTER(PointerAttachment::class.java, "pointer"),
|
||||
TOMBSTONE(TombstoneAttachment::class.java, "tombstone"),
|
||||
URI(UriAttachment::class.java, "uri")
|
||||
@@ -32,7 +31,6 @@ object AttachmentCreator : Parcelable.Creator<Attachment> {
|
||||
|
||||
return when (Subclass.values().first { rawCode == it.code }) {
|
||||
Subclass.DATABASE -> DatabaseAttachment(source)
|
||||
Subclass.MMS_NOTIFICATION -> MmsNotificationAttachment(source)
|
||||
Subclass.POINTER -> PointerAttachment(source)
|
||||
Subclass.TOMBSTONE -> TombstoneAttachment(source)
|
||||
Subclass.URI -> UriAttachment(source)
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package org.thoughtcrime.securesms.attachments;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
public class AttachmentId implements Parcelable {
|
||||
|
||||
@JsonProperty
|
||||
private final long rowId;
|
||||
|
||||
@JsonProperty
|
||||
private final long uniqueId;
|
||||
|
||||
public AttachmentId(@JsonProperty("rowId") long rowId, @JsonProperty("uniqueId") long uniqueId) {
|
||||
this.rowId = rowId;
|
||||
this.uniqueId = uniqueId;
|
||||
}
|
||||
|
||||
private AttachmentId(Parcel in) {
|
||||
this.rowId = in.readLong();
|
||||
this.uniqueId = in.readLong();
|
||||
}
|
||||
|
||||
public long getRowId() {
|
||||
return rowId;
|
||||
}
|
||||
|
||||
public long getUniqueId() {
|
||||
return uniqueId;
|
||||
}
|
||||
|
||||
public String[] toStrings() {
|
||||
return new String[] {String.valueOf(rowId), String.valueOf(uniqueId)};
|
||||
}
|
||||
|
||||
public @NonNull String toString() {
|
||||
return "AttachmentId::(" + rowId + ", " + uniqueId + ")";
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
return rowId >= 0 && uniqueId >= 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
|
||||
AttachmentId attachmentId = (AttachmentId)o;
|
||||
|
||||
if (rowId != attachmentId.rowId) return false;
|
||||
return uniqueId == attachmentId.uniqueId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Util.hashCode(rowId, uniqueId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeLong(rowId);
|
||||
dest.writeLong(uniqueId);
|
||||
}
|
||||
|
||||
public static final Creator<AttachmentId> CREATOR = new Creator<AttachmentId>() {
|
||||
@Override
|
||||
public AttachmentId createFromParcel(Parcel in) {
|
||||
return new AttachmentId(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AttachmentId[] newArray(int size) {
|
||||
return new AttachmentId[size];
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.thoughtcrime.securesms.attachments
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class AttachmentId(
|
||||
@JsonProperty("rowId")
|
||||
@JvmField
|
||||
val id: Long
|
||||
) : Parcelable {
|
||||
|
||||
val isValid: Boolean
|
||||
get() = id >= 0
|
||||
|
||||
override fun toString(): String {
|
||||
return "AttachmentId::$id"
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package org.thoughtcrime.securesms.attachments;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.os.ParcelCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.audio.AudioHash;
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties;
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil;
|
||||
|
||||
import java.util.Comparator;
|
||||
|
||||
public class DatabaseAttachment extends Attachment {
|
||||
|
||||
private final AttachmentId attachmentId;
|
||||
private final long mmsId;
|
||||
private final boolean hasData;
|
||||
private final boolean hasThumbnail;
|
||||
private final int displayOrder;
|
||||
|
||||
public DatabaseAttachment(AttachmentId attachmentId,
|
||||
long mmsId,
|
||||
boolean hasData,
|
||||
boolean hasThumbnail,
|
||||
String contentType,
|
||||
int transferProgress,
|
||||
long size,
|
||||
String fileName,
|
||||
int cdnNumber,
|
||||
String location,
|
||||
String key,
|
||||
String relay,
|
||||
byte[] digest,
|
||||
byte[] incrementalDigest,
|
||||
int incrementalMacChunkSize,
|
||||
String fastPreflightId,
|
||||
boolean voiceNote,
|
||||
boolean borderless,
|
||||
boolean videoGif,
|
||||
int width,
|
||||
int height,
|
||||
boolean quote,
|
||||
@Nullable String caption,
|
||||
@Nullable StickerLocator stickerLocator,
|
||||
@Nullable BlurHash blurHash,
|
||||
@Nullable AudioHash audioHash,
|
||||
@Nullable TransformProperties transformProperties,
|
||||
int displayOrder,
|
||||
long uploadTimestamp)
|
||||
{
|
||||
super(contentType, transferProgress, size, fileName, cdnNumber, location, key, relay, digest, incrementalDigest, fastPreflightId, voiceNote, borderless, videoGif, width, height, incrementalMacChunkSize, quote, uploadTimestamp, caption, stickerLocator, blurHash, audioHash, transformProperties);
|
||||
this.attachmentId = attachmentId;
|
||||
this.hasData = hasData;
|
||||
this.hasThumbnail = hasThumbnail;
|
||||
this.mmsId = mmsId;
|
||||
this.displayOrder = displayOrder;
|
||||
}
|
||||
|
||||
protected DatabaseAttachment(Parcel in) {
|
||||
super(in);
|
||||
this.attachmentId = ParcelCompat.readParcelable(in, AttachmentId.class.getClassLoader(), AttachmentId.class);
|
||||
this.hasData = ParcelUtil.readBoolean(in);
|
||||
this.hasThumbnail = ParcelUtil.readBoolean(in);
|
||||
this.mmsId = in.readLong();
|
||||
this.displayOrder = in.readInt();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
||||
super.writeToParcel(dest, flags);
|
||||
dest.writeParcelable(attachmentId, 0);
|
||||
ParcelUtil.writeBoolean(dest, hasData);
|
||||
ParcelUtil.writeBoolean(dest, hasThumbnail);
|
||||
dest.writeLong(mmsId);
|
||||
dest.writeInt(displayOrder);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public Uri getUri() {
|
||||
if (hasData || (FeatureFlags.instantVideoPlayback() && getIncrementalDigest() != null)) {
|
||||
return PartAuthority.getAttachmentDataUri(attachmentId);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Uri getPublicUri() {
|
||||
if (hasData) {
|
||||
return PartAuthority.getAttachmentPublicUri(getUri());
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public AttachmentId getAttachmentId() {
|
||||
return attachmentId;
|
||||
}
|
||||
|
||||
public int getDisplayOrder() {
|
||||
return displayOrder;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
return other != null &&
|
||||
other instanceof DatabaseAttachment &&
|
||||
((DatabaseAttachment) other).attachmentId.equals(this.attachmentId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return attachmentId.hashCode();
|
||||
}
|
||||
|
||||
public long getMmsId() {
|
||||
return mmsId;
|
||||
}
|
||||
|
||||
public boolean hasData() {
|
||||
return hasData;
|
||||
}
|
||||
|
||||
public boolean hasThumbnail() {
|
||||
return hasThumbnail;
|
||||
}
|
||||
|
||||
public static class DisplayOrderComparator implements Comparator<DatabaseAttachment> {
|
||||
@Override
|
||||
public int compare(DatabaseAttachment lhs, DatabaseAttachment rhs) {
|
||||
return Integer.compare(lhs.getDisplayOrder(), rhs.getDisplayOrder());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package org.thoughtcrime.securesms.attachments
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import androidx.core.os.ParcelCompat
|
||||
import org.thoughtcrime.securesms.audio.AudioHash
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil
|
||||
|
||||
class DatabaseAttachment : Attachment {
|
||||
|
||||
@JvmField
|
||||
val attachmentId: AttachmentId
|
||||
|
||||
@JvmField
|
||||
val mmsId: Long
|
||||
|
||||
@JvmField
|
||||
val hasData: Boolean
|
||||
|
||||
private val hasThumbnail: Boolean
|
||||
val displayOrder: Int
|
||||
|
||||
constructor(
|
||||
attachmentId: AttachmentId,
|
||||
mmsId: Long,
|
||||
hasData: Boolean,
|
||||
hasThumbnail: Boolean,
|
||||
contentType: String?,
|
||||
transferProgress: Int,
|
||||
size: Long,
|
||||
fileName: String?,
|
||||
cdnNumber: Int,
|
||||
location: String?,
|
||||
key: String?,
|
||||
digest: ByteArray?,
|
||||
incrementalDigest: ByteArray?,
|
||||
incrementalMacChunkSize: Int,
|
||||
fastPreflightId: String?,
|
||||
voiceNote: Boolean,
|
||||
borderless: Boolean,
|
||||
videoGif: Boolean,
|
||||
width: Int,
|
||||
height: Int,
|
||||
quote: Boolean,
|
||||
caption: String?,
|
||||
stickerLocator: StickerLocator?,
|
||||
blurHash: BlurHash?,
|
||||
audioHash: AudioHash?,
|
||||
transformProperties: TransformProperties?,
|
||||
displayOrder: Int,
|
||||
uploadTimestamp: Long
|
||||
) : super(
|
||||
contentType = contentType!!,
|
||||
transferState = transferProgress,
|
||||
size = size,
|
||||
fileName = fileName,
|
||||
cdnNumber = cdnNumber,
|
||||
remoteLocation = location,
|
||||
remoteKey = key,
|
||||
remoteDigest = digest,
|
||||
incrementalDigest = incrementalDigest,
|
||||
fastPreflightId = fastPreflightId,
|
||||
voiceNote = voiceNote,
|
||||
borderless = borderless,
|
||||
videoGif = videoGif, width = width,
|
||||
height = height,
|
||||
incrementalMacChunkSize = incrementalMacChunkSize,
|
||||
quote = quote,
|
||||
uploadTimestamp = uploadTimestamp,
|
||||
caption = caption,
|
||||
stickerLocator = stickerLocator,
|
||||
blurHash = blurHash,
|
||||
audioHash = audioHash,
|
||||
transformProperties = transformProperties
|
||||
) {
|
||||
this.attachmentId = attachmentId
|
||||
this.mmsId = mmsId
|
||||
this.hasData = hasData
|
||||
this.hasThumbnail = hasThumbnail
|
||||
this.displayOrder = displayOrder
|
||||
}
|
||||
|
||||
constructor(parcel: Parcel) : super(parcel) {
|
||||
attachmentId = ParcelCompat.readParcelable(parcel, AttachmentId::class.java.classLoader, AttachmentId::class.java)!!
|
||||
hasData = ParcelUtil.readBoolean(parcel)
|
||||
hasThumbnail = ParcelUtil.readBoolean(parcel)
|
||||
mmsId = parcel.readLong()
|
||||
displayOrder = parcel.readInt()
|
||||
}
|
||||
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
super.writeToParcel(dest, flags)
|
||||
dest.writeParcelable(attachmentId, 0)
|
||||
ParcelUtil.writeBoolean(dest, hasData)
|
||||
ParcelUtil.writeBoolean(dest, hasThumbnail)
|
||||
dest.writeLong(mmsId)
|
||||
dest.writeInt(displayOrder)
|
||||
}
|
||||
|
||||
override val uri: Uri?
|
||||
get() = if (hasData || FeatureFlags.instantVideoPlayback() && getIncrementalDigest() != null) {
|
||||
PartAuthority.getAttachmentDataUri(attachmentId)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
override val publicUri: Uri?
|
||||
get() = if (hasData) {
|
||||
PartAuthority.getAttachmentPublicUri(uri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return other != null &&
|
||||
other is DatabaseAttachment && other.attachmentId == attachmentId
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return attachmentId.hashCode()
|
||||
}
|
||||
|
||||
class DisplayOrderComparator : Comparator<DatabaseAttachment> {
|
||||
override fun compare(lhs: DatabaseAttachment, rhs: DatabaseAttachment): Int {
|
||||
return lhs.displayOrder.compareTo(rhs.displayOrder)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package org.thoughtcrime.securesms.attachments;
|
||||
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.database.MessageTable;
|
||||
|
||||
public class MmsNotificationAttachment extends Attachment {
|
||||
|
||||
public MmsNotificationAttachment(int status, long size) {
|
||||
super("application/mms", getTransferStateFromStatus(status), size, null, 0, null, null, null, null, null, null, false, false, false, 0, 0, 0, false, 0, null, null, null, null, null);
|
||||
}
|
||||
|
||||
protected MmsNotificationAttachment(Parcel in) {
|
||||
super(in);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Uri getUri() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Uri getPublicUri() {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int getTransferStateFromStatus(int status) {
|
||||
if (status == MessageTable.MmsStatus.DOWNLOAD_INITIALIZED ||
|
||||
status == MessageTable.MmsStatus.DOWNLOAD_NO_CONNECTIVITY)
|
||||
{
|
||||
return AttachmentTable.TRANSFER_PROGRESS_PENDING;
|
||||
} else if (status == MessageTable.MmsStatus.DOWNLOAD_CONNECTING) {
|
||||
return AttachmentTable.TRANSFER_PROGRESS_STARTED;
|
||||
} else {
|
||||
return AttachmentTable.TRANSFER_PROGRESS_FAILED;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package org.thoughtcrime.securesms.attachments;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.signal.core.util.Base64;
|
||||
import org.whispersystems.signalservice.api.InvalidMessageStructureException;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
|
||||
import org.whispersystems.signalservice.api.util.AttachmentPointerUtil;
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public class PointerAttachment extends Attachment {
|
||||
|
||||
private PointerAttachment(@NonNull String contentType,
|
||||
int transferState,
|
||||
long size,
|
||||
@Nullable String fileName,
|
||||
int cdnNumber,
|
||||
@NonNull String location,
|
||||
@Nullable String key,
|
||||
@Nullable String relay,
|
||||
@Nullable byte[] digest,
|
||||
@Nullable byte[] incrementalDigest,
|
||||
int incrementalMacChunkSize,
|
||||
@Nullable String fastPreflightId,
|
||||
boolean voiceNote,
|
||||
boolean borderless,
|
||||
boolean videoGif,
|
||||
int width,
|
||||
int height,
|
||||
long uploadTimestamp,
|
||||
@Nullable String caption,
|
||||
@Nullable StickerLocator stickerLocator,
|
||||
@Nullable BlurHash blurHash)
|
||||
{
|
||||
super(contentType, transferState, size, fileName, cdnNumber, location, key, relay, digest, incrementalDigest, fastPreflightId, voiceNote, borderless, videoGif, width, height, incrementalMacChunkSize, false, uploadTimestamp, caption, stickerLocator, blurHash, null, null);
|
||||
}
|
||||
|
||||
protected PointerAttachment(Parcel in) {
|
||||
super(in);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Uri getUri() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Uri getPublicUri() {
|
||||
return null;
|
||||
}
|
||||
|
||||
public static List<Attachment> forPointers(Optional<List<SignalServiceAttachment>> pointers) {
|
||||
List<Attachment> results = new LinkedList<>();
|
||||
|
||||
if (pointers.isPresent()) {
|
||||
for (SignalServiceAttachment pointer : pointers.get()) {
|
||||
Optional<Attachment> result = forPointer(Optional.of(pointer));
|
||||
|
||||
if (result.isPresent()) {
|
||||
results.add(result.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public static List<Attachment> forPointers(@Nullable List<SignalServiceDataMessage.Quote.QuotedAttachment> pointers) {
|
||||
List<Attachment> results = new LinkedList<>();
|
||||
|
||||
if (pointers != null) {
|
||||
for (SignalServiceDataMessage.Quote.QuotedAttachment pointer : pointers) {
|
||||
Optional<Attachment> result = forPointer(pointer);
|
||||
|
||||
if (result.isPresent()) {
|
||||
results.add(result.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public static Optional<Attachment> forPointer(Optional<SignalServiceAttachment> pointer) {
|
||||
return forPointer(pointer, null, null);
|
||||
}
|
||||
|
||||
public static Optional<Attachment> forPointer(Optional<SignalServiceAttachment> pointer, @Nullable StickerLocator stickerLocator) {
|
||||
return forPointer(pointer, stickerLocator, null);
|
||||
}
|
||||
|
||||
public static Optional<Attachment> forPointer(Optional<SignalServiceAttachment> pointer, @Nullable StickerLocator stickerLocator, @Nullable String fastPreflightId) {
|
||||
if (!pointer.isPresent() || !pointer.get().isPointer()) return Optional.empty();
|
||||
|
||||
String encodedKey = null;
|
||||
|
||||
if (pointer.get().asPointer().getKey() != null) {
|
||||
encodedKey = Base64.encodeWithPadding(pointer.get().asPointer().getKey());
|
||||
}
|
||||
|
||||
return Optional.of(new PointerAttachment(pointer.get().getContentType(),
|
||||
AttachmentTable.TRANSFER_PROGRESS_PENDING,
|
||||
pointer.get().asPointer().getSize().orElse(0),
|
||||
pointer.get().asPointer().getFileName().orElse(null),
|
||||
pointer.get().asPointer().getCdnNumber(),
|
||||
pointer.get().asPointer().getRemoteId().toString(),
|
||||
encodedKey,
|
||||
null,
|
||||
pointer.get().asPointer().getDigest().orElse(null),
|
||||
pointer.get().asPointer().getIncrementalDigest().orElse(null),
|
||||
pointer.get().asPointer().getIncrementalMacChunkSize(),
|
||||
fastPreflightId,
|
||||
pointer.get().asPointer().getVoiceNote(),
|
||||
pointer.get().asPointer().isBorderless(),
|
||||
pointer.get().asPointer().isGif(),
|
||||
pointer.get().asPointer().getWidth(),
|
||||
pointer.get().asPointer().getHeight(),
|
||||
pointer.get().asPointer().getUploadTimestamp(),
|
||||
pointer.get().asPointer().getCaption().orElse(null),
|
||||
stickerLocator,
|
||||
BlurHash.parseOrNull(pointer.get().asPointer().getBlurHash().orElse(null))));
|
||||
|
||||
}
|
||||
|
||||
public static Optional<Attachment> forPointer(SignalServiceDataMessage.Quote.QuotedAttachment pointer) {
|
||||
SignalServiceAttachment thumbnail = pointer.getThumbnail();
|
||||
|
||||
return Optional.of(new PointerAttachment(pointer.getContentType(),
|
||||
AttachmentTable.TRANSFER_PROGRESS_PENDING,
|
||||
thumbnail != null ? thumbnail.asPointer().getSize().orElse(0) : 0,
|
||||
pointer.getFileName(),
|
||||
thumbnail != null ? thumbnail.asPointer().getCdnNumber() : 0,
|
||||
thumbnail != null ? thumbnail.asPointer().getRemoteId().toString() : "0",
|
||||
thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeWithPadding(thumbnail.asPointer().getKey()) : null,
|
||||
null,
|
||||
thumbnail != null ? thumbnail.asPointer().getDigest().orElse(null) : null,
|
||||
thumbnail != null ? thumbnail.asPointer().getIncrementalDigest().orElse(null) : null,
|
||||
thumbnail != null ? thumbnail.asPointer().getIncrementalMacChunkSize() : 0,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
thumbnail != null ? thumbnail.asPointer().getWidth() : 0,
|
||||
thumbnail != null ? thumbnail.asPointer().getHeight() : 0,
|
||||
thumbnail != null ? thumbnail.asPointer().getUploadTimestamp() : 0,
|
||||
thumbnail != null ? thumbnail.asPointer().getCaption().orElse(null) : null,
|
||||
null,
|
||||
null));
|
||||
}
|
||||
|
||||
public static Optional<Attachment> forPointer(DataMessage.Quote.QuotedAttachment quotedAttachment) {
|
||||
SignalServiceAttachment thumbnail;
|
||||
try {
|
||||
thumbnail = quotedAttachment.thumbnail != null ? AttachmentPointerUtil.createSignalAttachmentPointer(quotedAttachment.thumbnail) : null;
|
||||
} catch (InvalidMessageStructureException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(new PointerAttachment(quotedAttachment.contentType,
|
||||
AttachmentTable.TRANSFER_PROGRESS_PENDING,
|
||||
thumbnail != null ? thumbnail.asPointer().getSize().orElse(0) : 0,
|
||||
quotedAttachment.fileName,
|
||||
thumbnail != null ? thumbnail.asPointer().getCdnNumber() : 0,
|
||||
thumbnail != null ? thumbnail.asPointer().getRemoteId().toString() : "0",
|
||||
thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeWithPadding(thumbnail.asPointer().getKey()) : null,
|
||||
null,
|
||||
thumbnail != null ? thumbnail.asPointer().getDigest().orElse(null) : null,
|
||||
thumbnail != null ? thumbnail.asPointer().getIncrementalDigest().orElse(null) : null,
|
||||
thumbnail != null ? thumbnail.asPointer().getIncrementalMacChunkSize() : 0,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
thumbnail != null ? thumbnail.asPointer().getWidth() : 0,
|
||||
thumbnail != null ? thumbnail.asPointer().getHeight() : 0,
|
||||
thumbnail != null ? thumbnail.asPointer().getUploadTimestamp() : 0,
|
||||
thumbnail != null ? thumbnail.asPointer().getCaption().orElse(null) : null,
|
||||
null,
|
||||
null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package org.thoughtcrime.securesms.attachments
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import org.signal.core.util.Base64.encodeWithPadding
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.whispersystems.signalservice.api.InvalidMessageStructureException
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage
|
||||
import org.whispersystems.signalservice.api.util.AttachmentPointerUtil
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import java.util.Optional
|
||||
|
||||
class PointerAttachment : Attachment {
|
||||
private constructor(
|
||||
contentType: String,
|
||||
transferState: Int,
|
||||
size: Long,
|
||||
fileName: String?,
|
||||
cdnNumber: Int,
|
||||
location: String,
|
||||
key: String?,
|
||||
digest: ByteArray?,
|
||||
incrementalDigest: ByteArray?,
|
||||
incrementalMacChunkSize: Int,
|
||||
fastPreflightId: String?,
|
||||
voiceNote: Boolean,
|
||||
borderless: Boolean,
|
||||
videoGif: Boolean,
|
||||
width: Int,
|
||||
height: Int,
|
||||
uploadTimestamp: Long,
|
||||
caption: String?,
|
||||
stickerLocator: StickerLocator?,
|
||||
blurHash: BlurHash?
|
||||
) : super(
|
||||
contentType = contentType,
|
||||
transferState = transferState,
|
||||
size = size,
|
||||
fileName = fileName,
|
||||
cdnNumber = cdnNumber,
|
||||
remoteLocation = location,
|
||||
remoteKey = key,
|
||||
remoteDigest = digest,
|
||||
incrementalDigest = incrementalDigest,
|
||||
fastPreflightId = fastPreflightId,
|
||||
voiceNote = voiceNote,
|
||||
borderless = borderless,
|
||||
videoGif = videoGif,
|
||||
width = width,
|
||||
height = height,
|
||||
incrementalMacChunkSize = incrementalMacChunkSize,
|
||||
quote = false,
|
||||
uploadTimestamp = uploadTimestamp,
|
||||
caption = caption,
|
||||
stickerLocator = stickerLocator,
|
||||
blurHash = blurHash,
|
||||
audioHash = null,
|
||||
transformProperties = null
|
||||
)
|
||||
|
||||
constructor(parcel: Parcel) : super(parcel)
|
||||
|
||||
override val uri: Uri? = null
|
||||
override val publicUri: Uri? = null
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun forPointers(pointers: Optional<List<SignalServiceAttachment>>): List<Attachment> {
|
||||
if (!pointers.isPresent) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return pointers.get()
|
||||
.map { forPointer(Optional.ofNullable(it)) }
|
||||
.filter { it.isPresent }
|
||||
.map { it.get() }
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun forPointer(pointer: Optional<SignalServiceAttachment>, stickerLocator: StickerLocator? = null, fastPreflightId: String? = null): Optional<Attachment> {
|
||||
if (!pointer.isPresent || !pointer.get().isPointer) {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
val encodedKey: String? = if (pointer.get().asPointer().key != null) {
|
||||
encodeWithPadding(pointer.get().asPointer().key)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return Optional.of(
|
||||
PointerAttachment(
|
||||
contentType = pointer.get().contentType,
|
||||
transferState = AttachmentTable.TRANSFER_PROGRESS_PENDING,
|
||||
size = pointer.get().asPointer().size.orElse(0).toLong(),
|
||||
fileName = pointer.get().asPointer().fileName.orElse(null),
|
||||
cdnNumber = pointer.get().asPointer().cdnNumber,
|
||||
location = pointer.get().asPointer().remoteId.toString(),
|
||||
key = encodedKey,
|
||||
digest = pointer.get().asPointer().digest.orElse(null),
|
||||
incrementalDigest = pointer.get().asPointer().incrementalDigest.orElse(null),
|
||||
incrementalMacChunkSize = pointer.get().asPointer().incrementalMacChunkSize,
|
||||
fastPreflightId = fastPreflightId,
|
||||
voiceNote = pointer.get().asPointer().voiceNote,
|
||||
borderless = pointer.get().asPointer().isBorderless,
|
||||
videoGif = pointer.get().asPointer().isGif,
|
||||
width = pointer.get().asPointer().width,
|
||||
height = pointer.get().asPointer().height,
|
||||
uploadTimestamp = pointer.get().asPointer().uploadTimestamp,
|
||||
caption = pointer.get().asPointer().caption.orElse(null),
|
||||
stickerLocator = stickerLocator,
|
||||
blurHash = BlurHash.parseOrNull(pointer.get().asPointer().blurHash.orElse(null))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun forPointer(pointer: SignalServiceDataMessage.Quote.QuotedAttachment): Optional<Attachment> {
|
||||
val thumbnail = pointer.thumbnail
|
||||
|
||||
return Optional.of(
|
||||
PointerAttachment(
|
||||
contentType = pointer.contentType,
|
||||
transferState = AttachmentTable.TRANSFER_PROGRESS_PENDING,
|
||||
size = (if (thumbnail != null) thumbnail.asPointer().size.orElse(0) else 0).toLong(),
|
||||
fileName = pointer.fileName,
|
||||
cdnNumber = thumbnail?.asPointer()?.cdnNumber ?: 0,
|
||||
location = thumbnail?.asPointer()?.remoteId?.toString() ?: "0",
|
||||
key = if (thumbnail != null && thumbnail.asPointer().key != null) encodeWithPadding(thumbnail.asPointer().key) else null,
|
||||
digest = thumbnail?.asPointer()?.digest?.orElse(null),
|
||||
incrementalDigest = thumbnail?.asPointer()?.incrementalDigest?.orElse(null),
|
||||
incrementalMacChunkSize = thumbnail?.asPointer()?.incrementalMacChunkSize ?: 0,
|
||||
fastPreflightId = null,
|
||||
voiceNote = false,
|
||||
borderless = false,
|
||||
videoGif = false,
|
||||
width = thumbnail?.asPointer()?.width ?: 0,
|
||||
height = thumbnail?.asPointer()?.height ?: 0,
|
||||
uploadTimestamp = thumbnail?.asPointer()?.uploadTimestamp ?: 0,
|
||||
caption = thumbnail?.asPointer()?.caption?.orElse(null),
|
||||
stickerLocator = null,
|
||||
blurHash = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun forPointer(quotedAttachment: DataMessage.Quote.QuotedAttachment): Optional<Attachment> {
|
||||
val thumbnail: SignalServiceAttachment? = try {
|
||||
if (quotedAttachment.thumbnail != null) {
|
||||
AttachmentPointerUtil.createSignalAttachmentPointer(quotedAttachment.thumbnail)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: InvalidMessageStructureException) {
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
return Optional.of(
|
||||
PointerAttachment(
|
||||
contentType = quotedAttachment.contentType!!,
|
||||
transferState = AttachmentTable.TRANSFER_PROGRESS_PENDING,
|
||||
size = (if (thumbnail != null) thumbnail.asPointer().size.orElse(0) else 0).toLong(),
|
||||
fileName = quotedAttachment.fileName,
|
||||
cdnNumber = thumbnail?.asPointer()?.cdnNumber ?: 0,
|
||||
location = thumbnail?.asPointer()?.remoteId?.toString() ?: "0",
|
||||
key = if (thumbnail != null && thumbnail.asPointer().key != null) encodeWithPadding(thumbnail.asPointer().key) else null,
|
||||
digest = thumbnail?.asPointer()?.digest?.orElse(null),
|
||||
incrementalDigest = thumbnail?.asPointer()?.incrementalDigest?.orElse(null),
|
||||
incrementalMacChunkSize = thumbnail?.asPointer()?.incrementalMacChunkSize ?: 0,
|
||||
fastPreflightId = null,
|
||||
voiceNote = false,
|
||||
borderless = false,
|
||||
videoGif = false,
|
||||
width = thumbnail?.asPointer()?.width ?: 0,
|
||||
height = thumbnail?.asPointer()?.height ?: 0,
|
||||
uploadTimestamp = thumbnail?.asPointer()?.uploadTimestamp ?: 0,
|
||||
caption = thumbnail?.asPointer()?.caption?.orElse(null),
|
||||
stickerLocator = null,
|
||||
blurHash = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package org.thoughtcrime.securesms.attachments;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
|
||||
/**
|
||||
* An attachment that represents where an attachment used to be. Useful when you need to know that
|
||||
* a message had an attachment and some metadata about it (like the contentType), even though the
|
||||
* underlying media no longer exists. An example usecase would be view-once messages, so that we can
|
||||
* quote them and know their contentType even though the media has been deleted.
|
||||
*/
|
||||
public class TombstoneAttachment extends Attachment {
|
||||
|
||||
public TombstoneAttachment(@NonNull String contentType, boolean quote) {
|
||||
super(contentType, AttachmentTable.TRANSFER_PROGRESS_DONE, 0, null, 0, null, null, null, null, null, null, false, false, false, 0, 0, 0, quote, 0, null, null, null, null, null);
|
||||
}
|
||||
|
||||
protected TombstoneAttachment(Parcel in) {
|
||||
super(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Uri getUri() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Uri getPublicUri() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.thoughtcrime.securesms.attachments
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
|
||||
/**
|
||||
* An attachment that represents where an attachment used to be. Useful when you need to know that
|
||||
* a message had an attachment and some metadata about it (like the contentType), even though the
|
||||
* underlying media no longer exists. An example usecase would be view-once messages, so that we can
|
||||
* quote them and know their contentType even though the media has been deleted.
|
||||
*/
|
||||
class TombstoneAttachment : Attachment {
|
||||
constructor(contentType: String, quote: Boolean) : super(
|
||||
contentType = contentType,
|
||||
quote = quote,
|
||||
transferState = AttachmentTable.TRANSFER_PROGRESS_DONE,
|
||||
size = 0,
|
||||
fileName = null,
|
||||
cdnNumber = 0,
|
||||
remoteLocation = null,
|
||||
remoteKey = null,
|
||||
remoteDigest = null,
|
||||
incrementalDigest = null,
|
||||
fastPreflightId = null,
|
||||
voiceNote = false,
|
||||
borderless = false,
|
||||
videoGif = false,
|
||||
width = 0,
|
||||
height = 0,
|
||||
incrementalMacChunkSize = 0,
|
||||
uploadTimestamp = 0,
|
||||
caption = null,
|
||||
stickerLocator = null,
|
||||
blurHash = null,
|
||||
audioHash = null,
|
||||
transformProperties = null
|
||||
)
|
||||
|
||||
constructor(parcel: Parcel) : super(parcel)
|
||||
|
||||
override val uri: Uri? = null
|
||||
override val publicUri: Uri? = null
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package org.thoughtcrime.securesms.attachments;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.os.ParcelCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.audio.AudioHash;
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class UriAttachment extends Attachment {
|
||||
|
||||
private final @NonNull Uri dataUri;
|
||||
|
||||
public UriAttachment(@NonNull Uri uri,
|
||||
@NonNull String contentType,
|
||||
int transferState,
|
||||
long size,
|
||||
@Nullable String fileName,
|
||||
boolean voiceNote,
|
||||
boolean borderless,
|
||||
boolean videoGif,
|
||||
boolean quote,
|
||||
@Nullable String caption,
|
||||
@Nullable StickerLocator stickerLocator,
|
||||
@Nullable BlurHash blurHash,
|
||||
@Nullable AudioHash audioHash,
|
||||
@Nullable TransformProperties transformProperties)
|
||||
{
|
||||
this(uri, contentType, transferState, size, 0, 0, fileName, null, voiceNote, borderless, videoGif, quote, caption, stickerLocator, blurHash, audioHash, transformProperties);
|
||||
}
|
||||
|
||||
public UriAttachment(@NonNull Uri dataUri,
|
||||
@NonNull String contentType,
|
||||
int transferState,
|
||||
long size,
|
||||
int width,
|
||||
int height,
|
||||
@Nullable String fileName,
|
||||
@Nullable String fastPreflightId,
|
||||
boolean voiceNote,
|
||||
boolean borderless,
|
||||
boolean videoGif,
|
||||
boolean quote,
|
||||
@Nullable String caption,
|
||||
@Nullable StickerLocator stickerLocator,
|
||||
@Nullable BlurHash blurHash,
|
||||
@Nullable AudioHash audioHash,
|
||||
@Nullable TransformProperties transformProperties)
|
||||
{
|
||||
super(contentType, transferState, size, fileName, 0, null, null, null, null, null, fastPreflightId, voiceNote, borderless, videoGif, width, height, 0, quote, 0, caption, stickerLocator, blurHash, audioHash, transformProperties);
|
||||
this.dataUri = Objects.requireNonNull(dataUri);
|
||||
}
|
||||
|
||||
protected UriAttachment(Parcel in) {
|
||||
super(in);
|
||||
this.dataUri = Objects.requireNonNull(ParcelCompat.readParcelable(in, Uri.class.getClassLoader(), Uri.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
||||
super.writeToParcel(dest, flags);
|
||||
dest.writeParcelable(dataUri, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public Uri getUri() {
|
||||
return dataUri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Uri getPublicUri() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
return other != null && other instanceof UriAttachment && ((UriAttachment) other).dataUri.equals(this.dataUri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return dataUri.hashCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package org.thoughtcrime.securesms.attachments
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import androidx.core.os.ParcelCompat
|
||||
import org.thoughtcrime.securesms.audio.AudioHash
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import java.util.Objects
|
||||
|
||||
class UriAttachment : Attachment {
|
||||
|
||||
constructor(
|
||||
uri: Uri,
|
||||
contentType: String,
|
||||
transferState: Int,
|
||||
size: Long,
|
||||
fileName: String?,
|
||||
voiceNote: Boolean,
|
||||
borderless: Boolean,
|
||||
videoGif: Boolean,
|
||||
quote: Boolean,
|
||||
caption: String?,
|
||||
stickerLocator: StickerLocator?,
|
||||
blurHash: BlurHash?,
|
||||
audioHash: AudioHash?,
|
||||
transformProperties: TransformProperties?
|
||||
) : this(
|
||||
dataUri = uri,
|
||||
contentType = contentType,
|
||||
transferState = transferState,
|
||||
size = size,
|
||||
width = 0,
|
||||
height = 0,
|
||||
fileName = fileName,
|
||||
fastPreflightId = null,
|
||||
voiceNote = voiceNote,
|
||||
borderless = borderless,
|
||||
videoGif = videoGif,
|
||||
quote = quote,
|
||||
caption = caption,
|
||||
stickerLocator = stickerLocator,
|
||||
blurHash = blurHash,
|
||||
audioHash = audioHash,
|
||||
transformProperties = transformProperties
|
||||
)
|
||||
|
||||
constructor(
|
||||
dataUri: Uri,
|
||||
contentType: String,
|
||||
transferState: Int,
|
||||
size: Long,
|
||||
width: Int,
|
||||
height: Int,
|
||||
fileName: String?,
|
||||
fastPreflightId: String?,
|
||||
voiceNote: Boolean,
|
||||
borderless: Boolean,
|
||||
videoGif: Boolean,
|
||||
quote: Boolean,
|
||||
caption: String?,
|
||||
stickerLocator: StickerLocator?,
|
||||
blurHash: BlurHash?,
|
||||
audioHash: AudioHash?,
|
||||
transformProperties: TransformProperties?
|
||||
) : super(
|
||||
contentType = contentType,
|
||||
transferState = transferState,
|
||||
size = size,
|
||||
fileName = fileName,
|
||||
cdnNumber = 0,
|
||||
remoteLocation = null,
|
||||
remoteKey = null,
|
||||
remoteDigest = null,
|
||||
incrementalDigest = null,
|
||||
fastPreflightId = fastPreflightId,
|
||||
voiceNote = voiceNote,
|
||||
borderless = borderless,
|
||||
videoGif = videoGif,
|
||||
width = width,
|
||||
height = height,
|
||||
incrementalMacChunkSize = 0,
|
||||
quote = quote,
|
||||
uploadTimestamp = 0,
|
||||
caption = caption,
|
||||
stickerLocator = stickerLocator,
|
||||
blurHash = blurHash,
|
||||
audioHash = audioHash,
|
||||
transformProperties = transformProperties
|
||||
) {
|
||||
uri = Objects.requireNonNull(dataUri)
|
||||
}
|
||||
|
||||
constructor(parcel: Parcel) : super(parcel) {
|
||||
uri = ParcelCompat.readParcelable(parcel, Uri::class.java.classLoader, Uri::class.java)!!
|
||||
}
|
||||
|
||||
override val uri: Uri
|
||||
override val publicUri: Uri? = null
|
||||
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
super.writeToParcel(dest, flags)
|
||||
dest.writeParcelable(uri, 0)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return other != null && other is UriAttachment && other.uri == uri
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return uri.hashCode()
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,23 @@ public class AudioRecorder {
|
||||
});
|
||||
}
|
||||
|
||||
public void discardRecording() {
|
||||
Log.i(TAG, "cancelRecording()");
|
||||
executor.execute(() -> {
|
||||
if (recorder == null) {
|
||||
Log.e(TAG, "MediaRecorder was never initialized successfully!");
|
||||
return;
|
||||
}
|
||||
audioFocusManager.abandonAudioFocus();
|
||||
recorder.stop();
|
||||
recordingUriFuture.cancel(true);
|
||||
|
||||
recordingSubject = null;
|
||||
recorder = null;
|
||||
recordingUriFuture = null;
|
||||
});
|
||||
}
|
||||
|
||||
public void stopRecording() {
|
||||
Log.i(TAG, "stopRecording()");
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.avatar
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
@Composable
|
||||
fun AvatarImage(
|
||||
recipient: Recipient,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Spacer(
|
||||
modifier = modifier
|
||||
.background(color = Color.Red, shape = CircleShape)
|
||||
)
|
||||
} else {
|
||||
AndroidView(
|
||||
factory = ::AvatarImageView,
|
||||
modifier = modifier.background(color = Color.Transparent, shape = CircleShape)
|
||||
) {
|
||||
it.setAvatarUsingProfile(recipient)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ object BackupCountQueries {
|
||||
@get:JvmStatic
|
||||
val attachmentCount: String = """
|
||||
SELECT COUNT(*) FROM ${AttachmentTable.TABLE_NAME}
|
||||
INNER JOIN ${MessageTable.TABLE_NAME} ON ${AttachmentTable.TABLE_NAME}.${AttachmentTable.MMS_ID} = ${MessageTable.TABLE_NAME}.${MessageTable.ID}
|
||||
INNER JOIN ${MessageTable.TABLE_NAME} ON ${AttachmentTable.TABLE_NAME}.${AttachmentTable.MESSAGE_ID} = ${MessageTable.TABLE_NAME}.${MessageTable.ID}
|
||||
WHERE ${MessageTable.TABLE_NAME}.${MessageTable.EXPIRES_IN} <= 0 AND ${MessageTable.TABLE_NAME}.${MessageTable.VIEW_ONCE} <= 0
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -119,8 +119,7 @@ class BackupFrameOutputStream extends FullBackupBase.BackupStream {
|
||||
try {
|
||||
write(outputStream, new BackupFrame.Builder()
|
||||
.attachment(new Attachment.Builder()
|
||||
.rowId(attachmentId.getRowId())
|
||||
.attachmentId(attachmentId.getUniqueId())
|
||||
.rowId(attachmentId.id)
|
||||
.length(Util.toIntExact(size))
|
||||
.build())
|
||||
.build());
|
||||
|
||||
@@ -44,7 +44,6 @@ import org.thoughtcrime.securesms.database.SessionTable;
|
||||
import org.thoughtcrime.securesms.database.SignedPreKeyTable;
|
||||
import org.thoughtcrime.securesms.database.StickerTable;
|
||||
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;
|
||||
@@ -65,6 +64,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import okio.ByteString;
|
||||
@@ -77,6 +77,7 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
private static final long TABLE_RECORD_COUNT_MULTIPLIER = 3L;
|
||||
private static final long IDENTITY_KEY_BACKUP_RECORD_COUNT = 2L;
|
||||
private static final long FINAL_MESSAGE_COUNT = 1L;
|
||||
private static final long EXPIRATION_BACKUP_THRESHOLD = TimeUnit.DAYS.toMillis(1);
|
||||
|
||||
/**
|
||||
* Tables in list will still have their *schema* exported (so the tables will be created),
|
||||
@@ -159,15 +160,15 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
for (String table : tables) {
|
||||
throwIfCanceled(cancellationSignal);
|
||||
if (table.equals(MessageTable.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringMmsMessage, null, count, estimatedCount, cancellationSignal);
|
||||
count = exportTable(table, input, outputStream, cursor -> isNonExpiringMessage(cursor), null, count, estimatedCount, cancellationSignal);
|
||||
} else if (table.equals(ReactionTable.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, new MessageId(CursorUtil.requireLong(cursor, ReactionTable.MESSAGE_ID))), null, count, estimatedCount, cancellationSignal);
|
||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, CursorUtil.requireLong(cursor, ReactionTable.MESSAGE_ID)), null, count, estimatedCount, cancellationSignal);
|
||||
} else if (table.equals(MentionTable.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessage(input, CursorUtil.requireLong(cursor, MentionTable.MESSAGE_ID)), null, count, estimatedCount, cancellationSignal);
|
||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, CursorUtil.requireLong(cursor, MentionTable.MESSAGE_ID)), null, count, estimatedCount, cancellationSignal);
|
||||
} else if (table.equals(GroupReceiptTable.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptTable.MMS_ID))), null, count, estimatedCount, cancellationSignal);
|
||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptTable.MMS_ID))), null, count, estimatedCount, cancellationSignal);
|
||||
} else if (table.equals(AttachmentTable.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentTable.MMS_ID))), (cursor, innerCount) -> exportAttachment(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal);
|
||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentTable.MESSAGE_ID))), (cursor, innerCount) -> exportAttachment(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal);
|
||||
} else if (table.equals(StickerTable.TABLE_NAME)) {
|
||||
count = exportTable(table, input, outputStream, cursor -> true, (cursor, innerCount) -> exportSticker(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal);
|
||||
} else if (!TABLE_CONTENT_BLOCKLIST.contains(table)) {
|
||||
@@ -444,11 +445,10 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
long estimatedCount)
|
||||
throws IOException
|
||||
{
|
||||
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentTable.ROW_ID));
|
||||
long uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentTable.UNIQUE_ID));
|
||||
long size = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentTable.SIZE));
|
||||
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentTable.ID));
|
||||
long size = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentTable.DATA_SIZE));
|
||||
|
||||
String data = cursor.getString(cursor.getColumnIndexOrThrow(AttachmentTable.DATA));
|
||||
String data = cursor.getString(cursor.getColumnIndexOrThrow(AttachmentTable.DATA_FILE));
|
||||
byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(AttachmentTable.DATA_RANDOM));
|
||||
|
||||
if (!TextUtils.isEmpty(data)) {
|
||||
@@ -457,14 +457,14 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
|
||||
if (size <= 0 || fileLength != dbLength) {
|
||||
size = calculateVeryOldStreamLength(attachmentSecret, random, data);
|
||||
Log.w(TAG, "Needed size calculation! Manual: " + size + " File: " + fileLength + " DB: " + dbLength + " ID: " + new AttachmentId(rowId, uniqueId));
|
||||
Log.w(TAG, "Needed size calculation! Manual: " + size + " File: " + fileLength + " DB: " + dbLength + " ID: " + new AttachmentId(rowId));
|
||||
}
|
||||
}
|
||||
|
||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||
if (!TextUtils.isEmpty(data) && size > 0) {
|
||||
try (InputStream inputStream = openAttachmentStream(attachmentSecret, random, data)) {
|
||||
outputStream.write(new AttachmentId(rowId, uniqueId), inputStream, size);
|
||||
outputStream.write(new AttachmentId(rowId), inputStream, size);
|
||||
} catch (FileNotFoundException e) {
|
||||
Log.w(TAG, "Missing attachment", e);
|
||||
}
|
||||
@@ -579,27 +579,25 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
return count;
|
||||
}
|
||||
|
||||
private static boolean isNonExpiringMmsMessage(@NonNull Cursor cursor) {
|
||||
return cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.EXPIRES_IN)) <= 0 &&
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.VIEW_ONCE)) <= 0;
|
||||
private static boolean isNonExpiringMessage(@NonNull Cursor cursor) {
|
||||
long expiresIn = CursorUtil.requireLong(cursor, MessageTable.EXPIRES_IN);
|
||||
boolean viewOnce = CursorUtil.requireBoolean(cursor, MessageTable.VIEW_ONCE);
|
||||
|
||||
if (expiresIn == 0 && !viewOnce) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return expiresIn > EXPIRATION_BACKUP_THRESHOLD;
|
||||
}
|
||||
|
||||
private static boolean isNonExpiringSmsMessage(@NonNull Cursor cursor) {
|
||||
return cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.EXPIRES_IN)) <= 0;
|
||||
}
|
||||
|
||||
private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, @NonNull MessageId messageId) {
|
||||
return isForNonExpiringMmsMessage(db, messageId.getId());
|
||||
}
|
||||
|
||||
private static boolean isForNonExpiringMmsMessage(@NonNull SQLiteDatabase db, long mmsId) {
|
||||
private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, long messageId) {
|
||||
String[] columns = new String[] { MessageTable.EXPIRES_IN, MessageTable.VIEW_ONCE };
|
||||
String where = MessageTable.ID + " = ?";
|
||||
String[] args = new String[] { String.valueOf(mmsId) };
|
||||
String[] args = SqlUtil.buildArgs(messageId);
|
||||
|
||||
try (Cursor mmsCursor = db.query(MessageTable.TABLE_NAME, columns, where, args, null, null, null)) {
|
||||
if (mmsCursor != null && mmsCursor.moveToFirst()) {
|
||||
return isNonExpiringMmsMessage(mmsCursor);
|
||||
return isNonExpiringMessage(mmsCursor);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -194,26 +194,33 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
private static void processAttachment(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Attachment attachment, BackupRecordInputStream inputStream)
|
||||
throws IOException
|
||||
{
|
||||
File dataFile = AttachmentTable.newFile(context);
|
||||
Pair<byte[], OutputStream> output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false);
|
||||
File dataFile = AttachmentTable.newFile(context);
|
||||
Pair<byte[], OutputStream> output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false);
|
||||
boolean isLegacyTable = SqlUtil.tableExists(db, "part");
|
||||
|
||||
String dataFileColumnName = isLegacyTable ? "_data" : AttachmentTable.DATA_FILE;
|
||||
String dataRandomColumnName = isLegacyTable ? "data_random" : AttachmentTable.DATA_RANDOM;
|
||||
String idColumnName = isLegacyTable ? "_id" : AttachmentTable.ID;
|
||||
String tableName = isLegacyTable ? "part" : AttachmentTable.TABLE_NAME;
|
||||
|
||||
ContentValues contentValues = new ContentValues();
|
||||
|
||||
try {
|
||||
inputStream.readAttachmentTo(output.second, attachment.length);
|
||||
|
||||
contentValues.put(AttachmentTable.DATA, dataFile.getAbsolutePath());
|
||||
contentValues.put(AttachmentTable.DATA_RANDOM, output.first);
|
||||
contentValues.put(dataFileColumnName, dataFile.getAbsolutePath());
|
||||
contentValues.put(dataRandomColumnName, output.first);
|
||||
} catch (BackupRecordInputStream.BadMacException e) {
|
||||
Log.w(TAG, "Bad MAC for attachment " + attachment.attachmentId + "! Can't restore it.", e);
|
||||
dataFile.delete();
|
||||
contentValues.put(AttachmentTable.DATA, (String) null);
|
||||
contentValues.put(AttachmentTable.DATA_RANDOM, (String) null);
|
||||
contentValues.put(dataFileColumnName, (String) null);
|
||||
contentValues.put(dataRandomColumnName, (String) null);
|
||||
}
|
||||
|
||||
db.update(AttachmentTable.TABLE_NAME, contentValues,
|
||||
AttachmentTable.ROW_ID + " = ? AND " + AttachmentTable.UNIQUE_ID + " = ?",
|
||||
new String[] {String.valueOf(attachment.rowId), String.valueOf(attachment.attachmentId)});
|
||||
db.update(tableName,
|
||||
contentValues,
|
||||
idColumnName + " = ?",
|
||||
SqlUtil.buildArgs(attachment.rowId));
|
||||
}
|
||||
|
||||
private static void processSticker(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Sticker sticker, BackupRecordInputStream inputStream)
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
import org.signal.core.util.EventTimer
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter
|
||||
import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.AccountDataProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.CallLogBackupProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.ChatBackupProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.ChatItemBackupProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.RecipientBackupProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.BackupExportWriter
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupWriter
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupReader
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupWriter
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveGetBackupInfoResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object BackupRepository {
|
||||
|
||||
private val TAG = Log.tag(BackupRepository::class.java)
|
||||
|
||||
fun export(plaintext: Boolean = false): ByteArray {
|
||||
val eventTimer = EventTimer()
|
||||
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
val writer: BackupExportWriter = if (plaintext) {
|
||||
PlainTextBackupWriter(outputStream)
|
||||
} else {
|
||||
EncryptedBackupWriter(
|
||||
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
|
||||
aci = SignalStore.account().aci!!,
|
||||
outputStream = outputStream,
|
||||
append = { mac -> outputStream.write(mac) }
|
||||
)
|
||||
}
|
||||
|
||||
writer.use {
|
||||
// Note: Without a transaction, we may export inconsistent state. But because we have a transaction,
|
||||
// writes from other threads are blocked. This is something to think more about.
|
||||
SignalDatabase.rawDatabase.withinTransaction {
|
||||
AccountDataProcessor.export {
|
||||
writer.write(it)
|
||||
eventTimer.emit("account")
|
||||
}
|
||||
|
||||
RecipientBackupProcessor.export {
|
||||
writer.write(it)
|
||||
eventTimer.emit("recipient")
|
||||
}
|
||||
|
||||
ChatBackupProcessor.export { frame ->
|
||||
writer.write(frame)
|
||||
eventTimer.emit("thread")
|
||||
}
|
||||
|
||||
CallLogBackupProcessor.export { frame ->
|
||||
writer.write(frame)
|
||||
eventTimer.emit("call")
|
||||
}
|
||||
|
||||
ChatItemBackupProcessor.export { frame ->
|
||||
writer.write(frame)
|
||||
eventTimer.emit("message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "export() ${eventTimer.stop().summary}")
|
||||
|
||||
return outputStream.toByteArray()
|
||||
}
|
||||
|
||||
fun import(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData, plaintext: Boolean = false) {
|
||||
val eventTimer = EventTimer()
|
||||
|
||||
val frameReader = if (plaintext) {
|
||||
PlainTextBackupReader(inputStreamFactory())
|
||||
} else {
|
||||
EncryptedBackupReader(
|
||||
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
|
||||
aci = selfData.aci,
|
||||
streamLength = length,
|
||||
dataStream = inputStreamFactory
|
||||
)
|
||||
}
|
||||
|
||||
// Note: Without a transaction, bad imports could lead to lost data. But because we have a transaction,
|
||||
// writes from other threads are blocked. This is something to think more about.
|
||||
SignalDatabase.rawDatabase.withinTransaction {
|
||||
SignalStore.clearAllDataForBackupRestore()
|
||||
SignalDatabase.recipients.clearAllDataForBackupRestore()
|
||||
SignalDatabase.distributionLists.clearAllDataForBackupRestore()
|
||||
SignalDatabase.threads.clearAllDataForBackupRestore()
|
||||
SignalDatabase.messages.clearAllDataForBackupRestore()
|
||||
SignalDatabase.attachments.clearAllDataForBackupRestore()
|
||||
|
||||
// Add back self after clearing data
|
||||
val selfId: RecipientId = SignalDatabase.recipients.getAndPossiblyMerge(selfData.aci, selfData.pni, selfData.e164, pniVerified = true, changeSelf = true)
|
||||
SignalDatabase.recipients.setProfileKey(selfId, selfData.profileKey)
|
||||
SignalDatabase.recipients.setProfileSharing(selfId, true)
|
||||
|
||||
val backupState = BackupState()
|
||||
val chatItemInserter: ChatItemImportInserter = ChatItemBackupProcessor.beginImport(backupState)
|
||||
|
||||
for (frame in frameReader) {
|
||||
when {
|
||||
frame.account != null -> {
|
||||
AccountDataProcessor.import(frame.account, selfId)
|
||||
eventTimer.emit("account")
|
||||
}
|
||||
|
||||
frame.recipient != null -> {
|
||||
RecipientBackupProcessor.import(frame.recipient, backupState)
|
||||
eventTimer.emit("recipient")
|
||||
}
|
||||
|
||||
frame.chat != null -> {
|
||||
ChatBackupProcessor.import(frame.chat, backupState)
|
||||
eventTimer.emit("chat")
|
||||
}
|
||||
|
||||
frame.call != null -> {
|
||||
CallLogBackupProcessor.import(frame.call, backupState)
|
||||
eventTimer.emit("call")
|
||||
}
|
||||
|
||||
frame.chatItem != null -> {
|
||||
chatItemInserter.insert(frame.chatItem)
|
||||
eventTimer.emit("chatItem")
|
||||
// TODO if there's stuff in the stream after chatItems, we need to flush the inserter before going to the next phase
|
||||
}
|
||||
|
||||
else -> Log.w(TAG, "Unrecognized frame")
|
||||
}
|
||||
}
|
||||
|
||||
if (chatItemInserter.flush()) {
|
||||
eventTimer.emit("chatItem")
|
||||
}
|
||||
|
||||
backupState.chatIdToLocalThreadId.values.forEach {
|
||||
SignalDatabase.threads.update(it, unarchive = false, allowDeletion = false)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "import() ${eventTimer.stop().summary}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object with details about the remote backup state.
|
||||
*/
|
||||
fun getRemoteBackupState(): NetworkResult<ArchiveGetBackupInfoResponse> {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return api
|
||||
.triggerBackupIdReservation(backupKey)
|
||||
.then { getAuthCredential() }
|
||||
.then { credential ->
|
||||
api.setPublicKey(backupKey, credential)
|
||||
.also { Log.i(TAG, "PublicKeyResult: $it") }
|
||||
.map { credential }
|
||||
}
|
||||
.then { credential ->
|
||||
api.getBackupInfo(backupKey, credential)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple test method that just hits various network endpoints. Only useful for the playground.
|
||||
*
|
||||
* @return True if successful, otherwise false.
|
||||
*/
|
||||
fun uploadBackupFile(backupStream: InputStream, backupStreamLength: Long): Boolean {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return api
|
||||
.triggerBackupIdReservation(backupKey)
|
||||
.then { getAuthCredential() }
|
||||
.then { credential ->
|
||||
api.setPublicKey(backupKey, credential)
|
||||
.also { Log.i(TAG, "PublicKeyResult: $it") }
|
||||
.map { credential }
|
||||
}
|
||||
.then { credential ->
|
||||
api.getMessageBackupUploadForm(backupKey, credential)
|
||||
.also { Log.i(TAG, "UploadFormResult: $it") }
|
||||
}
|
||||
.then { form ->
|
||||
api.getBackupResumableUploadUrl(form)
|
||||
.also { Log.i(TAG, "ResumableUploadUrlResult: $it") }
|
||||
.map { form to it }
|
||||
}
|
||||
.then { formAndUploadUrl ->
|
||||
val (form, resumableUploadUrl) = formAndUploadUrl
|
||||
api.uploadBackupFile(form, resumableUploadUrl, backupStream, backupStreamLength)
|
||||
.also { Log.i(TAG, "UploadBackupFileResult: $it") }
|
||||
}
|
||||
.also { Log.i(TAG, "OverallResult: $it") } is NetworkResult.Success
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an auth credential, preferring a cached value if available.
|
||||
*/
|
||||
private fun getAuthCredential(): NetworkResult<ArchiveServiceCredential> {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
|
||||
val credential = SignalStore.backup().credentialsByDay.getForCurrentTime(currentTime.milliseconds)
|
||||
|
||||
if (credential != null) {
|
||||
return NetworkResult.Success(credential)
|
||||
}
|
||||
|
||||
Log.w(TAG, "No credentials found for today, need to fetch new ones! This shouldn't happen under normal circumstances. We should ensure the routine fetch is running properly.")
|
||||
|
||||
return ApplicationDependencies.getSignalServiceAccountManager().archiveApi.getServiceCredentials(currentTime).map { result ->
|
||||
SignalStore.backup().addCredentials(result.credentials.toList())
|
||||
SignalStore.backup().clearCredentialsOlderThan(currentTime)
|
||||
SignalStore.backup().credentialsByDay.getForCurrentTime(currentTime.milliseconds)!!
|
||||
}
|
||||
}
|
||||
|
||||
data class SelfData(
|
||||
val aci: ACI,
|
||||
val pni: PNI,
|
||||
val e164: String,
|
||||
val profileKey: ProfileKey
|
||||
)
|
||||
}
|
||||
|
||||
class BackupState {
|
||||
val backupToLocalRecipientId = HashMap<Long, RecipientId>()
|
||||
val chatIdToLocalThreadId = HashMap<Long, Long>()
|
||||
val chatIdToLocalRecipientId = HashMap<Long, RecipientId>()
|
||||
val chatIdToBackupRecipientId = HashMap<Long, Long>()
|
||||
val callIdToType = HashMap<Long, Long>()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import org.signal.core.util.delete
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
|
||||
fun AttachmentTable.clearAllDataForBackupRestore() {
|
||||
writableDatabase.delete(AttachmentTable.TABLE_NAME).run()
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.core.content.contentValuesOf
|
||||
import org.signal.core.util.isNull
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.select
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Call
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import java.io.Closeable
|
||||
|
||||
typealias BackupCall = org.thoughtcrime.securesms.backup.v2.proto.Call
|
||||
|
||||
fun CallTable.getCallsForBackup(): CallLogIterator {
|
||||
return CallLogIterator(
|
||||
readableDatabase
|
||||
.select()
|
||||
.from(CallTable.TABLE_NAME)
|
||||
.where("${CallTable.EVENT} != ${CallTable.Event.serialize(CallTable.Event.DELETE)}")
|
||||
.run()
|
||||
)
|
||||
}
|
||||
|
||||
fun CallTable.restoreCallLogFromBackup(call: BackupCall, backupState: BackupState) {
|
||||
val type = when (call.type) {
|
||||
Call.Type.VIDEO_CALL -> CallTable.Type.VIDEO_CALL
|
||||
Call.Type.AUDIO_CALL -> CallTable.Type.AUDIO_CALL
|
||||
Call.Type.AD_HOC_CALL -> CallTable.Type.AD_HOC_CALL
|
||||
Call.Type.GROUP_CALL -> CallTable.Type.GROUP_CALL
|
||||
Call.Type.UNKNOWN_TYPE -> return
|
||||
}
|
||||
|
||||
val event = when (call.event) {
|
||||
Call.Event.DELETE -> CallTable.Event.DELETE
|
||||
Call.Event.JOINED -> CallTable.Event.JOINED
|
||||
Call.Event.GENERIC_GROUP_CALL -> CallTable.Event.GENERIC_GROUP_CALL
|
||||
Call.Event.DECLINED -> CallTable.Event.DECLINED
|
||||
Call.Event.ACCEPTED -> CallTable.Event.ACCEPTED
|
||||
Call.Event.MISSED -> CallTable.Event.MISSED
|
||||
Call.Event.OUTGOING_RING -> CallTable.Event.OUTGOING_RING
|
||||
Call.Event.OUTGOING -> CallTable.Event.ONGOING
|
||||
Call.Event.NOT_ACCEPTED -> CallTable.Event.NOT_ACCEPTED
|
||||
Call.Event.UNKNOWN_EVENT -> return
|
||||
}
|
||||
|
||||
val direction = if (call.outgoing) CallTable.Direction.OUTGOING else CallTable.Direction.INCOMING
|
||||
|
||||
backupState.callIdToType[call.callId] = CallTable.Call.getMessageType(type, direction, event)
|
||||
|
||||
val values = contentValuesOf(
|
||||
CallTable.CALL_ID to call.callId,
|
||||
CallTable.PEER to backupState.backupToLocalRecipientId[call.conversationRecipientId]!!.serialize(),
|
||||
CallTable.TYPE to CallTable.Type.serialize(type),
|
||||
CallTable.DIRECTION to CallTable.Direction.serialize(direction),
|
||||
CallTable.EVENT to CallTable.Event.serialize(event),
|
||||
CallTable.TIMESTAMP to call.timestamp
|
||||
)
|
||||
|
||||
writableDatabase.insert(CallTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s.
|
||||
* Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources.
|
||||
*/
|
||||
class CallLogIterator(private val cursor: Cursor) : Iterator<BackupCall?>, Closeable {
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count > 0 && !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): BackupCall? {
|
||||
if (!cursor.moveToNext()) {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
val callId = cursor.requireLong(CallTable.CALL_ID)
|
||||
val type = CallTable.Type.deserialize(cursor.requireInt(CallTable.TYPE))
|
||||
val direction = CallTable.Direction.deserialize(cursor.requireInt(CallTable.DIRECTION))
|
||||
val event = CallTable.Event.deserialize(cursor.requireInt(CallTable.EVENT))
|
||||
|
||||
return BackupCall(
|
||||
callId = callId,
|
||||
conversationRecipientId = cursor.requireLong(CallTable.PEER),
|
||||
type = when (type) {
|
||||
CallTable.Type.AUDIO_CALL -> Call.Type.AUDIO_CALL
|
||||
CallTable.Type.VIDEO_CALL -> Call.Type.VIDEO_CALL
|
||||
CallTable.Type.AD_HOC_CALL -> Call.Type.AD_HOC_CALL
|
||||
CallTable.Type.GROUP_CALL -> Call.Type.GROUP_CALL
|
||||
},
|
||||
outgoing = when (direction) {
|
||||
CallTable.Direction.OUTGOING -> true
|
||||
else -> false
|
||||
},
|
||||
timestamp = cursor.requireLong(CallTable.TIMESTAMP),
|
||||
ringerRecipientId = if (cursor.isNull(CallTable.RINGER)) null else cursor.requireLong(CallTable.RINGER),
|
||||
event = when (event) {
|
||||
CallTable.Event.ONGOING -> Call.Event.OUTGOING
|
||||
CallTable.Event.OUTGOING_RING -> Call.Event.OUTGOING_RING
|
||||
CallTable.Event.ACCEPTED -> Call.Event.ACCEPTED
|
||||
CallTable.Event.DECLINED -> Call.Event.DECLINED
|
||||
CallTable.Event.GENERIC_GROUP_CALL -> Call.Event.GENERIC_GROUP_CALL
|
||||
CallTable.Event.JOINED -> Call.Event.JOINED
|
||||
CallTable.Event.MISSED,
|
||||
CallTable.Event.MISSED_NOTIFICATION_PROFILE -> Call.Event.MISSED
|
||||
CallTable.Event.DELETE -> Call.Event.DELETE
|
||||
CallTable.Event.RINGING -> Call.Event.UNKNOWN_EVENT
|
||||
CallTable.Event.NOT_ACCEPTED -> Call.Event.NOT_ACCEPTED
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.database.Cursor
|
||||
import com.annimon.stream.Stream
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.Base64.decodeOrThrow
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.requireBlob
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireString
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.CallChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ExpirationTimerChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.GroupCallChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCallChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Quote
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Reaction
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.RemoteDeletedMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.SendStatus
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.SessionSwitchoverChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.SimpleChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Text
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ThreadMergeChatUpdate
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptTable
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.MessageTypes
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.calls
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet
|
||||
import org.thoughtcrime.securesms.database.documents.NetworkFailureSet
|
||||
import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import org.whispersystems.signalservice.api.util.toByteArray
|
||||
import java.io.Closeable
|
||||
import java.io.IOException
|
||||
import java.util.LinkedList
|
||||
import java.util.Queue
|
||||
import java.util.UUID
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.BodyRange as BackupBodyRange
|
||||
|
||||
/**
|
||||
* An iterator for chat items with a clever performance twist: rather than do the extra queries one at a time (for reactions,
|
||||
* attachments, etc), this will populate items in batches, doing bulk lookups to improve throughput. We keep these in a buffer
|
||||
* and only do more queries when the buffer is empty.
|
||||
*
|
||||
* All of this complexity is hidden from the user -- they just get a normal iterator interface.
|
||||
*/
|
||||
class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: Int) : Iterator<ChatItem>, Closeable {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ChatItemExportIterator::class.java)
|
||||
|
||||
const val COLUMN_BASE_TYPE = "base_type"
|
||||
}
|
||||
|
||||
/**
|
||||
* A queue of already-parsed ChatItems. Processing in batches means that we read ahead in the cursor and put
|
||||
* the pending items here.
|
||||
*/
|
||||
private val buffer: Queue<ChatItem> = LinkedList()
|
||||
|
||||
override fun hasNext(): Boolean {
|
||||
return buffer.isNotEmpty() || (cursor.count > 0 && !cursor.isLast && !cursor.isAfterLast)
|
||||
}
|
||||
|
||||
override fun next(): ChatItem {
|
||||
if (buffer.isNotEmpty()) {
|
||||
return buffer.remove()
|
||||
}
|
||||
|
||||
val records: LinkedHashMap<Long, BackupMessageRecord> = linkedMapOf()
|
||||
|
||||
for (i in 0 until batchSize) {
|
||||
if (cursor.moveToNext()) {
|
||||
val record = cursor.toBackupMessageRecord()
|
||||
records[record.id] = record
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
val reactionsById: Map<Long, List<ReactionRecord>> = SignalDatabase.reactions.getReactionsForMessages(records.keys)
|
||||
val groupReceiptsById: Map<Long, List<GroupReceiptTable.GroupReceiptInfo>> = SignalDatabase.groupReceipts.getGroupReceiptInfoForMessages(records.keys)
|
||||
|
||||
for ((id, record) in records) {
|
||||
val builder = record.toBasicChatItemBuilder(groupReceiptsById[id])
|
||||
|
||||
when {
|
||||
record.remoteDeleted -> builder.remoteDeletedMessage = RemoteDeletedMessage()
|
||||
MessageTypes.isJoinedType(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.JOINED_SIGNAL))
|
||||
MessageTypes.isIdentityUpdate(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.IDENTITY_UPDATE))
|
||||
MessageTypes.isIdentityVerified(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.IDENTITY_VERIFIED))
|
||||
MessageTypes.isIdentityDefault(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.IDENTITY_DEFAULT))
|
||||
MessageTypes.isChangeNumber(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.CHANGE_NUMBER))
|
||||
MessageTypes.isBoostRequest(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.BOOST_REQUEST))
|
||||
MessageTypes.isEndSessionType(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.END_SESSION))
|
||||
MessageTypes.isChatSessionRefresh(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.CHAT_SESSION_REFRESH))
|
||||
MessageTypes.isBadDecryptType(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.BAD_DECRYPT))
|
||||
MessageTypes.isPaymentsActivated(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.PAYMENTS_ACTIVATED))
|
||||
MessageTypes.isPaymentsRequestToActivate(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST))
|
||||
MessageTypes.isExpirationTimerUpdate(record.type) -> builder.updateMessage = ChatUpdateMessage(expirationTimerChange = ExpirationTimerChatUpdate((record.expiresIn / 1000).toInt()))
|
||||
MessageTypes.isProfileChange(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
profileChange = try {
|
||||
val decoded: ByteArray = Base64.decode(record.body!!)
|
||||
val profileChangeDetails = ProfileChangeDetails.ADAPTER.decode(decoded)
|
||||
if (profileChangeDetails.profileNameChange != null) {
|
||||
ProfileChangeChatUpdate(previousName = profileChangeDetails.profileNameChange.previous, newName = profileChangeDetails.profileNameChange.newValue)
|
||||
} else {
|
||||
ProfileChangeChatUpdate()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Profile name change details could not be read", e)
|
||||
ProfileChangeChatUpdate()
|
||||
}
|
||||
)
|
||||
}
|
||||
MessageTypes.isSessionSwitchoverType(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
sessionSwitchover = try {
|
||||
val event = SessionSwitchoverEvent.ADAPTER.decode(decodeOrThrow(record.body!!))
|
||||
SessionSwitchoverChatUpdate(event.e164.e164ToLong()!!)
|
||||
} catch (e: Exception) {
|
||||
SessionSwitchoverChatUpdate()
|
||||
}
|
||||
)
|
||||
}
|
||||
MessageTypes.isThreadMergeType(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
threadMerge = try {
|
||||
val event = ThreadMergeEvent.ADAPTER.decode(decodeOrThrow(record.body!!))
|
||||
ThreadMergeChatUpdate(event.previousE164.e164ToLong()!!)
|
||||
} catch (e: Exception) {
|
||||
ThreadMergeChatUpdate()
|
||||
}
|
||||
)
|
||||
}
|
||||
MessageTypes.isCallLog(record.type) -> {
|
||||
val call = calls.getCallByMessageId(record.id)
|
||||
if (call != null) {
|
||||
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callId = call.callId))
|
||||
} else {
|
||||
when {
|
||||
MessageTypes.isMissedAudioCall(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.MISSED_AUDIO_CALL)))
|
||||
}
|
||||
MessageTypes.isMissedVideoCall(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.MISSED_VIDEO_CALL)))
|
||||
}
|
||||
MessageTypes.isIncomingAudioCall(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.INCOMING_AUDIO_CALL)))
|
||||
}
|
||||
MessageTypes.isIncomingVideoCall(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.INCOMING_VIDEO_CALL)))
|
||||
}
|
||||
MessageTypes.isOutgoingAudioCall(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.OUTGOING_AUDIO_CALL)))
|
||||
}
|
||||
MessageTypes.isOutgoingVideoCall(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.OUTGOING_VIDEO_CALL)))
|
||||
}
|
||||
MessageTypes.isGroupCall(record.type) -> {
|
||||
try {
|
||||
val groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(record.body)
|
||||
|
||||
val joinedMembers = Stream.of(groupCallUpdateDetails.inCallUuids)
|
||||
.map { uuid: String? -> UuidUtil.parseOrNull(uuid) }
|
||||
.withoutNulls()
|
||||
.map { obj: UUID? -> ACI.from(obj!!).toByteString() }
|
||||
.toList()
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
callingMessage = CallChatUpdate(
|
||||
groupCall = GroupCallChatUpdate(
|
||||
startedCallAci = ACI.from(UuidUtil.parseOrThrow(groupCallUpdateDetails.startedCallUuid)).toByteString(),
|
||||
startedCallTimestamp = groupCallUpdateDetails.startedCallTimestamp,
|
||||
inCallAcis = joinedMembers
|
||||
)
|
||||
)
|
||||
)
|
||||
} catch (exception: java.lang.Exception) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
record.body == null -> {
|
||||
Log.w(TAG, "Record missing a body, skipping")
|
||||
continue
|
||||
}
|
||||
else -> builder.standardMessage = record.toTextMessage(reactionsById[id])
|
||||
}
|
||||
|
||||
buffer += builder.build()
|
||||
}
|
||||
|
||||
return if (buffer.isNotEmpty()) {
|
||||
buffer.remove()
|
||||
} else {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
|
||||
private fun String.e164ToLong(): Long? {
|
||||
val fixed = if (this.startsWith("+")) {
|
||||
this.substring(1)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
return fixed.toLongOrNull()
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toBasicChatItemBuilder(groupReceipts: List<GroupReceiptTable.GroupReceiptInfo>?): ChatItem.Builder {
|
||||
val record = this
|
||||
|
||||
return ChatItem.Builder().apply {
|
||||
chatId = record.threadId
|
||||
authorId = record.fromRecipientId
|
||||
dateSent = record.dateSent
|
||||
sealedSender = record.sealedSender
|
||||
expireStartDate = if (record.expireStarted > 0) record.expireStarted else null
|
||||
expiresInMs = if (record.expiresIn > 0) record.expiresIn else null
|
||||
revisions = emptyList()
|
||||
sms = !MessageTypes.isSecureType(record.type)
|
||||
|
||||
if (MessageTypes.isOutgoingMessageType(record.type)) {
|
||||
outgoing = ChatItem.OutgoingMessageDetails(
|
||||
sendStatus = record.toBackupSendStatus(groupReceipts)
|
||||
)
|
||||
} else {
|
||||
incoming = ChatItem.IncomingMessageDetails(
|
||||
dateServerSent = record.dateServer,
|
||||
dateReceived = record.dateReceived,
|
||||
read = record.read
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toTextMessage(reactionRecords: List<ReactionRecord>?): StandardMessage {
|
||||
return StandardMessage(
|
||||
quote = this.toQuote(),
|
||||
text = Text(
|
||||
body = this.body!!,
|
||||
bodyRanges = this.bodyRanges?.toBackupBodyRanges() ?: emptyList()
|
||||
),
|
||||
// TODO Link previews!
|
||||
linkPreview = emptyList(),
|
||||
longText = null,
|
||||
reactions = reactionRecords.toBackupReactions()
|
||||
)
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toQuote(): Quote? {
|
||||
return if (this.quoteTargetSentTimestamp != MessageTable.QUOTE_NOT_PRESENT_ID && this.quoteAuthor > 0) {
|
||||
// TODO Attachments!
|
||||
val type = QuoteModel.Type.fromCode(this.quoteType)
|
||||
Quote(
|
||||
targetSentTimestamp = this.quoteTargetSentTimestamp.takeIf { !this.quoteMissing && it != MessageTable.QUOTE_TARGET_MISSING_ID },
|
||||
authorId = this.quoteAuthor,
|
||||
text = this.quoteBody,
|
||||
bodyRanges = this.quoteBodyRanges?.toBackupBodyRanges() ?: emptyList(),
|
||||
type = when (type) {
|
||||
QuoteModel.Type.NORMAL -> Quote.Type.NORMAL
|
||||
QuoteModel.Type.GIFT_BADGE -> Quote.Type.GIFTBADGE
|
||||
}
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.toBackupBodyRanges(): List<BackupBodyRange> {
|
||||
val decoded: BodyRangeList = try {
|
||||
BodyRangeList.ADAPTER.decode(this)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to decode BodyRangeList!")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return decoded.ranges.map {
|
||||
BackupBodyRange(
|
||||
start = it.start,
|
||||
length = it.length,
|
||||
mentionAci = it.mentionUuid?.let { UuidUtil.parseOrThrow(it) }?.toByteArray()?.toByteString(),
|
||||
style = it.style?.toBackupBodyRangeStyle()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun BodyRangeList.BodyRange.Style.toBackupBodyRangeStyle(): BackupBodyRange.Style {
|
||||
return when (this) {
|
||||
BodyRangeList.BodyRange.Style.BOLD -> BackupBodyRange.Style.BOLD
|
||||
BodyRangeList.BodyRange.Style.ITALIC -> BackupBodyRange.Style.ITALIC
|
||||
BodyRangeList.BodyRange.Style.STRIKETHROUGH -> BackupBodyRange.Style.STRIKETHROUGH
|
||||
BodyRangeList.BodyRange.Style.MONOSPACE -> BackupBodyRange.Style.MONOSPACE
|
||||
BodyRangeList.BodyRange.Style.SPOILER -> BackupBodyRange.Style.SPOILER
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ReactionRecord>?.toBackupReactions(): List<Reaction> {
|
||||
return this
|
||||
?.map {
|
||||
Reaction(
|
||||
emoji = it.emoji,
|
||||
authorId = it.author.toLong(),
|
||||
sentTimestamp = it.dateSent,
|
||||
receivedTimestamp = it.dateReceived
|
||||
)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toBackupSendStatus(groupReceipts: List<GroupReceiptTable.GroupReceiptInfo>?): List<SendStatus> {
|
||||
if (!MessageTypes.isOutgoingMessageType(this.type)) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
if (!groupReceipts.isNullOrEmpty()) {
|
||||
return groupReceipts.toBackupSendStatus(this.networkFailureRecipientIds, this.identityMismatchRecipientIds)
|
||||
}
|
||||
|
||||
val status: SendStatus.Status = when {
|
||||
this.viewed -> SendStatus.Status.VIEWED
|
||||
this.hasReadReceipt -> SendStatus.Status.READ
|
||||
this.hasDeliveryReceipt -> SendStatus.Status.DELIVERED
|
||||
this.baseType == MessageTypes.BASE_SENT_TYPE -> SendStatus.Status.SENT
|
||||
MessageTypes.isFailedMessageType(this.type) -> SendStatus.Status.FAILED
|
||||
else -> SendStatus.Status.PENDING
|
||||
}
|
||||
|
||||
return listOf(
|
||||
SendStatus(
|
||||
recipientId = this.toRecipientId,
|
||||
deliveryStatus = status,
|
||||
lastStatusUpdateTimestamp = this.receiptTimestamp,
|
||||
sealedSender = this.sealedSender,
|
||||
networkFailure = this.networkFailureRecipientIds.contains(this.toRecipientId),
|
||||
identityKeyMismatch = this.identityMismatchRecipientIds.contains(this.toRecipientId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<GroupReceiptTable.GroupReceiptInfo>.toBackupSendStatus(networkFailureRecipientIds: Set<Long>, identityMismatchRecipientIds: Set<Long>): List<SendStatus> {
|
||||
return this.map {
|
||||
SendStatus(
|
||||
recipientId = it.recipientId.toLong(),
|
||||
deliveryStatus = it.status.toBackupDeliveryStatus(),
|
||||
sealedSender = it.isUnidentified,
|
||||
lastStatusUpdateTimestamp = it.timestamp,
|
||||
networkFailure = networkFailureRecipientIds.contains(it.recipientId.toLong()),
|
||||
identityKeyMismatch = identityMismatchRecipientIds.contains(it.recipientId.toLong())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.toBackupDeliveryStatus(): SendStatus.Status {
|
||||
return when (this) {
|
||||
GroupReceiptTable.STATUS_UNDELIVERED -> SendStatus.Status.PENDING
|
||||
GroupReceiptTable.STATUS_DELIVERED -> SendStatus.Status.DELIVERED
|
||||
GroupReceiptTable.STATUS_READ -> SendStatus.Status.READ
|
||||
GroupReceiptTable.STATUS_VIEWED -> SendStatus.Status.VIEWED
|
||||
GroupReceiptTable.STATUS_SKIPPED -> SendStatus.Status.SKIPPED
|
||||
else -> SendStatus.Status.SKIPPED
|
||||
}
|
||||
}
|
||||
|
||||
private fun String?.parseNetworkFailures(): Set<Long> {
|
||||
if (this.isNullOrBlank()) {
|
||||
return emptySet()
|
||||
}
|
||||
|
||||
return try {
|
||||
JsonUtils.fromJson(this, NetworkFailureSet::class.java).items.map { it.recipientId.toLong() }.toSet()
|
||||
} catch (e: IOException) {
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String?.parseIdentityMismatches(): Set<Long> {
|
||||
if (this.isNullOrBlank()) {
|
||||
return emptySet()
|
||||
}
|
||||
|
||||
return try {
|
||||
JsonUtils.fromJson(this, IdentityKeyMismatchSet::class.java).items.map { it.recipientId.toLong() }.toSet()
|
||||
} catch (e: IOException) {
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Cursor.toBackupMessageRecord(): BackupMessageRecord {
|
||||
return BackupMessageRecord(
|
||||
id = this.requireLong(MessageTable.ID),
|
||||
dateSent = this.requireLong(MessageTable.DATE_SENT),
|
||||
dateReceived = this.requireLong(MessageTable.DATE_RECEIVED),
|
||||
dateServer = this.requireLong(MessageTable.DATE_SERVER),
|
||||
type = this.requireLong(MessageTable.TYPE),
|
||||
threadId = this.requireLong(MessageTable.THREAD_ID),
|
||||
body = this.requireString(MessageTable.BODY),
|
||||
bodyRanges = this.requireBlob(MessageTable.MESSAGE_RANGES),
|
||||
fromRecipientId = this.requireLong(MessageTable.FROM_RECIPIENT_ID),
|
||||
toRecipientId = this.requireLong(MessageTable.TO_RECIPIENT_ID),
|
||||
expiresIn = this.requireLong(MessageTable.EXPIRES_IN),
|
||||
expireStarted = this.requireLong(MessageTable.EXPIRE_STARTED),
|
||||
remoteDeleted = this.requireBoolean(MessageTable.REMOTE_DELETED),
|
||||
sealedSender = this.requireBoolean(MessageTable.UNIDENTIFIED),
|
||||
quoteTargetSentTimestamp = this.requireLong(MessageTable.QUOTE_ID),
|
||||
quoteAuthor = this.requireLong(MessageTable.QUOTE_AUTHOR),
|
||||
quoteBody = this.requireString(MessageTable.QUOTE_BODY),
|
||||
quoteMissing = this.requireBoolean(MessageTable.QUOTE_MISSING),
|
||||
quoteBodyRanges = this.requireBlob(MessageTable.QUOTE_BODY_RANGES),
|
||||
quoteType = this.requireInt(MessageTable.QUOTE_TYPE),
|
||||
originalMessageId = this.requireLong(MessageTable.ORIGINAL_MESSAGE_ID),
|
||||
latestRevisionId = this.requireLong(MessageTable.LATEST_REVISION_ID),
|
||||
hasDeliveryReceipt = this.requireBoolean(MessageTable.HAS_DELIVERY_RECEIPT),
|
||||
viewed = this.requireBoolean(MessageTable.VIEWED_COLUMN),
|
||||
hasReadReceipt = this.requireBoolean(MessageTable.HAS_READ_RECEIPT),
|
||||
read = this.requireBoolean(MessageTable.READ),
|
||||
receiptTimestamp = this.requireLong(MessageTable.RECEIPT_TIMESTAMP),
|
||||
networkFailureRecipientIds = this.requireString(MessageTable.NETWORK_FAILURES).parseNetworkFailures(),
|
||||
identityMismatchRecipientIds = this.requireString(MessageTable.MISMATCHED_IDENTITIES).parseIdentityMismatches(),
|
||||
baseType = this.requireLong(COLUMN_BASE_TYPE)
|
||||
)
|
||||
}
|
||||
|
||||
private class BackupMessageRecord(
|
||||
val id: Long,
|
||||
val dateSent: Long,
|
||||
val dateReceived: Long,
|
||||
val dateServer: Long,
|
||||
val type: Long,
|
||||
val threadId: Long,
|
||||
val body: String?,
|
||||
val bodyRanges: ByteArray?,
|
||||
val fromRecipientId: Long,
|
||||
val toRecipientId: Long,
|
||||
val expiresIn: Long,
|
||||
val expireStarted: Long,
|
||||
val remoteDeleted: Boolean,
|
||||
val sealedSender: Boolean,
|
||||
val quoteTargetSentTimestamp: Long,
|
||||
val quoteAuthor: Long,
|
||||
val quoteBody: String?,
|
||||
val quoteMissing: Boolean,
|
||||
val quoteBodyRanges: ByteArray?,
|
||||
val quoteType: Int,
|
||||
val originalMessageId: Long,
|
||||
val latestRevisionId: Long,
|
||||
val hasDeliveryReceipt: Boolean,
|
||||
val hasReadReceipt: Boolean,
|
||||
val viewed: Boolean,
|
||||
val receiptTimestamp: Long,
|
||||
val read: Boolean,
|
||||
val networkFailureRecipientIds: Set<Long>,
|
||||
val identityMismatchRecipientIds: Set<Long>,
|
||||
val baseType: Long
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import androidx.core.content.contentValuesOf
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.toInt
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.BodyRange
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCallChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Quote
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Reaction
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.SendStatus
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.SimpleChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptTable
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.MessageTypes
|
||||
import org.thoughtcrime.securesms.database.ReactionTable
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet
|
||||
import org.thoughtcrime.securesms.database.documents.NetworkFailure
|
||||
import org.thoughtcrime.securesms.database.documents.NetworkFailureSet
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
|
||||
/**
|
||||
* An object that will ingest all fo the [ChatItem]s you want to write, buffer them until hitting a specified batch size, and then batch insert them
|
||||
* for fast throughput.
|
||||
*/
|
||||
class ChatItemImportInserter(
|
||||
private val db: SQLiteDatabase,
|
||||
private val backupState: BackupState,
|
||||
private val batchSize: Int
|
||||
) {
|
||||
companion object {
|
||||
private val TAG = Log.tag(ChatItemImportInserter::class.java)
|
||||
|
||||
private val MESSAGE_COLUMNS = arrayOf(
|
||||
MessageTable.DATE_SENT,
|
||||
MessageTable.DATE_RECEIVED,
|
||||
MessageTable.DATE_SERVER,
|
||||
MessageTable.TYPE,
|
||||
MessageTable.THREAD_ID,
|
||||
MessageTable.READ,
|
||||
MessageTable.BODY,
|
||||
MessageTable.FROM_RECIPIENT_ID,
|
||||
MessageTable.TO_RECIPIENT_ID,
|
||||
MessageTable.HAS_DELIVERY_RECEIPT,
|
||||
MessageTable.HAS_READ_RECEIPT,
|
||||
MessageTable.VIEWED_COLUMN,
|
||||
MessageTable.MISMATCHED_IDENTITIES,
|
||||
MessageTable.EXPIRES_IN,
|
||||
MessageTable.EXPIRE_STARTED,
|
||||
MessageTable.UNIDENTIFIED,
|
||||
MessageTable.REMOTE_DELETED,
|
||||
MessageTable.REMOTE_DELETED,
|
||||
MessageTable.NETWORK_FAILURES,
|
||||
MessageTable.QUOTE_ID,
|
||||
MessageTable.QUOTE_AUTHOR,
|
||||
MessageTable.QUOTE_BODY,
|
||||
MessageTable.QUOTE_MISSING,
|
||||
MessageTable.QUOTE_BODY_RANGES,
|
||||
MessageTable.QUOTE_TYPE,
|
||||
MessageTable.SHARED_CONTACTS,
|
||||
MessageTable.LINK_PREVIEWS,
|
||||
MessageTable.MESSAGE_RANGES,
|
||||
MessageTable.VIEW_ONCE
|
||||
)
|
||||
|
||||
private val REACTION_COLUMNS = arrayOf(
|
||||
ReactionTable.MESSAGE_ID,
|
||||
ReactionTable.AUTHOR_ID,
|
||||
ReactionTable.EMOJI,
|
||||
ReactionTable.DATE_SENT,
|
||||
ReactionTable.DATE_RECEIVED
|
||||
)
|
||||
|
||||
private val GROUP_RECEIPT_COLUMNS = arrayOf(
|
||||
GroupReceiptTable.MMS_ID,
|
||||
GroupReceiptTable.RECIPIENT_ID,
|
||||
GroupReceiptTable.STATUS,
|
||||
GroupReceiptTable.TIMESTAMP,
|
||||
GroupReceiptTable.UNIDENTIFIED
|
||||
)
|
||||
}
|
||||
|
||||
private val selfId = Recipient.self().id
|
||||
private val buffer: Buffer = Buffer()
|
||||
private var messageId: Long = SqlUtil.getNextAutoIncrementId(db, MessageTable.TABLE_NAME)
|
||||
|
||||
/**
|
||||
* Indicate that you want to insert the [ChatItem] into the database.
|
||||
* If this item causes the buffer to hit the batch size, then a batch of items will actually be inserted.
|
||||
*/
|
||||
fun insert(chatItem: ChatItem) {
|
||||
val fromLocalRecipientId: RecipientId? = backupState.backupToLocalRecipientId[chatItem.authorId]
|
||||
if (fromLocalRecipientId == null) {
|
||||
Log.w(TAG, "[insert] Could not find a local recipient for backup recipient ID ${chatItem.authorId}! Skipping.")
|
||||
return
|
||||
}
|
||||
|
||||
val chatLocalRecipientId: RecipientId? = backupState.chatIdToLocalRecipientId[chatItem.chatId]
|
||||
if (chatLocalRecipientId == null) {
|
||||
Log.w(TAG, "[insert] Could not find a local recipient for chatId ${chatItem.chatId}! Skipping.")
|
||||
return
|
||||
}
|
||||
|
||||
val localThreadId: Long? = backupState.chatIdToLocalThreadId[chatItem.chatId]
|
||||
if (localThreadId == null) {
|
||||
Log.w(TAG, "[insert] Could not find a local threadId for backup chatId ${chatItem.chatId}! Skipping.")
|
||||
return
|
||||
}
|
||||
|
||||
val chatBackupRecipientId: Long? = backupState.chatIdToBackupRecipientId[chatItem.chatId]
|
||||
if (chatBackupRecipientId == null) {
|
||||
Log.w(TAG, "[insert] Could not find a backup recipientId for backup chatId ${chatItem.chatId}! Skipping.")
|
||||
return
|
||||
}
|
||||
|
||||
buffer.messages += chatItem.toMessageInsert(fromLocalRecipientId, chatLocalRecipientId, localThreadId)
|
||||
buffer.reactions += chatItem.toReactionContentValues(messageId)
|
||||
buffer.groupReceipts += chatItem.toGroupReceiptContentValues(messageId, chatBackupRecipientId)
|
||||
|
||||
messageId++
|
||||
|
||||
if (buffer.size >= batchSize) {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if something was written to the db, otherwise false. */
|
||||
fun flush(): Boolean {
|
||||
if (buffer.size == 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
buildBulkInsert(MessageTable.TABLE_NAME, MESSAGE_COLUMNS, buffer.messages).forEach {
|
||||
db.rawQuery("${it.query.where} RETURNING ${MessageTable.ID}", it.query.whereArgs).use { cursor ->
|
||||
var index = 0
|
||||
while (cursor.moveToNext()) {
|
||||
val rowId = cursor.requireLong(MessageTable.ID)
|
||||
val followup = it.inserts[index].followUp
|
||||
if (followup != null) {
|
||||
followup(rowId)
|
||||
}
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SqlUtil.buildBulkInsert(ReactionTable.TABLE_NAME, REACTION_COLUMNS, buffer.reactions).forEach {
|
||||
db.execSQL(it.where, it.whereArgs)
|
||||
}
|
||||
|
||||
SqlUtil.buildBulkInsert(GroupReceiptTable.TABLE_NAME, GROUP_RECEIPT_COLUMNS, buffer.groupReceipts).forEach {
|
||||
db.execSQL(it.where, it.whereArgs)
|
||||
}
|
||||
|
||||
messageId = SqlUtil.getNextAutoIncrementId(db, MessageTable.TABLE_NAME)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun buildBulkInsert(tableName: String, columns: Array<String>, messageInserts: List<MessageInsert>, maxQueryArgs: Int = 999): List<BatchInsert> {
|
||||
val batchSize = maxQueryArgs / columns.size
|
||||
|
||||
return messageInserts
|
||||
.chunked(batchSize)
|
||||
.map { batch: List<MessageInsert> -> BatchInsert(batch, SqlUtil.buildSingleBulkInsert(tableName, columns, batch.map { it.contentValues })) }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun ChatItem.toMessageInsert(fromRecipientId: RecipientId, chatRecipientId: RecipientId, threadId: Long): MessageInsert {
|
||||
val contentValues = this.toMessageContentValues(fromRecipientId, chatRecipientId, threadId)
|
||||
|
||||
var followUp: ((Long) -> Unit)? = null
|
||||
if (this.updateMessage != null) {
|
||||
if (this.updateMessage.callingMessage != null && this.updateMessage.callingMessage.callId != null) {
|
||||
followUp = { messageRowId ->
|
||||
val callContentValues = ContentValues()
|
||||
callContentValues.put(CallTable.MESSAGE_ID, messageRowId)
|
||||
db.update(CallTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, callContentValues, "${CallTable.CALL_ID} = ?", SqlUtil.buildArgs(this.updateMessage.callingMessage.callId))
|
||||
}
|
||||
}
|
||||
}
|
||||
return MessageInsert(contentValues, followUp)
|
||||
}
|
||||
|
||||
private class BatchInsert(val inserts: List<MessageInsert>, val query: SqlUtil.Query)
|
||||
|
||||
private fun ChatItem.toMessageContentValues(fromRecipientId: RecipientId, chatRecipientId: RecipientId, threadId: Long): ContentValues {
|
||||
val contentValues = ContentValues()
|
||||
|
||||
contentValues.put(MessageTable.TYPE, this.getMessageType())
|
||||
contentValues.put(MessageTable.DATE_SENT, this.dateSent)
|
||||
contentValues.put(MessageTable.DATE_SERVER, this.incoming?.dateServerSent ?: -1)
|
||||
contentValues.put(MessageTable.FROM_RECIPIENT_ID, fromRecipientId.serialize())
|
||||
contentValues.put(MessageTable.TO_RECIPIENT_ID, (if (this.outgoing != null) chatRecipientId else selfId).serialize())
|
||||
contentValues.put(MessageTable.THREAD_ID, threadId)
|
||||
contentValues.put(MessageTable.DATE_RECEIVED, this.incoming?.dateReceived ?: this.dateSent)
|
||||
contentValues.put(MessageTable.RECEIPT_TIMESTAMP, this.outgoing?.sendStatus?.maxOf { it.lastStatusUpdateTimestamp } ?: 0)
|
||||
contentValues.putNull(MessageTable.LATEST_REVISION_ID)
|
||||
contentValues.putNull(MessageTable.ORIGINAL_MESSAGE_ID)
|
||||
contentValues.put(MessageTable.REVISION_NUMBER, 0)
|
||||
contentValues.put(MessageTable.EXPIRES_IN, this.expiresInMs ?: 0)
|
||||
contentValues.put(MessageTable.EXPIRE_STARTED, this.expireStartDate ?: 0)
|
||||
|
||||
if (this.outgoing != null) {
|
||||
val viewed = this.outgoing.sendStatus.any { it.deliveryStatus == SendStatus.Status.VIEWED }
|
||||
val hasReadReceipt = viewed || this.outgoing.sendStatus.any { it.deliveryStatus == SendStatus.Status.READ }
|
||||
val hasDeliveryReceipt = viewed || hasReadReceipt || this.outgoing.sendStatus.any { it.deliveryStatus == SendStatus.Status.DELIVERED }
|
||||
|
||||
contentValues.put(MessageTable.VIEWED_COLUMN, viewed.toInt())
|
||||
contentValues.put(MessageTable.HAS_READ_RECEIPT, hasReadReceipt.toInt())
|
||||
contentValues.put(MessageTable.HAS_DELIVERY_RECEIPT, hasDeliveryReceipt.toInt())
|
||||
contentValues.put(MessageTable.UNIDENTIFIED, this.outgoing.sendStatus.count { it.sealedSender })
|
||||
contentValues.put(MessageTable.READ, 1)
|
||||
|
||||
contentValues.addNetworkFailures(this, backupState)
|
||||
contentValues.addIdentityKeyMismatches(this, backupState)
|
||||
} else {
|
||||
contentValues.put(MessageTable.VIEWED_COLUMN, 0)
|
||||
contentValues.put(MessageTable.HAS_READ_RECEIPT, 0)
|
||||
contentValues.put(MessageTable.HAS_DELIVERY_RECEIPT, 0)
|
||||
contentValues.put(MessageTable.UNIDENTIFIED, this.sealedSender?.toInt())
|
||||
contentValues.put(MessageTable.READ, this.incoming?.read?.toInt() ?: 0)
|
||||
}
|
||||
|
||||
contentValues.put(MessageTable.QUOTE_ID, 0)
|
||||
contentValues.put(MessageTable.QUOTE_AUTHOR, 0)
|
||||
contentValues.put(MessageTable.QUOTE_MISSING, 0)
|
||||
contentValues.put(MessageTable.QUOTE_TYPE, 0)
|
||||
contentValues.put(MessageTable.VIEW_ONCE, 0)
|
||||
contentValues.put(MessageTable.REMOTE_DELETED, 0)
|
||||
|
||||
when {
|
||||
this.standardMessage != null -> contentValues.addStandardMessage(this.standardMessage)
|
||||
this.remoteDeletedMessage != null -> contentValues.put(MessageTable.REMOTE_DELETED, 1)
|
||||
this.updateMessage != null -> contentValues.addUpdateMessage(this.updateMessage)
|
||||
}
|
||||
|
||||
return contentValues
|
||||
}
|
||||
|
||||
private fun ChatItem.toReactionContentValues(messageId: Long): List<ContentValues> {
|
||||
val reactions: List<Reaction> = when {
|
||||
this.standardMessage != null -> this.standardMessage.reactions
|
||||
this.contactMessage != null -> this.contactMessage.reactions
|
||||
this.voiceMessage != null -> this.voiceMessage.reactions
|
||||
this.stickerMessage != null -> this.stickerMessage.reactions
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
return reactions
|
||||
.mapNotNull {
|
||||
val authorId: Long? = backupState.backupToLocalRecipientId[it.authorId]?.toLong()
|
||||
|
||||
if (authorId != null) {
|
||||
contentValuesOf(
|
||||
ReactionTable.MESSAGE_ID to messageId,
|
||||
ReactionTable.AUTHOR_ID to authorId,
|
||||
ReactionTable.DATE_SENT to it.sentTimestamp,
|
||||
ReactionTable.DATE_RECEIVED to it.receivedTimestamp,
|
||||
ReactionTable.EMOJI to it.emoji
|
||||
)
|
||||
} else {
|
||||
Log.w(TAG, "[Reaction] Could not find a local recipient for backup recipient ID ${it.authorId}! Skipping.")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ChatItem.toGroupReceiptContentValues(messageId: Long, chatBackupRecipientId: Long): List<ContentValues> {
|
||||
if (this.outgoing == null) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// TODO This seems like an indirect/bad way to detect if this is a 1:1 or group convo
|
||||
if (this.outgoing.sendStatus.size == 1 && this.outgoing.sendStatus[0].recipientId == chatBackupRecipientId) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return this.outgoing.sendStatus.mapNotNull { sendStatus ->
|
||||
val recipientId = backupState.backupToLocalRecipientId[sendStatus.recipientId]
|
||||
|
||||
if (recipientId != null) {
|
||||
contentValuesOf(
|
||||
GroupReceiptTable.MMS_ID to messageId,
|
||||
GroupReceiptTable.RECIPIENT_ID to recipientId.serialize(),
|
||||
GroupReceiptTable.STATUS to sendStatus.deliveryStatus.toLocalSendStatus(),
|
||||
GroupReceiptTable.TIMESTAMP to sendStatus.lastStatusUpdateTimestamp,
|
||||
GroupReceiptTable.UNIDENTIFIED to sendStatus.sealedSender
|
||||
)
|
||||
} else {
|
||||
Log.w(TAG, "[GroupReceipts] Could not find a local recipient for backup recipient ID ${sendStatus.recipientId}! Skipping.")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ChatItem.getMessageType(): Long {
|
||||
var type: Long = if (this.outgoing != null) {
|
||||
if (this.outgoing.sendStatus.count { it.identityKeyMismatch } > 0) {
|
||||
MessageTypes.BASE_SENT_FAILED_TYPE
|
||||
} else if (this.outgoing.sendStatus.count { it.networkFailure } > 0) {
|
||||
MessageTypes.BASE_SENDING_TYPE
|
||||
} else {
|
||||
MessageTypes.BASE_SENT_TYPE
|
||||
}
|
||||
} else {
|
||||
MessageTypes.BASE_INBOX_TYPE
|
||||
}
|
||||
|
||||
if (!this.sms) {
|
||||
type = type or MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT
|
||||
}
|
||||
|
||||
return type
|
||||
}
|
||||
|
||||
private fun ContentValues.addStandardMessage(standardMessage: StandardMessage) {
|
||||
if (standardMessage.text != null) {
|
||||
this.put(MessageTable.BODY, standardMessage.text.body)
|
||||
|
||||
if (standardMessage.text.bodyRanges.isNotEmpty()) {
|
||||
this.put(MessageTable.MESSAGE_RANGES, standardMessage.text.bodyRanges.toLocalBodyRanges()?.encode() as ByteArray?)
|
||||
}
|
||||
}
|
||||
|
||||
if (standardMessage.quote != null) {
|
||||
this.addQuote(standardMessage.quote)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ContentValues.addUpdateMessage(updateMessage: ChatUpdateMessage) {
|
||||
var typeFlags: Long = 0
|
||||
when {
|
||||
updateMessage.simpleUpdate != null -> {
|
||||
typeFlags = when (updateMessage.simpleUpdate.type) {
|
||||
SimpleChatUpdate.Type.UNKNOWN -> 0
|
||||
SimpleChatUpdate.Type.JOINED_SIGNAL -> MessageTypes.JOINED_TYPE
|
||||
SimpleChatUpdate.Type.IDENTITY_UPDATE -> MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT
|
||||
SimpleChatUpdate.Type.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT
|
||||
SimpleChatUpdate.Type.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT
|
||||
SimpleChatUpdate.Type.CHANGE_NUMBER -> MessageTypes.CHANGE_NUMBER_TYPE
|
||||
SimpleChatUpdate.Type.BOOST_REQUEST -> MessageTypes.BOOST_REQUEST_TYPE
|
||||
SimpleChatUpdate.Type.END_SESSION -> MessageTypes.END_SESSION_BIT
|
||||
SimpleChatUpdate.Type.CHAT_SESSION_REFRESH -> MessageTypes.ENCRYPTION_REMOTE_FAILED_BIT
|
||||
SimpleChatUpdate.Type.BAD_DECRYPT -> MessageTypes.BAD_DECRYPT_TYPE
|
||||
SimpleChatUpdate.Type.PAYMENTS_ACTIVATED -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED
|
||||
SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST
|
||||
}
|
||||
}
|
||||
updateMessage.expirationTimerChange != null -> {
|
||||
typeFlags = MessageTypes.EXPIRATION_TIMER_UPDATE_BIT
|
||||
put(MessageTable.EXPIRES_IN, updateMessage.expirationTimerChange.expiresInMs.toLong())
|
||||
}
|
||||
updateMessage.profileChange != null -> {
|
||||
typeFlags = MessageTypes.PROFILE_CHANGE_TYPE
|
||||
val profileChangeDetails = ProfileChangeDetails(profileNameChange = ProfileChangeDetails.StringChange(previous = updateMessage.profileChange.previousName, newValue = updateMessage.profileChange.newName))
|
||||
.encode()
|
||||
put(MessageTable.BODY, Base64.encodeWithPadding(profileChangeDetails))
|
||||
}
|
||||
updateMessage.sessionSwitchover != null -> {
|
||||
typeFlags = MessageTypes.SESSION_SWITCHOVER_TYPE
|
||||
val sessionSwitchoverDetails = SessionSwitchoverEvent(e164 = updateMessage.sessionSwitchover.e164.toString()).encode()
|
||||
put(MessageTable.BODY, Base64.encodeWithPadding(sessionSwitchoverDetails))
|
||||
}
|
||||
updateMessage.threadMerge != null -> {
|
||||
typeFlags = MessageTypes.THREAD_MERGE_TYPE
|
||||
val threadMergeDetails = ThreadMergeEvent(previousE164 = updateMessage.threadMerge.previousE164.toString()).encode()
|
||||
put(MessageTable.BODY, Base64.encodeWithPadding(threadMergeDetails))
|
||||
}
|
||||
updateMessage.callingMessage != null -> {
|
||||
when {
|
||||
updateMessage.callingMessage.callId != null -> {
|
||||
typeFlags = backupState.callIdToType[updateMessage.callingMessage.callId]!!
|
||||
}
|
||||
updateMessage.callingMessage.callMessage != null -> {
|
||||
typeFlags = when (updateMessage.callingMessage.callMessage.type) {
|
||||
IndividualCallChatUpdate.Type.INCOMING_AUDIO_CALL -> MessageTypes.INCOMING_AUDIO_CALL_TYPE
|
||||
IndividualCallChatUpdate.Type.INCOMING_VIDEO_CALL -> MessageTypes.INCOMING_VIDEO_CALL_TYPE
|
||||
IndividualCallChatUpdate.Type.OUTGOING_AUDIO_CALL -> MessageTypes.OUTGOING_AUDIO_CALL_TYPE
|
||||
IndividualCallChatUpdate.Type.OUTGOING_VIDEO_CALL -> MessageTypes.OUTGOING_VIDEO_CALL_TYPE
|
||||
IndividualCallChatUpdate.Type.MISSED_AUDIO_CALL -> MessageTypes.MISSED_AUDIO_CALL_TYPE
|
||||
IndividualCallChatUpdate.Type.MISSED_VIDEO_CALL -> MessageTypes.MISSED_VIDEO_CALL_TYPE
|
||||
IndividualCallChatUpdate.Type.UNKNOWN -> typeFlags
|
||||
}
|
||||
}
|
||||
}
|
||||
// Calls don't use the incoming/outgoing flags, so we overwrite the flags here
|
||||
this.put(MessageTable.TYPE, typeFlags)
|
||||
}
|
||||
}
|
||||
this.put(MessageTable.TYPE, getAsLong(MessageTable.TYPE) or typeFlags)
|
||||
}
|
||||
|
||||
private fun ContentValues.addQuote(quote: Quote) {
|
||||
this.put(MessageTable.QUOTE_ID, quote.targetSentTimestamp ?: MessageTable.QUOTE_TARGET_MISSING_ID)
|
||||
this.put(MessageTable.QUOTE_AUTHOR, backupState.backupToLocalRecipientId[quote.authorId]!!.serialize())
|
||||
this.put(MessageTable.QUOTE_BODY, quote.text)
|
||||
this.put(MessageTable.QUOTE_TYPE, quote.type.toLocalQuoteType())
|
||||
this.put(MessageTable.QUOTE_BODY_RANGES, quote.bodyRanges.toLocalBodyRanges()?.encode())
|
||||
// TODO quote attachments
|
||||
this.put(MessageTable.QUOTE_MISSING, (quote.targetSentTimestamp == null).toInt())
|
||||
}
|
||||
|
||||
private fun Quote.Type.toLocalQuoteType(): Int {
|
||||
return when (this) {
|
||||
Quote.Type.UNKNOWN -> QuoteModel.Type.NORMAL.code
|
||||
Quote.Type.NORMAL -> QuoteModel.Type.NORMAL.code
|
||||
Quote.Type.GIFTBADGE -> QuoteModel.Type.GIFT_BADGE.code
|
||||
}
|
||||
}
|
||||
|
||||
private fun ContentValues.addNetworkFailures(chatItem: ChatItem, backupState: BackupState) {
|
||||
if (chatItem.outgoing == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val networkFailures = chatItem.outgoing.sendStatus
|
||||
.filter { status -> status.networkFailure }
|
||||
.mapNotNull { status -> backupState.backupToLocalRecipientId[status.recipientId] }
|
||||
.map { recipientId -> NetworkFailure(recipientId) }
|
||||
.toSet()
|
||||
|
||||
if (networkFailures.isNotEmpty()) {
|
||||
this.put(MessageTable.NETWORK_FAILURES, JsonUtils.toJson(NetworkFailureSet(networkFailures)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun ContentValues.addIdentityKeyMismatches(chatItem: ChatItem, backupState: BackupState) {
|
||||
if (chatItem.outgoing == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val mismatches = chatItem.outgoing.sendStatus
|
||||
.filter { status -> status.identityKeyMismatch }
|
||||
.mapNotNull { status -> backupState.backupToLocalRecipientId[status.recipientId] }
|
||||
.map { recipientId -> IdentityKeyMismatch(recipientId, null) } // TODO We probably want the actual identity key in this status situation?
|
||||
.toSet()
|
||||
|
||||
if (mismatches.isNotEmpty()) {
|
||||
this.put(MessageTable.MISMATCHED_IDENTITIES, JsonUtils.toJson(IdentityKeyMismatchSet(mismatches)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<BodyRange>.toLocalBodyRanges(): BodyRangeList? {
|
||||
if (this.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return BodyRangeList(
|
||||
ranges = this.map { bodyRange ->
|
||||
BodyRangeList.BodyRange(
|
||||
mentionUuid = bodyRange.mentionAci?.let { UuidUtil.fromByteString(it) }?.toString(),
|
||||
style = bodyRange.style?.let {
|
||||
when (bodyRange.style) {
|
||||
BodyRange.Style.BOLD -> BodyRangeList.BodyRange.Style.BOLD
|
||||
BodyRange.Style.ITALIC -> BodyRangeList.BodyRange.Style.ITALIC
|
||||
BodyRange.Style.MONOSPACE -> BodyRangeList.BodyRange.Style.MONOSPACE
|
||||
BodyRange.Style.SPOILER -> BodyRangeList.BodyRange.Style.SPOILER
|
||||
BodyRange.Style.STRIKETHROUGH -> BodyRangeList.BodyRange.Style.STRIKETHROUGH
|
||||
else -> null
|
||||
}
|
||||
},
|
||||
start = bodyRange.start ?: 0,
|
||||
length = bodyRange.length ?: 0
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun SendStatus.Status.toLocalSendStatus(): Int {
|
||||
return when (this) {
|
||||
SendStatus.Status.UNKNOWN -> GroupReceiptTable.STATUS_UNKNOWN
|
||||
SendStatus.Status.FAILED -> GroupReceiptTable.STATUS_UNKNOWN
|
||||
SendStatus.Status.PENDING -> GroupReceiptTable.STATUS_UNDELIVERED
|
||||
SendStatus.Status.SENT -> GroupReceiptTable.STATUS_UNDELIVERED
|
||||
SendStatus.Status.DELIVERED -> GroupReceiptTable.STATUS_DELIVERED
|
||||
SendStatus.Status.READ -> GroupReceiptTable.STATUS_READ
|
||||
SendStatus.Status.VIEWED -> GroupReceiptTable.STATUS_VIEWED
|
||||
SendStatus.Status.SKIPPED -> GroupReceiptTable.STATUS_SKIPPED
|
||||
}
|
||||
}
|
||||
|
||||
private class MessageInsert(val contentValues: ContentValues, val followUp: ((Long) -> Unit)?)
|
||||
|
||||
private class Buffer(
|
||||
val messages: MutableList<MessageInsert> = mutableListOf(),
|
||||
val reactions: MutableList<ContentValues> = mutableListOf(),
|
||||
val groupReceipts: MutableList<ContentValues> = mutableListOf()
|
||||
) {
|
||||
val size: Int
|
||||
get() = listOf(messages.size, reactions.size, groupReceipts.size).max()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.CursorUtil
|
||||
import org.signal.core.util.delete
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.requireObject
|
||||
import org.signal.core.util.select
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupState
|
||||
import org.thoughtcrime.securesms.database.DistributionListTables
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListRecord
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import org.whispersystems.signalservice.api.util.toByteArray
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.DistributionList as BackupDistributionList
|
||||
|
||||
private val TAG = Log.tag(DistributionListTables::class.java)
|
||||
|
||||
fun DistributionListTables.getAllForBackup(): List<BackupRecipient> {
|
||||
val records = readableDatabase
|
||||
.select()
|
||||
.from(DistributionListTables.ListTable.TABLE_NAME)
|
||||
.run()
|
||||
.readToList { cursor ->
|
||||
val id: DistributionListId = DistributionListId.from(cursor.requireLong(DistributionListTables.ListTable.ID))
|
||||
val privacyMode: DistributionListPrivacyMode = cursor.requireObject(DistributionListTables.ListTable.PRIVACY_MODE, DistributionListPrivacyMode.Serializer)
|
||||
|
||||
DistributionListRecord(
|
||||
id = id,
|
||||
name = cursor.requireNonNullString(DistributionListTables.ListTable.NAME),
|
||||
distributionId = DistributionId.from(cursor.requireNonNullString(DistributionListTables.ListTable.DISTRIBUTION_ID)),
|
||||
allowsReplies = CursorUtil.requireBoolean(cursor, DistributionListTables.ListTable.ALLOWS_REPLIES),
|
||||
rawMembers = getRawMembers(id, privacyMode),
|
||||
members = getMembers(id),
|
||||
deletedAtTimestamp = 0L,
|
||||
isUnknown = CursorUtil.requireBoolean(cursor, DistributionListTables.ListTable.IS_UNKNOWN),
|
||||
privacyMode = privacyMode
|
||||
)
|
||||
}
|
||||
|
||||
return records
|
||||
.map { record ->
|
||||
BackupRecipient(
|
||||
distributionList = BackupDistributionList(
|
||||
name = record.name,
|
||||
distributionId = record.distributionId.asUuid().toByteArray().toByteString(),
|
||||
allowReplies = record.allowsReplies,
|
||||
deletionTimestamp = record.deletedAtTimestamp,
|
||||
privacyMode = record.privacyMode.toBackupPrivacyMode(),
|
||||
memberRecipientIds = record.members.map { it.toLong() }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun DistributionListTables.restoreFromBackup(dlist: BackupDistributionList, backupState: BackupState): RecipientId {
|
||||
val members: List<RecipientId> = dlist.memberRecipientIds
|
||||
.mapNotNull { backupState.backupToLocalRecipientId[it] }
|
||||
|
||||
if (members.size != dlist.memberRecipientIds.size) {
|
||||
Log.w(TAG, "Couldn't find some member recipients! Missing backup recipientIds: ${dlist.memberRecipientIds.toSet() - members.toSet()}")
|
||||
}
|
||||
|
||||
val dlistId = this.createList(
|
||||
name = dlist.name,
|
||||
members = members,
|
||||
distributionId = DistributionId.from(UuidUtil.fromByteString(dlist.distributionId)),
|
||||
allowsReplies = dlist.allowReplies,
|
||||
deletionTimestamp = dlist.deletionTimestamp,
|
||||
storageId = null,
|
||||
privacyMode = dlist.privacyMode.toLocalPrivacyMode()
|
||||
)!!
|
||||
|
||||
return SignalDatabase.distributionLists.getRecipientId(dlistId)!!
|
||||
}
|
||||
|
||||
fun DistributionListTables.clearAllDataForBackupRestore() {
|
||||
writableDatabase
|
||||
.delete(DistributionListTables.ListTable.TABLE_NAME)
|
||||
.run()
|
||||
|
||||
writableDatabase
|
||||
.delete(DistributionListTables.MembershipTable.TABLE_NAME)
|
||||
.run()
|
||||
}
|
||||
|
||||
private fun DistributionListPrivacyMode.toBackupPrivacyMode(): BackupDistributionList.PrivacyMode {
|
||||
return when (this) {
|
||||
DistributionListPrivacyMode.ONLY_WITH -> BackupDistributionList.PrivacyMode.ONLY_WITH
|
||||
DistributionListPrivacyMode.ALL -> BackupDistributionList.PrivacyMode.ALL
|
||||
DistributionListPrivacyMode.ALL_EXCEPT -> BackupDistributionList.PrivacyMode.ALL_EXCEPT
|
||||
}
|
||||
}
|
||||
|
||||
private fun BackupDistributionList.PrivacyMode.toLocalPrivacyMode(): DistributionListPrivacyMode {
|
||||
return when (this) {
|
||||
BackupDistributionList.PrivacyMode.UNKNOWN -> DistributionListPrivacyMode.ALL
|
||||
BackupDistributionList.PrivacyMode.ONLY_WITH -> DistributionListPrivacyMode.ONLY_WITH
|
||||
BackupDistributionList.PrivacyMode.ALL -> DistributionListPrivacyMode.ALL
|
||||
BackupDistributionList.PrivacyMode.ALL_EXCEPT -> DistributionListPrivacyMode.ALL_EXCEPT
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.select
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupState
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.MessageTypes
|
||||
|
||||
private val TAG = Log.tag(MessageTable::class.java)
|
||||
private const val BASE_TYPE = "base_type"
|
||||
|
||||
fun MessageTable.getMessagesForBackup(): ChatItemExportIterator {
|
||||
val cursor = readableDatabase
|
||||
.select(
|
||||
MessageTable.ID,
|
||||
MessageTable.DATE_SENT,
|
||||
MessageTable.DATE_RECEIVED,
|
||||
MessageTable.DATE_SERVER,
|
||||
MessageTable.TYPE,
|
||||
MessageTable.THREAD_ID,
|
||||
MessageTable.BODY,
|
||||
MessageTable.MESSAGE_RANGES,
|
||||
MessageTable.FROM_RECIPIENT_ID,
|
||||
MessageTable.TO_RECIPIENT_ID,
|
||||
MessageTable.EXPIRES_IN,
|
||||
MessageTable.EXPIRE_STARTED,
|
||||
MessageTable.REMOTE_DELETED,
|
||||
MessageTable.UNIDENTIFIED,
|
||||
MessageTable.QUOTE_ID,
|
||||
MessageTable.QUOTE_AUTHOR,
|
||||
MessageTable.QUOTE_BODY,
|
||||
MessageTable.QUOTE_MISSING,
|
||||
MessageTable.QUOTE_BODY_RANGES,
|
||||
MessageTable.QUOTE_TYPE,
|
||||
MessageTable.ORIGINAL_MESSAGE_ID,
|
||||
MessageTable.LATEST_REVISION_ID,
|
||||
MessageTable.HAS_DELIVERY_RECEIPT,
|
||||
MessageTable.HAS_READ_RECEIPT,
|
||||
MessageTable.VIEWED_COLUMN,
|
||||
MessageTable.RECEIPT_TIMESTAMP,
|
||||
MessageTable.READ,
|
||||
MessageTable.NETWORK_FAILURES,
|
||||
MessageTable.MISMATCHED_IDENTITIES,
|
||||
"${MessageTable.TYPE} & ${MessageTypes.BASE_TYPE_MASK} AS ${ChatItemExportIterator.COLUMN_BASE_TYPE}"
|
||||
)
|
||||
.from(MessageTable.TABLE_NAME)
|
||||
.where(
|
||||
"""
|
||||
$BASE_TYPE IN (
|
||||
${MessageTypes.BASE_INBOX_TYPE},
|
||||
${MessageTypes.BASE_OUTBOX_TYPE},
|
||||
${MessageTypes.BASE_SENT_TYPE},
|
||||
${MessageTypes.BASE_SENDING_TYPE},
|
||||
${MessageTypes.BASE_SENT_FAILED_TYPE}
|
||||
) OR ${MessageTable.IS_CALL_TYPE_CLAUSE}
|
||||
"""
|
||||
)
|
||||
.orderBy("${MessageTable.DATE_RECEIVED} ASC")
|
||||
.run()
|
||||
|
||||
return ChatItemExportIterator(cursor, 100)
|
||||
}
|
||||
|
||||
fun MessageTable.createChatItemInserter(backupState: BackupState): ChatItemImportInserter {
|
||||
return ChatItemImportInserter(writableDatabase, backupState, 100)
|
||||
}
|
||||
|
||||
fun MessageTable.clearAllDataForBackupRestore() {
|
||||
writableDatabase.delete(MessageTable.TABLE_NAME, null, null)
|
||||
SqlUtil.resetAutoIncrementValue(writableDatabase, MessageTable.TABLE_NAME)
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.delete
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.nullIfBlank
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireNonNullBlob
|
||||
import org.signal.core.util.requireString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.toInt
|
||||
import org.signal.core.util.update
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.AccountData
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Contact
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Group
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Self
|
||||
import org.thoughtcrime.securesms.database.GroupTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTableCursorUtil
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.RecipientExtras
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import java.io.Closeable
|
||||
|
||||
typealias BackupRecipient = org.thoughtcrime.securesms.backup.v2.proto.Recipient
|
||||
typealias BackupGroup = Group
|
||||
|
||||
/**
|
||||
* Fetches all individual contacts for backups and returns the result as an iterator.
|
||||
* It's important to note that the iterator still needs to be closed after it's used.
|
||||
* It's recommended to use `.use` or a try-with-resources pattern.
|
||||
*/
|
||||
fun RecipientTable.getContactsForBackup(selfId: Long): BackupContactIterator {
|
||||
val cursor = readableDatabase
|
||||
.select(
|
||||
RecipientTable.ID,
|
||||
RecipientTable.ACI_COLUMN,
|
||||
RecipientTable.PNI_COLUMN,
|
||||
RecipientTable.USERNAME,
|
||||
RecipientTable.E164,
|
||||
RecipientTable.BLOCKED,
|
||||
RecipientTable.HIDDEN,
|
||||
RecipientTable.REGISTERED,
|
||||
RecipientTable.UNREGISTERED_TIMESTAMP,
|
||||
RecipientTable.PROFILE_KEY,
|
||||
RecipientTable.PROFILE_SHARING,
|
||||
RecipientTable.PROFILE_GIVEN_NAME,
|
||||
RecipientTable.PROFILE_FAMILY_NAME,
|
||||
RecipientTable.PROFILE_JOINED_NAME,
|
||||
RecipientTable.MUTE_UNTIL,
|
||||
RecipientTable.EXTRAS
|
||||
)
|
||||
.from(RecipientTable.TABLE_NAME)
|
||||
.where(
|
||||
"""
|
||||
${RecipientTable.TYPE} = ? AND (
|
||||
${RecipientTable.ACI_COLUMN} NOT NULL OR
|
||||
${RecipientTable.PNI_COLUMN} NOT NULL OR
|
||||
${RecipientTable.E164} NOT NULL
|
||||
)
|
||||
""",
|
||||
RecipientTable.RecipientType.INDIVIDUAL.id
|
||||
)
|
||||
.run()
|
||||
|
||||
return BackupContactIterator(cursor, selfId)
|
||||
}
|
||||
|
||||
fun RecipientTable.getGroupsForBackup(): BackupGroupIterator {
|
||||
val cursor = readableDatabase
|
||||
.select(
|
||||
"${RecipientTable.TABLE_NAME}.${RecipientTable.ID}",
|
||||
"${RecipientTable.TABLE_NAME}.${RecipientTable.BLOCKED}",
|
||||
"${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_SHARING}",
|
||||
"${RecipientTable.TABLE_NAME}.${RecipientTable.MUTE_UNTIL}",
|
||||
"${RecipientTable.TABLE_NAME}.${RecipientTable.EXTRAS}",
|
||||
"${GroupTable.TABLE_NAME}.${GroupTable.V2_MASTER_KEY}",
|
||||
"${GroupTable.TABLE_NAME}.${GroupTable.SHOW_AS_STORY_STATE}"
|
||||
)
|
||||
.from(
|
||||
"""
|
||||
${RecipientTable.TABLE_NAME}
|
||||
INNER JOIN ${GroupTable.TABLE_NAME} ON ${RecipientTable.TABLE_NAME}.${RecipientTable.ID} = ${GroupTable.TABLE_NAME}.${GroupTable.RECIPIENT_ID}
|
||||
"""
|
||||
)
|
||||
.run()
|
||||
|
||||
return BackupGroupIterator(cursor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a [BackupRecipient] and writes it into the database.
|
||||
*/
|
||||
fun RecipientTable.restoreRecipientFromBackup(recipient: BackupRecipient, backupState: BackupState): RecipientId? {
|
||||
// TODO Need to handle groups
|
||||
// TODO Also, should we move this when statement up to mimic the export? Kinda weird that this calls distributionListTable functions
|
||||
return when {
|
||||
recipient.contact != null -> restoreContactFromBackup(recipient.contact)
|
||||
recipient.distributionList != null -> SignalDatabase.distributionLists.restoreFromBackup(recipient.distributionList, backupState)
|
||||
recipient.self != null -> Recipient.self().id
|
||||
else -> {
|
||||
Log.w(TAG, "Unrecognized recipient type!")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given [AccountData], this will insert the necessary data for the local user into the [RecipientTable].
|
||||
*/
|
||||
fun RecipientTable.restoreSelfFromBackup(accountData: AccountData, selfId: RecipientId) {
|
||||
val values = ContentValues().apply {
|
||||
put(RecipientTable.PROFILE_GIVEN_NAME, accountData.givenName.nullIfBlank())
|
||||
put(RecipientTable.PROFILE_FAMILY_NAME, accountData.familyName.nullIfBlank())
|
||||
put(RecipientTable.PROFILE_JOINED_NAME, ProfileName.fromParts(accountData.givenName, accountData.familyName).toString().nullIfBlank())
|
||||
put(RecipientTable.PROFILE_AVATAR, accountData.avatarUrlPath.nullIfBlank())
|
||||
put(RecipientTable.REGISTERED, RecipientTable.RegisteredState.REGISTERED.id)
|
||||
put(RecipientTable.PROFILE_SHARING, true)
|
||||
put(RecipientTable.UNREGISTERED_TIMESTAMP, 0)
|
||||
put(RecipientTable.EXTRAS, RecipientExtras().encode())
|
||||
|
||||
try {
|
||||
put(RecipientTable.PROFILE_KEY, Base64.encodeWithPadding(accountData.profileKey.toByteArray()).nullIfBlank())
|
||||
} catch (e: InvalidInputException) {
|
||||
Log.w(TAG, "Missing profile key during restore")
|
||||
}
|
||||
|
||||
put(RecipientTable.USERNAME, accountData.username)
|
||||
}
|
||||
|
||||
writableDatabase
|
||||
.update(RecipientTable.TABLE_NAME)
|
||||
.values(values)
|
||||
.where("${RecipientTable.ID} = ?", selfId)
|
||||
.run()
|
||||
}
|
||||
|
||||
fun RecipientTable.clearAllDataForBackupRestore() {
|
||||
writableDatabase.delete(RecipientTable.TABLE_NAME).run()
|
||||
SqlUtil.resetAutoIncrementValue(writableDatabase, RecipientTable.TABLE_NAME)
|
||||
|
||||
RecipientId.clearCache()
|
||||
ApplicationDependencies.getRecipientCache().clear()
|
||||
ApplicationDependencies.getRecipientCache().clearSelf()
|
||||
}
|
||||
|
||||
private fun RecipientTable.restoreContactFromBackup(contact: Contact): RecipientId {
|
||||
val id = getAndPossiblyMergePnpVerified(
|
||||
aci = ACI.parseOrNull(contact.aci?.toByteArray()),
|
||||
pni = PNI.parseOrNull(contact.pni?.toByteArray()),
|
||||
e164 = contact.formattedE164
|
||||
)
|
||||
|
||||
val profileKey = contact.profileKey?.toByteArray()
|
||||
|
||||
writableDatabase
|
||||
.update(RecipientTable.TABLE_NAME)
|
||||
.values(
|
||||
RecipientTable.BLOCKED to contact.blocked,
|
||||
RecipientTable.HIDDEN to contact.hidden,
|
||||
RecipientTable.PROFILE_FAMILY_NAME to contact.profileFamilyName.nullIfBlank(),
|
||||
RecipientTable.PROFILE_GIVEN_NAME to contact.profileGivenName.nullIfBlank(),
|
||||
RecipientTable.PROFILE_JOINED_NAME to ProfileName.fromParts(contact.profileGivenName.nullIfBlank(), contact.profileFamilyName.nullIfBlank()).toString().nullIfBlank(),
|
||||
RecipientTable.PROFILE_KEY to if (profileKey == null) null else Base64.encodeWithPadding(profileKey),
|
||||
RecipientTable.PROFILE_SHARING to contact.profileSharing.toInt(),
|
||||
RecipientTable.REGISTERED to contact.registered.toLocalRegisteredState().id,
|
||||
RecipientTable.USERNAME to contact.username,
|
||||
RecipientTable.UNREGISTERED_TIMESTAMP to contact.unregisteredTimestamp,
|
||||
RecipientTable.EXTRAS to contact.toLocalExtras().encode()
|
||||
)
|
||||
.where("${RecipientTable.ID} = ?", id)
|
||||
.run()
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
private fun Contact.toLocalExtras(): RecipientExtras {
|
||||
return RecipientExtras(
|
||||
hideStory = this.hideStory
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s.
|
||||
* Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources.
|
||||
*/
|
||||
class BackupContactIterator(private val cursor: Cursor, private val selfId: Long) : Iterator<BackupRecipient?>, Closeable {
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count > 0 && !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): BackupRecipient? {
|
||||
if (!cursor.moveToNext()) {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
val id = cursor.requireLong(RecipientTable.ID)
|
||||
if (id == selfId) {
|
||||
return BackupRecipient(
|
||||
id = id,
|
||||
self = Self()
|
||||
)
|
||||
}
|
||||
|
||||
val aci = ACI.parseOrNull(cursor.requireString(RecipientTable.ACI_COLUMN))
|
||||
val pni = PNI.parseOrNull(cursor.requireString(RecipientTable.PNI_COLUMN))
|
||||
val e164 = cursor.requireString(RecipientTable.E164)?.e164ToLong()
|
||||
val registeredState = RecipientTable.RegisteredState.fromId(cursor.requireInt(RecipientTable.REGISTERED))
|
||||
val profileKey = cursor.requireString(RecipientTable.PROFILE_KEY)
|
||||
val extras = RecipientTableCursorUtil.getExtras(cursor)
|
||||
|
||||
if (aci == null && pni == null && e164 == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return BackupRecipient(
|
||||
id = id,
|
||||
contact = Contact(
|
||||
aci = aci?.toByteArray()?.toByteString(),
|
||||
pni = pni?.toByteArray()?.toByteString(),
|
||||
username = cursor.requireString(RecipientTable.USERNAME),
|
||||
e164 = cursor.requireString(RecipientTable.E164)?.e164ToLong(),
|
||||
blocked = cursor.requireBoolean(RecipientTable.BLOCKED),
|
||||
hidden = cursor.requireBoolean(RecipientTable.HIDDEN),
|
||||
registered = registeredState.toContactRegisteredState(),
|
||||
unregisteredTimestamp = cursor.requireLong(RecipientTable.UNREGISTERED_TIMESTAMP),
|
||||
profileKey = if (profileKey != null) Base64.decode(profileKey).toByteString() else null,
|
||||
profileSharing = cursor.requireBoolean(RecipientTable.PROFILE_SHARING),
|
||||
profileGivenName = cursor.requireString(RecipientTable.PROFILE_GIVEN_NAME).nullIfBlank(),
|
||||
profileFamilyName = cursor.requireString(RecipientTable.PROFILE_FAMILY_NAME).nullIfBlank(),
|
||||
hideStory = extras?.hideStory() ?: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s.
|
||||
* Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources.
|
||||
*/
|
||||
class BackupGroupIterator(private val cursor: Cursor) : Iterator<BackupRecipient>, Closeable {
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count > 0 && !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): BackupRecipient {
|
||||
if (!cursor.moveToNext()) {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
val extras = RecipientTableCursorUtil.getExtras(cursor)
|
||||
val showAsStoryState: GroupTable.ShowAsStoryState = GroupTable.ShowAsStoryState.deserialize(cursor.requireInt(GroupTable.SHOW_AS_STORY_STATE))
|
||||
|
||||
return BackupRecipient(
|
||||
id = cursor.requireLong(RecipientTable.ID),
|
||||
group = BackupGroup(
|
||||
masterKey = cursor.requireNonNullBlob(GroupTable.V2_MASTER_KEY).toByteString(),
|
||||
whitelisted = cursor.requireBoolean(RecipientTable.PROFILE_SHARING),
|
||||
hideStory = extras?.hideStory() ?: false,
|
||||
storySendMode = showAsStoryState.toGroupStorySendMode()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.e164ToLong(): Long? {
|
||||
val fixed = if (this.startsWith("+")) {
|
||||
this.substring(1)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
return fixed.toLongOrNull()
|
||||
}
|
||||
|
||||
private fun RecipientTable.RegisteredState.toContactRegisteredState(): Contact.Registered {
|
||||
return when (this) {
|
||||
RecipientTable.RegisteredState.REGISTERED -> Contact.Registered.REGISTERED
|
||||
RecipientTable.RegisteredState.NOT_REGISTERED -> Contact.Registered.NOT_REGISTERED
|
||||
RecipientTable.RegisteredState.UNKNOWN -> Contact.Registered.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
private fun Contact.Registered.toLocalRegisteredState(): RecipientTable.RegisteredState {
|
||||
return when (this) {
|
||||
Contact.Registered.REGISTERED -> RecipientTable.RegisteredState.REGISTERED
|
||||
Contact.Registered.NOT_REGISTERED -> RecipientTable.RegisteredState.NOT_REGISTERED
|
||||
Contact.Registered.UNKNOWN -> RecipientTable.RegisteredState.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
private fun GroupTable.ShowAsStoryState.toGroupStorySendMode(): Group.StorySendMode {
|
||||
return when (this) {
|
||||
GroupTable.ShowAsStoryState.ALWAYS -> Group.StorySendMode.ENABLED
|
||||
GroupTable.ShowAsStoryState.NEVER -> Group.StorySendMode.DISABLED
|
||||
GroupTable.ShowAsStoryState.IF_ACTIVE -> Group.StorySendMode.DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
private val Contact.formattedE164: String?
|
||||
get() {
|
||||
return e164?.let {
|
||||
PhoneNumberFormatter.get(ApplicationDependencies.getApplication()).format(e164.toString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.database.Cursor
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.toInt
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Chat
|
||||
import org.thoughtcrime.securesms.database.ThreadTable
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import java.io.Closeable
|
||||
|
||||
private val TAG = Log.tag(ThreadTable::class.java)
|
||||
|
||||
fun ThreadTable.getThreadsForBackup(): ChatIterator {
|
||||
val cursor = readableDatabase
|
||||
.select(
|
||||
ThreadTable.ID,
|
||||
ThreadTable.RECIPIENT_ID,
|
||||
ThreadTable.ARCHIVED,
|
||||
ThreadTable.PINNED,
|
||||
ThreadTable.EXPIRES_IN
|
||||
)
|
||||
.from(ThreadTable.TABLE_NAME)
|
||||
.run()
|
||||
|
||||
return ChatIterator(cursor)
|
||||
}
|
||||
|
||||
fun ThreadTable.clearAllDataForBackupRestore() {
|
||||
writableDatabase.delete(ThreadTable.TABLE_NAME, null, null)
|
||||
SqlUtil.resetAutoIncrementValue(writableDatabase, ThreadTable.TABLE_NAME)
|
||||
clearCache()
|
||||
}
|
||||
|
||||
fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId): Long? {
|
||||
return writableDatabase
|
||||
.insertInto(ThreadTable.TABLE_NAME)
|
||||
.values(
|
||||
ThreadTable.RECIPIENT_ID to recipientId.serialize(),
|
||||
ThreadTable.PINNED to chat.pinnedOrder,
|
||||
ThreadTable.ARCHIVED to chat.archived.toInt()
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
class ChatIterator(private val cursor: Cursor) : Iterator<Chat>, Closeable {
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count > 0 && !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): Chat {
|
||||
if (!cursor.moveToNext()) {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
return Chat(
|
||||
id = cursor.requireLong(ThreadTable.ID),
|
||||
recipientId = cursor.requireLong(ThreadTable.RECIPIENT_ID),
|
||||
archived = cursor.requireBoolean(ThreadTable.ARCHIVED),
|
||||
pinnedOrder = cursor.requireInt(ThreadTable.PINNED),
|
||||
expirationTimerMs = cursor.requireLong(ThreadTable.EXPIRES_IN)
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.processor
|
||||
|
||||
import okio.ByteString.Companion.EMPTY
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.thoughtcrime.securesms.backup.v2.database.restoreSelfFromBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.AccountData
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
|
||||
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.subscription.Subscriber
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
import org.whispersystems.signalservice.api.storage.StorageRecordProtoUtil.defaultAccountRecord
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
|
||||
object AccountDataProcessor {
|
||||
|
||||
fun export(emitter: BackupFrameEmitter) {
|
||||
val context = ApplicationDependencies.getApplication()
|
||||
|
||||
val self = Recipient.self().fresh()
|
||||
val record = recipients.getRecordForSync(self.id)
|
||||
|
||||
val subscriber: Subscriber? = SignalStore.donationsValues().getSubscriber()
|
||||
|
||||
emitter.emit(
|
||||
Frame(
|
||||
account = AccountData(
|
||||
profileKey = self.profileKey?.toByteString() ?: EMPTY,
|
||||
givenName = self.profileName.givenName,
|
||||
familyName = self.profileName.familyName,
|
||||
avatarUrlPath = self.profileAvatar ?: "",
|
||||
subscriptionManuallyCancelled = SignalStore.donationsValues().isUserManuallyCancelled(),
|
||||
username = SignalStore.account().username,
|
||||
subscriberId = subscriber?.subscriberId?.bytes?.toByteString() ?: defaultAccountRecord.subscriberId,
|
||||
subscriberCurrencyCode = subscriber?.currencyCode ?: defaultAccountRecord.subscriberCurrencyCode,
|
||||
accountSettings = AccountData.AccountSettings(
|
||||
storyViewReceiptsEnabled = SignalStore.storyValues().viewedReceiptsEnabled,
|
||||
noteToSelfMarkedUnread = record != null && record.syncExtras.isForcedUnread,
|
||||
typingIndicators = TextSecurePreferences.isTypingIndicatorsEnabled(context),
|
||||
readReceipts = TextSecurePreferences.isReadReceiptsEnabled(context),
|
||||
sealedSenderIndicators = TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context),
|
||||
linkPreviews = SignalStore.settings().isLinkPreviewsEnabled,
|
||||
notDiscoverableByPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberListingMode.isUnlisted,
|
||||
phoneNumberSharingMode = SignalStore.phoneNumberPrivacy().phoneNumberSharingMode.toBackupPhoneNumberSharingMode(),
|
||||
preferContactAvatars = SignalStore.settings().isPreferSystemContactPhotos,
|
||||
universalExpireTimer = SignalStore.settings().universalExpireTimer,
|
||||
preferredReactionEmoji = SignalStore.emojiValues().reactions,
|
||||
storiesDisabled = SignalStore.storyValues().isFeatureDisabled,
|
||||
hasViewedOnboardingStory = SignalStore.storyValues().userHasViewedOnboardingStory,
|
||||
hasSetMyStoriesPrivacy = SignalStore.storyValues().userHasBeenNotifiedAboutStories,
|
||||
keepMutedChatsArchived = SignalStore.settings().shouldKeepMutedChatsArchived(),
|
||||
displayBadgesOnProfile = SignalStore.donationsValues().getDisplayBadgesOnProfile(),
|
||||
hasSeenGroupStoryEducationSheet = SignalStore.storyValues().userHasSeenGroupStoryEducationSheet
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun import(accountData: AccountData, selfId: RecipientId) {
|
||||
recipients.restoreSelfFromBackup(accountData, selfId)
|
||||
|
||||
SignalStore.account().setRegistered(true)
|
||||
|
||||
val context = ApplicationDependencies.getApplication()
|
||||
val settings = accountData.accountSettings
|
||||
|
||||
if (settings != null) {
|
||||
TextSecurePreferences.setReadReceiptsEnabled(context, settings.readReceipts)
|
||||
TextSecurePreferences.setTypingIndicatorsEnabled(context, settings.typingIndicators)
|
||||
TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, settings.sealedSenderIndicators)
|
||||
SignalStore.settings().isLinkPreviewsEnabled = settings.linkPreviews
|
||||
SignalStore.phoneNumberPrivacy().phoneNumberListingMode = if (settings.notDiscoverableByPhoneNumber) PhoneNumberPrivacyValues.PhoneNumberListingMode.UNLISTED else PhoneNumberPrivacyValues.PhoneNumberListingMode.LISTED
|
||||
SignalStore.phoneNumberPrivacy().phoneNumberSharingMode = settings.phoneNumberSharingMode.toLocalPhoneNumberMode()
|
||||
SignalStore.settings().isPreferSystemContactPhotos = settings.preferContactAvatars
|
||||
SignalStore.settings().universalExpireTimer = settings.universalExpireTimer
|
||||
SignalStore.emojiValues().reactions = settings.preferredReactionEmoji
|
||||
SignalStore.donationsValues().setDisplayBadgesOnProfile(settings.displayBadgesOnProfile)
|
||||
SignalStore.settings().setKeepMutedChatsArchived(settings.keepMutedChatsArchived)
|
||||
SignalStore.storyValues().userHasBeenNotifiedAboutStories = settings.hasSetMyStoriesPrivacy
|
||||
SignalStore.storyValues().userHasViewedOnboardingStory = settings.hasViewedOnboardingStory
|
||||
SignalStore.storyValues().isFeatureDisabled = settings.storiesDisabled
|
||||
SignalStore.storyValues().userHasSeenGroupStoryEducationSheet = settings.hasSeenGroupStoryEducationSheet
|
||||
SignalStore.storyValues().viewedReceiptsEnabled = settings.storyViewReceiptsEnabled ?: settings.readReceipts
|
||||
|
||||
if (accountData.subscriptionManuallyCancelled) {
|
||||
SignalStore.donationsValues().updateLocalStateForManualCancellation()
|
||||
} else {
|
||||
SignalStore.donationsValues().clearUserManuallyCancelled()
|
||||
}
|
||||
|
||||
if (accountData.subscriberId.size > 0) {
|
||||
val subscriber = Subscriber(SubscriberId.fromBytes(accountData.subscriberId.toByteArray()), accountData.subscriberCurrencyCode)
|
||||
SignalStore.donationsValues().setSubscriber(subscriber)
|
||||
}
|
||||
|
||||
if (accountData.avatarUrlPath.isNotEmpty()) {
|
||||
ApplicationDependencies.getJobManager().add(RetrieveProfileAvatarJob(Recipient.self().fresh(), accountData.avatarUrlPath))
|
||||
}
|
||||
|
||||
if (accountData.usernameLink != null) {
|
||||
SignalStore.account().usernameLink = UsernameLinkComponents(
|
||||
accountData.usernameLink.entropy.toByteArray(),
|
||||
UuidUtil.parseOrThrow(accountData.usernameLink.serverId.toByteArray())
|
||||
)
|
||||
SignalStore.misc().usernameQrCodeColorScheme = accountData.usernameLink.color.toLocalUsernameColor()
|
||||
}
|
||||
}
|
||||
|
||||
SignalDatabase.runPostSuccessfulTransaction { ProfileUtil.handleSelfProfileKeyChange() }
|
||||
|
||||
Recipient.self().live().refresh()
|
||||
}
|
||||
|
||||
private fun PhoneNumberPrivacyValues.PhoneNumberSharingMode.toBackupPhoneNumberSharingMode(): AccountData.PhoneNumberSharingMode {
|
||||
return when (this) {
|
||||
PhoneNumberPrivacyValues.PhoneNumberSharingMode.DEFAULT -> AccountData.PhoneNumberSharingMode.EVERYBODY
|
||||
PhoneNumberPrivacyValues.PhoneNumberSharingMode.EVERYBODY -> AccountData.PhoneNumberSharingMode.EVERYBODY
|
||||
PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY -> AccountData.PhoneNumberSharingMode.NOBODY
|
||||
}
|
||||
}
|
||||
|
||||
private fun AccountData.PhoneNumberSharingMode.toLocalPhoneNumberMode(): PhoneNumberPrivacyValues.PhoneNumberSharingMode {
|
||||
return when (this) {
|
||||
AccountData.PhoneNumberSharingMode.UNKNOWN -> PhoneNumberPrivacyValues.PhoneNumberSharingMode.EVERYBODY
|
||||
AccountData.PhoneNumberSharingMode.EVERYBODY -> PhoneNumberPrivacyValues.PhoneNumberSharingMode.EVERYBODY
|
||||
AccountData.PhoneNumberSharingMode.NOBODY -> PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY
|
||||
}
|
||||
}
|
||||
|
||||
private fun AccountData.UsernameLink.Color?.toLocalUsernameColor(): UsernameQrCodeColorScheme {
|
||||
return when (this) {
|
||||
AccountData.UsernameLink.Color.BLUE -> UsernameQrCodeColorScheme.Blue
|
||||
AccountData.UsernameLink.Color.WHITE -> UsernameQrCodeColorScheme.White
|
||||
AccountData.UsernameLink.Color.GREY -> UsernameQrCodeColorScheme.Grey
|
||||
AccountData.UsernameLink.Color.OLIVE -> UsernameQrCodeColorScheme.Tan
|
||||
AccountData.UsernameLink.Color.GREEN -> UsernameQrCodeColorScheme.Green
|
||||
AccountData.UsernameLink.Color.ORANGE -> UsernameQrCodeColorScheme.Orange
|
||||
AccountData.UsernameLink.Color.PINK -> UsernameQrCodeColorScheme.Pink
|
||||
AccountData.UsernameLink.Color.PURPLE -> UsernameQrCodeColorScheme.Purple
|
||||
else -> UsernameQrCodeColorScheme.Blue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.processor
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupState
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getCallsForBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.database.restoreCallLogFromBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
|
||||
typealias BackupCall = org.thoughtcrime.securesms.backup.v2.proto.Call
|
||||
|
||||
object CallLogBackupProcessor {
|
||||
|
||||
val TAG = Log.tag(CallLogBackupProcessor::class.java)
|
||||
|
||||
fun export(emitter: BackupFrameEmitter) {
|
||||
SignalDatabase.calls.getCallsForBackup().use { reader ->
|
||||
for (callLog in reader) {
|
||||
if (callLog != null) {
|
||||
emitter.emit(Frame(call = callLog))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun import(call: BackupCall, backupState: BackupState) {
|
||||
SignalDatabase.calls.restoreCallLogFromBackup(call, backupState)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.processor
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupState
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getThreadsForBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.database.restoreFromBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Chat
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
object ChatBackupProcessor {
|
||||
val TAG = Log.tag(ChatBackupProcessor::class.java)
|
||||
|
||||
fun export(emitter: BackupFrameEmitter) {
|
||||
SignalDatabase.threads.getThreadsForBackup().use { reader ->
|
||||
for (chat in reader) {
|
||||
emitter.emit(Frame(chat = chat))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun import(chat: Chat, backupState: BackupState) {
|
||||
val recipientId: RecipientId? = backupState.backupToLocalRecipientId[chat.recipientId]
|
||||
if (recipientId == null) {
|
||||
Log.w(TAG, "Missing recipient for chat ${chat.id}")
|
||||
return
|
||||
}
|
||||
|
||||
SignalDatabase.threads.restoreFromBackup(chat, recipientId)?.let { threadId ->
|
||||
backupState.chatIdToLocalRecipientId[chat.id] = recipientId
|
||||
backupState.chatIdToLocalThreadId[chat.id] = threadId
|
||||
backupState.chatIdToBackupRecipientId[chat.id] = chat.recipientId
|
||||
}
|
||||
|
||||
// TODO there's several fields in the chat that actually need to be restored on the recipient table
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.processor
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupState
|
||||
import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter
|
||||
import org.thoughtcrime.securesms.backup.v2.database.createChatItemInserter
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getMessagesForBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
|
||||
object ChatItemBackupProcessor {
|
||||
val TAG = Log.tag(ChatItemBackupProcessor::class.java)
|
||||
|
||||
fun export(emitter: BackupFrameEmitter) {
|
||||
SignalDatabase.messages.getMessagesForBackup().use { chatItems ->
|
||||
for (chatItem in chatItems) {
|
||||
emitter.emit(Frame(chatItem = chatItem))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun beginImport(backupState: BackupState): ChatItemImportInserter {
|
||||
return SignalDatabase.messages.createChatItemInserter(backupState)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.processor
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupState
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getAllForBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getContactsForBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getGroupsForBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.database.restoreRecipientFromBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
typealias BackupRecipient = org.thoughtcrime.securesms.backup.v2.proto.Recipient
|
||||
|
||||
object RecipientBackupProcessor {
|
||||
|
||||
val TAG = Log.tag(RecipientBackupProcessor::class.java)
|
||||
|
||||
fun export(emitter: BackupFrameEmitter) {
|
||||
val selfId = Recipient.self().id.toLong()
|
||||
|
||||
SignalDatabase.recipients.getContactsForBackup(selfId).use { reader ->
|
||||
for (backupRecipient in reader) {
|
||||
if (backupRecipient != null) {
|
||||
emitter.emit(Frame(recipient = backupRecipient))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SignalDatabase.recipients.getGroupsForBackup().use { reader ->
|
||||
for (backupRecipient in reader) {
|
||||
emitter.emit(Frame(recipient = backupRecipient))
|
||||
}
|
||||
}
|
||||
|
||||
SignalDatabase.distributionLists.getAllForBackup().forEach {
|
||||
emitter.emit(Frame(recipient = it))
|
||||
}
|
||||
}
|
||||
|
||||
fun import(recipient: BackupRecipient, backupState: BackupState) {
|
||||
val newId = SignalDatabase.recipients.restoreRecipientFromBackup(recipient, backupState)
|
||||
if (newId != null) {
|
||||
backupState.backupToLocalRecipientId[recipient.id] = newId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.stream
|
||||
|
||||
import org.signal.libsignal.protocol.kdf.HKDF
|
||||
import java.io.FilterOutputStream
|
||||
import java.io.OutputStream
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
class BackupEncryptedOutputStream(key: ByteArray, backupId: ByteArray, wrapped: OutputStream) : FilterOutputStream(wrapped) {
|
||||
|
||||
val cipher: Cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
val mac: Mac = Mac.getInstance("HmacSHA256")
|
||||
|
||||
var finalMac: ByteArray? = null
|
||||
|
||||
init {
|
||||
if (key.size != 32) {
|
||||
throw IllegalArgumentException("Key must be 32 bytes!")
|
||||
}
|
||||
|
||||
if (backupId.size != 16) {
|
||||
throw IllegalArgumentException("BackupId must be 32 bytes!")
|
||||
}
|
||||
|
||||
val extendedKey = HKDF.deriveSecrets(key, backupId, "20231003_Signal_Backups_EncryptMessageBackup".toByteArray(), 80)
|
||||
val macKey = extendedKey.copyOfRange(0, 32)
|
||||
val cipherKey = extendedKey.copyOfRange(32, 64)
|
||||
val iv = extendedKey.copyOfRange(64, 80)
|
||||
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
||||
mac.init(SecretKeySpec(macKey, "HmacSHA256"))
|
||||
}
|
||||
|
||||
override fun write(b: Int) {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun write(data: ByteArray) {
|
||||
write(data, 0, data.size)
|
||||
}
|
||||
|
||||
override fun write(data: ByteArray, off: Int, len: Int) {
|
||||
cipher.update(data, off, len)?.let { ciphertext ->
|
||||
mac.update(ciphertext)
|
||||
super.write(ciphertext)
|
||||
}
|
||||
}
|
||||
|
||||
override fun flush() {
|
||||
cipher.doFinal()?.let { ciphertext ->
|
||||
mac.update(ciphertext)
|
||||
super.write(ciphertext)
|
||||
}
|
||||
|
||||
finalMac = mac.doFinal()
|
||||
|
||||
super.flush()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
flush()
|
||||
super.close()
|
||||
}
|
||||
|
||||
fun getMac(): ByteArray {
|
||||
return finalMac ?: throw IllegalStateException("Mac not yet available! You must call flush() before asking for the mac.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.stream
|
||||
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
|
||||
interface BackupExportWriter : AutoCloseable {
|
||||
fun write(frame: Frame)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.stream
|
||||
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
|
||||
/**
|
||||
* An interface that lets sub-processors emit [Frame]s as they export data.
|
||||
*/
|
||||
fun interface BackupFrameEmitter {
|
||||
fun emit(frame: Frame)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.stream
|
||||
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
|
||||
interface BackupImportStream {
|
||||
fun read(): Frame?
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.stream
|
||||
|
||||
import org.signal.core.util.readFully
|
||||
import org.signal.core.util.readNBytesOrThrow
|
||||
import org.signal.core.util.readVarInt32
|
||||
import org.signal.core.util.stream.MacInputStream
|
||||
import org.signal.core.util.stream.TruncatingInputStream
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.whispersystems.signalservice.api.backup.BackupKey
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import java.io.EOFException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.zip.GZIPInputStream
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.CipherInputStream
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Provides the ability to read backup frames in a streaming fashion from a target [InputStream].
|
||||
* As it's being read, it will be both decrypted and uncompressed. Specifically, the data is decrypted,
|
||||
* that decrypted data is gunzipped, then that data is read as frames.
|
||||
*/
|
||||
class EncryptedBackupReader(
|
||||
key: BackupKey,
|
||||
aci: ACI,
|
||||
streamLength: Long,
|
||||
dataStream: () -> InputStream
|
||||
) : Iterator<Frame>, AutoCloseable {
|
||||
|
||||
var next: Frame? = null
|
||||
val stream: InputStream
|
||||
|
||||
init {
|
||||
val keyMaterial = key.deriveSecrets(aci)
|
||||
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
|
||||
init(Cipher.DECRYPT_MODE, SecretKeySpec(keyMaterial.cipherKey, "AES"), IvParameterSpec(keyMaterial.iv))
|
||||
}
|
||||
|
||||
validateMac(keyMaterial.macKey, streamLength, dataStream())
|
||||
|
||||
stream = GZIPInputStream(
|
||||
CipherInputStream(
|
||||
TruncatingInputStream(
|
||||
wrapped = dataStream(),
|
||||
maxBytes = streamLength - MAC_SIZE
|
||||
),
|
||||
cipher
|
||||
)
|
||||
)
|
||||
|
||||
next = read()
|
||||
}
|
||||
|
||||
override fun hasNext(): Boolean {
|
||||
return next != null
|
||||
}
|
||||
|
||||
override fun next(): Frame {
|
||||
next?.let { out ->
|
||||
next = read()
|
||||
return out
|
||||
} ?: throw NoSuchElementException()
|
||||
}
|
||||
|
||||
private fun read(): Frame? {
|
||||
try {
|
||||
val length = stream.readVarInt32().also { if (it < 0) return null }
|
||||
val frameBytes: ByteArray = stream.readNBytesOrThrow(length)
|
||||
|
||||
return Frame.ADAPTER.decode(frameBytes)
|
||||
} catch (e: EOFException) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
stream.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAC_SIZE = 32
|
||||
|
||||
fun validateMac(macKey: ByteArray, streamLength: Long, dataStream: InputStream) {
|
||||
val mac = Mac.getInstance("HmacSHA256").apply {
|
||||
init(SecretKeySpec(macKey, "HmacSHA256"))
|
||||
}
|
||||
|
||||
val macStream = MacInputStream(
|
||||
wrapped = TruncatingInputStream(dataStream, maxBytes = streamLength - MAC_SIZE),
|
||||
mac = mac
|
||||
)
|
||||
|
||||
macStream.readFully(false)
|
||||
|
||||
val calculatedMac = macStream.mac.doFinal()
|
||||
val expectedMac = dataStream.readNBytesOrThrow(MAC_SIZE)
|
||||
|
||||
if (!calculatedMac.contentEquals(expectedMac)) {
|
||||
throw IOException("Invalid MAC!")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.stream
|
||||
|
||||
import org.signal.core.util.stream.MacOutputStream
|
||||
import org.signal.core.util.writeVarInt32
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.whispersystems.signalservice.api.backup.BackupKey
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.CipherOutputStream
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Provides the ability to write backup frames in a streaming fashion to a target [OutputStream].
|
||||
* As it's being written, it will be both encrypted and compressed. Specifically, the backup frames
|
||||
* are gzipped, that gzipped data is encrypted, and then an HMAC of the encrypted data is appended
|
||||
* to the end of the [outputStream].
|
||||
*/
|
||||
class EncryptedBackupWriter(
|
||||
key: BackupKey,
|
||||
aci: ACI,
|
||||
private val outputStream: OutputStream,
|
||||
private val append: (ByteArray) -> Unit
|
||||
) : BackupExportWriter {
|
||||
|
||||
private val mainStream: GZIPOutputStream
|
||||
private val macStream: MacOutputStream
|
||||
|
||||
init {
|
||||
val keyMaterial = key.deriveSecrets(aci)
|
||||
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
|
||||
init(Cipher.ENCRYPT_MODE, SecretKeySpec(keyMaterial.cipherKey, "AES"), IvParameterSpec(keyMaterial.iv))
|
||||
}
|
||||
|
||||
val mac = Mac.getInstance("HmacSHA256").apply {
|
||||
init(SecretKeySpec(keyMaterial.macKey, "HmacSHA256"))
|
||||
}
|
||||
|
||||
macStream = MacOutputStream(outputStream, mac)
|
||||
|
||||
mainStream = GZIPOutputStream(
|
||||
CipherOutputStream(
|
||||
macStream,
|
||||
cipher
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun write(frame: Frame) {
|
||||
val frameBytes: ByteArray = frame.encode()
|
||||
|
||||
mainStream.writeVarInt32(frameBytes.size)
|
||||
mainStream.write(frameBytes)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun close() {
|
||||
// We need to close the main stream in order for the gzip and all the cipher operations to fully finish before
|
||||
// we can calculate the MAC. Unfortunately flush()/finish() is not sufficient. So we have to defer to the
|
||||
// caller to append the bytes to the end of the data however they see fit (like appending to a file).
|
||||
mainStream.close()
|
||||
val mac = macStream.mac.doFinal()
|
||||
append(mac)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.stream
|
||||
|
||||
import org.signal.core.util.readNBytesOrThrow
|
||||
import org.signal.core.util.readVarInt32
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import java.io.EOFException
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Reads a plaintext backup import stream one frame at a time.
|
||||
*/
|
||||
class PlainTextBackupReader(val inputStream: InputStream) : Iterator<Frame> {
|
||||
|
||||
var next: Frame? = null
|
||||
|
||||
init {
|
||||
next = read()
|
||||
}
|
||||
|
||||
override fun hasNext(): Boolean {
|
||||
return next != null
|
||||
}
|
||||
|
||||
override fun next(): Frame {
|
||||
next?.let { out ->
|
||||
next = read()
|
||||
return out
|
||||
} ?: throw NoSuchElementException()
|
||||
}
|
||||
|
||||
private fun read(): Frame? {
|
||||
try {
|
||||
val length = inputStream.readVarInt32().also { if (it < 0) return null }
|
||||
val frameBytes: ByteArray = inputStream.readNBytesOrThrow(length)
|
||||
|
||||
return Frame.ADAPTER.decode(frameBytes)
|
||||
} catch (e: EOFException) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.stream
|
||||
|
||||
import org.signal.core.util.writeVarInt32
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
|
||||
/**
|
||||
* Writes backup frames to the wrapped stream in plain text. Only for testing!
|
||||
*/
|
||||
class PlainTextBackupWriter(private val outputStream: OutputStream) : BackupExportWriter {
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun write(frame: Frame) {
|
||||
val frameBytes: ByteArray = frame.encode()
|
||||
|
||||
outputStream.writeVarInt32(frameBytes.size)
|
||||
outputStream.write(frameBytes)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
outputStream.close()
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.InputAwareLayout
|
||||
@@ -286,6 +287,8 @@ class GiftFlowConfirmationFragment :
|
||||
|
||||
override fun onProcessorActionProcessed() = Unit
|
||||
|
||||
override fun showSepaEuroMaximumDialog(sepaEuroMaximum: FiatMoney) = error("Unsupported operation")
|
||||
|
||||
override fun onUserLaunchedAnExternalApplication() = Unit
|
||||
|
||||
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) = error("Unsupported operation")
|
||||
|
||||
@@ -34,7 +34,7 @@ object GiftRowItem {
|
||||
override fun bind(model: Model) {
|
||||
binding.check.visible = false
|
||||
binding.badge.setBadge(model.giftBadge)
|
||||
binding.tagline.visible = false
|
||||
binding.tagline.visible = true
|
||||
|
||||
val price = FiatMoneyUtil.format(
|
||||
context.resources,
|
||||
@@ -46,7 +46,8 @@ object GiftRowItem {
|
||||
|
||||
val duration = TimeUnit.MILLISECONDS.toDays(model.giftBadge.duration)
|
||||
|
||||
binding.title.text = context.resources.getQuantityString(R.plurals.GiftRowItem_s_dot_d_day_duration, duration.toInt(), price, duration)
|
||||
binding.title.text = model.giftBadge.name
|
||||
binding.tagline.text = context.resources.getQuantityString(R.plurals.GiftRowItem_s_dot_d_day_duration, duration.toInt(), price, duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package org.thoughtcrime.securesms.badges.self.expired
|
||||
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.StripeDeclineCode
|
||||
import org.signal.donations.StripeFailureCode
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.ExpiredBadge
|
||||
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.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.mapToErrorStringResource
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.shouldRouteToGooglePay
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
|
||||
/**
|
||||
* Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again.
|
||||
*/
|
||||
class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
|
||||
peekHeightPercentage = 1f
|
||||
) {
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
ExpiredBadge.register(adapter)
|
||||
|
||||
adapter.submitList(getConfiguration().toMappingModelList())
|
||||
}
|
||||
|
||||
private fun getConfiguration(): DSLConfiguration {
|
||||
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
|
||||
val badge: Badge = args.badge
|
||||
val cancellationReason = UnexpectedSubscriptionCancellation.fromStatus(args.cancelationReason)
|
||||
val declineCode: StripeDeclineCode? = args.chargeFailure?.let { StripeDeclineCode.getFromCode(it) }
|
||||
val failureCode: StripeFailureCode? = args.chargeFailure?.let { StripeFailureCode.getFromCode(it) }
|
||||
val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer()
|
||||
val inactive = cancellationReason == UnexpectedSubscriptionCancellation.INACTIVE
|
||||
|
||||
Log.d(TAG, "Displaying Expired Badge Fragment with bundle: ${requireArguments()}", true)
|
||||
|
||||
return configure {
|
||||
customPref(ExpiredBadge.Model(badge))
|
||||
|
||||
sectionHeaderPref(
|
||||
DSLSettingsText.from(
|
||||
if (badge.isBoost()) {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__boost_badge_expired
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__monthly_donation_cancelled
|
||||
},
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(4f).toInt())
|
||||
|
||||
noPadTextPref(
|
||||
DSLSettingsText.from(
|
||||
if (badge.isBoost()) {
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_boost_badge_has_expired_and)
|
||||
} else if (declineCode != null) {
|
||||
getString(
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled_s,
|
||||
getString(declineCode.mapToErrorStringResource()),
|
||||
badge.name
|
||||
)
|
||||
} else if (failureCode != null) {
|
||||
getString(
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled_s,
|
||||
getString(failureCode.mapToErrorStringResource()),
|
||||
badge.name
|
||||
)
|
||||
} else if (inactive) {
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_automatically, badge.name)
|
||||
} else {
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled)
|
||||
},
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
if (badge.isSubscription() && declineCode?.shouldRouteToGooglePay() == true) {
|
||||
space(DimensionUnit.DP.toPixels(68f).toInt())
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__go_to_google_pay),
|
||||
onClick = {
|
||||
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.google_pay_url))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
noPadTextPref(
|
||||
DSLSettingsText.from(
|
||||
if (badge.isBoost()) {
|
||||
if (isLikelyASustainer) {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_reactivate
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_keep
|
||||
}
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can
|
||||
},
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(92f).toInt())
|
||||
}
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(
|
||||
if (badge.isBoost()) {
|
||||
if (isLikelyASustainer) {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__add_a_boost
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__become_a_sustainer
|
||||
}
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__renew_subscription
|
||||
}
|
||||
),
|
||||
onClick = {
|
||||
dismiss()
|
||||
if (isLikelyASustainer) {
|
||||
requireActivity().startActivity(AppSettingsActivity.boost(requireContext()))
|
||||
} else {
|
||||
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__not_now),
|
||||
onClick = {
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ExpiredBadgeBottomSheetDialogFragment::class.java)
|
||||
|
||||
@JvmStatic
|
||||
fun show(
|
||||
badge: Badge,
|
||||
cancellationReason: UnexpectedSubscriptionCancellation?,
|
||||
chargeFailure: ActiveSubscription.ChargeFailure?,
|
||||
fragmentManager: FragmentManager
|
||||
) {
|
||||
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.Builder(badge, cancellationReason?.status, chargeFailure?.code).build()
|
||||
val fragment = ExpiredBadgeBottomSheetDialogFragment()
|
||||
fragment.arguments = args.toBundle()
|
||||
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package org.thoughtcrime.securesms.badges.self.expired
|
||||
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.ExpiredBadge
|
||||
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.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
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
|
||||
/**
|
||||
* Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again.
|
||||
*/
|
||||
class ExpiredOneTimeBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
|
||||
peekHeightPercentage = 1f
|
||||
) {
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
ExpiredBadge.register(adapter)
|
||||
|
||||
adapter.submitList(getConfiguration().toMappingModelList())
|
||||
}
|
||||
|
||||
private fun getConfiguration(): DSLConfiguration {
|
||||
val args = ExpiredOneTimeBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
|
||||
val badge: Badge = args.badge
|
||||
val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer()
|
||||
|
||||
Log.d(TAG, "Displaying Expired Badge Fragment with bundle: ${requireArguments()}", true)
|
||||
|
||||
return configure {
|
||||
customPref(ExpiredBadge.Model(badge))
|
||||
|
||||
sectionHeaderPref(
|
||||
DSLSettingsText.from(
|
||||
if (badge.isBoost()) {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__boost_badge_expired
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__monthly_donation_cancelled
|
||||
},
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(4f).toInt())
|
||||
|
||||
noPadTextPref(
|
||||
DSLSettingsText.from(
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_boost_badge_has_expired_and),
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
noPadTextPref(
|
||||
DSLSettingsText.from(
|
||||
if (isLikelyASustainer) {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_reactivate
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_keep
|
||||
},
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(92f).toInt())
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(
|
||||
if (isLikelyASustainer) {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__add_a_boost
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__become_a_sustainer
|
||||
}
|
||||
),
|
||||
onClick = {
|
||||
dismiss()
|
||||
if (isLikelyASustainer) {
|
||||
requireActivity().startActivity(AppSettingsActivity.boost(requireContext()))
|
||||
} else {
|
||||
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__not_now),
|
||||
onClick = {
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ExpiredOneTimeBadgeBottomSheetDialogFragment::class.java)
|
||||
|
||||
@JvmStatic
|
||||
fun show(
|
||||
badge: Badge,
|
||||
cancellationReason: UnexpectedSubscriptionCancellation?,
|
||||
chargeFailure: ActiveSubscription.ChargeFailure?,
|
||||
fragmentManager: FragmentManager
|
||||
) {
|
||||
val args = ExpiredOneTimeBadgeBottomSheetDialogFragmentArgs.Builder(badge, cancellationReason?.status, chargeFailure?.code).build()
|
||||
val fragment = ExpiredOneTimeBadgeBottomSheetDialogFragment()
|
||||
fragment.arguments = args.toBundle()
|
||||
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package org.thoughtcrime.securesms.badges.self.expired
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Texts
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.donations.StripeDeclineCode
|
||||
import org.signal.donations.StripeFailureCode
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImage112
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.mapToErrorStringResource
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.ManageDonationsFragment
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
|
||||
class MonthlyDonationCanceledBottomSheetDialogFragment : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
val chargeFailure: ActiveSubscription.ChargeFailure? = SignalStore.donationsValues().getUnexpectedSubscriptionCancelationChargeFailure()
|
||||
val declineCode: StripeDeclineCode = StripeDeclineCode.getFromCode(chargeFailure?.outcomeNetworkReason)
|
||||
val failureCode: StripeFailureCode = StripeFailureCode.getFromCode(chargeFailure?.code)
|
||||
|
||||
val errorMessage = if (declineCode.isKnown()) {
|
||||
declineCode.mapToErrorStringResource()
|
||||
} else if (failureCode.isKnown) {
|
||||
failureCode.mapToErrorStringResource()
|
||||
} else {
|
||||
declineCode.mapToErrorStringResource()
|
||||
}
|
||||
|
||||
MonthlyDonationCanceled(
|
||||
badge = SignalStore.donationsValues().getExpiredBadge(),
|
||||
errorMessageRes = errorMessage,
|
||||
onRenewClicked = {
|
||||
startActivity(AppSettingsActivity.subscriptions(requireContext()))
|
||||
dismissAllowingStateLoss()
|
||||
},
|
||||
onNotNowClicked = {
|
||||
SignalStore.donationsValues().showMonthlyDonationCanceledDialog = false
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager) {
|
||||
val fragment = MonthlyDonationCanceledBottomSheetDialogFragment()
|
||||
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme", group = "ShortName", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "ShortName", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun MonthlyDonationCanceledPreview() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
MonthlyDonationCanceled(
|
||||
badge = Badge(
|
||||
id = "",
|
||||
category = Badge.Category.Donor,
|
||||
name = "Signal Star",
|
||||
description = "",
|
||||
imageUrl = Uri.EMPTY,
|
||||
imageDensity = "",
|
||||
expirationTimestamp = 0L,
|
||||
visible = true,
|
||||
duration = 0L
|
||||
),
|
||||
errorMessageRes = R.string.StripeFailureCode__verify_your_bank_details_are_correct,
|
||||
onRenewClicked = {},
|
||||
onNotNowClicked = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthlyDonationCanceled(
|
||||
badge: Badge?,
|
||||
@StringRes errorMessageRes: Int,
|
||||
onRenewClicked: () -> Unit,
|
||||
onNotNowClicked: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(horizontal = 34.dp)
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
if (badge != null) {
|
||||
Box(modifier = Modifier.padding(top = 21.dp, bottom = 16.dp)) {
|
||||
BadgeImage112(
|
||||
badge = badge,
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_error_circle_fill_24),
|
||||
contentScale = ContentScale.Inside,
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.error),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
.background(
|
||||
color = SignalTheme.colors.colorSurface1,
|
||||
shape = CircleShape
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.MonthlyDonationCanceled__title),
|
||||
style = MaterialTheme.typography.titleLarge.copy(textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurface),
|
||||
modifier = Modifier.padding(bottom = 20.dp)
|
||||
)
|
||||
|
||||
val context = LocalContext.current
|
||||
val learnMore = stringResource(id = R.string.MonthlyDonationCanceled__learn_more)
|
||||
val errorMessage = stringResource(id = errorMessageRes)
|
||||
val fullString = stringResource(id = R.string.MonthlyDonationCanceled__message, errorMessage, learnMore)
|
||||
val spanned = SpanUtil.urlSubsequence(fullString, learnMore, ManageDonationsFragment.DONATE_TROUBLESHOOTING_URL)
|
||||
Texts.LinkifiedText(
|
||||
textWithUrlSpans = spanned,
|
||||
onUrlClick = { CommunicationActions.openBrowserLink(context, it) },
|
||||
style = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
modifier = Modifier.padding(bottom = 36.dp)
|
||||
)
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onRenewClicked,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.MonthlyDonationCanceled__renew_button))
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onNotNowClicked,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(bottom = 56.dp)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.MonthlyDonationCanceled__not_now_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,8 @@ fun SignalCallRow(
|
||||
.align(CenterVertically)
|
||||
) {
|
||||
Text(
|
||||
text = callLink.state.name.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
|
||||
text = callLink.state.name.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) },
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = callUrl,
|
||||
|
||||
@@ -92,6 +92,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__create_call_link),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
@@ -32,6 +32,11 @@ class CallLogAdapter(
|
||||
callbacks: Callbacks
|
||||
) : PagingMappingAdapter<CallLogRow.Id>() {
|
||||
|
||||
companion object {
|
||||
private const val PAYLOAD_SELECTION_STATE = "PAYLOAD_SELECTION_STATE"
|
||||
private const val PAYLOAD_TIMESTAMP = "PAYLOAD_TIMESTAMP"
|
||||
}
|
||||
|
||||
init {
|
||||
registerFactory(
|
||||
CallModel::class.java,
|
||||
@@ -72,6 +77,10 @@ class CallLogAdapter(
|
||||
)
|
||||
}
|
||||
|
||||
fun onTimestampTick() {
|
||||
notifyItemRangeChanged(0, itemCount, PAYLOAD_TIMESTAMP)
|
||||
}
|
||||
|
||||
fun submitCallRows(
|
||||
rows: List<CallLogRow?>,
|
||||
selectionState: CallLogSelectionState,
|
||||
@@ -98,9 +107,6 @@ class CallLogAdapter(
|
||||
val selectionState: CallLogSelectionState,
|
||||
val itemCount: Int
|
||||
) : MappingModel<CallModel> {
|
||||
companion object {
|
||||
const val PAYLOAD_SELECTION_STATE = "PAYLOAD_SELECTION_STATE"
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(newItem: CallModel): Boolean = call.id == newItem.call.id
|
||||
override fun areContentsTheSame(newItem: CallModel): Boolean {
|
||||
@@ -133,10 +139,6 @@ class CallLogAdapter(
|
||||
val itemCount: Int
|
||||
) : MappingModel<CallLinkModel> {
|
||||
|
||||
companion object {
|
||||
const val PAYLOAD_SELECTION_STATE = "PAYLOAD_SELECTION_STATE"
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(newItem: CallLinkModel): Boolean {
|
||||
return callLink.record.roomId == newItem.callLink.record.roomId
|
||||
}
|
||||
@@ -149,7 +151,7 @@ class CallLogAdapter(
|
||||
|
||||
override fun getChangePayload(newItem: CallLinkModel): Any? {
|
||||
return if (callLink == newItem.callLink && (!isSelectionStateTheSame(newItem) || !isItemCountTheSame(newItem))) {
|
||||
CallModel.PAYLOAD_SELECTION_STATE
|
||||
PAYLOAD_SELECTION_STATE
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -183,6 +185,10 @@ class CallLogAdapter(
|
||||
private val onStartVideoCallClicked: (Recipient) -> Unit
|
||||
) : BindingViewHolder<CallLinkModel, CallLogAdapterItemBinding>(binding) {
|
||||
override fun bind(model: CallLinkModel) {
|
||||
if (payload.size == 1 && payload.contains(PAYLOAD_TIMESTAMP)) {
|
||||
return
|
||||
}
|
||||
|
||||
itemView.setOnClickListener {
|
||||
onCallLinkClicked(model.callLink)
|
||||
}
|
||||
@@ -195,7 +201,7 @@ class CallLogAdapter(
|
||||
binding.callSelected.isChecked = model.selectionState.contains(model.callLink.id)
|
||||
binding.callSelected.visible = model.selectionState.isNotEmpty(model.itemCount)
|
||||
|
||||
if (payload.contains(CallModel.PAYLOAD_SELECTION_STATE)) {
|
||||
if (payload.isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -252,7 +258,11 @@ class CallLogAdapter(
|
||||
binding.callSelected.isChecked = model.selectionState.contains(model.call.id)
|
||||
binding.callSelected.visible = model.selectionState.isNotEmpty(model.itemCount)
|
||||
|
||||
if (payload.contains(CallModel.PAYLOAD_SELECTION_STATE)) {
|
||||
if (payload.contains(PAYLOAD_TIMESTAMP)) {
|
||||
presentCallInfo(model.call, model.call.date)
|
||||
}
|
||||
|
||||
if (payload.isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -295,7 +305,7 @@ class CallLogAdapter(
|
||||
|
||||
val color = ContextCompat.getColor(
|
||||
context,
|
||||
if (call.record.event == CallTable.Event.MISSED) {
|
||||
if (call.record.event.isMissedCall()) {
|
||||
R.color.signal_colorError
|
||||
} else {
|
||||
R.color.signal_colorOnSurfaceVariant
|
||||
@@ -365,7 +375,7 @@ class CallLogAdapter(
|
||||
MessageTypes.OUTGOING_AUDIO_CALL_TYPE, MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.drawable.symbol_arrow_upright_compact_16
|
||||
MessageTypes.GROUP_CALL_TYPE -> when {
|
||||
call.type == CallTable.Type.AD_HOC_CALL -> R.drawable.symbol_link_compact_16
|
||||
call.event == CallTable.Event.MISSED -> R.drawable.symbol_missed_incoming_compact_16
|
||||
call.event.isMissedCall() -> R.drawable.symbol_missed_incoming_compact_16
|
||||
call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.drawable.symbol_group_compact_16
|
||||
call.direction == CallTable.Direction.INCOMING -> R.drawable.symbol_arrow_downleft_compact_16
|
||||
call.direction == CallTable.Direction.OUTGOING -> R.drawable.symbol_arrow_upright_compact_16
|
||||
@@ -379,8 +389,8 @@ class CallLogAdapter(
|
||||
@StringRes
|
||||
private fun getCallStateStringRes(call: CallTable.Call): Int {
|
||||
return when (call.messageType) {
|
||||
MessageTypes.MISSED_VIDEO_CALL_TYPE -> R.string.CallLogAdapter__missed
|
||||
MessageTypes.MISSED_AUDIO_CALL_TYPE -> R.string.CallLogAdapter__missed
|
||||
MessageTypes.MISSED_VIDEO_CALL_TYPE,
|
||||
MessageTypes.MISSED_AUDIO_CALL_TYPE -> if (call.event == CallTable.Event.MISSED) R.string.CallLogAdapter__missed else R.string.CallLogAdapter__missed_notification_profile
|
||||
MessageTypes.INCOMING_AUDIO_CALL_TYPE -> R.string.CallLogAdapter__incoming
|
||||
MessageTypes.INCOMING_VIDEO_CALL_TYPE -> R.string.CallLogAdapter__incoming
|
||||
MessageTypes.OUTGOING_AUDIO_CALL_TYPE -> R.string.CallLogAdapter__outgoing
|
||||
@@ -388,6 +398,7 @@ class CallLogAdapter(
|
||||
MessageTypes.GROUP_CALL_TYPE -> when {
|
||||
call.type == CallTable.Type.AD_HOC_CALL -> R.string.CallLogAdapter__call_link
|
||||
call.event == CallTable.Event.MISSED -> R.string.CallLogAdapter__missed
|
||||
call.event == CallTable.Event.MISSED_NOTIFICATION_PROFILE -> R.string.CallLogAdapter__missed_notification_profile
|
||||
call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.string.CallPreference__group_call
|
||||
call.direction == CallTable.Direction.INCOMING -> R.string.CallLogAdapter__incoming
|
||||
call.direction == CallTable.Direction.OUTGOING -> R.string.CallLogAdapter__outgoing
|
||||
|
||||
@@ -43,6 +43,7 @@ import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
|
||||
import org.thoughtcrime.securesms.conversation.ConversationUpdateTick
|
||||
import org.thoughtcrime.securesms.conversation.SignalBottomActionBarController
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationFilterBehavior
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource
|
||||
@@ -81,6 +82,8 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
private val disposables = LifecycleDisposable()
|
||||
private val callLogContextMenu = CallLogContextMenu(this, this)
|
||||
private val callLogActionMode = CallLogActionMode(CallLogActionModeCallback())
|
||||
private val conversationUpdateTick: ConversationUpdateTick = ConversationUpdateTick(this::onTimestampTick)
|
||||
private var callLogAdapter: CallLogAdapter? = null
|
||||
|
||||
private lateinit var signalBottomActionBarController: SignalBottomActionBarController
|
||||
|
||||
@@ -115,20 +118,22 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
requireActivity().addMenuProvider(menuProvider, viewLifecycleOwner)
|
||||
initializeSharedElementTransition()
|
||||
|
||||
val adapter = CallLogAdapter(this)
|
||||
viewLifecycleOwner.lifecycle.addObserver(conversationUpdateTick)
|
||||
|
||||
val callLogAdapter = CallLogAdapter(this)
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
adapter.setPagingController(viewModel.controller)
|
||||
callLogAdapter.setPagingController(viewModel.controller)
|
||||
|
||||
val scrollToPositionDelegate = ScrollToPositionDelegate(
|
||||
recyclerView = binding.recycler,
|
||||
canJumpToPosition = { adapter.isAvailableAround(it) }
|
||||
canJumpToPosition = { callLogAdapter.isAvailableAround(it) }
|
||||
)
|
||||
|
||||
disposables += scrollToPositionDelegate
|
||||
disposables += Flowables.combineLatest(viewModel.data, viewModel.selected)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { (data, selected) ->
|
||||
val filteredCount = adapter.submitCallRows(
|
||||
val filteredCount = callLogAdapter.submitCallRows(
|
||||
data,
|
||||
selected,
|
||||
scrollToPositionDelegate::notifyListCommitted
|
||||
@@ -147,7 +152,8 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
}
|
||||
}
|
||||
|
||||
binding.recycler.adapter = adapter
|
||||
binding.recycler.adapter = callLogAdapter
|
||||
this.callLogAdapter = callLogAdapter
|
||||
|
||||
requireListener<Material3OnScrollHelperBinder>().bindScrollHelper(binding.recycler)
|
||||
binding.fab.setOnClickListener {
|
||||
@@ -200,6 +206,10 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
viewModel.markAllCallEventsRead()
|
||||
}
|
||||
|
||||
private fun onTimestampTick() {
|
||||
callLogAdapter?.onTimestampTick()
|
||||
}
|
||||
|
||||
private fun initializeSharedElementTransition() {
|
||||
ViewCompat.setTransitionName(binding.fab, "new_convo_fab")
|
||||
ViewCompat.setTransitionName(binding.fabSharedElementTarget, "camera_fab")
|
||||
|
||||
@@ -30,9 +30,9 @@ import java.util.List;
|
||||
public class AlbumThumbnailView extends FrameLayout {
|
||||
|
||||
private @Nullable SlideClickListener thumbnailClickListener;
|
||||
private @Nullable SlidesClickedListener downloadClickListener;
|
||||
private @Nullable SlidesClickedListener cancelDownloadClickListener;
|
||||
private @Nullable SlideClickListener playVideoClickListener;
|
||||
private @Nullable SlidesClickedListener startTransferClickListener;
|
||||
private @Nullable SlidesClickedListener cancelTransferClickListener;
|
||||
private @Nullable SlideClickListener playVideoClickListener;
|
||||
|
||||
private int currentSizeClass;
|
||||
|
||||
@@ -72,10 +72,16 @@ public class AlbumThumbnailView extends FrameLayout {
|
||||
|
||||
if (showControls) {
|
||||
transferControlsStub.get().setShowSecondaryText(true);
|
||||
transferControlsStub.get().setDownloadClickListener(
|
||||
transferControlsStub.get().setTransferClickListener(
|
||||
v -> {
|
||||
if (downloadClickListener != null) {
|
||||
downloadClickListener.onClick(v, slides);
|
||||
if (startTransferClickListener != null) {
|
||||
startTransferClickListener.onClick(v, slides);
|
||||
}
|
||||
});
|
||||
transferControlsStub.get().setCancelClickListener(
|
||||
v -> {
|
||||
if (cancelTransferClickListener != null) {
|
||||
cancelTransferClickListener.onClick(v, slides);
|
||||
}
|
||||
});
|
||||
transferControlsStub.get().setSlides(slides);
|
||||
@@ -111,12 +117,12 @@ public class AlbumThumbnailView extends FrameLayout {
|
||||
thumbnailClickListener = listener;
|
||||
}
|
||||
|
||||
public void setDownloadClickListener(SlidesClickedListener listener) {
|
||||
this.downloadClickListener = listener;
|
||||
public void setStartTransferClickListener(SlidesClickedListener listener) {
|
||||
this.startTransferClickListener = listener;
|
||||
}
|
||||
|
||||
public void setCancelDownloadClickListener(SlidesClickedListener listener) {
|
||||
this.cancelDownloadClickListener = listener;
|
||||
public void setCancelTransferClickListener(SlidesClickedListener listener) {
|
||||
this.cancelTransferClickListener = listener;
|
||||
}
|
||||
|
||||
public void setPlayVideoClickListener(SlideClickListener listener) {
|
||||
@@ -282,8 +288,8 @@ public class AlbumThumbnailView extends FrameLayout {
|
||||
ThumbnailView cell = findViewById(id);
|
||||
cell.showSecondaryText(false);
|
||||
cell.setThumbnailClickListener(defaultThumbnailClickListener);
|
||||
cell.setDownloadClickListener(downloadClickListener);
|
||||
cell.setCancelDownloadClickListener(cancelDownloadClickListener);
|
||||
cell.setStartTransferClickListener(startTransferClickListener);
|
||||
cell.setCancelTransferClickListener(cancelTransferClickListener);
|
||||
if (MediaUtil.isInstantVideoSupported(slide)) {
|
||||
cell.setPlayVideoClickListener(playVideoClickListener);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,6 @@ import android.widget.ImageView;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
|
||||
import com.bumptech.glide.load.resource.bitmap.CenterInside;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
@@ -63,7 +60,7 @@ public class BorderlessImageView extends FrameLayout {
|
||||
image.setImageResource(glideRequests, slide, showControls, false);
|
||||
} else {
|
||||
image.setScaleType(ImageView.ScaleType.CENTER_CROP);
|
||||
image.setImageResource(glideRequests, slide, showControls, false, slide.asAttachment().getWidth(), slide.asAttachment().getHeight());
|
||||
image.setImageResource(glideRequests, slide, showControls, false, slide.asAttachment().width, slide.asAttachment().height);
|
||||
}
|
||||
|
||||
missingShade.setVisibility(showControls ? View.VISIBLE : View.GONE);
|
||||
@@ -74,6 +71,6 @@ public class BorderlessImageView extends FrameLayout {
|
||||
}
|
||||
|
||||
public void setDownloadClickListener(@NonNull SlidesClickedListener listener) {
|
||||
image.setDownloadClickListener(listener);
|
||||
image.setStartTransferClickListener(listener);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode;
|
||||
import org.thoughtcrime.securesms.conversation.v2.computed.FormattedDate;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
@@ -302,7 +302,9 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
|
||||
private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale, @NonNull ConversationItemDisplayMode displayMode) {
|
||||
dateView.forceLayout();
|
||||
if (messageRecord.isFailed()) {
|
||||
if (messageRecord.isMediaPending()) {
|
||||
dateView.setText(null);
|
||||
} else if (messageRecord.isFailed()) {
|
||||
int errorMsg;
|
||||
if (messageRecord.hasFailedWithNetworkFailures()) {
|
||||
errorMsg = R.string.ConversationItem_error_network_not_delivered;
|
||||
@@ -318,7 +320,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
} else if (messageRecord.isRateLimited()) {
|
||||
dateView.setText(R.string.ConversationItem_send_paused);
|
||||
} else if (MessageRecordUtil.isScheduled(messageRecord)) {
|
||||
dateView.setText(DateUtils.getOnlyTimeString(getContext(), locale, ((MediaMmsMessageRecord) messageRecord).getScheduledDate()));
|
||||
dateView.setText(DateUtils.getOnlyTimeString(getContext(), ((MmsMessageRecord) messageRecord).getScheduledDate()));
|
||||
} else {
|
||||
long timestamp = messageRecord.getTimestamp();
|
||||
if (messageRecord.isEditMessage()) {
|
||||
@@ -326,11 +328,18 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
timestamp = messageRecord.getDateSent();
|
||||
}
|
||||
}
|
||||
String date = DateUtils.getDatelessRelativeTimeSpanString(getContext(), locale, timestamp);
|
||||
FormattedDate date = DateUtils.getDatelessRelativeTimeSpanFormattedDate(getContext(), locale, timestamp);
|
||||
String dateLabel = date.getValue();
|
||||
if (displayMode != ConversationItemDisplayMode.Detailed.INSTANCE && messageRecord.isEditMessage() && messageRecord.isLatestRevision()) {
|
||||
date = getContext().getString(R.string.ConversationItem_edited_timestamp_footer, date);
|
||||
if (date.isNow()) {
|
||||
dateLabel = getContext().getString(R.string.ConversationItem_edited_now_timestamp_footer);
|
||||
} else if (date.isRelative()) {
|
||||
dateLabel = getContext().getString(R.string.ConversationItem_edited_relative_timestamp_footer, date.getValue());
|
||||
} else {
|
||||
dateLabel = getContext().getString(R.string.ConversationItem_edited_absolute_timestamp_footer, date.getValue());
|
||||
}
|
||||
}
|
||||
dateView.setText(date);
|
||||
dateView.setText(dateLabel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,7 +426,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
deliveryStatusView.setNone();
|
||||
} else if (messageRecord.isPending()) {
|
||||
deliveryStatusView.setPending();
|
||||
} else if (messageRecord.isRemoteRead()) {
|
||||
} else if (messageRecord.hasReadReceipt()) {
|
||||
deliveryStatusView.setRead();
|
||||
} else if (messageRecord.isDelivered()) {
|
||||
deliveryStatusView.setDelivered();
|
||||
@@ -434,7 +443,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
if (mmsMessageRecord.getSlideDeck().getAudioSlide() != null) {
|
||||
showAudioDurationViews();
|
||||
|
||||
if (messageRecord.getViewedReceiptCount() > 0 || (messageRecord.isOutgoing() && Objects.equals(messageRecord.getToRecipient(), Recipient.self()))) {
|
||||
if (messageRecord.isViewed() || (messageRecord.isOutgoing() && Objects.equals(messageRecord.getToRecipient(), Recipient.self()))) {
|
||||
revealDot.setProgress(1f);
|
||||
} else {
|
||||
revealDot.setProgress(0f);
|
||||
|
||||
@@ -251,10 +251,10 @@ class ConversationItemThumbnail @JvmOverloads constructor(
|
||||
state.applyState(thumbnail, album)
|
||||
}
|
||||
|
||||
fun setDownloadClickListener(listener: SlidesClickedListener?) {
|
||||
fun setStartTransferClickListener(listener: SlidesClickedListener?) {
|
||||
state = state.copy(
|
||||
thumbnailViewState = state.thumbnailViewState.copy(downloadClickListener = listener),
|
||||
albumViewState = state.albumViewState.copy(downloadClickListener = listener)
|
||||
thumbnailViewState = state.thumbnailViewState.copy(startTransferClickListener = listener),
|
||||
albumViewState = state.albumViewState.copy(startTransferClickListener = listener)
|
||||
)
|
||||
|
||||
state.applyState(thumbnail, album)
|
||||
@@ -269,10 +269,10 @@ class ConversationItemThumbnail @JvmOverloads constructor(
|
||||
state.applyState(thumbnail, album)
|
||||
}
|
||||
|
||||
fun setCancelDownloadClickListener(listener: SlidesClickedListener?) {
|
||||
fun setCancelTransferClickListener(listener: SlidesClickedListener?) {
|
||||
state = state.copy(
|
||||
thumbnailViewState = state.thumbnailViewState.copy(cancelDownloadClickListener = listener),
|
||||
albumViewState = state.albumViewState.copy(cancelDownloadClickListener = listener)
|
||||
thumbnailViewState = state.thumbnailViewState.copy(cancelTransferClickListener = listener),
|
||||
albumViewState = state.albumViewState.copy(cancelTransferClickListener = listener)
|
||||
)
|
||||
|
||||
state.applyState(thumbnail, album)
|
||||
|
||||
@@ -34,9 +34,9 @@ data class ConversationItemThumbnailState(
|
||||
@IgnoredOnParcel
|
||||
private val clickListener: SlideClickListener? = null,
|
||||
@IgnoredOnParcel
|
||||
private val downloadClickListener: SlidesClickedListener? = null,
|
||||
private val startTransferClickListener: SlidesClickedListener? = null,
|
||||
@IgnoredOnParcel
|
||||
private val cancelDownloadClickListener: SlidesClickedListener? = null,
|
||||
private val cancelTransferClickListener: SlidesClickedListener? = null,
|
||||
@IgnoredOnParcel
|
||||
private val playVideoClickListener: SlideClickListener? = null,
|
||||
@IgnoredOnParcel
|
||||
@@ -63,8 +63,8 @@ data class ConversationItemThumbnailState(
|
||||
thumbnailView.get().isClickable = clickable
|
||||
thumbnailView.get().setRadii(cornerTopLeft, cornerTopRight, cornerBottomRight, cornerBottomLeft)
|
||||
thumbnailView.get().setThumbnailClickListener(clickListener)
|
||||
thumbnailView.get().setDownloadClickListener(downloadClickListener)
|
||||
thumbnailView.get().setCancelDownloadClickListener(cancelDownloadClickListener)
|
||||
thumbnailView.get().setStartTransferClickListener(startTransferClickListener)
|
||||
thumbnailView.get().setCancelTransferClickListener(cancelTransferClickListener)
|
||||
thumbnailView.get().setPlayVideoClickListener(playVideoClickListener)
|
||||
thumbnailView.get().setOnLongClickListener(longClickListener)
|
||||
thumbnailView.get().setBounds(minWidth, maxWidth, minHeight, maxHeight)
|
||||
@@ -78,9 +78,9 @@ data class ConversationItemThumbnailState(
|
||||
@IgnoredOnParcel
|
||||
private val clickListener: SlideClickListener? = null,
|
||||
@IgnoredOnParcel
|
||||
private val downloadClickListener: SlidesClickedListener? = null,
|
||||
private val startTransferClickListener: SlidesClickedListener? = null,
|
||||
@IgnoredOnParcel
|
||||
private val cancelDownloadClickListener: SlidesClickedListener? = null,
|
||||
private val cancelTransferClickListener: SlidesClickedListener? = null,
|
||||
@IgnoredOnParcel
|
||||
private val playVideoClickListener: SlideClickListener? = null,
|
||||
@IgnoredOnParcel
|
||||
@@ -103,8 +103,8 @@ data class ConversationItemThumbnailState(
|
||||
albumView.get().isClickable = clickable
|
||||
albumView.get().setRadii(cornerTopLeft, cornerTopRight, cornerBottomRight, cornerBottomLeft)
|
||||
albumView.get().setThumbnailClickListener(clickListener)
|
||||
albumView.get().setDownloadClickListener(downloadClickListener)
|
||||
albumView.get().setCancelDownloadClickListener(cancelDownloadClickListener)
|
||||
albumView.get().setStartTransferClickListener(startTransferClickListener)
|
||||
albumView.get().setCancelTransferClickListener(cancelTransferClickListener)
|
||||
albumView.get().setPlayVideoClickListener(playVideoClickListener)
|
||||
albumView.get().setOnLongClickListener(longClickListener)
|
||||
albumView.get().setCellBackgroundColor(cellBackgroundColor)
|
||||
|
||||
@@ -105,6 +105,11 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
viewModel.onVisible()
|
||||
}
|
||||
|
||||
private fun submitLogs(debugLog: String, purpose: Purpose) {
|
||||
CommunicationActions.openEmail(
|
||||
requireContext(),
|
||||
|
||||
@@ -42,7 +42,9 @@ abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFr
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
WindowUtil.initializeScreenshotSecurity(requireContext(), dialog!!.window!!)
|
||||
dialog?.window?.let { window ->
|
||||
WindowUtil.initializeScreenshotSecurity(requireContext(), window)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
|
||||
@@ -26,12 +26,20 @@ class InputAwareConstraintLayout @JvmOverloads constructor(
|
||||
private var inputId: Int? = null
|
||||
private var input: Fragment? = null
|
||||
private var wasKeyboardVisibleBeforeToggle: Boolean = false
|
||||
private val listeners: MutableSet<Listener> = mutableSetOf()
|
||||
|
||||
val isInputShowing: Boolean
|
||||
get() = input != null
|
||||
|
||||
lateinit var fragmentManager: FragmentManager
|
||||
var listener: Listener? = null
|
||||
|
||||
fun addInputListener(listener: Listener) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
fun remoteInputListener(listener: Listener) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
fun showSoftkey(editText: EditText) {
|
||||
ViewUtil.focusAndShowKeyboard(editText)
|
||||
@@ -44,6 +52,33 @@ class InputAwareConstraintLayout @JvmOverloads constructor(
|
||||
hideInput(resetKeyboardGuideline = true)
|
||||
}
|
||||
|
||||
fun runAfterAllHidden(imeTarget: EditText, onHidden: () -> Unit) {
|
||||
if (isInputShowing || isKeyboardShowing) {
|
||||
val listener = object : Listener, KeyboardStateListener {
|
||||
override fun onInputHidden() {
|
||||
onHidden()
|
||||
remoteInputListener(this)
|
||||
removeKeyboardStateListener(this)
|
||||
}
|
||||
|
||||
override fun onKeyboardHidden() {
|
||||
onHidden()
|
||||
remoteInputListener(this)
|
||||
removeKeyboardStateListener(this)
|
||||
}
|
||||
|
||||
override fun onInputShown() = Unit
|
||||
override fun onKeyboardShown() = Unit
|
||||
}
|
||||
|
||||
addInputListener(listener)
|
||||
addKeyboardStateListener(listener)
|
||||
hideAll(imeTarget)
|
||||
} else {
|
||||
onHidden()
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleInput(fragmentCreator: FragmentCreator, imeTarget: EditText, showSoftKeyOnHide: Boolean = wasKeyboardVisibleBeforeToggle) {
|
||||
if (fragmentCreator.id == inputId) {
|
||||
if (showSoftKeyOnHide) {
|
||||
@@ -85,7 +120,7 @@ class InputAwareConstraintLayout @JvmOverloads constructor(
|
||||
overrideKeyboardGuidelineWithPreviousHeight()
|
||||
ViewUtil.hideKeyboard(context, imeTarget)
|
||||
|
||||
listener?.onInputShown()
|
||||
listeners.forEach { it.onInputShown() }
|
||||
}
|
||||
|
||||
private fun hideInput(resetKeyboardGuideline: Boolean) {
|
||||
@@ -107,7 +142,7 @@ class InputAwareConstraintLayout @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
if (inputHidden) {
|
||||
listener?.onInputHidden()
|
||||
listeners.forEach { it.onInputHidden() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdap
|
||||
import org.thoughtcrime.securesms.conversation.MessageStyler;
|
||||
import org.thoughtcrime.securesms.conversation.VoiceNoteDraftView;
|
||||
import org.thoughtcrime.securesms.database.DraftTable;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.Quote;
|
||||
@@ -423,10 +423,10 @@ public class InputPanel extends ConstraintLayout
|
||||
}
|
||||
|
||||
private void updateEditModeThumbnail(@NonNull GlideRequests glideRequests) {
|
||||
if (messageToEdit instanceof MediaMmsMessageRecord) {
|
||||
MediaMmsMessageRecord mediaEditMessage = (MediaMmsMessageRecord) messageToEdit;
|
||||
SlideDeck slideDeck = mediaEditMessage.getSlideDeck();
|
||||
Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null);
|
||||
if (messageToEdit instanceof MmsMessageRecord) {
|
||||
MmsMessageRecord mediaEditMessage = (MmsMessageRecord) messageToEdit;
|
||||
SlideDeck slideDeck = mediaEditMessage.getSlideDeck();
|
||||
Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null);
|
||||
|
||||
if (imageVideoSlide != null && imageVideoSlide.getUri() != null) {
|
||||
editMessageThumbnail.setVisibility(VISIBLE);
|
||||
|
||||
@@ -51,13 +51,14 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
private val windowTypes = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
|
||||
}
|
||||
|
||||
private val statusBarGuideline: Guideline? by lazy { findViewById(R.id.status_bar_guideline) }
|
||||
protected val statusBarGuideline: Guideline? by lazy { findViewById(R.id.status_bar_guideline) }
|
||||
private val navigationBarGuideline: Guideline? by lazy { findViewById(R.id.navigation_bar_guideline) }
|
||||
private val parentStartGuideline: Guideline? by lazy { findViewById(R.id.parent_start_guideline) }
|
||||
private val parentEndGuideline: Guideline? by lazy { findViewById(R.id.parent_end_guideline) }
|
||||
private val keyboardGuideline: Guideline? by lazy { findViewById(R.id.keyboard_guideline) }
|
||||
|
||||
private val listeners: MutableList<KeyboardStateListener> = mutableListOf()
|
||||
private val windowInsetsListeners: MutableSet<WindowInsetsListener> = mutableSetOf()
|
||||
private val keyboardStateListeners: MutableSet<KeyboardStateListener> = mutableSetOf()
|
||||
private val keyboardAnimator = KeyboardInsetAnimator()
|
||||
private val displayMetrics = DisplayMetrics()
|
||||
private var overridingKeyboard: Boolean = false
|
||||
@@ -82,20 +83,35 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
fun addKeyboardStateListener(listener: KeyboardStateListener) {
|
||||
listeners += listener
|
||||
keyboardStateListeners += listener
|
||||
}
|
||||
|
||||
fun removeKeyboardStateListener(listener: KeyboardStateListener) {
|
||||
listeners.remove(listener)
|
||||
keyboardStateListeners.remove(listener)
|
||||
}
|
||||
|
||||
fun addWindowInsetsListener(listener: WindowInsetsListener) {
|
||||
windowInsetsListeners += listener
|
||||
}
|
||||
|
||||
fun removeWindowInsetsListener(listener: WindowInsetsListener) {
|
||||
windowInsetsListeners.remove(listener)
|
||||
}
|
||||
|
||||
private fun applyInsets(windowInsets: Insets, keyboardInsets: Insets) {
|
||||
val isLtr = ViewUtil.isLtr(this)
|
||||
|
||||
statusBarGuideline?.setGuidelineBegin(windowInsets.top)
|
||||
navigationBarGuideline?.setGuidelineEnd(windowInsets.bottom)
|
||||
parentStartGuideline?.setGuidelineBegin(if (isLtr) windowInsets.left else windowInsets.right)
|
||||
parentEndGuideline?.setGuidelineEnd(if (isLtr) windowInsets.right else windowInsets.left)
|
||||
val statusBar = windowInsets.top
|
||||
val navigationBar = windowInsets.bottom
|
||||
val parentStart = if (isLtr) windowInsets.left else windowInsets.right
|
||||
val parentEnd = if (isLtr) windowInsets.right else windowInsets.left
|
||||
|
||||
statusBarGuideline?.setGuidelineBegin(statusBar)
|
||||
navigationBarGuideline?.setGuidelineEnd(navigationBar)
|
||||
parentStartGuideline?.setGuidelineBegin(parentStart)
|
||||
parentEndGuideline?.setGuidelineEnd(parentEnd)
|
||||
|
||||
windowInsetsListeners.forEach { it.onApplyWindowInsets(statusBar, navigationBar, parentStart, parentEnd) }
|
||||
|
||||
if (keyboardInsets.bottom > 0) {
|
||||
setKeyboardHeight(keyboardInsets.bottom)
|
||||
@@ -113,7 +129,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
if (previousKeyboardHeight != keyboardInsets.bottom) {
|
||||
listeners.forEach {
|
||||
keyboardStateListeners.forEach {
|
||||
if (previousKeyboardHeight <= 0) {
|
||||
it.onKeyboardShown()
|
||||
} else {
|
||||
@@ -191,6 +207,10 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
|
||||
fun onKeyboardHidden()
|
||||
}
|
||||
|
||||
interface WindowInsetsListener {
|
||||
fun onApplyWindowInsets(statusBar: Int, navigationBar: Int, parentStart: Int, parentEnd: Int)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts the [keyboardGuideline] to move with the IME keyboard opening or closing.
|
||||
*/
|
||||
|
||||
@@ -22,7 +22,7 @@ data class LinkPreviewViewThumbnailState(
|
||||
fun applyState(thumbnail: Stub<OutlinedThumbnailView>) {
|
||||
if (thumbnail.resolved()) {
|
||||
thumbnail.get().setCorners(cornerTopLeft, cornerTopRight, cornerBottomRight, cornerBottomLeft)
|
||||
thumbnail.get().setDownloadClickListener(downloadListener)
|
||||
thumbnail.get().setStartTransferClickListener(downloadListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@ import org.thoughtcrime.securesms.crash.CrashConfig
|
||||
import org.thoughtcrime.securesms.database.LogDatabase
|
||||
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository
|
||||
|
||||
class PromptLogsViewModel(private val context: Application, purpose: DebugLogsPromptDialogFragment.Purpose) : AndroidViewModel(context) {
|
||||
class PromptLogsViewModel(private val context: Application, private val purpose: DebugLogsPromptDialogFragment.Purpose) : AndroidViewModel(context) {
|
||||
|
||||
private val submitDebugLogRepository = SubmitDebugLogRepository()
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
init {
|
||||
fun onVisible() {
|
||||
if (purpose == DebugLogsPromptDialogFragment.Purpose.CRASH) {
|
||||
disposables += Single
|
||||
.fromCallable {
|
||||
|
||||
@@ -86,12 +86,12 @@ public class ThumbnailView extends FrameLayout {
|
||||
|
||||
private final CornerMask cornerMask;
|
||||
|
||||
private final Stub<TransferControlView> transferControlViewStub;
|
||||
private SlideClickListener thumbnailClickListener = null;
|
||||
private SlidesClickedListener downloadClickListener = null;
|
||||
private SlidesClickedListener cancelDownloadClickListener = null;
|
||||
private SlideClickListener playVideoClickListener = null;
|
||||
private Slide slide = null;
|
||||
private final Stub<TransferControlView> transferControlViewStub;
|
||||
private SlideClickListener thumbnailClickListener = null;
|
||||
private SlidesClickedListener startTransferClickListener = null;
|
||||
private SlidesClickedListener cancelTransferClickListener = null;
|
||||
private SlideClickListener playVideoClickListener = null;
|
||||
private Slide slide = null;
|
||||
|
||||
|
||||
public ThumbnailView(Context context) {
|
||||
@@ -363,7 +363,7 @@ public class ThumbnailView extends FrameLayout {
|
||||
}
|
||||
|
||||
if (showControls) {
|
||||
transferControlViewStub.get().setDownloadClickListener(new DownloadClickDispatcher());
|
||||
transferControlViewStub.get().setTransferClickListener(new DownloadClickDispatcher());
|
||||
transferControlViewStub.get().setCancelClickListener(new CancelClickDispatcher());
|
||||
if (MediaUtil.isInstantVideoSupported(slide)) {
|
||||
transferControlViewStub.get().setInstantPlaybackClickListener(new InstantVideoClickDispatcher());
|
||||
@@ -397,7 +397,7 @@ public class ThumbnailView extends FrameLayout {
|
||||
|
||||
Log.i(TAG, "loading part with id " + slide.asAttachment().getUri()
|
||||
+ ", progress " + slide.getTransferState() + ", fast preflight id: " +
|
||||
slide.asAttachment().getFastPreflightId());
|
||||
slide.asAttachment().fastPreflightId);
|
||||
|
||||
BlurHash previousBlurHash = this.slide != null ? this.slide.getPlaceholderBlur() : null;
|
||||
|
||||
@@ -516,12 +516,12 @@ public class ThumbnailView extends FrameLayout {
|
||||
this.thumbnailClickListener = listener;
|
||||
}
|
||||
|
||||
public void setDownloadClickListener(SlidesClickedListener listener) {
|
||||
this.downloadClickListener = listener;
|
||||
public void setStartTransferClickListener(SlidesClickedListener listener) {
|
||||
this.startTransferClickListener = listener;
|
||||
}
|
||||
|
||||
public void setCancelDownloadClickListener(SlidesClickedListener listener) {
|
||||
this.cancelDownloadClickListener = listener;
|
||||
public void setCancelTransferClickListener(SlidesClickedListener listener) {
|
||||
this.cancelTransferClickListener = listener;
|
||||
}
|
||||
|
||||
public void setPlayVideoClickListener(SlideClickListener listener) {
|
||||
@@ -532,8 +532,8 @@ public class ThumbnailView extends FrameLayout {
|
||||
if (Util.equals(slide, other)) {
|
||||
|
||||
if (slide != null && other != null) {
|
||||
byte[] digestLeft = slide.asAttachment().getDigest();
|
||||
byte[] digestRight = other.asAttachment().getDigest();
|
||||
byte[] digestLeft = slide.asAttachment().remoteDigest;
|
||||
byte[] digestRight = other.asAttachment().remoteDigest;
|
||||
|
||||
return Arrays.equals(digestLeft, digestRight);
|
||||
}
|
||||
@@ -667,10 +667,10 @@ public class ThumbnailView extends FrameLayout {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
Log.i(TAG, "onClick() for download button");
|
||||
if (downloadClickListener != null && slide != null) {
|
||||
downloadClickListener.onClick(view, Collections.singletonList(slide));
|
||||
if (startTransferClickListener != null && slide != null) {
|
||||
startTransferClickListener.onClick(view, Collections.singletonList(slide));
|
||||
} else {
|
||||
Log.w(TAG, "Received a download button click, but unable to execute it. slide: " + slide + " downloadClickListener: " + downloadClickListener);
|
||||
Log.w(TAG, "Received a download button click, but unable to execute it. slide: " + slide + " downloadClickListener: " + startTransferClickListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -679,10 +679,10 @@ public class ThumbnailView extends FrameLayout {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
Log.i(TAG, "onClick() for cancel button");
|
||||
if (cancelDownloadClickListener != null && slide != null) {
|
||||
cancelDownloadClickListener.onClick(view, Collections.singletonList(slide));
|
||||
if (cancelTransferClickListener != null && slide != null) {
|
||||
cancelTransferClickListener.onClick(view, Collections.singletonList(slide));
|
||||
} else {
|
||||
Log.w(TAG, "Received a cancel button click, but unable to execute it. slide: " + slide + " cancelDownloadClickListener: " + cancelDownloadClickListener);
|
||||
Log.w(TAG, "Received a cancel button click, but unable to execute it. slide: " + slide + " cancelDownloadClickListener: " + cancelTransferClickListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,21 +171,23 @@ public class TooltipPopup extends PopupWindow {
|
||||
ShapeAppearanceModel.Builder shapeAppearanceModel = ShapeAppearanceModel.builder()
|
||||
.setAllCornerSizes(DimensionUnit.DP.toPixels(18));
|
||||
|
||||
// If the arrow is within the last 20dp of the right hand side, use RIGHT and set corner to 9dp
|
||||
onLayout(() -> {
|
||||
if (arrow.getX() > getContentView().getWidth() / 2f) {
|
||||
arrow.setImageResource(R.drawable.ic_tooltip_arrow_up_right);
|
||||
}
|
||||
if (position == POSITION_BELOW) {
|
||||
// If the arrow is within the last 20dp of the right hand side, use RIGHT and set corner to 9dp
|
||||
onLayout(() -> {
|
||||
if (arrow.getX() > getContentView().getWidth() / 2f) {
|
||||
arrow.setImageResource(R.drawable.ic_tooltip_arrow_up_right);
|
||||
}
|
||||
|
||||
float arrowEnd = arrow.getX() + arrow.getRight();
|
||||
if (arrowEnd > getContentView().getRight() - DimensionUnit.DP.toPixels(20)) {
|
||||
shapeableBubbleBackground.setShapeAppearanceModel(shapeAppearanceModel.setTopRightCornerSize(DimensionUnit.DP.toPixels(9f)).build());
|
||||
bubble.setBackground(shapeableBubbleBackground);
|
||||
} else if (arrowEnd < DimensionUnit.DP.toPixels(20)) {
|
||||
shapeableBubbleBackground.setShapeAppearanceModel(shapeAppearanceModel.setTopLeftCornerSize(DimensionUnit.DP.toPixels(9f)).build());
|
||||
bubble.setBackground(shapeableBubbleBackground);
|
||||
}
|
||||
});
|
||||
float arrowEnd = arrow.getX() + arrow.getRight();
|
||||
if (arrowEnd > getContentView().getRight() - DimensionUnit.DP.toPixels(20)) {
|
||||
shapeableBubbleBackground.setShapeAppearanceModel(shapeAppearanceModel.setTopRightCornerSize(DimensionUnit.DP.toPixels(9f)).build());
|
||||
bubble.setBackground(shapeableBubbleBackground);
|
||||
} else if (arrowEnd < DimensionUnit.DP.toPixels(20)) {
|
||||
shapeableBubbleBackground.setShapeAppearanceModel(shapeAppearanceModel.setTopLeftCornerSize(DimensionUnit.DP.toPixels(9f)).build());
|
||||
bubble.setBackground(shapeableBubbleBackground);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
showAsDropDown(anchor, xoffset, yoffset);
|
||||
|
||||
@@ -355,7 +355,7 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
}
|
||||
|
||||
int overflowEnd = getLayout().getLineEnd(maxLines);
|
||||
CharSequence overflow = getText().subSequence(overflowStart, overflowEnd);
|
||||
CharSequence overflow = new SpannableString(getText().subSequence(overflowStart, overflowEnd).toString());
|
||||
float adjust = overflowText != null ? getPaint().measureText(overflowText, 0, overflowText.length()) : 0f;
|
||||
CharSequence ellipsized = TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END);
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.recyclerview
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
/**
|
||||
* Ignores all touch events, purely for rendering views in a recyclable manner.
|
||||
*/
|
||||
class NoTouchingRecyclerView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : RecyclerView(context, attrs, defStyleAttr) {
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(e: MotionEvent?): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onInterceptTouchEvent(e: MotionEvent?): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -264,7 +264,6 @@ class ChangeNumberRepository(
|
||||
accountManager.setPreKeys(
|
||||
PreKeyUpload(
|
||||
serviceIdType = ServiceIdType.PNI,
|
||||
identityKey = pniProtocolStore.identityKeyPair.publicKey,
|
||||
signedPreKey = signedPreKey,
|
||||
oneTimeEcPreKeys = oneTimePreKeys,
|
||||
lastResortKyberPreKey = null,
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.core.util.AppUtil
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.concurrent.SimpleTask
|
||||
import org.signal.core.util.logging.Log
|
||||
@@ -161,6 +160,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Backup Playground"),
|
||||
summary = DSLSettingsText.from("Test backup import/export."),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalBackupPlaygroundFragment())
|
||||
}
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from("'Internal Details' button"),
|
||||
summary = DSLSettingsText.from("Show a button in conversation settings that lets you see more information about a user."),
|
||||
@@ -492,9 +499,17 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
if (SignalStore.donationsValues().getSubscriber() != null) {
|
||||
dividerPref()
|
||||
switchPref(
|
||||
title = DSLSettingsText.from("Disable LBRed"),
|
||||
isChecked = state.callingDisableLBRed,
|
||||
onClick = {
|
||||
viewModel.setInternalCallingDisableLBRed(!state.callingDisableLBRed)
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
if (SignalStore.donationsValues().getSubscriber() != null) {
|
||||
sectionHeaderPref(DSLSettingsText.from("Badges"))
|
||||
|
||||
clickPref(
|
||||
@@ -527,6 +542,8 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
Toast.makeText(context, "Cleared", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
}
|
||||
|
||||
if (state.hasPendingOneTimeDonation) {
|
||||
@@ -591,6 +608,20 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Add remote donate megaphone"),
|
||||
onClick = {
|
||||
viewModel.addRemoteDonateMegaphone()
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Add donate_friend remote megaphone"),
|
||||
onClick = {
|
||||
viewModel.addRemoteDonateFriendMegaphone()
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(DSLSettingsText.from("CDS"))
|
||||
@@ -700,10 +731,11 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
.setTitle("Corrupt your username?")
|
||||
.setMessage("Are you sure? You might not be able to get your original username back.")
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
val random = "${Hex.toStringCondensed(Util.getSecretBytes(4))}.${Random.nextInt(1, 100)}"
|
||||
val random = "${(1..5).map { ('a'..'z').random() }.joinToString(separator = "") }.${Random.nextInt(1, 100)}"
|
||||
|
||||
SignalStore.account().username = random
|
||||
SignalDatabase.recipients.setUsername(Recipient.self().id, random)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
|
||||
Toast.makeText(context, "Done", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -724,6 +756,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
entropy = Util.getSecretBytes(32),
|
||||
serverId = SignalStore.account().usernameLink?.serverId ?: UUID.randomUUID()
|
||||
)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
Toast.makeText(context, "Done", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.internal
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
|
||||
import org.thoughtcrime.securesms.database.model.addStyle
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.emoji.EmojiFiles
|
||||
import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
|
||||
import org.thoughtcrime.securesms.jobs.FetchRemoteMegaphoneImageJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.notifications.v2.ConversationId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.releasechannel.ReleaseChannel
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
class InternalSettingsRepository(context: Context) {
|
||||
|
||||
@@ -58,4 +63,34 @@ class InternalSettingsRepository(context: Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addRemoteMegaphone(actionId: RemoteMegaphoneRecord.ActionId) {
|
||||
SignalExecutors.UNBOUNDED.execute {
|
||||
val record = RemoteMegaphoneRecord(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
priority = 100,
|
||||
countries = "*:1000000",
|
||||
minimumVersion = 1,
|
||||
doNotShowBefore = System.currentTimeMillis() - 2.days.inWholeMilliseconds,
|
||||
doNotShowAfter = System.currentTimeMillis() + 28.days.inWholeMilliseconds,
|
||||
showForNumberOfDays = 30,
|
||||
conditionalId = null,
|
||||
primaryActionId = actionId,
|
||||
secondaryActionId = RemoteMegaphoneRecord.ActionId.SNOOZE,
|
||||
imageUrl = "/static/release-notes/donate-heart.png",
|
||||
title = "Donate Test",
|
||||
body = "Donate body test.",
|
||||
primaryActionText = "Donate",
|
||||
secondaryActionText = "Snooze",
|
||||
primaryActionData = null,
|
||||
secondaryActionData = JSONObject("{ \"snoozeDurationDays\": [5, 7, 100] }")
|
||||
)
|
||||
|
||||
SignalDatabase.remoteMegaphones.insert(record)
|
||||
|
||||
if (record.imageUrl != null) {
|
||||
ApplicationDependencies.getJobManager().add(FetchRemoteMegaphoneImageJob(record.uuid, record.imageUrl))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ data class InternalSettingsState(
|
||||
val callingAudioProcessingMethod: CallManager.AudioProcessingMethod,
|
||||
val callingDataMode: CallManager.DataMode,
|
||||
val callingDisableTelecom: Boolean,
|
||||
val callingDisableLBRed: Boolean,
|
||||
val useBuiltInEmojiSet: Boolean,
|
||||
val emojiVersion: EmojiFiles.Version?,
|
||||
val removeSenderKeyMinimium: Boolean,
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import org.signal.ringrtc.CallManager
|
||||
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
|
||||
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
|
||||
import org.thoughtcrime.securesms.keyvalue.InternalValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -113,6 +114,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setInternalCallingDisableLBRed(enabled: Boolean) {
|
||||
preferenceDataStore.putBoolean(InternalValues.CALLING_DISABLE_LBRED, enabled)
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setUseConversationItemV2Media(enabled: Boolean) {
|
||||
SignalStore.internalValues().setUseConversationItemV2Media(enabled)
|
||||
refresh()
|
||||
@@ -122,6 +128,14 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
repository.addSampleReleaseNote()
|
||||
}
|
||||
|
||||
fun addRemoteDonateMegaphone() {
|
||||
repository.addRemoteMegaphone(RemoteMegaphoneRecord.ActionId.DONATE)
|
||||
}
|
||||
|
||||
fun addRemoteDonateFriendMegaphone() {
|
||||
repository.addRemoteMegaphone(RemoteMegaphoneRecord.ActionId.DONATE_FOR_FRIEND)
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
store.update { getState().copy(emojiVersion = it.emojiVersion) }
|
||||
}
|
||||
@@ -138,6 +152,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
callingAudioProcessingMethod = SignalStore.internalValues().callingAudioProcessingMethod(),
|
||||
callingDataMode = SignalStore.internalValues().callingDataMode(),
|
||||
callingDisableTelecom = SignalStore.internalValues().callingDisableTelecom(),
|
||||
callingDisableLBRed = SignalStore.internalValues().callingDisableLBRed(),
|
||||
useBuiltInEmojiSet = SignalStore.internalValues().forceBuiltInEmoji(),
|
||||
emojiVersion = null,
|
||||
removeSenderKeyMinimium = SignalStore.internalValues().removeSenderKeyMinimum(),
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.internal.backup
|
||||
|
||||
import android.app.Activity.RESULT_OK
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.getLength
|
||||
import org.signal.core.util.roundedString
|
||||
import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.BackupState
|
||||
import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.BackupUploadState
|
||||
import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.ScreenState
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
|
||||
class InternalBackupPlaygroundFragment : ComposeFragment() {
|
||||
|
||||
private val viewModel: InternalBackupPlaygroundViewModel by viewModels()
|
||||
private lateinit var exportFileLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var importFileLauncher: ActivityResultLauncher<Intent>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
exportFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
requireContext().contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.write(viewModel.backupData!!)
|
||||
Toast.makeText(requireContext(), "Saved successfully", Toast.LENGTH_SHORT).show()
|
||||
} ?: Toast.makeText(requireContext(), "Failed to open output stream", Toast.LENGTH_SHORT).show()
|
||||
} ?: Toast.makeText(requireContext(), "No URI selected", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
importFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
requireContext().contentResolver.getLength(uri)?.let { length ->
|
||||
viewModel.import(length) { requireContext().contentResolver.openInputStream(uri)!! }
|
||||
}
|
||||
} ?: Toast.makeText(requireContext(), "No URI selected", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state
|
||||
|
||||
Screen(
|
||||
state = state,
|
||||
onExportClicked = { viewModel.export() },
|
||||
onImportMemoryClicked = { viewModel.import() },
|
||||
onImportFileClicked = {
|
||||
val intent = Intent().apply {
|
||||
action = Intent.ACTION_GET_CONTENT
|
||||
type = "application/octet-stream"
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
}
|
||||
|
||||
importFileLauncher.launch(intent)
|
||||
},
|
||||
onPlaintextClicked = { viewModel.onPlaintextToggled() },
|
||||
onSaveToDiskClicked = {
|
||||
val intent = Intent().apply {
|
||||
action = Intent.ACTION_CREATE_DOCUMENT
|
||||
type = "application/octet-stream"
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
putExtra(Intent.EXTRA_TITLE, "backup-${if (state.plaintext) "plaintext" else "encrypted"}-${System.currentTimeMillis()}.bin")
|
||||
}
|
||||
|
||||
exportFileLauncher.launch(intent)
|
||||
},
|
||||
onUploadToRemoteClicked = { viewModel.uploadBackupToRemote() },
|
||||
onCheckRemoteBackupStateClicked = { viewModel.checkRemoteBackupState() }
|
||||
)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Screen(
|
||||
state: ScreenState,
|
||||
onExportClicked: () -> Unit = {},
|
||||
onImportMemoryClicked: () -> Unit = {},
|
||||
onImportFileClicked: () -> Unit = {},
|
||||
onPlaintextClicked: () -> Unit = {},
|
||||
onSaveToDiskClicked: () -> Unit = {},
|
||||
onUploadToRemoteClicked: () -> Unit = {},
|
||||
onCheckRemoteBackupStateClicked: () -> Unit = {}
|
||||
) {
|
||||
Surface {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
StateLabel(text = "Plaintext?")
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Switch(
|
||||
checked = state.plaintext,
|
||||
onCheckedChange = { onPlaintextClicked() }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Buttons.LargePrimary(
|
||||
onClick = onExportClicked,
|
||||
enabled = !state.backupState.inProgress
|
||||
) {
|
||||
Text("Export")
|
||||
}
|
||||
|
||||
Dividers.Default()
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onImportMemoryClicked,
|
||||
enabled = state.backupState == BackupState.EXPORT_DONE
|
||||
) {
|
||||
Text("Import from memory")
|
||||
}
|
||||
Buttons.LargeTonal(
|
||||
onClick = onImportFileClicked
|
||||
) {
|
||||
Text("Import from file")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
when (state.backupState) {
|
||||
BackupState.NONE -> {
|
||||
StateLabel("")
|
||||
}
|
||||
BackupState.EXPORT_IN_PROGRESS -> {
|
||||
StateLabel("Export in progress...")
|
||||
}
|
||||
BackupState.EXPORT_DONE -> {
|
||||
StateLabel("Export complete. Sitting in memory. You can click 'Import' to import that data, save it to a file, or upload it to remote.")
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Buttons.MediumTonal(onClick = onSaveToDiskClicked) {
|
||||
Text("Save to file")
|
||||
}
|
||||
}
|
||||
BackupState.IMPORT_IN_PROGRESS -> {
|
||||
StateLabel("Import in progress...")
|
||||
}
|
||||
}
|
||||
|
||||
Dividers.Default()
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onCheckRemoteBackupStateClicked
|
||||
) {
|
||||
Text("Check remote backup state")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
when (state.remoteBackupState) {
|
||||
is InternalBackupPlaygroundViewModel.RemoteBackupState.Available -> {
|
||||
StateLabel("Exists/allocated. Space used by media: ${state.remoteBackupState.response.usedSpace ?: 0} bytes (${state.remoteBackupState.response.usedSpace?.bytes?.inMebiBytes?.roundedString(3) ?: 0} MiB)")
|
||||
}
|
||||
InternalBackupPlaygroundViewModel.RemoteBackupState.GeneralError -> {
|
||||
StateLabel("Hit an unknown error. Check the logs.")
|
||||
}
|
||||
InternalBackupPlaygroundViewModel.RemoteBackupState.NotFound -> {
|
||||
StateLabel("Not found.")
|
||||
}
|
||||
InternalBackupPlaygroundViewModel.RemoteBackupState.Unknown -> {
|
||||
StateLabel("Hit the button above to check the state.")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Buttons.LargePrimary(
|
||||
onClick = onUploadToRemoteClicked,
|
||||
enabled = state.backupState == BackupState.EXPORT_DONE
|
||||
) {
|
||||
Text("Upload to remote")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
when (state.uploadState) {
|
||||
BackupUploadState.NONE -> {
|
||||
StateLabel("")
|
||||
}
|
||||
BackupUploadState.UPLOAD_IN_PROGRESS -> {
|
||||
StateLabel("Upload in progress...")
|
||||
}
|
||||
BackupUploadState.UPLOAD_DONE -> {
|
||||
StateLabel("Upload complete.")
|
||||
}
|
||||
BackupUploadState.UPLOAD_FAILED -> {
|
||||
StateLabel("Upload failed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StateLabel(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun PreviewScreen() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
Screen(state = ScreenState(backupState = BackupState.NONE, plaintext = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun PreviewScreenExportInProgress() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
Screen(state = ScreenState(backupState = BackupState.EXPORT_IN_PROGRESS, plaintext = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun PreviewScreenExportDone() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
Screen(state = ScreenState(backupState = BackupState.EXPORT_DONE, plaintext = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun PreviewScreenImportInProgress() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
Screen(state = ScreenState(backupState = BackupState.IMPORT_IN_PROGRESS, plaintext = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun PreviewScreenUploadInProgress() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
Screen(state = ScreenState(uploadState = BackupUploadState.UPLOAD_IN_PROGRESS, plaintext = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.internal.backup
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveGetBackupInfoResponse
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
|
||||
class InternalBackupPlaygroundViewModel : ViewModel() {
|
||||
|
||||
var backupData: ByteArray? = null
|
||||
|
||||
val disposables = CompositeDisposable()
|
||||
|
||||
private val _state: MutableState<ScreenState> = mutableStateOf(ScreenState(backupState = BackupState.NONE, uploadState = BackupUploadState.NONE, plaintext = false))
|
||||
val state: State<ScreenState> = _state
|
||||
|
||||
fun export() {
|
||||
_state.value = _state.value.copy(backupState = BackupState.EXPORT_IN_PROGRESS)
|
||||
val plaintext = _state.value.plaintext
|
||||
|
||||
disposables += Single.fromCallable { BackupRepository.export(plaintext = plaintext) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { data ->
|
||||
backupData = data
|
||||
_state.value = _state.value.copy(backupState = BackupState.EXPORT_DONE)
|
||||
}
|
||||
}
|
||||
|
||||
fun import() {
|
||||
backupData?.let {
|
||||
_state.value = _state.value.copy(backupState = BackupState.IMPORT_IN_PROGRESS)
|
||||
val plaintext = _state.value.plaintext
|
||||
|
||||
val self = Recipient.self()
|
||||
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
|
||||
|
||||
disposables += Single.fromCallable { BackupRepository.import(it.size.toLong(), { ByteArrayInputStream(it) }, selfData, plaintext = plaintext) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { nothing ->
|
||||
backupData = null
|
||||
_state.value = _state.value.copy(backupState = BackupState.NONE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun import(length: Long, inputStreamFactory: () -> InputStream) {
|
||||
_state.value = _state.value.copy(backupState = BackupState.IMPORT_IN_PROGRESS)
|
||||
val plaintext = _state.value.plaintext
|
||||
|
||||
val self = Recipient.self()
|
||||
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
|
||||
|
||||
disposables += Single.fromCallable { BackupRepository.import(length, inputStreamFactory, selfData, plaintext = plaintext) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { nothing ->
|
||||
backupData = null
|
||||
_state.value = _state.value.copy(backupState = BackupState.NONE)
|
||||
}
|
||||
}
|
||||
|
||||
fun onPlaintextToggled() {
|
||||
_state.value = _state.value.copy(plaintext = !_state.value.plaintext)
|
||||
}
|
||||
|
||||
fun uploadBackupToRemote() {
|
||||
_state.value = _state.value.copy(uploadState = BackupUploadState.UPLOAD_IN_PROGRESS)
|
||||
|
||||
disposables += Single
|
||||
.fromCallable { BackupRepository.uploadBackupFile(backupData!!.inputStream(), backupData!!.size.toLong()) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe { success ->
|
||||
_state.value = _state.value.copy(uploadState = if (success) BackupUploadState.UPLOAD_DONE else BackupUploadState.UPLOAD_FAILED)
|
||||
}
|
||||
}
|
||||
|
||||
fun checkRemoteBackupState() {
|
||||
_state.value = _state.value.copy(remoteBackupState = RemoteBackupState.Unknown)
|
||||
|
||||
disposables += Single
|
||||
.fromCallable { BackupRepository.getRemoteBackupState() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe { result ->
|
||||
when {
|
||||
result is NetworkResult.Success -> {
|
||||
_state.value = _state.value.copy(remoteBackupState = RemoteBackupState.Available(result.result))
|
||||
}
|
||||
result is NetworkResult.StatusCodeError && result.code == 404 -> {
|
||||
_state.value = _state.value.copy(remoteBackupState = RemoteBackupState.NotFound)
|
||||
}
|
||||
else -> {
|
||||
_state.value = _state.value.copy(remoteBackupState = RemoteBackupState.GeneralError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
data class ScreenState(
|
||||
val backupState: BackupState = BackupState.NONE,
|
||||
val uploadState: BackupUploadState = BackupUploadState.NONE,
|
||||
val remoteBackupState: RemoteBackupState = RemoteBackupState.Unknown,
|
||||
val plaintext: Boolean
|
||||
)
|
||||
|
||||
enum class BackupState(val inProgress: Boolean = false) {
|
||||
NONE, EXPORT_IN_PROGRESS(true), EXPORT_DONE, IMPORT_IN_PROGRESS(true)
|
||||
}
|
||||
|
||||
enum class BackupUploadState(val inProgress: Boolean = false) {
|
||||
NONE, UPLOAD_IN_PROGRESS(true), UPLOAD_DONE, UPLOAD_FAILED
|
||||
}
|
||||
|
||||
sealed class RemoteBackupState {
|
||||
object Unknown : RemoteBackupState()
|
||||
object NotFound : RemoteBackupState()
|
||||
object GeneralError : RemoteBackupState()
|
||||
data class Available(val response: ArchiveGetBackupInfoResponse) : RemoteBackupState()
|
||||
}
|
||||
}
|
||||
@@ -144,19 +144,21 @@ class InternalDonorErrorConfigurationViewModel : ViewModel() {
|
||||
|
||||
private fun handleSubscriptionExpiration(state: InternalDonorErrorConfigurationState) {
|
||||
SignalStore.donationsValues().setExpiredBadge(state.selectedBadge)
|
||||
SignalStore.donationsValues().clearUserManuallyCancelled()
|
||||
handleSubscriptionPaymentFailure(state)
|
||||
}
|
||||
|
||||
private fun handleSubscriptionPaymentFailure(state: InternalDonorErrorConfigurationState) {
|
||||
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = state.selectedUnexpectedSubscriptionCancellation?.status
|
||||
SignalStore.donationsValues().unexpectedSubscriptionCancelationTimestamp = System.currentTimeMillis()
|
||||
SignalStore.donationsValues().showMonthlyDonationCanceledDialog = true
|
||||
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(
|
||||
state.selectedStripeDeclineCode?.let {
|
||||
ActiveSubscription.ChargeFailure(
|
||||
it.code,
|
||||
"Test Charge Failure",
|
||||
"Test Network Status",
|
||||
"Test Network Reason",
|
||||
it.code,
|
||||
"Test"
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user