Compare commits

..

116 Commits

Author SHA1 Message Date
Greyson Parrelli
b2c3a34d68 Bump version to 5.18.0 2021-07-21 16:57:04 -04:00
Greyson Parrelli
90925f4d8c Updated language translations. 2021-07-21 16:57:04 -04:00
Lucio Maciel
833f90ce53 Fix margins on message clusters and 1:1 messages. 2021-07-21 16:57:04 -04:00
Lucio Maciel
26c9b5166e Update timer icons and received text bubble. 2021-07-21 16:57:04 -04:00
Alex Hart
a27d60f830 Adjust new avatar picker logic.
* Better emoji rendering support
* Deleting an avatar will deselect it
* Added padding to the bottom of recyclers
* Disabled save if no edit / selection has been made.
* Clearing and saving will remove a user's avatar.
2021-07-21 16:57:04 -04:00
Alex Hart
a75f634c0a Add megaphone for new avatar picker. 2021-07-21 16:57:04 -04:00
lucio-signal
963c018e0c Add SingleLineEmojiTextView to fix flickering on conversations list. 2021-07-21 16:57:04 -04:00
Alex Hart
6cc0eed5fe Fail linked preview thumbnail request instead of crashing app. 2021-07-21 16:57:04 -04:00
Alex Hart
cdcc7b6fa5 Fix voice note player crash in Android 4.4 2021-07-21 16:57:04 -04:00
Alex Hart
24482b5a65 Disable conversation overscroll for Android 12. 2021-07-21 16:57:03 -04:00
Alex Hart
b100262c6a Fix crash when sending video (due to IllegalStateException). 2021-07-21 16:57:03 -04:00
Alex Hart
ed23c3fe7c Add avatar picker and defaults. 2021-07-21 16:57:03 -04:00
Greyson Parrelli
0093e1d3eb Add the ability to increase log lifespan. 2021-07-21 16:57:03 -04:00
Greyson Parrelli
7419da7247 Move logging into a database. 2021-07-21 16:57:03 -04:00
Greyson Parrelli
0b85852621 Bump version to 5.17.3 2021-07-19 13:05:11 -04:00
Greyson Parrelli
556518973d Fix crash during cache warming for fresh installs. 2021-07-19 13:01:53 -04:00
Greyson Parrelli
b9514d0b94 Bump version to 5.17.2 2021-07-19 12:40:21 -04:00
Greyson Parrelli
a9dab90a1e Updated language translations. 2021-07-19 12:40:21 -04:00
Greyson Parrelli
39709c8d64 Fix some timing issues around recipient events. 2021-07-19 12:40:21 -04:00
Greyson Parrelli
c2a6963a6d Warm up some recipients from the contact selection screen. 2021-07-19 11:57:26 -04:00
Greyson Parrelli
bfdebbfa5d Sort contacts that start with a number at the end. 2021-07-19 11:57:26 -04:00
Alex Hart
167a691018 Update SMS tag visibility in onRecipientChanged. 2021-07-19 11:57:26 -04:00
Greyson Parrelli
8004565c84 Bump version to 5.17.1 2021-07-16 15:55:54 -04:00
Greyson Parrelli
a101dc4fd1 Updated language translations. 2021-07-16 15:53:42 -04:00
Alex Hart
57f730d8ee Fix cursor issue for non-signal-contact searches. 2021-07-16 16:34:38 -03:00
Alex Hart
3543cc80ba Don't show SMS label for push groups. 2021-07-16 16:34:02 -03:00
Greyson Parrelli
71613d9db1 Ensure SQLCipher libraries are loaded. 2021-07-16 14:12:00 -04:00
Greyson Parrelli
4a0e6a3eb2 Improve logging around message sends. 2021-07-16 12:53:29 -04:00
Alex Hart
f1a87518e1 Fix contact search query returning outdated or bad recipients. 2021-07-16 13:53:17 -03:00
Alex Hart
61f880fd78 Hide all media for new conversations. 2021-07-16 13:20:30 -03:00
Greyson Parrelli
09904e7a16 Remove GIF button from attachment keyboard.
We've had it there for ~45 days for education purposes, but we can
remove it now.
2021-07-16 11:01:10 -04:00
Alex Hart
94658e9090 Fix bug where marquee text stopped scrolling. 2021-07-16 09:23:47 -03:00
Greyson Parrelli
a47448b6c6 Bump version to 5.17.0 2021-07-15 16:41:11 -04:00
Greyson Parrelli
7e4b9b685a Updated language translations. 2021-07-15 16:40:28 -04:00
lucio-signal
64922a8e51 Fix custom vibration settings. 2021-07-15 16:29:43 -04:00
Cody Henthorne
f65f4704c9 Improve routine around bulk attachment deletion. 2021-07-15 16:29:11 -04:00
Greyson Parrelli
b04ca202f6 Fix ApplicationMigrations UI. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
83086a5a2b Make Sms/MmsDatabase ID's autoincrement. 2021-07-15 16:28:13 -04:00
Cody Henthorne
51a521594f Fix crash when deleting threads directly after backup restore. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
0a7a7cf5a9 Fix envelope type conversion. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
6bd689504c Make internal recipient details selectable. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
efec40ff57 Fix crash with GV2 group repair during storage sync. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
69716dde4a Fix navigation directly to the help screen. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
e90fa05d60 Update recipient merging. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
580c000bda Move distribution message processing into the decryption phase. 2021-07-15 16:28:13 -04:00
lucio-signal
2f3d04d3e8 Add EmojiFilter to SearchView input field. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
bf37d412e9 Add message trimming info to the debug log. 2021-07-15 16:28:13 -04:00
Alex Hart
fd115ebb72 Drop voice notes that do not have a URI. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
b9657208fe Make ThreadDatabase ID's autoincrement. 2021-07-15 16:28:13 -04:00
Cody Henthorne
5d6d78a51e Initial WebSocket refactor. 2021-07-15 16:28:13 -04:00
Cody Henthorne
916006e664 Tweak sizes and padding of various keyboard elements. 2021-07-15 16:28:13 -04:00
Cody Henthorne
55c69cd50a Add additional fallback logic for change dialog. 2021-07-15 16:28:13 -04:00
Cody Henthorne
14565b0864 Fix crash when building notification state for messages without threads. 2021-07-15 16:28:13 -04:00
Alex Hart
a157c1ae1d Refresh contact search views. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
a4d458f969 Use current tag for nightly versionName. 2021-07-15 16:28:13 -04:00
Alex Hart
3f53abedab Migrate to new Share APIs. 2021-07-15 16:28:13 -04:00
Jordan Rose
68a2d5ed20 Reimplement ProfileCipherInputStream using libsignal-client.
libsignal-client provides an AES-GCM streaming interface that can
replace the implementation in AES-GCM-Provider. Using it from
ProfileCipherInputStream requires some knowledge about the tag size of
AES-GCM, but frees it from the JCE interface.

Note that it remains a serious error to not read the *entire* stream,
since the authentication tag is at the end!
2021-07-15 16:28:11 -04:00
Jordan Rose
35e9e31a7b Update to libsignal-client 0.8.3
This also fixes a misalignment where signal-client-android was on
0.8.0 but signal-client-java was 0.8.1, which was fortunately harmless
for this particular pair of versions.
2021-07-12 20:29:07 -04:00
Cody Henthorne
444d947743 Add RxJava. 2021-07-12 20:29:07 -04:00
Cody Henthorne
c427dbad08 Bump version to 5.16.3 2021-07-12 20:27:40 -04:00
Cody Henthorne
2cefe813e4 Updated language translations. 2021-07-12 20:17:43 -04:00
Alex Hart
123ffe42c3 Fix crash saving a FLAC file. 2021-07-12 13:37:59 -03:00
Alex Hart
da20e66ecd Fix issue where shared contact render would not hide audio view. 2021-07-12 13:24:04 -03:00
Alex Hart
901440017a Fix audio view width on very narrow screens. 2021-07-12 13:20:56 -03:00
Cody Henthorne
0be76a37fe Bump version to 5.16.2 2021-07-09 15:43:11 -04:00
Cody Henthorne
36dadc8777 Updated language translations. 2021-07-09 15:37:19 -04:00
Cody Henthorne
182749c101 Fix bug where some profile fetches would 400 over the websocket. 2021-07-09 15:30:08 -04:00
Alex Hart
d9228bd911 Fix issue where compose views still display under draft. 2021-07-09 15:30:08 -04:00
Greyson Parrelli
a361fcc8f3 Add additional logging to media send jobs. 2021-07-09 15:30:08 -04:00
Alex Hart
ff4f0b9f42 Stop voice note playback after user locks Signal. 2021-07-09 15:30:07 -04:00
Alex Hart
060dffc9cc Fix crash caused when quote draft left and re-entered. 2021-07-09 15:30:07 -04:00
Alex Hart
172cc302fc Add warning dialog for chat color deletion with no uses. 2021-07-09 15:30:07 -04:00
Alex Hart
416e62112f Refresh shared media screens. 2021-07-09 15:30:07 -04:00
Alex Hart
e584a90f81 Fix several voice note beta bugs.
* Sim label positioning
* Bad player state when navigating to and from conversations
* Scrolling date header placement
2021-07-09 15:29:40 -04:00
Cody Henthorne
9876ffb5e4 Bump version to 5.16.1 2021-07-08 17:52:50 -04:00
Cody Henthorne
53e10f2cad Updated language translations. 2021-07-08 17:43:16 -04:00
Cody Henthorne
cb79f75ac1 Fix bug when calling non-Signal contacts from Settings.
Fixes #11450
2021-07-08 17:36:14 -04:00
Cody Henthorne
5ec9c1cd90 Fix crash when saving media with octet stream content type. 2021-07-08 17:36:14 -04:00
Greyson Parrelli
1f28a30ace Add a nightly build type. 2021-07-08 17:36:14 -04:00
Alex Hart
7715917436 Fix issue where position would not update in draft. 2021-07-08 17:36:14 -04:00
Alex Hart
f79b445fdf Fix issue where drafts might not be properly deleted. 2021-07-08 17:36:14 -04:00
Alex Hart
14484deabe Implement count-down in inline player. 2021-07-08 17:36:14 -04:00
Alex Hart
3ac395d33e Fix row item size issue with huge fonts. 2021-07-08 17:36:14 -04:00
Alex Hart
f83b520ca9 Bump version to 5.16.0 2021-07-07 14:58:51 -03:00
Alex Hart
0123f9aa87 Updated language translations. 2021-07-07 14:58:51 -03:00
Alex Hart
06b64fe619 Add inline voice note player to conversation and conversation list. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
1bb87834d8 Reduce recipient resolves in MessageContentProcessor. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
ae4167ddae Write to RecipientIdCache on cache miss. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
383beafdef Move 'you' to end of unnamed groups. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
062e88b24f Rotate sender key flag. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
8299d49042 Show an error for internal users for decryption failures. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
4677883838 Improve mapping SignalServiceAddresses to Recipients. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
7f0a0bef5a Incrementally insert MSL entries for legacy group sends. 2021-07-07 14:58:50 -03:00
Greyson Parrelli
acc825971b Handle additional places where MSL entries need to be deleted. 2021-07-07 14:58:50 -03:00
Greyson Parrelli
62040d06b4 Create a write-through cache for PendingRetryReceiptDatabase. 2021-07-07 14:58:50 -03:00
Greyson Parrelli
0921ebe5f1 Add read and viewed receipts to the MSL. 2021-07-07 14:58:50 -03:00
Greyson Parrelli
3d0e15e2b8 Add delivery receipts to the MSL. 2021-07-07 14:58:50 -03:00
Greyson Parrelli
5372f79c40 Allow for MSL entries to be associated with multiple messages. 2021-07-07 14:58:50 -03:00
Christian
92e8f9de0e Do not collapse list to hide only one entry. 2021-07-07 14:58:50 -03:00
Christian
c3cf846a10 Fix OutdatedBuildReminder duration.
Fixes #11438
2021-07-07 14:58:50 -03:00
Alex Hart
5826b0c068 Implement drafts for voice notes. 2021-07-07 14:58:50 -03:00
Alex Hart
2d7c043398 Implement a playback speed toggle for voice notes. 2021-07-07 14:58:50 -03:00
Alex Hart
e20d6b63cf Fix adaptive shortcut icon shapes. 2021-07-07 14:58:50 -03:00
Cody Henthorne
b85c5eb54a Make it more likely 8 emoji fit on a row, fix emoji search emoticons. 2021-07-07 14:58:50 -03:00
Greyson Parrelli
a1c8573fad Insert resent messages at the proper location. 2021-07-07 14:58:50 -03:00
Cody Henthorne
90a27d2227 Fix device transfer test dependent on native library. 2021-07-07 14:58:50 -03:00
Cody Henthorne
c54c6018b2 Remove dead keyboard code after refresh. 2021-06-30 16:13:42 -04:00
Rainer Matischek
7419570f94 Fix rotation not updated on phones using 'Legacy API'.
Fixes #10940
2021-06-30 16:13:42 -04:00
Jim Gustafson
8860f792c4 Update to RingRTC v2.10.6 2021-06-30 16:13:42 -04:00
Greyson Parrelli
e47db0d532 Ensure recipients added to the cache have an identifier. 2021-06-30 16:13:42 -04:00
Greyson Parrelli
ab5d3badc2 Enable WAL mode. 2021-06-30 16:13:42 -04:00
Greyson Parrelli
fce362960f Switch to LinkifyCompat.
We've seen some inconsistencies across OEMs with Linkify. Hopefully
LinkifyCompat will resolve them.
2021-06-30 16:13:42 -04:00
Greyson Parrelli
5bf23dcfb3 Bump version to 5.15.6 2021-06-30 16:12:58 -04:00
Greyson Parrelli
65c7dc6ca2 Updated language translations. 2021-06-30 16:12:36 -04:00
Greyson Parrelli
e30a8b6954 Use proper EmojiTextView in conversation settings toolbar. 2021-06-30 15:43:21 -04:00
Alex Hart
838e318200 Fix edit profile theming issue and mute until issue. 2021-06-30 11:11:35 -03:00
586 changed files with 17698 additions and 7495 deletions

View File

@@ -24,8 +24,8 @@ 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: Remove Android 31 (S)
run: $ANDROID_HOME/tools/bin/sdkmanager --uninstall "platforms;android-31"
- name: Build with Gradle
run: ./gradlew qa

View File

@@ -57,8 +57,8 @@ protobuf {
}
}
def canonicalVersionCode = 872
def canonicalVersionName = "5.15.5"
def canonicalVersionCode = 882
def canonicalVersionName = "5.18.0"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -262,6 +262,15 @@ android {
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 {
dimension 'distribution'
@@ -308,13 +317,21 @@ android {
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
}
}
}
@@ -327,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)
}
}
@@ -344,7 +367,8 @@ android {
}
dependencies {
implementation 'androidx.fragment:fragment-ktx:1.2.5'
implementation 'androidx.core:core-ktx:1.5.0'
implementation 'androidx.fragment:fragment-ktx:1.3.5'
lintChecks project(':lintchecks')
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
@@ -374,6 +398,7 @@ 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:22.0.0') {
exclude group: 'com.google.firebase', module: 'firebase-core'
@@ -398,7 +423,7 @@ 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.1.0') {
@@ -407,7 +432,7 @@ dependencies {
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'
@@ -477,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 {
@@ -578,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"
@@ -598,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
}

View File

@@ -198,7 +198,7 @@
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value=".service.DirectShareService" />
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
</activity>
@@ -319,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"/>
@@ -651,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" />

Binary file not shown.

View File

@@ -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();
}

View File

@@ -32,11 +32,12 @@ import org.signal.aesgcmprovider.AesGcmProvider;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.AndroidLogger;
import org.signal.core.util.logging.Log;
import org.signal.core.util.logging.PersistentLogger;
import org.signal.core.util.tracing.Tracer;
import org.signal.glide.SignalGlideCodecs;
import org.signal.ringrtc.CallManager;
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
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;
@@ -53,7 +54,7 @@ import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
import org.thoughtcrime.securesms.logging.LogSecretProvider;
import org.thoughtcrime.securesms.logging.PersistentLogger;
import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
@@ -71,7 +72,6 @@ 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;
@@ -86,6 +86,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.
*
@@ -119,11 +122,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)
@@ -146,6 +153,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
})
.addBlocking("blob-provider", this::initializeBlobProvider)
.addBlocking("feature-flags", FeatureFlags::init)
.addNonBlocking(this::cleanAvatarStorage)
.addNonBlocking(this::initializeRevealableMessageManager)
.addNonBlocking(this::initializePendingRetryReceiptManager)
.addNonBlocking(this::initializeGcmCheck)
@@ -238,7 +246,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
}
private void initializeLogging() {
persistentLogger = new PersistentLogger(this, LogSecretProvider.getOrCreateAttachmentSecret(this), BuildConfig.VERSION_NAME, FeatureFlags.internalUser() ? 15 : 7, ByteUnit.KILOBYTES.toBytes(300));
persistentLogger = new PersistentLogger(this, TimeUnit.DAYS.toMillis(2));
org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), persistentLogger);
SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());
@@ -369,6 +377,11 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
BlobProvider.getInstance().initialize(this);
}
@WorkerThread
private void cleanAvatarStorage() {
AvatarPickerStorage.cleanOrphans(this);
}
@WorkerThread
private void initializeCleanup() {
int deleted = DatabaseFactory.getAttachmentDatabase(this).deleteAbandonedPreuploadedAttachments();

View File

@@ -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);

View File

@@ -1,176 +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.ReentrantSessionLock;
import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessageDatabase;
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 = ReentrantSessionLock.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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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 {

View File

@@ -150,6 +150,7 @@ public class DeviceActivity extends PassphraseRequiredActivity
});
}
@SuppressLint("MissingSuperCall")
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -171,6 +171,7 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
initializeObservers();
}
@SuppressLint("MissingSuperCall")
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);

View File

@@ -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)

View File

@@ -65,4 +65,8 @@ public class PushContactSelectionActivity extends ContactSelectionActivity {
setResult(RESULT_OK, resultIntent);
finish();
}
@Override
public void onSelectionChanged() {
}
}

View File

@@ -208,6 +208,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
.execute();
}
@SuppressLint("MissingSuperCall")
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);

View File

@@ -18,6 +18,7 @@
package org.thoughtcrime.securesms;
import android.Manifest;
import android.annotation.SuppressLint;
import android.app.PictureInPictureParams;
import android.content.Context;
import android.content.Intent;
@@ -180,6 +181,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
EventBus.getDefault().unregister(this);
}
@SuppressLint("MissingSuperCall")
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);

View File

@@ -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);

View File

@@ -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);
}
}
});
}

View File

@@ -0,0 +1,79 @@
package org.thoughtcrime.securesms.avatar
import android.net.Uri
import org.thoughtcrime.securesms.R
/**
* Represents an Avatar which the user can choose, edit, and render into a bitmap via the renderer.
*/
sealed class Avatar(
open val databaseId: DatabaseId
) {
data class Resource(
val resourceId: Int,
val color: Avatars.ColorPair
) : Avatar(DatabaseId.DoNotPersist) {
override fun isSameAs(other: Avatar): Boolean {
return other is Resource && other.resourceId == resourceId
}
}
data class Text(
val text: String,
val color: Avatars.ColorPair,
override val databaseId: DatabaseId,
) : Avatar(databaseId) {
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
return copy(databaseId = databaseId)
}
override fun isSameAs(other: Avatar): Boolean {
return other is Text && other.databaseId == databaseId
}
}
data class Vector(
val key: String,
val color: Avatars.ColorPair,
override val databaseId: DatabaseId,
) : Avatar(databaseId) {
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
return copy(databaseId = databaseId)
}
override fun isSameAs(other: Avatar): Boolean {
return other is Vector && other.key == key
}
}
data class Photo(
val uri: Uri,
val size: Long,
override val databaseId: DatabaseId
) : Avatar(databaseId) {
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
return copy(databaseId = databaseId)
}
override fun isSameAs(other: Avatar): Boolean {
return other is Photo && databaseId == other.databaseId
}
}
open fun withDatabaseId(databaseId: DatabaseId): Avatar {
throw UnsupportedOperationException()
}
abstract fun isSameAs(other: Avatar): Boolean
companion object {
fun getDefaultForSelf(): Resource = Resource(R.drawable.ic_profile_outline_40, Avatars.colors.random())
fun getDefaultForGroup(): Resource = Resource(R.drawable.ic_group_outline_40, Avatars.colors.random())
}
sealed class DatabaseId {
object DoNotPersist : DatabaseId()
object NotSet : DatabaseId()
data class Saved(val id: Long) : DatabaseId()
}
}

View File

@@ -0,0 +1,69 @@
package org.thoughtcrime.securesms.avatar
import android.os.Bundle
import java.lang.IllegalStateException
/**
* Utility class which encapsulates reading and writing Avatar objects to and from Bundles.
*/
object AvatarBundler {
private const val TEXT = "org.thoughtcrime.securesms.avatar.TEXT"
private const val COLOR = "org.thoughtcrime.securesms.avatar.COLOR"
private const val URI = "org.thoughtcrime.securesms.avatar.URI"
private const val KEY = "org.thoughtcrime.securesms.avatar.KEY"
private const val DATABASE_ID = "org.thoughtcrime.securesms.avatar.DATABASE_ID"
private const val SIZE = "org.thoughtcrime.securesms.avatar.SIZE"
fun bundleText(text: Avatar.Text): Bundle = Bundle().apply {
putString(TEXT, text.text)
putString(COLOR, text.color.code)
putDatabaseId(DATABASE_ID, text.databaseId)
}
fun extractText(bundle: Bundle): Avatar.Text = Avatar.Text(
text = requireNotNull(bundle.getString(TEXT)),
color = Avatars.colorMap[bundle.getString(COLOR)] ?: throw IllegalStateException(),
databaseId = bundle.getDatabaseId()
)
fun bundlePhoto(photo: Avatar.Photo): Bundle = Bundle().apply {
putParcelable(URI, photo.uri)
putLong(SIZE, photo.size)
putDatabaseId(DATABASE_ID, photo.databaseId)
}
fun extractPhoto(bundle: Bundle): Avatar.Photo = Avatar.Photo(
uri = requireNotNull(bundle.getParcelable(URI)),
size = bundle.getLong(SIZE),
databaseId = bundle.getDatabaseId()
)
fun bundleVector(vector: Avatar.Vector): Bundle = Bundle().apply {
putString(KEY, vector.key)
putString(COLOR, vector.color.code)
putDatabaseId(DATABASE_ID, vector.databaseId)
}
fun extractVector(bundle: Bundle): Avatar.Vector = Avatar.Vector(
key = requireNotNull(bundle.getString(KEY)),
color = Avatars.colorMap[bundle.getString(COLOR)] ?: throw IllegalStateException(),
databaseId = bundle.getDatabaseId()
)
private fun Bundle.getDatabaseId(): Avatar.DatabaseId {
val id = getLong(DATABASE_ID, -1L)
return if (id == -1L) {
Avatar.DatabaseId.NotSet
} else {
Avatar.DatabaseId.Saved(id)
}
}
private fun Bundle.putDatabaseId(key: String, databaseId: Avatar.DatabaseId) {
if (databaseId is Avatar.DatabaseId.Saved) {
putLong(key, databaseId.id)
}
}
}

View File

@@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.avatar
import android.view.View
import android.widget.ImageView
import com.airbnb.lottie.SimpleColorFilter
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
typealias OnAvatarColorClickListener = (Avatars.ColorPair) -> Unit
/**
* Selectable color item for choosing colors when editing a Text or Vector avatar.
*/
data class AvatarColorItem(
val colors: Avatars.ColorPair,
val selected: Boolean
) {
companion object {
fun registerViewHolder(adapter: MappingAdapter, onAvatarColorClickListener: OnAvatarColorClickListener) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onAvatarColorClickListener) }, R.layout.avatar_color_item))
}
}
class Model(val colorItem: AvatarColorItem) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = newItem.colorItem.colors == colorItem.colors
override fun areContentsTheSame(newItem: Model): Boolean = newItem.colorItem == colorItem
}
private class ViewHolder(itemView: View, private val onAvatarColorClickListener: OnAvatarColorClickListener) : MappingViewHolder<Model>(itemView) {
private val imageView: ImageView = findViewById(R.id.avatar_color_item)
override fun bind(model: Model) {
itemView.setOnClickListener { onAvatarColorClickListener(model.colorItem.colors) }
imageView.background.colorFilter = SimpleColorFilter(model.colorItem.colors.backgroundColor)
imageView.isSelected = model.colorItem.selected
}
}
}

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.avatar
import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.storage.FileStorage
import java.io.InputStream
object AvatarPickerStorage {
private const val DIRECTORY = "avatar_picker"
private const val FILENAME_BASE = "avatar"
@JvmStatic
fun read(context: Context, fileName: String) = FileStorage.read(context, DIRECTORY, fileName)
fun save(context: Context, media: Media): Uri {
val fileName = FileStorage.save(context, PartAuthority.getAttachmentStream(context, media.uri), DIRECTORY, FILENAME_BASE, MediaUtil.getExtension(context, media.uri) ?: "")
return PartAuthority.getAvatarPickerUri(fileName)
}
fun save(context: Context, inputStream: InputStream): Uri {
val fileName = FileStorage.save(context, inputStream, DIRECTORY, FILENAME_BASE, MimeTypeMap.getSingleton().getExtensionFromMimeType(MediaUtil.IMAGE_JPEG) ?: "")
return PartAuthority.getAvatarPickerUri(fileName)
}
@JvmStatic
fun cleanOrphans(context: Context) {
val avatarFiles = FileStorage.getAllFiles(context, DIRECTORY, FILENAME_BASE)
val database = DatabaseFactory.getAvatarPickerDatabase(context)
val photoAvatars = database
.getAllAvatars()
.filterIsInstance<Avatar.Photo>()
val inDatabaseFileNames = photoAvatars.map { PartAuthority.getAvatarPickerFilename(it.uri) }
val onDiskFileNames = avatarFiles.map { it.name }
val inDatabaseButNotOnDisk = inDatabaseFileNames - onDiskFileNames
val onDiskButNotInDatabase = onDiskFileNames - inDatabaseFileNames
avatarFiles
.filter { onDiskButNotInDatabase.contains(it.name) }
.forEach { it.delete() }
photoAvatars
.filter { inDatabaseButNotOnDisk.contains(PartAuthority.getAvatarPickerFilename(it.uri)) }
.forEach { database.deleteAvatar(it) }
}
}

View File

@@ -0,0 +1,132 @@
package org.thoughtcrime.securesms.avatar
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.net.Uri
import androidx.appcompat.content.res.AppCompatResources
import com.airbnb.lottie.SimpleColorFilter
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.util.MediaUtil
import org.whispersystems.libsignal.util.guava.Optional
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import javax.annotation.meta.Exhaustive
/**
* Renders Avatar objects into Media objects. This can involve creating a Bitmap, depending on the
* type of Avatar passed to `renderAvatar`
*/
object AvatarRenderer {
val DIMENSIONS = AvatarHelper.AVATAR_DIMENSIONS
fun getTypeface(context: Context): Typeface {
return Typeface.createFromAsset(context.assets, "fonts/Inter-Medium.otf")
}
fun renderAvatar(context: Context, avatar: Avatar, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
@Exhaustive
when (avatar) {
is Avatar.Resource -> renderResource(context, avatar, onAvatarRendered, onRenderFailed)
is Avatar.Vector -> renderVector(context, avatar, onAvatarRendered, onRenderFailed)
is Avatar.Photo -> renderPhoto(context, avatar, onAvatarRendered)
is Avatar.Text -> renderText(context, avatar, onAvatarRendered, onRenderFailed)
}
}
@JvmStatic
fun createTextDrawable(
context: Context,
avatar: Avatar.Text,
inverted: Boolean = false,
size: Int = DIMENSIONS,
): Drawable {
return TextAvatarDrawable(context, avatar, inverted, size)
}
private fun renderVector(context: Context, avatar: Avatar.Vector, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas ->
val drawableResourceId = Avatars.getDrawableResource(avatar.key) ?: return@renderInBackground Result.failure(Exception("Drawable resource for key ${avatar.key} does not exist."))
val vector: Drawable = requireNotNull(AppCompatResources.getDrawable(context, drawableResourceId))
vector.setBounds(0, 0, DIMENSIONS, DIMENSIONS)
canvas.drawColor(avatar.color.backgroundColor)
vector.draw(canvas)
Result.success(Unit)
}
}
private fun renderText(context: Context, avatar: Avatar.Text, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas ->
val textDrawable = createTextDrawable(context, avatar)
canvas.drawColor(avatar.color.backgroundColor)
textDrawable.draw(canvas)
Result.success(Unit)
}
}
private fun renderPhoto(context: Context, avatar: Avatar.Photo, onAvatarRendered: (Media) -> Unit) {
SignalExecutors.BOUNDED.execute {
val blob = BlobProvider.getInstance()
.forData(AvatarPickerStorage.read(context, PartAuthority.getAvatarPickerFilename(avatar.uri)), avatar.size)
.createForSingleSessionOnDisk(context)
onAvatarRendered(createMedia(blob, avatar.size))
}
}
private fun renderResource(context: Context, avatar: Avatar.Resource, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas ->
val resource: Drawable = requireNotNull(AppCompatResources.getDrawable(context, avatar.resourceId))
resource.colorFilter = SimpleColorFilter(avatar.color.foregroundColor)
val padding = (DIMENSIONS * 0.2).toInt()
resource.setBounds(0 + padding, 0 + padding, DIMENSIONS - padding, DIMENSIONS - padding)
canvas.drawColor(avatar.color.backgroundColor)
resource.draw(canvas)
Result.success(Unit)
}
}
private fun renderInBackground(context: Context, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit, drawAvatar: (Canvas) -> Result<Unit>) {
SignalExecutors.BOUNDED.execute {
val canvasBitmap = Bitmap.createBitmap(DIMENSIONS, DIMENSIONS, Bitmap.Config.ARGB_8888)
val canvas = Canvas(canvasBitmap)
val drawResult = drawAvatar(canvas)
if (drawResult.isFailure) {
canvasBitmap.recycle()
onRenderFailed(drawResult.exceptionOrNull())
}
val outStream = ByteArrayOutputStream()
val compressed = canvasBitmap.compress(Bitmap.CompressFormat.JPEG, 80, outStream)
canvasBitmap.recycle()
if (!compressed) {
onRenderFailed(IOException("Failed to compress bitmap"))
return@execute
}
val bytes = outStream.toByteArray()
val inStream = ByteArrayInputStream(bytes)
val uri = BlobProvider.getInstance().forData(inStream, bytes.size.toLong()).createForSingleSessionOnDisk(context)
onAvatarRendered(createMedia(uri, bytes.size.toLong()))
}
}
private fun createMedia(uri: Uri, size: Long): Media {
return Media(uri, MediaUtil.IMAGE_JPEG, System.currentTimeMillis(), DIMENSIONS, DIMENSIONS, size, 0, false, false, Optional.absent(), Optional.absent(), Optional.absent())
}
}

View File

@@ -0,0 +1,153 @@
package org.thoughtcrime.securesms.avatar
import android.content.Context
import android.graphics.Paint
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.annotation.Px
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import kotlin.math.abs
import kotlin.math.min
object Avatars {
/**
* Enum class mirroring AvatarColors codes but utilizing foreground colors for text or icon tinting.
*/
enum class ForegroundColor(private val code: String, @ColorInt val colorInt: Int) {
A100("A100", 0xFF3838F5.toInt()),
A110("A110", 0xFF1251D3.toInt()),
A120("A120", 0xFF086DA0.toInt()),
A130("A130", 0xFF067906.toInt()),
A140("A140", 0xFF661AFF.toInt()),
A150("A150", 0xFF9F00F0.toInt()),
A160("A160", 0xFFB8057C.toInt()),
A170("A170", 0xFFBE0404.toInt()),
A180("A180", 0xFF836B01.toInt()),
A190("A190", 0xFF7D6F40.toInt()),
A200("A200", 0xFF4F4F6D.toInt()),
A210("A210", 0xFF5C5C5C.toInt());
fun deserialize(code: String): ForegroundColor {
return values().find { it.code == code } ?: throw IllegalArgumentException()
}
fun serialize(): String = code
}
/**
* Mapping which associates color codes to ColorPair objects containing background and foreground colors.
*/
val colorMap: Map<String, ColorPair> = ForegroundColor.values().map {
ColorPair(AvatarColor.deserialize(it.serialize()), it)
}.associateBy {
it.code
}
val colors: List<ColorPair> = colorMap.values.toList()
val defaultAvatarsForSelf = linkedMapOf(
"avatar_abstract_01" to DefaultAvatar(R.drawable.ic_avatar_abstract_01, "A130"),
"avatar_abstract_02" to DefaultAvatar(R.drawable.ic_avatar_abstract_02, "A120"),
"avatar_abstract_03" to DefaultAvatar(R.drawable.ic_avatar_abstract_03, "A170"),
"avatar_cat" to DefaultAvatar(R.drawable.ic_avatar_cat, "A190"),
"avatar_dog" to DefaultAvatar(R.drawable.ic_avatar_dog, "A140"),
"avatar_fox" to DefaultAvatar(R.drawable.ic_avatar_fox, "A190"),
"avatar_tucan" to DefaultAvatar(R.drawable.ic_avatar_tucan, "A120"),
"avatar_sloth" to DefaultAvatar(R.drawable.ic_avatar_sloth, "A160"),
"avatar_dinosaur" to DefaultAvatar(R.drawable.ic_avatar_dinosour, "A130"),
"avatar_pig" to DefaultAvatar(R.drawable.ic_avatar_pig, "A180"),
"avatar_incognito" to DefaultAvatar(R.drawable.ic_avatar_incognito, "A220"),
"avatar_ghost" to DefaultAvatar(R.drawable.ic_avatar_ghost, "A100")
)
val defaultAvatarsForGroup = linkedMapOf(
"avatar_heart" to DefaultAvatar(R.drawable.ic_avatar_heart, "A180"),
"avatar_house" to DefaultAvatar(R.drawable.ic_avatar_house, "A120"),
"avatar_melon" to DefaultAvatar(R.drawable.ic_avatar_melon, "A110"),
"avatar_drink" to DefaultAvatar(R.drawable.ic_avatar_drink, "A170"),
"avatar_celebration" to DefaultAvatar(R.drawable.ic_avatar_celebration, "A100"),
"avatar_balloon" to DefaultAvatar(R.drawable.ic_avatar_balloon, "A220"),
"avatar_book" to DefaultAvatar(R.drawable.ic_avatar_book, "A100"),
"avatar_briefcase" to DefaultAvatar(R.drawable.ic_avatar_briefcase, "A180"),
"avatar_sunset" to DefaultAvatar(R.drawable.ic_avatar_sunset, "A120"),
"avatar_surfboard" to DefaultAvatar(R.drawable.ic_avatar_surfboard, "A110"),
"avatar_soccerball" to DefaultAvatar(R.drawable.ic_avatar_soccerball, "A130"),
"avatar_football" to DefaultAvatar(R.drawable.ic_avatar_football, "A220"),
)
@DrawableRes
fun getDrawableResource(key: String): Int? {
val defaultAvatar = defaultAvatarsForSelf.getOrDefault(key, defaultAvatarsForGroup[key])
return defaultAvatar?.vectorDrawableId
}
private fun textPaint(context: Context) = Paint().apply {
isAntiAlias = true
typeface = AvatarRenderer.getTypeface(context)
textSize = 1f
}
/**
* Calculate the text size for a give string using a maximum desired width and a maximum desired font size.
*/
@JvmStatic
fun getTextSizeForLength(context: Context, text: String, @Px maxWidth: Float, @Px maxSize: Float): Float {
val paint = textPaint(context)
return branchSizes(0f, maxWidth / 2, maxWidth, maxSize, paint, text)
}
/**
* Uses binary search to determine optimal font size to within 1% given the input parameters.
*/
private fun branchSizes(@Px lastFontSize: Float, @Px fontSize: Float, @Px target: Float, @Px maxFontSize: Float, paint: Paint, text: String): Float {
paint.textSize = fontSize
val textWidth = paint.measureText(text)
val delta = abs(lastFontSize - fontSize) / 2f
val isWithinThreshold = abs(1f - (textWidth / target)) <= 0.01f
if (textWidth == 0f) {
return maxFontSize
}
if (delta == 0f) {
return min(maxFontSize, fontSize)
}
return when {
fontSize >= maxFontSize -> {
maxFontSize
}
isWithinThreshold -> {
fontSize
}
textWidth > target -> {
branchSizes(fontSize, fontSize - delta, target, maxFontSize, paint, text)
}
else -> {
branchSizes(fontSize, fontSize + delta, target, maxFontSize, paint, text)
}
}
}
@JvmStatic
fun getForegroundColor(avatarColor: AvatarColor): ForegroundColor {
return ForegroundColor.values().firstOrNull { it.serialize() == avatarColor.serialize() } ?: ForegroundColor.A210
}
data class DefaultAvatar(
@DrawableRes val vectorDrawableId: Int,
val colorCode: String
)
data class ColorPair(
val backgroundAvatarColor: AvatarColor,
val foregroundAvatarColor: ForegroundColor
) {
@ColorInt val backgroundColor: Int = backgroundAvatarColor.colorInt()
@ColorInt val foregroundColor: Int = foregroundAvatarColor.colorInt
val code: String = backgroundAvatarColor.serialize()
}
}

View File

@@ -0,0 +1,58 @@
package org.thoughtcrime.securesms.avatar
import android.content.Context
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.PixelFormat
import android.graphics.drawable.Drawable
import android.util.TypedValue
import android.view.Gravity
import android.widget.FrameLayout
import androidx.core.view.updateLayoutParams
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
/**
* Uses EmojiTextView to properly render a Text Avatar with emoji in it.
*/
class TextAvatarDrawable(
context: Context,
avatar: Avatar.Text,
inverted: Boolean = false,
private val size: Int = AvatarRenderer.DIMENSIONS,
) : Drawable() {
private val layout: FrameLayout = FrameLayout(context)
private val textView: EmojiTextView = EmojiTextView(context)
init {
textView.typeface = AvatarRenderer.getTypeface(context)
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, Avatars.getTextSizeForLength(context, avatar.text, size * 0.8f, size * 0.45f))
textView.text = avatar.text
textView.gravity = Gravity.CENTER
textView.setTextColor(if (inverted) avatar.color.backgroundColor else avatar.color.foregroundColor)
layout.addView(textView)
textView.updateLayoutParams {
width = size
height = size
}
layout.measure(size, size)
layout.layout(0, 0, size, size)
}
override fun getIntrinsicHeight(): Int = size
override fun getIntrinsicWidth(): Int = size
override fun draw(canvas: Canvas) {
layout.draw(canvas)
}
override fun setAlpha(alpha: Int) = Unit
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
override fun getOpacity(): Int = PixelFormat.OPAQUE
}

View File

@@ -0,0 +1,65 @@
package org.thoughtcrime.securesms.avatar.photo
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.fragment.app.setFragmentResult
import androidx.navigation.Navigation
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarBundler
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), ImageEditorFragment.Controller {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val args = PhotoEditorFragmentArgs.fromBundle(requireArguments())
val photo = AvatarBundler.extractPhoto(args.photoAvatar)
val imageEditorFragment = ImageEditorFragment.newInstanceForAvatarEdit(photo.uri)
childFragmentManager.commit {
add(R.id.fragment_container, imageEditorFragment, IMAGE_EDITOR)
}
}
override fun onTouchEventsNeeded(needed: Boolean) {
}
override fun onRequestFullScreen(fullScreen: Boolean, hideKeyboard: Boolean) {
}
override fun onDoneEditing() {
val args = PhotoEditorFragmentArgs.fromBundle(requireArguments())
val applicationContext = requireContext().applicationContext
val imageEditorFragment: ImageEditorFragment = childFragmentManager.findFragmentByTag(IMAGE_EDITOR) as ImageEditorFragment
SignalExecutors.BOUNDED.execute {
val editedImageUri = imageEditorFragment.renderToSingleUseBlob()
val size = BlobProvider.getFileSize(editedImageUri) ?: 0
val inputStream = BlobProvider.getInstance().getStream(applicationContext, editedImageUri)
val onDiskUri = AvatarPickerStorage.save(applicationContext, inputStream)
val photo = AvatarBundler.extractPhoto(args.photoAvatar)
val database = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
val newPhoto = photo.copy(uri = onDiskUri, size = size)
database.update(newPhoto)
BlobProvider.getInstance().delete(requireContext(), photo.uri)
ThreadUtil.runOnMain {
setFragmentResult(REQUEST_KEY_EDIT, AvatarBundler.bundlePhoto(newPhoto))
Navigation.findNavController(requireView()).popBackStack()
}
}
}
companion object {
const val REQUEST_KEY_EDIT = "org.thoughtcrime.securesms.avatar.photo.EDIT"
private const val IMAGE_EDITOR = "image_editor"
}
}

View File

@@ -0,0 +1,235 @@
package org.thoughtcrime.securesms.avatar.picker
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.widget.PopupMenu
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarBundler
import org.thoughtcrime.securesms.avatar.photo.PhotoEditorFragment
import org.thoughtcrime.securesms.avatar.text.TextAvatarCreationFragment
import org.thoughtcrime.securesms.avatar.vector.VectorAvatarCreationFragment
import org.thoughtcrime.securesms.components.ButtonStripItemView
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
import org.thoughtcrime.securesms.groups.ParcelableGroupId
import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.visible
import java.util.Objects
/**
* Primary Avatar picker fragment, displays current user avatar and a list of recently used avatars and defaults.
*/
class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
companion object {
const val REQUEST_KEY_SELECT_AVATAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR"
const val SELECT_AVATAR_MEDIA = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_MEDIA"
const val SELECT_AVATAR_CLEAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_CLEAR"
private const val REQUEST_CODE_SELECT_IMAGE = 1
}
private val viewModel: AvatarPickerViewModel by viewModels(factoryProducer = this::createFactory)
private fun createFactory(): AvatarPickerViewModel.Factory {
val args = AvatarPickerFragmentArgs.fromBundle(requireArguments())
val groupId = ParcelableGroupId.get(args.groupId)
return AvatarPickerViewModel.Factory(AvatarPickerRepository(requireContext()), groupId, args.isNewGroup, args.groupAvatarMedia)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.avatar_picker_toolbar)
val recycler: RecyclerView = view.findViewById(R.id.avatar_picker_recycler)
val cameraButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_camera)
val photoButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_photo)
val textButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_text)
val saveButton: View = view.findViewById(R.id.avatar_picker_save)
val clearButton: View = view.findViewById(R.id.avatar_picker_clear)
recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16)))
val adapter = MappingAdapter()
AvatarPickerItem.register(adapter, this::onAvatarClick, this::onAvatarLongClick)
recycler.adapter = adapter
val avatarViewHolder = AvatarPickerItem.ViewHolder(view)
viewModel.state.observe(viewLifecycleOwner) { state ->
if (state.currentAvatar != null) {
avatarViewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false))
}
clearButton.visible = state.canClear
val wasEnabled = saveButton.isEnabled
saveButton.isEnabled = state.canSave
if (wasEnabled != state.canSave) {
val alpha = if (state.canSave) 1f else 0.5f
saveButton.animate().cancel()
saveButton.animate().alpha(alpha)
}
adapter.submitList(state.selectableAvatars.map { AvatarPickerItem.Model(it, it == state.currentAvatar) })
}
toolbar.setNavigationOnClickListener { Navigation.findNavController(it).popBackStack() }
cameraButton.setOnIconClickedListener { openCameraCapture() }
photoButton.setOnIconClickedListener { openGallery() }
textButton.setOnIconClickedListener { openTextEditor(null) }
saveButton.setOnClickListener { v ->
viewModel.save(
{
setFragmentResult(
REQUEST_KEY_SELECT_AVATAR,
Bundle().apply {
putParcelable(SELECT_AVATAR_MEDIA, it)
}
)
Navigation.findNavController(v).popBackStack()
},
{
setFragmentResult(
REQUEST_KEY_SELECT_AVATAR,
Bundle().apply {
putBoolean(SELECT_AVATAR_CLEAR, true)
}
)
Navigation.findNavController(v).popBackStack()
}
)
}
clearButton.setOnClickListener { viewModel.clear() }
setFragmentResultListener(TextAvatarCreationFragment.REQUEST_KEY_TEXT) { _, bundle ->
val text = AvatarBundler.extractText(bundle)
viewModel.onAvatarEditCompleted(text)
}
setFragmentResultListener(VectorAvatarCreationFragment.REQUEST_KEY_VECTOR) { _, bundle ->
val vector = AvatarBundler.extractVector(bundle)
viewModel.onAvatarEditCompleted(vector)
}
setFragmentResultListener(PhotoEditorFragment.REQUEST_KEY_EDIT) { _, bundle ->
val photo = AvatarBundler.extractPhoto(bundle)
viewModel.onAvatarEditCompleted(photo)
}
}
override fun onResume() {
super.onResume()
ViewUtil.hideKeyboard(requireContext(), requireView())
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE_SELECT_IMAGE && resultCode == Activity.RESULT_OK && data != null) {
val media: Media = Objects.requireNonNull(data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA))
viewModel.onAvatarPhotoSelectionCompleted(media)
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
private fun onAvatarClick(avatar: Avatar, isSelected: Boolean) {
if (isSelected) {
openEditor(avatar)
} else {
viewModel.onAvatarSelectedFromGrid(avatar)
}
}
private fun onAvatarLongClick(anchorView: View, avatar: Avatar): Boolean {
val menuRes = when (avatar) {
is Avatar.Photo -> R.menu.avatar_picker_context
is Avatar.Text -> R.menu.avatar_picker_context
is Avatar.Vector -> return false
is Avatar.Resource -> return false
}
val popup = PopupMenu(context, anchorView, Gravity.TOP)
popup.menuInflater.inflate(menuRes, popup.menu)
popup.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_delete -> viewModel.delete(avatar)
}
true
}
popup.show()
return true
}
fun openEditor(avatar: Avatar) {
when (avatar) {
is Avatar.Photo -> openPhotoEditor(avatar)
is Avatar.Resource -> throw UnsupportedOperationException()
is Avatar.Text -> openTextEditor(avatar)
is Avatar.Vector -> openVectorEditor(avatar)
}
}
fun openPhotoEditor(photo: Avatar.Photo) {
Navigation.findNavController(requireView())
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToAvatarPhotoEditorFragment(AvatarBundler.bundlePhoto(photo)))
}
fun openVectorEditor(vector: Avatar.Vector) {
Navigation.findNavController(requireView())
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToVectorAvatarCreationFragment(AvatarBundler.bundleVector(vector)))
}
fun openTextEditor(text: Avatar.Text?) {
val bundle = if (text != null) AvatarBundler.bundleText(text) else null
Navigation.findNavController(requireView())
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToTextAvatarCreationFragment(bundle))
}
fun openCameraCapture() {
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.onAllGranted {
val intent = AvatarSelectionActivity.getIntentForCameraCapture(requireContext())
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
}
.onAnyDenied {
Toast.makeText(requireContext(), R.string.AvatarSelectionBottomSheetDialogFragment__taking_a_photo_requires_the_camera_permission, Toast.LENGTH_SHORT)
.show()
}
.execute()
}
fun openGallery() {
Permissions.with(this)
.request(Manifest.permission.READ_EXTERNAL_STORAGE)
.ifNecessary()
.onAllGranted {
val intent = AvatarSelectionActivity.getIntentForGallery(requireContext())
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
}
.onAnyDenied {
Toast.makeText(requireContext(), R.string.AvatarSelectionBottomSheetDialogFragment__viewing_your_gallery_requires_the_storage_permission, Toast.LENGTH_SHORT)
.show()
}
.execute()
}
}

View File

@@ -0,0 +1,144 @@
package org.thoughtcrime.securesms.avatar.picker
import android.util.TypedValue
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.view.setPadding
import com.airbnb.lottie.SimpleColorFilter
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarRenderer
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
typealias OnAvatarClickListener = (Avatar, Boolean) -> Unit
typealias OnAvatarLongClickListener = (View, Avatar) -> Boolean
object AvatarPickerItem {
private val SELECTION_CHANGED = Any()
fun register(adapter: MappingAdapter, onAvatarClickListener: OnAvatarClickListener, onAvatarLongClickListener: OnAvatarLongClickListener) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onAvatarClickListener, onAvatarLongClickListener) }, R.layout.avatar_picker_item))
}
class Model(val avatar: Avatar, val isSelected: Boolean) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = avatar.isSameAs(newItem.avatar)
override fun areContentsTheSame(newItem: Model): Boolean = avatar == newItem.avatar && isSelected == newItem.isSelected
override fun getChangePayload(newItem: Model): Any? {
return if (newItem.avatar == avatar && isSelected != newItem.isSelected) {
SELECTION_CHANGED
} else {
null
}
}
}
class ViewHolder(
itemView: View,
private val onAvatarClickListener: OnAvatarClickListener? = null,
private val onAvatarLongClickListener: OnAvatarLongClickListener? = null
) : MappingViewHolder<Model>(itemView) {
private val imageView: ImageView = itemView.findViewById(R.id.avatar_picker_item_image)
private val textView: TextView = itemView.findViewById(R.id.avatar_picker_item_text)
private val selectedFader: View? = itemView.findViewById(R.id.avatar_picker_item_fader)
private val selectedOverlay: View? = itemView.findViewById(R.id.avatar_picker_item_selection_overlay)
init {
textView.typeface = AvatarRenderer.getTypeface(context)
textView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
updateAndApplyText(textView.text.toString())
}
}
private fun updateAndApplyText(text: String) {
val textSize = Avatars.getTextSizeForLength(context, text, textView.measuredWidth * 0.8f, textView.measuredHeight * 0.45f)
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
textView.text = text
}
override fun bind(model: Model) {
val alpha = if (model.isSelected) 1f else 0f
val scale = if (model.isSelected) 0.9f else 1f
imageView.animate().cancel()
textView.animate().cancel()
selectedOverlay?.animate()?.cancel()
selectedFader?.animate()?.cancel()
if (model.isSelected) {
itemView.setOnLongClickListener {
onAvatarLongClickListener?.invoke(itemView, model.avatar) ?: false
}
} else {
itemView.setOnLongClickListener(null)
}
itemView.setOnClickListener { onAvatarClickListener?.invoke(model.avatar, model.isSelected) }
if (payload.isNotEmpty() && payload.contains(SELECTION_CHANGED)) {
imageView.animate().scaleX(scale).scaleY(scale)
textView.animate().scaleX(scale).scaleY(scale)
selectedOverlay?.animate()?.alpha(alpha)
selectedFader?.animate()?.alpha(alpha)
return
}
imageView.scaleX = scale
imageView.scaleY = scale
textView.scaleX = scale
textView.scaleY = scale
selectedFader?.alpha = alpha
selectedOverlay?.alpha = alpha
imageView.clearColorFilter()
imageView.setPadding(0)
when (model.avatar) {
is Avatar.Text -> {
textView.visible = true
updateAndApplyText(model.avatar.text)
imageView.setImageDrawable(null)
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
textView.setTextColor(model.avatar.color.foregroundColor)
}
is Avatar.Vector -> {
textView.visible = false
val drawableId = Avatars.getDrawableResource(model.avatar.key)
if (drawableId == null) {
imageView.setImageDrawable(null)
} else {
imageView.setImageDrawable(AppCompatResources.getDrawable(context, drawableId))
}
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
}
is Avatar.Photo -> {
textView.visible = false
GlideApp.with(imageView).load(DecryptableStreamUriLoader.DecryptableUri(model.avatar.uri)).into(imageView)
}
is Avatar.Resource -> {
imageView.setPadding((imageView.width * 0.2).toInt())
textView.visible = false
GlideApp.with(imageView).clear(imageView)
imageView.setImageResource(model.avatar.resourceId)
imageView.colorFilter = SimpleColorFilter(model.avatar.color.foregroundColor)
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
}
}
}
}
}

View File

@@ -0,0 +1,189 @@
package org.thoughtcrime.securesms.avatar.picker
import android.content.Context
import android.net.Uri
import android.widget.Toast
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.StreamUtil
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage
import org.thoughtcrime.securesms.avatar.AvatarRenderer
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.NameUtil
import org.whispersystems.signalservice.api.util.StreamDetails
import java.io.IOException
private val TAG = Log.tag(AvatarPickerRepository::class.java)
class AvatarPickerRepository(context: Context) {
private val applicationContext = context.applicationContext
fun getAvatarForSelf(): Single<Avatar> = Single.fromCallable {
val details: StreamDetails? = AvatarHelper.getSelfProfileAvatarStream(applicationContext)
if (details != null) {
try {
val bytes = StreamUtil.readFully(details.stream)
Avatar.Photo(
BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(),
details.length,
Avatar.DatabaseId.DoNotPersist
)
} catch (e: IOException) {
Log.w(TAG, "Failed to read avatar!")
getDefaultAvatarForSelf()
}
} else {
getDefaultAvatarForSelf()
}
}
fun getAvatarForGroup(groupId: GroupId): Single<Avatar> = Single.fromCallable {
val recipient = Recipient.externalGroupExact(applicationContext, groupId)
if (AvatarHelper.hasAvatar(applicationContext, recipient.id)) {
try {
val bytes = AvatarHelper.getAvatarBytes(applicationContext, recipient.id)
Avatar.Photo(
BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(),
AvatarHelper.getAvatarLength(applicationContext, recipient.id),
Avatar.DatabaseId.DoNotPersist
)
} catch (e: IOException) {
Log.w(TAG, "Failed to read group avatar!")
getDefaultAvatarForGroup(recipient.avatarColor)
}
} else {
getDefaultAvatarForGroup(recipient.avatarColor)
}
}
fun getPersistedAvatarsForSelf(): Single<List<Avatar>> = Single.fromCallable {
DatabaseFactory.getAvatarPickerDatabase(applicationContext).getAvatarsForSelf()
}
fun getPersistedAvatarsForGroup(groupId: GroupId): Single<List<Avatar>> = Single.fromCallable {
DatabaseFactory.getAvatarPickerDatabase(applicationContext).getAvatarsForGroup(groupId)
}
fun getDefaultAvatarsForSelf(): Single<List<Avatar>> = Single.fromCallable {
Avatars.defaultAvatarsForSelf.entries.mapIndexed { index, entry ->
Avatar.Vector(entry.key, color = Avatars.colors[index % Avatars.colors.size], Avatar.DatabaseId.NotSet)
}
}
fun getDefaultAvatarsForGroup(): Single<List<Avatar>> = Single.fromCallable {
Avatars.defaultAvatarsForGroup.entries.mapIndexed { index, entry ->
Avatar.Vector(entry.key, color = Avatars.colors[index % Avatars.colors.size], Avatar.DatabaseId.NotSet)
}
}
fun writeMediaToMultiSessionStorage(media: Media, onMediaWrittenToMultiSessionStorage: (Uri) -> Unit) {
SignalExecutors.BOUNDED.execute {
onMediaWrittenToMultiSessionStorage(AvatarPickerStorage.save(applicationContext, media))
}
}
fun persistAvatarForSelf(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
SignalExecutors.BOUNDED.execute {
val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
val savedAvatar = avatarDatabase.saveAvatarForSelf(avatar)
avatarDatabase.markUsage(savedAvatar)
onPersisted(savedAvatar)
}
}
fun persistAvatarForGroup(avatar: Avatar, groupId: GroupId, onPersisted: (Avatar) -> Unit) {
SignalExecutors.BOUNDED.execute {
val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
val savedAvatar = avatarDatabase.saveAvatarForGroup(avatar, groupId)
avatarDatabase.markUsage(savedAvatar)
onPersisted(savedAvatar)
}
}
fun persistAndCreateMediaForSelf(avatar: Avatar, onSaved: (Media) -> Unit) {
SignalExecutors.BOUNDED.execute {
if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) {
persistAvatarForSelf(avatar) {
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
}
} else {
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
}
}
}
fun persistAndCreateMediaForGroup(avatar: Avatar, groupId: GroupId, onSaved: (Media) -> Unit) {
SignalExecutors.BOUNDED.execute {
if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) {
persistAvatarForGroup(avatar, groupId) {
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
}
} else {
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
}
}
}
fun createMediaForNewGroup(avatar: Avatar, onSaved: (Media) -> Unit) {
SignalExecutors.BOUNDED.execute {
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
}
}
fun handleRenderFailure(throwable: Throwable?) {
Log.w(TAG, "Failed to render avatar.", throwable)
ThreadUtil.postToMain {
Toast.makeText(applicationContext, R.string.AvatarPickerRepository__failed_to_save_avatar, Toast.LENGTH_SHORT).show()
}
}
fun getDefaultAvatarForSelf(): Avatar {
val initials = NameUtil.getAbbreviation(Recipient.self().getDisplayName(applicationContext))
return if (initials.isNullOrBlank()) {
Avatar.getDefaultForSelf()
} else {
Avatar.Text(initials, requireNotNull(Avatars.colorMap[Recipient.self().avatarColor.serialize()]), Avatar.DatabaseId.DoNotPersist)
}
}
fun getDefaultAvatarForGroup(groupId: GroupId): Avatar {
val recipient = Recipient.externalGroupExact(applicationContext, groupId)
return getDefaultAvatarForGroup(recipient.avatarColor)
}
fun getDefaultAvatarForGroup(color: AvatarColor?): Avatar {
val colorPair = Avatars.colorMap[color?.serialize()]
val defaultColor = Avatar.getDefaultForGroup()
return if (colorPair != null) {
defaultColor.copy(color = colorPair)
} else {
defaultColor
}
}
fun delete(avatar: Avatar, onDelete: () -> Unit) {
SignalExecutors.BOUNDED.execute {
if (avatar.databaseId is Avatar.DatabaseId.Saved) {
val avatarDatabase = DatabaseFactory.getAvatarPickerDatabase(applicationContext)
avatarDatabase.deleteAvatar(avatar)
}
onDelete()
}
}
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.avatar.picker
import org.thoughtcrime.securesms.avatar.Avatar
data class AvatarPickerState(
val currentAvatar: Avatar? = null,
val selectableAvatars: List<Avatar> = listOf(),
val canSave: Boolean = false,
val canClear: Boolean = false,
val isCleared: Boolean = false
)

View File

@@ -0,0 +1,198 @@
package org.thoughtcrime.securesms.avatar.picker
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.util.livedata.Store
sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepository) : ViewModel() {
private val disposables = CompositeDisposable()
private val store = Store(AvatarPickerState())
val state: LiveData<AvatarPickerState> = store.stateLiveData
protected abstract fun getAvatar(): Single<Avatar>
protected abstract fun getDefaultAvatarFromRepository(): Avatar
protected abstract fun getPersistedAvatars(): Single<List<Avatar>>
protected abstract fun getDefaultAvatars(): Single<List<Avatar>>
protected abstract fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit)
protected abstract fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit)
fun delete(avatar: Avatar) {
repository.delete(avatar) {
refreshAvatar()
refreshSelectableAvatars()
}
}
fun clear() {
store.update {
val avatar = getDefaultAvatarFromRepository()
it.copy(currentAvatar = avatar, canSave = true, canClear = false, isCleared = true)
}
}
fun save(onSaved: (Media) -> Unit, onCleared: () -> Unit) {
if (store.state.isCleared) {
onCleared()
} else {
val avatar = store.state.currentAvatar ?: throw AssertionError()
persistAndCreateMedia(avatar, onSaved)
}
}
fun onAvatarSelectedFromGrid(avatar: Avatar) {
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) }
}
fun onAvatarEditCompleted(avatar: Avatar) {
persistAvatar(avatar) { saved ->
store.update { it.copy(currentAvatar = saved, canSave = isSaveable(saved), canClear = true, isCleared = false) }
refreshSelectableAvatars()
}
}
fun onAvatarPhotoSelectionCompleted(media: Media) {
repository.writeMediaToMultiSessionStorage(media) { multiSessionUri ->
persistAvatar(Avatar.Photo(multiSessionUri, media.size, Avatar.DatabaseId.NotSet)) { avatar ->
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) }
refreshSelectableAvatars()
}
}
}
protected fun refreshAvatar() {
disposables.add(
getAvatar().subscribeOn(Schedulers.io()).subscribe { avatar ->
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = avatar is Avatar.Photo && !isSaveable(avatar), isCleared = false) }
}
)
}
protected fun refreshSelectableAvatars() {
disposables.add(
Single.zip(getPersistedAvatars(), getDefaultAvatars()) { custom, def ->
val customKeys = custom.filterIsInstance(Avatar.Vector::class.java).map { it.key }
custom + def.filterNot {
it is Avatar.Vector && customKeys.contains(it.key)
}
}.subscribeOn(Schedulers.io()).subscribe { avatars ->
store.update { it.copy(selectableAvatars = avatars) }
}
)
}
private fun isSaveable(avatar: Avatar) = avatar.databaseId != Avatar.DatabaseId.DoNotPersist
override fun onCleared() {
disposables.dispose()
}
private class SelfAvatarPickerViewModel(private val repository: AvatarPickerRepository) : AvatarPickerViewModel(repository) {
init {
refreshAvatar()
refreshSelectableAvatars()
}
override fun getAvatar(): Single<Avatar> = repository.getAvatarForSelf()
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForSelf()
override fun getPersistedAvatars(): Single<List<Avatar>> = repository.getPersistedAvatarsForSelf()
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForSelf()
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
repository.persistAvatarForSelf(avatar, onPersisted)
}
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) {
repository.persistAndCreateMediaForSelf(avatar, onSaved)
}
}
private class GroupAvatarPickerViewModel(
private val groupId: GroupId,
private val repository: AvatarPickerRepository,
groupAvatarMedia: Media?
) : AvatarPickerViewModel(repository) {
private val initialAvatar: Avatar? = groupAvatarMedia?.let { Avatar.Photo(it.uri, it.size, Avatar.DatabaseId.DoNotPersist) }
init {
refreshAvatar()
refreshSelectableAvatars()
}
override fun getAvatar(): Single<Avatar> {
return if (initialAvatar != null) {
Single.just(initialAvatar)
} else {
repository.getAvatarForGroup(groupId)
}
}
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(groupId)
override fun getPersistedAvatars(): Single<List<Avatar>> = repository.getPersistedAvatarsForGroup(groupId)
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForGroup()
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
repository.persistAvatarForGroup(avatar, groupId, onPersisted)
}
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) {
repository.persistAndCreateMediaForGroup(avatar, groupId, onSaved)
}
}
private class NewGroupAvatarPickerViewModel(
private val repository: AvatarPickerRepository,
initialMedia: Media?
) : AvatarPickerViewModel(repository) {
private val initialAvatar: Avatar? = initialMedia?.let { Avatar.Photo(it.uri, it.size, Avatar.DatabaseId.DoNotPersist) }
init {
refreshAvatar()
refreshSelectableAvatars()
}
override fun getAvatar(): Single<Avatar> {
return if (initialAvatar != null) {
Single.just(initialAvatar)
} else {
Single.fromCallable { getDefaultAvatarFromRepository() }
}
}
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(null)
override fun getPersistedAvatars(): Single<List<Avatar>> = Single.just(listOf())
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForGroup()
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) = onPersisted(avatar)
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) = repository.createMediaForNewGroup(avatar, onSaved)
}
class Factory(
private val repository: AvatarPickerRepository,
private val groupId: GroupId?,
private val isNewGroup: Boolean,
private val groupAvatarMedia: Media?
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
val viewModel = if (groupId == null && !isNewGroup) {
SelfAvatarPickerViewModel(repository)
} else if (groupId == null) {
NewGroupAvatarPickerViewModel(repository, groupAvatarMedia)
} else {
GroupAvatarPickerViewModel(groupId, repository, groupAvatarMedia)
}
return requireNotNull(modelClass.cast(viewModel))
}
}
}

View File

@@ -0,0 +1,158 @@
package org.thoughtcrime.securesms.avatar.text
import android.os.Bundle
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import androidx.appcompat.widget.Toolbar
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionManager
import com.google.android.material.tabs.TabLayout
import org.signal.core.util.EditTextUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarBundler
import org.thoughtcrime.securesms.avatar.AvatarColorItem
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.avatar.picker.AvatarPickerItem
import org.thoughtcrime.securesms.components.BoldSelectionTabItem
import org.thoughtcrime.securesms.components.ControllableTabLayout
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Fragment to create an avatar based off of a Vector or Text (via a pager)
*/
class TextAvatarCreationFragment : Fragment(R.layout.text_avatar_creation_fragment_hidden_recycler) {
private val viewModel: TextAvatarCreationViewModel by viewModels(factoryProducer = this::createFactory)
private lateinit var textInput: EditText
private lateinit var recycler: RecyclerView
private val withRecyclerSet = ConstraintSet()
private val withoutRecyclerSet = ConstraintSet()
private var hasBoundFromViewModel: Boolean = false
private fun createFactory(): TextAvatarCreationViewModel.Factory {
val args = TextAvatarCreationFragmentArgs.fromBundle(requireArguments())
val textBundle = args.textAvatar
val text = if (textBundle != null) {
AvatarBundler.extractText(textBundle)
} else {
Avatar.Text("", Avatars.colors.random(), Avatar.DatabaseId.NotSet)
}
return TextAvatarCreationViewModel.Factory(text)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.text_avatar_creation_toolbar)
val tabLayout: ControllableTabLayout = view.findViewById(R.id.text_avatar_creation_tabs)
val doneButton: View = view.findViewById(R.id.text_avatar_creation_done)
withRecyclerSet.load(requireContext(), R.layout.text_avatar_creation_fragment)
withoutRecyclerSet.load(requireContext(), R.layout.text_avatar_creation_fragment_hidden_recycler)
recycler = view.findViewById(R.id.text_avatar_creation_recycler)
textInput = view.findViewById(R.id.avatar_picker_item_text)
toolbar.setNavigationOnClickListener { Navigation.findNavController(it).popBackStack() }
BoldSelectionTabItem.registerListeners(tabLayout)
val onTabSelectedListener = OnTabSelectedListener()
tabLayout.addOnTabSelectedListener(onTabSelectedListener)
onTabSelectedListener.onTabSelected(requireNotNull(tabLayout.getTabAt(tabLayout.selectedTabPosition)))
val adapter = MappingAdapter()
recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16)))
AvatarColorItem.registerViewHolder(adapter) {
viewModel.setColor(it)
}
recycler.adapter = adapter
val viewHolder = AvatarPickerItem.ViewHolder(view)
viewModel.state.observe(viewLifecycleOwner) { state ->
EditTextUtil.setCursorColor(textInput, state.currentAvatar.color.foregroundColor)
val hadText = textInput.length() > 0
val selectionStart = textInput.selectionStart
val selectionEnd = textInput.selectionEnd
viewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false))
textInput.post {
if (!hadText) {
textInput.setSelection(textInput.length())
} else {
textInput.setSelection(selectionStart, selectionEnd)
}
}
adapter.submitList(state.colors().map { AvatarColorItem.Model(it) })
hasBoundFromViewModel = true
}
EditTextUtil.addGraphemeClusterLimitFilter(textInput, 3)
textInput.doAfterTextChanged {
if (it != null && hasBoundFromViewModel) {
viewModel.setText(it.toString())
}
}
doneButton.setOnClickListener { v ->
setFragmentResult(REQUEST_KEY_TEXT, AvatarBundler.bundleText(viewModel.getCurrentAvatar()))
Navigation.findNavController(v).popBackStack()
}
textInput.setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
doneButton.performClick()
true
} else {
false
}
}
}
private inner class OnTabSelectedListener : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
when (tab.position) {
0 -> {
textInput.isEnabled = true
ViewUtil.focusAndShowKeyboard(textInput)
val constraintLayout = requireView() as ConstraintLayout
TransitionManager.endTransitions(constraintLayout)
withoutRecyclerSet.applyTo(constraintLayout)
TransitionManager.beginDelayedTransition(constraintLayout)
textInput.setSelection(textInput.length())
}
1 -> {
textInput.isEnabled = false
ViewUtil.hideKeyboard(requireContext(), textInput)
val constraintLayout = requireView() as ConstraintLayout
TransitionManager.endTransitions(constraintLayout)
withRecyclerSet.applyTo(constraintLayout)
TransitionManager.beginDelayedTransition(constraintLayout)
}
}
}
override fun onTabUnselected(tab: TabLayout.Tab?) = Unit
override fun onTabReselected(tab: TabLayout.Tab?) = Unit
}
companion object {
const val REQUEST_KEY_TEXT = "org.thoughtcrime.securesms.avatar.text.TEXT"
}
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.avatar.text
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarColorItem
import org.thoughtcrime.securesms.avatar.Avatars
data class TextAvatarCreationState(
val currentAvatar: Avatar.Text,
) {
fun colors(): List<AvatarColorItem> = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) }
}

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.avatar.text
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.util.livedata.Store
class TextAvatarCreationViewModel(initialText: Avatar.Text) : ViewModel() {
private val store = Store(TextAvatarCreationState(initialText))
val state: LiveData<TextAvatarCreationState> = Transformations.distinctUntilChanged(store.stateLiveData)
fun setColor(colorPair: Avatars.ColorPair) {
store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) }
}
fun setText(text: String) {
store.update {
if (it.currentAvatar.text == text) {
it
} else {
it.copy(currentAvatar = it.currentAvatar.copy(text = text))
}
}
}
fun getCurrentAvatar(): Avatar.Text {
return store.state.currentAvatar
}
class Factory(private val initialText: Avatar.Text) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(TextAvatarCreationViewModel(initialText)))
}
}
}

View File

@@ -0,0 +1,64 @@
package org.thoughtcrime.securesms.avatar.vector
import android.os.Bundle
import android.view.View
import android.widget.ImageView
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.lottie.SimpleColorFilter
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarBundler
import org.thoughtcrime.securesms.avatar.AvatarColorItem
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Fragment to create an avatar based off a default vector.
*/
class VectorAvatarCreationFragment : Fragment(R.layout.vector_avatar_creation_fragment) {
private val viewModel: VectorAvatarCreationViewModel by viewModels(factoryProducer = this::createFactory)
private fun createFactory(): VectorAvatarCreationViewModel.Factory {
val args = VectorAvatarCreationFragmentArgs.fromBundle(requireArguments())
val vectorBundle = args.vectorAvatar
return VectorAvatarCreationViewModel.Factory(AvatarBundler.extractVector(vectorBundle))
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.vector_avatar_creation_toolbar)
val recycler: RecyclerView = view.findViewById(R.id.vector_avatar_creation_recycler)
val doneButton: View = view.findViewById(R.id.vector_avatar_creation_done)
val preview: ImageView = view.findViewById(R.id.vector_avatar_creation_image)
val adapter = MappingAdapter()
recycler.adapter = adapter
recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16)))
AvatarColorItem.registerViewHolder(adapter) {
viewModel.setColor(it)
}
viewModel.state.observe(viewLifecycleOwner) { state ->
preview.background.colorFilter = SimpleColorFilter(state.currentAvatar.color.backgroundColor)
preview.setImageResource(requireNotNull(Avatars.getDrawableResource(state.currentAvatar.key)))
adapter.submitList(state.colors().map { AvatarColorItem.Model(it) })
}
toolbar.setNavigationOnClickListener { Navigation.findNavController(view).popBackStack() }
doneButton.setOnClickListener {
setFragmentResult(REQUEST_KEY_VECTOR, AvatarBundler.bundleVector(viewModel.getCurrentAvatar()))
Navigation.findNavController(it).popBackStack()
}
}
companion object {
const val REQUEST_KEY_VECTOR = "org.thoughtcrime.securesms.avatar.text.VECTOR"
}
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.avatar.vector
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarColorItem
import org.thoughtcrime.securesms.avatar.Avatars
data class VectorAvatarCreationState(
val currentAvatar: Avatar.Vector,
) {
fun colors(): List<AvatarColorItem> = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) }
}

View File

@@ -0,0 +1,27 @@
package org.thoughtcrime.securesms.avatar.vector
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.util.livedata.Store
class VectorAvatarCreationViewModel(initialAvatar: Avatar.Vector) : ViewModel() {
private val store = Store(VectorAvatarCreationState(initialAvatar))
val state: LiveData<VectorAvatarCreationState> = store.stateLiveData
fun setColor(colorPair: Avatars.ColorPair) {
store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) }
}
fun getCurrentAvatar() = store.state.currentAvatar
class Factory(private val initialAvatar: Avatar.Vector) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(VectorAvatarCreationViewModel(initialAvatar)))
}
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -106,7 +106,7 @@ public final class AvatarImageView extends AppCompatImageView {
outlinePaint = ThemeUtil.isDarkTheme(context) ? DARK_THEME_OUTLINE_PAINT : LIGHT_THEME_OUTLINE_PAINT;
unknownRecipientDrawable = new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20).asDrawable(context, AvatarColor.UNKNOWN.colorInt(), inverted);
unknownRecipientDrawable = new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20).asDrawable(context, AvatarColor.UNKNOWN, inverted);
blurred = false;
chatColors = null;
}
@@ -248,7 +248,7 @@ public final class AvatarImageView extends AppCompatImageView {
requestManager.clear(this);
if (fallbackPhotoProvider != null) {
setImageDrawable(fallbackPhotoProvider.getPhotoForRecipientWithoutName()
.asDrawable(getContext(), AvatarColor.UNKNOWN.colorInt(), inverted));
.asDrawable(getContext(), AvatarColor.UNKNOWN, inverted));
} else {
setImageDrawable(unknownRecipientDrawable);
}
@@ -285,7 +285,7 @@ public final class AvatarImageView extends AppCompatImageView {
{
Drawable fallback = Util.firstNonNull(fallbackPhotoProvider, Recipient.DEFAULT_FALLBACK_PHOTO_PROVIDER)
.getPhotoForGroup()
.asDrawable(getContext(), color.colorInt());
.asDrawable(getContext(), color);
GlideApp.with(this)
.load(avatarBytes)

View File

@@ -0,0 +1,88 @@
package org.thoughtcrime.securesms.components
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import android.widget.TextView
import androidx.core.widget.doAfterTextChanged
import com.google.android.material.tabs.TabLayout
import org.thoughtcrime.securesms.R
import java.util.Objects
/**
* Custom View for Tabs which will render bold text when the view is selected
*/
class BoldSelectionTabItem @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
private lateinit var unselectedTextView: TextView
private lateinit var selectedTextView: TextView
override fun onFinishInflate() {
super.onFinishInflate()
unselectedTextView = findViewById(android.R.id.text1)
selectedTextView = findViewById(R.id.text1_bold)
unselectedTextView.doAfterTextChanged {
selectedTextView.text = it
}
}
fun select() {
unselectedTextView.alpha = 0f
selectedTextView.alpha = 1f
}
fun unselect() {
unselectedTextView.alpha = 1f
selectedTextView.alpha = 0f
}
companion object {
@JvmStatic
fun registerListeners(tabLayout: ControllableTabLayout) {
val newTabListener = NewTabListener()
val onTabSelectedListener = OnTabSelectedListener()
(0 until tabLayout.tabCount).mapNotNull { tabLayout.getTabAt(it) }.forEach {
newTabListener.onNewTab(it)
if (it.isSelected) {
onTabSelectedListener.onTabSelected(it)
} else {
onTabSelectedListener.onTabUnselected(it)
}
}
tabLayout.setNewTabListener(newTabListener)
tabLayout.addOnTabSelectedListener(onTabSelectedListener)
}
}
private class NewTabListener : ControllableTabLayout.NewTabListener {
override fun onNewTab(tab: TabLayout.Tab) {
val customView = tab.customView
if (customView == null) {
tab.setCustomView(R.layout.bold_selection_tab_item)
}
}
}
private class OnTabSelectedListener : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
val view = Objects.requireNonNull(tab.customView) as BoldSelectionTabItem
view.select()
}
override fun onTabUnselected(tab: TabLayout.Tab) {
val view = Objects.requireNonNull(tab.customView) as BoldSelectionTabItem
view.unselect()
}
override fun onTabReselected(tab: TabLayout.Tab) = Unit
}
}

View File

@@ -0,0 +1,41 @@
package org.thoughtcrime.securesms.components
import android.content.Context
import android.util.AttributeSet
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import org.thoughtcrime.securesms.R
class ButtonStripItemView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
private val iconView: ImageView
private val labelView: TextView
init {
inflate(context, R.layout.button_strip_item_view, this)
iconView = findViewById(R.id.icon)
labelView = findViewById(R.id.label)
val array = context.obtainStyledAttributes(attrs, R.styleable.ButtonStripItemView)
val icon = array.getDrawable(R.styleable.ButtonStripItemView_bsiv_icon)
val contentDescription = array.getString(R.styleable.ButtonStripItemView_bsiv_icon_contentDescription)
val label = array.getString(R.styleable.ButtonStripItemView_bsiv_label)
iconView.setImageDrawable(icon)
iconView.contentDescription = contentDescription
labelView.text = label
array.recycle()
}
fun setOnIconClickedListener(onIconClickedListener: (() -> Unit)?) {
iconView.setOnClickListener { onIconClickedListener?.invoke() }
}
}

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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();

View File

@@ -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)
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -10,9 +10,10 @@ import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.Emoj
import org.thoughtcrime.securesms.util.InsetItemDecoration
import org.thoughtcrime.securesms.util.ViewUtil
private val EDGE_LENGTH: Int = ViewUtil.dpToPx(7)
private val HORIZONTAL_INSET: Int = ViewUtil.dpToPx(11)
private val VERTICAL_INSET: Int = ViewUtil.dpToPx(8)
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
@@ -41,11 +42,12 @@ class EmojiItemDecoration(private val allowVariations: Boolean, private val vari
private class SetInset : InsetItemDecoration.SetInset() {
override fun setInset(outRect: Rect, view: View, parent: RecyclerView) {
val isFirstHeader = view.javaClass == AppCompatTextView::class.java && getPosition(view, parent) == 0
val isHeader = view.javaClass == AppCompatTextView::class.java
outRect.left = HORIZONTAL_INSET
outRect.right = HORIZONTAL_INSET
outRect.top = if (isFirstHeader) 0 else VERTICAL_INSET
outRect.bottom = VERTICAL_INSET
outRect.top = if (isHeader) HEADER_VERTICAL_INSET else EMOJI_VERTICAL_INSET
outRect.bottom = if (isHeader) 0 else EMOJI_VERTICAL_INSET
}
}
}

View File

@@ -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);
}
}

View File

@@ -16,22 +16,19 @@ import androidx.recyclerview.widget.LinearSmoothScroller;
import androidx.recyclerview.widget.RecyclerView;
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.emoji.EmojiCategory;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.MappingModelList;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.List;
import java.util.Optional;
public class EmojiPageView extends RecyclerView implements VariationSelectorListener {
private EmojiPageModel model;
private AdapterFactory adapterFactory;
private LinearLayoutManager layoutManager;
private RecyclerView.OnItemTouchListener scrollDisabler;
@@ -60,26 +57,26 @@ public class EmojiPageView extends RecyclerView implements VariationSelectorList
@NonNull VariationSelectorListener variationSelectorListener,
boolean allowVariations,
@NonNull LinearLayoutManager layoutManager,
@LayoutRes int displayItemLayoutResId)
@LayoutRes int displayEmojiLayoutResId,
@LayoutRes int displayEmoticonLayoutResId)
{
super(context);
initialize(emojiSelectionListener, variationSelectorListener, allowVariations, layoutManager, displayItemLayoutResId);
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);
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));
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 displayItemLayoutResId)
@LayoutRes int displayEmojiLayoutResId,
@LayoutRes int displayEmoticonLayoutResId)
{
this.variationSelectorListener = variationSelectorListener;
@@ -90,7 +87,8 @@ public class EmojiPageView extends RecyclerView implements VariationSelectorList
emojiSelectionListener,
this,
allowVariations,
displayItemLayoutResId);
displayEmojiLayoutResId,
displayEmoticonLayoutResId);
if (this.layoutManager instanceof GridLayoutManager) {
GridLayoutManager gridLayout = (GridLayoutManager) this.layoutManager;
@@ -109,6 +107,9 @@ public class EmojiPageView extends RecyclerView implements VariationSelectorList
}
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() {
@@ -121,45 +122,15 @@ public class EmojiPageView extends RecyclerView implements VariationSelectorList
}
public void onSelected() {
if (getAdapter() != null && (model == null || model.isDynamic())) {
if (getAdapter() != null) {
getAdapter().notifyDataSetChanged();
}
}
public void setList(@NonNull MappingModelList list) {
this.model = null;
public void setList(@NonNull List<MappingModel<?>> list, @Nullable Runnable commitCallback) {
EmojiPageViewGridAdapter adapter = adapterFactory.create();
setAdapter(adapter);
adapter.submitList(list);
}
public void setModel(@Nullable EmojiPageModel model) {
this.model = model;
EmojiPageViewGridAdapter adapter = adapterFactory.create();
setAdapter(adapter);
adapter.submitList(getMappingModelList());
}
public void bindSearchableAdapter(@Nullable EmojiPageModel model) {
this.model = model;
EmojiPageViewGridAdapter adapter = adapterFactory.create();
setAdapter(adapter);
adapter.submitList(getMappingModelList());
}
private @NonNull MappingModelList getMappingModelList() {
if (model != null) {
boolean emoticonPage = EmojiCategory.EMOTICONS.getKey().equals(model.getKey());
return model.getDisplayEmoji()
.stream()
.map(e -> emoticonPage ? new EmojiPageViewGridAdapter.EmojiTextModel(model.getKey(), e)
: new EmojiPageViewGridAdapter.EmojiModel(model.getKey(), e))
.collect(MappingModelList.collect());
}
return new MappingModelList();
adapter.submitList(list, commitCallback);
}
@Override

View File

@@ -10,7 +10,6 @@ import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
import org.thoughtcrime.securesms.util.MappingAdapter;
import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.MappingViewHolder;
@@ -23,15 +22,16 @@ public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWin
@NonNull EmojiEventListener emojiEventListener,
@NonNull VariationSelectorListener variationSelectorListener,
boolean allowVariations,
@LayoutRes int displayItemLayoutResId)
@LayoutRes int displayEmojiLayoutResId,
@LayoutRes int displayEmoticonLayoutResId)
{
this.variationSelectorListener = variationSelectorListener;
popup.setOnDismissListener(this);
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), displayItemLayoutResId));
registerFactory(EmojiTextModel.class, new LayoutFactory<>(v -> new EmojiTextViewHolder(v, emojiEventListener), R.layout.emoji_text_display_item));
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));
}
@@ -119,7 +119,6 @@ public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWin
private final boolean allowVariations;
private final ImageView imageView;
private final ImageView hintCorner;
public EmojiViewHolder(@NonNull View itemView,
@NonNull EmojiEventListener emojiEventListener,
@@ -135,7 +134,6 @@ public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWin
this.allowVariations = allowVariations;
this.imageView = itemView.findViewById(R.id.emoji_image);
this.hintCorner = itemView.findViewById(R.id.emoji_variation_hint);
}
@Override
@@ -152,9 +150,6 @@ public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWin
});
if (allowVariations && model.emoji.hasMultipleVariations()) {
if (hintCorner != null) {
hintCorner.setVisibility(View.VISIBLE);
}
itemView.setOnLongClickListener(v -> {
popup.dismiss();
popup.setVariations(model.emoji.getVariations());
@@ -163,9 +158,6 @@ public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWin
return true;
});
} else {
if (hintCorner != null) {
hintCorner.setVisibility(View.GONE);
}
itemView.setOnLongClickListener(null);
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.components.emoji
import android.content.Context
import android.text.TextUtils
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.libsignal.util.guava.Optional
open class SingleLineEmojiTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {
private var bufferType: BufferType? = null
init {
maxLines = 1
}
override fun setText(text: CharSequence?, type: BufferType?) {
bufferType = type
val candidates = if (isInEditMode) null else EmojiProvider.getCandidates(text)
if (SignalStore.settings().isPreferSystemEmoji || candidates == null || candidates.size() == 0) {
super.setText(Optional.fromNullable(text).or(""), BufferType.NORMAL)
} else {
val newContent = if (width == 0) {
text
} else {
TextUtils.ellipsize(text, paint, width.toFloat(), TextUtils.TruncateAt.END, false, null)
}
val newCandidates = if (isInEditMode) null else EmojiProvider.getCandidates(newContent)
val newText = if (newCandidates == null || newCandidates.size() == 0) {
newContent
} else {
EmojiProvider.emojify(newCandidates, newContent, this)
}
super.setText(newText, type)
}
}
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
super.onSizeChanged(width, height, oldWidth, oldHeight)
if (width > 0 && oldWidth != width) {
setText(text, bufferType ?: BufferType.NORMAL)
}
}
override fun setMaxLines(maxLines: Int) {
check(maxLines == 1) { "setMaxLines: $maxLines != 1" }
super.setMaxLines(maxLines)
}
}

View File

@@ -0,0 +1,50 @@
package org.thoughtcrime.securesms.components.recyclerview
import android.graphics.Rect
import android.view.View
import androidx.annotation.Px
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Decoration which will add an equal amount of space between each item in a grid.
*/
open class GridDividerDecoration(
private val spanCount: Int,
@Px private val space: Int
) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
return setItemOffsets(parent.getChildAdapterPosition(view), view, outRect)
}
protected fun setItemOffsets(position: Int, view: View, outRect: Rect) {
val column = position % spanCount
val isRtl = ViewUtil.isRtl(view)
val distanceFromEnd = spanCount - 1 - column
val spaceStart = (column / spanCount.toFloat()) * space
val spaceEnd = (distanceFromEnd / spanCount.toFloat()) * space
outRect.setStart(spaceStart.toInt(), isRtl)
outRect.setEnd(spaceEnd.toInt(), isRtl)
outRect.bottom = space
}
private fun Rect.setEnd(end: Int, isRtl: Boolean) {
if (isRtl) {
left = end
} else {
right = end
}
}
private fun Rect.setStart(start: Int, isRtl: Boolean) {
if (isRtl) {
right = start
} else {
left = start
}
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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);

View File

@@ -12,6 +12,8 @@ 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 = -1,
@@ -66,72 +68,4 @@ abstract class DSLSettingsFragment(
}
}
}
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
}
protected abstract fun show(duration: Long)
protected abstract fun hide(duration: Long)
enum class AnimationState {
NONE,
HIDE,
SHOW
}
}
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)
}
}
}

View File

@@ -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)
}

View File

@@ -244,6 +244,15 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
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)

View File

@@ -15,4 +15,5 @@ data class InternalSettingsState(
val useBuiltInEmojiSet: Boolean,
val emojiVersion: EmojiFiles.Version?,
val removeSenderKeyMinimium: Boolean,
val delayResends: Boolean,
)

View File

@@ -70,6 +70,11 @@ 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()
@@ -91,7 +96,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
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 {

View File

@@ -31,6 +31,8 @@ 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
@@ -300,6 +302,9 @@ class ConversationSettingsFragment : DSLSettingsFragment(
recipient = state.recipient,
onDisableProfileSharingClick = {
viewModel.disableProfileSharing()
},
onDeleteSessionClick = {
viewModel.deleteSession()
}
)
)

View File

@@ -24,6 +24,7 @@ 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)
@@ -185,12 +186,22 @@ class ConversationSettingsRepository(
}
}
fun disableProfileSharing(recipientId: RecipientId) {
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)

View File

@@ -116,6 +116,8 @@ sealed class ConversationSettingsViewModel(
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(
@@ -164,12 +166,13 @@ sealed class ConversationSettingsViewModel(
repository.getGroupsInCommon(recipientId) { groupsInCommon ->
store.update { state ->
val recipientSettings = state.requireRecipientSettingsState()
val expanded = recipientSettings.groupsInCommonExpanded
val canShowMore = !recipientSettings.groupsInCommonExpanded && groupsInCommon.size > 6
state.copy(
specificSettingsState = recipientSettings.copy(
allGroupsInCommon = groupsInCommon,
groupsInCommon = if (expanded) groupsInCommon else groupsInCommon.take(5),
canShowMoreGroupsInCommon = !expanded && groupsInCommon.size > 5
groupsInCommon = if (!canShowMore) groupsInCommon else groupsInCommon.take(5),
canShowMoreGroupsInCommon = canShowMore
)
)
}
@@ -236,7 +239,11 @@ sealed class ConversationSettingsViewModel(
}
override fun disableProfileSharing() {
repository.disableProfileSharing(recipientId)
repository.disableProfileSharingForInternalUser(recipientId)
}
override fun deleteSession() {
repository.deleteSessionForInternalUser(recipientId)
}
}
@@ -304,12 +311,13 @@ sealed class ConversationSettingsViewModel(
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 (groupState.groupMembersExpanded) fullMembers else fullMembers.take(5),
canShowMoreGroupMembers = !groupState.groupMembersExpanded && fullMembers.size > 5
members = if (!canShowMore) fullMembers else fullMembers.take(5),
canShowMoreGroupMembers = canShowMore
)
)
}

View File

@@ -19,7 +19,8 @@ object InternalPreference {
class Model(
private val recipient: Recipient,
val onDisableProfileSharingClick: () -> Unit
val onDisableProfileSharingClick: () -> Unit,
val onDeleteSessionClick: () -> Unit
) : PreferenceModel<Model>() {
val body: String get() {
@@ -58,10 +59,12 @@ object InternalPreference {
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() }
}
}
}

View File

@@ -22,7 +22,7 @@ class SoundsAndNotificationsSettingsViewModel(
store.update(Recipient.live(recipientId).liveData) { recipient, state ->
state.copy(
recipientId = recipientId,
muteUntil = recipient.muteUntil,
muteUntil = if (recipient.isMuted) recipient.muteUntil else 0L,
mentionSetting = recipient.mentionSetting,
hasMentionsSupport = recipient.isPushV2Group,
hasCustomNotificationSettings = recipient.notificationChannel != null || !NotificationChannels.supported()

View File

@@ -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())
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.components.voice
interface VoiceNoteMediaControllerOwner {
val voiceNoteMediaController: VoiceNoteMediaController
}

View File

@@ -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);
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();

View File

@@ -26,6 +26,7 @@ import com.google.android.exoplayer2.DefaultRenderersFactory;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.ExoPlayerFactory;
import com.google.android.exoplayer2.LoadControl;
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.audio.AudioAttributes;
@@ -35,15 +36,15 @@ import com.google.android.exoplayer2.ui.PlayerNotificationManager;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import java.util.Collections;
@@ -54,6 +55,8 @@ import java.util.List;
*/
public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
public static final String ACTION_NEXT_PLAYBACK_SPEED = "org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackService.action.next_playback_speed";
private static final String TAG = Log.tag(VoiceNotePlaybackService.class);
private static final String EMPTY_ROOT_ID = "empty-root-id";
private static final int LOAD_MORE_THRESHOLD = 2;
@@ -69,11 +72,13 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
private PlaybackStateCompat.Builder stateBuilder;
private SimpleExoPlayer player;
private BecomingNoisyReceiver becomingNoisyReceiver;
private KeyClearedReceiver keyClearedReceiver;
private VoiceNoteNotificationManager voiceNoteNotificationManager;
private VoiceNoteQueueDataAdapter queueDataAdapter;
private VoiceNotePlaybackPreparer voiceNotePlaybackPreparer;
private VoiceNoteProximityManager voiceNoteProximityManager;
private boolean isForegroundService;
private VoiceNotePlaybackParameters voiceNotePlaybackParameters;
private final LoadControl loadControl = new DefaultLoadControl.Builder()
.setBufferDurationsMs(Integer.MAX_VALUE,
@@ -87,10 +92,13 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
super.onCreate();
mediaSession = new MediaSessionCompat(this, TAG);
voiceNotePlaybackParameters = new VoiceNotePlaybackParameters(mediaSession);
stateBuilder = new PlaybackStateCompat.Builder()
.setActions(SUPPORTED_ACTIONS);
mediaSessionConnector = new MediaSessionConnector(mediaSession, null);
.setActions(SUPPORTED_ACTIONS)
.addCustomAction(ACTION_NEXT_PLAYBACK_SPEED, "speed", R.drawable.ic_toggle_24);
mediaSessionConnector = new MediaSessionConnector(mediaSession, new VoiceNotePlaybackController(voiceNotePlaybackParameters));
becomingNoisyReceiver = new BecomingNoisyReceiver(this, mediaSession.getSessionToken());
keyClearedReceiver = new KeyClearedReceiver(this, mediaSession.getSessionToken());
player = ExoPlayerFactory.newSimpleInstance(this, new DefaultRenderersFactory(this), new DefaultTrackSelector(), loadControl);
queueDataAdapter = new VoiceNoteQueueDataAdapter();
voiceNoteNotificationManager = new VoiceNoteNotificationManager(this,
@@ -100,7 +108,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
AttachmentMediaSourceFactory mediaSourceFactory = new AttachmentMediaSourceFactory(this);
voiceNotePlaybackPreparer = new VoiceNotePlaybackPreparer(this, player, queueDataAdapter, mediaSourceFactory);
voiceNotePlaybackPreparer = new VoiceNotePlaybackPreparer(this, player, queueDataAdapter, mediaSourceFactory, voiceNotePlaybackParameters);
voiceNoteProximityManager = new VoiceNoteProximityManager(this, player, queueDataAdapter);
mediaSession.setPlaybackState(stateBuilder.build());
@@ -117,6 +125,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
setSessionToken(mediaSession.getSessionToken());
mediaSession.setActive(true);
keyClearedReceiver.register();
}
@Override
@@ -132,6 +141,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
mediaSession.setActive(false);
mediaSession.release();
becomingNoisyReceiver.unregister();
keyClearedReceiver.unregister();
player.release();
}
@@ -150,6 +160,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
}
private class VoiceNotePlayerEventListener implements Player.EventListener {
@Override
public void onPlayerStateChanged(boolean playWhenReady, int playbackState) {
switch (playbackState) {
@@ -182,9 +193,19 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
}
if (reason == Player.DISCONTINUITY_REASON_PERIOD_TRANSITION) {
MediaDescriptionCompat mediaDescriptionCompat = queueDataAdapter.getMediaDescription(currentWindowIndex);
sendViewedReceiptForCurrentWindowIndex();
MediaDescriptionCompat mediaDescriptionCompat = queueDataAdapter.getMediaDescription(currentWindowIndex);
Log.d(TAG, "onPositionDiscontinuity: current window uri: " + mediaDescriptionCompat.getMediaUri());
PlaybackParameters playbackParameters = getPlaybackParametersForWindowPosition(currentWindowIndex);
final float speed = playbackParameters != null ? playbackParameters.speed : 1f;
if (speed != player.getPlaybackParameters().speed) {
player.setPlayWhenReady(false);
player.setPlaybackParameters(playbackParameters);
player.seekTo(currentWindowIndex, 1);
player.setPlayWhenReady(true);
}
}
boolean isWithinThreshold = currentWindowIndex < LOAD_MORE_THRESHOLD ||
@@ -202,6 +223,18 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
}
}
private @Nullable PlaybackParameters getPlaybackParametersForWindowPosition(int currentWindowIndex) {
if (isAudioMessage(currentWindowIndex)) {
return voiceNotePlaybackParameters.getParameters();
} else {
return null;
}
}
private boolean isAudioMessage(int currentWindowIndex) {
return currentWindowIndex % 2 == 0;
}
private void sendViewedReceiptForCurrentWindowIndex() {
if (player.getPlaybackState() == Player.STATE_READY &&
player.getPlayWhenReady() &&
@@ -225,7 +258,8 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
if (markedMessageInfo != null) {
ApplicationDependencies.getJobManager().add(new SendViewedReceiptJob(markedMessageInfo.getThreadId(),
recipientId,
markedMessageInfo.getSyncMessageId().getTimetamp()));
markedMessageInfo.getSyncMessageId().getTimetamp(),
new MessageId(messageId, true)));
MultiDeviceViewedUpdateJob.enqueue(Collections.singletonList(markedMessageInfo.getSyncMessageId()));
}
});
@@ -251,6 +285,46 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
}
}
/**
* Receiver to stop playback and kill the notification if user locks signal via screen lock.
*/
private static class KeyClearedReceiver extends BroadcastReceiver {
private static final IntentFilter KEY_CLEARED_FILTER = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT);
private final Context context;
private final MediaControllerCompat controller;
private boolean registered;
private KeyClearedReceiver(@NonNull Context context, @NonNull MediaSessionCompat.Token token) {
this.context = context;
try {
this.controller = new MediaControllerCompat(context, token);
} catch (RemoteException e) {
throw new IllegalArgumentException("Failed to create controller from token", e);
}
}
void register() {
if (!registered) {
context.registerReceiver(this, KEY_CLEARED_FILTER);
registered = true;
}
}
void unregister() {
if (registered) {
context.unregisterReceiver(this);
registered = false;
}
}
@Override
public void onReceive(Context context, Intent intent) {
controller.getTransportControls().stop();
}
}
/**
* Receiver to pause playback when things become noisy.
*/

View File

@@ -1,53 +0,0 @@
package org.thoughtcrime.securesms.components.voice;
import android.net.Uri;
import androidx.annotation.NonNull;
/**
* Domain-level state object representing the state of the currently playing voice note.
*/
public class VoiceNotePlaybackState {
public static final VoiceNotePlaybackState NONE = new VoiceNotePlaybackState(Uri.EMPTY, 0, 0, false);
private final Uri uri;
private final long playheadPositionMillis;
private final long trackDuration;
private final boolean autoReset;
public VoiceNotePlaybackState(@NonNull Uri uri, long playheadPositionMillis, long trackDuration, boolean autoReset) {
this.uri = uri;
this.playheadPositionMillis = playheadPositionMillis;
this.trackDuration = trackDuration;
this.autoReset = autoReset;
}
/**
* @return Uri of the currently playing AudioSlide
*/
public Uri getUri() {
return uri;
}
/**
* @return The last known playhead position
*/
public long getPlayheadPositionMillis() {
return playheadPositionMillis;
}
/**
* @return The track duration in ms
*/
public long getTrackDuration() {
return trackDuration;
}
/**
* @return true if we should reset the currently playing clip.
*/
public boolean isAutoReset() {
return autoReset;
}
}

View File

@@ -0,0 +1,66 @@
package org.thoughtcrime.securesms.components.voice
import android.net.Uri
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Domain-level state object representing the state of the currently playing voice note.
*/
data class VoiceNotePlaybackState(
/**
* @return Uri of the currently playing AudioSlide
*/
val uri: Uri,
/**
* @return The last known playhead position
*/
val playheadPositionMillis: Long,
/**
* @return The track duration in ms
*/
val trackDuration: Long,
/**
* @return true if we should reset the currently playing clip.
*/
val isAutoReset: Boolean,
/**
* @return The current playback speed factor
*/
val speed: Float,
/**
* @return Whether we are playing or paused
*/
val isPlaying: Boolean,
/**
* @return Information about the type this clip represents.
*/
val clipType: ClipType
) {
companion object {
@JvmField
val NONE = VoiceNotePlaybackState(Uri.EMPTY, 0, 0, false, 1f, false, ClipType.Idle)
}
fun asPaused(): VoiceNotePlaybackState {
return copy(isPlaying = false)
}
sealed class ClipType {
data class Message(
val messageId: Long,
val senderId: RecipientId,
val threadRecipientId: RecipientId,
val messagePosition: Long,
val threadId: Long,
val timestamp: Long
) : ClipType()
object Draft : ClipType()
object Idle : ClipType()
}
}

View File

@@ -0,0 +1,201 @@
package org.thoughtcrime.securesms.components.voice
import android.content.Context
import android.net.Uri
import android.util.AttributeSet
import android.view.View
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import com.airbnb.lottie.LottieAnimationView
import com.airbnb.lottie.LottieProperty
import com.airbnb.lottie.SimpleColorFilter
import com.airbnb.lottie.model.KeyPath
import com.airbnb.lottie.value.LottieValueCallback
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.PlaybackSpeedToggleTextView
import org.thoughtcrime.securesms.recipients.RecipientId
import java.util.concurrent.TimeUnit
private const val ANIMATE_DURATION: Long = 150L
private const val TO_PAUSE = 1
private const val TO_PLAY = -1
/**
* Renders a bar at the top of Conversation list and in a conversation to allow
* playback manipulation of voice notes.
*/
class VoiceNotePlayerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
private val playPauseToggleView: LottieAnimationView
private val infoView: TextView
private val speedView: PlaybackSpeedToggleTextView
private val closeButton: View
private var lastState: State? = null
private var playerVisible: Boolean = false
private var lottieDirection: Int = 0
var listener: Listener? = null
init {
inflate(context, R.layout.voice_note_player_view, this)
playPauseToggleView = findViewById(R.id.voice_note_player_play_pause_toggle)
infoView = findViewById(R.id.voice_note_player_info)
speedView = findViewById(R.id.voice_note_player_speed)
closeButton = findViewById(R.id.voice_note_player_close)
infoView.isSelected = true
val speedTouchTarget: View = findViewById(R.id.voice_note_player_speed_touch_target)
speedTouchTarget.setOnClickListener {
speedView.performClick()
}
speedView.playbackSpeedListener = object : PlaybackSpeedToggleTextView.PlaybackSpeedListener {
override fun onPlaybackSpeedChanged(speed: Float) {
lastState?.let {
listener?.onSpeedChangeRequested(it.uri, speed)
}
}
}
closeButton.setOnClickListener {
lastState?.let {
listener?.onCloseRequested(it.uri)
}
}
playPauseToggleView.setOnClickListener {
lastState?.let {
if (it.isPaused) {
if (it.playbackPosition >= it.playbackDuration) {
listener?.onPlay(it.uri, it.messageId, 0.0)
} else {
listener?.onPlay(it.uri, it.messageId, it.playbackPosition.toDouble() / it.playbackDuration)
}
} else {
listener?.onPause(it.uri)
}
}
}
post {
playPauseToggleView.addValueCallback(
KeyPath("**"),
LottieProperty.COLOR_FILTER,
LottieValueCallback(SimpleColorFilter(ContextCompat.getColor(context, R.color.signal_icon_tint_primary)))
)
}
if (background != null) {
background.colorFilter = SimpleColorFilter(ContextCompat.getColor(context, R.color.voice_note_player_view_background))
}
setOnClickListener {
lastState?.let {
listener?.onNavigateToMessage(it.threadId, it.threadRecipientId, it.senderId, it.messageTimestamp, it.messagePositionInThread)
}
}
}
fun setState(state: State) {
this.lastState = state
if (state.isPaused) {
animateToggleToPlay()
} else {
animateToggleToPause()
}
infoView.text = context.getString(R.string.VoiceNotePlayerView__s_dot_s, state.name, formatDuration(state.playbackDuration - state.playbackPosition))
speedView.setCurrentSpeed(state.playbackSpeed)
}
fun show() {
if (!playerVisible) {
visibility = VISIBLE
val animation = AnimationUtils.loadAnimation(context, R.anim.slide_from_top)
animation.duration = ANIMATE_DURATION
startAnimation(animation)
}
playerVisible = true
}
fun hide() {
if (playerVisible) {
val animation = AnimationUtils.loadAnimation(context, R.anim.slide_to_top)
animation.duration = ANIMATE_DURATION
animation.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation?) = Unit
override fun onAnimationRepeat(animation: Animation?) = Unit
override fun onAnimationEnd(animation: Animation?) {
visibility = GONE
}
})
startAnimation(animation)
}
playerVisible = false
}
private fun formatDuration(duration: Long): String {
val secs = TimeUnit.MILLISECONDS.toSeconds(duration)
return resources.getString(R.string.AudioView_duration, secs / 60, secs % 60)
}
private fun animateToggleToPlay() {
startLottieAnimation(TO_PLAY)
}
private fun animateToggleToPause() {
startLottieAnimation(TO_PAUSE)
}
private fun startLottieAnimation(direction: Int) {
if (lottieDirection == direction) {
return
}
lottieDirection = direction
playPauseToggleView.pauseAnimation()
playPauseToggleView.speed = (direction * 2).toFloat()
playPauseToggleView.resumeAnimation()
}
data class State(
val uri: Uri,
val messageId: Long,
val threadId: Long,
val isPaused: Boolean,
val senderId: RecipientId,
val threadRecipientId: RecipientId,
val messagePositionInThread: Long,
val messageTimestamp: Long,
val name: String,
val playbackPosition: Long,
val playbackDuration: Long,
val playbackSpeed: Float
)
interface Listener {
fun onPlay(uri: Uri, messageId: Long, position: Double)
fun onPause(uri: Uri)
fun onCloseRequested(uri: Uri)
fun onSpeedChangeRequested(uri: Uri, speed: Float)
fun onNavigateToMessage(threadId: Long, threadRecipientId: RecipientId, senderId: RecipientId, messageSentAt: Long, messagePositionInThread: Long)
}
}

View File

@@ -108,15 +108,31 @@ public class ContactRepository {
}
@WorkerThread
public Cursor querySignalContacts(@NonNull String query) {
public @NonNull Cursor querySignalContacts(@NonNull String query) {
return querySignalContacts(query, true);
}
@WorkerThread
public Cursor querySignalContacts(@NonNull String query, boolean includeSelf) {
Cursor cursor = TextUtils.isEmpty(query) ? recipientDatabase.getSignalContacts(includeSelf)
: recipientDatabase.querySignalContacts(query, includeSelf);
public @NonNull Cursor querySignalContacts(@NonNull String query, boolean includeSelf) {
Cursor cursor = TextUtils.isEmpty(query) ? recipientDatabase.getSignalContacts(includeSelf)
: recipientDatabase.querySignalContacts(query, includeSelf);
cursor = handleNoteToSelfQuery(query, includeSelf, cursor);
return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS);
}
@WorkerThread
public @NonNull Cursor queryNonGroupContacts(@NonNull String query, boolean includeSelf) {
Cursor cursor = TextUtils.isEmpty(query) ? recipientDatabase.getNonGroupContacts(includeSelf)
: recipientDatabase.queryNonGroupContacts(query, includeSelf);
cursor = handleNoteToSelfQuery(query, includeSelf, cursor);
return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS);
}
private @NonNull Cursor handleNoteToSelfQuery(@NonNull String query, boolean includeSelf, Cursor cursor) {
if (includeSelf && noteToSelfTitle.toLowerCase().contains(query.toLowerCase())) {
Recipient self = Recipient.self();
boolean nameMatch = self.getDisplayName(context).toLowerCase().contains(query.toLowerCase());
@@ -130,8 +146,7 @@ public class ContactRepository {
cursor = cursor == null ? selfCursor : new MergeCursor(new Cursor[]{ cursor, selfCursor });
}
}
return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS);
return cursor;
}
@WorkerThread

View File

@@ -1,16 +1,16 @@
/**
* Copyright (C) 2014 Open Whisper Systems
*
* <p>
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* <p>
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* <p>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@@ -21,6 +21,7 @@ import android.database.Cursor;
import android.provider.ContactsContract;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.ForegroundColorSpan;
import android.view.LayoutInflater;
import android.view.View;
@@ -29,7 +30,6 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import org.signal.core.util.logging.Log;
@@ -40,12 +40,15 @@ import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter.ViewHolde
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.CharacterIterable;
import org.thoughtcrime.securesms.util.CursorUtil;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration.StickyHeaderAdapter;
import org.thoughtcrime.securesms.util.Util;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/**
@@ -54,8 +57,8 @@ import java.util.Set;
* @author Jake McGinty
*/
public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewHolder>
implements FastScrollAdapter,
StickyHeaderAdapter<HeaderViewHolder>
implements FastScrollAdapter,
StickyHeaderAdapter<HeaderViewHolder>
{
@SuppressWarnings("unused")
private final static String TAG = Log.tag(ContactSelectionListAdapter.class);
@@ -98,14 +101,28 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
super(itemView);
}
public abstract void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, String about, int color, boolean checkboxVisible);
public abstract void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, String about, boolean checkboxVisible);
public abstract void unbind(@NonNull GlideRequests glideRequests);
public abstract void setChecked(boolean checked);
public void animateChecked(boolean checked) {
// Intentionally empty.
}
public abstract void setEnabled(boolean enabled);
public void setLetterHeaderCharacter(@Nullable String letterHeaderCharacter) {
// Intentionally empty.
}
}
public static class ContactViewHolder extends ViewHolder {
ContactViewHolder(@NonNull final View itemView,
public static class ContactViewHolder extends ViewHolder implements LetterHeaderDecoration.LetterHeaderItem {
private String letterHeader;
ContactViewHolder(@NonNull final View itemView,
@Nullable final ItemClickListener clickListener)
{
super(itemView);
@@ -118,8 +135,8 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
return (ContactSelectionListItem) itemView;
}
public void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, String about, int color, boolean checkBoxVisible) {
getView().set(glideRequests, recipientId, type, name, number, label, about, color, checkBoxVisible);
public void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, String about, boolean checkBoxVisible) {
getView().set(glideRequests, recipientId, type, name, number, label, about, checkBoxVisible);
}
@Override
@@ -129,13 +146,28 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
@Override
public void setChecked(boolean checked) {
getView().setChecked(checked);
getView().setChecked(checked, false);
}
@Override
public void animateChecked(boolean checked) {
getView().setChecked(checked, true);
}
@Override
public void setEnabled(boolean enabled) {
getView().setEnabled(enabled);
}
@Override
public @Nullable String getHeaderLetter() {
return letterHeader;
}
@Override
public void setLetterHeaderCharacter(@Nullable String letterHeaderCharacter) {
this.letterHeader = letterHeaderCharacter;
}
}
public static class DividerViewHolder extends ViewHolder {
@@ -148,7 +180,7 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
}
@Override
public void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, String about, int color, boolean checkboxVisible) {
public void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, String about, boolean checkboxVisible) {
this.label.setText(name);
}
@@ -168,15 +200,15 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
}
}
public ContactSelectionListAdapter(@NonNull Context context,
@NonNull GlideRequests glideRequests,
public ContactSelectionListAdapter(@NonNull Context context,
@NonNull GlideRequests glideRequests,
@Nullable Cursor cursor,
@Nullable ItemClickListener clickListener,
boolean multiSelect,
@NonNull Set<RecipientId> currentContacts)
{
super(context, cursor);
this.layoutInflater = LayoutInflater.from(context);
this.layoutInflater = LayoutInflater.from(context);
this.glideRequests = glideRequests;
this.multiSelect = multiSelect;
this.clickListener = clickListener;
@@ -186,7 +218,7 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
@Override
public long getHeaderId(int i) {
if (!isActiveCursor()) return -1;
else if (i == -1) return -1;
else if (i == -1) return -1;
int contactType = getContactType(i);
@@ -215,15 +247,10 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
String label = CursorUtil.requireString(cursor, ContactRepository.LABEL_COLUMN);
String labelText = ContactsContract.CommonDataKinds.Phone.getTypeLabel(getContext().getResources(),
numberType, label).toString();
boolean isPush = (contactType & ContactRepository.PUSH_TYPE) > 0;
int color = isPush ? ContextCompat.getColor(getContext(), R.color.signal_text_primary)
: ContextCompat.getColor(getContext(), R.color.signal_inverse_transparent_60);
boolean currentContact = currentContacts.contains(id);
viewHolder.unbind(glideRequests);
viewHolder.bind(glideRequests, id, contactType, name, number, labelText, about, color, multiSelect || currentContact);
viewHolder.bind(glideRequests, id, contactType, name, number, labelText, about, multiSelect || currentContact);
viewHolder.setEnabled(true);
if (currentContact) {
@@ -234,6 +261,54 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
} else {
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forPhone(id, number)));
}
if (isContactRow(contactType)) {
int position = cursor.getPosition();
if (position == 0) {
viewHolder.setLetterHeaderCharacter(getHeaderLetterForDisplayName(cursor));
} else {
cursor.moveToPrevious();
int previousRowContactType = CursorUtil.requireInt(cursor, ContactRepository.CONTACT_TYPE_COLUMN);
if (!isContactRow(previousRowContactType)) {
cursor.moveToNext();
viewHolder.setLetterHeaderCharacter(getHeaderLetterForDisplayName(cursor));
} else {
String previousHeaderLetter = getHeaderLetterForDisplayName(cursor);
cursor.moveToNext();
String newHeaderLetter = getHeaderLetterForDisplayName(cursor);
if (Objects.equals(previousHeaderLetter, newHeaderLetter)) {
viewHolder.setLetterHeaderCharacter(null);
} else {
viewHolder.setLetterHeaderCharacter(newHeaderLetter);
}
}
}
}
}
private boolean isContactRow(int contactType) {
return (contactType & (ContactRepository.NEW_PHONE_TYPE | ContactRepository.NEW_USERNAME_TYPE | ContactRepository.DIVIDER_TYPE)) == 0;
}
private @Nullable String getHeaderLetterForDisplayName(@NonNull Cursor cursor) {
String name = CursorUtil.requireString(cursor, ContactRepository.NAME_COLUMN);
Iterator<String> characterIterator = new CharacterIterable(name).iterator();
if (!TextUtils.isEmpty(name) && characterIterator.hasNext()) {
String next = characterIterator.next();
if (Character.isLetter(next.codePointAt(0))) {
return next.toUpperCase();
} else {
return "#";
}
} else {
return null;
}
}
@Override
@@ -250,12 +325,12 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
viewHolder.setEnabled(true);
if (currentContacts.contains(id)) {
viewHolder.setChecked(true);
viewHolder.animateChecked(true);
viewHolder.setEnabled(false);
} else if (numberType == ContactRepository.NEW_USERNAME_TYPE) {
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forUsername(id, number)));
viewHolder.animateChecked(selectedContacts.contains(SelectedContact.forUsername(id, number)));
} else {
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forPhone(id, number)));
viewHolder.animateChecked(selectedContacts.contains(SelectedContact.forPhone(id, number)));
}
}
@@ -275,7 +350,7 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
@Override
public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position, int type) {
((TextView)viewHolder.itemView).setText(getSpannedHeaderString(position));
((TextView) viewHolder.itemView).setText(getSpannedHeaderString(position));
}
@Override
@@ -301,6 +376,10 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
return selectedContacts.size();
}
public int getCurrentContactsCount() {
return currentContacts.size();
}
private CharSequence getSpannedHeaderString(int position) {
final String headerString = getHeaderString(position);
if (isPush(position)) {

View File

@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.FromTextView;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
@@ -34,6 +35,7 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
private FromTextView nameView;
private TextView labelView;
private CheckBox checkBox;
private View smsTag;
private String number;
private String chipName;
@@ -61,6 +63,7 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
this.labelView = findViewById(R.id.label);
this.nameView = findViewById(R.id.name);
this.checkBox = findViewById(R.id.check_box);
this.smsTag = findViewById(R.id.sms_tag);
ViewUtil.setTextViewGravityStart(this.nameView, getContext());
}
@@ -72,7 +75,6 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
String number,
String label,
String about,
int color,
boolean checkboxVisible)
{
this.glideRequests = glideRequests;
@@ -89,13 +91,23 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
} else if (recipientId != null) {
this.recipient = Recipient.live(recipientId);
this.recipient.observeForever(this);
name = this.recipient.get().getDisplayName(getContext());
}
Recipient recipientSnapshot = recipient != null ? recipient.get() : null;
this.nameView.setTextColor(color);
this.numberView.setTextColor(color);
if (recipientSnapshot != null && !recipientSnapshot.isResolving()) {
contactName = recipientSnapshot.getDisplayName(getContext());
name = contactName;
} else if (recipient != null) {
name = "";
}
if (recipientSnapshot == null || recipientSnapshot.isResolving() || recipientSnapshot.isRegistered()) {
smsTag.setVisibility(GONE);
} else {
smsTag.setVisibility(VISIBLE);
}
if (recipientSnapshot == null || recipientSnapshot.isResolving()) {
this.contactPhotoImage.setAvatar(glideRequests, null, false);
setText(null, type, name, number, label, about);
@@ -107,8 +119,20 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
this.checkBox.setVisibility(checkboxVisible ? View.VISIBLE : View.GONE);
}
public void setChecked(boolean selected) {
this.checkBox.setChecked(selected);
public void setChecked(boolean selected, boolean animate) {
boolean wasSelected = checkBox.isChecked();
if (wasSelected != selected) {
checkBox.setChecked(selected);
float alpha = selected ? 1f : 0f;
if (animate) {
checkBox.animate().setDuration(250L).alpha(alpha);
} else {
checkBox.animate().cancel();
checkBox.setAlpha(alpha);
}
}
}
@Override
@@ -146,7 +170,7 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
} else {
this.numberView.setText(!Util.isEmpty(about) ? about : number);
this.nameView.setEnabled(true);
this.labelView.setText(label != null && !label.equals("null") ? label : "");
this.labelView.setText(label != null && !label.equals("null") ? getResources().getString(R.string.ContactSelectionListItem__dot_s, label) : "");
this.labelView.setVisibility(View.VISIBLE);
}
@@ -190,8 +214,13 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
@Override
public void onRecipientChanged(@NonNull Recipient recipient) {
if (this.recipient != null && this.recipient.getId().equals(recipient.getId())) {
contactName = recipient.getDisplayName(getContext());
contactAbout = recipient.getCombinedAboutAndEmoji();
contactNumber = PhoneNumberFormatter.prettyPrint(recipient.getE164().or(""));
contactPhotoImage.setAvatar(glideRequests, recipient, false);
setText(recipient, contactType, contactName, contactNumber, contactLabel, contactAbout);
smsTag.setVisibility(recipient.isRegistered() ? GONE : VISIBLE);
} else {
Log.w(TAG, "Bad change! Local recipient doesn't match. Ignoring. Local: " + (this.recipient == null ? "null" : this.recipient.getId()) + ", Changed: " + recipient.getId());
}

View File

@@ -16,7 +16,6 @@
*/
package org.thoughtcrime.securesms.contacts;
import android.Manifest;
import android.content.Context;
import android.database.Cursor;
import android.database.MatrixCursor;
@@ -30,7 +29,6 @@ import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.phonenumbers.NumberUtil;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -126,7 +124,9 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
List<Cursor> contacts = getContactsCursors();
if (!isCursorListEmpty(contacts)) {
cursorList.add(ContactsCursorRows.forContactsHeader(getContext()));
if (!getFilter().isEmpty() || recents) {
cursorList.add(ContactsCursorRows.forContactsHeader(getContext()));
}
cursorList.addAll(contacts);
}
}
@@ -195,19 +195,14 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
private List<Cursor> getContactsCursors() {
List<Cursor> cursorList = new ArrayList<>(2);
if (!Permissions.hasAny(getContext(), Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)) {
return cursorList;
}
if (pushEnabled(mode)) {
cursorList.add(contactRepository.querySignalContacts(getFilter(), selfEnabled(mode)));
}
if (pushEnabled(mode) && smsEnabled(mode)) {
cursorList.add(contactRepository.queryNonSignalContacts(getFilter()));
cursorList.add(contactRepository.queryNonGroupContacts(getFilter(), selfEnabled(mode)));
} else if (pushEnabled(mode)) {
cursorList.add(contactRepository.querySignalContacts(getFilter(), selfEnabled(mode)));
} else if (smsEnabled(mode)) {
cursorList.add(filterNonPushContacts(contactRepository.queryNonSignalContacts(getFilter())));
cursorList.add(contactRepository.queryNonSignalContacts(getFilter()));
}
return cursorList;
}
@@ -240,25 +235,6 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
}
}
private @NonNull Cursor filterNonPushContacts(@NonNull Cursor cursor) {
try {
final long startMillis = System.currentTimeMillis();
final MatrixCursor matrix = ContactsCursorRows.createMatrixCursor();
while (cursor.moveToNext()) {
final RecipientId id = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ContactRepository.ID_COLUMN)));
final Recipient recipient = Recipient.resolved(id);
if (recipient.resolve().getRegistered() != RecipientDatabase.RegisteredState.REGISTERED) {
matrix.addRow(ContactsCursorRows.forNonPushContact(cursor));
}
}
Log.i(TAG, "filterNonPushContacts() -> " + (System.currentTimeMillis() - startMillis) + "ms");
return matrix;
} finally {
cursor.close();
}
}
private static boolean isCursorListEmpty(List<Cursor> list) {
int sum = 0;
for (Cursor cursor : list) {

View File

@@ -0,0 +1,86 @@
package org.thoughtcrime.securesms.contacts
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.Typeface
import android.view.LayoutInflater
import android.view.View
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ViewUtil
/**
* ItemDecoration which paints a letter header at the appropriate location above a LetterHeaderItem.
*/
class LetterHeaderDecoration(private val context: Context, private val hideDecoration: () -> Boolean) : RecyclerView.ItemDecoration() {
private val textBounds = Rect()
private val bounds = Rect()
private val padTop = ViewUtil.dpToPx(16)
private val padStart = context.resources.getDimensionPixelSize(R.dimen.dsl_settings_gutter)
private var dividerHeight = -1
private val textPaint = Paint().apply {
color = ContextCompat.getColor(context, R.color.signal_text_primary)
isAntiAlias = true
style = Paint.Style.FILL
typeface = Typeface.create("sans-serif-medium", Typeface.BOLD)
textAlign = Paint.Align.LEFT
textSize = ViewUtil.spToPx(16f).toFloat()
}
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val viewHolder = parent.getChildViewHolder(view)
if (hideDecoration() || viewHolder !is LetterHeaderItem || viewHolder.getHeaderLetter() == null) {
outRect.set(0, 0, 0, 0)
return
}
if (dividerHeight == -1) {
val v = LayoutInflater.from(context).inflate(R.layout.dsl_section_header, parent, false)
v.measure(0, 0)
dividerHeight = v.measuredHeight
}
outRect.set(0, dividerHeight, 0, 0)
}
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
if (hideDecoration()) {
return
}
val childCount = parent.childCount
val isRtl = parent.layoutDirection == View.LAYOUT_DIRECTION_RTL
for (i in 0 until childCount) {
val child = parent.getChildAt(i)
val holder = parent.getChildViewHolder(child)
val headerLetter = if (holder is LetterHeaderItem) holder.getHeaderLetter() else null
if (headerLetter != null) {
parent.getDecoratedBoundsWithMargins(child, bounds)
textPaint.getTextBounds(headerLetter, 0, headerLetter.length, textBounds)
val x = if (isRtl) getLayoutBoundsRTL() else getLayoutBoundsLTR()
val y = bounds.top + padTop - textBounds.top
canvas.save()
canvas.drawText(headerLetter, x.toFloat(), y.toFloat(), textPaint)
canvas.restore()
}
}
}
private fun getLayoutBoundsLTR() = bounds.left + padStart
private fun getLayoutBoundsRTL() = bounds.right - padStart - textBounds.width()
interface LetterHeaderItem {
fun getHeaderLetter(): String?
}
}

View File

@@ -3,11 +3,15 @@ package org.thoughtcrime.securesms.contacts.avatars;
import android.content.Context;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
public interface FallbackContactPhoto {
public Drawable asDrawable(Context context, int color);
public Drawable asDrawable(Context context, int color, boolean inverted);
public Drawable asSmallDrawable(Context context, int color, boolean inverted);
public Drawable asCallCard(Context context);
Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color);
Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted);
Drawable asSmallDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted);
Drawable asCallCard(@NonNull Context context);
}

View File

@@ -10,6 +10,8 @@ import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.graphics.drawable.DrawableCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.avatar.Avatars;
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Objects;
@@ -26,33 +28,33 @@ public final class FallbackPhoto20dp implements FallbackContactPhoto {
}
@Override
public Drawable asDrawable(Context context, int color) {
public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color) {
return buildDrawable(context, color);
}
@Override
public Drawable asDrawable(Context context, int color, boolean inverted) {
public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
return buildDrawable(context, color);
}
@Override
public Drawable asSmallDrawable(Context context, int color, boolean inverted) {
public Drawable asSmallDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
return buildDrawable(context, color);
}
@Override
public Drawable asCallCard(Context context) {
public Drawable asCallCard(@NonNull Context context) {
throw new UnsupportedOperationException();
}
private @NonNull Drawable buildDrawable(@NonNull Context context, int color) {
private @NonNull Drawable buildDrawable(@NonNull Context context, @NonNull AvatarColor color) {
Drawable background = DrawableCompat.wrap(Objects.requireNonNull(AppCompatResources.getDrawable(context, R.drawable.circle_tintable))).mutate();
Drawable foreground = AppCompatResources.getDrawable(context, drawable20dp);
Drawable gradient = AppCompatResources.getDrawable(context, R.drawable.avatar_gradient);
LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground, gradient});
Drawable foreground = Objects.requireNonNull(AppCompatResources.getDrawable(context, drawable20dp));
LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground});
int foregroundInset = ViewUtil.dpToPx(2);
DrawableCompat.setTint(background, color);
DrawableCompat.setTint(background, color.colorInt());
DrawableCompat.setTint(foreground, Avatars.getForegroundColor(color).getColorInt());
drawable.setLayerInset(1, foregroundInset, foregroundInset, foregroundInset, foregroundInset);

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.contacts.avatars;
import android.content.Context;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
@@ -10,48 +11,58 @@ import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.graphics.drawable.DrawableCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.avatar.Avatars;
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Objects;
public final class FallbackPhoto80dp implements FallbackContactPhoto {
@DrawableRes private final int drawable80dp;
private final int backgroundColor;
@DrawableRes private final int drawable80dp;
private final AvatarColor color;
public FallbackPhoto80dp(@DrawableRes int drawable80dp, int backgroundColor) {
this.drawable80dp = drawable80dp;
this.backgroundColor = backgroundColor;
public FallbackPhoto80dp(@DrawableRes int drawable80dp, @NonNull AvatarColor color) {
this.drawable80dp = drawable80dp;
this.color = color;
}
@Override
public Drawable asDrawable(Context context, int color) {
public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color) {
return buildDrawable(context);
}
@Override
public Drawable asDrawable(Context context, int color, boolean inverted) {
public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
return buildDrawable(context);
}
@Override
public Drawable asSmallDrawable(Context context, int color, boolean inverted) {
public Drawable asSmallDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
throw new UnsupportedOperationException();
}
@Override
public Drawable asCallCard(Context context) {
throw new UnsupportedOperationException();
public Drawable asCallCard(@NonNull Context context) {
Drawable background = new ColorDrawable(color.colorInt());
Drawable foreground = Objects.requireNonNull(AppCompatResources.getDrawable(context, drawable80dp));
LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground});
int foregroundInset = ViewUtil.dpToPx(24);
DrawableCompat.setTint(foreground, Avatars.getForegroundColor(color).getColorInt());
drawable.setLayerInset(1, foregroundInset, foregroundInset, foregroundInset, foregroundInset);
return drawable;
}
private @NonNull Drawable buildDrawable(@NonNull Context context) {
Drawable background = DrawableCompat.wrap(Objects.requireNonNull(AppCompatResources.getDrawable(context, R.drawable.circle_tintable))).mutate();
Drawable foreground = AppCompatResources.getDrawable(context, drawable80dp);
Drawable gradient = AppCompatResources.getDrawable(context, R.drawable.avatar_gradient);
LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground, gradient});
Drawable foreground = Objects.requireNonNull(AppCompatResources.getDrawable(context, drawable80dp));
LayerDrawable drawable = new LayerDrawable(new Drawable[]{background, foreground});
int foregroundInset = ViewUtil.dpToPx(24);
DrawableCompat.setTint(background, backgroundColor);
DrawableCompat.setTint(background, color.colorInt());
DrawableCompat.setTint(foreground, Avatars.getForegroundColor(color).getColorInt());
drawable.setLayerInset(1, foregroundInset, foregroundInset, foregroundInset, foregroundInset);

View File

@@ -1,79 +1,72 @@
package org.thoughtcrime.securesms.contacts.avatars;
import android.content.Context;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.text.TextUtils;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.content.ContextCompat;
import com.amulyakhare.textdrawable.TextDrawable;
import com.airbnb.lottie.SimpleColorFilter;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.avatar.Avatar;
import org.thoughtcrime.securesms.avatar.AvatarRenderer;
import org.thoughtcrime.securesms.avatar.Avatars;
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.NameUtil;
import java.util.regex.Pattern;
import java.util.Objects;
public class GeneratedContactPhoto implements FallbackContactPhoto {
private static final Pattern PATTERN = Pattern.compile("[^\\p{L}\\p{Nd}\\p{S}]+");
private static final Typeface TYPEFACE = Typeface.create("sans-serif-medium", Typeface.NORMAL);
private final String name;
private final int fallbackResId;
private final int targetSize;
private final int fontSize;
public GeneratedContactPhoto(@NonNull String name, @DrawableRes int fallbackResId) {
this(name, fallbackResId, -1, ViewUtil.dpToPx(24));
this(name, fallbackResId, -1);
}
public GeneratedContactPhoto(@NonNull String name, @DrawableRes int fallbackResId, int targetSize, int fontSize) {
public GeneratedContactPhoto(@NonNull String name, @DrawableRes int fallbackResId, int targetSize) {
this.name = name;
this.fallbackResId = fallbackResId;
this.targetSize = targetSize;
this.fontSize = fontSize;
}
@Override
public Drawable asDrawable(Context context, int color) {
public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color) {
return asDrawable(context, color,false);
}
@Override
public Drawable asDrawable(Context context, int color, boolean inverted) {
public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
int targetSize = this.targetSize != -1
? this.targetSize
: context.getResources().getDimensionPixelSize(R.dimen.contact_photo_target_size);
String character = getAbbreviation(name);
String character = NameUtil.getAbbreviation(name);
if (!TextUtils.isEmpty(character)) {
Drawable base = TextDrawable.builder()
.beginConfig()
.width(targetSize)
.height(targetSize)
.useFont(TYPEFACE)
.fontSize(fontSize)
.textColor(inverted ? color : Color.WHITE)
.endConfig()
.buildRound(character, inverted ? Color.WHITE : color);
Avatars.ForegroundColor foregroundColor = Avatars.getForegroundColor(color);
Avatar.Text avatar = new Avatar.Text(character, new Avatars.ColorPair(color, foregroundColor), Avatar.DatabaseId.DoNotPersist.INSTANCE);
Drawable foreground = AvatarRenderer.createTextDrawable(context, avatar, inverted, targetSize);
Drawable background = Objects.requireNonNull(ContextCompat.getDrawable(context, R.drawable.circle_tintable));
Drawable gradient = ContextUtil.requireDrawable(context, R.drawable.avatar_gradient);
return new LayerDrawable(new Drawable[] { base, gradient });
background.setColorFilter(new SimpleColorFilter(inverted ? foregroundColor.getColorInt() : color.colorInt()));
return new LayerDrawable(new Drawable[] { background, foreground });
}
return newFallbackDrawable(context, color, inverted);
}
@Override
public Drawable asSmallDrawable(Context context, int color, boolean inverted) {
public Drawable asSmallDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
return asDrawable(context, color, inverted);
}
@@ -81,32 +74,12 @@ public class GeneratedContactPhoto implements FallbackContactPhoto {
return fallbackResId;
}
protected Drawable newFallbackDrawable(@NonNull Context context, int color, boolean inverted) {
protected Drawable newFallbackDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
return new ResourceContactPhoto(fallbackResId).asDrawable(context, color, inverted);
}
private @Nullable String getAbbreviation(String name) {
String[] parts = name.split(" ");
StringBuilder builder = new StringBuilder();
int count = 0;
for (int i = 0; i < parts.length && count < 2; i++) {
String cleaned = PATTERN.matcher(parts[i]).replaceFirst("");
if (!TextUtils.isEmpty(cleaned)) {
builder.appendCodePoint(cleaned.codePointAt(0));
count++;
}
}
if (builder.length() == 0) {
return null;
} else {
return builder.toString();
}
}
@Override
public Drawable asCallCard(Context context) {
public Drawable asCallCard(@NonNull Context context) {
return AppCompatResources.getDrawable(context, R.drawable.ic_person_large);
}

View File

@@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.contacts.avatars;
import android.content.Context;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
@@ -15,8 +14,9 @@ import androidx.appcompat.content.res.AppCompatResources;
import com.amulyakhare.textdrawable.TextDrawable;
import com.makeramen.roundedimageview.RoundedDrawable;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.jetbrains.annotations.NotNull;
import org.thoughtcrime.securesms.avatar.Avatars;
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
public class ResourceContactPhoto implements FallbackContactPhoto {
@@ -45,38 +45,34 @@ public class ResourceContactPhoto implements FallbackContactPhoto {
}
@Override
public @NonNull Drawable asDrawable(@NonNull Context context, int color) {
public @NonNull Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color) {
return asDrawable(context, color, false);
}
@Override
public @NonNull Drawable asDrawable(@NonNull Context context, int color, boolean inverted) {
public @NonNull Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
return buildDrawable(context, resourceId, color, inverted);
}
@Override
public @NonNull Drawable asSmallDrawable(@NonNull Context context, int color, boolean inverted) {
public @NonNull Drawable asSmallDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
return buildDrawable(context, smallResourceId, color, inverted);
}
private @NonNull Drawable buildDrawable(@NonNull Context context, int resourceId, int color, boolean inverted) {
Drawable background = TextDrawable.builder().buildRound(" ", inverted ? Color.WHITE : color);
RoundedDrawable foreground = (RoundedDrawable) RoundedDrawable.fromDrawable(AppCompatResources.getDrawable(context, resourceId));
private @NonNull Drawable buildDrawable(@NonNull Context context, int resourceId, @NonNull AvatarColor color, boolean inverted) {
Avatars.ForegroundColor foregroundColor = Avatars.getForegroundColor(color);
Drawable background = TextDrawable.builder().buildRound(" ", inverted ? foregroundColor.getColorInt() : color.colorInt());
RoundedDrawable foreground = (RoundedDrawable) RoundedDrawable.fromDrawable(AppCompatResources.getDrawable(context, resourceId));
//noinspection ConstantConditions
foreground.setScaleType(scaleType);
foreground.setColorFilter(inverted ? color.colorInt() : foregroundColor.getColorInt(), PorterDuff.Mode.SRC_ATOP);
if (inverted) {
foreground.setColorFilter(color, PorterDuff.Mode.SRC_ATOP);
}
Drawable gradient = ContextUtil.requireDrawable(context, R.drawable.avatar_gradient);
return new ExpandingLayerDrawable(new Drawable[] {background, foreground, gradient});
return new ExpandingLayerDrawable(new Drawable[] {background, foreground});
}
@Override
public @Nullable Drawable asCallCard(@NonNull Context context) {
public @Nullable Drawable asCallCard(@NotNull @NonNull Context context) {
return AppCompatResources.getDrawable(context, callCardResourceId);
}

View File

@@ -3,33 +3,36 @@ package org.thoughtcrime.securesms.contacts.avatars;
import android.content.Context;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import com.makeramen.roundedimageview.RoundedDrawable;
import org.jetbrains.annotations.NotNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
public class TransparentContactPhoto implements FallbackContactPhoto {
public TransparentContactPhoto() {}
@Override
public Drawable asDrawable(Context context, int color) {
public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color) {
return asDrawable(context, color, false);
}
@Override
public Drawable asDrawable(Context context, int color, boolean inverted) {
public Drawable asDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
return RoundedDrawable.fromDrawable(context.getResources().getDrawable(android.R.color.transparent));
}
@Override
public Drawable asSmallDrawable(Context context, int color, boolean inverted) {
public Drawable asSmallDrawable(@NonNull Context context, @NonNull AvatarColor color, boolean inverted) {
return asDrawable(context, color, inverted);
}
@Override
public Drawable asCallCard(Context context) {
public Drawable asCallCard(@NonNull Context context) {
return ContextCompat.getDrawable(context, R.drawable.ic_contact_picture_large);
}

View File

@@ -53,8 +53,9 @@ import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
import org.whispersystems.signalservice.api.services.ProfileService;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture;
import org.whispersystems.signalservice.internal.ServiceResponse;
import java.io.IOException;
import java.util.Calendar;
@@ -66,9 +67,10 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
* Manages all the stuff around determining if a user is registered or not.
@@ -131,7 +133,7 @@ public class DirectoryHelper {
Stopwatch stopwatch = new Stopwatch("single");
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
RegisteredState originalRegisteredState = recipient.resolve().getRegistered();
RegisteredState newRegisteredState = null;
RegisteredState newRegisteredState;
if (recipient.hasUuid() && !recipient.hasE164()) {
boolean isRegistered = isUuidRegistered(context, recipient);
@@ -510,29 +512,34 @@ public class DirectoryHelper {
.filter(r -> hasCommunicatedWith(context, r))
.toList();
List<Pair<Recipient, ListenableFuture<ProfileAndCredential>>> futures = Stream.of(possiblyUnlisted)
.map(r -> new Pair<>(r, ProfileUtil.retrieveProfile(context, r, SignalServiceProfile.RequestType.PROFILE)))
.toList();
Set<RecipientId> potentiallyActiveIds = new HashSet<>();
Set<RecipientId> retries = new HashSet<>();
ProfileService profileService = new ProfileService(ApplicationDependencies.getGroupsV2Operations().getProfileOperations(),
ApplicationDependencies.getSignalServiceMessageReceiver(),
ApplicationDependencies.getSignalWebSocket());
Stream.of(futures)
.forEach(pair -> {
try {
pair.second().get(5, TimeUnit.SECONDS);
potentiallyActiveIds.add(pair.first().getId());
} catch (InterruptedException | TimeoutException e) {
retries.add(pair.first().getId());
potentiallyActiveIds.add(pair.first().getId());
} catch (ExecutionException e) {
if (!(e.getCause() instanceof NotFoundException)) {
retries.add(pair.first().getId());
potentiallyActiveIds.add(pair.first().getId());
}
}
});
List<Observable<Pair<Recipient, ServiceResponse<ProfileAndCredential>>>> requests = Stream.of(possiblyUnlisted)
.map(r -> ProfileUtil.retrieveProfile(context, r, SignalServiceProfile.RequestType.PROFILE, profileService)
.toObservable()
.timeout(5, TimeUnit.SECONDS)
.onErrorReturn(t -> new Pair<>(r, ServiceResponse.forUnknownError(t))))
.toList();
return new UnlistedResult(potentiallyActiveIds, retries);
return Observable.mergeDelayError(requests)
.observeOn(Schedulers.io(), true)
.scan(new UnlistedResult.Builder(), (builder, pair) -> {
Recipient recipient = pair.first();
ProfileService.ProfileResponseProcessor processor = new ProfileService.ProfileResponseProcessor(pair.second());
if (processor.hasResult()) {
builder.potentiallyActiveIds.add(recipient.getId());
} else if (processor.genericIoError() || !processor.notFound()) {
builder.retries.add(recipient.getId());
builder.potentiallyActiveIds.add(recipient.getId());
}
return builder;
})
.lastOrError()
.map(UnlistedResult.Builder::build)
.blockingGet();
}
private static boolean hasCommunicatedWith(@NonNull Context context, @NonNull Recipient recipient) {
@@ -584,6 +591,15 @@ public class DirectoryHelper {
@NonNull Set<RecipientId> getRetries() {
return retries;
}
private static class Builder {
final Set<RecipientId> potentiallyActiveIds = new HashSet<>();
final Set<RecipientId> retries = new HashSet<>();
@NonNull UnlistedResult build() {
return new UnlistedResult(potentiallyActiveIds, retries);
}
}
}
private static class AccountHolder {

View File

@@ -28,7 +28,6 @@ public class AttachmentKeyboard extends FrameLayout implements InputAwareLayout.
private static final List<AttachmentKeyboardButton> DEFAULT_BUTTONS = Arrays.asList(
AttachmentKeyboardButton.GALLERY,
AttachmentKeyboardButton.GIF,
AttachmentKeyboardButton.FILE,
AttachmentKeyboardButton.PAYMENT,
AttachmentKeyboardButton.CONTACT,

View File

@@ -8,7 +8,6 @@ import org.thoughtcrime.securesms.R;
public enum AttachmentKeyboardButton {
GALLERY(R.string.AttachmentKeyboard_gallery, R.drawable.ic_photo_album_outline_32),
GIF(R.string.AttachmentKeyboard_gif, R.drawable.ic_gif_outline_32),
FILE(R.string.AttachmentKeyboard_file, R.drawable.ic_file_outline_32),
PAYMENT(R.string.AttachmentKeyboard_payment, R.drawable.ic_payments_32),
CONTACT(R.string.AttachmentKeyboard_contact, R.drawable.ic_contact_circle_outline_32),

Some files were not shown because too many files have changed in this diff Show More