mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-15 15:37:29 +00:00
Compare commits
173 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8004565c84 | ||
|
|
a101dc4fd1 | ||
|
|
57f730d8ee | ||
|
|
3543cc80ba | ||
|
|
71613d9db1 | ||
|
|
4a0e6a3eb2 | ||
|
|
f1a87518e1 | ||
|
|
61f880fd78 | ||
|
|
09904e7a16 | ||
|
|
94658e9090 | ||
|
|
a47448b6c6 | ||
|
|
7e4b9b685a | ||
|
|
64922a8e51 | ||
|
|
f65f4704c9 | ||
|
|
b04ca202f6 | ||
|
|
83086a5a2b | ||
|
|
51a521594f | ||
|
|
0a7a7cf5a9 | ||
|
|
6bd689504c | ||
|
|
efec40ff57 | ||
|
|
69716dde4a | ||
|
|
e90fa05d60 | ||
|
|
580c000bda | ||
|
|
2f3d04d3e8 | ||
|
|
bf37d412e9 | ||
|
|
fd115ebb72 | ||
|
|
b9657208fe | ||
|
|
5d6d78a51e | ||
|
|
916006e664 | ||
|
|
55c69cd50a | ||
|
|
14565b0864 | ||
|
|
a157c1ae1d | ||
|
|
a4d458f969 | ||
|
|
3f53abedab | ||
|
|
68a2d5ed20 | ||
|
|
35e9e31a7b | ||
|
|
444d947743 | ||
|
|
c427dbad08 | ||
|
|
2cefe813e4 | ||
|
|
123ffe42c3 | ||
|
|
da20e66ecd | ||
|
|
901440017a | ||
|
|
0be76a37fe | ||
|
|
36dadc8777 | ||
|
|
182749c101 | ||
|
|
d9228bd911 | ||
|
|
a361fcc8f3 | ||
|
|
ff4f0b9f42 | ||
|
|
060dffc9cc | ||
|
|
172cc302fc | ||
|
|
416e62112f | ||
|
|
e584a90f81 | ||
|
|
9876ffb5e4 | ||
|
|
53e10f2cad | ||
|
|
cb79f75ac1 | ||
|
|
5ec9c1cd90 | ||
|
|
1f28a30ace | ||
|
|
7715917436 | ||
|
|
f79b445fdf | ||
|
|
14484deabe | ||
|
|
3ac395d33e | ||
|
|
f83b520ca9 | ||
|
|
0123f9aa87 | ||
|
|
06b64fe619 | ||
|
|
1bb87834d8 | ||
|
|
ae4167ddae | ||
|
|
383beafdef | ||
|
|
062e88b24f | ||
|
|
8299d49042 | ||
|
|
4677883838 | ||
|
|
7f0a0bef5a | ||
|
|
acc825971b | ||
|
|
62040d06b4 | ||
|
|
0921ebe5f1 | ||
|
|
3d0e15e2b8 | ||
|
|
5372f79c40 | ||
|
|
92e8f9de0e | ||
|
|
c3cf846a10 | ||
|
|
5826b0c068 | ||
|
|
2d7c043398 | ||
|
|
e20d6b63cf | ||
|
|
b85c5eb54a | ||
|
|
a1c8573fad | ||
|
|
90a27d2227 | ||
|
|
c54c6018b2 | ||
|
|
7419570f94 | ||
|
|
8860f792c4 | ||
|
|
e47db0d532 | ||
|
|
ab5d3badc2 | ||
|
|
fce362960f | ||
|
|
5bf23dcfb3 | ||
|
|
65c7dc6ca2 | ||
|
|
e30a8b6954 | ||
|
|
838e318200 | ||
|
|
62ee411901 | ||
|
|
ceefd2d92f | ||
|
|
e3870f5656 | ||
|
|
6e7022ab70 | ||
|
|
031d1551e7 | ||
|
|
6755b25361 | ||
|
|
11d0a73675 | ||
|
|
44119b6437 | ||
|
|
d4a3b442f4 | ||
|
|
aba5774446 | ||
|
|
911dd9efb1 | ||
|
|
f2a490b07e | ||
|
|
5675f080f2 | ||
|
|
f0dbe230b5 | ||
|
|
8b81800052 | ||
|
|
f598c14298 | ||
|
|
58b070e6e3 | ||
|
|
71c92a1c90 | ||
|
|
b86acb9773 | ||
|
|
1b8758b657 | ||
|
|
ed4bab1b8b | ||
|
|
a71fe0fd75 | ||
|
|
3d2a634aac | ||
|
|
01047f0546 | ||
|
|
9dac5691f0 | ||
|
|
3c489ad247 | ||
|
|
7797351341 | ||
|
|
f7212b9916 | ||
|
|
93bb49dc16 | ||
|
|
e504c490c8 | ||
|
|
42e865813c | ||
|
|
fc14d1d464 | ||
|
|
2a1e5e4471 | ||
|
|
da2ee33dff | ||
|
|
f19033a7a2 | ||
|
|
6502ef64ce | ||
|
|
b3ebf778fd | ||
|
|
1dca3698d2 | ||
|
|
2bfe1198d1 | ||
|
|
4f704670b1 | ||
|
|
a1aafd7453 | ||
|
|
4932623937 | ||
|
|
b93568d9c6 | ||
|
|
b3041ab6e0 | ||
|
|
3a151b30ac | ||
|
|
97b3d36433 | ||
|
|
81e3252128 | ||
|
|
426c83c6cc | ||
|
|
b427754a81 | ||
|
|
08f023fb12 | ||
|
|
5f1454aeb8 | ||
|
|
0d254e0724 | ||
|
|
e882e6e111 | ||
|
|
4b0811f9aa | ||
|
|
817f1ee938 | ||
|
|
2d93d74b9f | ||
|
|
93f37ad70f | ||
|
|
3c6bed90db | ||
|
|
fa26fb6b8b | ||
|
|
263ddb0d1e | ||
|
|
8be659c1c8 | ||
|
|
e5c9dddb5a | ||
|
|
6da72aad6d | ||
|
|
5dd5a024c9 | ||
|
|
c0eac5564c | ||
|
|
0d0ee753df | ||
|
|
908f952893 | ||
|
|
1c80e65c5a | ||
|
|
20b13a929b | ||
|
|
4637e1b5d8 | ||
|
|
4b6cb79c75 | ||
|
|
feaf2a33a9 | ||
|
|
4c893a11fc | ||
|
|
f4dd80c929 | ||
|
|
4af078007e | ||
|
|
be297120a1 | ||
|
|
a9741cadbf | ||
|
|
79200c82da | ||
|
|
d9c9ae8dae |
3
.github/workflows/android.yml
vendored
3
.github/workflows/android.yml
vendored
@@ -24,6 +24,9 @@ jobs:
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/wrapper-validation-action@v1
|
||||
|
||||
- name: Remove Android S
|
||||
run: $ANDROID_HOME/tools/bin/sdkmanager --uninstall "platforms;android-S"
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew qa
|
||||
|
||||
|
||||
3
.idea/codeStyles/Project.xml
generated
3
.idea/codeStyles/Project.xml
generated
@@ -10,6 +10,9 @@
|
||||
<option name="ALIGN_MULTILINE_TEXT_BLOCKS" value="true" />
|
||||
</JavaCodeStyleSettings>
|
||||
<JetCodeStyleSettings>
|
||||
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
|
||||
<value />
|
||||
</option>
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
|
||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
|
||||
100
app/build.gradle
100
app/build.gradle
@@ -11,6 +11,8 @@ apply plugin: 'witness'
|
||||
apply plugin: 'org.jlleitschuh.gradle.ktlint'
|
||||
apply from: 'translations.gradle'
|
||||
apply from: 'witness-verifications.gradle'
|
||||
apply plugin: 'org.jetbrains.kotlin.android'
|
||||
apply plugin: 'app.cash.exhaustive'
|
||||
|
||||
repositories {
|
||||
maven {
|
||||
@@ -55,8 +57,8 @@ protobuf {
|
||||
}
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 864
|
||||
def canonicalVersionName = "5.14.3"
|
||||
def canonicalVersionCode = 879
|
||||
def canonicalVersionName = "5.17.1"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -75,6 +77,7 @@ android {
|
||||
useLibrary 'org.apache.http.legacy'
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
freeCompilerArgs = ["-Xallow-result-return-type"]
|
||||
}
|
||||
|
||||
@@ -115,6 +118,8 @@ android {
|
||||
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\""
|
||||
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_NAMES", "new String[]{\"Test\", \"Staging\"}"
|
||||
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_URLS", "new String[]{\"https://sfu.test.voip.signal.org\", \"https://sfu.staging.voip.signal.org\"}"
|
||||
buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
|
||||
buildConfigField "int", "CONTENT_PROXY_PORT", "443"
|
||||
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
|
||||
@@ -132,6 +137,10 @@ android {
|
||||
buildConfigField "String", "GIPHY_API_KEY", "\"3o6ZsYH6U6Eri53TXy\""
|
||||
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\""
|
||||
|
||||
ndk {
|
||||
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
|
||||
}
|
||||
@@ -196,28 +205,34 @@ android {
|
||||
'proguard/proguard.cfg'
|
||||
testProguardFiles 'proguard/proguard-automation.pro',
|
||||
'proguard/proguard.cfg'
|
||||
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Debug\""
|
||||
}
|
||||
flipper {
|
||||
initWith debug
|
||||
isDefault false
|
||||
minifyEnabled false
|
||||
matchingFallbacks = ['debug']
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Flipper\""
|
||||
}
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles = buildTypes.debug.proguardFiles
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Release\""
|
||||
}
|
||||
perf {
|
||||
initWith debug
|
||||
isDefault false
|
||||
debuggable false
|
||||
matchingFallbacks = ['debug']
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Perf\""
|
||||
}
|
||||
mock {
|
||||
initWith debug
|
||||
isDefault false
|
||||
minifyEnabled false
|
||||
matchingFallbacks = ['debug']
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Mock\""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +243,7 @@ android {
|
||||
ext.websiteUpdateUrl = "null"
|
||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"play\""
|
||||
}
|
||||
|
||||
website {
|
||||
@@ -235,6 +251,7 @@ android {
|
||||
ext.websiteUpdateUrl = "https://updates.signal.org/android"
|
||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "true"
|
||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\""
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"website\""
|
||||
}
|
||||
|
||||
internal {
|
||||
@@ -242,6 +259,16 @@ android {
|
||||
ext.websiteUpdateUrl = "null"
|
||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"internal\""
|
||||
}
|
||||
|
||||
nightly {
|
||||
dimension 'distribution'
|
||||
versionNameSuffix "-nightly-untagged-${getDateSuffix()}"
|
||||
ext.websiteUpdateUrl = "null"
|
||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"internal\""
|
||||
}
|
||||
|
||||
study {
|
||||
@@ -251,6 +278,7 @@ android {
|
||||
ext.websiteUpdateUrl = "null"
|
||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"study\""
|
||||
}
|
||||
|
||||
prod {
|
||||
@@ -259,6 +287,7 @@ android {
|
||||
isDefault true
|
||||
|
||||
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"mainnet\""
|
||||
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Prod\""
|
||||
}
|
||||
|
||||
staging {
|
||||
@@ -281,18 +310,28 @@ android {
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdls=\""
|
||||
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
|
||||
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\""
|
||||
|
||||
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\""
|
||||
}
|
||||
}
|
||||
|
||||
android.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
|
||||
def abiName = output.getFilter("ABI") ?: 'universal'
|
||||
def postFix = abiPostFix.get(abiName, 0)
|
||||
if (output.baseName.contains('nightly')) {
|
||||
output.versionCodeOverride = canonicalVersionCode * postFixSize + 5
|
||||
def tag = getCurrentGitTag()
|
||||
if (tag != null && tag.length() > 0) {
|
||||
output.versionNameOverride = tag
|
||||
}
|
||||
} 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")
|
||||
if (postFix >= postFixSize) throw new AssertionError("postFix is too large")
|
||||
|
||||
output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix
|
||||
output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +344,12 @@ android {
|
||||
variant.setIgnore(true)
|
||||
} else if (distribution != 'study' && buildType == 'mock') {
|
||||
variant.setIgnore(true)
|
||||
} else if (distribution == 'internal' && buildType != 'flipper' && buildType != 'perf' && buildType != 'release') {
|
||||
variant.setIgnore(true)
|
||||
} else if (distribution == 'nightly' && environment != 'prod') {
|
||||
variant.setIgnore(true)
|
||||
} else if (distribution == 'nightly' && buildType != 'flipper' && buildType != 'perf' && buildType != 'release') {
|
||||
variant.setIgnore(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +367,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.5.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.2.5'
|
||||
lintChecks project(':lintchecks')
|
||||
|
||||
@@ -352,8 +398,9 @@ dependencies {
|
||||
implementation "androidx.concurrent:concurrent-futures:1.0.0"
|
||||
implementation "androidx.autofill:autofill:1.0.0"
|
||||
implementation "androidx.biometric:biometric:1.1.0"
|
||||
implementation "androidx.sharetarget:sharetarget:1.1.0"
|
||||
|
||||
implementation ('com.google.firebase:firebase-messaging:20.2.0') {
|
||||
implementation ('com.google.firebase:firebase-messaging:22.0.0') {
|
||||
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'
|
||||
@@ -376,16 +423,16 @@ dependencies {
|
||||
implementation project(':device-transfer')
|
||||
|
||||
implementation 'org.signal:zkgroup-android:0.7.0'
|
||||
implementation 'org.whispersystems:signal-client-android:0.8.0'
|
||||
implementation 'org.whispersystems:signal-client-android:0.8.3'
|
||||
implementation 'com.google.protobuf:protobuf-javalite:3.10.0'
|
||||
|
||||
implementation('com.mobilecoin:android-sdk:1.0.0') {
|
||||
implementation('com.mobilecoin:android-sdk:1.1.0') {
|
||||
exclude group: 'com.google.protobuf'
|
||||
}
|
||||
|
||||
implementation 'org.signal:argon2:13.1@aar'
|
||||
|
||||
implementation 'org.signal:ringrtc-android:2.10.1.1'
|
||||
implementation 'org.signal:ringrtc-android:2.10.6'
|
||||
|
||||
implementation "me.leolin:ShortcutBadger:1.1.22"
|
||||
implementation 'se.emilsjolander:stickylistheaders:2.7.0'
|
||||
@@ -455,11 +502,16 @@ dependencies {
|
||||
testImplementation 'org.robolectric:shadows-multidex:4.4'
|
||||
testImplementation 'org.hamcrest:hamcrest:2.2'
|
||||
|
||||
testImplementation(testFixtures(project(":libsignal-service")))
|
||||
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
|
||||
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation "com.fasterxml.jackson.module:jackson-module-kotlin:2.12.0"
|
||||
|
||||
implementation 'io.reactivex.rxjava3:rxandroid:3.0.0'
|
||||
implementation 'io.reactivex.rxjava3:rxkotlin:3.0.1'
|
||||
}
|
||||
|
||||
dependencyVerification {
|
||||
@@ -556,6 +608,26 @@ def getGitHash() {
|
||||
return stdout.toString().trim()
|
||||
}
|
||||
|
||||
def getCurrentGitTag() {
|
||||
if (!(new File('.git').exists())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
def stdout = new ByteArrayOutputStream()
|
||||
exec {
|
||||
commandLine 'git', 'tag', '--points-at', 'HEAD'
|
||||
standardOutput = stdout
|
||||
}
|
||||
|
||||
def output = stdout.toString().trim()
|
||||
|
||||
if (output != null && output.size() > 0) {
|
||||
return output.split('\n')[0];
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(Test) {
|
||||
testLogging {
|
||||
events "failed"
|
||||
@@ -576,3 +648,9 @@ def loadKeystoreProperties(filename) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
def getDateSuffix() {
|
||||
def date = new Date()
|
||||
def formattedDate = date.format('yyyy-MM-dd-HH:mm')
|
||||
return formattedDate
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@
|
||||
|
||||
<meta-data
|
||||
android:name="android.service.chooser.chooser_target_service"
|
||||
android:value=".service.DirectShareService" />
|
||||
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
|
||||
|
||||
</activity>
|
||||
|
||||
@@ -308,14 +308,6 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar" />
|
||||
|
||||
<activity android:name=".groups.ui.managegroup.ManageGroupActivity"
|
||||
android:windowSoftInputMode="stateAlwaysHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".recipients.ui.managerecipient.ManageRecipientActivity"
|
||||
android:windowSoftInputMode="stateAlwaysHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".recipients.ui.disappearingmessages.RecipientDisappearingMessagesActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
@@ -327,7 +319,7 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".migrations.ApplicationMigrationActivity"
|
||||
android:theme="@style/NoAnimation.Theme.AppCompat.Light.DarkActionBar"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
@@ -381,6 +373,13 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".components.settings.conversation.ConversationSettingsActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:theme="@style/Signal.DayNight.ConversationSettings"
|
||||
android:windowSoftInputMode="stateAlwaysHidden">
|
||||
</activity>
|
||||
|
||||
|
||||
<activity android:name=".wallpaper.ChatWallpaperActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:windowSoftInputMode="stateAlwaysHidden">
|
||||
@@ -652,13 +651,6 @@
|
||||
<meta-data android:name="android.provider.CONTACTS_STRUCTURE" android:resource="@xml/contactsformat" />
|
||||
</service>
|
||||
|
||||
<service android:name=".service.DirectShareService"
|
||||
android:permission="android.permission.BIND_CHOOSER_TARGET_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.chooser.ChooserTargetService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service android:name=".service.GenericForegroundService"/>
|
||||
|
||||
<service android:name=".gcm.FcmFetchService" />
|
||||
|
||||
@@ -512,7 +512,12 @@ final class SignalCameraXModule {
|
||||
return rotationDegrees;
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeExperimentalUsageError")
|
||||
public void invalidateView() {
|
||||
if (mPreview != null) {
|
||||
mPreview.setTargetRotation(getDisplaySurfaceRotation()); // Fixes issue #10940 (rotation not updated on phones using "Legacy API")
|
||||
}
|
||||
|
||||
updateViewInfo();
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ import androidx.multidex.MultiDexApplication;
|
||||
|
||||
import com.google.android.gms.security.ProviderInstaller;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.conscrypt.Conscrypt;
|
||||
import org.signal.aesgcmprovider.AesGcmProvider;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
@@ -37,6 +39,7 @@ import org.signal.core.util.tracing.Tracer;
|
||||
import org.signal.glide.SignalGlideCodecs;
|
||||
import org.signal.ringrtc.CallManager;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider;
|
||||
@@ -71,6 +74,7 @@ import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.ByteUnit;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
|
||||
@@ -85,6 +89,9 @@ import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider;
|
||||
import java.security.Security;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
/**
|
||||
* Will be called once when the TextSecure process is created.
|
||||
*
|
||||
@@ -118,11 +125,15 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
|
||||
AppStartup.getInstance().addBlocking("security-provider", this::initializeSecurityProvider)
|
||||
.addBlocking("logging", () -> {
|
||||
initializeLogging();
|
||||
Log.i(TAG, "onCreate()");
|
||||
initializeLogging();
|
||||
Log.i(TAG, "onCreate()");
|
||||
})
|
||||
.addBlocking("crash-handling", this::initializeCrashHandling)
|
||||
.addBlocking("eat-db", () -> DatabaseFactory.getInstance(this))
|
||||
.addBlocking("sqlcipher-init", () -> SqlCipherLibraryLoader.load(this))
|
||||
.addBlocking("rx-init", () -> {
|
||||
RxJavaPlugins.setInitIoSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED_IO, true, false));
|
||||
RxJavaPlugins.setInitComputationSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED, true, false));
|
||||
})
|
||||
.addBlocking("app-dependencies", this::initializeAppDependencies)
|
||||
.addBlocking("notification-channels", () -> NotificationChannels.create(this))
|
||||
.addBlocking("first-launch", this::initializeFirstEverAppLaunch)
|
||||
@@ -163,6 +174,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this)))
|
||||
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
|
||||
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
|
||||
.addPostRender(() -> DatabaseFactory.getMessageLogDatabase(this).trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()))
|
||||
.execute();
|
||||
|
||||
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
@@ -236,7 +248,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
}
|
||||
|
||||
private void initializeLogging() {
|
||||
persistentLogger = new PersistentLogger(this, LogSecretProvider.getOrCreateAttachmentSecret(this), BuildConfig.VERSION_NAME);
|
||||
persistentLogger = new PersistentLogger(this, LogSecretProvider.getOrCreateAttachmentSecret(this), BuildConfig.VERSION_NAME, FeatureFlags.internalUser() ? 15 : 7, ByteUnit.KILOBYTES.toBytes(300));
|
||||
org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), persistentLogger);
|
||||
|
||||
SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());
|
||||
|
||||
@@ -70,11 +70,13 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
|
||||
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
|
||||
void onMessageWithRecaptchaNeededClicked(@NonNull MessageRecord messageRecord);
|
||||
void onIncomingIdentityMismatchClicked(@NonNull RecipientId recipientId);
|
||||
void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
|
||||
void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
|
||||
void onVoiceNotePause(@NonNull Uri uri);
|
||||
void onVoiceNotePlay(@NonNull Uri uri, long messageId, double position);
|
||||
void onVoiceNoteSeekTo(@NonNull Uri uri, double position);
|
||||
void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed);
|
||||
void onGroupMigrationLearnMoreClicked(@NonNull GroupMigrationMembershipChange membershipChange);
|
||||
void onChatSessionRefreshLearnMoreClicked();
|
||||
void onBadDecryptLearnMoreClicked(@NonNull RecipientId author);
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.os.AsyncTask;
|
||||
import android.text.SpannableString;
|
||||
import android.text.Spanned;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSessionLock;
|
||||
import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
||||
import org.thoughtcrime.securesms.database.PushDatabase;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.PushDecryptMessageJob;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.thoughtcrime.securesms.util.VerifySpan;
|
||||
import org.whispersystems.libsignal.SignalProtocolAddress;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class ConfirmIdentityDialog extends AlertDialog {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(ConfirmIdentityDialog.class);
|
||||
|
||||
private OnClickListener callback;
|
||||
|
||||
public ConfirmIdentityDialog(Context context,
|
||||
MessageRecord messageRecord,
|
||||
IdentityKeyMismatch mismatch)
|
||||
{
|
||||
super(context);
|
||||
|
||||
Recipient recipient = Recipient.resolved(mismatch.getRecipientId(context));
|
||||
String name = recipient.getDisplayName(context);
|
||||
String introduction = context.getString(R.string.ConfirmIdentityDialog_your_safety_number_with_s_has_changed, name, name);
|
||||
SpannableString spannableString = new SpannableString(introduction + " " +
|
||||
context.getString(R.string.ConfirmIdentityDialog_you_may_wish_to_verify_your_safety_number_with_this_contact));
|
||||
|
||||
spannableString.setSpan(new VerifySpan(context, mismatch),
|
||||
introduction.length()+1, spannableString.length(),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
setTitle(name);
|
||||
setMessage(spannableString);
|
||||
|
||||
setButton(AlertDialog.BUTTON_POSITIVE, context.getString(R.string.ConfirmIdentityDialog_accept), new AcceptListener(messageRecord, mismatch, recipient.getId()));
|
||||
setButton(AlertDialog.BUTTON_NEGATIVE, context.getString(android.R.string.cancel), new CancelListener());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void show() {
|
||||
super.show();
|
||||
((TextView)this.findViewById(android.R.id.message))
|
||||
.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
}
|
||||
|
||||
public void setCallback(OnClickListener callback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
private class AcceptListener implements OnClickListener {
|
||||
|
||||
private final MessageRecord messageRecord;
|
||||
private final IdentityKeyMismatch mismatch;
|
||||
private final RecipientId recipientId;
|
||||
|
||||
private AcceptListener(MessageRecord messageRecord, IdentityKeyMismatch mismatch, RecipientId recipientId) {
|
||||
this.messageRecord = messageRecord;
|
||||
this.mismatch = mismatch;
|
||||
this.recipientId = recipientId;
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which) {
|
||||
new AsyncTask<Void, Void, Void>()
|
||||
{
|
||||
@Override
|
||||
protected Void doInBackground(Void... params) {
|
||||
try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) {
|
||||
SignalProtocolAddress mismatchAddress = new SignalProtocolAddress(Recipient.resolved(recipientId).requireServiceId(), 1);
|
||||
TextSecureIdentityKeyStore identityKeyStore = new TextSecureIdentityKeyStore(getContext());
|
||||
|
||||
identityKeyStore.saveIdentity(mismatchAddress, mismatch.getIdentityKey(), true);
|
||||
}
|
||||
|
||||
processMessageRecord(messageRecord);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void processMessageRecord(MessageRecord messageRecord) {
|
||||
if (messageRecord.isOutgoing()) processOutgoingMessageRecord(messageRecord);
|
||||
else processIncomingMessageRecord(messageRecord);
|
||||
}
|
||||
|
||||
private void processOutgoingMessageRecord(MessageRecord messageRecord) {
|
||||
MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(getContext());
|
||||
MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(getContext());
|
||||
|
||||
if (messageRecord.isMms()) {
|
||||
mmsDatabase.removeMismatchedIdentity(messageRecord.getId(),
|
||||
mismatch.getRecipientId(getContext()),
|
||||
mismatch.getIdentityKey());
|
||||
|
||||
if (messageRecord.getRecipient().isPushGroup()) {
|
||||
MessageSender.resendGroupMessage(getContext(), messageRecord, Recipient.resolved(mismatch.getRecipientId(getContext())).getId());
|
||||
} else {
|
||||
MessageSender.resend(getContext(), messageRecord);
|
||||
}
|
||||
} else {
|
||||
smsDatabase.removeMismatchedIdentity(messageRecord.getId(),
|
||||
mismatch.getRecipientId(getContext()),
|
||||
mismatch.getIdentityKey());
|
||||
|
||||
MessageSender.resend(getContext(), messageRecord);
|
||||
}
|
||||
}
|
||||
|
||||
private void processIncomingMessageRecord(MessageRecord messageRecord) {
|
||||
try {
|
||||
MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(getContext());
|
||||
|
||||
smsDatabase.removeMismatchedIdentity(messageRecord.getId(),
|
||||
mismatch.getRecipientId(getContext()),
|
||||
mismatch.getIdentityKey());
|
||||
|
||||
boolean legacy = !messageRecord.isContentBundleKeyExchange();
|
||||
|
||||
SignalServiceEnvelope envelope = new SignalServiceEnvelope(SignalServiceProtos.Envelope.Type.PREKEY_BUNDLE_VALUE,
|
||||
Optional.of(RecipientUtil.toSignalServiceAddress(getContext(), messageRecord.getIndividualRecipient())),
|
||||
messageRecord.getRecipientDeviceId(),
|
||||
messageRecord.getDateSent(),
|
||||
legacy ? Base64.decode(messageRecord.getBody()) : null,
|
||||
!legacy ? Base64.decode(messageRecord.getBody()) : null,
|
||||
0,
|
||||
0,
|
||||
null);
|
||||
|
||||
ApplicationDependencies.getJobManager().add(new PushDecryptMessageJob(getContext(), envelope, messageRecord.getId()));
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
|
||||
if (callback != null) callback.onClick(null, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private class CancelListener implements OnClickListener {
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which) {
|
||||
if (callback != null) callback.onClick(null, 0);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -20,13 +20,13 @@ import android.content.Context;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterToolbar;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
@@ -56,7 +56,8 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
|
||||
protected ContactSelectionListFragment contactsFragment;
|
||||
|
||||
private ContactFilterToolbar toolbar;
|
||||
private Toolbar toolbar;
|
||||
private ContactFilterView contactFilterView;
|
||||
|
||||
@Override
|
||||
protected void onPreCreate() {
|
||||
@@ -73,6 +74,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
|
||||
setContentView(getIntent().getIntExtra(EXTRA_LAYOUT_RES_ID, R.layout.contact_selection_activity));
|
||||
|
||||
initializeContactFilterView();
|
||||
initializeToolbar();
|
||||
initializeResources();
|
||||
initializeSearch();
|
||||
@@ -84,16 +86,23 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
dynamicTheme.onResume(this);
|
||||
}
|
||||
|
||||
protected ContactFilterToolbar getToolbar() {
|
||||
protected Toolbar getToolbar() {
|
||||
return toolbar;
|
||||
}
|
||||
|
||||
protected ContactFilterView getContactFilterView() {
|
||||
return contactFilterView;
|
||||
}
|
||||
|
||||
private void initializeContactFilterView() {
|
||||
this.contactFilterView = findViewById(R.id.contact_filter_edit_text);
|
||||
}
|
||||
|
||||
private void initializeToolbar() {
|
||||
this.toolbar = findViewById(R.id.toolbar);
|
||||
setSupportActionBar(toolbar);
|
||||
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(false);
|
||||
getSupportActionBar().setDisplayShowTitleEnabled(false);
|
||||
getSupportActionBar().setIcon(null);
|
||||
getSupportActionBar().setLogo(null);
|
||||
}
|
||||
@@ -104,7 +113,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
}
|
||||
|
||||
private void initializeSearch() {
|
||||
toolbar.setOnFilterChangedListener(filter -> contactsFragment.setQueryFilter(filter));
|
||||
contactFilterView.setOnFilterChangedListener(filter -> contactsFragment.setQueryFilter(filter));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -155,7 +164,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
ContactSelectionActivity activity = this.activity.get();
|
||||
|
||||
if (activity != null && !activity.isFinishing()) {
|
||||
activity.toolbar.clear();
|
||||
activity.contactFilterView.clear();
|
||||
activity.contactsFragment.resetQueryFilter();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,13 +57,14 @@ import com.pnikosis.materialishprogress.ProgressWheel;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
|
||||
import org.thoughtcrime.securesms.components.emoji.WarningTextView;
|
||||
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper;
|
||||
import org.thoughtcrime.securesms.contacts.AbstractContactsCursorLoader;
|
||||
import org.thoughtcrime.securesms.contacts.ContactChip;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
@@ -74,7 +75,6 @@ import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
@@ -131,9 +131,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
|
||||
private ChipGroup chipGroup;
|
||||
private HorizontalScrollView chipGroupScrollContainer;
|
||||
private WarningTextView groupLimit;
|
||||
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
|
||||
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
|
||||
private View shadowView;
|
||||
private ToolbarShadowAnimationHelper toolbarShadowAnimationHelper;
|
||||
|
||||
|
||||
@Nullable private FixedViewsAdapter headerAdapter;
|
||||
@@ -233,9 +234,12 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
showContactsProgress = view.findViewById(R.id.progress);
|
||||
chipGroup = view.findViewById(R.id.chipGroup);
|
||||
chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer);
|
||||
groupLimit = view.findViewById(R.id.group_limit);
|
||||
constraintLayout = view.findViewById(R.id.container);
|
||||
shadowView = view.findViewById(R.id.toolbar_shadow);
|
||||
|
||||
toolbarShadowAnimationHelper = new ToolbarShadowAnimationHelper(shadowView);
|
||||
|
||||
recyclerView.addOnScrollListener(toolbarShadowAnimationHelper);
|
||||
recyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
|
||||
recyclerView.setItemAnimator(new DefaultItemAnimator() {
|
||||
@Override
|
||||
@@ -272,8 +276,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
|
||||
currentSelection = getCurrentSelection();
|
||||
|
||||
updateGroupLimit(getChipCount());
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -281,13 +283,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
return getArguments() != null ? getArguments() : new Bundle();
|
||||
}
|
||||
|
||||
private void updateGroupLimit(int chipCount) {
|
||||
int members = currentSelection.size() + chipCount;
|
||||
groupLimit.setText(getResources().getQuantityString(R.plurals.ContactSelectionListFragment_d_members, members, members));
|
||||
groupLimit.setVisibility(isMulti && !hideCount ? View.VISIBLE : View.GONE);
|
||||
groupLimit.setWarning(selectionWarningLimitExceeded());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
||||
@@ -309,6 +304,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
return cursorRecyclerViewAdapter.getSelectedContactsCount();
|
||||
}
|
||||
|
||||
public int getTotalMemberCount() {
|
||||
if (cursorRecyclerViewAdapter == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return cursorRecyclerViewAdapter.getSelectedContactsCount() + cursorRecyclerViewAdapter.getCurrentContactsCount();
|
||||
}
|
||||
|
||||
private Set<RecipientId> getCurrentSelection() {
|
||||
List<RecipientId> currentSelection = safeArguments().getParcelableArrayList(CURRENT_SELECTION);
|
||||
if (currentSelection == null) {
|
||||
@@ -349,8 +352,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
concatenateAdapter.addAdapter(footerAdapter);
|
||||
}
|
||||
|
||||
recyclerView.addItemDecoration(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders));
|
||||
recyclerView.setAdapter(concatenateAdapter);
|
||||
recyclerView.addItemDecoration(new StickyHeaderDecoration(concatenateAdapter, true, true, 0));
|
||||
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
|
||||
@Override
|
||||
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
|
||||
@@ -361,6 +364,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onSelectionChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hideLetterHeaders() {
|
||||
return hasQueryFilter() || shouldDisplayRecents();
|
||||
}
|
||||
|
||||
private View createInviteActionView(@NonNull ListCallback listCallback) {
|
||||
@@ -429,7 +440,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
|
||||
FragmentActivity activity = requireActivity();
|
||||
int displayMode = safeArguments().getInt(DISPLAY_MODE, activity.getIntent().getIntExtra(DISPLAY_MODE, DisplayMode.FLAG_ALL));
|
||||
boolean displayRecents = safeArguments().getBoolean(RECENTS, activity.getIntent().getBooleanExtra(RECENTS, false));
|
||||
boolean displayRecents = shouldDisplayRecents();
|
||||
|
||||
if (cursorFactoryProvider != null) {
|
||||
return cursorFactoryProvider.get().create();
|
||||
@@ -475,6 +486,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
fastScroller.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private boolean shouldDisplayRecents() {
|
||||
return safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private void handleContactPermissionGranted() {
|
||||
final Context context = requireContext();
|
||||
@@ -606,12 +621,19 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
if (isMulti) {
|
||||
addChipForSelectedContact(selectedContact);
|
||||
}
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onSelectionChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
|
||||
cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact);
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
removeChipForContact(selectedContact);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onSelectionChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void removeChipForContact(@NonNull SelectedContact contact) {
|
||||
@@ -622,8 +644,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
}
|
||||
|
||||
updateGroupLimit(getChipCount());
|
||||
|
||||
if (getChipCount() == 0) {
|
||||
setChipGroupVisibility(ConstraintSet.GONE);
|
||||
}
|
||||
@@ -673,7 +693,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
|
||||
private void addChip(@NonNull ContactChip chip) {
|
||||
chipGroup.addView(chip);
|
||||
updateGroupLimit(getChipCount());
|
||||
if (selectionWarningLimitReachedExactly()) {
|
||||
if (onSelectionLimitReachedListener != null) {
|
||||
onSelectionLimitReachedListener.onSuggestedLimitReached(selectionLimit.getRecommendedLimit());
|
||||
@@ -726,6 +745,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
/** @return True if the contact is allowed to be selected, otherwise false. */
|
||||
boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number);
|
||||
void onContactDeselected(Optional<RecipientId> recipientId, String number);
|
||||
void onSelectionChanged();
|
||||
}
|
||||
|
||||
public interface OnSelectionLimitReachedListener {
|
||||
|
||||
@@ -7,8 +7,6 @@ import android.graphics.PorterDuff;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.View;
|
||||
import android.view.View.OnClickListener;
|
||||
import android.view.ViewGroup;
|
||||
@@ -24,8 +22,8 @@ import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
|
||||
|
||||
import org.thoughtcrime.securesms.components.ContactFilterToolbar;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterToolbar.OnFilterChangedListener;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView.OnFilterChangedListener;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
@@ -100,7 +98,8 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
View shareButton = findViewById(R.id.share_button);
|
||||
Button smsButton = findViewById(R.id.sms_button);
|
||||
Button smsCancelButton = findViewById(R.id.cancel_sms_button);
|
||||
ContactFilterToolbar contactFilter = findViewById(R.id.contact_filter);
|
||||
Toolbar smsToolbar = findViewById(R.id.sms_send_frame_toolbar);
|
||||
ContactFilterView contactFilter = findViewById(R.id.contact_filter_edit_text);
|
||||
|
||||
inviteText = findViewById(R.id.invite_text);
|
||||
smsSendFrame = findViewById(R.id.sms_send_frame);
|
||||
@@ -121,7 +120,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
smsCancelButton.setOnClickListener(new SmsCancelClickListener());
|
||||
smsSendButton.setOnClickListener(new SmsSendClickListener());
|
||||
contactFilter.setOnFilterChangedListener(new ContactFilterChangedListener());
|
||||
contactFilter.setNavigationIcon(R.drawable.ic_search_conversation_24);
|
||||
smsToolbar.setNavigationIcon(R.drawable.ic_search_conversation_24);
|
||||
|
||||
if (Util.isDefaultSmsProvider(this)) {
|
||||
shareButton.setOnClickListener(new ShareClickListener());
|
||||
@@ -150,6 +149,10 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
updateSmsButtonText(contactsFragment.getSelectedContacts().size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSelectionChanged() {
|
||||
}
|
||||
|
||||
private void sendSmsInvites() {
|
||||
new SendSmsInvitesAsyncTask(this, inviteText.getText().toString())
|
||||
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR,
|
||||
|
||||
@@ -9,6 +9,8 @@ import android.os.Bundle;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
||||
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferLockedDialog;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
@@ -17,13 +19,15 @@ import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
|
||||
public class MainActivity extends PassphraseRequiredActivity {
|
||||
public class MainActivity extends PassphraseRequiredActivity implements VoiceNoteMediaControllerOwner {
|
||||
|
||||
public static final int RESULT_CONFIG_CHANGED = Activity.RESULT_FIRST_USER + 901;
|
||||
|
||||
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
|
||||
private final MainNavigator navigator = new MainNavigator(this);
|
||||
|
||||
private VoiceNoteMediaController mediaController;
|
||||
|
||||
public static @NonNull Intent clearTop(@NonNull Context context) {
|
||||
Intent intent = new Intent(context, MainActivity.class);
|
||||
|
||||
@@ -40,6 +44,7 @@ public class MainActivity extends PassphraseRequiredActivity {
|
||||
super.onCreate(savedInstanceState, ready);
|
||||
setContentView(R.layout.main_activity);
|
||||
|
||||
mediaController = new VoiceNoteMediaController(this);
|
||||
navigator.onCreate(savedInstanceState);
|
||||
|
||||
handleGroupLinkInIntent(getIntent());
|
||||
@@ -109,4 +114,9 @@ public class MainActivity extends PassphraseRequiredActivity {
|
||||
CommunicationActions.handlePotentialProxyLinkUrl(this, data.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull VoiceNoteMediaController getVoiceNoteMediaController() {
|
||||
return mediaController;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class MuteDialog extends AlertDialog {
|
||||
@@ -29,7 +31,7 @@ public class MuteDialog extends AlertDialog {
|
||||
}
|
||||
|
||||
public static void show(final Context context, final @NonNull MuteSelectionListener listener, @Nullable Runnable cancelListener) {
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(context);
|
||||
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(context);
|
||||
builder.setTitle(R.string.MuteDialog_mute_notifications);
|
||||
builder.setItems(R.array.mute_durations, new DialogInterface.OnClickListener() {
|
||||
@Override
|
||||
|
||||
@@ -56,6 +56,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
super.onCreate(bundle, ready);
|
||||
assert getSupportActionBar() != null;
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
getSupportActionBar().setTitle(R.string.NewConversationActivity__new_message);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -96,6 +97,10 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSelectionChanged() {
|
||||
}
|
||||
|
||||
private void launch(Recipient recipient) {
|
||||
long existingThread = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId());
|
||||
Intent intent = ConversationIntents.createBuilder(this, recipient.getId(), existingThread)
|
||||
|
||||
@@ -65,4 +65,8 @@ public class PushContactSelectionActivity extends ContactSelectionActivity {
|
||||
setResult(RESULT_OK, resultIntent);
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSelectionChanged() {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ import android.graphics.BitmapFactory;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.drawable.BitmapDrawable;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
import android.os.Vibrator;
|
||||
@@ -63,9 +62,8 @@ import androidx.fragment.app.FragmentTransaction;
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.color.MaterialColor;
|
||||
import org.thoughtcrime.securesms.components.camera.CameraView;
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSessionLock;
|
||||
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable;
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
@@ -87,16 +85,13 @@ import org.thoughtcrime.securesms.util.IdentityUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.libsignal.fingerprint.Fingerprint;
|
||||
import org.whispersystems.libsignal.fingerprint.FingerprintParsingException;
|
||||
import org.whispersystems.libsignal.fingerprint.FingerprintVersionMismatchException;
|
||||
import org.whispersystems.libsignal.fingerprint.NumericFingerprintGenerator;
|
||||
import org.whispersystems.signalservice.api.SignalSessionLock;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.Locale;
|
||||
|
||||
@@ -615,7 +610,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
final RecipientId recipientId = recipient.getId();
|
||||
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) {
|
||||
try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
|
||||
if (isChecked) {
|
||||
Log.i(TAG, "Saving identity: " + recipientId);
|
||||
DatabaseFactory.getIdentityDatabase(getActivity())
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package org.thoughtcrime.securesms.animation.transitions
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.ObjectAnimator
|
||||
import android.animation.PropertyValuesHolder
|
||||
import android.animation.TypeEvaluator
|
||||
import android.content.Context
|
||||
import android.transition.Transition
|
||||
import android.transition.TransitionValues
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.view.animation.Interpolator
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
|
||||
private const val POSITION_ON_SCREEN = "signal.circleavatartransition.positiononscreen"
|
||||
private const val WIDTH = "signal.circleavatartransition.width"
|
||||
private const val HEIGHT = "signal.circleavatartransition.height"
|
||||
|
||||
/**
|
||||
* Custom transition for Circular avatars, because once you have multiple things animating stuff was getting broken and weird.
|
||||
*/
|
||||
@RequiresApi(21)
|
||||
class CircleAvatarTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
|
||||
override fun captureStartValues(transitionValues: TransitionValues) {
|
||||
captureValues(transitionValues)
|
||||
}
|
||||
|
||||
override fun captureEndValues(transitionValues: TransitionValues) {
|
||||
captureValues(transitionValues)
|
||||
}
|
||||
|
||||
private fun captureValues(transitionValues: TransitionValues) {
|
||||
val view: View = transitionValues.view
|
||||
|
||||
if (view is AvatarImageView) {
|
||||
val topLeft = intArrayOf(0, 0)
|
||||
view.getLocationOnScreen(topLeft)
|
||||
transitionValues.values[POSITION_ON_SCREEN] = topLeft
|
||||
transitionValues.values[WIDTH] = view.measuredWidth
|
||||
transitionValues.values[HEIGHT] = view.measuredHeight
|
||||
}
|
||||
}
|
||||
|
||||
override fun createAnimator(sceneRoot: ViewGroup, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
|
||||
if (startValues == null || endValues == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val view: View = endValues.view
|
||||
if (view !is AvatarImageView || view.transitionName != "avatar") {
|
||||
return null
|
||||
}
|
||||
|
||||
val startCoords: IntArray = startValues.values[POSITION_ON_SCREEN] as? IntArray ?: intArrayOf(0, 0).apply { view.getLocationOnScreen(this) }
|
||||
val endCoords: IntArray = endValues.values[POSITION_ON_SCREEN] as? IntArray ?: intArrayOf(0, 0).apply { view.getLocationOnScreen(this) }
|
||||
|
||||
val startWidth: Int = startValues.values[WIDTH] as? Int ?: view.measuredWidth
|
||||
val endWidth: Int = endValues.values[WIDTH] as? Int ?: view.measuredWidth
|
||||
|
||||
val startHeight: Int = startValues.values[HEIGHT] as? Int ?: view.measuredHeight
|
||||
val endHeight: Int = endValues.values[HEIGHT] as? Int ?: view.measuredHeight
|
||||
|
||||
val startHeightOffset = (endHeight - startHeight) / 2f
|
||||
val startWidthOffset = (endWidth - startWidth) / 2f
|
||||
|
||||
val translateXHolder = PropertyValuesHolder.ofFloat("translationX", startCoords[0] - endCoords[0] - startWidthOffset, 0f).apply {
|
||||
setEvaluator(FloatInterpolatorEvaluator(DecelerateInterpolator()))
|
||||
}
|
||||
val translateYHolder = PropertyValuesHolder.ofFloat("translationY", startCoords[1] - endCoords[1] - startHeightOffset, 0f).apply {
|
||||
setEvaluator(FloatInterpolatorEvaluator(AccelerateInterpolator()))
|
||||
}
|
||||
|
||||
val widthRatio = startWidth.toFloat() / endWidth
|
||||
val scaleXHolder = PropertyValuesHolder.ofFloat("scaleX", widthRatio, 1f)
|
||||
|
||||
val heightRatio = startHeight.toFloat() / endHeight
|
||||
val scaleYHolder = PropertyValuesHolder.ofFloat("scaleY", heightRatio, 1f)
|
||||
|
||||
return ObjectAnimator.ofPropertyValuesHolder(view, translateXHolder, translateYHolder, scaleXHolder, scaleYHolder)
|
||||
}
|
||||
|
||||
private class FloatInterpolatorEvaluator(
|
||||
private val interpolator: Interpolator
|
||||
) : TypeEvaluator<Float> {
|
||||
|
||||
override fun evaluate(fraction: Float, startValue: Float, endValue: Float): Float {
|
||||
val interpolatedFraction = interpolator.getInterpolation(fraction)
|
||||
val delta = endValue - startValue
|
||||
|
||||
return delta * interpolatedFraction + startValue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.thoughtcrime.securesms.animation.transitions
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.ObjectAnimator
|
||||
import android.animation.RectEvaluator
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.transition.Transition
|
||||
import android.transition.TransitionValues
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.animation.addListener
|
||||
import androidx.fragment.app.FragmentContainerView
|
||||
|
||||
private const val BOUNDS = "signal.wipedowntransition.bottom"
|
||||
|
||||
/**
|
||||
* WipeDownTransition will animate the bottom position of a view such that it "wipes" down the screen to a final position.
|
||||
*/
|
||||
@RequiresApi(21)
|
||||
class WipeDownTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
|
||||
override fun captureStartValues(transitionValues: TransitionValues) {
|
||||
captureValues(transitionValues)
|
||||
}
|
||||
|
||||
override fun captureEndValues(transitionValues: TransitionValues) {
|
||||
captureValues(transitionValues)
|
||||
}
|
||||
|
||||
private fun captureValues(transitionValues: TransitionValues) {
|
||||
val view: View = transitionValues.view
|
||||
|
||||
if (view is ViewGroup) {
|
||||
val rect = Rect()
|
||||
view.getLocalVisibleRect(rect)
|
||||
transitionValues.values[BOUNDS] = rect
|
||||
}
|
||||
}
|
||||
|
||||
override fun createAnimator(sceneRoot: ViewGroup, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
|
||||
if (startValues == null || endValues == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val view: View = endValues.view
|
||||
if (view !is FragmentContainerView) {
|
||||
return null
|
||||
}
|
||||
|
||||
val startBottom: Rect = startValues.values[BOUNDS] as? Rect ?: Rect().apply { view.getLocalVisibleRect(this) }
|
||||
val endBottom: Rect = endValues.values[BOUNDS] as? Rect ?: Rect().apply { view.getLocalVisibleRect(this) }
|
||||
|
||||
return ObjectAnimator.ofObject(view, "clipBounds", RectEvaluator(), startBottom, endBottom).apply {
|
||||
addListener(
|
||||
onEnd = {
|
||||
view.clipBounds = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,11 @@ import androidx.annotation.NonNull;
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
@@ -51,7 +51,7 @@ public class AudioRecorder {
|
||||
captureUri = BlobProvider.getInstance()
|
||||
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
|
||||
.withMimeType(MediaUtil.AUDIO_AAC)
|
||||
.createForSingleSessionOnDiskAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
|
||||
.createForDraftAttachmentAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
|
||||
audioCodec = new AudioCodec();
|
||||
|
||||
audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1]));
|
||||
@@ -61,10 +61,10 @@ public class AudioRecorder {
|
||||
});
|
||||
}
|
||||
|
||||
public @NonNull ListenableFuture<Pair<Uri, Long>> stopRecording() {
|
||||
public @NonNull ListenableFuture<VoiceNoteDraft> stopRecording() {
|
||||
Log.i(TAG, "stopRecording()");
|
||||
|
||||
final SettableFuture<Pair<Uri, Long>> future = new SettableFuture<>();
|
||||
final SettableFuture<VoiceNoteDraft> future = new SettableFuture<>();
|
||||
|
||||
executor.execute(() -> {
|
||||
if (audioCodec == null) {
|
||||
@@ -76,7 +76,7 @@ public class AudioRecorder {
|
||||
|
||||
try {
|
||||
long size = MediaUtil.getMediaSize(context, captureUri);
|
||||
sendToFuture(future, new Pair<>(captureUri, size));
|
||||
sendToFuture(future, new VoiceNoteDraft(captureUri, size));
|
||||
} catch (IOException ioe) {
|
||||
Log.w(TAG, ioe);
|
||||
sendToFuture(future, ioe);
|
||||
|
||||
@@ -65,12 +65,6 @@ public final class AudioWaveForm {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(attachment instanceof DatabaseAttachment)) {
|
||||
Log.i(TAG, "Not yet in database");
|
||||
ThreadUtil.runOnMain(onFailure);
|
||||
return;
|
||||
}
|
||||
|
||||
String cacheKey = uri.toString();
|
||||
AudioFileInfo cached = WAVE_FORM_CACHE.get(cacheKey);
|
||||
if (cached != null) {
|
||||
@@ -104,26 +98,46 @@ public final class AudioWaveForm {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
|
||||
DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment;
|
||||
long startTime = System.currentTimeMillis();
|
||||
if (attachment instanceof DatabaseAttachment) {
|
||||
try {
|
||||
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
|
||||
DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment;
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), AudioWaveFormData.getDefaultInstance());
|
||||
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), AudioWaveFormData.getDefaultInstance());
|
||||
|
||||
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
|
||||
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
|
||||
|
||||
AudioFileInfo fileInfo = generateWaveForm(uri);
|
||||
AudioFileInfo fileInfo = generateWaveForm(uri);
|
||||
|
||||
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
|
||||
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
|
||||
|
||||
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf());
|
||||
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf());
|
||||
|
||||
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
|
||||
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
|
||||
} catch (Throwable e) {
|
||||
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
|
||||
ThreadUtil.runOnMain(onFailure);
|
||||
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
|
||||
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
|
||||
} catch (Throwable e) {
|
||||
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
|
||||
ThreadUtil.runOnMain(onFailure);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
Log.i(TAG, "Not in database and not cached. Generating wave form on-the-fly.");
|
||||
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
|
||||
|
||||
AudioFileInfo fileInfo = generateWaveForm(uri);
|
||||
|
||||
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
|
||||
|
||||
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
|
||||
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
|
||||
ThreadUtil.runOnMain(onFailure);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.content.Intent;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.ViewSwitcher;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.StringRes;
|
||||
@@ -18,7 +17,7 @@ import com.google.android.material.snackbar.Snackbar;
|
||||
import org.thoughtcrime.securesms.ContactSelectionListFragment;
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterToolbar;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
@@ -47,27 +46,26 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
|
||||
viewModel = ViewModelProviders.of(this, factory).get(BlockedUsersViewModel.class);
|
||||
|
||||
ViewSwitcher viewSwitcher = findViewById(R.id.toolbar_switcher);
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
ContactFilterToolbar contactFilterToolbar = findViewById(R.id.filter_toolbar);
|
||||
View container = findViewById(R.id.fragment_container);
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
ContactFilterView contactFilterView = findViewById(R.id.contact_filter_edit_text);
|
||||
View container = findViewById(R.id.fragment_container);
|
||||
|
||||
toolbar.setNavigationOnClickListener(unused -> onBackPressed());
|
||||
contactFilterToolbar.setNavigationOnClickListener(unused -> onBackPressed());
|
||||
contactFilterToolbar.setOnFilterChangedListener(query -> {
|
||||
contactFilterView.setOnFilterChangedListener(query -> {
|
||||
Fragment fragment = getSupportFragmentManager().findFragmentByTag(CONTACT_SELECTION_FRAGMENT);
|
||||
if (fragment != null) {
|
||||
((ContactSelectionListFragment) fragment).setQueryFilter(query);
|
||||
}
|
||||
});
|
||||
contactFilterToolbar.setHint(R.string.BlockedUsersActivity__add_blocked_user);
|
||||
contactFilterView.setHint(R.string.BlockedUsersActivity__add_blocked_user);
|
||||
|
||||
//noinspection CodeBlock2Expr
|
||||
getSupportFragmentManager().addOnBackStackChangedListener(() -> {
|
||||
viewSwitcher.setDisplayedChild(getSupportFragmentManager().getBackStackEntryCount());
|
||||
|
||||
if (getSupportFragmentManager().getBackStackEntryCount() == 1) {
|
||||
contactFilterToolbar.focusAndShowKeyboard();
|
||||
contactFilterView.setVisibility(View.VISIBLE);
|
||||
contactFilterView.focusAndShowKeyboard();
|
||||
} else {
|
||||
contactFilterView.setVisibility(View.GONE);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -119,6 +117,10 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSelectionChanged() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleAddUserToBlockedList() {
|
||||
ContactSelectionListFragment fragment = new ContactSelectionListFragment();
|
||||
@@ -164,6 +166,6 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
throw new IllegalArgumentException("Unsupported event type " + event);
|
||||
}
|
||||
|
||||
Snackbar.make(view, getString(messageResId, displayName), Snackbar.LENGTH_SHORT).show();
|
||||
Snackbar.make(view, getString(messageResId, displayName), Snackbar.LENGTH_SHORT).setTextColor(Color.WHITE).show();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,17 +45,21 @@ public final class AudioView extends FrameLayout {
|
||||
|
||||
private static final String TAG = Log.tag(AudioView.class);
|
||||
|
||||
private static final int MODE_NORMAL = 0;
|
||||
private static final int MODE_SMALL = 1;
|
||||
private static final int MODE_DRAFT = 2;
|
||||
|
||||
private static final int FORWARDS = 1;
|
||||
private static final int REVERSE = -1;
|
||||
|
||||
@NonNull private final AnimatingToggle controlToggle;
|
||||
@NonNull private final View progressAndPlay;
|
||||
@NonNull private final LottieAnimationView playPauseButton;
|
||||
@NonNull private final ImageView downloadButton;
|
||||
@NonNull private final ProgressWheel circleProgress;
|
||||
@NonNull private final SeekBar seekBar;
|
||||
private final boolean smallView;
|
||||
private final boolean autoRewind;
|
||||
@NonNull private final AnimatingToggle controlToggle;
|
||||
@NonNull private final View progressAndPlay;
|
||||
@NonNull private final LottieAnimationView playPauseButton;
|
||||
@NonNull private final ImageView downloadButton;
|
||||
@Nullable private final ProgressWheel circleProgress;
|
||||
@NonNull private final SeekBar seekBar;
|
||||
private final boolean smallView;
|
||||
private final boolean autoRewind;
|
||||
|
||||
@Nullable private final TextView duration;
|
||||
|
||||
@@ -87,10 +91,23 @@ public final class AudioView extends FrameLayout {
|
||||
try {
|
||||
typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AudioView, 0, 0);
|
||||
|
||||
smallView = typedArray.getBoolean(R.styleable.AudioView_small, false);
|
||||
int mode = typedArray.getInteger(R.styleable.AudioView_audioView_mode, MODE_NORMAL);
|
||||
smallView = mode == MODE_SMALL;
|
||||
autoRewind = typedArray.getBoolean(R.styleable.AudioView_autoRewind, false);
|
||||
|
||||
inflate(context, smallView ? R.layout.audio_view_small : R.layout.audio_view, this);
|
||||
switch (mode) {
|
||||
case MODE_NORMAL:
|
||||
inflate(context, R.layout.audio_view, this);
|
||||
break;
|
||||
case MODE_SMALL:
|
||||
inflate(context, R.layout.audio_view_small, this);
|
||||
break;
|
||||
case MODE_DRAFT:
|
||||
inflate(context, R.layout.audio_view_draft, this);
|
||||
break;
|
||||
default:
|
||||
throw new IllegalStateException("Unsupported mode: " + mode);
|
||||
}
|
||||
|
||||
this.controlToggle = findViewById(R.id.control_toggle);
|
||||
this.playPauseButton = findViewById(R.id.play);
|
||||
@@ -110,7 +127,7 @@ public final class AudioView extends FrameLayout {
|
||||
this.waveFormUnplayedBarsColor = typedArray.getColor(R.styleable.AudioView_waveformUnplayedBarsColor, Color.WHITE);
|
||||
this.waveFormThumbTint = typedArray.getColor(R.styleable.AudioView_waveformThumbTint, Color.WHITE);
|
||||
|
||||
progressAndPlay.getBackground().setColorFilter(typedArray.getColor(R.styleable.AudioView_progressAndPlayTint, Color.BLACK), PorterDuff.Mode.SRC_IN);
|
||||
setProgressAndPlayBackgroundTint(typedArray.getColor(R.styleable.AudioView_progressAndPlayTint, Color.BLACK));
|
||||
} finally {
|
||||
if (typedArray != null) {
|
||||
typedArray.recycle();
|
||||
@@ -130,6 +147,10 @@ public final class AudioView extends FrameLayout {
|
||||
EventBus.getDefault().unregister(this);
|
||||
}
|
||||
|
||||
public void setProgressAndPlayBackgroundTint(@ColorInt int color) {
|
||||
progressAndPlay.getBackground().setColorFilter(color, PorterDuff.Mode.SRC_IN);
|
||||
}
|
||||
|
||||
public Observer<VoiceNotePlaybackState> getPlaybackStateObserver() {
|
||||
return playbackStateObserver;
|
||||
}
|
||||
@@ -158,16 +179,20 @@ public final class AudioView extends FrameLayout {
|
||||
controlToggle.displayQuick(downloadButton);
|
||||
seekBar.setEnabled(false);
|
||||
downloadButton.setOnClickListener(new DownloadClickedListener(audio));
|
||||
if (circleProgress.isSpinning()) circleProgress.stopSpinning();
|
||||
circleProgress.setVisibility(View.GONE);
|
||||
if (circleProgress != null) {
|
||||
if (circleProgress.isSpinning()) circleProgress.stopSpinning();
|
||||
circleProgress.setVisibility(View.GONE);
|
||||
}
|
||||
} else if (showControls && audio.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) {
|
||||
controlToggle.displayQuick(progressAndPlay);
|
||||
seekBar.setEnabled(false);
|
||||
circleProgress.setVisibility(View.VISIBLE);
|
||||
circleProgress.spin();
|
||||
if (circleProgress != null) {
|
||||
circleProgress.setVisibility(View.VISIBLE);
|
||||
circleProgress.spin();
|
||||
}
|
||||
} else {
|
||||
seekBar.setEnabled(true);
|
||||
if (circleProgress.isSpinning()) circleProgress.stopSpinning();
|
||||
if (circleProgress != null && circleProgress.isSpinning()) circleProgress.stopSpinning();
|
||||
showPlayButton();
|
||||
}
|
||||
|
||||
@@ -211,10 +236,11 @@ public final class AudioView extends FrameLayout {
|
||||
|
||||
private void onPlaybackState(@NonNull VoiceNotePlaybackState voiceNotePlaybackState) {
|
||||
onDuration(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.getTrackDuration());
|
||||
onStart(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.isAutoReset());
|
||||
onProgress(voiceNotePlaybackState.getUri(),
|
||||
(double) voiceNotePlaybackState.getPlayheadPositionMillis() / voiceNotePlaybackState.getTrackDuration(),
|
||||
voiceNotePlaybackState.getPlayheadPositionMillis());
|
||||
onSpeedChanged(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.getSpeed());
|
||||
onStart(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.isPlaying(), voiceNotePlaybackState.isAutoReset());
|
||||
}
|
||||
|
||||
private void onDuration(@NonNull Uri uri, long durationMillis) {
|
||||
@@ -223,8 +249,8 @@ public final class AudioView extends FrameLayout {
|
||||
}
|
||||
}
|
||||
|
||||
private void onStart(@NonNull Uri uri, boolean autoReset) {
|
||||
if (!isTarget(uri)) {
|
||||
private void onStart(@NonNull Uri uri, boolean statePlaying, boolean autoReset) {
|
||||
if (!isTarget(uri) || !statePlaying) {
|
||||
if (hasAudioUri()) {
|
||||
onStop(audioSlide.getUri(), autoReset);
|
||||
}
|
||||
@@ -274,6 +300,12 @@ public final class AudioView extends FrameLayout {
|
||||
}
|
||||
}
|
||||
|
||||
private void onSpeedChanged(@NonNull Uri uri, float speed) {
|
||||
if (callbacks != null) {
|
||||
callbacks.onSpeedChanged(speed, isTarget(uri));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isTarget(@NonNull Uri uri) {
|
||||
return hasAudioUri() && Objects.equals(uri, audioSlide.getUri());
|
||||
}
|
||||
@@ -318,7 +350,7 @@ public final class AudioView extends FrameLayout {
|
||||
duration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60));
|
||||
}
|
||||
|
||||
if (smallView) {
|
||||
if (smallView && circleProgress != null) {
|
||||
circleProgress.setInstantProgress(seekBar.getProgress() == 0 ? 1 : progress);
|
||||
}
|
||||
}
|
||||
@@ -329,7 +361,10 @@ public final class AudioView extends FrameLayout {
|
||||
new LottieValueCallback<>(new SimpleColorFilter(foregroundTint))));
|
||||
|
||||
this.downloadButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
|
||||
this.circleProgress.setBarColor(foregroundTint);
|
||||
|
||||
if (circleProgress != null) {
|
||||
this.circleProgress.setBarColor(foregroundTint);
|
||||
}
|
||||
|
||||
if (this.duration != null) {
|
||||
this.duration.setTextColor(foregroundTint);
|
||||
@@ -372,11 +407,14 @@ public final class AudioView extends FrameLayout {
|
||||
}
|
||||
|
||||
private void showPlayButton() {
|
||||
if (!smallView) {
|
||||
circleProgress.setVisibility(GONE);
|
||||
} else if (seekBar.getProgress() == 0) {
|
||||
circleProgress.setInstantProgress(1);
|
||||
if (circleProgress != null) {
|
||||
if (!smallView) {
|
||||
circleProgress.setVisibility(GONE);
|
||||
} else if (seekBar.getProgress() == 0) {
|
||||
circleProgress.setInstantProgress(1);
|
||||
}
|
||||
}
|
||||
|
||||
playPauseButton.setVisibility(VISIBLE);
|
||||
controlToggle.displayQuick(progressAndPlay);
|
||||
}
|
||||
@@ -451,6 +489,8 @@ public final class AudioView extends FrameLayout {
|
||||
if (callbacks != null) {
|
||||
if (wasPlaying) {
|
||||
callbacks.onSeekTo(audioSlide.getUri(), getProgress());
|
||||
} else {
|
||||
callbacks.onProgressUpdated(durationMillis, Math.round(durationMillis * getProgress()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -465,7 +505,7 @@ public final class AudioView extends FrameLayout {
|
||||
|
||||
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
|
||||
public void onEventAsync(final PartProgressEvent event) {
|
||||
if (audioSlide != null && event.attachment.equals(audioSlide.asAttachment())) {
|
||||
if (audioSlide != null && circleProgress != null && event.attachment.equals(audioSlide.asAttachment())) {
|
||||
circleProgress.setInstantProgress(((float) event.progress) / event.total);
|
||||
}
|
||||
}
|
||||
@@ -475,6 +515,7 @@ public final class AudioView extends FrameLayout {
|
||||
void onPause(@NonNull Uri audioUri);
|
||||
void onSeekTo(@NonNull Uri audioUri, double progress);
|
||||
void onStopAndReset(@NonNull Uri audioUri);
|
||||
void onSpeedChanged(float speed, boolean isPlaying);
|
||||
void onProgressUpdated(long durationMillis, long playheadMillis);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,15 @@ import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.IntRange;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.Px;
|
||||
import androidx.appcompat.widget.AppCompatImageView;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
|
||||
@@ -20,21 +23,23 @@ import com.bumptech.glide.load.Transformation;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.resource.bitmap.CircleCrop;
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy;
|
||||
import com.bumptech.glide.request.target.SimpleTarget;
|
||||
import com.bumptech.glide.request.transition.Transition;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
|
||||
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequest;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment;
|
||||
import org.thoughtcrime.securesms.recipients.ui.managerecipient.ManageRecipientActivity;
|
||||
import org.thoughtcrime.securesms.util.AvatarUtil;
|
||||
import org.thoughtcrime.securesms.util.BlurTransformation;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
@@ -74,6 +79,7 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
private Recipient.FallbackPhotoProvider fallbackPhotoProvider;
|
||||
private boolean blurred;
|
||||
private ChatColors chatColors;
|
||||
private FixedSizeTarget fixedSizeTarget;
|
||||
|
||||
private @Nullable RecipientContactPhoto recipientContactPhoto;
|
||||
private @NonNull Drawable unknownRecipientDrawable;
|
||||
@@ -93,8 +99,8 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
|
||||
if (attrs != null) {
|
||||
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AvatarImageView, 0, 0);
|
||||
inverted = typedArray.getBoolean(R.styleable.AvatarImageView_inverted, false);
|
||||
size = typedArray.getInt(R.styleable.AvatarImageView_fallbackImageSize, SIZE_LARGE);
|
||||
inverted = typedArray.getBoolean(R.styleable.AvatarImageView_inverted, false);
|
||||
size = typedArray.getInt(R.styleable.AvatarImageView_fallbackImageSize, SIZE_LARGE);
|
||||
typedArray.recycle();
|
||||
}
|
||||
|
||||
@@ -105,6 +111,11 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
chatColors = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setClipBounds(Rect clipBounds) {
|
||||
super.setClipBounds(clipBounds);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
super.onDraw(canvas);
|
||||
@@ -148,6 +159,10 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
}
|
||||
}
|
||||
|
||||
public AvatarOptions.Builder buildOptions() {
|
||||
return new AvatarOptions.Builder(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows self as the note to self icon.
|
||||
*/
|
||||
@@ -167,11 +182,22 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
}
|
||||
|
||||
public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, boolean quickContactEnabled, boolean useSelfProfileAvatar) {
|
||||
setAvatar(requestManager, recipient, new AvatarOptions.Builder(this)
|
||||
.withUseSelfProfileAvatar(useSelfProfileAvatar)
|
||||
.withQuickContactEnabled(quickContactEnabled)
|
||||
.build());
|
||||
}
|
||||
|
||||
private void setAvatar(@Nullable Recipient recipient, @NonNull AvatarOptions avatarOptions) {
|
||||
setAvatar(GlideApp.with(this), recipient, avatarOptions);
|
||||
}
|
||||
|
||||
private void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, @NonNull AvatarOptions avatarOptions) {
|
||||
if (recipient != null) {
|
||||
RecipientContactPhoto photo = (recipient.isSelf() && useSelfProfileAvatar) ? new RecipientContactPhoto(recipient,
|
||||
new ProfileContactPhoto(Recipient.self(),
|
||||
Recipient.self().getProfileAvatar()))
|
||||
: new RecipientContactPhoto(recipient);
|
||||
RecipientContactPhoto photo = (recipient.isSelf() && avatarOptions.useSelfProfileAvatar) ? new RecipientContactPhoto(recipient,
|
||||
new ProfileContactPhoto(Recipient.self(),
|
||||
Recipient.self().getProfileAvatar()))
|
||||
: new RecipientContactPhoto(recipient);
|
||||
|
||||
boolean shouldBlur = recipient.shouldBlurAvatar();
|
||||
ChatColors chatColors = recipient.getChatColors();
|
||||
@@ -184,6 +210,10 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
Drawable fallbackContactPhotoDrawable = size == SIZE_SMALL ? photo.recipient.getSmallFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider)
|
||||
: photo.recipient.getFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider);
|
||||
|
||||
if (fixedSizeTarget != null) {
|
||||
requestManager.clear(fixedSizeTarget);
|
||||
}
|
||||
|
||||
if (photo.contactPhoto != null) {
|
||||
|
||||
List<Transformation<Bitmap>> transforms = new ArrayList<>();
|
||||
@@ -193,19 +223,26 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
transforms.add(new CircleCrop());
|
||||
blurred = shouldBlur;
|
||||
|
||||
requestManager.load(photo.contactPhoto)
|
||||
.fallback(fallbackContactPhotoDrawable)
|
||||
.error(fallbackContactPhotoDrawable)
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.downsample(DownsampleStrategy.CENTER_INSIDE)
|
||||
.transform(new MultiTransformation<>(transforms))
|
||||
.into(this);
|
||||
GlideRequest<Drawable> request = requestManager.load(photo.contactPhoto)
|
||||
.fallback(fallbackContactPhotoDrawable)
|
||||
.error(fallbackContactPhotoDrawable)
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.downsample(DownsampleStrategy.CENTER_INSIDE)
|
||||
.transform(new MultiTransformation<>(transforms));
|
||||
|
||||
if (avatarOptions.fixedSize > 0) {
|
||||
fixedSizeTarget = new FixedSizeTarget(avatarOptions.fixedSize);
|
||||
request.into(fixedSizeTarget);
|
||||
} else {
|
||||
request.into(this);
|
||||
}
|
||||
|
||||
} else {
|
||||
setImageDrawable(fallbackContactPhotoDrawable);
|
||||
}
|
||||
}
|
||||
|
||||
setAvatarClickHandler(recipient, quickContactEnabled);
|
||||
setAvatarClickHandler(recipient, avatarOptions.quickContactEnabled);
|
||||
} else {
|
||||
recipientContactPhoto = null;
|
||||
requestManager.clear(this);
|
||||
@@ -225,15 +262,15 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
super.setOnClickListener(v -> {
|
||||
Context context = getContext();
|
||||
if (recipient.isPushGroup()) {
|
||||
context.startActivity(ManageGroupActivity.newIntent(context, recipient.requireGroupId().requirePush()),
|
||||
ManageGroupActivity.createTransitionBundle(context, this));
|
||||
context.startActivity(ConversationSettingsActivity.forGroup(context, recipient.requireGroupId().requirePush()),
|
||||
ConversationSettingsActivity.createTransitionBundle(context, this));
|
||||
} else {
|
||||
if (context instanceof FragmentActivity) {
|
||||
RecipientBottomSheetDialogFragment.create(recipient.getId(), null)
|
||||
.show(((FragmentActivity) context).getSupportFragmentManager(), "BOTTOM");
|
||||
} else {
|
||||
context.startActivity(ManageRecipientActivity.newIntent(context, recipient.getId()),
|
||||
ManageRecipientActivity.createTransitionBundle(context, this));
|
||||
context.startActivity(ConversationSettingsActivity.forRecipient(context, recipient.getId()),
|
||||
ConversationSettingsActivity.createTransitionBundle(context, this));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -294,4 +331,65 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
Objects.equals(other.contactPhoto, contactPhoto);
|
||||
}
|
||||
}
|
||||
|
||||
private final class FixedSizeTarget extends SimpleTarget<Drawable> {
|
||||
|
||||
FixedSizeTarget(int size) {
|
||||
super(size, size);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
|
||||
setImageDrawable(resource);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class AvatarOptions {
|
||||
|
||||
private final boolean quickContactEnabled;
|
||||
private final boolean useSelfProfileAvatar;
|
||||
private final int fixedSize;
|
||||
|
||||
private AvatarOptions(@NonNull Builder builder) {
|
||||
this.quickContactEnabled = builder.quickContactEnabled;
|
||||
this.useSelfProfileAvatar = builder.useSelfProfileAvatar;
|
||||
this.fixedSize = builder.fixedSize;
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
|
||||
private final AvatarImageView avatarImageView;
|
||||
|
||||
private boolean quickContactEnabled = false;
|
||||
private boolean useSelfProfileAvatar = false;
|
||||
private int fixedSize = -1;
|
||||
|
||||
private Builder(@NonNull AvatarImageView avatarImageView) {
|
||||
this.avatarImageView = avatarImageView;
|
||||
}
|
||||
|
||||
public @NonNull Builder withQuickContactEnabled(boolean quickContactEnabled) {
|
||||
this.quickContactEnabled = quickContactEnabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder withUseSelfProfileAvatar(boolean useSelfProfileAvatar) {
|
||||
this.useSelfProfileAvatar = useSelfProfileAvatar;
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull Builder withFixedSize(@Px @IntRange(from = 1) int fixedSize) {
|
||||
this.fixedSize = fixedSize;
|
||||
return this;
|
||||
}
|
||||
|
||||
public AvatarOptions build() {
|
||||
return new AvatarOptions(this);
|
||||
}
|
||||
|
||||
public void load(@Nullable Recipient recipient) {
|
||||
avatarImageView.setAvatar(recipient, build());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.util.AttributeSet;
|
||||
import android.view.TouchDelegate;
|
||||
import android.view.View;
|
||||
import android.widget.EditText;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
@@ -20,9 +21,8 @@ import androidx.core.widget.TextViewCompat;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.views.DarkOverflowToolbar;
|
||||
|
||||
public final class ContactFilterToolbar extends DarkOverflowToolbar {
|
||||
public final class ContactFilterView extends FrameLayout {
|
||||
private OnFilterChangedListener listener;
|
||||
|
||||
private final EditText searchText;
|
||||
@@ -32,17 +32,17 @@ public final class ContactFilterToolbar extends DarkOverflowToolbar {
|
||||
private final ImageView clearToggle;
|
||||
private final LinearLayout toggleContainer;
|
||||
|
||||
public ContactFilterToolbar(Context context) {
|
||||
public ContactFilterView(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public ContactFilterToolbar(Context context, AttributeSet attrs) {
|
||||
public ContactFilterView(Context context, AttributeSet attrs) {
|
||||
this(context, attrs, R.attr.toolbarStyle);
|
||||
}
|
||||
|
||||
public ContactFilterToolbar(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
public ContactFilterView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
inflate(context, R.layout.contact_filter_toolbar, this);
|
||||
inflate(context, R.layout.contact_filter_view, this);
|
||||
|
||||
this.searchText = findViewById(R.id.search_view);
|
||||
this.toggle = findViewById(R.id.button_toggle);
|
||||
@@ -99,8 +99,6 @@ public final class ContactFilterToolbar extends DarkOverflowToolbar {
|
||||
}
|
||||
});
|
||||
|
||||
setLogo(null);
|
||||
setContentInsetStartWithNavigation(0);
|
||||
expandTapArea(toggleContainer, dialpadToggle);
|
||||
applyAttributes(searchText, context, attrs, defStyleAttr);
|
||||
searchText.requestFocus();
|
||||
@@ -4,6 +4,9 @@ import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
|
||||
import java.util.List;
|
||||
@@ -15,6 +18,8 @@ public class ControllableTabLayout extends TabLayout {
|
||||
|
||||
private List<View> touchables;
|
||||
|
||||
private NewTabListener newTabListener;
|
||||
|
||||
public ControllableTabLayout(Context context) {
|
||||
super(context);
|
||||
}
|
||||
@@ -39,4 +44,28 @@ public class ControllableTabLayout extends TabLayout {
|
||||
|
||||
super.setEnabled(enabled);
|
||||
}
|
||||
|
||||
public void setNewTabListener(@Nullable NewTabListener newTabListener) {
|
||||
this.newTabListener = newTabListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Tab newTab() {
|
||||
Tab tab = super.newTab();
|
||||
|
||||
if (newTabListener != null) {
|
||||
newTabListener.onNewTab(tab);
|
||||
}
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows implementor to modify tabs when they are created, before they are added to the tab layout.
|
||||
* This is useful for loading custom views, to ensure that time is not spent inflating these views
|
||||
* as the user is switching between pages.
|
||||
*/
|
||||
public interface NewTabListener {
|
||||
void onNewTab(@NonNull Tab tab);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.Manifest;
|
||||
import android.animation.Animator;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.PorterDuffColorFilter;
|
||||
import android.graphics.Rect;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
|
||||
import com.airbnb.lottie.LottieAnimationView;
|
||||
import com.airbnb.lottie.LottieProperty;
|
||||
import com.airbnb.lottie.model.KeyPath;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
@@ -30,7 +35,6 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.dualsim.SubscriptionInfoCompat;
|
||||
@@ -40,17 +44,24 @@ import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class ConversationItemFooter extends LinearLayout {
|
||||
public class ConversationItemFooter extends ConstraintLayout {
|
||||
|
||||
private TextView dateView;
|
||||
private TextView simView;
|
||||
private ExpirationTimerView timerView;
|
||||
private ImageView insecureIndicatorView;
|
||||
private DeliveryStatusView deliveryStatusView;
|
||||
private boolean onlyShowSendingStatus;
|
||||
private View audioSpace;
|
||||
private TextView audioDuration;
|
||||
private LottieAnimationView revealDot;
|
||||
private TextView dateView;
|
||||
private TextView simView;
|
||||
private ExpirationTimerView timerView;
|
||||
private ImageView insecureIndicatorView;
|
||||
private DeliveryStatusView deliveryStatusView;
|
||||
private boolean onlyShowSendingStatus;
|
||||
private TextView audioDuration;
|
||||
private LottieAnimationView revealDot;
|
||||
private PlaybackSpeedToggleTextView playbackSpeedToggleTextView;
|
||||
private boolean isOutgoing;
|
||||
private boolean hasShrunkDate;
|
||||
|
||||
private OnTouchDelegateChangedListener onTouchDelegateChangedListener;
|
||||
|
||||
private final Rect speedToggleHitRect = new Rect();
|
||||
private final int touchTargetSize = ViewUtil.dpToPx(48);
|
||||
|
||||
public ConversationItemFooter(Context context) {
|
||||
super(context);
|
||||
@@ -68,24 +79,55 @@ public class ConversationItemFooter extends LinearLayout {
|
||||
}
|
||||
|
||||
private void init(@Nullable AttributeSet attrs) {
|
||||
inflate(getContext(), R.layout.conversation_item_footer, this);
|
||||
|
||||
dateView = findViewById(R.id.footer_date);
|
||||
simView = findViewById(R.id.footer_sim_info);
|
||||
timerView = findViewById(R.id.footer_expiration_timer);
|
||||
insecureIndicatorView = findViewById(R.id.footer_insecure_indicator);
|
||||
deliveryStatusView = findViewById(R.id.footer_delivery_status);
|
||||
audioDuration = findViewById(R.id.footer_audio_duration);
|
||||
audioSpace = findViewById(R.id.footer_audio_duration_space);
|
||||
revealDot = findViewById(R.id.footer_revealed_dot);
|
||||
|
||||
final TypedArray typedArray;
|
||||
if (attrs != null) {
|
||||
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemFooter, 0, 0);
|
||||
typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemFooter, 0, 0);
|
||||
} else {
|
||||
typedArray = null;
|
||||
}
|
||||
|
||||
final @LayoutRes int contentId;
|
||||
if (typedArray != null) {
|
||||
int mode = typedArray.getInt(R.styleable.ConversationItemFooter_footer_mode, 0);
|
||||
isOutgoing = mode == 0;
|
||||
|
||||
if (isOutgoing) {
|
||||
contentId = R.layout.conversation_item_footer_outgoing;
|
||||
} else {
|
||||
contentId = R.layout.conversation_item_footer_incoming;
|
||||
}
|
||||
} else {
|
||||
contentId = R.layout.conversation_item_footer_outgoing;
|
||||
isOutgoing = true;
|
||||
}
|
||||
|
||||
inflate(getContext(), contentId, this);
|
||||
|
||||
dateView = findViewById(R.id.footer_date);
|
||||
simView = findViewById(R.id.footer_sim_info);
|
||||
timerView = findViewById(R.id.footer_expiration_timer);
|
||||
insecureIndicatorView = findViewById(R.id.footer_insecure_indicator);
|
||||
deliveryStatusView = findViewById(R.id.footer_delivery_status);
|
||||
audioDuration = findViewById(R.id.footer_audio_duration);
|
||||
revealDot = findViewById(R.id.footer_revealed_dot);
|
||||
playbackSpeedToggleTextView = findViewById(R.id.footer_audio_playback_speed_toggle);
|
||||
|
||||
if (typedArray != null) {
|
||||
setTextColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_text_color, getResources().getColor(R.color.core_white)));
|
||||
setIconColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_icon_color, getResources().getColor(R.color.core_white)));
|
||||
setRevealDotColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_reveal_dot_color, getResources().getColor(R.color.core_white)));
|
||||
typedArray.recycle();
|
||||
}
|
||||
|
||||
dateView.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
|
||||
if (oldLeft != left || oldRight != right) {
|
||||
notifyTouchDelegateChanged(getPlaybackSpeedToggleTouchDelegateRect(), playbackSpeedToggleTextView);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void setOnTouchDelegateChangedListener(@Nullable OnTouchDelegateChangedListener onTouchDelegateChangedListener) {
|
||||
this.onTouchDelegateChangedListener = onTouchDelegateChangedListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -108,6 +150,20 @@ public class ConversationItemFooter extends LinearLayout {
|
||||
audioDuration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60));
|
||||
}
|
||||
|
||||
public void setPlaybackSpeedListener(@Nullable PlaybackSpeedToggleTextView.PlaybackSpeedListener playbackSpeedListener) {
|
||||
playbackSpeedToggleTextView.setPlaybackSpeedListener(playbackSpeedListener);
|
||||
}
|
||||
|
||||
public void setAudioPlaybackSpeed(float playbackSpeed, boolean isPlaying) {
|
||||
if (isPlaying) {
|
||||
showPlaybackSpeedToggle();
|
||||
} else {
|
||||
hidePlaybackSpeedToggle();
|
||||
}
|
||||
|
||||
playbackSpeedToggleTextView.setCurrentSpeed(playbackSpeed);
|
||||
}
|
||||
|
||||
public void setTextColor(int color) {
|
||||
dateView.setTextColor(color);
|
||||
simView.setTextColor(color);
|
||||
@@ -155,6 +211,84 @@ public class ConversationItemFooter extends LinearLayout {
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyTouchDelegateChanged(@NonNull Rect rect, @NonNull View touchDelegate) {
|
||||
if (onTouchDelegateChangedListener != null) {
|
||||
onTouchDelegateChangedListener.onTouchDelegateChanged(rect, touchDelegate);
|
||||
}
|
||||
}
|
||||
|
||||
private void showPlaybackSpeedToggle() {
|
||||
if (hasShrunkDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasShrunkDate = true;
|
||||
|
||||
playbackSpeedToggleTextView.animate()
|
||||
.alpha(1f)
|
||||
.scaleX(1f)
|
||||
.scaleY(1f)
|
||||
.setDuration(150L)
|
||||
.setListener(new AnimationCompleteListener() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
playbackSpeedToggleTextView.setClickable(true);
|
||||
}
|
||||
});
|
||||
|
||||
if (isOutgoing) {
|
||||
dateView.setMaxWidth(ViewUtil.dpToPx(28));
|
||||
} else {
|
||||
ConstraintSet constraintSet = new ConstraintSet();
|
||||
constraintSet.clone(this);
|
||||
constraintSet.constrainMaxWidth(R.id.date_and_expiry_wrapper, ViewUtil.dpToPx(40));
|
||||
constraintSet.applyTo(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void hidePlaybackSpeedToggle() {
|
||||
if (!hasShrunkDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasShrunkDate = false;
|
||||
|
||||
playbackSpeedToggleTextView.animate()
|
||||
.alpha(0f)
|
||||
.scaleX(0.5f)
|
||||
.scaleY(0.5f)
|
||||
.setDuration(150L).setListener(new AnimationCompleteListener() {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
playbackSpeedToggleTextView.setClickable(false);
|
||||
playbackSpeedToggleTextView.clearRequestedSpeed();
|
||||
}
|
||||
});
|
||||
|
||||
if (isOutgoing) {
|
||||
dateView.setMaxWidth(Integer.MAX_VALUE);
|
||||
} else {
|
||||
ConstraintSet constraintSet = new ConstraintSet();
|
||||
constraintSet.clone(this);
|
||||
constraintSet.constrainMaxWidth(R.id.date_and_expiry_wrapper, -1);
|
||||
constraintSet.applyTo(this);
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull Rect getPlaybackSpeedToggleTouchDelegateRect() {
|
||||
playbackSpeedToggleTextView.getHitRect(speedToggleHitRect);
|
||||
|
||||
int widthOffset = (touchTargetSize - speedToggleHitRect.width()) / 2;
|
||||
int heightOffset = (touchTargetSize - speedToggleHitRect.height()) / 2;
|
||||
|
||||
speedToggleHitRect.top -= heightOffset;
|
||||
speedToggleHitRect.left -= widthOffset;
|
||||
speedToggleHitRect.right += widthOffset;
|
||||
speedToggleHitRect.bottom += heightOffset;
|
||||
|
||||
return speedToggleHitRect;
|
||||
}
|
||||
|
||||
private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
|
||||
dateView.forceLayout();
|
||||
if (messageRecord.isFailed()) {
|
||||
@@ -189,7 +323,7 @@ public class ConversationItemFooter extends LinearLayout {
|
||||
simView.setText(getContext().getString(R.string.ConversationItem_from_s, subscriptionInfo.get().getDisplayName()));
|
||||
simView.setVisibility(View.VISIBLE);
|
||||
} else if (subscriptionInfo.isPresent()) {
|
||||
simView.setText(getContext().getString(R.string.ConversationItem_to_s, subscriptionInfo.get().getDisplayName()));
|
||||
simView.setText(getContext().getString(R.string.ConversationItem_to_s, subscriptionInfo.get().getDisplayName()));
|
||||
simView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
simView.setVisibility(View.GONE);
|
||||
@@ -218,7 +352,7 @@ public class ConversationItemFooter extends LinearLayout {
|
||||
boolean mms = messageRecord.isMms();
|
||||
|
||||
if (mms) DatabaseFactory.getMmsDatabase(getContext()).markExpireStarted(id);
|
||||
else DatabaseFactory.getSmsDatabase(getContext()).markExpireStarted(id);
|
||||
else DatabaseFactory.getSmsDatabase(getContext()).markExpireStarted(id);
|
||||
|
||||
expirationManager.scheduleDeletion(id, mms, messageRecord.getExpiresIn());
|
||||
});
|
||||
@@ -245,7 +379,7 @@ public class ConversationItemFooter extends LinearLayout {
|
||||
deliveryStatusView.setNone();
|
||||
}
|
||||
} else {
|
||||
if (!messageRecord.isOutgoing()) {
|
||||
if (!messageRecord.isOutgoing()) {
|
||||
deliveryStatusView.setNone();
|
||||
} else if (messageRecord.isPending()) {
|
||||
deliveryStatusView.setPending();
|
||||
@@ -264,11 +398,6 @@ public class ConversationItemFooter extends LinearLayout {
|
||||
MmsMessageRecord mmsMessageRecord = (MmsMessageRecord) messageRecord;
|
||||
|
||||
if (mmsMessageRecord.getSlideDeck().getAudioSlide() != null) {
|
||||
if (messageRecord.isOutgoing()) {
|
||||
moveAudioViewsForOutgoing();
|
||||
} else {
|
||||
moveAudioViewsForIncoming();
|
||||
}
|
||||
showAudioDurationViews();
|
||||
|
||||
if (messageRecord.getViewedReceiptCount() > 0) {
|
||||
@@ -284,41 +413,19 @@ public class ConversationItemFooter extends LinearLayout {
|
||||
}
|
||||
}
|
||||
|
||||
private void moveAudioViewsForOutgoing() {
|
||||
removeView(audioSpace);
|
||||
removeView(audioDuration);
|
||||
removeView(revealDot);
|
||||
addView(audioSpace, 0);
|
||||
addView(revealDot, 0);
|
||||
addView(audioDuration, 0);
|
||||
|
||||
int padStart = ViewUtil.dpToPx(60);
|
||||
int padLeft = ViewUtil.isLtr(this) ? padStart : 0;
|
||||
int padRight = ViewUtil.isRtl(this) ? padStart : 0;
|
||||
|
||||
audioDuration.setPadding(padLeft, 0, padRight, 0);
|
||||
}
|
||||
|
||||
private void moveAudioViewsForIncoming() {
|
||||
removeView(audioSpace);
|
||||
removeView(audioDuration);
|
||||
removeView(revealDot);
|
||||
addView(audioSpace);
|
||||
addView(revealDot);
|
||||
addView(audioDuration);
|
||||
|
||||
audioDuration.setPadding(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
private void showAudioDurationViews() {
|
||||
audioSpace.setVisibility(View.VISIBLE);
|
||||
audioDuration.setVisibility(View.GONE);
|
||||
audioDuration.setVisibility(View.VISIBLE);
|
||||
revealDot.setVisibility(View.VISIBLE);
|
||||
playbackSpeedToggleTextView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
private void hideAudioDurationViews() {
|
||||
audioSpace.setVisibility(View.GONE);
|
||||
audioDuration.setVisibility(View.GONE);
|
||||
revealDot.setVisibility(View.GONE);
|
||||
playbackSpeedToggleTextView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public interface OnTouchDelegateChangedListener {
|
||||
void onTouchDelegateChanged(@NonNull Rect delegateRect, @NonNull View delegateView);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.widget.EditText;
|
||||
|
||||
import androidx.annotation.AttrRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
/**
|
||||
* Custom styled search view that we can insert into ActionBar menus
|
||||
*/
|
||||
public class DarkSearchView extends androidx.appcompat.widget.SearchView {
|
||||
public DarkSearchView(@NonNull Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public DarkSearchView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
this(context, attrs, R.attr.search_view_style_dark);
|
||||
}
|
||||
|
||||
public DarkSearchView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
|
||||
EditText searchText = findViewById(androidx.appcompat.R.id.search_src_text);
|
||||
searchText.setTextColor(ContextCompat.getColor(context, R.color.signal_text_toolbar_subtitle));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.PorterDuffColorFilter;
|
||||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.SpannableStringBuilder;
|
||||
@@ -9,11 +12,15 @@ import android.text.style.StyleSpan;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class FromTextView extends EmojiTextView {
|
||||
|
||||
@@ -65,10 +72,17 @@ public class FromTextView extends EmojiTextView {
|
||||
|
||||
setText(builder);
|
||||
|
||||
if (recipient.isBlocked()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_block_grey600_18dp, 0, 0, 0);
|
||||
else if (recipient.isMuted()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_volume_off_grey600_18dp, 0, 0, 0);
|
||||
if (recipient.isBlocked()) setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_block_grey600_18dp, 0, 0, 0);
|
||||
else if (recipient.isMuted()) setCompoundDrawablesRelativeWithIntrinsicBounds(getMuted(), null, null, null);
|
||||
else setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
private Drawable getMuted() {
|
||||
Drawable mutedDrawable = Objects.requireNonNull(ContextCompat.getDrawable(getContext(), R.drawable.ic_bell_disabled_16));
|
||||
|
||||
mutedDrawable.setBounds(0, 0, ViewUtil.dpToPx(18), ViewUtil.dpToPx(18));
|
||||
mutedDrawable.setColorFilter(new PorterDuffColorFilter(ContextCompat.getColor(getContext(), R.color.signal_icon_tint_secondary), PorterDuff.Mode.SRC_IN));
|
||||
|
||||
return mutedDrawable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
@@ -31,11 +32,13 @@ import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||
import org.thoughtcrime.securesms.conversation.VoiceNoteDraftView;
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
@@ -52,6 +55,7 @@ import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -59,7 +63,7 @@ import java.util.concurrent.TimeUnit;
|
||||
public class InputPanel extends LinearLayout
|
||||
implements MicrophoneRecorderView.Listener,
|
||||
KeyboardAwareLinearLayout.OnKeyboardShownListener,
|
||||
EmojiKeyboardProvider.EmojiEventListener,
|
||||
EmojiEventListener,
|
||||
ConversationStickerSuggestionAdapter.EventListener
|
||||
{
|
||||
|
||||
@@ -84,6 +88,7 @@ public class InputPanel extends LinearLayout
|
||||
private SlideToCancel slideToCancel;
|
||||
private RecordTime recordTime;
|
||||
private ValueAnimator quoteAnimator;
|
||||
private VoiceNoteDraftView voiceNoteDraftView;
|
||||
|
||||
private @Nullable Listener listener;
|
||||
private boolean emojiVisible;
|
||||
@@ -119,6 +124,7 @@ public class InputPanel extends LinearLayout
|
||||
this.buttonToggle = findViewById(R.id.button_toggle);
|
||||
this.recordingContainer = findViewById(R.id.recording_container);
|
||||
this.recordLockCancel = findViewById(R.id.record_cancel);
|
||||
this.voiceNoteDraftView = findViewById(R.id.voice_note_draft_view);
|
||||
this.slideToCancel = new SlideToCancel(findViewById(R.id.slide_to_cancel));
|
||||
this.microphoneRecorderView = findViewById(R.id.recorder_view);
|
||||
this.microphoneRecorderView.setListener(this);
|
||||
@@ -155,6 +161,7 @@ public class InputPanel extends LinearLayout
|
||||
this.listener = listener;
|
||||
|
||||
mediaKeyboard.setOnClickListener(v -> listener.onEmojiToggle());
|
||||
voiceNoteDraftView.setListener(listener);
|
||||
}
|
||||
|
||||
public void setMediaListener(@NonNull MediaListener listener) {
|
||||
@@ -230,6 +237,10 @@ public class InputPanel extends LinearLayout
|
||||
return animator;
|
||||
}
|
||||
|
||||
public boolean hasSaveableContent() {
|
||||
return getQuote().isPresent() || voiceNoteDraftView.getDraft() != null;
|
||||
}
|
||||
|
||||
public Optional<QuoteModel> getQuote() {
|
||||
if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) {
|
||||
return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody().toString(), false, quoteView.getAttachments(), quoteView.getMentions()));
|
||||
@@ -317,7 +328,10 @@ public class InputPanel extends LinearLayout
|
||||
recordTime.display();
|
||||
slideToCancel.display();
|
||||
|
||||
if (emojiVisible) ViewUtil.fadeOut(mediaKeyboard, FADE_TIME, View.INVISIBLE);
|
||||
if (emojiVisible) {
|
||||
ViewUtil.fadeOut(mediaKeyboard, FADE_TIME, View.INVISIBLE);
|
||||
}
|
||||
|
||||
ViewUtil.fadeOut(composeText, FADE_TIME, View.INVISIBLE);
|
||||
ViewUtil.fadeOut(quickCameraToggle, FADE_TIME, View.INVISIBLE);
|
||||
ViewUtil.fadeOut(quickAudioToggle, FADE_TIME, View.INVISIBLE);
|
||||
@@ -370,6 +384,10 @@ public class InputPanel extends LinearLayout
|
||||
this.microphoneRecorderView.cancelAction();
|
||||
}
|
||||
|
||||
public @NonNull Observer<VoiceNotePlaybackState> getPlaybackStateObserver() {
|
||||
return voiceNoteDraftView.getPlaybackStateObserver();
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
composeText.setEnabled(enabled);
|
||||
mediaKeyboard.setEnabled(enabled);
|
||||
@@ -386,11 +404,7 @@ public class InputPanel extends LinearLayout
|
||||
future.addListener(new AssertedSuccessListener<Void>() {
|
||||
@Override
|
||||
public void onSuccess(Void result) {
|
||||
if (emojiVisible) ViewUtil.fadeIn(mediaKeyboard, FADE_TIME);
|
||||
ViewUtil.fadeIn(composeText, FADE_TIME);
|
||||
ViewUtil.fadeIn(quickCameraToggle, FADE_TIME);
|
||||
ViewUtil.fadeIn(quickAudioToggle, FADE_TIME);
|
||||
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
|
||||
fadeInNormalComposeViews();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -431,15 +445,57 @@ public class InputPanel extends LinearLayout
|
||||
microphoneRecorderView.unlockAction();
|
||||
}
|
||||
|
||||
public void showGifMovedTooltip() {
|
||||
TooltipPopup.forTarget(mediaKeyboard)
|
||||
.setBackgroundTint(ContextCompat.getColor(getContext(), R.color.signal_accent_primary))
|
||||
.setTextColor(getResources().getColor(R.color.core_white))
|
||||
.setText(R.string.ConversationActivity__gifs_are_now_here)
|
||||
.show(TooltipPopup.POSITION_ABOVE);
|
||||
public void setVoiceNoteDraft(@Nullable DraftDatabase.Draft voiceNoteDraft) {
|
||||
if (voiceNoteDraft != null) {
|
||||
voiceNoteDraftView.setDraft(voiceNoteDraft);
|
||||
voiceNoteDraftView.setVisibility(VISIBLE);
|
||||
hideNormalComposeViews();
|
||||
} else {
|
||||
voiceNoteDraftView.clearDraft();
|
||||
ViewUtil.fadeOut(voiceNoteDraftView, FADE_TIME);
|
||||
fadeInNormalComposeViews();
|
||||
}
|
||||
}
|
||||
|
||||
public interface Listener {
|
||||
public @Nullable DraftDatabase.Draft getVoiceNoteDraft() {
|
||||
return voiceNoteDraftView.getDraft();
|
||||
}
|
||||
|
||||
private void hideNormalComposeViews() {
|
||||
if (emojiVisible) {
|
||||
Animation animation = mediaKeyboard.getAnimation();
|
||||
if (animation != null) {
|
||||
animation.cancel();
|
||||
}
|
||||
|
||||
mediaKeyboard.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
|
||||
for (Animation animation : Arrays.asList(composeText.getAnimation(), quickCameraToggle.getAnimation(), quickAudioToggle.getAnimation())) {
|
||||
if (animation != null) {
|
||||
animation.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
buttonToggle.animate().cancel();
|
||||
|
||||
composeText.setVisibility(View.INVISIBLE);
|
||||
quickCameraToggle.setVisibility(View.INVISIBLE);
|
||||
quickAudioToggle.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
|
||||
private void fadeInNormalComposeViews() {
|
||||
if (emojiVisible) {
|
||||
ViewUtil.fadeIn(mediaKeyboard, FADE_TIME);
|
||||
}
|
||||
|
||||
ViewUtil.fadeIn(composeText, FADE_TIME);
|
||||
ViewUtil.fadeIn(quickCameraToggle, FADE_TIME);
|
||||
ViewUtil.fadeIn(quickAudioToggle, FADE_TIME);
|
||||
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
|
||||
}
|
||||
|
||||
public interface Listener extends VoiceNoteDraftView.Listener {
|
||||
void onRecorderStarted();
|
||||
void onRecorderLocked();
|
||||
void onRecorderFinished();
|
||||
|
||||
@@ -229,7 +229,8 @@ public class KeyboardAwareLinearLayout extends LinearLayoutCompat {
|
||||
|
||||
private int getKeyboardPortraitHeight() {
|
||||
if (isBubble) {
|
||||
return getRootView().getHeight() - minCustomKeyboardTopMarginPortrait;
|
||||
int height = getRootView().getHeight();
|
||||
return height - (int)(height * 0.45);
|
||||
}
|
||||
|
||||
int keyboardHeight = PreferenceManager.getDefaultSharedPreferences(getContext())
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
class PlaybackSpeedToggleTextView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : AppCompatTextView(context, attrs, defStyleAttr) {
|
||||
|
||||
private val speeds: IntArray = context.resources.getIntArray(R.array.PlaybackSpeedToggleTextView__speeds)
|
||||
private val labels: Array<String> = context.resources.getStringArray(R.array.PlaybackSpeedToggleTextView__speed_labels)
|
||||
private var currentSpeedIndex = 0
|
||||
private var requestedSpeed: Float? = null
|
||||
|
||||
var playbackSpeedListener: PlaybackSpeedListener? = null
|
||||
|
||||
init {
|
||||
text = getCurrentLabel()
|
||||
super.setOnClickListener {
|
||||
currentSpeedIndex = getNextSpeedIndex()
|
||||
text = getCurrentLabel()
|
||||
requestedSpeed = getCurrentSpeed()
|
||||
|
||||
playbackSpeedListener?.onPlaybackSpeedChanged(getCurrentSpeed())
|
||||
}
|
||||
|
||||
isClickable = false
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent?): Boolean {
|
||||
if (isClickable) {
|
||||
when (event?.action) {
|
||||
MotionEvent.ACTION_DOWN -> zoomIn()
|
||||
MotionEvent.ACTION_UP -> zoomOut()
|
||||
MotionEvent.ACTION_CANCEL -> zoomOut()
|
||||
}
|
||||
}
|
||||
|
||||
return super.onTouchEvent(event)
|
||||
}
|
||||
|
||||
fun clearRequestedSpeed() {
|
||||
requestedSpeed = null
|
||||
}
|
||||
|
||||
fun setCurrentSpeed(speed: Float) {
|
||||
if (speed == getCurrentSpeed() || (requestedSpeed != null && requestedSpeed != speed)) {
|
||||
if (requestedSpeed == speed) {
|
||||
requestedSpeed = null
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
requestedSpeed = null
|
||||
|
||||
val outOf100 = (speed * 100).toInt()
|
||||
val index = speeds.indexOf(outOf100)
|
||||
|
||||
if (index != -1) {
|
||||
currentSpeedIndex = index
|
||||
text = getCurrentLabel()
|
||||
} else {
|
||||
throw IllegalArgumentException("Invalid Speed $speed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNextSpeedIndex(): Int = (currentSpeedIndex + 1) % speeds.size
|
||||
|
||||
private fun getCurrentSpeed(): Float = speeds[currentSpeedIndex] / 100f
|
||||
|
||||
private fun getCurrentLabel(): String = labels[currentSpeedIndex]
|
||||
|
||||
private fun zoomIn() {
|
||||
animate()
|
||||
.setInterpolator(DecelerateInterpolator())
|
||||
.setDuration(150L)
|
||||
.scaleX(1.2f)
|
||||
.scaleY(1.2f)
|
||||
}
|
||||
|
||||
private fun zoomOut() {
|
||||
animate()
|
||||
.setInterpolator(DecelerateInterpolator())
|
||||
.setDuration(150L)
|
||||
.scaleX(1f)
|
||||
.scaleY(1f)
|
||||
}
|
||||
|
||||
override fun setOnClickListener(l: OnClickListener?) {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
interface PlaybackSpeedListener {
|
||||
fun onPlaybackSpeedChanged(speed: Float)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.InputFilter;
|
||||
import android.util.AttributeSet;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.AttrRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiFilter;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
|
||||
/**
|
||||
* Custom styled search view that we can insert into ActionBar menus
|
||||
@@ -23,5 +27,31 @@ public class SearchView extends androidx.appcompat.widget.SearchView {
|
||||
|
||||
public SearchView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
initEmojiFilter();
|
||||
}
|
||||
|
||||
private void initEmojiFilter() {
|
||||
if (!isInEditMode() && !SignalStore.settings().isPreferSystemEmoji()) {
|
||||
TextView searchText = findViewById(androidx.appcompat.R.id.search_src_text);
|
||||
if (searchText != null) {
|
||||
searchText.setFilters(appendEmojiFilter(searchText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private InputFilter[] appendEmojiFilter(@NonNull TextView view) {
|
||||
InputFilter[] originalFilters = view.getFilters();
|
||||
InputFilter[] result;
|
||||
|
||||
if (originalFilters != null) {
|
||||
result = new InputFilter[originalFilters.length + 1];
|
||||
System.arraycopy(originalFilters, 0, result, 1, originalFilters.length);
|
||||
} else {
|
||||
result = new InputFilter[1];
|
||||
}
|
||||
|
||||
result[0] = new EmojiFilter(view);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@ package org.thoughtcrime.securesms.components;
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.PorterDuffColorFilter;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.graphics.drawable.ShapeDrawable;
|
||||
import android.graphics.drawable.shapes.RoundRectShape;
|
||||
import android.graphics.drawable.shapes.Shape;
|
||||
import android.net.Uri;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
@@ -12,6 +18,7 @@ import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.UiThread;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.bumptech.glide.RequestBuilder;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
@@ -41,6 +48,7 @@ import org.thoughtcrime.securesms.util.views.Stub;
|
||||
import org.thoughtcrime.securesms.video.VideoPlayer;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
@@ -93,6 +101,7 @@ public class ThumbnailView extends FrameLayout {
|
||||
this.blurhash = findViewById(R.id.thumbnail_blurhash);
|
||||
this.playOverlay = findViewById(R.id.play_overlay);
|
||||
this.captionIcon = findViewById(R.id.thumbnail_caption_icon);
|
||||
|
||||
super.setOnClickListener(new ThumbnailClickDispatcher());
|
||||
|
||||
if (attrs != null) {
|
||||
@@ -103,9 +112,18 @@ public class ThumbnailView extends FrameLayout {
|
||||
bounds[MAX_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0);
|
||||
radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius));
|
||||
fit = typedArray.getInt(R.styleable.ThumbnailView_thumbnail_fit, 0) == 1 ? new FitCenter() : new CenterCrop();
|
||||
|
||||
int transparentOverlayColor = typedArray.getColor(R.styleable.ThumbnailView_transparent_overlay_color, -1);
|
||||
if (transparentOverlayColor > 0) {
|
||||
image.setColorFilter(new PorterDuffColorFilter(transparentOverlayColor, PorterDuff.Mode.SRC_ATOP));
|
||||
} else {
|
||||
image.setColorFilter(null);
|
||||
}
|
||||
|
||||
typedArray.recycle();
|
||||
} else {
|
||||
radius = getResources().getDimensionPixelSize(R.dimen.message_corner_collapse_radius);
|
||||
image.setColorFilter(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,19 +6,25 @@ import androidx.annotation.AttrRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Arrays;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class CompositeEmojiPageModel implements EmojiPageModel {
|
||||
@AttrRes private final int iconAttr;
|
||||
@NonNull private final List<EmojiPageModel> models;
|
||||
@AttrRes private final int iconAttr;
|
||||
@NonNull private final List<EmojiPageModel> models;
|
||||
|
||||
public CompositeEmojiPageModel(@AttrRes int iconAttr, @NonNull List<EmojiPageModel> models) {
|
||||
this.iconAttr = iconAttr;
|
||||
this.models = models;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKey() {
|
||||
return Util.hasItems(models) ? models.get(0).getKey() : "";
|
||||
}
|
||||
|
||||
public int getIconAttr() {
|
||||
return iconAttr;
|
||||
}
|
||||
|
||||
@@ -22,4 +22,8 @@ public class Emoji {
|
||||
public List<String> getVariations() {
|
||||
return variations;
|
||||
}
|
||||
|
||||
public boolean hasMultipleVariations() {
|
||||
return variations.size() > 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.view.KeyEvent;
|
||||
|
||||
public interface EmojiEventListener {
|
||||
void onEmojiSelected(String emoji);
|
||||
|
||||
void onKeyEvent(KeyEvent keyEvent);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.thoughtcrime.securesms.components.emoji
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiModel
|
||||
import org.thoughtcrime.securesms.util.InsetItemDecoration
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
private val EDGE_LENGTH: Int = ViewUtil.dpToPx(6)
|
||||
private val HORIZONTAL_INSET: Int = ViewUtil.dpToPx(6)
|
||||
private val EMOJI_VERTICAL_INSET: Int = ViewUtil.dpToPx(5)
|
||||
private val HEADER_VERTICAL_INSET: Int = ViewUtil.dpToPx(8)
|
||||
|
||||
/**
|
||||
* Use super class to add insets to the emojis and use the [onDrawOver] to draw the variation
|
||||
* hint if the emoji has more than one variation.
|
||||
*/
|
||||
class EmojiItemDecoration(private val allowVariations: Boolean, private val variationsDrawable: Drawable) : InsetItemDecoration(SetInset()) {
|
||||
|
||||
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
super.onDrawOver(canvas, parent, state)
|
||||
|
||||
val adapter: EmojiPageViewGridAdapter? = parent.adapter as? EmojiPageViewGridAdapter
|
||||
if (allowVariations && adapter != null) {
|
||||
for (i in 0 until parent.childCount) {
|
||||
val child: View = parent.getChildAt(i)
|
||||
val position: Int = parent.getChildAdapterPosition(child)
|
||||
if (position >= 0 && position <= adapter.itemCount) {
|
||||
val model = adapter.currentList[position]
|
||||
if (model is EmojiModel && model.emoji.hasMultipleVariations()) {
|
||||
variationsDrawable.setBounds(child.right, child.bottom - EDGE_LENGTH, child.right + EDGE_LENGTH, child.bottom)
|
||||
variationsDrawable.draw(canvas)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class SetInset : InsetItemDecoration.SetInset() {
|
||||
override fun setInset(outRect: Rect, view: View, parent: RecyclerView) {
|
||||
val isHeader = view.javaClass == AppCompatTextView::class.java
|
||||
|
||||
outRect.left = HORIZONTAL_INSET
|
||||
outRect.right = HORIZONTAL_INSET
|
||||
outRect.top = if (isHeader) HEADER_VERTICAL_INSET else EMOJI_VERTICAL_INSET
|
||||
outRect.bottom = if (isHeader) 0 else EMOJI_VERTICAL_INSET
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.viewpager.widget.PagerAdapter;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.util.ResUtil;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A provider to select emoji in the {@link org.thoughtcrime.securesms.components.emoji.MediaKeyboard}.
|
||||
*
|
||||
* TODO [alex] -- Are we still using any of this?
|
||||
*/
|
||||
public class EmojiKeyboardProvider implements MediaKeyboardProvider,
|
||||
MediaKeyboardProvider.TabIconProvider,
|
||||
MediaKeyboardProvider.BackspaceObserver,
|
||||
VariationSelectorListener
|
||||
{
|
||||
private static final KeyEvent DELETE_KEY_EVENT = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL);
|
||||
|
||||
// TODO [alex] -- We are using this.
|
||||
public static final String RECENT_STORAGE_KEY = "pref_recent_emoji2";
|
||||
|
||||
private final Context context;
|
||||
private final List<EmojiPageModel> models;
|
||||
private final RecentEmojiPageModel recentModel;
|
||||
private final EmojiPagerAdapter emojiPagerAdapter;
|
||||
private final EmojiEventListener emojiEventListener;
|
||||
|
||||
private Controller controller;
|
||||
private int currentPosition;
|
||||
|
||||
public EmojiKeyboardProvider(@NonNull Context context, @Nullable EmojiEventListener emojiEventListener) {
|
||||
this.context = context;
|
||||
this.emojiEventListener = emojiEventListener;
|
||||
this.models = new LinkedList<>();
|
||||
this.recentModel = new RecentEmojiPageModel(context, RECENT_STORAGE_KEY);
|
||||
this.emojiPagerAdapter = new EmojiPagerAdapter(context, models, new EmojiEventListener() {
|
||||
@Override
|
||||
public void onEmojiSelected(String emoji) {
|
||||
recentModel.onCodePointSelected(emoji);
|
||||
SignalStore.emojiValues().setPreferredVariation(emoji);
|
||||
|
||||
if (emojiEventListener != null) {
|
||||
emojiEventListener.onEmojiSelected(emoji);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onKeyEvent(KeyEvent keyEvent) {
|
||||
if (emojiEventListener != null) {
|
||||
emojiEventListener.onKeyEvent(keyEvent);
|
||||
}
|
||||
}
|
||||
}, this);
|
||||
|
||||
models.add(recentModel);
|
||||
models.addAll(EmojiSource.getLatest().getDisplayPages());
|
||||
|
||||
currentPosition = recentModel.getEmoji().size() > 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestPresentation(@NonNull Presenter presenter, boolean isSoloProvider) {
|
||||
presenter.present(this, emojiPagerAdapter, this, this, null, null, currentPosition);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setCurrentPosition(int currentPosition) {
|
||||
this.currentPosition = currentPosition;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setController(@Nullable Controller controller) {
|
||||
this.controller = controller;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getProviderIconView(boolean selected) {
|
||||
if (selected) {
|
||||
return R.layout.emoji_keyboard_icon_selected;
|
||||
} else {
|
||||
return R.layout.emoji_keyboard_icon;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadCategoryTabIcon(@NonNull GlideRequests glideRequests, @NonNull ImageView imageView, int index) {
|
||||
Drawable drawable = ResUtil.getDrawable(context, models.get(index).getIconAttr());
|
||||
imageView.setImageDrawable(drawable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackspaceClicked() {
|
||||
if (emojiEventListener != null) {
|
||||
emojiEventListener.onKeyEvent(DELETE_KEY_EVENT);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onVariationSelectorStateChanged(boolean open) {
|
||||
if (controller != null) {
|
||||
controller.setViewPagerEnabled(!open);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(@Nullable Object obj) {
|
||||
return obj instanceof EmojiKeyboardProvider;
|
||||
}
|
||||
|
||||
private static class EmojiPagerAdapter extends PagerAdapter {
|
||||
private Context context;
|
||||
private List<EmojiPageModel> pages;
|
||||
private EmojiEventListener emojiSelectionListener;
|
||||
private VariationSelectorListener variationSelectorListener;
|
||||
|
||||
public EmojiPagerAdapter(@NonNull Context context,
|
||||
@NonNull List<EmojiPageModel> pages,
|
||||
@NonNull EmojiEventListener emojiSelectionListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener)
|
||||
{
|
||||
super();
|
||||
this.context = context;
|
||||
this.pages = pages;
|
||||
this.emojiSelectionListener = emojiSelectionListener;
|
||||
this.variationSelectorListener = variationSelectorListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return pages.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Object instantiateItem(@NonNull ViewGroup container, int position) {
|
||||
EmojiPageView page = new EmojiPageView(context, emojiSelectionListener, variationSelectorListener, true);
|
||||
page.setModel(pages.get(position));
|
||||
container.addView(page);
|
||||
return page;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroyItem(ViewGroup container, int position, Object object) {
|
||||
container.removeView((View)object);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPrimaryItem(ViewGroup container, int position, Object object) {
|
||||
EmojiPageView current = (EmojiPageView) object;
|
||||
current.onSelected();
|
||||
super.setPrimaryItem(container, position, object);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isViewFromObject(View view, Object object) {
|
||||
return view == object;
|
||||
}
|
||||
}
|
||||
|
||||
public interface EmojiEventListener {
|
||||
void onEmojiSelected(String emoji);
|
||||
void onKeyEvent(KeyEvent keyEvent);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.annotation.Nullable;
|
||||
import java.util.List;
|
||||
|
||||
public interface EmojiPageModel {
|
||||
String getKey();
|
||||
int getIconAttr();
|
||||
List<String> getEmoji();
|
||||
List<Emoji> getDisplayEmoji();
|
||||
|
||||
@@ -1,110 +1,136 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.LayoutInflater;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.LinearSmoothScroller;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiHeader;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiNoResultsModel;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
|
||||
import org.thoughtcrime.securesms.util.MappingModelList;
|
||||
import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.DrawableUtil;
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
public class EmojiPageView extends FrameLayout implements VariationSelectorListener {
|
||||
private static final String TAG = Log.tag(EmojiPageView.class);
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public class EmojiPageView extends RecyclerView implements VariationSelectorListener {
|
||||
|
||||
private EmojiPageModel model;
|
||||
private AdapterFactory adapterFactory;
|
||||
private RecyclerView recyclerView;
|
||||
private RecyclerView.LayoutManager layoutManager;
|
||||
private LinearLayoutManager layoutManager;
|
||||
private RecyclerView.OnItemTouchListener scrollDisabler;
|
||||
private VariationSelectorListener variationSelectorListener;
|
||||
private EmojiVariationSelectorPopup popup;
|
||||
|
||||
public EmojiPageView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public EmojiPageView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
|
||||
public EmojiPageView(@NonNull Context context,
|
||||
@NonNull EmojiEventListener emojiSelectionListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations)
|
||||
{
|
||||
this(context, emojiSelectionListener, variationSelectorListener, allowVariations, new GridLayoutManager(context, 8), R.layout.emoji_display_item);
|
||||
super(context);
|
||||
initialize(emojiSelectionListener, variationSelectorListener, allowVariations);
|
||||
}
|
||||
|
||||
public EmojiPageView(@NonNull Context context,
|
||||
@NonNull EmojiEventListener emojiSelectionListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations,
|
||||
@NonNull RecyclerView.LayoutManager layoutManager,
|
||||
@LayoutRes int displayItemLayoutResId)
|
||||
@NonNull LinearLayoutManager layoutManager,
|
||||
@LayoutRes int displayEmojiLayoutResId,
|
||||
@LayoutRes int displayEmoticonLayoutResId)
|
||||
{
|
||||
super(context);
|
||||
final View view = LayoutInflater.from(getContext()).inflate(R.layout.emoji_grid_layout, this, true);
|
||||
initialize(emojiSelectionListener, variationSelectorListener, allowVariations, layoutManager, displayEmojiLayoutResId, displayEmoticonLayoutResId);
|
||||
}
|
||||
|
||||
public void initialize(@NonNull EmojiEventListener emojiSelectionListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations)
|
||||
{
|
||||
initialize(emojiSelectionListener, variationSelectorListener, allowVariations, new GridLayoutManager(getContext(), 8), R.layout.emoji_display_item_grid, R.layout.emoji_text_display_item_grid);
|
||||
}
|
||||
|
||||
public void initialize(@NonNull EmojiEventListener emojiSelectionListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations,
|
||||
@NonNull LinearLayoutManager layoutManager,
|
||||
@LayoutRes int displayEmojiLayoutResId,
|
||||
@LayoutRes int displayEmoticonLayoutResId)
|
||||
{
|
||||
this.variationSelectorListener = variationSelectorListener;
|
||||
|
||||
this.recyclerView = view.findViewById(R.id.emoji);
|
||||
this.layoutManager = layoutManager;
|
||||
this.scrollDisabler = new ScrollDisabler();
|
||||
this.popup = new EmojiVariationSelectorPopup(context, emojiSelectionListener);
|
||||
this.popup = new EmojiVariationSelectorPopup(getContext(), emojiSelectionListener);
|
||||
this.adapterFactory = () -> new EmojiPageViewGridAdapter(popup,
|
||||
emojiSelectionListener,
|
||||
this,
|
||||
allowVariations,
|
||||
displayItemLayoutResId);
|
||||
displayEmojiLayoutResId,
|
||||
displayEmoticonLayoutResId);
|
||||
|
||||
recyclerView.setLayoutManager(layoutManager);
|
||||
recyclerView.setItemAnimator(null);
|
||||
if (this.layoutManager instanceof GridLayoutManager) {
|
||||
GridLayoutManager gridLayout = (GridLayoutManager) this.layoutManager;
|
||||
gridLayout.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
|
||||
@Override
|
||||
public int getSpanSize(int position) {
|
||||
if (getAdapter() != null) {
|
||||
Optional<MappingModel<?>> model = getAdapter().getModel(position);
|
||||
if (model.isPresent() && (model.get() instanceof EmojiHeader || model.get() instanceof EmojiNoResultsModel)) {
|
||||
return gridLayout.getSpanCount();
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setLayoutManager(layoutManager);
|
||||
|
||||
Drawable drawable = DrawableUtil.tint(ContextUtil.requireDrawable(getContext(), R.drawable.triangle_bottom_right_corner), ContextCompat.getColor(getContext(), R.color.signal_button_secondary_text_disabled));
|
||||
addItemDecoration(new EmojiItemDecoration(allowVariations, drawable));
|
||||
}
|
||||
|
||||
public void presentForEmojiKeyboard() {
|
||||
recyclerView.setPadding(recyclerView.getPaddingLeft(),
|
||||
recyclerView.getPaddingTop(),
|
||||
recyclerView.getPaddingRight(),
|
||||
recyclerView.getPaddingBottom() + ViewUtil.dpToPx(56));
|
||||
setPadding(getPaddingLeft(),
|
||||
getPaddingTop(),
|
||||
getPaddingRight(),
|
||||
getPaddingBottom() + ViewUtil.dpToPx(56));
|
||||
|
||||
recyclerView.setClipToPadding(false);
|
||||
setClipToPadding(false);
|
||||
}
|
||||
|
||||
public void onSelected() {
|
||||
if (model.isDynamic() && recyclerView.getAdapter() != null) {
|
||||
recyclerView.getAdapter().notifyDataSetChanged();
|
||||
if (getAdapter() != null) {
|
||||
getAdapter().notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void setModel(@Nullable EmojiPageModel model) {
|
||||
this.model = model;
|
||||
|
||||
public void setList(@NonNull List<MappingModel<?>> list, @Nullable Runnable commitCallback) {
|
||||
EmojiPageViewGridAdapter adapter = adapterFactory.create();
|
||||
recyclerView.setAdapter(adapter);
|
||||
adapter.submitList(getMappingModelList());
|
||||
}
|
||||
|
||||
public void bindSearchableAdapter(@Nullable EmojiPageModel model) {
|
||||
this.model = model;
|
||||
|
||||
EmojiPageViewGridAdapter adapter = adapterFactory.create();
|
||||
recyclerView.setAdapter(adapter);
|
||||
adapter.submitList(getMappingModelList());
|
||||
}
|
||||
|
||||
private @NonNull MappingModelList getMappingModelList() {
|
||||
MappingModelList mappingModels = new MappingModelList();
|
||||
|
||||
if (model != null) {
|
||||
mappingModels.addAll(Stream.of(model.getDisplayEmoji()).map(EmojiPageViewGridAdapter.EmojiModel::new).toList());
|
||||
}
|
||||
|
||||
return mappingModels;
|
||||
setAdapter(adapter);
|
||||
adapter.submitList(list, commitCallback);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -117,8 +143,8 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
|
||||
@Override
|
||||
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||
if (layoutManager instanceof GridLayoutManager) {
|
||||
int idealWidth = getContext().getResources().getDimensionPixelOffset(R.dimen.emoji_drawer_item_width);
|
||||
int spanCount = Math.max(w / idealWidth, 1);
|
||||
int idealWidth = getContext().getResources().getDimensionPixelOffset(R.dimen.emoji_drawer_item_width);
|
||||
int spanCount = Math.max(w / idealWidth, 1);
|
||||
|
||||
((GridLayoutManager) layoutManager).setSpanCount(spanCount);
|
||||
}
|
||||
@@ -127,9 +153,9 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
|
||||
@Override
|
||||
public void onVariationSelectorStateChanged(boolean open) {
|
||||
if (open) {
|
||||
recyclerView.addOnItemTouchListener(scrollDisabler);
|
||||
addOnItemTouchListener(scrollDisabler);
|
||||
} else {
|
||||
post(() -> recyclerView.removeOnItemTouchListener(scrollDisabler));
|
||||
post(() -> removeOnItemTouchListener(scrollDisabler));
|
||||
}
|
||||
|
||||
if (variationSelectorListener != null) {
|
||||
@@ -138,7 +164,29 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
|
||||
}
|
||||
|
||||
public void setRecyclerNestedScrollingEnabled(boolean enabled) {
|
||||
recyclerView.setNestedScrollingEnabled(enabled);
|
||||
setNestedScrollingEnabled(enabled);
|
||||
}
|
||||
|
||||
public void smoothScrollToPositionTop(int position) {
|
||||
int currentPosition = layoutManager.findFirstCompletelyVisibleItemPosition();
|
||||
boolean shortTrip = Math.abs(currentPosition - position) < 475;
|
||||
|
||||
if (shortTrip) {
|
||||
RecyclerView.SmoothScroller smoothScroller = new LinearSmoothScroller(getContext()) {
|
||||
@Override
|
||||
protected int getVerticalSnapPreference() {
|
||||
return LinearSmoothScroller.SNAP_TO_START;
|
||||
}
|
||||
};
|
||||
smoothScroller.setTargetPosition(position);
|
||||
layoutManager.startSmoothScroll(smoothScroller);
|
||||
} else {
|
||||
layoutManager.scrollToPositionWithOffset(position, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable EmojiPageViewGridAdapter getAdapter() {
|
||||
return (EmojiPageViewGridAdapter) super.getAdapter();
|
||||
}
|
||||
|
||||
private static class ScrollDisabler implements RecyclerView.OnItemTouchListener {
|
||||
|
||||
@@ -2,38 +2,37 @@ package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.PopupWindow;
|
||||
import android.widget.Space;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView;
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter;
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder;
|
||||
|
||||
public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWindow.OnDismissListener {
|
||||
|
||||
private final VariationSelectorListener variationSelectorListener;
|
||||
private final VariationSelectorListener variationSelectorListener;
|
||||
|
||||
public EmojiPageViewGridAdapter(@NonNull EmojiVariationSelectorPopup popup,
|
||||
@NonNull EmojiEventListener emojiEventListener,
|
||||
@NonNull VariationSelectorListener variationSelectorListener,
|
||||
boolean allowVariations,
|
||||
@LayoutRes int displayItemLayoutResId)
|
||||
@LayoutRes int displayEmojiLayoutResId,
|
||||
@LayoutRes int displayEmoticonLayoutResId)
|
||||
{
|
||||
this.variationSelectorListener = variationSelectorListener;
|
||||
|
||||
popup.setOnDismissListener(this);
|
||||
|
||||
registerFactory(EmojiModel.class, new LayoutFactory<>(v -> new EmojiViewHolder(v, emojiEventListener, variationSelectorListener, popup, allowVariations), displayItemLayoutResId));
|
||||
registerFactory(EmojiHeader.class, new LayoutFactory<>(EmojiHeaderViewHolder::new, R.layout.emoji_grid_header));
|
||||
registerFactory(EmojiModel.class, new LayoutFactory<>(v -> new EmojiViewHolder(v, emojiEventListener, variationSelectorListener, popup, allowVariations), displayEmojiLayoutResId));
|
||||
registerFactory(EmojiTextModel.class, new LayoutFactory<>(v -> new EmojiTextViewHolder(v, emojiEventListener), displayEmoticonLayoutResId));
|
||||
registerFactory(EmojiNoResultsModel.class, new LayoutFactory<>(MappingViewHolder.SimpleViewHolder::new, R.layout.emoji_grid_no_results));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -41,21 +40,73 @@ public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWin
|
||||
variationSelectorListener.onVariationSelectorStateChanged(false);
|
||||
}
|
||||
|
||||
static class EmojiModel implements MappingModel<EmojiModel> {
|
||||
public static class EmojiHeader implements MappingModel<EmojiHeader>, HasKey {
|
||||
|
||||
private final Emoji emoji;
|
||||
private final String key;
|
||||
private final int title;
|
||||
|
||||
EmojiModel(@NonNull Emoji emoji) {
|
||||
public EmojiHeader(@NonNull String key, int title) {
|
||||
this.key = key;
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull EmojiHeader newItem) {
|
||||
return title == newItem.title;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull EmojiHeader newItem) {
|
||||
return areItemsTheSame(newItem);
|
||||
}
|
||||
}
|
||||
|
||||
static class EmojiHeaderViewHolder extends MappingViewHolder<EmojiHeader> {
|
||||
|
||||
private final TextView title;
|
||||
|
||||
public EmojiHeaderViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
title = findViewById(R.id.emoji_grid_header_title);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull EmojiHeader model) {
|
||||
title.setText(model.title);
|
||||
}
|
||||
}
|
||||
|
||||
public static class EmojiModel implements MappingModel<EmojiModel>, HasKey {
|
||||
|
||||
private final String key;
|
||||
private final Emoji emoji;
|
||||
|
||||
public EmojiModel(@NonNull String key, @NonNull Emoji emoji) {
|
||||
this.key = key;
|
||||
this.emoji = emoji;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull @NotNull EmojiModel newItem) {
|
||||
public @NonNull String getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
public @NonNull Emoji getEmoji() {
|
||||
return emoji;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull EmojiModel newItem) {
|
||||
return newItem.emoji.getValue().equals(emoji.getValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull @NotNull EmojiModel newItem) {
|
||||
public boolean areContentsTheSame(@NonNull EmojiModel newItem) {
|
||||
return areItemsTheSame(newItem);
|
||||
}
|
||||
}
|
||||
@@ -67,9 +118,7 @@ public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWin
|
||||
private final EmojiEventListener emojiEventListener;
|
||||
private final boolean allowVariations;
|
||||
|
||||
private final ImageView imageView;
|
||||
private final AsciiEmojiView textView;
|
||||
private final ImageView hintCorner;
|
||||
private final ImageView imageView;
|
||||
|
||||
public EmojiViewHolder(@NonNull View itemView,
|
||||
@NonNull EmojiEventListener emojiEventListener,
|
||||
@@ -85,31 +134,22 @@ public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWin
|
||||
this.allowVariations = allowVariations;
|
||||
|
||||
this.imageView = itemView.findViewById(R.id.emoji_image);
|
||||
this.textView = itemView.findViewById(R.id.emoji_text);
|
||||
this.hintCorner = itemView.findViewById(R.id.emoji_variation_hint);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull @NotNull EmojiModel model) {
|
||||
public void bind(@NonNull EmojiModel model) {
|
||||
final Drawable drawable = EmojiProvider.getEmojiDrawable(imageView.getContext(), model.emoji.getValue());
|
||||
|
||||
if (drawable != null) {
|
||||
textView.setVisibility(View.GONE);
|
||||
imageView.setVisibility(View.VISIBLE);
|
||||
|
||||
imageView.setImageDrawable(drawable);
|
||||
} else {
|
||||
textView.setVisibility(View.VISIBLE);
|
||||
imageView.setVisibility(View.GONE);
|
||||
|
||||
textView.setEmoji(model.emoji.getValue());
|
||||
}
|
||||
|
||||
itemView.setOnClickListener(v -> {
|
||||
emojiEventListener.onEmojiSelected(model.emoji.getValue());
|
||||
});
|
||||
|
||||
if (allowVariations && model.emoji.getVariations().size() > 1) {
|
||||
if (allowVariations && model.emoji.hasMultipleVariations()) {
|
||||
itemView.setOnLongClickListener(v -> {
|
||||
popup.dismiss();
|
||||
popup.setVariations(model.emoji.getVariations());
|
||||
@@ -117,14 +157,81 @@ public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWin
|
||||
variationSelectorListener.onVariationSelectorStateChanged(true);
|
||||
return true;
|
||||
});
|
||||
hintCorner.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
itemView.setOnLongClickListener(null);
|
||||
hintCorner.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class EmojiTextModel implements MappingModel<EmojiTextModel>, HasKey {
|
||||
private final String key;
|
||||
private final Emoji emoji;
|
||||
|
||||
public EmojiTextModel(@NonNull String key, @NonNull Emoji emoji) {
|
||||
this.key = key;
|
||||
this.emoji = emoji;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
public @NonNull Emoji getEmoji() {
|
||||
return emoji;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull EmojiTextModel newItem) {
|
||||
return newItem.emoji.getValue().equals(emoji.getValue());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull EmojiTextModel newItem) {
|
||||
return areItemsTheSame(newItem);
|
||||
}
|
||||
}
|
||||
|
||||
static class EmojiTextViewHolder extends MappingViewHolder<EmojiTextModel> {
|
||||
|
||||
private final EmojiEventListener emojiEventListener;
|
||||
private final AsciiEmojiView textView;
|
||||
|
||||
public EmojiTextViewHolder(@NonNull View itemView,
|
||||
@NonNull EmojiEventListener emojiEventListener)
|
||||
{
|
||||
super(itemView);
|
||||
|
||||
this.emojiEventListener = emojiEventListener;
|
||||
this.textView = itemView.findViewById(R.id.emoji_text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull EmojiTextModel model) {
|
||||
textView.setEmoji(model.emoji.getValue());
|
||||
|
||||
itemView.setOnClickListener(v -> {
|
||||
emojiEventListener.onEmojiSelected(model.emoji.getValue());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static class EmojiNoResultsModel implements MappingModel<EmojiNoResultsModel> {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull EmojiNoResultsModel newItem) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull EmojiNoResultsModel newItem) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public interface HasKey {
|
||||
@NonNull String getKey();
|
||||
}
|
||||
|
||||
public interface VariationSelectorListener {
|
||||
void onVariationSelectorStateChanged(boolean open);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,9 @@ import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.widget.AppCompatImageButton;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.stickers.StickerKeyboardProvider;
|
||||
import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components.emoji;
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
@@ -13,7 +12,6 @@ import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.fragment.app.FragmentTransaction;
|
||||
import androidx.viewpager.widget.PagerAdapter;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
@@ -22,13 +20,7 @@ import org.thoughtcrime.securesms.keyboard.KeyboardPage;
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPagerFragment;
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment;
|
||||
|
||||
import java.security.Key;
|
||||
|
||||
public class MediaKeyboard extends FrameLayout implements InputView,
|
||||
MediaKeyboardProvider.Presenter,
|
||||
MediaKeyboardProvider.Controller,
|
||||
MediaKeyboardBottomTabAdapter.EventListener
|
||||
{
|
||||
public class MediaKeyboard extends FrameLayout implements InputView {
|
||||
|
||||
private static final String TAG = Log.tag(MediaKeyboard.class);
|
||||
private static final String EMOJI_SEARCH = "emoji_search_fragment";
|
||||
@@ -88,60 +80,6 @@ public class MediaKeyboard extends FrameLayout implements InputView,
|
||||
keyboardPagerFragment.hide();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void present(@NonNull MediaKeyboardProvider provider,
|
||||
@NonNull PagerAdapter pagerAdapter,
|
||||
@NonNull MediaKeyboardProvider.TabIconProvider tabIconProvider,
|
||||
@Nullable MediaKeyboardProvider.BackspaceObserver backspaceObserver,
|
||||
@Nullable MediaKeyboardProvider.AddObserver addObserver,
|
||||
@Nullable MediaKeyboardProvider.SearchObserver searchObserver,
|
||||
int startingIndex)
|
||||
{
|
||||
// if (categoryPager == null) return;
|
||||
// if (!provider.equals(providers[providerIndex])) return;
|
||||
// if (keyboardListener != null) keyboardListener.onKeyboardChanged(provider);
|
||||
//
|
||||
// boolean isSolo = providers.length == 1;
|
||||
//
|
||||
// presentProviderStrip(isSolo);
|
||||
// presentCategoryPager(pagerAdapter, tabIconProvider, startingIndex);
|
||||
// presentProviderTabs(providers, providerIndex);
|
||||
// presentSearchButton(searchObserver);
|
||||
// presentBackspaceButton(backspaceObserver, isSolo);
|
||||
// presentAddButton(addObserver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCurrentPosition() {
|
||||
// return categoryPager != null ? categoryPager.getCurrentItem() : 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requestDismissal() {
|
||||
hide(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isVisible() {
|
||||
return getVisibility() == View.VISIBLE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTabSelected(int index) {
|
||||
// if (categoryPager != null) {
|
||||
// categoryPager.setCurrentItem(index);
|
||||
// categoryTabs.smoothScrollToPosition(index);
|
||||
// }
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setViewPagerEnabled(boolean enabled) {
|
||||
// if (categoryPager != null) {
|
||||
// categoryPager.setEnabled(enabled);
|
||||
// }
|
||||
}
|
||||
|
||||
public void onCloseEmojiSearch() {
|
||||
onCloseEmojiSearchInternal(true);
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboardProvider.TabIconProvider;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
|
||||
public class MediaKeyboardBottomTabAdapter extends RecyclerView.Adapter<MediaKeyboardBottomTabAdapter.MediaKeyboardBottomTabViewHolder> {
|
||||
|
||||
private final GlideRequests glideRequests;
|
||||
private final EventListener eventListener;
|
||||
|
||||
private TabIconProvider tabIconProvider;
|
||||
private int activePosition;
|
||||
private int count;
|
||||
|
||||
public MediaKeyboardBottomTabAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) {
|
||||
this.glideRequests = glideRequests;
|
||||
this.eventListener = eventListener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull MediaKeyboardBottomTabViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
|
||||
return new MediaKeyboardBottomTabViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.media_keyboard_bottom_tab_item, viewGroup, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull MediaKeyboardBottomTabViewHolder viewHolder, int i) {
|
||||
viewHolder.bind(glideRequests, eventListener, tabIconProvider, i, i == activePosition);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewRecycled(@NonNull MediaKeyboardBottomTabViewHolder holder) {
|
||||
holder.recycle();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return count;
|
||||
}
|
||||
|
||||
public void setTabIconProvider(@NonNull TabIconProvider iconProvider, int count) {
|
||||
this.tabIconProvider = iconProvider;
|
||||
this.count = count;
|
||||
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void setActivePosition(int position) {
|
||||
this.activePosition = position;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
static class MediaKeyboardBottomTabViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
private final ImageView image;
|
||||
private final View imageSelected;
|
||||
|
||||
public MediaKeyboardBottomTabViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
this.image = itemView.findViewById(R.id.category_icon);
|
||||
this.imageSelected = itemView.findViewById(R.id.category_icon_selected);
|
||||
}
|
||||
|
||||
void bind(@NonNull GlideRequests glideRequests,
|
||||
@NonNull EventListener eventListener,
|
||||
@NonNull TabIconProvider tabIconProvider,
|
||||
int index,
|
||||
boolean selected)
|
||||
{
|
||||
tabIconProvider.loadCategoryTabIcon(glideRequests, image, index);
|
||||
image.setAlpha(selected ? 1 : 0.5f);
|
||||
imageSelected.setSelected(selected);
|
||||
|
||||
itemView.setOnClickListener(v -> eventListener.onTabSelected(index));
|
||||
}
|
||||
|
||||
void recycle() {
|
||||
itemView.setOnClickListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
public interface EventListener {
|
||||
void onTabSelected(int index);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.viewpager.widget.PagerAdapter;
|
||||
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
|
||||
public interface MediaKeyboardProvider {
|
||||
@LayoutRes int getProviderIconView(boolean selected);
|
||||
/** @return True if the click was handled with provider-specific logic, otherwise false */
|
||||
void requestPresentation(@NonNull Presenter presenter, boolean isSoloProvider);
|
||||
void setController(@Nullable Controller controller);
|
||||
void setCurrentPosition(int currentPosition);
|
||||
|
||||
interface BackspaceObserver {
|
||||
void onBackspaceClicked();
|
||||
}
|
||||
|
||||
interface AddObserver {
|
||||
void onAddClicked();
|
||||
}
|
||||
|
||||
interface SearchObserver {
|
||||
void onSearchOpened();
|
||||
void onSearchClosed();
|
||||
void onSearchChanged(@NonNull String query);
|
||||
}
|
||||
|
||||
interface Controller {
|
||||
void setViewPagerEnabled(boolean enabled);
|
||||
}
|
||||
|
||||
interface Presenter {
|
||||
void present(@NonNull MediaKeyboardProvider provider,
|
||||
@NonNull PagerAdapter pagerAdapter,
|
||||
@NonNull TabIconProvider iconProvider,
|
||||
@Nullable BackspaceObserver backspaceObserver,
|
||||
@Nullable AddObserver addObserver,
|
||||
@Nullable SearchObserver searchObserver,
|
||||
int startingIndex);
|
||||
int getCurrentPosition();
|
||||
void requestDismissal();
|
||||
boolean isVisible();
|
||||
}
|
||||
|
||||
interface TabIconProvider {
|
||||
void loadCategoryTabIcon(@NonNull GlideRequests glideRequests, @NonNull ImageView imageView, int index);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import java.util.List;
|
||||
public class RecentEmojiPageModel implements EmojiPageModel {
|
||||
private static final String TAG = Log.tag(RecentEmojiPageModel.class);
|
||||
private static final int EMOJI_LRU_SIZE = 50;
|
||||
public static final String KEY = "Recents";
|
||||
|
||||
private final SharedPreferences prefs;
|
||||
private final String preferenceName;
|
||||
@@ -55,6 +56,11 @@ public class RecentEmojiPageModel implements EmojiPageModel {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override public int getIconAttr() {
|
||||
return R.attr.emoji_category_recent;
|
||||
}
|
||||
@@ -100,13 +106,4 @@ public class RecentEmojiPageModel implements EmojiPageModel {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String[] toReversePrimitiveArray(@NonNull LinkedHashSet<String> emojiSet) {
|
||||
String[] emojis = new String[emojiSet.size()];
|
||||
int i = emojiSet.size() - 1;
|
||||
for (String emoji : emojiSet) {
|
||||
emojis[i--] = emoji;
|
||||
}
|
||||
return emojis;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,39 +2,39 @@ package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.AttrRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiCategory;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class StaticEmojiPageModel implements EmojiPageModel {
|
||||
@AttrRes private final int iconAttr;
|
||||
@NonNull private final List<Emoji> emoji;
|
||||
@Nullable private final Uri sprite;
|
||||
private final @NonNull EmojiCategory category;
|
||||
private final @NonNull List<Emoji> emoji;
|
||||
private final @Nullable Uri sprite;
|
||||
|
||||
public StaticEmojiPageModel(@AttrRes int iconAttr, @NonNull String[] strings, @Nullable Uri sprite) {
|
||||
List<Emoji> emoji = new ArrayList<>(strings.length);
|
||||
for (String s : strings) {
|
||||
emoji.add(new Emoji(Collections.singletonList(s)));
|
||||
}
|
||||
public StaticEmojiPageModel(@NonNull EmojiCategory category, @NonNull String[] strings, @Nullable Uri sprite) {
|
||||
this(category, Arrays.stream(strings).map(s -> new Emoji(Collections.singletonList(s))).collect(Collectors.toList()), sprite);
|
||||
}
|
||||
|
||||
this.iconAttr = iconAttr;
|
||||
this.emoji = emoji;
|
||||
public StaticEmojiPageModel(@NonNull EmojiCategory category, @NonNull List<Emoji> emoji, @Nullable Uri sprite) {
|
||||
this.category = category;
|
||||
this.emoji = Collections.unmodifiableList(emoji);
|
||||
this.sprite = sprite;
|
||||
}
|
||||
|
||||
public StaticEmojiPageModel(@AttrRes int iconAttr, @NonNull List<Emoji> emoji, @Nullable Uri sprite) {
|
||||
this.iconAttr = iconAttr;
|
||||
this.emoji = Collections.unmodifiableList(emoji);
|
||||
this.sprite = sprite;
|
||||
@Override
|
||||
public String getKey() {
|
||||
return category.getKey();
|
||||
}
|
||||
|
||||
public int getIconAttr() {
|
||||
return iconAttr;
|
||||
return category.getIcon();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -8,7 +8,7 @@ import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSessionLock;
|
||||
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
|
||||
@@ -43,7 +43,7 @@ public class UntrustedSendDialog extends AlertDialog.Builder implements DialogIn
|
||||
final IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(getContext());
|
||||
|
||||
SimpleTask.run(() -> {
|
||||
try(SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) {
|
||||
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
|
||||
for (IdentityRecord identityRecord : untrustedRecords) {
|
||||
identityDatabase.setApproval(identityRecord.getRecipientId(), true);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSessionLock;
|
||||
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
|
||||
@@ -44,7 +44,7 @@ public class UnverifiedSendDialog extends AlertDialog.Builder implements DialogI
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
@Override
|
||||
protected Void doInBackground(Void... params) {
|
||||
try(SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) {
|
||||
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
|
||||
for (IdentityRecord identityRecord : untrustedRecords) {
|
||||
identityDatabase.setVerified(identityRecord.getRecipientId(),
|
||||
identityRecord.getIdentityKey(),
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.thoughtcrime.securesms.components.recyclerview
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
/**
|
||||
* Allows implementor to trigger an animation when the attached recyclerview is
|
||||
* scrolled.
|
||||
*/
|
||||
abstract class OnScrollAnimationHelper : RecyclerView.OnScrollListener() {
|
||||
private var lastAnimationState = AnimationState.NONE
|
||||
|
||||
protected open val duration: Long = 250L
|
||||
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
val newAnimationState = getAnimationState(recyclerView)
|
||||
|
||||
if (newAnimationState == lastAnimationState) {
|
||||
return
|
||||
}
|
||||
|
||||
if (lastAnimationState == AnimationState.NONE) {
|
||||
setImmediateState(recyclerView)
|
||||
return
|
||||
}
|
||||
|
||||
when (newAnimationState) {
|
||||
AnimationState.NONE -> throw AssertionError()
|
||||
AnimationState.HIDE -> hide(duration)
|
||||
AnimationState.SHOW -> show(duration)
|
||||
}
|
||||
|
||||
lastAnimationState = newAnimationState
|
||||
}
|
||||
|
||||
fun setImmediateState(recyclerView: RecyclerView) {
|
||||
val newAnimationState = getAnimationState(recyclerView)
|
||||
|
||||
when (newAnimationState) {
|
||||
AnimationState.NONE -> throw AssertionError()
|
||||
AnimationState.HIDE -> hide(0L)
|
||||
AnimationState.SHOW -> show(0L)
|
||||
}
|
||||
|
||||
lastAnimationState = newAnimationState
|
||||
}
|
||||
|
||||
protected open fun getAnimationState(recyclerView: RecyclerView): AnimationState {
|
||||
return if (recyclerView.canScrollVertically(-1)) AnimationState.SHOW else AnimationState.HIDE
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired when the RecyclerView is able to be scrolled up
|
||||
*/
|
||||
protected abstract fun show(duration: Long)
|
||||
|
||||
/**
|
||||
* Fired when the RecyclerView is not able to be scrolled up
|
||||
*/
|
||||
protected abstract fun hide(duration: Long)
|
||||
|
||||
enum class AnimationState {
|
||||
NONE,
|
||||
HIDE,
|
||||
SHOW
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.thoughtcrime.securesms.components.recyclerview
|
||||
|
||||
import android.view.View
|
||||
|
||||
/**
|
||||
* Animates in and out a given view. This is intended to be used to show and hide a toolbar shadow,
|
||||
* but makes no restrictions in this manner.
|
||||
*/
|
||||
open class ToolbarShadowAnimationHelper(private val toolbarShadow: View) : OnScrollAnimationHelper() {
|
||||
|
||||
override fun show(duration: Long) {
|
||||
toolbarShadow.animate()
|
||||
.setDuration(duration)
|
||||
.alpha(1f)
|
||||
}
|
||||
|
||||
override fun hide(duration: Long) {
|
||||
toolbarShadow.animate()
|
||||
.setDuration(duration)
|
||||
.alpha(0f)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ public class OutdatedBuildReminder extends Reminder {
|
||||
}
|
||||
|
||||
private static CharSequence getPluralsText(final Context context) {
|
||||
int days = getDaysUntilExpiry() - 1;
|
||||
int days = getDaysUntilExpiry();
|
||||
|
||||
if (days == 0) {
|
||||
return context.getString(R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today);
|
||||
|
||||
@@ -8,10 +8,11 @@ import androidx.navigation.fragment.NavHostFragment
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
|
||||
open class DSLSettingsActivity : PassphraseRequiredActivity() {
|
||||
|
||||
private val dynamicTheme = DynamicNoActionBarTheme()
|
||||
protected open val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme()
|
||||
|
||||
protected lateinit var navController: NavController
|
||||
private set
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
class DSLSettingsAdapter : MappingAdapter() {
|
||||
init {
|
||||
@@ -42,13 +43,9 @@ abstract class PreferenceViewHolder<T : PreferenceModel<T>>(itemView: View) : Ma
|
||||
it.isEnabled = model.isEnabled
|
||||
}
|
||||
|
||||
if (model.iconId != -1) {
|
||||
iconView.setImageResource(model.iconId)
|
||||
iconView.visibility = View.VISIBLE
|
||||
} else {
|
||||
iconView.setImageDrawable(null)
|
||||
iconView.visibility = View.GONE
|
||||
}
|
||||
val icon = model.icon?.resolve(context)
|
||||
iconView.setImageDrawable(icon)
|
||||
iconView.visible = icon != null
|
||||
|
||||
val title = model.title?.resolve(context)
|
||||
if (title != null) {
|
||||
@@ -93,13 +90,31 @@ class RadioListPreferenceViewHolder(itemView: View) : PreferenceViewHolder<Radio
|
||||
summaryView.text = model.listItems[model.selected]
|
||||
|
||||
itemView.setOnClickListener {
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(model.title.resolve(context))
|
||||
var selection = -1
|
||||
val builder = MaterialAlertDialogBuilder(context)
|
||||
.setTitle(model.dialogTitle.resolve(context))
|
||||
.setSingleChoiceItems(model.listItems, model.selected) { dialog, which ->
|
||||
model.onSelected(which)
|
||||
dialog.dismiss()
|
||||
if (model.confirmAction) {
|
||||
selection = which
|
||||
} else {
|
||||
model.onSelected(which)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
.show()
|
||||
|
||||
if (model.confirmAction) {
|
||||
builder
|
||||
.setPositiveButton(android.R.string.ok) { dialog, _ ->
|
||||
model.onSelected(selection)
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
builder.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,21 +12,25 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
|
||||
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper
|
||||
|
||||
abstract class DSLSettingsFragment(
|
||||
@StringRes private val titleId: Int,
|
||||
@StringRes private val titleId: Int = -1,
|
||||
@MenuRes private val menuId: Int = -1,
|
||||
@LayoutRes layoutId: Int = R.layout.dsl_settings_fragment
|
||||
) : Fragment(layoutId) {
|
||||
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
private lateinit var toolbarShadowHelper: ToolbarShadowHelper
|
||||
private lateinit var scrollAnimationHelper: OnScrollAnimationHelper
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
val toolbarShadow: View = view.findViewById(R.id.toolbar_shadow)
|
||||
|
||||
toolbar.setTitle(titleId)
|
||||
if (titleId != -1) {
|
||||
toolbar.setTitle(titleId)
|
||||
}
|
||||
|
||||
toolbar.setNavigationOnClickListener {
|
||||
requireActivity().onBackPressed()
|
||||
@@ -39,18 +43,17 @@ abstract class DSLSettingsFragment(
|
||||
|
||||
recyclerView = view.findViewById(R.id.recycler)
|
||||
recyclerView.edgeEffectFactory = EdgeEffectFactory()
|
||||
toolbarShadowHelper = ToolbarShadowHelper(toolbarShadow)
|
||||
scrollAnimationHelper = getOnScrollAnimationHelper(toolbarShadow)
|
||||
val adapter = DSLSettingsAdapter()
|
||||
|
||||
recyclerView.adapter = adapter
|
||||
recyclerView.addOnScrollListener(toolbarShadowHelper)
|
||||
recyclerView.addOnScrollListener(scrollAnimationHelper)
|
||||
|
||||
bindAdapter(adapter)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
toolbarShadowHelper.onScrolled(recyclerView, 0, 0)
|
||||
protected open fun getOnScrollAnimationHelper(toolbarShadow: View): OnScrollAnimationHelper {
|
||||
return ToolbarShadowAnimationHelper(toolbarShadow)
|
||||
}
|
||||
|
||||
abstract fun bindAdapter(adapter: DSLSettingsAdapter)
|
||||
@@ -65,32 +68,4 @@ abstract class DSLSettingsFragment(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ToolbarShadowHelper(private val toolbarShadow: View) : RecyclerView.OnScrollListener() {
|
||||
|
||||
private var lastAnimationState = ToolbarAnimationState.NONE
|
||||
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
val newAnimationState =
|
||||
if (recyclerView.canScrollVertically(-1)) ToolbarAnimationState.SHOW else ToolbarAnimationState.HIDE
|
||||
|
||||
if (newAnimationState == lastAnimationState) {
|
||||
return
|
||||
}
|
||||
|
||||
when (newAnimationState) {
|
||||
ToolbarAnimationState.NONE -> throw AssertionError()
|
||||
ToolbarAnimationState.HIDE -> toolbarShadow.animate().alpha(0f)
|
||||
ToolbarAnimationState.SHOW -> toolbarShadow.animate().alpha(1f)
|
||||
}
|
||||
|
||||
lastAnimationState = newAnimationState
|
||||
}
|
||||
}
|
||||
|
||||
private enum class ToolbarAnimationState {
|
||||
NONE,
|
||||
HIDE,
|
||||
SHOW
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.thoughtcrime.securesms.components.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
const val NO_TINT = -1
|
||||
|
||||
sealed class DSLSettingsIcon {
|
||||
|
||||
private data class FromResource(
|
||||
@DrawableRes private val iconId: Int,
|
||||
@ColorRes private val iconTintId: Int
|
||||
) : DSLSettingsIcon() {
|
||||
override fun resolve(context: Context) = requireNotNull(ContextCompat.getDrawable(context, iconId)).apply {
|
||||
if (iconTintId != NO_TINT) {
|
||||
colorFilter = PorterDuffColorFilter(ContextCompat.getColor(context, iconTintId), PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class FromDrawable(
|
||||
private val drawable: Drawable
|
||||
) : DSLSettingsIcon() {
|
||||
override fun resolve(context: Context): Drawable = drawable
|
||||
}
|
||||
|
||||
abstract fun resolve(context: Context): Drawable
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun from(@DrawableRes iconId: Int, @ColorRes iconTintId: Int = R.color.signal_icon_tint_primary): DSLSettingsIcon = FromResource(iconId, iconTintId)
|
||||
|
||||
@JvmStatic
|
||||
fun from(drawable: Drawable): DSLSettingsIcon = FromDrawable(drawable)
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ class AppSettingsActivity : DSLSettingsActivity() {
|
||||
|
||||
@JvmStatic
|
||||
fun help(context: Context, startCategoryIndex: Int = 0): Intent {
|
||||
return getIntentForStartLocation(context, StartLocation.HOME)
|
||||
return getIntentForStartLocation(context, StartLocation.HELP)
|
||||
.putExtra(HelpFragment.START_CATEGORY_INDEX, startCategoryIndex)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
|
||||
@@ -44,7 +45,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.AccountSettingsFragment__account),
|
||||
iconId = R.drawable.ic_profile_circle_24,
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_profile_circle_24),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
|
||||
}
|
||||
@@ -52,7 +53,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__linked_devices),
|
||||
iconId = R.drawable.ic_linked_devices_24,
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_linked_devices_24),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_deviceActivity)
|
||||
}
|
||||
@@ -72,7 +73,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__appearance),
|
||||
iconId = R.drawable.ic_appearance_24,
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_appearance_24),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_appearanceSettingsFragment)
|
||||
}
|
||||
@@ -80,7 +81,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences_chats__chats),
|
||||
iconId = R.drawable.ic_message_tinted_bitmap_24,
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_message_tinted_bitmap_24),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_chatsSettingsFragment)
|
||||
}
|
||||
@@ -88,7 +89,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__notifications),
|
||||
iconId = R.drawable.ic_bell_24,
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_bell_24),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_notificationsSettingsFragment)
|
||||
}
|
||||
@@ -96,7 +97,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__privacy),
|
||||
iconId = R.drawable.ic_lock_24,
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_lock_24),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_privacySettingsFragment)
|
||||
}
|
||||
@@ -104,7 +105,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__data_and_storage),
|
||||
iconId = R.drawable.ic_archive_24dp,
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_archive_24dp),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_dataAndStorageSettingsFragment)
|
||||
}
|
||||
@@ -114,7 +115,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__help),
|
||||
iconId = R.drawable.ic_help_24,
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_helpSettingsFragment)
|
||||
}
|
||||
@@ -122,7 +123,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.AppSettingsFragment__invite_your_friends),
|
||||
iconId = R.drawable.ic_invite_24,
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_invite_24),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_inviteActivity)
|
||||
}
|
||||
@@ -130,7 +131,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
|
||||
|
||||
externalLinkPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
|
||||
iconId = R.drawable.ic_heart_24,
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
|
||||
linkId = R.string.donate_url
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.widget.Toast
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
@@ -242,6 +243,40 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
viewModel.setRemoveSenderKeyMinimum(!state.removeSenderKeyMinimium)
|
||||
}
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_delay_resends),
|
||||
summary = DSLSettingsText.from(R.string.preferences__internal_delay_resending_messages_in_response_to_retry_receipts),
|
||||
isChecked = state.delayResends,
|
||||
onClick = {
|
||||
viewModel.setDelayResends(!state.delayResends)
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.preferences__internal_calling)
|
||||
|
||||
radioPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__internal_calling_default),
|
||||
summary = DSLSettingsText.from(BuildConfig.SIGNAL_SFU_URL),
|
||||
isChecked = state.callingServer == BuildConfig.SIGNAL_SFU_URL,
|
||||
onClick = {
|
||||
viewModel.setInternalGroupCallingServer(null)
|
||||
}
|
||||
)
|
||||
|
||||
BuildConfig.SIGNAL_SFU_INTERNAL_NAMES.zip(BuildConfig.SIGNAL_SFU_INTERNAL_URLS)
|
||||
.forEach { (name, server) ->
|
||||
radioPref(
|
||||
title = DSLSettingsText.from(requireContext().getString(R.string.preferences__internal_calling_s_server, name)),
|
||||
summary = DSLSettingsText.from(server),
|
||||
isChecked = state.callingServer == server,
|
||||
onClick = {
|
||||
viewModel.setInternalGroupCallingServer(server)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ data class InternalSettingsState(
|
||||
val disableAutoMigrationInitiation: Boolean,
|
||||
val disableAutoMigrationNotification: Boolean,
|
||||
val forceCensorship: Boolean,
|
||||
val callingServer: String,
|
||||
val useBuiltInEmojiSet: Boolean,
|
||||
val emojiVersion: EmojiFiles.Version?,
|
||||
val removeSenderKeyMinimium: Boolean,
|
||||
val delayResends: Boolean,
|
||||
)
|
||||
|
||||
@@ -70,6 +70,16 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setDelayResends(enabled: Boolean) {
|
||||
preferenceDataStore.putBoolean(InternalValues.DELAY_RESENDS, enabled)
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setInternalGroupCallingServer(server: String?) {
|
||||
preferenceDataStore.putString(InternalValues.CALLING_SERVER, server)
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
store.update { getState().copy(emojiVersion = it.emojiVersion) }
|
||||
}
|
||||
@@ -83,9 +93,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
disableAutoMigrationInitiation = SignalStore.internalValues().disableGv1AutoMigrateInitiation(),
|
||||
disableAutoMigrationNotification = SignalStore.internalValues().disableGv1AutoMigrateNotification(),
|
||||
forceCensorship = SignalStore.internalValues().forcedCensorship(),
|
||||
callingServer = SignalStore.internalValues().groupCallingServer(),
|
||||
useBuiltInEmojiSet = SignalStore.internalValues().forceBuiltInEmoji(),
|
||||
emojiVersion = null,
|
||||
removeSenderKeyMinimium = SignalStore.internalValues().removeSenderKeyMinimum()
|
||||
removeSenderKeyMinimium = SignalStore.internalValues().removeSenderKeyMinimum(),
|
||||
delayResends = SignalStore.internalValues().delayResends()
|
||||
)
|
||||
|
||||
class Factory(private val repository: InternalSettingsRepository) : ViewModelProvider.Factory {
|
||||
|
||||
@@ -289,7 +289,7 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
|
||||
val radioListPreference: RadioListPreference
|
||||
) : PreferenceModel<LedColorPreference>(
|
||||
title = radioListPreference.title,
|
||||
iconId = radioListPreference.iconId,
|
||||
icon = radioListPreference.icon,
|
||||
summary = radioListPreference.summary
|
||||
) {
|
||||
override fun areContentsTheSame(newItem: LedColorPreference): Boolean {
|
||||
|
||||
@@ -430,7 +430,7 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac
|
||||
) : PreferenceModel<ValueClickPreference>(
|
||||
title = clickPreference.title,
|
||||
summary = clickPreference.summary,
|
||||
iconId = clickPreference.iconId,
|
||||
icon = clickPreference.icon,
|
||||
isEnabled = clickPreference.isEnabled
|
||||
) {
|
||||
override fun areContentsTheSame(newItem: ValueClickPreference): Boolean {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.privacy.advanced
|
||||
|
||||
import android.content.Context
|
||||
import com.google.firebase.iid.FirebaseInstanceId
|
||||
import com.google.android.gms.tasks.Tasks
|
||||
import com.google.firebase.installations.FirebaseInstallations
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
@@ -14,6 +15,7 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ExecutionException
|
||||
|
||||
private val TAG = Log.tag(AdvancedPrivacySettingsRepository::class.java)
|
||||
|
||||
@@ -29,12 +31,18 @@ class AdvancedPrivacySettingsRepository(private val context: Context) {
|
||||
Log.w(TAG, e)
|
||||
}
|
||||
if (!TextSecurePreferences.isFcmDisabled(context)) {
|
||||
FirebaseInstanceId.getInstance().deleteInstanceId()
|
||||
Tasks.await(FirebaseInstallations.getInstance().delete())
|
||||
}
|
||||
DisablePushMessagesResult.SUCCESS
|
||||
} catch (ioe: IOException) {
|
||||
Log.w(TAG, ioe)
|
||||
DisablePushMessagesResult.NETWORK_ERROR
|
||||
} catch (e: InterruptedException) {
|
||||
Log.w(TAG, "Interrupted while deleting", e)
|
||||
DisablePushMessagesResult.NETWORK_ERROR
|
||||
} catch (e: ExecutionException) {
|
||||
Log.w(TAG, "Error deleting", e.cause)
|
||||
DisablePushMessagesResult.NETWORK_ERROR
|
||||
}
|
||||
|
||||
consumer(result)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.util.Pair
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.ParcelableGroupId
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.DynamicConversationSettingsTheme
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
|
||||
class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettingsFragment.Callback {
|
||||
|
||||
override val dynamicTheme: DynamicTheme = DynamicConversationSettingsTheme()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
ActivityCompat.postponeEnterTransition(this)
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
}
|
||||
|
||||
override fun onContentWillRender() {
|
||||
ActivityCompat.startPostponedEnterTransition(this)
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
super.finish()
|
||||
overridePendingTransition(0, R.anim.slide_fade_to_bottom)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmStatic
|
||||
fun createTransitionBundle(context: Context, avatar: View, windowContent: View): Bundle? {
|
||||
return if (context is Activity) {
|
||||
ActivityOptionsCompat.makeSceneTransitionAnimation(
|
||||
context,
|
||||
Pair.create(avatar, "avatar"),
|
||||
Pair.create(windowContent, "window_content")
|
||||
).toBundle()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun createTransitionBundle(context: Context, avatar: View): Bundle? {
|
||||
return if (context is Activity) {
|
||||
ActivityOptionsCompat.makeSceneTransitionAnimation(
|
||||
context,
|
||||
avatar,
|
||||
"avatar",
|
||||
).toBundle()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun forGroup(context: Context, groupId: GroupId): Intent {
|
||||
val startBundle = ConversationSettingsFragmentArgs.Builder(null, ParcelableGroupId.from(groupId))
|
||||
.build()
|
||||
.toBundle()
|
||||
|
||||
return getIntent(context)
|
||||
.putExtra(ARG_START_BUNDLE, startBundle)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun forRecipient(context: Context, recipientId: RecipientId): Intent {
|
||||
val startBundle = ConversationSettingsFragmentArgs.Builder(recipientId, null)
|
||||
.build()
|
||||
.toBundle()
|
||||
|
||||
return getIntent(context)
|
||||
.putExtra(ARG_START_BUNDLE, startBundle)
|
||||
}
|
||||
|
||||
private fun getIntent(context: Context): Intent {
|
||||
return Intent(context, ConversationSettingsActivity::class.java)
|
||||
.putExtra(ARG_NAV_GRAPH, R.navigation.conversation_settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
sealed class ConversationSettingsEvent {
|
||||
class AddToAGroup(
|
||||
val recipientId: RecipientId,
|
||||
val groupMembership: List<RecipientId>
|
||||
) : ConversationSettingsEvent()
|
||||
|
||||
class AddMembersToGroup(
|
||||
val groupId: GroupId,
|
||||
val selectionWarning: Int,
|
||||
val selectionLimit: Int,
|
||||
val groupMembersWithoutSelf: List<RecipientId>
|
||||
) : ConversationSettingsEvent()
|
||||
|
||||
object ShowGroupHardLimitDialog : ConversationSettingsEvent()
|
||||
|
||||
class ShowAddMembersToGroupError(
|
||||
val failureReason: GroupChangeFailureReason
|
||||
) : ConversationSettingsEvent()
|
||||
|
||||
class ShowGroupInvitesSentDialog(
|
||||
val invitesSentTo: List<Recipient>
|
||||
) : ConversationSettingsEvent()
|
||||
|
||||
class ShowMembersAdded(
|
||||
val membersAddedCount: Int
|
||||
) : ConversationSettingsEvent()
|
||||
|
||||
class InitiateGroupMigration(
|
||||
val recipientId: RecipientId
|
||||
) : ConversationSettingsEvent()
|
||||
}
|
||||
@@ -0,0 +1,770 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.Rect
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.doOnPreDraw
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import app.cash.exhaustive.Exhaustive
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.thoughtcrime.securesms.AvatarPreviewActivity
|
||||
import org.thoughtcrime.securesms.BlockUnblockDialog
|
||||
import org.thoughtcrime.securesms.InviteActivity
|
||||
import org.thoughtcrime.securesms.MediaPreviewActivity
|
||||
import org.thoughtcrime.securesms.MuteDialog
|
||||
import org.thoughtcrime.securesms.PushContactSelectionActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.VerifyIdentityActivity
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
|
||||
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.NO_TINT
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.AvatarPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.BioTextPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.GroupDescriptionPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.InternalPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LargeIconClickPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.SharedMediaPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.groups.ParcelableGroupId
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupErrors
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog
|
||||
import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog
|
||||
import org.thoughtcrime.securesms.groups.ui.addmembers.AddMembersActivity
|
||||
import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsActivity
|
||||
import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity
|
||||
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescriptionDialog
|
||||
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog
|
||||
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInitiationBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
|
||||
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientExporter
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.recipients.ui.sharablegrouplink.ShareableGroupLinkDialogFragment
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.ContextUtil
|
||||
import org.thoughtcrime.securesms.util.ExpirationUtil
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity
|
||||
|
||||
private const val REQUEST_CODE_VIEW_CONTACT = 1
|
||||
private const val REQUEST_CODE_ADD_CONTACT = 2
|
||||
private const val REQUEST_CODE_ADD_MEMBERS_TO_GROUP = 3
|
||||
private const val REQUEST_CODE_RETURN_FROM_MEDIA = 4
|
||||
|
||||
class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
layoutId = R.layout.conversation_settings_fragment,
|
||||
menuId = R.menu.conversation_settings
|
||||
) {
|
||||
|
||||
private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) }
|
||||
private val blockIcon by lazy {
|
||||
ContextUtil.requireDrawable(requireContext(), R.drawable.ic_block_tinted_24).apply {
|
||||
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
private val unblockIcon by lazy {
|
||||
ContextUtil.requireDrawable(requireContext(), R.drawable.ic_block_tinted_24)
|
||||
}
|
||||
|
||||
private val leaveIcon by lazy {
|
||||
ContextUtil.requireDrawable(requireContext(), R.drawable.ic_leave_tinted_24).apply {
|
||||
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
}
|
||||
|
||||
private val viewModel by viewModels<ConversationSettingsViewModel>(
|
||||
factoryProducer = {
|
||||
val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
|
||||
val groupId = args.groupId as? ParcelableGroupId
|
||||
|
||||
ConversationSettingsViewModel.Factory(
|
||||
recipientId = args.recipientId,
|
||||
groupId = ParcelableGroupId.get(groupId),
|
||||
repository = ConversationSettingsRepository(requireContext())
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
private lateinit var callback: Callback
|
||||
|
||||
private lateinit var toolbar: Toolbar
|
||||
private lateinit var toolbarAvatar: AvatarImageView
|
||||
private lateinit var toolbarTitle: TextView
|
||||
private lateinit var toolbarBackground: View
|
||||
|
||||
private val navController get() = Navigation.findNavController(requireView())
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
|
||||
callback = context as Callback
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbarAvatar = view.findViewById(R.id.toolbar_avatar)
|
||||
toolbarTitle = view.findViewById(R.id.toolbar_title)
|
||||
toolbarBackground = view.findViewById(R.id.toolbar_background)
|
||||
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
when (requestCode) {
|
||||
REQUEST_CODE_ADD_MEMBERS_TO_GROUP -> if (data != null) {
|
||||
val selected: List<RecipientId> = requireNotNull(data.getParcelableArrayListExtra(PushContactSelectionActivity.KEY_SELECTED_RECIPIENTS))
|
||||
val progress: SimpleProgressDialog.DismissibleDialog = SimpleProgressDialog.showDelayed(requireContext())
|
||||
|
||||
viewModel.onAddToGroupComplete(selected) {
|
||||
progress.dismiss()
|
||||
}
|
||||
}
|
||||
REQUEST_CODE_RETURN_FROM_MEDIA -> viewModel.refreshSharedMedia()
|
||||
REQUEST_CODE_ADD_CONTACT -> viewModel.refreshRecipient()
|
||||
REQUEST_CODE_VIEW_CONTACT -> viewModel.refreshRecipient()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getOnScrollAnimationHelper(toolbarShadow: View): OnScrollAnimationHelper {
|
||||
return ConversationSettingsOnUserScrolledAnimationHelper(toolbarAvatar, toolbarTitle, toolbarBackground, toolbarShadow)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return if (item.itemId == R.id.action_edit) {
|
||||
val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
|
||||
val groupId = args.groupId as ParcelableGroupId
|
||||
|
||||
startActivity(EditProfileActivity.getIntentForGroupProfile(requireActivity(), requireNotNull(ParcelableGroupId.get(groupId))))
|
||||
true
|
||||
} else {
|
||||
super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
BioTextPreference.register(adapter)
|
||||
AvatarPreference.register(adapter)
|
||||
ButtonStripPreference.register(adapter)
|
||||
LargeIconClickPreference.register(adapter)
|
||||
SharedMediaPreference.register(adapter)
|
||||
RecipientPreference.register(adapter)
|
||||
InternalPreference.register(adapter)
|
||||
GroupDescriptionPreference.register(adapter)
|
||||
LegacyGroupPreference.register(adapter)
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
|
||||
if (state.recipient != Recipient.UNKNOWN) {
|
||||
toolbarAvatar.buildOptions()
|
||||
.withQuickContactEnabled(false)
|
||||
.withUseSelfProfileAvatar(false)
|
||||
.withFixedSize(ViewUtil.dpToPx(80))
|
||||
.load(state.recipient)
|
||||
|
||||
state.withRecipientSettingsState {
|
||||
toolbarTitle.text = state.recipient.getDisplayName(requireContext())
|
||||
}
|
||||
|
||||
state.withGroupSettingsState {
|
||||
toolbarTitle.text = it.groupTitle
|
||||
toolbar.menu.findItem(R.id.action_edit).isVisible = it.canEditGroupAttributes
|
||||
}
|
||||
}
|
||||
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList()) {
|
||||
if (state.isLoaded) {
|
||||
(view?.parent as? ViewGroup)?.doOnPreDraw {
|
||||
callback.onContentWillRender()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.events.observe(viewLifecycleOwner) { event ->
|
||||
@Exhaustive
|
||||
when (event) {
|
||||
is ConversationSettingsEvent.AddToAGroup -> handleAddToAGroup(event)
|
||||
is ConversationSettingsEvent.AddMembersToGroup -> handleAddMembersToGroup(event)
|
||||
ConversationSettingsEvent.ShowGroupHardLimitDialog -> showGroupHardLimitDialog()
|
||||
is ConversationSettingsEvent.ShowAddMembersToGroupError -> showAddMembersToGroupError(event)
|
||||
is ConversationSettingsEvent.ShowGroupInvitesSentDialog -> showGroupInvitesSentDialog(event)
|
||||
is ConversationSettingsEvent.ShowMembersAdded -> showMembersAdded(event)
|
||||
is ConversationSettingsEvent.InitiateGroupMigration -> GroupsV1MigrationInitiationBottomSheetDialogFragment.showForInitiation(parentFragmentManager, event.recipientId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: ConversationSettingsState): DSLConfiguration {
|
||||
return configure {
|
||||
if (state.recipient == Recipient.UNKNOWN) {
|
||||
return@configure
|
||||
}
|
||||
|
||||
customPref(
|
||||
AvatarPreference.Model(
|
||||
recipient = state.recipient,
|
||||
onAvatarClick = { avatar ->
|
||||
requireActivity().apply {
|
||||
startActivity(
|
||||
AvatarPreviewActivity.intentFromRecipientId(this, state.recipient.id),
|
||||
AvatarPreviewActivity.createTransitionBundle(this, avatar)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
state.withRecipientSettingsState {
|
||||
customPref(BioTextPreference.RecipientModel(recipient = state.recipient))
|
||||
}
|
||||
|
||||
state.withGroupSettingsState { groupState ->
|
||||
|
||||
val groupMembershipDescription = if (groupState.groupId.isV1) {
|
||||
String.format("%s · %s", groupState.membershipCountDescription, getString(R.string.ManageGroupActivity_legacy_group))
|
||||
} else if (!groupState.canEditGroupAttributes && groupState.groupDescription.isNullOrEmpty()) {
|
||||
groupState.membershipCountDescription
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
customPref(
|
||||
BioTextPreference.GroupModel(
|
||||
groupTitle = groupState.groupTitle,
|
||||
groupMembershipDescription = groupMembershipDescription
|
||||
)
|
||||
)
|
||||
|
||||
if (groupState.groupId.isV2) {
|
||||
customPref(
|
||||
GroupDescriptionPreference.Model(
|
||||
groupId = groupState.groupId,
|
||||
groupDescription = groupState.groupDescription,
|
||||
descriptionShouldLinkify = groupState.groupDescriptionShouldLinkify,
|
||||
canEditGroupAttributes = groupState.canEditGroupAttributes,
|
||||
onEditGroupDescription = {
|
||||
startActivity(EditProfileActivity.getIntentForGroupProfile(requireActivity(), groupState.groupId))
|
||||
},
|
||||
onViewGroupDescription = {
|
||||
GroupDescriptionDialog.show(childFragmentManager, groupState.groupId, null, groupState.groupDescriptionShouldLinkify)
|
||||
}
|
||||
)
|
||||
)
|
||||
} else if (groupState.legacyGroupState != LegacyGroupPreference.State.NONE) {
|
||||
customPref(
|
||||
LegacyGroupPreference.Model(
|
||||
state = groupState.legacyGroupState,
|
||||
onLearnMoreClick = { GroupsLearnMoreBottomSheetDialogFragment.show(parentFragmentManager) },
|
||||
onUpgradeClick = { viewModel.initiateGroupUpgrade() },
|
||||
onMmsWarningClick = { startActivity(Intent(requireContext(), InviteActivity::class.java)) }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
state.withRecipientSettingsState { recipientState ->
|
||||
if (recipientState.displayInternalRecipientDetails) {
|
||||
customPref(
|
||||
InternalPreference.Model(
|
||||
recipient = state.recipient,
|
||||
onDisableProfileSharingClick = {
|
||||
viewModel.disableProfileSharing()
|
||||
},
|
||||
onDeleteSessionClick = {
|
||||
viewModel.deleteSession()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
customPref(
|
||||
ButtonStripPreference.Model(
|
||||
state = state.buttonStripState,
|
||||
onVideoClick = {
|
||||
CommunicationActions.startVideoCall(requireActivity(), state.recipient)
|
||||
},
|
||||
onAudioClick = {
|
||||
CommunicationActions.startVoiceCall(requireActivity(), state.recipient)
|
||||
},
|
||||
onMuteClick = {
|
||||
if (!state.buttonStripState.isMuted) {
|
||||
MuteDialog.show(requireContext(), viewModel::setMuteUntil)
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(state.recipient.muteUntil.formatMutedUntil(requireContext()))
|
||||
.setPositiveButton(R.string.ConversationSettingsFragment__unmute) { dialog, _ ->
|
||||
viewModel.unmute()
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() }
|
||||
.show()
|
||||
}
|
||||
},
|
||||
onSearchClick = {
|
||||
val intent = ConversationIntents.createBuilder(requireContext(), state.recipient.id, state.threadId)
|
||||
.withSearchOpen(true)
|
||||
.build()
|
||||
|
||||
startActivity(intent)
|
||||
requireActivity().finish()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
val summary = DSLSettingsText.from(formatDisappearingMessagesLifespan(state.disappearingMessagesLifespan))
|
||||
val icon = if (state.disappearingMessagesLifespan <= 0) {
|
||||
R.drawable.ic_update_timer_disabled_16
|
||||
} else {
|
||||
R.drawable.ic_update_timer_16
|
||||
}
|
||||
|
||||
var enabled = true
|
||||
state.withGroupSettingsState {
|
||||
enabled = it.canEditGroupAttributes
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__disappearing_messages),
|
||||
summary = summary,
|
||||
icon = DSLSettingsIcon.from(icon),
|
||||
isEnabled = enabled,
|
||||
onClick = {
|
||||
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToAppSettingsExpireTimer()
|
||||
.setInitialValue(state.disappearingMessagesLifespan)
|
||||
.setRecipientId(state.recipient.id)
|
||||
.setForResultMode(false)
|
||||
|
||||
navController.navigate(action)
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.preferences__chat_color_and_wallpaper),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_color_24),
|
||||
onClick = {
|
||||
startActivity(ChatWallpaperActivity.createIntent(requireContext(), state.recipient.id))
|
||||
}
|
||||
)
|
||||
|
||||
if (!state.recipient.isSelf) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__sounds_and_notifications),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_speaker_24),
|
||||
onClick = {
|
||||
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(state.recipient.id)
|
||||
|
||||
navController.navigate(action)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
state.withRecipientSettingsState { recipientState ->
|
||||
when (recipientState.contactLinkState) {
|
||||
ContactLinkState.OPEN -> {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__contact_details),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_profile_circle_24),
|
||||
onClick = {
|
||||
startActivityForResult(Intent(Intent.ACTION_VIEW, state.recipient.contactUri), REQUEST_CODE_VIEW_CONTACT)
|
||||
}
|
||||
)
|
||||
}
|
||||
ContactLinkState.ADD -> {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_as_a_contact),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_plus_24),
|
||||
onClick = {
|
||||
startActivityForResult(RecipientExporter.export(state.recipient).asAddContactIntent(), REQUEST_CODE_ADD_CONTACT)
|
||||
}
|
||||
)
|
||||
}
|
||||
ContactLinkState.NONE -> {
|
||||
}
|
||||
}
|
||||
|
||||
if (recipientState.identityRecord != null) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__view_safety_number),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_safety_number_24),
|
||||
onClick = {
|
||||
startActivity(VerifyIdentityActivity.newIntent(requireActivity(), recipientState.identityRecord))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.sharedMedia != null && state.sharedMedia.count > 0) {
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.recipient_preference_activity__shared_media)
|
||||
|
||||
customPref(
|
||||
SharedMediaPreference.Model(
|
||||
mediaCursor = state.sharedMedia,
|
||||
mediaIds = state.sharedMediaIds,
|
||||
onMediaRecordClick = { mediaRecord, isLtr ->
|
||||
startActivityForResult(
|
||||
MediaPreviewActivity.intentFromMediaRecord(requireContext(), mediaRecord, isLtr),
|
||||
REQUEST_CODE_RETURN_FROM_MEDIA
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__see_all),
|
||||
onClick = {
|
||||
startActivity(MediaOverviewActivity.forThread(requireContext(), state.threadId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
state.withRecipientSettingsState { groupState ->
|
||||
if (groupState.selfHasGroups) {
|
||||
|
||||
dividerPref()
|
||||
|
||||
val groupsInCommonCount = groupState.allGroupsInCommon.size
|
||||
sectionHeaderPref(
|
||||
DSLSettingsText.from(
|
||||
if (groupsInCommonCount == 0) {
|
||||
getString(R.string.ManageRecipientActivity_no_groups_in_common)
|
||||
} else {
|
||||
resources.getQuantityString(
|
||||
R.plurals.ManageRecipientActivity_d_groups_in_common,
|
||||
groupsInCommonCount,
|
||||
groupsInCommonCount
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
customPref(
|
||||
LargeIconClickPreference.Model(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_to_a_group),
|
||||
icon = DSLSettingsIcon.from(R.drawable.add_to_a_group, NO_TINT),
|
||||
onClick = {
|
||||
viewModel.onAddToGroup()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
for (group in groupState.groupsInCommon) {
|
||||
customPref(
|
||||
RecipientPreference.Model(
|
||||
recipient = group,
|
||||
onClick = {
|
||||
CommunicationActions.startConversation(requireActivity(), group, null)
|
||||
requireActivity().finish()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (groupState.canShowMoreGroupsInCommon) {
|
||||
customPref(
|
||||
LargeIconClickPreference.Model(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__see_all),
|
||||
icon = DSLSettingsIcon.from(R.drawable.show_more, NO_TINT),
|
||||
onClick = {
|
||||
viewModel.revealAllMembers()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.withGroupSettingsState { groupState ->
|
||||
val memberCount = groupState.allMembers.size
|
||||
|
||||
if (groupState.canAddToGroup || memberCount > 0) {
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(DSLSettingsText.from(resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, memberCount, memberCount)))
|
||||
}
|
||||
|
||||
if (groupState.canAddToGroup) {
|
||||
customPref(
|
||||
LargeIconClickPreference.Model(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_members),
|
||||
icon = DSLSettingsIcon.from(R.drawable.add_to_a_group, NO_TINT),
|
||||
onClick = {
|
||||
viewModel.onAddToGroup()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
for (member in groupState.members) {
|
||||
customPref(
|
||||
RecipientPreference.Model(
|
||||
recipient = member.member,
|
||||
isAdmin = member.isAdmin,
|
||||
onClick = {
|
||||
RecipientBottomSheetDialogFragment.create(member.member.id, groupState.groupId).show(parentFragmentManager, "BOTTOM")
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (groupState.canShowMoreGroupMembers) {
|
||||
customPref(
|
||||
LargeIconClickPreference.Model(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__see_all),
|
||||
icon = DSLSettingsIcon.from(R.drawable.show_more, NO_TINT),
|
||||
onClick = {
|
||||
viewModel.revealAllMembers()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (state.recipient.isPushV2Group) {
|
||||
dividerPref()
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__group_link),
|
||||
summary = DSLSettingsText.from(if (groupState.groupLinkEnabled) R.string.preferences_on else R.string.preferences_off),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_link_16),
|
||||
onClick = {
|
||||
ShareableGroupLinkDialogFragment.create(groupState.groupId.requireV2()).show(parentFragmentManager, "DIALOG")
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__requests_and_invites),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_update_group_add_16),
|
||||
onClick = {
|
||||
startActivity(ManagePendingAndRequestingMembersActivity.newIntent(requireContext(), groupState.groupId.requireV2()))
|
||||
}
|
||||
)
|
||||
|
||||
if (groupState.isSelfAdmin) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__permissions),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_lock_24),
|
||||
onClick = {
|
||||
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToPermissionsSettingsFragment(ParcelableGroupId.from(groupState.groupId))
|
||||
navController.navigate(action)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (groupState.canLeave) {
|
||||
dividerPref()
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.conversation__menu_leave_group, alertTint),
|
||||
icon = DSLSettingsIcon.from(leaveIcon),
|
||||
onClick = {
|
||||
LeaveGroupDialog.handleLeavePushGroup(requireActivity(), groupState.groupId.requirePush(), null)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.canModifyBlockedState) {
|
||||
state.withRecipientSettingsState {
|
||||
dividerPref()
|
||||
}
|
||||
|
||||
state.withGroupSettingsState {
|
||||
if (!it.canLeave) {
|
||||
dividerPref()
|
||||
}
|
||||
}
|
||||
|
||||
val isBlocked = state.recipient.isBlocked
|
||||
val isGroup = state.recipient.isPushGroup
|
||||
|
||||
val title = when {
|
||||
isBlocked && isGroup -> R.string.ConversationSettingsFragment__unblock_group
|
||||
isBlocked -> R.string.ConversationSettingsFragment__unblock
|
||||
isGroup -> R.string.ConversationSettingsFragment__block_group
|
||||
else -> R.string.ConversationSettingsFragment__block
|
||||
}
|
||||
|
||||
val titleTint = if (isBlocked) null else alertTint
|
||||
val blockUnblockIcon = if (isBlocked) unblockIcon else blockIcon
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(title, titleTint),
|
||||
icon = DSLSettingsIcon.from(blockUnblockIcon),
|
||||
onClick = {
|
||||
if (state.recipient.isBlocked) {
|
||||
BlockUnblockDialog.showUnblockFor(requireContext(), viewLifecycleOwner.lifecycle, state.recipient) {
|
||||
viewModel.unblock()
|
||||
}
|
||||
} else {
|
||||
BlockUnblockDialog.showBlockFor(requireContext(), viewLifecycleOwner.lifecycle, state.recipient) {
|
||||
viewModel.block()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDisappearingMessagesLifespan(disappearingMessagesLifespan: Int): String {
|
||||
return if (disappearingMessagesLifespan <= 0) {
|
||||
getString(R.string.preferences_off)
|
||||
} else {
|
||||
ExpirationUtil.getExpirationDisplayValue(requireContext(), disappearingMessagesLifespan)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAddToAGroup(addToAGroup: ConversationSettingsEvent.AddToAGroup) {
|
||||
startActivity(AddToGroupsActivity.newIntent(requireContext(), addToAGroup.recipientId, addToAGroup.groupMembership))
|
||||
}
|
||||
|
||||
private fun handleAddMembersToGroup(addMembersToGroup: ConversationSettingsEvent.AddMembersToGroup) {
|
||||
startActivityForResult(
|
||||
AddMembersActivity.createIntent(
|
||||
requireContext(),
|
||||
addMembersToGroup.groupId,
|
||||
ContactsCursorLoader.DisplayMode.FLAG_PUSH,
|
||||
addMembersToGroup.selectionWarning,
|
||||
addMembersToGroup.selectionLimit,
|
||||
addMembersToGroup.groupMembersWithoutSelf
|
||||
),
|
||||
REQUEST_CODE_ADD_MEMBERS_TO_GROUP
|
||||
)
|
||||
}
|
||||
|
||||
private fun showGroupHardLimitDialog() {
|
||||
GroupLimitDialog.showHardLimitMessage(requireContext())
|
||||
}
|
||||
|
||||
private fun showAddMembersToGroupError(showAddMembersToGroupError: ConversationSettingsEvent.ShowAddMembersToGroupError) {
|
||||
Toast.makeText(requireContext(), GroupErrors.getUserDisplayMessage(showAddMembersToGroupError.failureReason), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun showGroupInvitesSentDialog(showGroupInvitesSentDialog: ConversationSettingsEvent.ShowGroupInvitesSentDialog) {
|
||||
GroupInviteSentDialog.showInvitesSent(requireContext(), showGroupInvitesSentDialog.invitesSentTo)
|
||||
}
|
||||
|
||||
private fun showMembersAdded(showMembersAdded: ConversationSettingsEvent.ShowMembersAdded) {
|
||||
val string = resources.getQuantityString(
|
||||
R.plurals.ManageGroupActivity_added,
|
||||
showMembersAdded.membersAddedCount,
|
||||
showMembersAdded.membersAddedCount
|
||||
)
|
||||
|
||||
Snackbar.make(requireView(), string, Snackbar.LENGTH_SHORT).setTextColor(Color.WHITE).show()
|
||||
}
|
||||
|
||||
private class ConversationSettingsOnUserScrolledAnimationHelper(
|
||||
private val toolbarAvatar: View,
|
||||
private val toolbarTitle: View,
|
||||
private val toolbarBackground: View,
|
||||
toolbarShadow: View
|
||||
) : ToolbarShadowAnimationHelper(toolbarShadow) {
|
||||
|
||||
override val duration: Long = 200L
|
||||
|
||||
private val actionBarSize = ThemeUtil.getThemedDimen(toolbarShadow.context, R.attr.actionBarSize)
|
||||
private val rect = Rect()
|
||||
|
||||
override fun getAnimationState(recyclerView: RecyclerView): AnimationState {
|
||||
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
|
||||
|
||||
return if (layoutManager.findFirstVisibleItemPosition() == 0) {
|
||||
val firstChild = requireNotNull(layoutManager.getChildAt(0))
|
||||
firstChild.getLocalVisibleRect(rect)
|
||||
|
||||
if (rect.height() <= actionBarSize) {
|
||||
AnimationState.SHOW
|
||||
} else {
|
||||
AnimationState.HIDE
|
||||
}
|
||||
} else {
|
||||
AnimationState.SHOW
|
||||
}
|
||||
}
|
||||
|
||||
override fun show(duration: Long) {
|
||||
super.show(duration)
|
||||
|
||||
toolbarAvatar
|
||||
.animate()
|
||||
.setDuration(duration)
|
||||
.translationY(0f)
|
||||
.alpha(1f)
|
||||
|
||||
toolbarTitle
|
||||
.animate()
|
||||
.setDuration(duration)
|
||||
.translationY(0f)
|
||||
.alpha(1f)
|
||||
|
||||
toolbarBackground
|
||||
.animate()
|
||||
.setDuration(duration)
|
||||
.alpha(1f)
|
||||
}
|
||||
|
||||
override fun hide(duration: Long) {
|
||||
super.hide(duration)
|
||||
|
||||
toolbarAvatar
|
||||
.animate()
|
||||
.setDuration(duration)
|
||||
.translationY(ViewUtil.dpToPx(56).toFloat())
|
||||
.alpha(0f)
|
||||
|
||||
toolbarTitle
|
||||
.animate()
|
||||
.setDuration(duration)
|
||||
.translationY(ViewUtil.dpToPx(56).toFloat())
|
||||
.alpha(0f)
|
||||
|
||||
toolbarBackground
|
||||
.animate()
|
||||
.setDuration(duration)
|
||||
.alpha(0f)
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onContentWillRender()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.LiveData
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember
|
||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase
|
||||
import org.thoughtcrime.securesms.database.MediaDatabase
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupManager
|
||||
import org.thoughtcrime.securesms.groups.GroupProtoUtil
|
||||
import org.thoughtcrime.securesms.groups.LiveGroup
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
import org.whispersystems.libsignal.util.guava.Preconditions
|
||||
import java.io.IOException
|
||||
|
||||
private val TAG = Log.tag(ConversationSettingsRepository::class.java)
|
||||
|
||||
class ConversationSettingsRepository(
|
||||
private val context: Context
|
||||
) {
|
||||
|
||||
@WorkerThread
|
||||
fun getThreadMedia(threadId: Long): Optional<Cursor> {
|
||||
return if (threadId <= 0) {
|
||||
Optional.absent()
|
||||
} else {
|
||||
Optional.of(DatabaseFactory.getMediaDatabase(context).getGalleryMediaForThread(threadId, MediaDatabase.Sorting.Newest))
|
||||
}
|
||||
}
|
||||
|
||||
fun getThreadId(recipientId: RecipientId, consumer: (Long) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
consumer(DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(recipientId))
|
||||
}
|
||||
}
|
||||
|
||||
fun getThreadId(groupId: GroupId, consumer: (Long) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val recipientId = Recipient.externalGroupExact(context, groupId).id
|
||||
consumer(DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(recipientId))
|
||||
}
|
||||
}
|
||||
|
||||
fun isInternalRecipientDetailsEnabled(): Boolean = SignalStore.internalValues().recipientDetails()
|
||||
|
||||
fun hasGroups(consumer: (Boolean) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute { consumer(DatabaseFactory.getGroupDatabase(context).activeGroupCount > 0) }
|
||||
}
|
||||
|
||||
fun getIdentity(recipientId: RecipientId, consumer: (IdentityDatabase.IdentityRecord?) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
consumer(
|
||||
DatabaseFactory.getIdentityDatabase(context)
|
||||
.getIdentity(recipientId)
|
||||
.orNull()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getGroupsInCommon(recipientId: RecipientId, consumer: (List<Recipient>) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
consumer(
|
||||
DatabaseFactory
|
||||
.getGroupDatabase(context)
|
||||
.getPushGroupsContainingMember(recipientId)
|
||||
.asSequence()
|
||||
.filter { it.members.contains(Recipient.self().id) }
|
||||
.map(GroupDatabase.GroupRecord::getRecipientId)
|
||||
.map(Recipient::resolved)
|
||||
.sortedBy { gr -> gr.getDisplayName(context) }
|
||||
.toList()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getGroupMembership(recipientId: RecipientId, consumer: (List<RecipientId>) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val groupDatabase = DatabaseFactory.getGroupDatabase(context)
|
||||
val groupRecords = groupDatabase.getPushGroupsContainingMember(recipientId)
|
||||
val groupRecipients = ArrayList<RecipientId>(groupRecords.size)
|
||||
for (groupRecord in groupRecords) {
|
||||
groupRecipients.add(groupRecord.recipientId)
|
||||
}
|
||||
consumer(groupRecipients)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshRecipient(recipientId: RecipientId) {
|
||||
SignalExecutors.UNBOUNDED.execute {
|
||||
try {
|
||||
DirectoryHelper.refreshDirectoryFor(context, Recipient.resolved(recipientId), false)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to refresh user after adding to contacts.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setMuteUntil(recipientId: RecipientId, until: Long) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
DatabaseFactory.getRecipientDatabase(context).setMuted(recipientId, until)
|
||||
}
|
||||
}
|
||||
|
||||
fun getGroupCapacity(groupId: GroupId, consumer: (GroupCapacityResult) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val groupRecord: GroupDatabase.GroupRecord = DatabaseFactory.getGroupDatabase(context).getGroup(groupId).get()
|
||||
consumer(
|
||||
if (groupRecord.isV2Group) {
|
||||
val decryptedGroup: DecryptedGroup = groupRecord.requireV2GroupProperties().decryptedGroup
|
||||
val pendingMembers: List<RecipientId> = decryptedGroup.pendingMembersList
|
||||
.map(DecryptedPendingMember::getUuid)
|
||||
.map(GroupProtoUtil::uuidByteStringToRecipientId)
|
||||
|
||||
val members = mutableListOf<RecipientId>()
|
||||
|
||||
members.addAll(groupRecord.members)
|
||||
members.addAll(pendingMembers)
|
||||
|
||||
GroupCapacityResult(Recipient.self().id, members, FeatureFlags.groupLimits())
|
||||
} else {
|
||||
GroupCapacityResult(Recipient.self().id, groupRecord.members, FeatureFlags.groupLimits())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun addMembers(groupId: GroupId, selected: List<RecipientId>, consumer: (GroupAddMembersResult) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
consumer(
|
||||
try {
|
||||
val groupActionResult = GroupManager.addMembers(context, groupId.requirePush(), selected)
|
||||
GroupAddMembersResult.Success(groupActionResult.addedMemberCount, Recipient.resolvedList(groupActionResult.invitedMembers))
|
||||
} catch (e: Exception) {
|
||||
GroupAddMembersResult.Failure(GroupChangeFailureReason.fromException(e))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setMuteUntil(groupId: GroupId, until: Long) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val recipientId = Recipient.externalGroupExact(context, groupId).id
|
||||
DatabaseFactory.getRecipientDatabase(context).setMuted(recipientId, until)
|
||||
}
|
||||
}
|
||||
|
||||
fun block(recipientId: RecipientId) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
RecipientUtil.blockNonGroup(context, recipient)
|
||||
}
|
||||
}
|
||||
|
||||
fun unblock(recipientId: RecipientId) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
RecipientUtil.unblock(context, recipient)
|
||||
}
|
||||
}
|
||||
|
||||
fun block(groupId: GroupId) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val recipient = Recipient.externalGroupExact(context, groupId)
|
||||
RecipientUtil.block(context, recipient)
|
||||
}
|
||||
}
|
||||
|
||||
fun unblock(groupId: GroupId) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val recipient = Recipient.externalGroupExact(context, groupId)
|
||||
RecipientUtil.unblock(context, recipient)
|
||||
}
|
||||
}
|
||||
|
||||
fun disableProfileSharingForInternalUser(recipientId: RecipientId) {
|
||||
Preconditions.checkArgument(FeatureFlags.internalUser(), "Internal users only!")
|
||||
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(recipientId, false)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSessionForInternalUser(recipientId: RecipientId) {
|
||||
Preconditions.checkArgument(FeatureFlags.internalUser(), "Internal users only!")
|
||||
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipientId)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun isMessageRequestAccepted(recipient: Recipient): Boolean {
|
||||
return RecipientUtil.isMessageRequestAccepted(context, recipient)
|
||||
}
|
||||
|
||||
fun getMembershipCountDescription(liveGroup: LiveGroup): LiveData<String> {
|
||||
return liveGroup.getMembershipCountDescription(context.resources)
|
||||
}
|
||||
|
||||
fun getExternalPossiblyMigratedGroupRecipientId(groupId: GroupId, consumer: (RecipientId) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
consumer(Recipient.externalPossiblyMigratedGroup(context, groupId).id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import android.database.Cursor
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
data class ConversationSettingsState(
|
||||
val threadId: Long = -1,
|
||||
val recipient: Recipient = Recipient.UNKNOWN,
|
||||
val buttonStripState: ButtonStripPreference.State = ButtonStripPreference.State(),
|
||||
val disappearingMessagesLifespan: Int = 0,
|
||||
val canModifyBlockedState: Boolean = false,
|
||||
val sharedMedia: Cursor? = null,
|
||||
val sharedMediaIds: List<Long> = listOf(),
|
||||
private val sharedMediaLoaded: Boolean = false,
|
||||
private val specificSettingsState: SpecificSettingsState,
|
||||
) {
|
||||
|
||||
val isLoaded: Boolean = recipient != Recipient.UNKNOWN && sharedMediaLoaded && specificSettingsState.isLoaded
|
||||
|
||||
fun withRecipientSettingsState(consumer: (SpecificSettingsState.RecipientSettingsState) -> Unit) {
|
||||
if (specificSettingsState is SpecificSettingsState.RecipientSettingsState) {
|
||||
consumer(specificSettingsState)
|
||||
}
|
||||
}
|
||||
|
||||
fun withGroupSettingsState(consumer: (SpecificSettingsState.GroupSettingsState) -> Unit) {
|
||||
if (specificSettingsState is SpecificSettingsState.GroupSettingsState) {
|
||||
consumer(specificSettingsState)
|
||||
}
|
||||
}
|
||||
|
||||
fun requireRecipientSettingsState(): SpecificSettingsState.RecipientSettingsState = specificSettingsState.requireRecipientSettingsState()
|
||||
fun requireGroupSettingsState(): SpecificSettingsState.GroupSettingsState = specificSettingsState.requireGroupSettingsState()
|
||||
}
|
||||
|
||||
sealed class SpecificSettingsState {
|
||||
|
||||
abstract val isLoaded: Boolean
|
||||
|
||||
data class RecipientSettingsState(
|
||||
val identityRecord: IdentityDatabase.IdentityRecord? = null,
|
||||
val allGroupsInCommon: List<Recipient> = listOf(),
|
||||
val groupsInCommon: List<Recipient> = listOf(),
|
||||
val selfHasGroups: Boolean = false,
|
||||
val canShowMoreGroupsInCommon: Boolean = false,
|
||||
val groupsInCommonExpanded: Boolean = false,
|
||||
val contactLinkState: ContactLinkState = ContactLinkState.NONE,
|
||||
val displayInternalRecipientDetails: Boolean
|
||||
) : SpecificSettingsState() {
|
||||
|
||||
override val isLoaded: Boolean = true
|
||||
|
||||
override fun requireRecipientSettingsState() = this
|
||||
}
|
||||
|
||||
data class GroupSettingsState(
|
||||
val groupId: GroupId,
|
||||
val allMembers: List<GroupMemberEntry.FullMember> = listOf(),
|
||||
val members: List<GroupMemberEntry.FullMember> = listOf(),
|
||||
val isSelfAdmin: Boolean = false,
|
||||
val canAddToGroup: Boolean = false,
|
||||
val canEditGroupAttributes: Boolean = false,
|
||||
val canLeave: Boolean = false,
|
||||
val canShowMoreGroupMembers: Boolean = false,
|
||||
val groupMembersExpanded: Boolean = false,
|
||||
val groupTitle: String = "",
|
||||
private val groupTitleLoaded: Boolean = false,
|
||||
val groupDescription: String? = null,
|
||||
val groupDescriptionShouldLinkify: Boolean = false,
|
||||
private val groupDescriptionLoaded: Boolean = false,
|
||||
val groupLinkEnabled: Boolean = false,
|
||||
val membershipCountDescription: String = "",
|
||||
val legacyGroupState: LegacyGroupPreference.State = LegacyGroupPreference.State.NONE
|
||||
) : SpecificSettingsState() {
|
||||
|
||||
override val isLoaded: Boolean = groupTitleLoaded && groupDescriptionLoaded
|
||||
|
||||
override fun requireGroupSettingsState(): GroupSettingsState = this
|
||||
}
|
||||
|
||||
open fun requireRecipientSettingsState(): RecipientSettingsState = error("Not a recipient settings state")
|
||||
open fun requireGroupSettingsState(): GroupSettingsState = error("Not a group settings state")
|
||||
}
|
||||
|
||||
enum class ContactLinkState {
|
||||
OPEN,
|
||||
ADD,
|
||||
NONE
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import android.database.Cursor
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.LiveGroup
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil
|
||||
import org.thoughtcrime.securesms.util.CursorUtil
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
|
||||
sealed class ConversationSettingsViewModel(
|
||||
private val repository: ConversationSettingsRepository,
|
||||
specificSettingsState: SpecificSettingsState,
|
||||
) : ViewModel() {
|
||||
|
||||
private val openedMediaCursors = HashSet<Cursor>()
|
||||
|
||||
@Volatile
|
||||
private var cleared = false
|
||||
|
||||
protected val store = Store(
|
||||
ConversationSettingsState(
|
||||
specificSettingsState = specificSettingsState
|
||||
)
|
||||
)
|
||||
protected val internalEvents = SingleLiveEvent<ConversationSettingsEvent>()
|
||||
|
||||
private val sharedMediaUpdateTrigger = MutableLiveData(Unit)
|
||||
|
||||
val state: LiveData<ConversationSettingsState> = store.stateLiveData
|
||||
val events: LiveData<ConversationSettingsEvent> = internalEvents
|
||||
|
||||
init {
|
||||
val threadId: LiveData<Long> = Transformations.distinctUntilChanged(Transformations.map(state) { it.threadId })
|
||||
val updater: LiveData<Long> = LiveDataUtil.combineLatest(threadId, sharedMediaUpdateTrigger) { tId, _ -> tId }
|
||||
|
||||
val sharedMedia: LiveData<Optional<Cursor>> = LiveDataUtil.mapAsync(SignalExecutors.BOUNDED, updater) { tId ->
|
||||
repository.getThreadMedia(tId)
|
||||
}
|
||||
|
||||
store.update(sharedMedia) { cursor, state ->
|
||||
if (!cleared) {
|
||||
if (cursor.isPresent) {
|
||||
openedMediaCursors.add(cursor.get())
|
||||
}
|
||||
|
||||
val ids: List<Long> = cursor.transform<List<Long>> {
|
||||
val result = mutableListOf<Long>()
|
||||
while (it.moveToNext()) {
|
||||
result.add(CursorUtil.requireLong(it, AttachmentDatabase.ROW_ID))
|
||||
}
|
||||
result
|
||||
}.or(listOf())
|
||||
|
||||
state.copy(
|
||||
sharedMedia = cursor.orNull(),
|
||||
sharedMediaIds = ids,
|
||||
sharedMediaLoaded = true
|
||||
)
|
||||
} else {
|
||||
cursor.orNull().ensureClosed()
|
||||
state.copy(sharedMedia = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshSharedMedia() {
|
||||
sharedMediaUpdateTrigger.postValue(Unit)
|
||||
}
|
||||
|
||||
open fun refreshRecipient(): Unit = error("This ViewModel does not support this interaction")
|
||||
|
||||
abstract fun setMuteUntil(muteUntil: Long)
|
||||
|
||||
abstract fun unmute()
|
||||
|
||||
abstract fun block()
|
||||
|
||||
abstract fun unblock()
|
||||
|
||||
abstract fun onAddToGroup()
|
||||
|
||||
abstract fun onAddToGroupComplete(selected: List<RecipientId>, onComplete: () -> Unit)
|
||||
|
||||
abstract fun revealAllMembers()
|
||||
|
||||
override fun onCleared() {
|
||||
cleared = true
|
||||
store.update { state ->
|
||||
openedMediaCursors.forEach { it.ensureClosed() }
|
||||
state.copy(sharedMedia = null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Cursor?.ensureClosed() {
|
||||
if (this != null && !this.isClosed) {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
|
||||
open fun disableProfileSharing(): Unit = error("This ViewModel does not support this interaction")
|
||||
|
||||
open fun deleteSession(): Unit = error("This ViewModel does not support this interaction")
|
||||
|
||||
open fun initiateGroupUpgrade(): Unit = error("This ViewModel does not support this interaction")
|
||||
|
||||
private class RecipientSettingsViewModel(
|
||||
private val recipientId: RecipientId,
|
||||
private val repository: ConversationSettingsRepository
|
||||
) : ConversationSettingsViewModel(
|
||||
repository,
|
||||
SpecificSettingsState.RecipientSettingsState(
|
||||
displayInternalRecipientDetails = repository.isInternalRecipientDetailsEnabled()
|
||||
)
|
||||
) {
|
||||
|
||||
private val liveRecipient = Recipient.live(recipientId)
|
||||
|
||||
init {
|
||||
store.update(liveRecipient.liveData) { recipient, state ->
|
||||
state.copy(
|
||||
recipient = recipient,
|
||||
buttonStripState = ButtonStripPreference.State(
|
||||
isVideoAvailable = recipient.registered == RecipientDatabase.RegisteredState.REGISTERED && !recipient.isSelf,
|
||||
isAudioAvailable = !recipient.isGroup && !recipient.isSelf,
|
||||
isAudioSecure = recipient.registered == RecipientDatabase.RegisteredState.REGISTERED,
|
||||
isMuted = recipient.isMuted,
|
||||
isMuteAvailable = !recipient.isSelf,
|
||||
isSearchAvailable = true
|
||||
),
|
||||
disappearingMessagesLifespan = recipient.expireMessages,
|
||||
canModifyBlockedState = !recipient.isSelf,
|
||||
specificSettingsState = state.requireRecipientSettingsState().copy(
|
||||
contactLinkState = when {
|
||||
recipient.isSelf -> ContactLinkState.NONE
|
||||
recipient.isSystemContact -> ContactLinkState.OPEN
|
||||
else -> ContactLinkState.ADD
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
repository.getThreadId(recipientId) { threadId ->
|
||||
store.update { state ->
|
||||
state.copy(threadId = threadId)
|
||||
}
|
||||
}
|
||||
|
||||
if (recipientId != Recipient.self().id) {
|
||||
repository.getGroupsInCommon(recipientId) { groupsInCommon ->
|
||||
store.update { state ->
|
||||
val recipientSettings = state.requireRecipientSettingsState()
|
||||
val canShowMore = !recipientSettings.groupsInCommonExpanded && groupsInCommon.size > 6
|
||||
|
||||
state.copy(
|
||||
specificSettingsState = recipientSettings.copy(
|
||||
allGroupsInCommon = groupsInCommon,
|
||||
groupsInCommon = if (!canShowMore) groupsInCommon else groupsInCommon.take(5),
|
||||
canShowMoreGroupsInCommon = canShowMore
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
repository.hasGroups { hasGroups ->
|
||||
store.update { state ->
|
||||
val recipientSettings = state.requireRecipientSettingsState()
|
||||
state.copy(
|
||||
specificSettingsState = recipientSettings.copy(
|
||||
selfHasGroups = hasGroups
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
repository.getIdentity(recipientId) { identityRecord ->
|
||||
store.update { state ->
|
||||
state.copy(specificSettingsState = state.requireRecipientSettingsState().copy(identityRecord = identityRecord))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAddToGroup() {
|
||||
repository.getGroupMembership(recipientId) {
|
||||
internalEvents.postValue(ConversationSettingsEvent.AddToAGroup(recipientId, it))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAddToGroupComplete(selected: List<RecipientId>, onComplete: () -> Unit) {
|
||||
}
|
||||
|
||||
override fun revealAllMembers() {
|
||||
store.update { state ->
|
||||
state.copy(
|
||||
specificSettingsState = state.requireRecipientSettingsState().copy(
|
||||
groupsInCommon = state.requireRecipientSettingsState().allGroupsInCommon,
|
||||
groupsInCommonExpanded = true,
|
||||
canShowMoreGroupsInCommon = false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun refreshRecipient() {
|
||||
repository.refreshRecipient(recipientId)
|
||||
}
|
||||
|
||||
override fun setMuteUntil(muteUntil: Long) {
|
||||
repository.setMuteUntil(recipientId, muteUntil)
|
||||
}
|
||||
|
||||
override fun unmute() {
|
||||
repository.setMuteUntil(recipientId, 0)
|
||||
}
|
||||
|
||||
override fun block() {
|
||||
repository.block(recipientId)
|
||||
}
|
||||
|
||||
override fun unblock() {
|
||||
repository.unblock(recipientId)
|
||||
}
|
||||
|
||||
override fun disableProfileSharing() {
|
||||
repository.disableProfileSharingForInternalUser(recipientId)
|
||||
}
|
||||
|
||||
override fun deleteSession() {
|
||||
repository.deleteSessionForInternalUser(recipientId)
|
||||
}
|
||||
}
|
||||
|
||||
private class GroupSettingsViewModel(
|
||||
private val groupId: GroupId,
|
||||
private val repository: ConversationSettingsRepository
|
||||
) : ConversationSettingsViewModel(repository, SpecificSettingsState.GroupSettingsState(groupId)) {
|
||||
|
||||
private val liveGroup = LiveGroup(groupId)
|
||||
|
||||
init {
|
||||
store.update(liveGroup.groupRecipient) { recipient, state ->
|
||||
state.copy(
|
||||
recipient = recipient,
|
||||
buttonStripState = ButtonStripPreference.State(
|
||||
isVideoAvailable = recipient.isPushV2Group,
|
||||
isAudioAvailable = false,
|
||||
isAudioSecure = recipient.isPushV2Group,
|
||||
isMuted = recipient.isMuted,
|
||||
isMuteAvailable = true,
|
||||
isSearchAvailable = true
|
||||
),
|
||||
canModifyBlockedState = RecipientUtil.isBlockable(recipient),
|
||||
specificSettingsState = state.requireGroupSettingsState().copy(
|
||||
legacyGroupState = getLegacyGroupState(recipient)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
repository.getThreadId(groupId) { threadId ->
|
||||
store.update { state ->
|
||||
state.copy(threadId = threadId)
|
||||
}
|
||||
}
|
||||
|
||||
store.update(liveGroup.selfCanEditGroupAttributes()) { selfCanEditGroupAttributes, state ->
|
||||
state.copy(
|
||||
specificSettingsState = state.requireGroupSettingsState().copy(
|
||||
canEditGroupAttributes = selfCanEditGroupAttributes
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
store.update(liveGroup.isSelfAdmin) { isSelfAdmin, state ->
|
||||
state.copy(
|
||||
specificSettingsState = state.requireGroupSettingsState().copy(
|
||||
isSelfAdmin = isSelfAdmin
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
store.update(liveGroup.expireMessages) { expireMessages, state ->
|
||||
state.copy(
|
||||
disappearingMessagesLifespan = expireMessages
|
||||
)
|
||||
}
|
||||
|
||||
store.update(liveGroup.selfCanAddMembers()) { canAddMembers, state ->
|
||||
state.copy(
|
||||
specificSettingsState = state.requireGroupSettingsState().copy(
|
||||
canAddToGroup = canAddMembers
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
store.update(liveGroup.fullMembers) { fullMembers, state ->
|
||||
val groupState = state.requireGroupSettingsState()
|
||||
val canShowMore = !groupState.groupMembersExpanded && fullMembers.size > 6
|
||||
|
||||
state.copy(
|
||||
specificSettingsState = groupState.copy(
|
||||
allMembers = fullMembers,
|
||||
members = if (!canShowMore) fullMembers else fullMembers.take(5),
|
||||
canShowMoreGroupMembers = canShowMore
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val isMessageRequestAccepted: LiveData<Boolean> = LiveDataUtil.mapAsync(liveGroup.groupRecipient) { r -> repository.isMessageRequestAccepted(r) }
|
||||
val descriptionState: LiveData<DescriptionState> = LiveDataUtil.combineLatest(liveGroup.description, isMessageRequestAccepted, ::DescriptionState)
|
||||
|
||||
store.update(descriptionState) { d, state ->
|
||||
state.copy(
|
||||
specificSettingsState = state.requireGroupSettingsState().copy(
|
||||
groupDescription = d.description,
|
||||
groupDescriptionShouldLinkify = d.canLinkify,
|
||||
groupDescriptionLoaded = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
store.update(liveGroup.isActive) { isActive, state ->
|
||||
state.copy(
|
||||
specificSettingsState = state.requireGroupSettingsState().copy(
|
||||
canLeave = isActive && groupId.isPush
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
store.update(liveGroup.title) { title, state ->
|
||||
state.copy(
|
||||
specificSettingsState = state.requireGroupSettingsState().copy(
|
||||
groupTitle = title,
|
||||
groupTitleLoaded = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
store.update(liveGroup.groupLink) { groupLink, state ->
|
||||
state.copy(
|
||||
specificSettingsState = state.requireGroupSettingsState().copy(
|
||||
groupLinkEnabled = groupLink.isEnabled
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
store.update(repository.getMembershipCountDescription(liveGroup)) { description, state ->
|
||||
state.copy(
|
||||
specificSettingsState = state.requireGroupSettingsState().copy(
|
||||
membershipCountDescription = description
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLegacyGroupState(recipient: Recipient): LegacyGroupPreference.State {
|
||||
val showLegacyInfo = recipient.requireGroupId().isV1
|
||||
|
||||
return if (showLegacyInfo && recipient.participants.size > FeatureFlags.groupLimits().hardLimit) {
|
||||
LegacyGroupPreference.State.TOO_LARGE
|
||||
} else if (showLegacyInfo) {
|
||||
LegacyGroupPreference.State.UPGRADE
|
||||
} else if (groupId.isMms) {
|
||||
LegacyGroupPreference.State.MMS_WARNING
|
||||
} else {
|
||||
LegacyGroupPreference.State.NONE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAddToGroup() {
|
||||
repository.getGroupCapacity(groupId) { capacityResult ->
|
||||
if (capacityResult.getRemainingCapacity() > 0) {
|
||||
internalEvents.postValue(
|
||||
ConversationSettingsEvent.AddMembersToGroup(
|
||||
groupId,
|
||||
capacityResult.getSelectionWarning(),
|
||||
capacityResult.getSelectionLimit(),
|
||||
capacityResult.getMembersWithoutSelf()
|
||||
)
|
||||
)
|
||||
} else {
|
||||
internalEvents.postValue(ConversationSettingsEvent.ShowGroupHardLimitDialog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAddToGroupComplete(selected: List<RecipientId>, onComplete: () -> Unit) {
|
||||
repository.addMembers(groupId, selected) {
|
||||
ThreadUtil.runOnMain { onComplete() }
|
||||
|
||||
when (it) {
|
||||
is GroupAddMembersResult.Success -> {
|
||||
if (it.newMembersInvited.isNotEmpty()) {
|
||||
internalEvents.postValue(ConversationSettingsEvent.ShowGroupInvitesSentDialog(it.newMembersInvited))
|
||||
}
|
||||
|
||||
if (it.numberOfMembersAdded > 0) {
|
||||
internalEvents.postValue(ConversationSettingsEvent.ShowMembersAdded(it.numberOfMembersAdded))
|
||||
}
|
||||
}
|
||||
is GroupAddMembersResult.Failure -> internalEvents.postValue(ConversationSettingsEvent.ShowAddMembersToGroupError(it.reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun revealAllMembers() {
|
||||
store.update { state ->
|
||||
state.copy(
|
||||
specificSettingsState = state.requireGroupSettingsState().copy(
|
||||
members = state.requireGroupSettingsState().allMembers,
|
||||
groupMembersExpanded = true,
|
||||
canShowMoreGroupMembers = false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun setMuteUntil(muteUntil: Long) {
|
||||
repository.setMuteUntil(groupId, muteUntil)
|
||||
}
|
||||
|
||||
override fun unmute() {
|
||||
repository.setMuteUntil(groupId, 0)
|
||||
}
|
||||
|
||||
override fun block() {
|
||||
repository.block(groupId)
|
||||
}
|
||||
|
||||
override fun unblock() {
|
||||
repository.unblock(groupId)
|
||||
}
|
||||
|
||||
override fun initiateGroupUpgrade() {
|
||||
repository.getExternalPossiblyMigratedGroupRecipientId(groupId) {
|
||||
internalEvents.postValue(ConversationSettingsEvent.InitiateGroupMigration(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val recipientId: RecipientId? = null,
|
||||
private val groupId: GroupId? = null,
|
||||
private val repository: ConversationSettingsRepository,
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(
|
||||
modelClass.cast(
|
||||
when {
|
||||
recipientId != null -> RecipientSettingsViewModel(recipientId, repository)
|
||||
groupId != null -> GroupSettingsViewModel(groupId, repository)
|
||||
else -> error("One of RecipientId or GroupId required.")
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class DescriptionState(
|
||||
val description: String?,
|
||||
val canLinkify: Boolean
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
sealed class GroupAddMembersResult {
|
||||
class Success(
|
||||
val numberOfMembersAdded: Int,
|
||||
val newMembersInvited: List<Recipient>
|
||||
) : GroupAddMembersResult()
|
||||
|
||||
class Failure(
|
||||
val reason: GroupChangeFailureReason
|
||||
) : GroupAddMembersResult()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import org.thoughtcrime.securesms.ContactSelectionListFragment
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
class GroupCapacityResult(
|
||||
private val selfId: RecipientId,
|
||||
private val members: List<RecipientId>,
|
||||
private val selectionLimits: SelectionLimits
|
||||
) {
|
||||
fun getMembers(): List<RecipientId?> {
|
||||
return members
|
||||
}
|
||||
|
||||
fun getSelectionLimit(): Int {
|
||||
if (!selectionLimits.hasHardLimit()) {
|
||||
return ContactSelectionListFragment.NO_LIMIT
|
||||
}
|
||||
val containsSelf = members.indexOf(selfId) != -1
|
||||
return selectionLimits.hardLimit - if (containsSelf) 1 else 0
|
||||
}
|
||||
|
||||
fun getSelectionWarning(): Int {
|
||||
if (!selectionLimits.hasRecommendedLimit()) {
|
||||
return ContactSelectionListFragment.NO_LIMIT
|
||||
}
|
||||
|
||||
val containsSelf = members.indexOf(selfId) != -1
|
||||
return selectionLimits.recommendedLimit - if (containsSelf) 1 else 0
|
||||
}
|
||||
|
||||
fun getRemainingCapacity(): Int {
|
||||
return selectionLimits.hardLimit - members.size
|
||||
}
|
||||
|
||||
fun getMembersWithoutSelf(): List<RecipientId> {
|
||||
val recipientIds = ArrayList<RecipientId>(members.size)
|
||||
for (recipientId in members) {
|
||||
if (recipientId != selfId) {
|
||||
recipientIds.add(recipientId)
|
||||
}
|
||||
}
|
||||
return recipientIds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.permissions
|
||||
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
|
||||
|
||||
sealed class PermissionsSettingsEvents {
|
||||
class GroupChangeError(val reason: GroupChangeFailureReason) : PermissionsSettingsEvents()
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.permissions
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.fragment.app.viewModels
|
||||
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.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.groups.ParcelableGroupId
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupErrors
|
||||
|
||||
class PermissionsSettingsFragment : DSLSettingsFragment(
|
||||
titleId = R.string.ConversationSettingsFragment__permissions
|
||||
) {
|
||||
|
||||
private val permissionsOptions: Array<String> by lazy {
|
||||
resources.getStringArray(R.array.PermissionsSettingsFragment__editor_labels)
|
||||
}
|
||||
|
||||
private val viewModel: PermissionsSettingsViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
val args = PermissionsSettingsFragmentArgs.fromBundle(requireArguments())
|
||||
val groupId = requireNotNull(ParcelableGroupId.get(args.groupId as ParcelableGroupId))
|
||||
val repository = PermissionsSettingsRepository(requireContext())
|
||||
|
||||
PermissionsSettingsViewModel.Factory(groupId, repository)
|
||||
}
|
||||
)
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
|
||||
viewModel.events.observe(viewLifecycleOwner) { event ->
|
||||
when (event) {
|
||||
is PermissionsSettingsEvents.GroupChangeError -> handleGroupChangeError(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGroupChangeError(groupChangeError: PermissionsSettingsEvents.GroupChangeError) {
|
||||
Toast.makeText(context, GroupErrors.getUserDisplayMessage(groupChangeError.reason), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: PermissionsSettingsState): DSLConfiguration {
|
||||
return configure {
|
||||
|
||||
radioListPref(
|
||||
title = DSLSettingsText.from(R.string.PermissionsSettingsFragment__add_members),
|
||||
isEnabled = state.selfCanEditSettings,
|
||||
listItems = permissionsOptions,
|
||||
dialogTitle = DSLSettingsText.from(R.string.PermissionsSettingsFragment__who_can_add_new_members),
|
||||
selected = getSelected(state.nonAdminCanAddMembers),
|
||||
confirmAction = true,
|
||||
onSelected = {
|
||||
viewModel.setNonAdminCanAddMembers(it == 1)
|
||||
}
|
||||
)
|
||||
|
||||
radioListPref(
|
||||
title = DSLSettingsText.from(R.string.PermissionsSettingsFragment__edit_group_info),
|
||||
isEnabled = state.selfCanEditSettings,
|
||||
listItems = permissionsOptions,
|
||||
dialogTitle = DSLSettingsText.from(R.string.PermissionsSettingsFragment__who_can_edit_this_groups_info),
|
||||
selected = getSelected(state.nonAdminCanEditGroupInfo),
|
||||
confirmAction = true,
|
||||
onSelected = {
|
||||
viewModel.setNonAdminCanEditGroupInfo(it == 1)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@StringRes
|
||||
private fun getSelected(isNonAdminAllowed: Boolean): Int {
|
||||
return if (isNonAdminAllowed) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.permissions
|
||||
|
||||
import android.content.Context
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.groups.GroupAccessControl
|
||||
import org.thoughtcrime.securesms.groups.GroupChangeException
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupManager
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupChangeErrorCallback
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
|
||||
import java.io.IOException
|
||||
|
||||
private val TAG = Log.tag(PermissionsSettingsRepository::class.java)
|
||||
|
||||
class PermissionsSettingsRepository(private val context: Context) {
|
||||
|
||||
fun applyMembershipRightsChange(groupId: GroupId, newRights: GroupAccessControl, error: GroupChangeErrorCallback) {
|
||||
SignalExecutors.UNBOUNDED.execute {
|
||||
try {
|
||||
GroupManager.applyMembershipAdditionRightsChange(context, groupId.requireV2(), newRights)
|
||||
} catch (e: GroupChangeException) {
|
||||
Log.w(TAG, e)
|
||||
error.onError(GroupChangeFailureReason.fromException(e))
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
error.onError(GroupChangeFailureReason.fromException(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun applyAttributesRightsChange(groupId: GroupId, newRights: GroupAccessControl, error: GroupChangeErrorCallback) {
|
||||
SignalExecutors.UNBOUNDED.execute {
|
||||
try {
|
||||
GroupManager.applyAttributesRightsChange(context, groupId.requireV2(), newRights)
|
||||
} catch (e: GroupChangeException) {
|
||||
Log.w(TAG, e)
|
||||
error.onError(GroupChangeFailureReason.fromException(e))
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
error.onError(GroupChangeFailureReason.fromException(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.permissions
|
||||
|
||||
data class PermissionsSettingsState(
|
||||
val selfCanEditSettings: Boolean = false,
|
||||
val nonAdminCanAddMembers: Boolean = false,
|
||||
val nonAdminCanEditGroupInfo: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.permissions
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.groups.GroupAccessControl
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.LiveGroup
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class PermissionsSettingsViewModel(
|
||||
private val groupId: GroupId,
|
||||
private val repository: PermissionsSettingsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store(PermissionsSettingsState())
|
||||
private val liveGroup = LiveGroup(groupId)
|
||||
private val internalEvents = SingleLiveEvent<PermissionsSettingsEvents>()
|
||||
|
||||
val state: LiveData<PermissionsSettingsState> = store.stateLiveData
|
||||
val events: LiveData<PermissionsSettingsEvents> = internalEvents
|
||||
|
||||
init {
|
||||
store.update(liveGroup.isSelfAdmin) { isSelfAdmin, state ->
|
||||
state.copy(selfCanEditSettings = isSelfAdmin)
|
||||
}
|
||||
|
||||
store.update(liveGroup.membershipAdditionAccessControl) { membershipAdditionAccessControl, state ->
|
||||
state.copy(nonAdminCanAddMembers = membershipAdditionAccessControl == GroupAccessControl.ALL_MEMBERS)
|
||||
}
|
||||
|
||||
store.update(liveGroup.attributesAccessControl) { attributesAccessControl, state ->
|
||||
state.copy(nonAdminCanEditGroupInfo = attributesAccessControl == GroupAccessControl.ALL_MEMBERS)
|
||||
}
|
||||
}
|
||||
|
||||
fun setNonAdminCanAddMembers(nonAdminCanAddMembers: Boolean) {
|
||||
repository.applyMembershipRightsChange(groupId, nonAdminCanAddMembers.asGroupAccessControl()) { reason ->
|
||||
internalEvents.postValue(PermissionsSettingsEvents.GroupChangeError(reason))
|
||||
}
|
||||
}
|
||||
|
||||
fun setNonAdminCanEditGroupInfo(nonAdminCanEditGroupInfo: Boolean) {
|
||||
repository.applyAttributesRightsChange(groupId, nonAdminCanEditGroupInfo.asGroupAccessControl()) { reason ->
|
||||
internalEvents.postValue(PermissionsSettingsEvents.GroupChangeError(reason))
|
||||
}
|
||||
}
|
||||
|
||||
private fun Boolean.asGroupAccessControl(): GroupAccessControl {
|
||||
return if (this) {
|
||||
GroupAccessControl.ALL_MEMBERS
|
||||
} else {
|
||||
GroupAccessControl.ONLY_ADMINS
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val groupId: GroupId,
|
||||
private val repository: PermissionsSettingsRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(PermissionsSettingsViewModel(groupId, repository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.view.ViewCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
/**
|
||||
* Renders a large avatar (80dp) for a given Recipient.
|
||||
*/
|
||||
object AvatarPreference {
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.conversation_settings_avatar_preference_item))
|
||||
}
|
||||
|
||||
class Model(
|
||||
val recipient: Recipient,
|
||||
val onAvatarClick: (View) -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return recipient == newItem.recipient
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient)
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
private val avatar: AvatarImageView = itemView.findViewById<AvatarImageView>(R.id.bio_preference_avatar).apply {
|
||||
ViewCompat.setTransitionName(this, "avatar")
|
||||
}
|
||||
|
||||
override fun bind(model: Model) {
|
||||
avatar.setAvatar(model.recipient)
|
||||
avatar.disableQuickContact()
|
||||
avatar.setOnClickListener { model.onAvatarClick(avatar) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
|
||||
/**
|
||||
* Renders name, description, about, etc. for a given group or recipient.
|
||||
*/
|
||||
object BioTextPreference {
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(RecipientModel::class.java, MappingAdapter.LayoutFactory(::RecipientViewHolder, R.layout.conversation_settings_bio_preference_item))
|
||||
adapter.registerFactory(GroupModel::class.java, MappingAdapter.LayoutFactory(::GroupViewHolder, R.layout.conversation_settings_bio_preference_item))
|
||||
}
|
||||
|
||||
abstract class BioTextPreferenceModel<T : BioTextPreferenceModel<T>> : PreferenceModel<T>() {
|
||||
abstract fun getHeadlineText(context: Context): String
|
||||
abstract fun getSubhead1Text(): String?
|
||||
abstract fun getSubhead2Text(): String?
|
||||
}
|
||||
|
||||
class RecipientModel(
|
||||
private val recipient: Recipient,
|
||||
) : BioTextPreferenceModel<RecipientModel>() {
|
||||
|
||||
override fun getHeadlineText(context: Context): String = recipient.getDisplayNameOrUsername(context)
|
||||
|
||||
override fun getSubhead1Text(): String? = recipient.combinedAboutAndEmoji
|
||||
|
||||
override fun getSubhead2Text(): String? = recipient.e164.transform(PhoneNumberFormatter::prettyPrint).orNull()
|
||||
|
||||
override fun areContentsTheSame(newItem: RecipientModel): Boolean {
|
||||
return super.areContentsTheSame(newItem) && newItem.recipient.hasSameContent(recipient)
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(newItem: RecipientModel): Boolean {
|
||||
return newItem.recipient.id == recipient.id
|
||||
}
|
||||
}
|
||||
|
||||
class GroupModel(
|
||||
val groupTitle: String,
|
||||
val groupMembershipDescription: String?
|
||||
) : BioTextPreferenceModel<GroupModel>() {
|
||||
override fun getHeadlineText(context: Context): String = groupTitle
|
||||
|
||||
override fun getSubhead1Text(): String? = groupMembershipDescription
|
||||
|
||||
override fun getSubhead2Text(): String? = null
|
||||
|
||||
override fun areContentsTheSame(newItem: GroupModel): Boolean {
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
groupTitle == newItem.groupTitle &&
|
||||
groupMembershipDescription == newItem.groupMembershipDescription
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(newItem: GroupModel): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private abstract class BioTextViewHolder<T : BioTextPreferenceModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
|
||||
|
||||
private val headline: TextView = itemView.findViewById(R.id.bio_preference_headline)
|
||||
private val subhead1: TextView = itemView.findViewById(R.id.bio_preference_subhead_1)
|
||||
protected val subhead2: TextView = itemView.findViewById(R.id.bio_preference_subhead_2)
|
||||
|
||||
override fun bind(model: T) {
|
||||
headline.text = model.getHeadlineText(context)
|
||||
|
||||
model.getSubhead1Text().let {
|
||||
subhead1.text = it
|
||||
subhead1.visibility = if (it == null) View.GONE else View.VISIBLE
|
||||
}
|
||||
|
||||
model.getSubhead2Text().let {
|
||||
subhead2.text = it
|
||||
subhead2.visibility = if (it == null) View.GONE else View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class RecipientViewHolder(itemView: View) : BioTextViewHolder<RecipientModel>(itemView) {
|
||||
override fun bind(model: RecipientModel) {
|
||||
super.bind(model)
|
||||
|
||||
val phoneNumber = model.getSubhead2Text()
|
||||
if (!phoneNumber.isNullOrEmpty()) {
|
||||
subhead2.setOnLongClickListener {
|
||||
val clipboardManager = ServiceUtil.getClipboardManager(context)
|
||||
clipboardManager.setPrimaryClip(ClipData.newPlainText(context.getString(R.string.ConversationSettingsFragment__phone_number), subhead2.text.toString()))
|
||||
Toast.makeText(context, R.string.ConversationSettingsFragment__copied_phone_number_to_clipboard, Toast.LENGTH_SHORT).show()
|
||||
true
|
||||
}
|
||||
} else {
|
||||
subhead2.setOnLongClickListener(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class GroupViewHolder(itemView: View) : BioTextViewHolder<GroupModel>(itemView)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
* Renders a configurable strip of buttons
|
||||
*/
|
||||
object ButtonStripPreference {
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.conversation_settings_button_strip))
|
||||
}
|
||||
|
||||
class Model(
|
||||
val state: State,
|
||||
val background: DSLSettingsIcon? = null,
|
||||
val onMessageClick: () -> Unit = {},
|
||||
val onVideoClick: () -> Unit = {},
|
||||
val onAudioClick: () -> Unit = {},
|
||||
val onMuteClick: () -> Unit = {},
|
||||
val onSearchClick: () -> Unit = {}
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && state == newItem.state
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val message: View = itemView.findViewById(R.id.message)
|
||||
private val messageLabel: View = itemView.findViewById(R.id.message_label)
|
||||
private val videoCall: View = itemView.findViewById(R.id.start_video)
|
||||
private val videoLabel: View = itemView.findViewById(R.id.start_video_label)
|
||||
private val audioCall: ImageView = itemView.findViewById(R.id.start_audio)
|
||||
private val audioLabel: TextView = itemView.findViewById(R.id.start_audio_label)
|
||||
private val mute: ImageView = itemView.findViewById(R.id.mute)
|
||||
private val muteLabel: TextView = itemView.findViewById(R.id.mute_label)
|
||||
private val search: View = itemView.findViewById(R.id.search)
|
||||
private val searchLabel: View = itemView.findViewById(R.id.search_label)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
message.visible = model.state.isMessageAvailable
|
||||
messageLabel.visible = model.state.isMessageAvailable
|
||||
videoCall.visible = model.state.isVideoAvailable
|
||||
videoLabel.visible = model.state.isVideoAvailable
|
||||
audioCall.visible = model.state.isAudioAvailable
|
||||
audioLabel.visible = model.state.isAudioAvailable
|
||||
mute.visible = model.state.isMuteAvailable
|
||||
muteLabel.visible = model.state.isMuteAvailable
|
||||
search.visible = model.state.isSearchAvailable
|
||||
searchLabel.visible = model.state.isSearchAvailable
|
||||
|
||||
if (model.state.isAudioSecure) {
|
||||
audioLabel.setText(R.string.ConversationSettingsFragment__audio)
|
||||
audioCall.setImageDrawable(AppCompatResources.getDrawable(context, R.drawable.ic_phone_right_24))
|
||||
} else {
|
||||
audioLabel.setText(R.string.ConversationSettingsFragment__call)
|
||||
audioCall.setImageDrawable(AppCompatResources.getDrawable(context, R.drawable.ic_phone_right_unlock_primary_accent_24))
|
||||
}
|
||||
|
||||
if (model.state.isMuted) {
|
||||
mute.setImageDrawable(AppCompatResources.getDrawable(context, R.drawable.ic_bell_disabled_24))
|
||||
muteLabel.setText(R.string.ConversationSettingsFragment__muted)
|
||||
} else {
|
||||
mute.setImageDrawable(AppCompatResources.getDrawable(context, R.drawable.ic_bell_24))
|
||||
muteLabel.setText(R.string.ConversationSettingsFragment__mute)
|
||||
}
|
||||
|
||||
if (model.background != null) {
|
||||
listOf(message, videoCall, audioCall, mute, search).forEach {
|
||||
it.background = model.background.resolve(context)
|
||||
}
|
||||
}
|
||||
|
||||
message.setOnClickListener { model.onMessageClick() }
|
||||
videoCall.setOnClickListener { model.onVideoClick() }
|
||||
audioCall.setOnClickListener { model.onAudioClick() }
|
||||
mute.setOnClickListener { model.onMuteClick() }
|
||||
search.setOnClickListener { model.onSearchClick() }
|
||||
}
|
||||
}
|
||||
|
||||
data class State(
|
||||
val isMessageAvailable: Boolean = false,
|
||||
val isVideoAvailable: Boolean = false,
|
||||
val isAudioAvailable: Boolean = false,
|
||||
val isMuteAvailable: Boolean = false,
|
||||
val isSearchAvailable: Boolean = false,
|
||||
val isAudioSecure: Boolean = false,
|
||||
val isMuted: Boolean = false,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil
|
||||
import org.thoughtcrime.securesms.util.LongClickMovementMethod
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
object GroupDescriptionPreference {
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.conversation_settings_group_description_preference))
|
||||
}
|
||||
|
||||
class Model(
|
||||
private val groupId: GroupId,
|
||||
val groupDescription: String?,
|
||||
val descriptionShouldLinkify: Boolean,
|
||||
val canEditGroupAttributes: Boolean,
|
||||
val onEditGroupDescription: () -> Unit,
|
||||
val onViewGroupDescription: () -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return groupId == newItem.groupId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
groupDescription == newItem.groupDescription &&
|
||||
descriptionShouldLinkify == newItem.descriptionShouldLinkify &&
|
||||
canEditGroupAttributes == newItem.canEditGroupAttributes
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val groupDescriptionTextView: EmojiTextView = findViewById(R.id.manage_group_description)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
groupDescriptionTextView.movementMethod = LongClickMovementMethod.getInstance(context)
|
||||
|
||||
if (model.groupDescription.isNullOrEmpty()) {
|
||||
if (model.canEditGroupAttributes) {
|
||||
groupDescriptionTextView.setOverflowText(null)
|
||||
groupDescriptionTextView.setText(R.string.ManageGroupActivity_add_group_description)
|
||||
groupDescriptionTextView.setOnClickListener { model.onEditGroupDescription() }
|
||||
}
|
||||
} else {
|
||||
groupDescriptionTextView.setOnClickListener(null)
|
||||
GroupDescriptionUtil.setText(
|
||||
context,
|
||||
groupDescriptionTextView,
|
||||
model.groupDescription,
|
||||
model.descriptionShouldLinkify
|
||||
) {
|
||||
model.onViewGroupDescription()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.Base64
|
||||
import org.thoughtcrime.securesms.util.Hex
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import java.util.UUID
|
||||
|
||||
object InternalPreference {
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.conversation_settings_internal_preference))
|
||||
}
|
||||
|
||||
class Model(
|
||||
private val recipient: Recipient,
|
||||
val onDisableProfileSharingClick: () -> Unit,
|
||||
val onDeleteSessionClick: () -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
|
||||
val body: String get() {
|
||||
return String.format(
|
||||
"""
|
||||
-- Profile Name --
|
||||
[${recipient.profileName.givenName}] [${recipient.profileName.familyName}]
|
||||
|
||||
-- Profile Sharing --
|
||||
${recipient.isProfileSharing}
|
||||
|
||||
-- Profile Key (Base64) --
|
||||
${recipient.profileKey?.let(Base64::encodeBytes) ?: "None"}
|
||||
|
||||
-- Profile Key (Hex) --
|
||||
${recipient.profileKey?.let(Hex::toStringCondensed) ?: "None"}
|
||||
|
||||
-- Sealed Sender Mode --
|
||||
${recipient.unidentifiedAccessMode}
|
||||
|
||||
-- UUID --
|
||||
${recipient.uuid.transform { obj: UUID -> obj.toString() }.or("None")}
|
||||
|
||||
-- RecipientId --
|
||||
${recipient.id.serialize()}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return recipient == newItem.recipient
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val body: TextView = itemView.findViewById(R.id.internal_preference_body)
|
||||
private val disableProfileSharing: View = itemView.findViewById(R.id.internal_disable_profile_sharing)
|
||||
private val deleteSession: View = itemView.findViewById(R.id.internal_delete_session)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
body.text = model.body
|
||||
disableProfileSharing.setOnClickListener { model.onDisableProfileSharingClick() }
|
||||
deleteSession.setOnClickListener { model.onDeleteSessionClick() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
|
||||
/**
|
||||
* Renders a preference line item with a larger (40dp) icon
|
||||
*/
|
||||
object LargeIconClickPreference {
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.large_icon_preference_item))
|
||||
}
|
||||
|
||||
class Model(
|
||||
override val title: DSLSettingsText?,
|
||||
override val icon: DSLSettingsIcon,
|
||||
val onClick: () -> Unit
|
||||
) : PreferenceModel<Model>()
|
||||
|
||||
private class ViewHolder(itemView: View) : PreferenceViewHolder<Model>(itemView) {
|
||||
override fun bind(model: Model) {
|
||||
super.bind(model)
|
||||
itemView.setOnClickListener { model.onClick() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.views.LearnMoreTextView
|
||||
|
||||
object LegacyGroupPreference {
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.conversation_settings_legacy_group_preference))
|
||||
}
|
||||
|
||||
class Model(
|
||||
val state: State,
|
||||
val onLearnMoreClick: () -> Unit,
|
||||
val onUpgradeClick: () -> Unit,
|
||||
val onMmsWarningClick: () -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return state == newItem.state
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val groupInfoText: LearnMoreTextView = findViewById(R.id.manage_group_info_text)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
itemView.visibility = View.VISIBLE
|
||||
|
||||
groupInfoText.setLinkColor(ContextCompat.getColor(context, R.color.signal_text_primary))
|
||||
|
||||
when (model.state) {
|
||||
State.LEARN_MORE -> {
|
||||
groupInfoText.setText(R.string.ManageGroupActivity_legacy_group_learn_more)
|
||||
groupInfoText.setOnLinkClickListener { model.onLearnMoreClick() }
|
||||
groupInfoText.setLearnMoreVisible(true)
|
||||
}
|
||||
State.UPGRADE -> {
|
||||
groupInfoText.setText(R.string.ManageGroupActivity_legacy_group_upgrade)
|
||||
groupInfoText.setOnLinkClickListener { model.onUpgradeClick() }
|
||||
groupInfoText.setLearnMoreVisible(true, R.string.ManageGroupActivity_upgrade_this_group)
|
||||
}
|
||||
State.TOO_LARGE -> {
|
||||
groupInfoText.text = context.getString(R.string.ManageGroupActivity_legacy_group_too_large, FeatureFlags.groupLimits().hardLimit - 1)
|
||||
groupInfoText.setLearnMoreVisible(false)
|
||||
}
|
||||
State.MMS_WARNING -> {
|
||||
groupInfoText.setText(R.string.ManageGroupActivity_this_is_an_insecure_mms_group)
|
||||
groupInfoText.setOnLinkClickListener { model.onMmsWarningClick() }
|
||||
groupInfoText.setLearnMoreVisible(true, R.string.ManageGroupActivity_invite_now)
|
||||
}
|
||||
State.NONE -> itemView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class State {
|
||||
LEARN_MORE,
|
||||
UPGRADE,
|
||||
TOO_LARGE,
|
||||
MMS_WARNING,
|
||||
NONE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
* Renders a Recipient as a row item with an icon, avatar, status, and admin state
|
||||
*/
|
||||
object RecipientPreference {
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.group_recipient_list_item))
|
||||
}
|
||||
|
||||
class Model(
|
||||
val recipient: Recipient,
|
||||
val isAdmin: Boolean = false,
|
||||
val onClick: () -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return recipient.id == newItem.recipient.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
recipient.hasSameContent(newItem.recipient) &&
|
||||
isAdmin == newItem.isAdmin
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
private val avatar: AvatarImageView = itemView.findViewById(R.id.recipient_avatar)
|
||||
private val name: TextView = itemView.findViewById(R.id.recipient_name)
|
||||
private val about: TextView = itemView.findViewById(R.id.recipient_about)
|
||||
private val admin: View = itemView.findViewById(R.id.admin)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
itemView.setOnClickListener { model.onClick() }
|
||||
|
||||
avatar.setRecipient(model.recipient)
|
||||
name.text = if (model.recipient.isSelf) {
|
||||
context.getString(R.string.Recipient_you)
|
||||
} else {
|
||||
model.recipient.getDisplayName(context)
|
||||
}
|
||||
|
||||
val aboutText = model.recipient.combinedAboutAndEmoji
|
||||
if (aboutText.isNullOrEmpty()) {
|
||||
about.visibility = View.GONE
|
||||
} else {
|
||||
about.text = model.recipient.combinedAboutAndEmoji
|
||||
about.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
admin.visible = model.isAdmin
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
|
||||
import android.database.Cursor
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ThreadPhotoRailView
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.database.MediaDatabase
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
/**
|
||||
* Renders the shared media photo rail.
|
||||
*/
|
||||
object SharedMediaPreference {
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.conversation_settings_shared_media))
|
||||
}
|
||||
|
||||
class Model(
|
||||
val mediaCursor: Cursor,
|
||||
val mediaIds: List<Long>,
|
||||
val onMediaRecordClick: (MediaDatabase.MediaRecord, Boolean) -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
mediaIds == newItem.mediaIds
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val rail: ThreadPhotoRailView = itemView.findViewById(R.id.rail_view)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
rail.setCursor(GlideApp.with(rail), model.mediaCursor)
|
||||
rail.setListener {
|
||||
model.onMediaRecordClick(it, ViewUtil.isLtr(rail))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
|
||||
import android.content.Context
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import java.util.Locale
|
||||
|
||||
object Utils {
|
||||
|
||||
fun Long.formatMutedUntil(context: Context): String {
|
||||
return if (this == Long.MAX_VALUE) {
|
||||
context.getString(R.string.ConversationSettingsFragment__conversation_muted_forever)
|
||||
} else {
|
||||
context.getString(
|
||||
R.string.ConversationSettingsFragment__conversation_muted_until_s,
|
||||
DateUtils.getTimeString(context, Locale.getDefault(), this)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.sounds
|
||||
|
||||
import androidx.fragment.app.viewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.thoughtcrime.securesms.MuteDialog
|
||||
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.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.ui.notifications.CustomNotificationsDialogFragment
|
||||
|
||||
class SoundsAndNotificationsSettingsFragment : DSLSettingsFragment(
|
||||
titleId = R.string.ConversationSettingsFragment__sounds_and_notifications
|
||||
) {
|
||||
|
||||
private val mentionLabels: Array<String> by lazy {
|
||||
resources.getStringArray(R.array.SoundsAndNotificationsSettingsFragment__mention_labels)
|
||||
}
|
||||
|
||||
private val viewModel: SoundsAndNotificationsSettingsViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
val recipientId = SoundsAndNotificationsSettingsFragmentArgs.fromBundle(requireArguments()).recipientId
|
||||
val repository = SoundsAndNotificationsSettingsRepository(requireContext())
|
||||
|
||||
SoundsAndNotificationsSettingsViewModel.Factory(recipientId, repository)
|
||||
}
|
||||
)
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
if (state.recipientId != Recipient.UNKNOWN.id) {
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: SoundsAndNotificationsSettingsState): DSLConfiguration {
|
||||
return configure {
|
||||
|
||||
val muteSummary = if (state.muteUntil > 0) {
|
||||
state.muteUntil.formatMutedUntil(requireContext())
|
||||
} else {
|
||||
getString(R.string.SoundsAndNotificationsSettingsFragment__not_muted)
|
||||
}
|
||||
|
||||
val muteIcon = if (state.muteUntil > 0) {
|
||||
R.drawable.ic_bell_disabled_24
|
||||
} else {
|
||||
R.drawable.ic_bell_24
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.SoundsAndNotificationsSettingsFragment__mute_notifications),
|
||||
icon = DSLSettingsIcon.from(muteIcon),
|
||||
summary = DSLSettingsText.from(muteSummary),
|
||||
onClick = {
|
||||
if (state.muteUntil <= 0) {
|
||||
MuteDialog.show(requireContext(), viewModel::setMuteUntil)
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(muteSummary)
|
||||
.setPositiveButton(R.string.ConversationSettingsFragment__unmute) { dialog, _ ->
|
||||
viewModel.unmute()
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (state.hasMentionsSupport) {
|
||||
val mentionSelection = if (state.mentionSetting == RecipientDatabase.MentionSetting.ALWAYS_NOTIFY) {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
|
||||
radioListPref(
|
||||
title = DSLSettingsText.from(R.string.SoundsAndNotificationsSettingsFragment__mentions),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_at_24),
|
||||
selected = mentionSelection,
|
||||
listItems = mentionLabels,
|
||||
onSelected = {
|
||||
viewModel.setMentionSetting(
|
||||
if (it == 0) {
|
||||
RecipientDatabase.MentionSetting.ALWAYS_NOTIFY
|
||||
} else {
|
||||
RecipientDatabase.MentionSetting.DO_NOT_NOTIFY
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val customSoundSummary = if (state.hasCustomNotificationSettings) {
|
||||
R.string.preferences_on
|
||||
} else {
|
||||
R.string.preferences_off
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.SoundsAndNotificationsSettingsFragment__custom_notifications),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_speaker_24),
|
||||
summary = DSLSettingsText.from(customSoundSummary),
|
||||
onClick = {
|
||||
CustomNotificationsDialogFragment.create(state.recipientId).show(parentFragmentManager, null)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.sounds
|
||||
|
||||
import android.content.Context
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
class SoundsAndNotificationsSettingsRepository(private val context: Context) {
|
||||
|
||||
fun setMuteUntil(recipientId: RecipientId, muteUntil: Long) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
DatabaseFactory.getRecipientDatabase(context).setMuted(recipientId, muteUntil)
|
||||
}
|
||||
}
|
||||
|
||||
fun setMentionSetting(recipientId: RecipientId, mentionSetting: RecipientDatabase.MentionSetting) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
DatabaseFactory.getRecipientDatabase(context).setMentionSetting(recipientId, mentionSetting)
|
||||
}
|
||||
}
|
||||
|
||||
fun hasCustomNotificationSettings(recipientId: RecipientId, consumer: (Boolean) -> Unit) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
consumer(
|
||||
if (recipient.notificationChannel != null || !NotificationChannels.supported()) {
|
||||
true
|
||||
} else {
|
||||
NotificationChannels.updateWithShortcutBasedChannel(context, recipient)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.sounds
|
||||
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
data class SoundsAndNotificationsSettingsState(
|
||||
val recipientId: RecipientId = Recipient.UNKNOWN.id,
|
||||
val muteUntil: Long = 0L,
|
||||
val mentionSetting: RecipientDatabase.MentionSetting = RecipientDatabase.MentionSetting.DO_NOT_NOTIFY,
|
||||
val hasCustomNotificationSettings: Boolean = false,
|
||||
val hasMentionsSupport: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.sounds
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class SoundsAndNotificationsSettingsViewModel(
|
||||
private val recipientId: RecipientId,
|
||||
private val repository: SoundsAndNotificationsSettingsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store(SoundsAndNotificationsSettingsState())
|
||||
|
||||
val state: LiveData<SoundsAndNotificationsSettingsState> = store.stateLiveData
|
||||
|
||||
init {
|
||||
store.update(Recipient.live(recipientId).liveData) { recipient, state ->
|
||||
state.copy(
|
||||
recipientId = recipientId,
|
||||
muteUntil = if (recipient.isMuted) recipient.muteUntil else 0L,
|
||||
mentionSetting = recipient.mentionSetting,
|
||||
hasMentionsSupport = recipient.isPushV2Group,
|
||||
hasCustomNotificationSettings = recipient.notificationChannel != null || !NotificationChannels.supported()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setMuteUntil(muteUntil: Long) {
|
||||
repository.setMuteUntil(recipientId, muteUntil)
|
||||
}
|
||||
|
||||
fun unmute() {
|
||||
repository.setMuteUntil(recipientId, 0L)
|
||||
}
|
||||
|
||||
fun setMentionSetting(mentionSetting: RecipientDatabase.MentionSetting) {
|
||||
repository.setMentionSetting(recipientId, mentionSetting)
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val recipientId: RecipientId,
|
||||
private val repository: SoundsAndNotificationsSettingsRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(SoundsAndNotificationsSettingsViewModel(recipientId, repository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
package org.thoughtcrime.securesms.components.settings
|
||||
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
import org.thoughtcrime.securesms.util.MappingModelList
|
||||
|
||||
private const val UNSET = -1
|
||||
|
||||
fun configure(init: DSLConfiguration.() -> Unit): DSLConfiguration {
|
||||
val configuration = DSLConfiguration()
|
||||
configuration.init()
|
||||
@@ -23,13 +20,24 @@ class DSLConfiguration {
|
||||
|
||||
fun radioListPref(
|
||||
title: DSLSettingsText,
|
||||
@DrawableRes iconId: Int = UNSET,
|
||||
icon: DSLSettingsIcon? = null,
|
||||
dialogTitle: DSLSettingsText = title,
|
||||
isEnabled: Boolean = true,
|
||||
listItems: Array<String>,
|
||||
selected: Int,
|
||||
confirmAction: Boolean = false,
|
||||
onSelected: (Int) -> Unit
|
||||
) {
|
||||
val preference = RadioListPreference(title, iconId, isEnabled, listItems, selected, onSelected)
|
||||
val preference = RadioListPreference(
|
||||
title = title,
|
||||
icon = icon,
|
||||
isEnabled = isEnabled,
|
||||
dialogTitle = dialogTitle,
|
||||
listItems = listItems,
|
||||
selected = selected,
|
||||
confirmAction = confirmAction,
|
||||
onSelected = onSelected
|
||||
)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
@@ -47,12 +55,12 @@ class DSLConfiguration {
|
||||
fun switchPref(
|
||||
title: DSLSettingsText,
|
||||
summary: DSLSettingsText? = null,
|
||||
@DrawableRes iconId: Int = UNSET,
|
||||
icon: DSLSettingsIcon? = null,
|
||||
isEnabled: Boolean = true,
|
||||
isChecked: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val preference = SwitchPreference(title, summary, iconId, isEnabled, isChecked, onClick)
|
||||
val preference = SwitchPreference(title, summary, icon, isEnabled, isChecked, onClick)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
@@ -70,20 +78,20 @@ class DSLConfiguration {
|
||||
fun clickPref(
|
||||
title: DSLSettingsText,
|
||||
summary: DSLSettingsText? = null,
|
||||
@DrawableRes iconId: Int = UNSET,
|
||||
icon: DSLSettingsIcon? = null,
|
||||
isEnabled: Boolean = true,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val preference = ClickPreference(title, summary, iconId, isEnabled, onClick)
|
||||
val preference = ClickPreference(title, summary, icon, isEnabled, onClick)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
fun externalLinkPref(
|
||||
title: DSLSettingsText,
|
||||
@DrawableRes iconId: Int = UNSET,
|
||||
icon: DSLSettingsIcon? = null,
|
||||
@StringRes linkId: Int
|
||||
) {
|
||||
val preference = ExternalLinkPreference(title, iconId, linkId)
|
||||
val preference = ExternalLinkPreference(title, icon, linkId)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
@@ -116,8 +124,8 @@ class DSLConfiguration {
|
||||
abstract class PreferenceModel<T : PreferenceModel<T>>(
|
||||
open val title: DSLSettingsText? = null,
|
||||
open val summary: DSLSettingsText? = null,
|
||||
@DrawableRes open val iconId: Int = UNSET,
|
||||
open val isEnabled: Boolean = true
|
||||
open val icon: DSLSettingsIcon? = null,
|
||||
open val isEnabled: Boolean = true,
|
||||
) : MappingModel<T> {
|
||||
override fun areItemsTheSame(newItem: T): Boolean {
|
||||
return when {
|
||||
@@ -131,7 +139,7 @@ abstract class PreferenceModel<T : PreferenceModel<T>>(
|
||||
override fun areContentsTheSame(newItem: T): Boolean {
|
||||
return areItemsTheSame(newItem) &&
|
||||
newItem.summary == summary &&
|
||||
newItem.iconId == iconId &&
|
||||
newItem.icon == icon &&
|
||||
newItem.isEnabled == isEnabled
|
||||
}
|
||||
}
|
||||
@@ -147,12 +155,14 @@ class DividerPreference : PreferenceModel<DividerPreference>() {
|
||||
|
||||
class RadioListPreference(
|
||||
override val title: DSLSettingsText,
|
||||
@DrawableRes override val iconId: Int = UNSET,
|
||||
override val icon: DSLSettingsIcon? = null,
|
||||
override val isEnabled: Boolean,
|
||||
val dialogTitle: DSLSettingsText = title,
|
||||
val listItems: Array<String>,
|
||||
val selected: Int,
|
||||
val onSelected: (Int) -> Unit
|
||||
) : PreferenceModel<RadioListPreference>(title = title, iconId = iconId, isEnabled = isEnabled) {
|
||||
val onSelected: (Int) -> Unit,
|
||||
val confirmAction: Boolean = false
|
||||
) : PreferenceModel<RadioListPreference>() {
|
||||
|
||||
override fun areContentsTheSame(newItem: RadioListPreference): Boolean {
|
||||
return super.areContentsTheSame(newItem) && listItems.contentEquals(newItem.listItems) && selected == newItem.selected
|
||||
@@ -176,11 +186,11 @@ class MultiSelectListPreference(
|
||||
class SwitchPreference(
|
||||
override val title: DSLSettingsText,
|
||||
override val summary: DSLSettingsText? = null,
|
||||
@DrawableRes override val iconId: Int = UNSET,
|
||||
isEnabled: Boolean,
|
||||
override val icon: DSLSettingsIcon? = null,
|
||||
override val isEnabled: Boolean,
|
||||
val isChecked: Boolean,
|
||||
val onClick: () -> Unit
|
||||
) : PreferenceModel<SwitchPreference>(title = title, summary = summary, iconId = iconId, isEnabled = isEnabled) {
|
||||
) : PreferenceModel<SwitchPreference>() {
|
||||
override fun areContentsTheSame(newItem: SwitchPreference): Boolean {
|
||||
return super.areContentsTheSame(newItem) && isChecked == newItem.isChecked
|
||||
}
|
||||
@@ -201,15 +211,15 @@ class RadioPreference(
|
||||
class ClickPreference(
|
||||
override val title: DSLSettingsText,
|
||||
override val summary: DSLSettingsText? = null,
|
||||
@DrawableRes override val iconId: Int = UNSET,
|
||||
isEnabled: Boolean = true,
|
||||
override val icon: DSLSettingsIcon? = null,
|
||||
override val isEnabled: Boolean = true,
|
||||
val onClick: () -> Unit
|
||||
) : PreferenceModel<ClickPreference>(title = title, summary = summary, iconId = iconId, isEnabled = isEnabled)
|
||||
) : PreferenceModel<ClickPreference>()
|
||||
|
||||
class ExternalLinkPreference(
|
||||
override val title: DSLSettingsText,
|
||||
@DrawableRes override val iconId: Int,
|
||||
override val icon: DSLSettingsIcon?,
|
||||
@StringRes val linkId: Int
|
||||
) : PreferenceModel<ExternalLinkPreference>(title = title, iconId = iconId)
|
||||
) : PreferenceModel<ExternalLinkPreference>()
|
||||
|
||||
class SectionHeaderPreference(override val title: DSLSettingsText) : PreferenceModel<SectionHeaderPreference>(title = title)
|
||||
class SectionHeaderPreference(override val title: DSLSettingsText) : PreferenceModel<SectionHeaderPreference>()
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.net.Uri
|
||||
import org.thoughtcrime.securesms.database.DraftDatabase
|
||||
import java.lang.IllegalArgumentException
|
||||
|
||||
private const val SIZE = "size"
|
||||
|
||||
class VoiceNoteDraft(
|
||||
val uri: Uri,
|
||||
val size: Long
|
||||
) {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun fromDraft(draft: DraftDatabase.Draft): VoiceNoteDraft {
|
||||
if (draft.type != DraftDatabase.Draft.VOICE_NOTE) {
|
||||
throw IllegalArgumentException()
|
||||
}
|
||||
|
||||
val draftUri = Uri.parse(draft.value)
|
||||
|
||||
val uri: Uri = draftUri.buildUpon().clearQuery().build()
|
||||
val size: Long = draftUri.getQueryParameter("size")!!.toLong()
|
||||
|
||||
return VoiceNoteDraft(uri, size)
|
||||
}
|
||||
}
|
||||
|
||||
fun asDraft(): DraftDatabase.Draft {
|
||||
val draftUri = uri.buildUpon().appendQueryParameter(SIZE, size.toString())
|
||||
|
||||
return DraftDatabase.Draft(DraftDatabase.Draft.VOICE_NOTE, draftUri.build().toString())
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.voice;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.media.AudioManager;
|
||||
import android.media.session.PlaybackState;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
@@ -9,19 +10,28 @@ import android.os.Looper;
|
||||
import android.os.Message;
|
||||
import android.os.RemoteException;
|
||||
import android.support.v4.media.MediaBrowserCompat;
|
||||
import android.support.v4.media.MediaDescriptionCompat;
|
||||
import android.support.v4.media.MediaMetadataCompat;
|
||||
import android.support.v4.media.session.MediaControllerCompat;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
import android.support.v4.media.session.PlaybackStateCompat;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.lifecycle.DefaultLifecycleObserver;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@@ -35,16 +45,18 @@ import java.util.Objects;
|
||||
*/
|
||||
public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
|
||||
public static final String EXTRA_THREAD_ID = "voice.note.thread_id";
|
||||
public static final String EXTRA_MESSAGE_ID = "voice.note.message_id";
|
||||
public static final String EXTRA_PROGRESS = "voice.note.playhead";
|
||||
public static final String EXTRA_PLAY_SINGLE = "voice.note.play.single";
|
||||
|
||||
private static final String TAG = Log.tag(VoiceNoteMediaController.class);
|
||||
|
||||
private MediaBrowserCompat mediaBrowser;
|
||||
private AppCompatActivity activity;
|
||||
private ProgressEventHandler progressEventHandler;
|
||||
private MutableLiveData<VoiceNotePlaybackState> voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE);
|
||||
private MediaBrowserCompat mediaBrowser;
|
||||
private AppCompatActivity activity;
|
||||
private ProgressEventHandler progressEventHandler;
|
||||
private MutableLiveData<VoiceNotePlaybackState> voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE);
|
||||
private LiveData<Optional<VoiceNotePlayerView.State>> voiceNotePlayerViewState;
|
||||
|
||||
private final MediaControllerCompatCallback mediaControllerCompatCallback = new MediaControllerCompatCallback();
|
||||
|
||||
@@ -56,12 +68,44 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
null);
|
||||
|
||||
activity.getLifecycle().addObserver(this);
|
||||
|
||||
voiceNotePlayerViewState = Transformations.switchMap(voiceNotePlaybackState, playbackState -> {
|
||||
if (playbackState.getClipType() instanceof VoiceNotePlaybackState.ClipType.Message) {
|
||||
VoiceNotePlaybackState.ClipType.Message message = (VoiceNotePlaybackState.ClipType.Message) playbackState.getClipType();
|
||||
LiveRecipient sender = Recipient.live(message.getSenderId());
|
||||
LiveRecipient threadRecipient = Recipient.live(message.getThreadRecipientId());
|
||||
LiveData<String> name = LiveDataUtil.combineLatest(sender.getLiveDataResolved(),
|
||||
threadRecipient.getLiveDataResolved(),
|
||||
(s, t) -> VoiceNoteMediaDescriptionCompatFactory.getTitle(activity, s, t, null));
|
||||
|
||||
return Transformations.map(name, displayName -> Optional.of(
|
||||
new VoiceNotePlayerView.State(
|
||||
playbackState.getUri(),
|
||||
message.getMessageId(),
|
||||
message.getThreadId(),
|
||||
!playbackState.isPlaying(),
|
||||
message.getSenderId(),
|
||||
message.getThreadRecipientId(),
|
||||
message.getMessagePosition(),
|
||||
message.getTimestamp(),
|
||||
displayName,
|
||||
playbackState.getPlayheadPositionMillis(),
|
||||
playbackState.getTrackDuration(),
|
||||
playbackState.getSpeed())));
|
||||
} else {
|
||||
return new DefaultValueLiveData<>(Optional.absent());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public LiveData<VoiceNotePlaybackState> getVoiceNotePlaybackState() {
|
||||
return voiceNotePlaybackState;
|
||||
}
|
||||
|
||||
public LiveData<Optional<VoiceNotePlayerView.State>> getVoiceNotePlayerViewState() {
|
||||
return voiceNotePlayerViewState;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart(@NonNull LifecycleOwner owner) {
|
||||
mediaBrowser.connect();
|
||||
@@ -93,17 +137,29 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
playbackStateCompat.getState() == PlaybackStateCompat.STATE_PLAYING;
|
||||
}
|
||||
|
||||
private static boolean isPlayerPaused(@NonNull PlaybackStateCompat playbackStateCompat) {
|
||||
return playbackStateCompat.getState() == PlaybackStateCompat.STATE_PAUSED;
|
||||
}
|
||||
|
||||
private static boolean isPlayerStopped(@NonNull PlaybackStateCompat playbackStateCompat) {
|
||||
return playbackStateCompat.getState() <= PlaybackStateCompat.STATE_STOPPED;
|
||||
}
|
||||
|
||||
private @NonNull MediaControllerCompat getMediaController() {
|
||||
return MediaControllerCompat.getMediaController(activity);
|
||||
}
|
||||
|
||||
|
||||
public void startConsecutivePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) {
|
||||
startPlayback(audioSlideUri, messageId, progress, false);
|
||||
startPlayback(audioSlideUri, messageId, -1, progress, false);
|
||||
}
|
||||
|
||||
public void startSinglePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) {
|
||||
startPlayback(audioSlideUri, messageId, progress, true);
|
||||
startPlayback(audioSlideUri, messageId, -1, progress, true);
|
||||
}
|
||||
|
||||
public void startSinglePlaybackForDraft(@NonNull Uri draftUri, long threadId, double progress) {
|
||||
startPlayback(draftUri, -1, threadId, progress, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,7 +171,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
* @param progress The desired progress % to seek to.
|
||||
* @param singlePlayback The player will only play back the specified Uri, and not build a playlist.
|
||||
*/
|
||||
private void startPlayback(@NonNull Uri audioSlideUri, long messageId, double progress, boolean singlePlayback) {
|
||||
private void startPlayback(@NonNull Uri audioSlideUri, long messageId, long threadId, double progress, boolean singlePlayback) {
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
long duration = getMediaController().getMetadata().getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
|
||||
|
||||
@@ -124,6 +180,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
} else {
|
||||
Bundle extras = new Bundle();
|
||||
extras.putLong(EXTRA_MESSAGE_ID, messageId);
|
||||
extras.putLong(EXTRA_THREAD_ID, threadId);
|
||||
extras.putDouble(EXTRA_PROGRESS, progress);
|
||||
extras.putBoolean(EXTRA_PLAY_SINGLE, singlePlayback);
|
||||
|
||||
@@ -170,6 +227,15 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
}
|
||||
}
|
||||
|
||||
public void setPlaybackSpeed(@NonNull Uri audioSlideUri, float playbackSpeed) {
|
||||
if (isCurrentTrack(audioSlideUri)) {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, playbackSpeed);
|
||||
|
||||
getMediaController().sendCommand(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, bundle, null);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isCurrentTrack(@NonNull Uri uri) {
|
||||
MediaMetadataCompat metadataCompat = getMediaController().getMetadata();
|
||||
|
||||
@@ -198,6 +264,17 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
|
||||
MediaControllerCompat.setMediaController(activity, mediaController);
|
||||
|
||||
MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata();
|
||||
if (canExtractPlaybackInformationFromMetadata(mediaMetadataCompat)) {
|
||||
VoiceNotePlaybackState newState = extractStateFromMetadata(mediaController, mediaMetadataCompat, null);
|
||||
|
||||
if (newState != null) {
|
||||
voiceNotePlaybackState.postValue(newState);
|
||||
} else {
|
||||
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE);
|
||||
}
|
||||
}
|
||||
|
||||
mediaController.registerCallback(mediaControllerCompatCallback);
|
||||
|
||||
mediaControllerCompatCallback.onPlaybackStateChanged(mediaController.getPlaybackState());
|
||||
@@ -207,6 +284,107 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean canExtractPlaybackInformationFromMetadata(@Nullable MediaMetadataCompat mediaMetadataCompat) {
|
||||
return mediaMetadataCompat != null &&
|
||||
mediaMetadataCompat.getDescription() != null &&
|
||||
mediaMetadataCompat.getDescription().getMediaUri() != null;
|
||||
}
|
||||
|
||||
private static @Nullable VoiceNotePlaybackState extractStateFromMetadata(@NonNull MediaControllerCompat mediaController,
|
||||
@NonNull MediaMetadataCompat mediaMetadataCompat,
|
||||
@Nullable VoiceNotePlaybackState previousState)
|
||||
{
|
||||
Uri mediaUri = Objects.requireNonNull(mediaMetadataCompat.getDescription().getMediaUri());
|
||||
boolean autoReset = Objects.equals(mediaUri, VoiceNotePlaybackPreparer.NEXT_URI) || Objects.equals(mediaUri, VoiceNotePlaybackPreparer.END_URI);
|
||||
long position = mediaController.getPlaybackState().getPosition();
|
||||
long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
|
||||
Bundle extras = mediaController.getExtras();
|
||||
float speed = extras != null ? extras.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f) : 1f;
|
||||
|
||||
if (previousState != null && Objects.equals(mediaUri, previousState.getUri())) {
|
||||
if (position < 0 && previousState.getPlayheadPositionMillis() >= 0) {
|
||||
position = previousState.getPlayheadPositionMillis();
|
||||
}
|
||||
|
||||
if (duration <= 0 && previousState.getTrackDuration() > 0) {
|
||||
duration = previousState.getTrackDuration();
|
||||
}
|
||||
}
|
||||
|
||||
if (duration > 0 && position >= 0 && position <= duration) {
|
||||
return new VoiceNotePlaybackState(mediaUri,
|
||||
position,
|
||||
duration,
|
||||
autoReset,
|
||||
speed,
|
||||
isPlayerActive(mediaController.getPlaybackState()),
|
||||
getClipType(mediaMetadataCompat.getBundle()));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable VoiceNotePlaybackState constructPlaybackState(@NonNull MediaControllerCompat mediaController,
|
||||
@Nullable VoiceNotePlaybackState previousState)
|
||||
{
|
||||
MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata();
|
||||
if (isPlayerActive(mediaController.getPlaybackState()) &&
|
||||
canExtractPlaybackInformationFromMetadata(mediaMetadataCompat))
|
||||
{
|
||||
return extractStateFromMetadata(mediaController, mediaMetadataCompat, previousState);
|
||||
} else if (isPlayerPaused(mediaController.getPlaybackState()) &&
|
||||
mediaMetadataCompat != null)
|
||||
{
|
||||
long position = mediaController.getPlaybackState().getPosition();
|
||||
long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
|
||||
|
||||
if (previousState != null && position < duration) {
|
||||
return previousState.asPaused();
|
||||
} else {
|
||||
return VoiceNotePlaybackState.NONE;
|
||||
}
|
||||
} else {
|
||||
return VoiceNotePlaybackState.NONE;
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull VoiceNotePlaybackState.ClipType getClipType(@Nullable Bundle mediaExtras) {
|
||||
long messageId = -1L;
|
||||
RecipientId senderId = RecipientId.UNKNOWN;
|
||||
long messagePosition = -1L;
|
||||
long threadId = -1L;
|
||||
RecipientId threadRecipientId = RecipientId.UNKNOWN;
|
||||
long timestamp = -1L;
|
||||
|
||||
if (mediaExtras != null) {
|
||||
messageId = mediaExtras.getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_ID, -1L);
|
||||
messagePosition = mediaExtras.getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_POSITION, -1L);
|
||||
threadId = mediaExtras.getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_ID, -1L);
|
||||
timestamp = mediaExtras.getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_TIMESTAMP, -1L);
|
||||
|
||||
String serializedSenderId = mediaExtras.getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_INDIVIDUAL_RECIPIENT_ID);
|
||||
if (serializedSenderId != null) {
|
||||
senderId = RecipientId.from(serializedSenderId);
|
||||
}
|
||||
|
||||
String serializedThreadRecipientId = mediaExtras.getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_RECIPIENT_ID);
|
||||
if (serializedThreadRecipientId != null) {
|
||||
threadRecipientId = RecipientId.from(serializedThreadRecipientId);
|
||||
}
|
||||
}
|
||||
|
||||
if (messageId != -1L) {
|
||||
return new VoiceNotePlaybackState.ClipType.Message(messageId,
|
||||
senderId,
|
||||
threadRecipientId,
|
||||
messagePosition,
|
||||
threadId,
|
||||
timestamp);
|
||||
} else {
|
||||
return VoiceNotePlaybackState.ClipType.Draft.INSTANCE;
|
||||
}
|
||||
}
|
||||
|
||||
private static class ProgressEventHandler extends Handler {
|
||||
|
||||
private final MediaControllerCompat mediaController;
|
||||
@@ -223,36 +401,14 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
|
||||
@Override
|
||||
public void handleMessage(@NonNull Message msg) {
|
||||
MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata();
|
||||
if (isPlayerActive(mediaController.getPlaybackState()) &&
|
||||
mediaMetadataCompat != null &&
|
||||
mediaMetadataCompat.getDescription() != null &&
|
||||
mediaMetadataCompat.getDescription().getMediaUri() != null)
|
||||
{
|
||||
VoiceNotePlaybackState newPlaybackState = constructPlaybackState(mediaController, voiceNotePlaybackState.getValue());
|
||||
|
||||
Uri mediaUri = Objects.requireNonNull(mediaMetadataCompat.getDescription().getMediaUri());
|
||||
boolean autoReset = Objects.equals(mediaUri, VoiceNotePlaybackPreparer.NEXT_URI) || Objects.equals(mediaUri, VoiceNotePlaybackPreparer.END_URI);
|
||||
VoiceNotePlaybackState previousState = voiceNotePlaybackState.getValue();
|
||||
long position = mediaController.getPlaybackState().getPosition();
|
||||
long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
|
||||
|
||||
if (previousState != null && Objects.equals(mediaUri, previousState.getUri())) {
|
||||
if (position < 0 && previousState.getPlayheadPositionMillis() >= 0) {
|
||||
position = previousState.getPlayheadPositionMillis();
|
||||
}
|
||||
|
||||
if (duration <= 0 && previousState.getTrackDuration() > 0) {
|
||||
duration = previousState.getTrackDuration();
|
||||
}
|
||||
}
|
||||
|
||||
if (duration > 0 && position >= 0 && position <= duration) {
|
||||
voiceNotePlaybackState.postValue(new VoiceNotePlaybackState(mediaUri, position, duration, autoReset));
|
||||
}
|
||||
if (newPlaybackState != null) {
|
||||
voiceNotePlaybackState.postValue(newPlaybackState);
|
||||
}
|
||||
|
||||
if (isPlayerActive(mediaController.getPlaybackState())) {
|
||||
sendEmptyMessageDelayed(0, 50);
|
||||
} else {
|
||||
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,6 +420,10 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
|
||||
notifyProgressEventHandler();
|
||||
} else {
|
||||
clearProgressEventHandler();
|
||||
|
||||
if (isPlayerStopped(state)) {
|
||||
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
interface VoiceNoteMediaControllerOwner {
|
||||
val voiceNoteMediaController: VoiceNoteMediaController
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.os.Bundle;
|
||||
import android.support.v4.media.MediaDescriptionCompat;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -14,10 +15,10 @@ import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.mms.AudioSlide;
|
||||
import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
@@ -34,23 +35,44 @@ class VoiceNoteMediaDescriptionCompatFactory {
|
||||
public static final String EXTRA_THREAD_ID = "voice.note.extra.THREAD_ID";
|
||||
public static final String EXTRA_COLOR = "voice.note.extra.COLOR";
|
||||
public static final String EXTRA_MESSAGE_ID = "voice.note.extra.MESSAGE_ID";
|
||||
public static final String EXTRA_MESSAGE_TIMESTAMP = "voice.note.extra.MESSAGE_TIMESTAMP";
|
||||
|
||||
private static final String TAG = Log.tag(VoiceNoteMediaDescriptionCompatFactory.class);
|
||||
|
||||
private VoiceNoteMediaDescriptionCompatFactory() {}
|
||||
|
||||
static MediaDescriptionCompat buildMediaDescription(@NonNull Context context,
|
||||
long threadId,
|
||||
@NonNull Uri draftUri)
|
||||
{
|
||||
|
||||
Recipient threadRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId);
|
||||
if (threadRecipient == null) {
|
||||
threadRecipient = Recipient.UNKNOWN;
|
||||
}
|
||||
|
||||
return buildMediaDescription(context,
|
||||
threadRecipient,
|
||||
Recipient.self(),
|
||||
Recipient.self(),
|
||||
0,
|
||||
threadId,
|
||||
-1,
|
||||
System.currentTimeMillis(),
|
||||
draftUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build out a MediaDescriptionCompat for a given voice note. Expects to be run
|
||||
* on a background thread.
|
||||
*
|
||||
* @param context Context.
|
||||
* @param messageRecord The MessageRecord of the given voice note.
|
||||
*
|
||||
* @return A MediaDescriptionCompat with all the details the service expects.
|
||||
*/
|
||||
@WorkerThread
|
||||
static MediaDescriptionCompat buildMediaDescription(@NonNull Context context,
|
||||
@NonNull MessageRecord messageRecord)
|
||||
@Nullable static MediaDescriptionCompat buildMediaDescription(@NonNull Context context,
|
||||
@NonNull MessageRecord messageRecord)
|
||||
{
|
||||
int startingPosition = DatabaseFactory.getMmsSmsDatabase(context)
|
||||
.getMessagePositionInConversation(messageRecord.getThreadId(),
|
||||
@@ -58,46 +80,87 @@ class VoiceNoteMediaDescriptionCompatFactory {
|
||||
|
||||
Recipient threadRecipient = Objects.requireNonNull(DatabaseFactory.getThreadDatabase(context)
|
||||
.getRecipientForThreadId(messageRecord.getThreadId()));
|
||||
Recipient sender = messageRecord.isOutgoing() ? Recipient.self() : messageRecord.getIndividualRecipient();
|
||||
Recipient avatarRecipient = threadRecipient.isGroup() ? threadRecipient : sender;
|
||||
Recipient sender = messageRecord.isOutgoing() ? Recipient.self() : messageRecord.getIndividualRecipient();
|
||||
Recipient avatarRecipient = threadRecipient.isGroup() ? threadRecipient : sender;
|
||||
AudioSlide audioSlide = ((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide();
|
||||
|
||||
if (audioSlide == null) {
|
||||
Log.w(TAG, "Message does not have an audio slide. Can't play this voice note.");
|
||||
return null;
|
||||
}
|
||||
|
||||
Uri uri = audioSlide.getUri();
|
||||
if (uri == null) {
|
||||
Log.w(TAG, "Audio slide does not have a URI. Can't play this voice note.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildMediaDescription(context,
|
||||
threadRecipient,
|
||||
avatarRecipient,
|
||||
sender,
|
||||
startingPosition,
|
||||
messageRecord.getThreadId(),
|
||||
messageRecord.getId(),
|
||||
messageRecord.getDateReceived(),
|
||||
uri);
|
||||
}
|
||||
|
||||
private static MediaDescriptionCompat buildMediaDescription(@NonNull Context context,
|
||||
@NonNull Recipient threadRecipient,
|
||||
@NonNull Recipient avatarRecipient,
|
||||
@NonNull Recipient sender,
|
||||
int startingPosition,
|
||||
long threadId,
|
||||
long messageId,
|
||||
long dateReceived,
|
||||
@NonNull Uri audioUri)
|
||||
{
|
||||
Bundle extras = new Bundle();
|
||||
extras.putString(EXTRA_THREAD_RECIPIENT_ID, threadRecipient.getId().serialize());
|
||||
extras.putString(EXTRA_AVATAR_RECIPIENT_ID, avatarRecipient.getId().serialize());
|
||||
extras.putString(EXTRA_INDIVIDUAL_RECIPIENT_ID, sender.getId().serialize());
|
||||
extras.putLong(EXTRA_MESSAGE_POSITION, startingPosition);
|
||||
extras.putLong(EXTRA_THREAD_ID, messageRecord.getThreadId());
|
||||
extras.putLong(EXTRA_THREAD_ID, threadId);
|
||||
extras.putLong(EXTRA_COLOR, threadRecipient.getChatColors().asSingleColor());
|
||||
extras.putLong(EXTRA_MESSAGE_ID, messageRecord.getId());
|
||||
extras.putLong(EXTRA_MESSAGE_ID, messageId);
|
||||
extras.putLong(EXTRA_MESSAGE_TIMESTAMP, dateReceived);
|
||||
|
||||
NotificationPrivacyPreference preference = SignalStore.settings().getMessageNotificationsPrivacy();
|
||||
|
||||
String title;
|
||||
if (preference.isDisplayContact() && threadRecipient.isGroup()) {
|
||||
title = context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__s_to_s,
|
||||
sender.getDisplayName(context),
|
||||
threadRecipient.getDisplayName(context));
|
||||
} else if (preference.isDisplayContact()) {
|
||||
title = sender.getDisplayName(context);
|
||||
} else {
|
||||
title = context.getString(R.string.MessageNotifier_signal_message);
|
||||
}
|
||||
String title = getTitle(context, sender, threadRecipient, preference);
|
||||
|
||||
String subtitle = null;
|
||||
if (preference.isDisplayContact()) {
|
||||
subtitle = context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__voice_message,
|
||||
DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(),
|
||||
messageRecord.getDateReceived()));
|
||||
dateReceived));
|
||||
}
|
||||
|
||||
Uri uri = ((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide().getUri();
|
||||
|
||||
return new MediaDescriptionCompat.Builder()
|
||||
.setMediaUri(uri)
|
||||
.setMediaUri(audioUri)
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setExtras(extras)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static @NonNull String getTitle(@NonNull Context context, @NonNull Recipient sender, @NonNull Recipient threadRecipient, @Nullable NotificationPrivacyPreference notificationPrivacyPreference) {
|
||||
NotificationPrivacyPreference preference;
|
||||
if (notificationPrivacyPreference == null) {
|
||||
preference = new NotificationPrivacyPreference("all");
|
||||
} else {
|
||||
preference = notificationPrivacyPreference;
|
||||
}
|
||||
|
||||
if (preference.isDisplayContact() && threadRecipient.isGroup()) {
|
||||
return context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__s_to_s,
|
||||
sender.getDisplayName(context),
|
||||
threadRecipient.getDisplayName(context));
|
||||
} else if (preference.isDisplayContact()) {
|
||||
return sender.getDisplayName(context);
|
||||
} else {
|
||||
return context.getString(R.string.MessageNotifier_signal_message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.thoughtcrime.securesms.components.voice
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import com.google.android.exoplayer2.PlaybackParameters
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ext.mediasession.DefaultPlaybackController
|
||||
|
||||
class VoiceNotePlaybackController(private val voiceNotePlaybackParameters: VoiceNotePlaybackParameters) : DefaultPlaybackController() {
|
||||
|
||||
override fun getCommands(): Array<String> {
|
||||
return arrayOf(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED)
|
||||
}
|
||||
|
||||
override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?) {
|
||||
if (command == VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED) {
|
||||
val speed = extras?.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f) ?: 1f
|
||||
|
||||
player.playbackParameters = PlaybackParameters(speed)
|
||||
voiceNotePlaybackParameters.setSpeed(speed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.thoughtcrime.securesms.components.voice;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
|
||||
public final class VoiceNotePlaybackParameters {
|
||||
|
||||
private final MediaSessionCompat mediaSessionCompat;
|
||||
|
||||
VoiceNotePlaybackParameters(@NonNull MediaSessionCompat mediaSessionCompat) {
|
||||
this.mediaSessionCompat = mediaSessionCompat;
|
||||
}
|
||||
|
||||
@NonNull PlaybackParameters getParameters() {
|
||||
float speed = getSpeed();
|
||||
return new PlaybackParameters(speed);
|
||||
}
|
||||
|
||||
void setSpeed(float speed) {
|
||||
Bundle extras = new Bundle();
|
||||
extras.putFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, speed);
|
||||
|
||||
mediaSessionCompat.setExtras(extras);
|
||||
}
|
||||
|
||||
private float getSpeed() {
|
||||
Bundle extras = mediaSessionCompat.getController().getExtras();
|
||||
|
||||
if (extras == null) {
|
||||
return 1f;
|
||||
} else {
|
||||
return extras.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.os.Bundle;
|
||||
import android.os.ResultReceiver;
|
||||
import android.support.v4.media.MediaDescriptionCompat;
|
||||
import android.support.v4.media.session.PlaybackStateCompat;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
@@ -19,10 +20,14 @@ import com.google.android.exoplayer2.Timeline;
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||
import com.google.android.exoplayer2.source.ConcatenatingMediaSource;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
@@ -33,6 +38,7 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* ExoPlayer Preparer for Voice Notes. This only supports ACTION_PLAY_FROM_URI
|
||||
@@ -46,11 +52,12 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
|
||||
public static final Uri NEXT_URI = Uri.parse("file:///android_asset/sounds/state-change_confirm-down.ogg");
|
||||
public static final Uri END_URI = Uri.parse("file:///android_asset/sounds/state-change_confirm-up.ogg");
|
||||
|
||||
private final Context context;
|
||||
private final SimpleExoPlayer player;
|
||||
private final Context context;
|
||||
private final SimpleExoPlayer player;
|
||||
private final VoiceNoteQueueDataAdapter queueDataAdapter;
|
||||
private final AttachmentMediaSourceFactory mediaSourceFactory;
|
||||
private final ConcatenatingMediaSource dataSource;
|
||||
private final ConcatenatingMediaSource dataSource;
|
||||
private final VoiceNotePlaybackParameters voiceNotePlaybackParameters;
|
||||
|
||||
private boolean canLoadMore;
|
||||
private Uri latestUri = Uri.EMPTY;
|
||||
@@ -58,13 +65,15 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
|
||||
VoiceNotePlaybackPreparer(@NonNull Context context,
|
||||
@NonNull SimpleExoPlayer player,
|
||||
@NonNull VoiceNoteQueueDataAdapter queueDataAdapter,
|
||||
@NonNull AttachmentMediaSourceFactory mediaSourceFactory)
|
||||
@NonNull AttachmentMediaSourceFactory mediaSourceFactory,
|
||||
@NonNull VoiceNotePlaybackParameters voiceNotePlaybackParameters)
|
||||
{
|
||||
this.context = context;
|
||||
this.player = player;
|
||||
this.queueDataAdapter = queueDataAdapter;
|
||||
this.mediaSourceFactory = mediaSourceFactory;
|
||||
this.dataSource = new ConcatenatingMediaSource();
|
||||
this.dataSource = new ConcatenatingMediaSource();
|
||||
this.voiceNotePlaybackParameters = voiceNotePlaybackParameters;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -92,6 +101,7 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
|
||||
Log.d(TAG, "onPrepareFromUri: " + uri);
|
||||
|
||||
long messageId = extras.getLong(VoiceNoteMediaController.EXTRA_MESSAGE_ID);
|
||||
long threadId = extras.getLong(VoiceNoteMediaController.EXTRA_THREAD_ID);
|
||||
double progress = extras.getDouble(VoiceNoteMediaController.EXTRA_PROGRESS, 0);
|
||||
boolean singlePlayback = extras.getBoolean(VoiceNoteMediaController.EXTRA_PLAY_SINGLE, false);
|
||||
|
||||
@@ -101,7 +111,11 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
|
||||
SimpleTask.run(EXECUTOR,
|
||||
() -> {
|
||||
if (singlePlayback) {
|
||||
return loadMediaDescriptionForSinglePlayback(messageId);
|
||||
if (messageId != -1) {
|
||||
return loadMediaDescriptionForSinglePlayback(messageId);
|
||||
} else {
|
||||
return loadMediaDescriptionForDraftPlayback(threadId, uri);
|
||||
}
|
||||
} else {
|
||||
return loadMediaDescriptionsForConsecutivePlayback(messageId);
|
||||
}
|
||||
@@ -119,7 +133,10 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
|
||||
@Override
|
||||
public void onTimelineChanged(Timeline timeline, @Nullable Object manifest, int reason) {
|
||||
if (timeline.getWindowCount() >= window) {
|
||||
player.setPlayWhenReady(false);
|
||||
player.setPlaybackParameters(voiceNotePlaybackParameters.getParameters());
|
||||
player.seekTo(window, (long) (player.getDuration() * progress));
|
||||
player.setPlayWhenReady(true);
|
||||
player.removeListener(this);
|
||||
}
|
||||
}
|
||||
@@ -127,6 +144,10 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
|
||||
|
||||
player.prepare(dataSource);
|
||||
canLoadMore = !singlePlayback;
|
||||
} else if (Objects.equals(latestUri, uri)) {
|
||||
Log.w(TAG, "Requested playback but no voice notes could be found.");
|
||||
ThreadUtil.postToMain(() -> Toast.makeText(context, R.string.VoiceNotePlaybackPreparer__failed_to_play_voice_message, Toast.LENGTH_SHORT)
|
||||
.show());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -249,21 +270,31 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return Collections.singletonList(VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context ,messageRecord));
|
||||
MediaDescriptionCompat mediaDescriptionCompat = VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context ,messageRecord);
|
||||
if (mediaDescriptionCompat == null) {
|
||||
return Collections.emptyList();
|
||||
} else {
|
||||
return Collections.singletonList(mediaDescriptionCompat);
|
||||
}
|
||||
} catch (NoSuchMessageException e) {
|
||||
Log.w(TAG, "Could not find message.", e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull List<MediaDescriptionCompat> loadMediaDescriptionForDraftPlayback(long threadId, @NonNull Uri draftUri) {
|
||||
return Collections.singletonList(VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context, threadId, draftUri));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull List<MediaDescriptionCompat> loadMediaDescriptionsForConsecutivePlayback(long messageId) {
|
||||
try {
|
||||
List<MessageRecord> recordsAfter = DatabaseFactory.getMmsSmsDatabase(context).getMessagesAfterVoiceNoteInclusive(messageId, LIMIT);
|
||||
|
||||
return Stream.of(buildFilteredMessageRecordList(recordsAfter))
|
||||
.map(record -> VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context, record))
|
||||
.toList();
|
||||
return buildFilteredMessageRecordList(recordsAfter).stream()
|
||||
.map(record -> VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context, record))
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
} catch (NoSuchMessageException e) {
|
||||
Log.w(TAG, "Could not find message.", e);
|
||||
return Collections.emptyList();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user