Compare commits

..

176 Commits

Author SHA1 Message Date
Alex Hart
372c6f6ba3 Bump version to 6.17.0 2023-04-05 16:47:03 -03:00
Alex Hart
d6f4a89326 Updated baseline profile. 2023-04-05 16:46:56 -03:00
Alex Hart
60905c7409 Updated language translations. 2023-04-05 16:44:15 -03:00
Clark Chen
f3490d07bf Fix settings icons for older API levels. 2023-04-05 16:40:23 -03:00
Alex Hart
b99855afbe Fix avatar photo editing. 2023-04-05 16:40:23 -03:00
Alex Hart
921c903190 Allow forwarding of contacts. 2023-04-05 16:40:23 -03:00
Alex Hart
9771b53c79 Add logging to where we mark StoryContent as Ready. 2023-04-05 16:40:23 -03:00
Nicholas
0ab5bbb240 Detect and recover from SCO interruptions. 2023-04-05 16:40:23 -03:00
Alex Hart
fef533f101 Upgrade CameraX to 1.2.2 2023-04-05 16:40:23 -03:00
Alex Hart
3399af5a96 Fix mic toggle state. 2023-04-05 16:40:23 -03:00
Alex Hart
022195508a Swap AlertDialog.Builder for MaterialAlertDialogBuilder. 2023-04-05 16:40:23 -03:00
Alex Hart
d3daaff6a4 Update collapsed toolbar state for group calling. 2023-04-05 16:40:23 -03:00
Nicholas
89a3c62637 Only deauthorize on identified connection. 2023-04-05 16:40:23 -03:00
Alex Hart
6da9db6d86 Allow MediaSelectionActivity to ignore uiMode configuration changes. 2023-04-05 16:40:23 -03:00
Alex Hart
c254b08e33 Add the join / return button to call log items. 2023-04-05 16:40:23 -03:00
Alex Hart
9d575650d1 Add create call link sheet. 2023-04-05 16:40:23 -03:00
Greyson Parrelli
d8ac5a390a Write to MSL before sending a sync message. 2023-04-05 16:40:23 -03:00
Alex Hart
60842a10ff Align donate for a friend duration with data from gift badge. 2023-04-05 16:40:23 -03:00
Alex Hart
1cab6f87a0 Remove extra rows from PhoneNumberPrivacySettingsFragment. 2023-04-05 16:40:23 -03:00
Nicholas
a0aeac767d New Android 12+ audio route picker for calls. 2023-04-05 16:40:23 -03:00
Greyson Parrelli
99bd8e82ca Do not process messages while change number is happening. 2023-04-05 16:40:23 -03:00
Greyson Parrelli
bbdf54097e Prevent certain types of circular job dependencies. 2023-04-05 16:40:23 -03:00
Greyson Parrelli
2a9576baf5 Convert FastJobStorage to kotlin. 2023-04-05 16:40:23 -03:00
Clark Chen
2ca4c2d1c1 Fix gradle verification for Mac/Windows. 2023-04-05 16:40:23 -03:00
Greyson Parrelli
3231f8302c Do not add dependencies on previous message sends if you have no media.
This was an accidental carry-over from the PushMediaSendJob ->
IndividualSendJob rename.

Previously, PushMediaSendJob basically always had media, so this check
wasn't needed. Now it rarely has media, so we have to add it.
2023-04-05 16:40:23 -03:00
Greyson Parrelli
e0be9b4ef5 Fix resend operation for sync messages.
We shouldn't be using sealed sender for any sync messages.
2023-04-05 16:40:23 -03:00
Clark
83b0963533 Add fix for m4a mapping to audio/mpeg. 2023-04-05 16:40:23 -03:00
Alex Hart
f9548dcffe Add support for group call disposition.
Co-authored-by: Cody Henthorne <cody@signal.org>
2023-04-05 16:40:23 -03:00
Greyson Parrelli
e94a84d4ec Fixed MessageProcessingPerformanceTest. 2023-04-05 16:40:23 -03:00
Greyson Parrelli
db5f8707ec Remove TracingExecutors. 2023-04-05 16:40:23 -03:00
Greyson Parrelli
0f15562a28 Rename ComposeFragment.SheetContent -> FragmentContent. 2023-04-05 16:40:23 -03:00
Greyson Parrelli
b300f223ba Update AGP to 7.4.2 2023-04-05 16:40:23 -03:00
Clark
ad9337021c Streamline export account data to not save to disk. 2023-04-04 12:16:45 -03:00
Greyson Parrelli
5e94c350ed Add dependency for kotlinx immutable collections. 2023-04-04 12:16:45 -03:00
Clark
666020c3dc Add learn more link to export account data. 2023-04-04 12:16:45 -03:00
Alex Hart
f249a6edd5 Add StatusBarColorNestedScrollConnection. 2023-04-04 12:16:45 -03:00
Cody Henthorne
2e45bd719a Add kotlin/proto level message processing. 2023-04-04 12:16:45 -03:00
Clark
28f27915c5 Add support for time stickers in image editor. 2023-04-04 12:16:45 -03:00
Alex Hart
08ebca501b Bump version to 6.16.2 2023-04-04 11:07:18 -03:00
Alex Hart
417cda1d38 Updated baseline profile. 2023-04-04 11:06:48 -03:00
Alex Hart
dd730f5fbf Updated language translations. 2023-04-04 11:01:17 -03:00
Nicholas Tinsley
77bb3702a9 Add more detail to 502 errors during registration. 2023-04-03 14:43:26 -04:00
Nicholas Tinsley
5046f58c6f Constrain end of code entry subheader. 2023-04-03 14:32:17 -04:00
Greyson Parrelli
d02f605874 De-pluralize some strings. 2023-03-31 15:01:11 -04:00
Nicholas
36a8c4d8ba Bump version to 6.16.1 2023-03-30 18:33:53 -04:00
Nicholas
25f0427585 Updated baseline profile. 2023-03-30 18:33:00 -04:00
Nicholas
5a501f4815 Updated language translations. 2023-03-30 18:27:30 -04:00
Greyson Parrelli
de0a37d356 Fix possible dangling thread records. 2023-03-30 17:17:10 -04:00
Clark
5c65d5435c Fix rtl hiding conversation title. 2023-03-30 16:29:08 -04:00
Greyson Parrelli
8d6a4c2888 Fix SKDM processing. 2023-03-30 15:48:53 -04:00
Nicholas
b4a7ffdc12 Bump version to 6.16.0 2023-03-29 14:18:55 -04:00
Nicholas
5dd10f6fcc Updated baseline profile. 2023-03-29 14:16:21 -04:00
Nicholas
e76b5007e0 Updated language translations. 2023-03-29 14:13:51 -04:00
Nicholas Tinsley
16e8f9633e fixup! Add benchmark for conversation open. 2023-03-29 12:44:27 -04:00
Clark Chen
cb4a45fea3 Fix empty name crash when fetching first alpha recipient row. 2023-03-29 10:55:00 -04:00
Cody Henthorne
0017b7af26 Ignore contact joined message when determining if we should apply universal disappearing messages. 2023-03-28 16:06:23 -04:00
Greyson Parrelli
5f645193e4 Create Buttons.ActionButton component. 2023-03-28 16:02:45 -04:00
Cody Henthorne
607a06d379 Enable scheduled backups regardless of API version. 2023-03-28 09:24:11 -04:00
Cody Henthorne
149955e07a Fix crash when searching and lowercase snippet differs from input snippet. 2023-03-27 21:16:36 -04:00
Greyson Parrelli
80b9e4e7ae Updated username education icons. 2023-03-27 13:05:38 -04:00
Greyson Parrelli
f02ac86e45 Updated strings for username education fragment. 2023-03-27 11:07:43 -04:00
elena
45e96f0efe Show character count when writing a payment note.
Close #12616
2023-03-27 09:47:30 -04:00
Cody Henthorne
06894d6a7e Fix formatting on long text messages. 2023-03-24 17:15:02 -04:00
Greyson Parrelli
b67dfe10d4 Add some accessibility labels for the camera screen. 2023-03-24 16:44:05 -04:00
Greyson Parrelli
b9b6a57e2c Fix possible username conflict in storage update. 2023-03-24 16:32:38 -04:00
Cody Henthorne
ba2d005b2a Fix search result styling for formatting and query highlighting. 2023-03-24 15:49:27 -04:00
Cody Henthorne
f53679f24a Fix spoiler rendering in quotes. 2023-03-24 15:49:27 -04:00
Cody Henthorne
7eb00e41a2 Fix rendering of links and mentions covered by spoilers. 2023-03-24 15:49:26 -04:00
Jim Gustafson
168e37c3fc Update to RingRTC v2.26.1 2023-03-24 15:49:26 -04:00
Clark
98438ff8e4 Update async layout inflater to fix AppCompat views. 2023-03-24 15:49:26 -04:00
Clark
d6a9ed1a8d Add setting for requesting user account data. 2023-03-24 15:49:26 -04:00
Jim Gustafson
b194c0e84b Update to RingRTC v2.26.0 2023-03-24 15:49:26 -04:00
Nicholas
ed67e7ac04 Ignore smartwatch as possible headset. 2023-03-24 15:49:26 -04:00
Cody Henthorne
43cd647036 Add spoiler format style. 2023-03-24 15:49:26 -04:00
Greyson Parrelli
5d6889786c Bump version to 6.15.3 2023-03-24 14:39:52 -04:00
Greyson Parrelli
53d4e5c4d1 Updated language translations. 2023-03-24 14:39:52 -04:00
Greyson Parrelli
87918da943 Shorten lifespan of buffered store. 2023-03-24 14:39:51 -04:00
Greyson Parrelli
5914a4d1cf Improved logging around backup export. 2023-03-24 14:39:51 -04:00
Alex Hart
351baa4135 Update content descriptions for call toggles. 2023-03-24 14:39:51 -04:00
Alex Hart
1a71e1a5ae Fix cases where first letter is not an integer or character. 2023-03-24 14:39:51 -04:00
Alex Hart
3ce68a7df8 Allow toggle to be manually slid on preference screens. 2023-03-24 14:39:51 -04:00
Alex Hart
e83c2f1e05 Fix bottom row overlap in group creation / add activities. 2023-03-24 09:47:54 -03:00
Alex Hart
684e53402e Close keyboard after successful profile save. 2023-03-24 09:40:59 -03:00
Greyson Parrelli
db1853f775 Fix timestamp logs on call messages. 2023-03-23 18:58:49 -04:00
Greyson Parrelli
aad835323b Bump version to 6.15.2 2023-03-23 18:22:09 -04:00
Greyson Parrelli
d6f6633c73 Updated language translations. 2023-03-23 18:22:09 -04:00
Greyson Parrelli
76984ab042 Improve logging around message sending.
There were some message types where we didn't log the timestamp.
The timestamp is important for debugging issues (let's us locate an
error more precisely in the logs).
2023-03-23 18:08:39 -04:00
Greyson Parrelli
d58c4ef439 Unify locks in protocol stores. 2023-03-23 15:37:45 -04:00
Alex Hart
2763cfe6f4 Reintroduce labels for incoming call screen. 2023-03-23 16:10:25 -03:00
Greyson Parrelli
454e9a99fc Fix possible NPE in ConversationListFragment. 2023-03-23 14:41:40 -04:00
Alex Hart
aeb250cae1 Fix ISK minimum currency precision. 2023-03-23 14:41:25 -03:00
Greyson Parrelli
34367b4e70 Bump version to 6.15.1 2023-03-23 13:34:32 -04:00
Greyson Parrelli
451537d320 Updated language translations. 2023-03-23 13:34:32 -04:00
Greyson Parrelli
53d4825e12 Fully rebuild FTS after a backup restore. 2023-03-23 13:34:32 -04:00
Greyson Parrelli
24ee4a869f Fix empty state incorrectly showing in search. 2023-03-23 13:32:51 -04:00
Alex Hart
6ae3fb49e0 Add transitions to call state pill. 2023-03-23 13:32:51 -04:00
Alex Hart
8f9713a2c0 Add new call toast and remove call button labels. 2023-03-23 13:32:51 -04:00
Alex Hart
7a2ad37333 Add proper tinting to new chat icons. 2023-03-23 13:32:51 -04:00
Alex Hart
2509d1be73 Fix text alignment and padding on onboarding cards. 2023-03-23 13:32:51 -04:00
Alex Hart
19f4073068 Fix listener behavior for manual directory refresh invocation. 2023-03-23 13:32:51 -04:00
Alex Hart
fd612525a1 Fix touch interactions with MaterialSwitch in preferences. 2023-03-23 13:32:39 -04:00
Greyson Parrelli
631b428a84 Fix log statement.
We were printing out the envelope deviceId, which isn't populated with
sealed sender and was always 0. Should have used the one from the
decryption result.
2023-03-23 13:26:06 -04:00
Greyson Parrelli
09cd581cf4 Bump version to 6.15.0 2023-03-22 14:30:28 -04:00
Greyson Parrelli
fc1ea458f7 Updated language translations. 2023-03-22 14:28:36 -04:00
Clark
247edce7b0 Catch exceptions in share repository for blob provider IO exceptions. 2023-03-22 14:28:36 -04:00
Alex Hart
57a2a32c71 Update call button iconography and colours. 2023-03-22 14:28:36 -04:00
Clark
d9c1ecab9b Fix precaching of conversation list items. 2023-03-22 14:28:36 -04:00
Alex Hart
c70f1f5d75 Fix call screen transition. 2023-03-22 14:28:36 -04:00
Alex Hart
c26cc56f20 Fix bottom bar state handling and active state when menu is open. 2023-03-22 14:28:36 -04:00
Nicholas Tinsley
ca21ab667a Force LTR layout direction on NumericKeyboardView. 2023-03-22 14:28:36 -04:00
Clark
e2ae0063a5 Fix send button disappearing for voice drafts. 2023-03-22 14:28:36 -04:00
Alex Hart
eb150d9a15 Update SwitchMaterial to the new MaterialSwitch. 2023-03-22 14:28:36 -04:00
Cody Henthorne
ee48e6c347 Add sync message handling and stop formatting behavior. 2023-03-22 14:28:36 -04:00
Nicholas
cedf512726 Fix PanicKit for PIN lock.
Fixes #12816.
2023-03-22 14:28:10 -04:00
Clark
2256c8591a Add special audio recording sample rate for Xiaomi Mi 9T. 2023-03-22 14:28:10 -04:00
Alex Hart
1056adb591 Move distribution type operation into ConversationViewModel. 2023-03-22 14:28:10 -04:00
Alex Hart
53716019b6 Remove QuoteRestorationTask in favour of using DraftViewModel to resolve it. 2023-03-22 14:28:10 -04:00
Alex Hart
30f6faf3d7 Move mute handling into ConversationViewModel. 2023-03-22 14:28:10 -04:00
Alex Hart
2a43ffad4f Extract ConversationParentFragment Options Menu into a MenuProvider. 2023-03-22 14:28:10 -04:00
Alex Hart
f9ed5c4d03 Correct some icon tinting. 2023-03-22 14:28:10 -04:00
Cody Henthorne
25028e0e6f Add additional text formatting support. 2023-03-22 14:28:10 -04:00
Alex Hart
1c3636eedd Add undo-ability to call tab deletion. 2023-03-22 14:28:10 -04:00
Alex Hart
4d735d23b6 Remove unnecessary method calls in options menu code. 2023-03-22 14:28:10 -04:00
Greyson Parrelli
834d0a1cee Trigger an automatic session reset after failing to send a retry receipt. 2023-03-22 14:28:09 -04:00
Alex Hart
166e555d32 Kill two unused classes. 2023-03-22 14:28:09 -04:00
Greyson Parrelli
7f963d7628 Keep protocol error logs longer. 2023-03-22 14:28:09 -04:00
Alex Hart
cebe600014 Update bottom bar to support just calls and chats. 2023-03-22 14:28:09 -04:00
Alex Hart
5c688289a5 Ensure we do not stage shared element transition view when opening media from a bubble. 2023-03-22 14:28:09 -04:00
Greyson Parrelli
bf611f3a56 Fix potential NPE in SNC dialog. 2023-03-22 14:28:09 -04:00
Clark
150c42c590 Add notification for failed story messages. 2023-03-22 14:28:09 -04:00
Clark
069b707d9d Add dark mode for location picker. 2023-03-22 14:28:09 -04:00
Alex Hart
8c0d979abd Add call tab bottom bar. 2023-03-22 14:28:09 -04:00
Alex Hart
545f1fa5a4 Add call tab info screen. 2023-03-22 14:28:09 -04:00
Greyson Parrelli
49a814abef Show blocked users as 'skipped' when sending to curated story list. 2023-03-22 14:28:09 -04:00
Clark
17fc0dc0a1 Add indicator and story ring for stories in chat selection. 2023-03-22 14:28:09 -04:00
Greyson Parrelli
7c8de901f1 Store Job data as bytes. 2023-03-22 14:28:09 -04:00
Alex Hart
b5af581205 Set proper filter labeling on call tab. 2023-03-22 14:28:09 -04:00
Alex Hart
de73744432 Add new symbols for call tab. 2023-03-22 14:28:09 -04:00
Alex Hart
ce3770a0fb Add new call screen for calls tab. 2023-03-22 14:28:09 -04:00
Greyson Parrelli
1210b2af0f Some additional decryption perf improvements. 2023-03-22 14:28:09 -04:00
Greyson Parrelli
c6861f1778 Add support for the ManifestRecord.sourceDevice field. 2023-03-22 14:28:09 -04:00
Clark
906dd5cb40 Drop link preview thumbnail from forward if URI isn't present. 2023-03-22 14:27:59 -04:00
Clark
97b349b0de Add benchmark for conversation open. 2023-03-20 17:39:09 -04:00
Clark
f3b830ae20 Fix dark mode for compose bottom sheets. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
7d7e6e5013 Update SQLCipher to 4.5.3-FTS-S3 2023-03-20 17:39:09 -04:00
Alex Hart
8ca596580c Add info action wiring in calls tab. 2023-03-20 17:39:09 -04:00
Alex Hart
7521520b26 Ensure scrolling properly highlights action bar in calls tab. 2023-03-20 17:39:09 -04:00
Alex Hart
18554170f2 Update call tab to display unread missed call count. 2023-03-20 17:39:09 -04:00
Alex Hart
cd5a3768eb Fix back handling between tabs. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
cf64f06c36 Add a new test case for recipient merging. 2023-03-20 17:39:09 -04:00
Alex Hart
88de0f21e7 Add initial implementation of calls tab behind a feature flag. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
d1373d2767 Remove queue drained constraint from receipt jobs. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
baece9823b Remove log when enqueuing job within a transaction.
Found the bug I put the logging in for, and now this log happens way to
much after the decryption batching.
2023-03-20 17:39:09 -04:00
Greyson Parrelli
e18b2d263c Fix rendering of story replies in quote thread view. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
d12830cb66 Add language support for Uyghur. 2023-03-20 17:39:09 -04:00
Cody Henthorne
59141bc6a4 Improve delete thread performance. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
431e366e76 Add in possible recovery for DB error handler.
A bad FTS index can result in the corruption handler being triggered.
We can attempt to rebuild it to see if that helps.
2023-03-20 17:39:09 -04:00
Nicholas
66cb2a04c3 Rename properties of AccountAttributes. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
90cc672c37 Convert MessageTable to kotlin. 2023-03-20 17:39:09 -04:00
Clark
c2a76c4313 Convert ConversationTitleView to a ConstraintLayout. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
ee685936c5 Updated MessageProcessingPerformanceTest to use websocket injection. 2023-03-20 17:39:09 -04:00
Alex Hart
a7bca89889 Perform username deletion if no local name is set. 2023-03-20 17:39:09 -04:00
Clark
39f5aebbec Add support for scheduling media to multiple contacts. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
35571e7ab2 Added another RecipientTable.getAndPossiblyMerge test case. 2023-03-20 17:39:09 -04:00
Clark
ed2d6ea903 Only setup mock data once for baseline profiles and benchmarks. 2023-03-20 17:39:09 -04:00
Alex Hart
e1e117ce73 Increase logging around username synchronization. 2023-03-20 17:39:09 -04:00
Greyson Parrelli
894095414a Perform message decryptions in batches. 2023-03-20 17:39:09 -04:00
Clark
04baa7925f Add support for baseline profiles. 2023-03-20 17:39:08 -04:00
Clark
79a062c838 Introduce thread priorities for threads and handlerthreads. 2023-03-20 17:39:08 -04:00
Greyson Parrelli
2cef06cd6e Bump version to 6.14.5 2023-03-20 17:37:51 -04:00
Greyson Parrelli
af4b98f424 Updated language translations. 2023-03-20 17:37:51 -04:00
Greyson Parrelli
cd66ba60e3 Disable view precaching of chat list to fix selection checkmark bug. 2023-03-20 17:37:49 -04:00
Greyson Parrelli
2d2a1049a4 Make onboarding card close button background borderless. 2023-03-20 17:37:24 -04:00
Greyson Parrelli
03aa6a1d61 Fix potential crash when starting IncomingMessageObserver service. 2023-03-20 17:37:24 -04:00
Greyson Parrelli
6c6d4e801f Fix crash when starting multiple audio records. 2023-03-20 17:37:24 -04:00
Cody Henthorne
a6d7b0c7bf Fix crash in multishare flow. 2023-03-20 17:37:24 -04:00
800 changed files with 86893 additions and 13816 deletions

View File

@@ -46,8 +46,8 @@ ktlint {
version = "0.47.1"
}
def canonicalVersionCode = 1232
def canonicalVersionName = "6.14.5"
def canonicalVersionCode = 1239
def canonicalVersionName = "6.17.0"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -68,6 +68,7 @@ def selectableVariants = [
'playProdDebug',
'playProdSpinner',
'playProdPerf',
'playProdBenchmark',
'playProdInstrumentation',
'playProdRelease',
'playStagingDebug',
@@ -219,6 +220,7 @@ android {
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"unset\""
buildConfigField "String", "BADGE_STATIC_ROOT", "\"https://updates2.signal.org/static/badges/\""
buildConfigField "String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\""
buildConfigField "boolean", "TRACING_ENABLED", "false"
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
@@ -228,7 +230,7 @@ android {
splits {
abi {
enable true
enable !project.hasProperty('generateBaselineProfile')
reset()
include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
universalApk true
@@ -304,6 +306,17 @@ android {
minifyEnabled true
matchingFallbacks = ['debug']
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Perf\""
buildConfigField "boolean", "TRACING_ENABLED", "true"
}
benchmark {
initWith debug
isDefault false
debuggable false
minifyEnabled true
matchingFallbacks = ['debug']
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Benchmark\""
buildConfigField "boolean", "TRACING_ENABLED", "true"
}
}
@@ -462,6 +475,9 @@ dependencies {
implementation libs.androidx.autofill
implementation libs.androidx.biometric
implementation libs.androidx.sharetarget
implementation libs.androidx.profileinstaller
implementation libs.androidx.asynclayoutinflater
implementation libs.androidx.asynclayoutinflater.appcompat
implementation (libs.firebase.messaging) {
exclude group: 'com.google.firebase', module: 'firebase-core'
@@ -540,6 +556,7 @@ dependencies {
exclude group: 'org.freemarker'
}
implementation libs.dnsjava
implementation libs.kotlinx.collections.immutable
spinnerImplementation project(":spinner")
spinnerImplementation libs.square.leakcanary

View File

@@ -0,0 +1,750 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.ringrtc.CallId
import org.signal.ringrtc.CallManager
import org.thoughtcrime.securesms.testing.SignalActivityRule
@RunWith(AndroidJUnit4::class)
class CallTableTest {
@get:Rule
val harness = SignalActivityRule()
@Test
fun givenACall_whenISetTimestamp_thenIExpectUpdatedTimestamp() {
val callId = 1L
val now = System.currentTimeMillis()
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
harness.others[0],
CallTable.Direction.INCOMING,
now
)
SignalDatabase.calls.setTimestamp(callId, -1L)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(-1L, call?.timestamp)
val messageRecord = SignalDatabase.messages.getMessageRecord(call!!.messageId!!)
assertEquals(-1L, messageRecord.dateReceived)
assertEquals(-1L, messageRecord.dateSent)
}
@Test
fun givenPreExistingEvent_whenIDeleteGroupCall_thenIMarkDeletedAndSetTimestamp() {
val callId = 1L
val now = System.currentTimeMillis()
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
harness.others[0],
CallTable.Direction.INCOMING,
now
)
val call = SignalDatabase.calls.getCallById(callId)
SignalDatabase.calls.deleteGroupCall(call!!)
val deletedCall = SignalDatabase.calls.getCallById(callId)
val oldestDeletionTimestamp = SignalDatabase.calls.getOldestDeletionTimestamp()
assertEquals(CallTable.Event.DELETE, deletedCall?.event)
assertNotEquals(0L, oldestDeletionTimestamp)
assertNull(deletedCall!!.messageId)
}
@Test
fun givenNoPreExistingEvent_whenIDeleteGroupCall_thenIInsertAndMarkCallDeleted() {
val callId = 1L
SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent(
callId,
harness.others[0],
CallTable.Direction.OUTGOING,
System.currentTimeMillis()
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
val oldestDeletionTimestamp = SignalDatabase.calls.getOldestDeletionTimestamp()
assertEquals(CallTable.Event.DELETE, call?.event)
assertNotEquals(oldestDeletionTimestamp, 0)
assertNull(call?.messageId)
}
@Test
fun givenNoPriorEvent_whenIInsertAcceptedOutgoingGroupCall_thenIExpectLocalRingerAndOutgoingRing() {
val callId = 1L
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
harness.others[0],
CallTable.Direction.OUTGOING,
System.currentTimeMillis()
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.OUTGOING_RING, call?.event)
assertEquals(harness.self.id, call?.ringerRecipient)
assertNotNull(call?.messageId)
}
@Test
fun givenNoPriorEvent_whenIInsertAcceptedIncomingGroupCall_thenIExpectJoined() {
val callId = 1L
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
harness.others[0],
CallTable.Direction.INCOMING,
System.currentTimeMillis()
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.JOINED, call?.event)
assertNull(call?.ringerRecipient)
assertNotNull(call?.messageId)
}
@Test
fun givenARingingCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
val callId = 1L
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
ringId = callId,
groupRecipientId = harness.others[0],
ringerRecipient = harness.others[1],
dateReceived = System.currentTimeMillis(),
ringState = CallManager.RingUpdate.REQUESTED
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.RINGING, call?.event)
SignalDatabase.calls.acceptIncomingGroupCall(
call!!
)
val acceptedCall = SignalDatabase.calls.getCallById(callId)
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
}
@Test
fun givenAMissedCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
val callId = 1L
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
ringId = callId,
groupRecipientId = harness.others[0],
ringerRecipient = harness.others[1],
dateReceived = System.currentTimeMillis(),
ringState = CallManager.RingUpdate.EXPIRED_REQUEST
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
SignalDatabase.calls.acceptIncomingGroupCall(
call!!
)
val acceptedCall = SignalDatabase.calls.getCallById(callId)
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
}
@Test
fun givenADeclinedCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
val callId = 1L
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
ringId = callId,
groupRecipientId = harness.others[0],
ringerRecipient = harness.others[1],
dateReceived = System.currentTimeMillis(),
ringState = CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.DECLINED, call?.event)
SignalDatabase.calls.acceptIncomingGroupCall(
call!!
)
val acceptedCall = SignalDatabase.calls.getCallById(callId)
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
}
@Test
fun givenAGenericGroupCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
timestamp = System.currentTimeMillis(),
peekGroupCallEraId = "aaa",
peekJoinedUuids = emptyList(),
isCallFull = false
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
SignalDatabase.calls.acceptIncomingGroupCall(
call!!
)
val acceptedCall = SignalDatabase.calls.getCallById(callId)
assertEquals(CallTable.Event.JOINED, acceptedCall?.event)
}
@Test
fun givenNoPriorCallEvent_whenIReceiveAGroupCallUpdateMessage_thenIExpectAGenericGroupCall() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
timestamp = System.currentTimeMillis(),
peekGroupCallEraId = "aaa",
peekJoinedUuids = emptyList(),
isCallFull = false
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
}
@Test
fun givenAPriorCallEventWithNewerTimestamp_whenIReceiveAGroupCallUpdateMessage_thenIExpectAnUpdatedTimestamp() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
timestamp = now,
peekGroupCallEraId = "aaa",
peekJoinedUuids = emptyList(),
isCallFull = false
)
SignalDatabase.calls.getCallById(callId).let {
assertNotNull(it)
assertEquals(now, it?.timestamp)
}
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
timestamp = 1L,
peekGroupCallEraId = "aaa",
peekJoinedUuids = emptyList(),
isCallFull = false
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
assertEquals(1L, call?.timestamp)
}
@Test
fun givenADeletedCallEvent_whenIReceiveARingUpdate_thenIIgnoreTheRingUpdate() {
val callId = 1L
SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent(
callId = callId,
recipientId = harness.others[0],
direction = CallTable.Direction.INCOMING,
timestamp = System.currentTimeMillis()
)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
ringId = callId,
groupRecipientId = harness.others[0],
ringerRecipient = harness.others[1],
dateReceived = System.currentTimeMillis(),
ringState = CallManager.RingUpdate.REQUESTED
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.DELETE, call?.event)
}
@Test
fun givenAGenericCallEvent_whenRingRequested_thenISetRingerAndMoveToRingingState() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
timestamp = now,
peekGroupCallEraId = "aaa",
peekJoinedUuids = emptyList(),
isCallFull = false
)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.REQUESTED
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.RINGING, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
}
@Test
fun givenAJoinedCallEvent_whenRingRequested_thenISetRingerAndMoveToRingingState() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
harness.others[0],
CallTable.Direction.INCOMING,
now
)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.REQUESTED
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.ACCEPTED, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
}
@Test
fun givenAGenericCallEvent_whenRingExpired_thenISetRingerAndMoveToMissedState() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
timestamp = now,
peekGroupCallEraId = "aaa",
peekJoinedUuids = emptyList(),
isCallFull = false
)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.EXPIRED_REQUEST
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
}
@Test
fun givenARingingCallEvent_whenRingExpired_thenISetRingerAndMoveToMissedState() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
timestamp = now,
peekGroupCallEraId = "aaa",
peekJoinedUuids = emptyList(),
isCallFull = false
)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.REQUESTED
)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.EXPIRED_REQUEST
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
}
@Test
fun givenAJoinedCallEvent_whenRingIsCancelledBecauseUserIsBusyLocally_thenIMoveToAcceptedState() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
harness.others[0],
CallTable.Direction.INCOMING,
now
)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.BUSY_LOCALLY
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.ACCEPTED, call?.event)
}
@Test
fun givenAJoinedCallEvent_whenRingIsCancelledBecauseUserIsBusyOnAnotherDevice_thenIMoveToAcceptedState() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
harness.others[0],
CallTable.Direction.INCOMING,
now
)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.ACCEPTED, call?.event)
}
@Test
fun givenARingingCallEvent_whenRingCancelledBecauseUserIsBusyLocally_thenIMoveToMissedState() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
timestamp = now,
peekGroupCallEraId = "aaa",
peekJoinedUuids = emptyList(),
isCallFull = false
)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.REQUESTED
)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.BUSY_LOCALLY
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
}
@Test
fun givenARingingCallEvent_whenRingCancelledBecauseUserIsBusyOnAnotherDevice_thenIMoveToMissedState() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
timestamp = now,
peekGroupCallEraId = "aaa",
peekJoinedUuids = emptyList(),
isCallFull = false
)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.REQUESTED
)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
}
@Test
fun givenACallEvent_whenRingIsAcceptedOnAnotherDevice_thenIMoveToAcceptedState() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
timestamp = now,
peekGroupCallEraId = "aaa",
peekJoinedUuids = emptyList(),
isCallFull = false
)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.ACCEPTED, call?.event)
}
@Test
fun givenARingingCallEvent_whenRingDeclinedOnAnotherDevice_thenIMoveToDeclinedState() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.REQUESTED
)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.DECLINED, call?.event)
}
@Test
fun givenAMissedCallEvent_whenRingDeclinedOnAnotherDevice_thenIMoveToDeclinedState() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.EXPIRED_REQUEST
)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.DECLINED, call?.event)
}
@Test
fun givenAnOutgoingRingCallEvent_whenRingDeclinedOnAnotherDevice_thenIDoNotChangeState() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
harness.others[0],
CallTable.Direction.OUTGOING,
System.currentTimeMillis()
)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.OUTGOING_RING, call?.event)
}
@Test
fun givenNoPriorEvent_whenRingRequested_thenICreateAnEventInTheRingingStateAndSetRinger() {
val callId = 1L
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.REQUESTED
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.RINGING, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
assertNotNull(call?.messageId)
}
@Test
fun givenNoPriorEvent_whenRingExpired_thenICreateAnEventInTheMissedStateAndSetRinger() {
val callId = 1L
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.EXPIRED_REQUEST
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
assertNotNull(call?.messageId)
}
@Test
fun givenNoPriorEvent_whenRingCancelledByRinger_thenICreateAnEventInTheMissedStateAndSetRinger() {
val callId = 1L
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.CANCELLED_BY_RINGER
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
assertNotNull(call?.messageId)
}
@Test
fun givenNoPriorEvent_whenRingCancelledBecauseUserIsBusyLocally_thenICreateAnEventInTheMissedState() {
val callId = 1L
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.BUSY_LOCALLY
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertNotNull(call?.messageId)
}
@Test
fun givenNoPriorEvent_whenRingCancelledBecauseUserIsBusyOnAnotherDevice_thenICreateAnEventInTheMissedState() {
val callId = 1L
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertNotNull(call?.messageId)
}
@Test
fun givenNoPriorEvent_whenRingAcceptedOnAnotherDevice_thenICreateAnEventInTheAcceptedState() {
val callId = 1L
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.ACCEPTED, call?.event)
assertNotNull(call?.messageId)
}
@Test
fun givenNoPriorEvent_whenRingDeclinedOnAnotherDevice_thenICreateAnEventInTheDeclinedState() {
val callId = 1L
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
harness.others[1],
System.currentTimeMillis(),
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId)
assertNotNull(call)
assertEquals(CallTable.Event.DECLINED, call?.event)
assertNotNull(call?.messageId)
}
}

View File

@@ -247,6 +247,12 @@ class RecipientTableTest_getAndPossiblyMerge {
expectSessionSwitchoverEvent(E164_A)
}
test("e164 matches, e164 + aci provided") {
given(E164_A, PNI_A, null)
process(E164_A, null, ACI_A)
expect(E164_A, PNI_A, ACI_A)
}
test("pni matches, all provided, no pni session") {
given(null, PNI_A, null)
process(E164_A, PNI_A, ACI_A)
@@ -359,6 +365,18 @@ class RecipientTableTest_getAndPossiblyMerge {
expectSessionSwitchoverEvent(id2, E164_B)
}
test("steal, e164+pni+aci & e164+aci, no pni provided, change number") {
given(E164_A, PNI_A, ACI_A)
given(E164_B, null, ACI_B)
process(E164_A, null, ACI_B)
expect(null, PNI_A, ACI_A)
expect(E164_A, null, ACI_B)
expectChangeNumberEvent()
}
test("merge, e164 & pni & aci, all provided") {
given(E164_A, null, null)
given(null, PNI_A, null)

View File

@@ -2,14 +2,18 @@ package org.thoughtcrime.securesms.dependencies
import android.app.Application
import okhttp3.ConnectionSpec
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import okio.ByteString
import org.mockito.kotlin.any
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.KbsEnclave
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
@@ -52,7 +56,10 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
baseUrl = webServer.url("").toString()
addMockWebRequestHandlers(
Get("/v1/websocket/") {
Get("/v1/websocket/?login=") {
MockResponse().success().withWebSocketUpgrade(mockIdentifiedWebSocket)
},
Get("/v1/websocket", { !it.path.contains("login") }) {
MockResponse().success().withWebSocketUpgrade(object : WebSocketListener() {})
}
)
@@ -60,9 +67,7 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
webServer.setDispatcher(object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val handler = handlers.firstOrNull {
request.method == it.verb && request.path.startsWith("/${it.path}")
}
val handler = handlers.firstOrNull { it.requestPredicate(request) }
return handler?.responseFactory?.invoke(request) ?: MockResponse().setResponseCode(500)
}
})
@@ -106,18 +111,51 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
return recipientCache
}
class MockWebSocket : WebSocketListener() {
private val TAG = "MockWebSocket"
var webSocket: WebSocket? = null
private set
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.i(TAG, "onOpen(${webSocket.hashCode()})")
this.webSocket = webSocket
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
Log.i(TAG, "onClosing(${webSocket.hashCode()}): $code, $reason")
this.webSocket = null
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.i(TAG, "onClosed(${webSocket.hashCode()}): $code, $reason")
this.webSocket = null
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.w(TAG, "onFailure(${webSocket.hashCode()})", t)
this.webSocket = null
}
}
companion object {
lateinit var webServer: MockWebServer
private set
lateinit var baseUrl: String
private set
val mockIdentifiedWebSocket = MockWebSocket()
private val handlers: MutableList<Verb> = mutableListOf()
fun addMockWebRequestHandlers(vararg verbs: Verb) {
handlers.addAll(verbs)
}
fun injectWebSocketMessage(value: ByteString) {
mockIdentifiedWebSocket.webSocket!!.send(value)
}
fun clearHandlers() {
handlers.clear()
}

View File

@@ -0,0 +1,381 @@
package org.thoughtcrime.securesms.messages
import android.database.Cursor
import android.util.Base64
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.ThreadUtil
import org.signal.core.util.readToList
import org.signal.core.util.select
import org.signal.core.util.toSingleLine
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.MessageTypes
import org.thoughtcrime.securesms.database.MessageTypes.isOutgoingMessageType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.ThreadTable
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.Entry
import org.thoughtcrime.securesms.testing.InMemoryLogger
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assertIs
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
import org.whispersystems.signalservice.api.messages.SignalServiceContent
import org.whispersystems.signalservice.api.messages.SignalServiceMetadata
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import org.whispersystems.signalservice.internal.serialize.SignalServiceAddressProtobufSerializer
import org.whispersystems.signalservice.internal.serialize.SignalServiceMetadataProtobufSerializer
import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto
import java.util.Optional
@RunWith(AndroidJUnit4::class)
class MessageContentProcessorTestV2 {
companion object {
private val TAGS = listOf(MessageContentProcessorV2.TAG, AttachmentTable.TAG)
private val GENERALIZE_TAG = mapOf(
MessageContentProcessorV2.TAG to "MCP",
AttachmentTable.TAG to AttachmentTable.TAG
)
private val IGNORE_MESSAGE_COLUMNS = listOf(
MessageTable.DATE_RECEIVED,
MessageTable.NOTIFIED_TIMESTAMP,
MessageTable.REACTIONS_LAST_SEEN,
MessageTable.NOTIFIED
)
private val IGNORE_ATTACHMENT_COLUMNS = listOf(
AttachmentTable.UNIQUE_ID,
AttachmentTable.TRANSFER_FILE
)
}
@get:Rule
val harness = SignalActivityRule()
private lateinit var processorV1: MessageContentProcessor
private lateinit var processorV2: MessageContentProcessorV2
private lateinit var testResult: TestResults
private var envelopeTimestamp: Long = 0
@Before
fun setup() {
processorV1 = MessageContentProcessor(harness.context)
processorV2 = MessageContentProcessorV2(harness.context)
envelopeTimestamp = System.currentTimeMillis()
testResult = TestResults()
}
@Test
fun textMessage() {
var start = envelopeTimestamp
val messages: List<TestMessage> = (0 until 1).map {
start += 200
TestMessage(
envelope = MessageContentFuzzer.envelope(start),
content = MessageContentFuzzer.fuzzTextMessage(),
metadata = MessageContentFuzzer.fuzzMetadata(harness.others[0], harness.self.id),
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(start)
)
}
testResult.runV2(messages)
testResult.runV1(messages)
testResult.assert()
}
@Test
fun mediaMessage() {
var start = envelopeTimestamp
val messages: List<TestMessage> = (0 until 10).map {
start += 200
TestMessage(
envelope = MessageContentFuzzer.envelope(start),
content = MessageContentFuzzer.fuzzTextMessage(),
metadata = MessageContentFuzzer.fuzzMetadata(harness.others[0], harness.self.id),
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(start)
)
}
val moreMessages: List<TestMessage> = (0 until 10).map {
start += 200
TestMessage(
envelope = MessageContentFuzzer.envelope(start),
content = MessageContentFuzzer.fuzzMediaMessageWithBody(messages),
metadata = MessageContentFuzzer.fuzzMetadata(harness.others[0], harness.self.id),
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(start)
)
}
val evenMoreMessages: List<TestMessage> = (0 until 10).map {
start += 200
TestMessage(
envelope = MessageContentFuzzer.envelope(start),
content = MessageContentFuzzer.fuzzMediaMessageNoContent(messages + moreMessages),
metadata = MessageContentFuzzer.fuzzMetadata(harness.others[0], harness.self.id),
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(start)
)
}
val evenMoreMoreMessages: List<TestMessage> = (0 until 10).map {
start += 200
TestMessage(
envelope = MessageContentFuzzer.envelope(start),
content = MessageContentFuzzer.fuzzMediaMessageNoText(messages + moreMessages),
metadata = MessageContentFuzzer.fuzzMetadata(harness.others[0], harness.self.id),
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(start)
)
}
testResult.runV2(messages + moreMessages + evenMoreMessages + evenMoreMoreMessages)
testResult.runV1(messages + moreMessages + evenMoreMessages + evenMoreMoreMessages)
testResult.assert()
}
// @Test
// fun fuzzIt() {
// MessageContentFuzzer.fuzzProto(SignalServiceProtos.DataMessage.Contact.Name::class)
// MessageContentFuzzer.fuzzProto(SignalServiceProtos.DataMessage.Contact.Avatar::class)
// MessageContentFuzzer.fuzzProto(SignalServiceProtos.DataMessage.Contact.Email::class)
// }
private inner class TestResults {
private lateinit var v1Logs: List<Entry>
private lateinit var v1Messages: List<List<Pair<String, String?>>>
private lateinit var v1Attachments: List<List<Pair<String, String?>>>
private lateinit var v2Logs: List<Entry>
private lateinit var v2Messages: List<List<Pair<String, String?>>>
private lateinit var v2Attachments: List<List<Pair<String, String?>>>
fun runV1(messages: List<TestMessage>) {
messages.forEach { (envelope, content, metadata, serverDeliveredTimestamp) ->
if (content.hasDataMessage()) {
processorV1.process(
MessageContentProcessor.MessageState.DECRYPTED_OK,
toSignalServiceContent(envelope, content, metadata, serverDeliveredTimestamp),
null,
envelope.timestamp,
-1
)
ThreadUtil.sleep(1)
}
}
v1Logs = harness.inMemoryLogger.logs()
harness.inMemoryLogger.clear()
v1Messages = dumpMessages()
v1Attachments = dumpAttachments()
}
fun runV2(messages: List<TestMessage>) {
messages.forEach { (envelope, content, metadata, serverDeliveredTimestamp) ->
if (content.hasDataMessage()) {
processorV2.process(
envelope,
content,
metadata,
serverDeliveredTimestamp,
false
)
ThreadUtil.sleep(1)
}
}
v2Logs = harness.inMemoryLogger.logs()
harness.inMemoryLogger.clear()
v2Messages = dumpMessages()
v2Attachments = dumpAttachments()
cleanup()
}
fun cleanup() {
SignalDatabase.rawDatabase.withinTransaction { db ->
SignalDatabase.threads.deleteAllConversations()
db.execSQL("DELETE FROM sqlite_sequence WHERE name = '${MessageTable.TABLE_NAME}'")
db.execSQL("DELETE FROM sqlite_sequence WHERE name = '${ThreadTable.TABLE_NAME}'")
db.execSQL("DELETE FROM sqlite_sequence WHERE name = '${AttachmentTable.TABLE_NAME}'")
}
}
fun assert() {
v2Logs.zip(v1Logs)
.forEach { (v2, v1) ->
GENERALIZE_TAG[v2.tag]!!.assertIs(GENERALIZE_TAG[v1.tag]!!)
if (v2.tag != AttachmentTable.TAG) {
if (v2.message?.startsWith("[") == true && v1.message?.startsWith("[") == false) {
v2.message!!.substring(v2.message!!.indexOf(']') + 2).assertIs(v1.message)
} else {
v2.message.assertIs(v1.message)
}
} else {
if (v2.message?.startsWith("Inserted attachment at ID: AttachmentId::") == true) {
v2.message!!
.substring(0, v2.message!!.indexOf(','))
.assertIs(
v1.message!!
.substring(0, v1.message!!.indexOf(','))
)
} else {
v2.message.assertIs(v1.message)
}
}
v2.throwable.assertIs(v1.throwable)
}
v2Messages.zip(v1Messages)
.forEach { (v2, v1) ->
v2.assertIs(v1)
}
v2Attachments.zip(v1Attachments)
.forEach { (v2, v1) ->
v2.assertIs(v1)
}
}
private fun InMemoryLogger.logs(): List<Entry> {
return entries()
.filter { TAGS.contains(it.tag) }
}
private fun dumpMessages(): List<List<Pair<String, String?>>> {
return dumpTable(MessageTable.TABLE_NAME)
.map { row ->
val newRow = row.toMutableList()
newRow.removeIf { IGNORE_MESSAGE_COLUMNS.contains(it.first) }
newRow
}
}
private fun dumpAttachments(): List<List<Pair<String, String?>>> {
return dumpTable(AttachmentTable.TABLE_NAME)
.map { row ->
val newRow = row.toMutableList()
newRow.removeIf { IGNORE_ATTACHMENT_COLUMNS.contains(it.first) }
newRow
}
}
private fun dumpTable(table: String): List<List<Pair<String, String?>>> {
return SignalDatabase.rawDatabase
.select()
.from(table)
.run()
.readToList { cursor ->
val map: List<Pair<String, String?>> = cursor.columnNames.map { column ->
val index = cursor.getColumnIndex(column)
var data: String? = when (cursor.getType(index)) {
Cursor.FIELD_TYPE_BLOB -> Base64.encodeToString(cursor.getBlob(index), 0)
else -> cursor.getString(index)
}
if (table == MessageTable.TABLE_NAME && column == "type") {
data = thing(cursor.getLong(index))
}
column to data
}
map
}
}
}
private fun toSignalServiceContent(envelope: SignalServiceProtos.Envelope, content: SignalServiceProtos.Content, metadata: EnvelopeMetadata, serverDeliveredTimestamp: Long): SignalServiceContent {
val localAddress = SignalServiceAddress(metadata.destinationServiceId, Optional.ofNullable(SignalStore.account().e164))
val signalServiceMetadata = SignalServiceMetadata(
SignalServiceAddress(metadata.sourceServiceId, Optional.ofNullable(metadata.sourceE164)),
metadata.sourceDeviceId,
envelope.timestamp,
envelope.serverTimestamp,
serverDeliveredTimestamp,
metadata.sealedSender,
envelope.serverGuid,
Optional.ofNullable(metadata.groupId),
metadata.destinationServiceId.toString()
)
val contentProto = SignalServiceContentProto.newBuilder()
.setLocalAddress(SignalServiceAddressProtobufSerializer.toProtobuf(localAddress))
.setMetadata(SignalServiceMetadataProtobufSerializer.toProtobuf(signalServiceMetadata))
.setContent(content)
.build()
return SignalServiceContent.createFromProto(contentProto)!!
}
fun thing(type: Long): String {
return """
isOutgoingMessageType:${isOutgoingMessageType(type)}
isForcedSms:${type and MessageTypes.MESSAGE_FORCE_SMS_BIT != 0L}
isDraftMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_DRAFT_TYPE}
isFailedMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_SENT_FAILED_TYPE}
isPendingMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_OUTBOX_TYPE || type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_SENDING_TYPE}
isSentType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_SENT_TYPE}
isPendingSmsFallbackType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_PENDING_INSECURE_SMS_FALLBACK || type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_PENDING_SECURE_SMS_FALLBACK}
isPendingSecureSmsFallbackType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_PENDING_SECURE_SMS_FALLBACK}
isPendingInsecureSmsFallbackType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_PENDING_INSECURE_SMS_FALLBACK}
isInboxType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BASE_INBOX_TYPE}
isJoinedType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.JOINED_TYPE}
isUnsupportedMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.UNSUPPORTED_MESSAGE_TYPE}
isInvalidMessageType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.INVALID_MESSAGE_TYPE}
isBadDecryptType:${type and MessageTypes.BASE_TYPE_MASK == MessageTypes.BAD_DECRYPT_TYPE}
isSecureType:${type and MessageTypes.SECURE_MESSAGE_BIT != 0L}
isPushType:${type and MessageTypes.PUSH_MESSAGE_BIT != 0L}
isEndSessionType:${type and MessageTypes.END_SESSION_BIT != 0L}
isKeyExchangeType:${type and MessageTypes.KEY_EXCHANGE_BIT != 0L}
isIdentityVerified:${type and MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT != 0L}
isIdentityDefault:${type and MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT != 0L}
isCorruptedKeyExchange:${type and MessageTypes.KEY_EXCHANGE_CORRUPTED_BIT != 0L}
isInvalidVersionKeyExchange:${type and MessageTypes.KEY_EXCHANGE_INVALID_VERSION_BIT != 0L}
isBundleKeyExchange:${type and MessageTypes.KEY_EXCHANGE_BUNDLE_BIT != 0L}
isContentBundleKeyExchange:${type and MessageTypes.KEY_EXCHANGE_CONTENT_FORMAT != 0L}
isIdentityUpdate:${type and MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT != 0L}
isRateLimited:${type and MessageTypes.MESSAGE_RATE_LIMITED_BIT != 0L}
isExpirationTimerUpdate:${type and MessageTypes.EXPIRATION_TIMER_UPDATE_BIT != 0L}
isIncomingAudioCall:${type == MessageTypes.INCOMING_AUDIO_CALL_TYPE}
isIncomingVideoCall:${type == MessageTypes.INCOMING_VIDEO_CALL_TYPE}
isOutgoingAudioCall:${type == MessageTypes.OUTGOING_AUDIO_CALL_TYPE}
isOutgoingVideoCall:${type == MessageTypes.OUTGOING_VIDEO_CALL_TYPE}
isMissedAudioCall:${type == MessageTypes.MISSED_AUDIO_CALL_TYPE}
isMissedVideoCall:${type == MessageTypes.MISSED_VIDEO_CALL_TYPE}
isGroupCall:${type == MessageTypes.GROUP_CALL_TYPE}
isGroupUpdate:${type and MessageTypes.GROUP_UPDATE_BIT != 0L}
isGroupV2:${type and MessageTypes.GROUP_V2_BIT != 0L}
isGroupQuit:${type and MessageTypes.GROUP_LEAVE_BIT != 0L && type and MessageTypes.GROUP_V2_BIT == 0L}
isChatSessionRefresh:${type and MessageTypes.ENCRYPTION_REMOTE_FAILED_BIT != 0L}
isDuplicateMessageType:${type and MessageTypes.ENCRYPTION_REMOTE_DUPLICATE_BIT != 0L}
isDecryptInProgressType:${type and 0x40000000 != 0L}
isNoRemoteSessionType:${type and MessageTypes.ENCRYPTION_REMOTE_NO_SESSION_BIT != 0L}
isLegacyType:${type and MessageTypes.ENCRYPTION_REMOTE_LEGACY_BIT != 0L || type and MessageTypes.ENCRYPTION_REMOTE_BIT != 0L}
isProfileChange:${type == MessageTypes.PROFILE_CHANGE_TYPE}
isGroupV1MigrationEvent:${type == MessageTypes.GV1_MIGRATION_TYPE}
isChangeNumber:${type == MessageTypes.CHANGE_NUMBER_TYPE}
isBoostRequest:${type == MessageTypes.BOOST_REQUEST_TYPE}
isThreadMerge:${type == MessageTypes.THREAD_MERGE_TYPE}
isSmsExport:${type == MessageTypes.SMS_EXPORT_TYPE}
isGroupV2LeaveOnly:${type and MessageTypes.GROUP_V2_LEAVE_BITS == MessageTypes.GROUP_V2_LEAVE_BITS}
isSpecialType:${type and MessageTypes.SPECIAL_TYPES_MASK != 0L}
isStoryReaction:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_STORY_REACTION}
isGiftBadge:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_GIFT_BADGE}
isPaymentsNotificaiton:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION}
isRequestToActivatePayments:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST}
isPaymentsActivated:${type and MessageTypes.SPECIAL_TYPES_MASK == MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED}
""".trimIndent().replace(Regex("is[A-Z][A-Za-z0-9]*:false\n?"), "").toSingleLine()
}
}

View File

@@ -2,11 +2,13 @@ package org.thoughtcrime.securesms.messages
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -15,6 +17,7 @@ import org.signal.libsignal.protocol.ecc.Curve
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.AliceClient
import org.thoughtcrime.securesms.testing.BobClient
@@ -23,6 +26,10 @@ import org.thoughtcrime.securesms.testing.FakeClientHelpers
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.awaitFor
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
import org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketMessage
import org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketRequestMessage
import java.util.regex.Pattern
import kotlin.random.Random
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import android.util.Log as AndroidLog
@@ -30,13 +37,15 @@ import android.util.Log as AndroidLog
/**
* Sends N messages from Bob to Alice to track performance of Alice's processing of messages.
*/
@Ignore("Ignore test in normal testing as it's a performance test with no assertions")
// @Ignore("Ignore test in normal testing as it's a performance test with no assertions")
@RunWith(AndroidJUnit4::class)
class MessageProcessingPerformanceTest {
companion object {
private val TAG = Log.tag(MessageProcessingPerformanceTest::class.java)
private val TIMING_TAG = "TIMING_$TAG".substring(0..23)
private val DECRYPTION_TIME_PATTERN = Pattern.compile("^Decrypted (?<count>\\d+) envelopes in (?<duration>\\d+) ms.*$")
}
@get:Rule
@@ -49,14 +58,14 @@ class MessageProcessingPerformanceTest {
mockkStatic(UnidentifiedAccessUtil::class)
every { UnidentifiedAccessUtil.getCertificateValidator() } returns FakeClientHelpers.noOpCertificateValidator
mockkStatic(MessageContentProcessor::class)
every { MessageContentProcessor.create(harness.application) } returns TimingMessageContentProcessor(harness.application)
mockkObject(MessageContentProcessorV2)
every { MessageContentProcessorV2.create(harness.application) } returns TimingMessageContentProcessorV2(harness.application)
}
@After
fun after() {
unmockkStatic(UnidentifiedAccessUtil::class)
unmockkStatic(MessageContentProcessor::class)
unmockkStatic(MessageContentProcessorV2::class)
}
@Test
@@ -76,68 +85,47 @@ class MessageProcessingPerformanceTest {
profileKey = ProfileKey(bob.profileKey)
)
// Send message from Bob to Alice (self)
val firstPreKeyMessageTimestamp = System.currentTimeMillis()
val encryptedEnvelope = bobClient.encrypt(firstPreKeyMessageTimestamp)
val aliceProcessFirstMessageLatch = harness
.inMemoryLogger
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(firstPreKeyMessageTimestamp))
Thread { aliceClient.process(encryptedEnvelope, System.currentTimeMillis()) }.start()
aliceProcessFirstMessageLatch.awaitFor(15.seconds)
// Send message from Alice to Bob
val aliceNow = System.currentTimeMillis()
bobClient.decrypt(aliceClient.encrypt(aliceNow, bob), aliceNow)
// Build N messages from Bob to Alice
// Send the initial messages to get past the prekey phase
establishSession(aliceClient, bobClient, bob)
// Have Bob generate N messages that will be received by Alice
val messageCount = 100
val envelopes = ArrayList<Envelope>(messageCount)
var now = System.currentTimeMillis()
for (i in 0..messageCount) {
envelopes += bobClient.encrypt(now)
now += 3
}
val envelopes = generateInboundEnvelopes(bobClient, messageCount)
val firstTimestamp = envelopes.first().timestamp
val lastTimestamp = envelopes.last().timestamp
// Alice processes N messages
val aliceProcessLastMessageLatch = harness
.inMemoryLogger
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(lastTimestamp))
// Inject the envelopes into the websocket
Thread {
for (envelope in envelopes) {
Log.i(TIMING_TAG, "Retrieved envelope! ${envelope.timestamp}")
aliceClient.process(envelope, envelope.timestamp)
InstrumentationApplicationDependencyProvider.injectWebSocketMessage(envelope.toWebSocketPayload())
}
InstrumentationApplicationDependencyProvider.injectWebSocketMessage(webSocketTombstone())
}.start()
// Wait for Alice to finish processing messages
aliceProcessLastMessageLatch.awaitFor(1.minutes)
// Wait until they've all been fully decrypted + processed
harness
.inMemoryLogger
.getLockForUntil(TimingMessageContentProcessorV2.endTagPredicate(lastTimestamp))
.awaitFor(1.minutes)
harness.inMemoryLogger.flush()
// Process logs for timing data
val entries = harness.inMemoryLogger.entries()
// Calculate decryption average
val totalDecryptDuration: Long = entries
.mapNotNull { entry -> entry.message?.let { DECRYPTION_TIME_PATTERN.matcher(it) } }
.filter { it.matches() }
.drop(1) // Ignore the first message, which represents the prekey exchange
.sumOf { it.group("duration")!!.toLong() }
val decrypts = entries
.filter { it.tag == AliceClient.TAG }
.drop(1)
val totalDecryptDuration = decrypts.sumOf { it.message!!.toLong() }
AndroidLog.w(TAG, "Decryption: Average runtime: ${totalDecryptDuration.toFloat() / decrypts.size.toFloat()}ms")
AndroidLog.w(TAG, "Decryption: Average runtime: ${totalDecryptDuration.toFloat() / messageCount.toFloat()}ms")
// Calculate MessageContentProcessor
val takeLast: List<Entry> = entries.filter { it.tag == TimingMessageContentProcessor.TAG }.drop(2)
val takeLast: List<Entry> = entries.filter { it.tag == TimingMessageContentProcessorV2.TAG }.drop(2)
val iterator = takeLast.iterator()
var processCount = 0L
var processDuration = 0L
@@ -153,11 +141,69 @@ class MessageProcessingPerformanceTest {
// Calculate messages per second from "retrieving" first message post session initialization to processing last message
val start = entries.first { it.message == "Retrieved envelope! $firstTimestamp" }
val end = entries.first { it.message == TimingMessageContentProcessor.endTag(lastTimestamp) }
val end = entries.first { it.message == TimingMessageContentProcessorV2.endTag(lastTimestamp) }
val duration = (end.timestamp - start.timestamp).toFloat() / 1000f
val messagePerSecond = messageCount.toFloat() / duration
AndroidLog.w(TAG, "Processing $messageCount messages took ${duration}s or ${messagePerSecond}m/s")
}
private fun establishSession(aliceClient: AliceClient, bobClient: BobClient, bob: Recipient) {
// Send message from Bob to Alice (self)
val firstPreKeyMessageTimestamp = System.currentTimeMillis()
val encryptedEnvelope = bobClient.encrypt(firstPreKeyMessageTimestamp)
val aliceProcessFirstMessageLatch = harness
.inMemoryLogger
.getLockForUntil(TimingMessageContentProcessorV2.endTagPredicate(firstPreKeyMessageTimestamp))
Thread { aliceClient.process(encryptedEnvelope, System.currentTimeMillis()) }.start()
aliceProcessFirstMessageLatch.awaitFor(15.seconds)
// Send message from Alice to Bob
val aliceNow = System.currentTimeMillis()
bobClient.decrypt(aliceClient.encrypt(aliceNow, bob), aliceNow)
}
private fun generateInboundEnvelopes(bobClient: BobClient, count: Int): List<Envelope> {
val envelopes = ArrayList<Envelope>(count)
var now = System.currentTimeMillis()
for (i in 0..count) {
envelopes += bobClient.encrypt(now)
now += 3
}
return envelopes
}
private fun webSocketTombstone(): ByteString {
return WebSocketMessage
.newBuilder()
.setRequest(
WebSocketRequestMessage.newBuilder()
.setVerb("PUT")
.setPath("/api/v1/queue/empty")
)
.build()
.toByteArray()
.toByteString()
}
private fun Envelope.toWebSocketPayload(): ByteString {
return WebSocketMessage
.newBuilder()
.setType(WebSocketMessage.Type.REQUEST)
.setRequest(
WebSocketRequestMessage.newBuilder()
.setVerb("PUT")
.setPath("/api/v1/message")
.setId(Random(System.currentTimeMillis()).nextLong())
.addHeaders("X-Signal-Timestamp: ${this.timestamp}")
.setBody(this.toByteString())
)
.build()
.toByteArray()
.toByteString()
}
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.messages
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
data class TestMessage(
val envelope: SignalServiceProtos.Envelope,
val content: SignalServiceProtos.Content,
val metadata: EnvelopeMetadata,
val serverDeliveredTimestamp: Long
)

View File

@@ -1,25 +0,0 @@
package org.thoughtcrime.securesms.messages
import android.content.Context
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.testing.LogPredicate
import org.whispersystems.signalservice.api.messages.SignalServiceContent
class TimingMessageContentProcessor(context: Context) : MessageContentProcessor(context) {
companion object {
val TAG = Log.tag(TimingMessageContentProcessor::class.java)
fun endTagPredicate(timestamp: Long): LogPredicate = { entry ->
entry.tag == TAG && entry.message == endTag(timestamp)
}
private fun startTag(timestamp: Long) = "$timestamp start"
fun endTag(timestamp: Long) = "$timestamp end"
}
override fun process(messageState: MessageState?, content: SignalServiceContent?, exceptionMetadata: ExceptionMetadata?, envelopeTimestamp: Long, smsMessageId: Long) {
Log.d(TAG, startTag(envelopeTimestamp))
super.process(messageState, content, exceptionMetadata, envelopeTimestamp, smsMessageId)
Log.d(TAG, endTag(envelopeTimestamp))
}
}

View File

@@ -0,0 +1,26 @@
package org.thoughtcrime.securesms.messages
import android.content.Context
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.testing.LogPredicate
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
class TimingMessageContentProcessorV2(context: Context) : MessageContentProcessorV2(context) {
companion object {
val TAG = Log.tag(TimingMessageContentProcessorV2::class.java)
fun endTagPredicate(timestamp: Long): LogPredicate = { entry ->
entry.tag == TAG && entry.message == endTag(timestamp)
}
private fun startTag(timestamp: Long) = "$timestamp start"
fun endTag(timestamp: Long) = "$timestamp end"
}
override fun process(envelope: SignalServiceProtos.Envelope, content: SignalServiceProtos.Content, metadata: EnvelopeMetadata, serverDeliveredTimestamp: Long, processingEarlyContent: Boolean) {
Log.d(TAG, startTag(envelope.timestamp))
super.process(envelope, content, metadata, serverDeliveredTimestamp, processingEarlyContent)
Log.d(TAG, endTag(envelope.timestamp))
}
}

View File

@@ -6,6 +6,7 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.messages.protocol.BufferedProtocolStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.FakeClientHelpers.toEnvelope
import org.whispersystems.signalservice.api.push.ServiceId
@@ -35,7 +36,13 @@ class AliceClient(val serviceId: ServiceId, val e164: String, val trustRoot: ECK
fun process(envelope: Envelope, serverDeliveredTimestamp: Long) {
val start = System.currentTimeMillis()
ApplicationDependencies.getIncomingMessageObserver().processEnvelope(envelope, serverDeliveredTimestamp)
val bufferedStore = BufferedProtocolStore.create()
ApplicationDependencies.getIncomingMessageObserver()
.processEnvelope(bufferedStore, envelope, serverDeliveredTimestamp)
?.mapNotNull { it.run() }
?.forEach { ApplicationDependencies.getJobManager().add(it) }
bufferedStore.flushToDisk()
val end = System.currentTimeMillis()
Log.d(TAG, "${end - start}")
}

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.testing
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import java.util.concurrent.CountDownLatch
@@ -13,7 +14,7 @@ typealias LogPredicate = (Entry) -> Boolean
*/
class InMemoryLogger : Log.Logger() {
private val executor = SignalExecutors.newCachedSingleThreadExecutor("inmemory-logger")
private val executor = SignalExecutors.newCachedSingleThreadExecutor("inmemory-logger", ThreadUtil.PRIORITY_BACKGROUND_THREAD)
private val predicates = mutableListOf<LogPredicate>()
private val logEntries = mutableListOf<Entry>()
@@ -29,6 +30,16 @@ class InMemoryLogger : Log.Logger() {
latch.await()
}
fun clear() {
val latch = CountDownLatch(1)
executor.execute {
predicates.clear()
logEntries.clear()
latch.countDown()
}
latch.await()
}
private fun add(entry: Entry) {
executor.execute {
logEntries += entry

View File

@@ -0,0 +1,220 @@
package org.thoughtcrime.securesms.testing
import com.google.protobuf.ByteString
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.model.toProtoByteString
import org.thoughtcrime.securesms.messages.TestMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.AttachmentPointer
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Content
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
import java.util.UUID
import kotlin.random.Random
import kotlin.random.nextInt
import kotlin.reflect.KClass
import kotlin.reflect.KFunction
import kotlin.reflect.full.declaredFunctions
import kotlin.reflect.full.functions
import kotlin.reflect.jvm.jvmErasure
import kotlin.time.Duration.Companion.days
object MessageContentFuzzer {
private val mediaTypes = listOf("image/png", "image/jpeg", "image/heic", "image/heif", "image/avif", "image/webp", "image/gif", "audio/aac", "audio/*", "video/mp4", "video/*", "text/x-vcard", "text/x-signal-plain", "application/x-signal-view-once", "*/*", "application/octet-stream")
private val emojis = listOf("😂", "❤️", "🔥", "😍", "👀", "🤔", "🙏", "👍", "🤷", "🥺")
private val random = Random(1)
fun envelope(timestamp: Long): Envelope {
return Envelope.newBuilder()
.setTimestamp(timestamp)
.setServerTimestamp(timestamp + 5)
.setServerGuidBytes(UuidUtil.toByteString(UUID.randomUUID()))
.build()
}
fun fuzzTextMessage(): Content {
return Content.newBuilder()
.setDataMessage(
DataMessage.newBuilder().run {
body = string()
if (random.nextBoolean()) {
expireTimer = random.nextInt(0..28.days.inWholeSeconds.toInt())
}
if (random.nextBoolean()) {
addBodyRanges(
SignalServiceProtos.BodyRange.newBuilder().run {
start = 0
length = 1
style = SignalServiceProtos.BodyRange.Style.BOLD
build()
}
)
}
build()
}
)
.build()
}
fun fuzzMediaMessageWithBody(quoteAble: List<TestMessage> = emptyList()): Content {
return Content.newBuilder()
.setDataMessage(
DataMessage.newBuilder().run {
if (random.nextBoolean()) {
body = string()
}
if (random.nextBoolean() && quoteAble.isNotEmpty()) {
body = string()
val quoted = quoteAble.random(random)
quote = DataMessage.Quote.newBuilder().run {
id = quoted.envelope.timestamp
authorUuid = quoted.metadata.sourceServiceId.toString()
text = quoted.content.dataMessage.body
addAllAttachments(quoted.content.dataMessage.attachmentsList)
addAllBodyRanges(quoted.content.dataMessage.bodyRangesList)
type = DataMessage.Quote.Type.NORMAL
build()
}
}
if (random.nextFloat() < 0.1 && quoteAble.isNotEmpty()) {
val quoted = quoteAble.random(random)
quote = DataMessage.Quote.newBuilder().run {
id = random.nextLong(quoted.envelope.timestamp - 1000000, quoted.envelope.timestamp)
authorUuid = quoted.metadata.sourceServiceId.toString()
text = quoted.content.dataMessage.body
build()
}
}
if (random.nextFloat() < 0.25) {
val total = random.nextInt(1, 2)
(0..total).forEach { _ -> addAttachments(attachmentPointer()) }
}
build()
}
)
.build()
}
fun fuzzMediaMessageNoContent(previousMessages: List<TestMessage> = emptyList()): Content {
return Content.newBuilder()
.setDataMessage(
DataMessage.newBuilder().run {
if (random.nextFloat() < 0.25) {
val reactTo = previousMessages.random(random)
reaction = DataMessage.Reaction.newBuilder().run {
emoji = emojis.random(random)
remove = false
targetAuthorUuid = reactTo.metadata.sourceServiceId.toString()
targetSentTimestamp = reactTo.envelope.timestamp
build()
}
}
build()
}
).build()
}
fun fuzzMediaMessageNoText(previousMessages: List<TestMessage> = emptyList()): Content {
return Content.newBuilder()
.setDataMessage(
DataMessage.newBuilder().run {
if (random.nextFloat() < 0.9) {
sticker = DataMessage.Sticker.newBuilder().run {
packId = byteString(length = 24)
packKey = byteString(length = 128)
stickerId = random.nextInt()
data = attachmentPointer()
emoji = emojis.random(random)
build()
}
}
build()
}
).build()
}
fun string(length: Int = 10, allowNullString: Boolean = false): String {
var string = ""
if (allowNullString && random.nextBoolean()) {
return string
}
for (i in 0 until length) {
string += random.nextInt(65..90).toChar()
}
return string
}
fun byteString(length: Int = 512): ByteString {
return random.nextBytes(512).toProtoByteString()
}
fun attachmentPointer(): SignalServiceProtos.AttachmentPointer {
return SignalServiceProtos.AttachmentPointer.newBuilder().run {
cdnKey = string()
contentType = mediaTypes.random(random)
key = byteString()
size = random.nextInt(1024 * 1024 * 50)
thumbnail = byteString()
digest = byteString()
fileName = string()
flags = 0
width = random.nextInt(until = 1024)
height = random.nextInt(until = 1024)
caption = string(allowNullString = true)
blurHash = string()
uploadTimestamp = random.nextLong()
cdnNumber = 1
build()
}
}
fun fuzzServerDeliveredTimestamp(envelopeTimestamp: Long): Long {
return envelopeTimestamp + 10
}
fun fuzzMetadata(source: RecipientId, destination: RecipientId): EnvelopeMetadata {
return EnvelopeMetadata(
sourceServiceId = Recipient.resolved(source).requireServiceId(),
sourceE164 = null,
sourceDeviceId = 1,
sealedSender = true,
groupId = null,
destinationServiceId = Recipient.resolved(destination).requireServiceId()
)
}
fun <T : Any> fuzzProto(protoClazz: KClass<T>) {
val newBuilder: Any = protoClazz.declaredFunctions.first { it.name == "newBuilder" }.call()!!
val setters: List<KFunction<*>> = newBuilder::class.functions.filter { it.name.startsWith("set") && !it.name.contains("Bytes") }
for (setter in setters) {
val type = setter.parameters[1].type.jvmErasure
when {
type == String::class -> setter.call(newBuilder, string())
type == Int::class -> setter.call(newBuilder, random.nextInt())
type == Long::class -> setter.call(newBuilder, random.nextLong())
type == AttachmentPointer::class -> setter.call(newBuilder, attachmentPointer())
type == Boolean::class -> setter.call(newBuilder, random.nextBoolean())
// type.superclasses.contains(EnumLite::class) ->
else -> Log.e("CODY", "WHAT!?!?!?! ${setter.parameters[1].type.jvmErasure}")
}
}
Log.e("CODY", newBuilder::class.functions.first { it.name == "build" }.call(newBuilder).toString())
}
}

View File

@@ -7,17 +7,20 @@ import org.thoughtcrime.securesms.util.JsonUtils
import java.util.concurrent.TimeUnit
typealias ResponseFactory = (request: RecordedRequest) -> MockResponse
typealias RequestPredicate = (request: RecordedRequest) -> Boolean
/**
* Represent an HTTP verb for mocking web requests.
*/
sealed class Verb(val verb: String, val path: String, val responseFactory: ResponseFactory)
sealed class Verb(val requestPredicate: RequestPredicate, val responseFactory: ResponseFactory)
class Get(path: String, responseFactory: ResponseFactory) : Verb("GET", path, responseFactory)
class Get(path: String, predicate: RequestPredicate, responseFactory: ResponseFactory) : Verb(defaultRequestPredicate("GET", path, predicate), responseFactory) {
constructor(path: String, responseFactory: ResponseFactory) : this(path, { true }, responseFactory)
}
class Put(path: String, responseFactory: ResponseFactory) : Verb("PUT", path, responseFactory)
class Put(path: String, responseFactory: ResponseFactory) : Verb(defaultRequestPredicate("PUT", path), responseFactory)
class Post(path: String, responseFactory: ResponseFactory) : Verb("POST", path, responseFactory)
class Post(path: String, responseFactory: ResponseFactory) : Verb(defaultRequestPredicate("POST", path), responseFactory)
fun MockResponse.success(response: Any? = null): MockResponse {
return setResponseCode(200).apply {
@@ -48,3 +51,7 @@ inline fun <reified T> RecordedRequest.parsedRequestBody(): T {
val bodyString = String(body.readByteArray())
return JsonUtils.fromJson(bodyString, T::class.java)
}
private fun defaultRequestPredicate(verb: String, path: String, predicate: RequestPredicate = { true }): RequestPredicate = { request ->
request.method == verb && request.path.startsWith("/$path") && predicate(request)
}

View File

@@ -102,6 +102,8 @@ class SignalActivityRule(private val othersCount: Int = 4) : ExternalResource()
RegistrationUtil.maybeMarkRegistrationComplete()
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson"))
SignalStore.settings().isMessageNotificationsEnabled = false
return Recipient.self()
}

View File

@@ -36,7 +36,7 @@ fun <T : Any?> T.assertIsNotNull() {
assertThat(this, notNullValue())
}
infix fun <T : Any> T.assertIs(expected: T) {
infix fun <T : Any?> T.assertIs(expected: T) {
assertThat(this, `is`(expected))
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<profileable android:shell="true" />
<activity android:name="org.signal.benchmark.BenchmarkSetupActivity"
android:launchMode="singleTask"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateHidden"
android:exported="true"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
</application>
</manifest>

View File

@@ -0,0 +1,66 @@
package org.signal.benchmark
import android.os.Bundle
import android.widget.TextView
import org.signal.benchmark.setup.TestMessages
import org.signal.benchmark.setup.TestUsers
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.recipients.Recipient
class BenchmarkSetupActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
when (intent.extras!!.getString("setup-type")) {
"cold-start" -> setupColdStart()
"conversation-open" -> setupConversationOpen()
}
val textView: TextView = TextView(this).apply {
text = "done"
}
setContentView(textView)
}
private fun setupColdStart() {
TestUsers.setupSelf()
TestUsers.setupTestRecipients(50).forEach {
val recipient: Recipient = Recipient.resolved(it)
TestMessages.insertIncomingTextMessage(other = recipient, body = "Cool text message?!?!")
TestMessages.insertIncomingImageMessage(other = recipient, attachmentCount = 1)
TestMessages.insertIncomingImageMessage(other = recipient, attachmentCount = 2, body = "Album")
TestMessages.insertIncomingImageMessage(other = recipient, body = "Test", attachmentCount = 1, failed = true)
SignalDatabase.messages.setAllMessagesRead()
SignalDatabase.threads.update(SignalDatabase.threads.getOrCreateThreadIdFor(recipient = recipient), true)
}
}
private fun setupConversationOpen() {
TestUsers.setupSelf()
TestUsers.setupTestRecipient().let {
val recipient: Recipient = Recipient.resolved(it)
val messagesToAdd = 1000
val generator: TestMessages.TimestampGenerator = TestMessages.TimestampGenerator(System.currentTimeMillis() - (messagesToAdd * 2000L) - 60_000L)
for (i in 0 until messagesToAdd) {
TestMessages.insertIncomingTextMessage(other = recipient, body = "Test message $i", timestamp = generator.nextTimestamp())
TestMessages.insertOutgoingTextMessage(other = recipient, body = "Test message $i", timestamp = generator.nextTimestamp())
}
val voiceMessageId = TestMessages.insertIncomingVoiceMessage(other = recipient, timestamp = generator.nextTimestamp())
val mmsRecord = SignalDatabase.messages.getMessageRecord(voiceMessageId) as MediaMmsMessageRecord
TestMessages.insertOutgoingImageMessage(other = recipient, body = "test", 2, generator.nextTimestamp())
TestMessages.insertIncomingTextMessage(other = recipient, "reply to the test message", generator.nextTimestamp())
TestMessages.insertIncomingQuoteTextMessage(other = recipient, quote = QuoteModel(mmsRecord.timestamp, recipient.id, "Fake voice message text", false, mmsRecord.slideDeck.asAttachments(), null, QuoteModel.Type.NORMAL, null), body = "Here is a cool quote", timestamp = generator.nextTimestamp())
TestMessages.insertOutgoingTextMessage(other = recipient, body = "longaweorijoaijwerijoiajwer", timestamp = generator.nextTimestamp())
SignalDatabase.threads.update(SignalDatabase.threads.getOrCreateThreadIdFor(recipient = recipient), true)
}
}
}

View File

@@ -0,0 +1,43 @@
package org.signal.benchmark
import android.content.Context
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.state.PreKeyRecord
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.push.AccountManagerFactory
import org.thoughtcrime.securesms.util.FeatureFlags
import org.whispersystems.signalservice.api.SignalServiceAccountManager
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceIdType
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import java.io.IOException
import java.util.Optional
class DummyAccountManagerFactory : AccountManagerFactory() {
override fun createAuthenticated(context: Context, aci: ACI, pni: PNI, number: String, deviceId: Int, password: String): SignalServiceAccountManager {
return DummyAccountManager(
ApplicationDependencies.getSignalServiceNetworkAccess().getConfiguration(number),
aci,
pni,
number,
deviceId,
password,
BuildConfig.SIGNAL_AGENT,
FeatureFlags.okHttpAutomaticRetry(),
FeatureFlags.groupLimits().hardLimit
)
}
private class DummyAccountManager(configuration: SignalServiceConfiguration?, aci: ACI?, pni: PNI?, e164: String?, deviceId: Int, password: String?, signalAgent: String?, automaticNetworkRetry: Boolean, maxGroupSize: Int) : SignalServiceAccountManager(configuration, aci, pni, e164, deviceId, password, signalAgent, automaticNetworkRetry, maxGroupSize) {
@Throws(IOException::class)
override fun setGcmId(gcmRegistrationId: Optional<String>) {
}
@Throws(IOException::class)
override fun setPreKeys(serviceIdType: ServiceIdType, identityKey: IdentityKey, signedPreKey: SignedPreKeyRecord, oneTimePreKeys: List<PreKeyRecord>) {
}
}
}

View File

@@ -0,0 +1,189 @@
package org.signal.benchmark.setup
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.TestDbUtils
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.releasechannel.ReleaseChannel
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import java.util.Collections
import java.util.Optional
object TestMessages {
fun insertOutgoingTextMessage(other: Recipient, body: String, timestamp: Long = System.currentTimeMillis()) {
insertOutgoingMessage(
recipient = other,
message = OutgoingMessage(
recipient = other,
body = body,
timestamp = timestamp,
isSecure = true
),
timestamp = timestamp
)
}
fun insertOutgoingImageMessage(other: Recipient, body: String? = null, attachmentCount: Int, timestamp: Long = System.currentTimeMillis()): Long {
val attachments: List<SignalServiceAttachmentPointer> = (0 until attachmentCount).map {
imageAttachment()
}
val message = OutgoingMessage(
recipient = other,
body = body,
attachments = PointerAttachment.forPointers(Optional.of(attachments)),
timestamp = timestamp,
isSecure = true
)
return insertOutgoingMediaMessage(recipient = other, message = message, timestamp = timestamp)
}
private fun insertOutgoingMediaMessage(recipient: Recipient, message: OutgoingMessage, timestamp: Long): Long {
val insert = insertOutgoingMessage(recipient, message = message, timestamp = timestamp)
setMessageMediaTransfered(insert)
return insert
}
private fun insertOutgoingMessage(recipient: Recipient, message: OutgoingMessage, timestamp: Long? = null): Long {
val insert = SignalDatabase.messages.insertMessageOutbox(
message,
SignalDatabase.threads.getOrCreateThreadIdFor(recipient),
false,
null
)
if (timestamp != null) {
TestDbUtils.setMessageReceived(insert, timestamp)
}
SignalDatabase.messages.markAsSent(insert, true)
return insert
}
fun insertIncomingTextMessage(other: Recipient, body: String, timestamp: Long? = null) {
val message = IncomingMediaMessage(
from = other.id,
body = body,
sentTimeMillis = timestamp ?: System.currentTimeMillis(),
serverTimeMillis = timestamp ?: System.currentTimeMillis(),
receivedTimeMillis = timestamp ?: System.currentTimeMillis()
)
SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get().messageId
}
fun insertIncomingQuoteTextMessage(other: Recipient, body: String, quote: QuoteModel, timestamp: Long?) {
val message = IncomingMediaMessage(
from = other.id,
body = body,
sentTimeMillis = timestamp ?: System.currentTimeMillis(),
serverTimeMillis = timestamp ?: System.currentTimeMillis(),
receivedTimeMillis = timestamp ?: System.currentTimeMillis(),
quote = quote
)
insertIncomingMessage(other, message = message)
}
fun insertIncomingImageMessage(other: Recipient, body: String? = null, attachmentCount: Int, timestamp: Long? = null, failed: Boolean = false): Long {
val attachments: List<SignalServiceAttachmentPointer> = (0 until attachmentCount).map {
imageAttachment()
}
val message = IncomingMediaMessage(
from = other.id,
sentTimeMillis = timestamp ?: System.currentTimeMillis(),
serverTimeMillis = timestamp ?: System.currentTimeMillis(),
receivedTimeMillis = timestamp ?: System.currentTimeMillis(),
attachments = PointerAttachment.forPointers(Optional.of(attachments))
)
return insertIncomingMediaMessage(recipient = other, message = message, failed = failed)
}
fun insertIncomingVoiceMessage(other: Recipient, timestamp: Long? = null): Long {
val message = IncomingMediaMessage(
from = other.id,
sentTimeMillis = timestamp ?: System.currentTimeMillis(),
serverTimeMillis = timestamp ?: System.currentTimeMillis(),
receivedTimeMillis = timestamp ?: System.currentTimeMillis(),
attachments = PointerAttachment.forPointers(Optional.of(Collections.singletonList(voiceAttachment()) as List<SignalServiceAttachment>))
)
return insertIncomingMediaMessage(recipient = other, message = message, failed = false)
}
private fun insertIncomingMediaMessage(recipient: Recipient, message: IncomingMediaMessage, failed: Boolean = false): Long {
val id = insertIncomingMessage(recipient = recipient, message = message)
if (failed) {
setMessageMediaFailed(id)
} else {
setMessageMediaTransfered(id)
}
return id
}
private fun insertIncomingMessage(recipient: Recipient, message: IncomingMediaMessage): Long {
return SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(recipient)).get().messageId
}
private fun setMessageMediaFailed(messageId: Long) {
SignalDatabase.attachments.getAttachmentsForMessage(messageId).forEachIndexed { index, attachment ->
SignalDatabase.attachments.setTransferProgressPermanentFailure(attachment.attachmentId, messageId)
}
}
private fun setMessageMediaTransfered(messageId: Long) {
SignalDatabase.attachments.getAttachmentsForMessage(messageId).forEachIndexed { _, attachment ->
SignalDatabase.attachments.setTransferState(messageId, attachment.attachmentId, AttachmentTable.TRANSFER_PROGRESS_DONE)
}
}
private fun imageAttachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
ReleaseChannel.CDN_NUMBER,
SignalServiceAttachmentRemoteId.from(""),
"image/webp",
null,
Optional.empty(),
Optional.empty(),
1024,
1024,
Optional.empty(),
Optional.of("/not-there.jpg"),
false,
false,
false,
Optional.empty(),
Optional.empty(),
System.currentTimeMillis()
)
}
private fun voiceAttachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
ReleaseChannel.CDN_NUMBER,
SignalServiceAttachmentRemoteId.from(""),
"audio/aac",
null,
Optional.empty(),
Optional.empty(),
1024,
1024,
Optional.empty(),
Optional.of("/not-there.aac"),
true,
false,
false,
Optional.empty(),
Optional.empty(),
System.currentTimeMillis()
)
}
class TimestampGenerator(private var start: Long = System.currentTimeMillis()) {
fun nextTimestamp(): Long {
start += 500L
return start
}
}
}

View File

@@ -0,0 +1,103 @@
package org.signal.benchmark.setup
import android.app.Application
import android.content.SharedPreferences
import android.preference.PreferenceManager
import org.signal.benchmark.DummyAccountManagerFactory
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.push.AccountManagerFactory
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.registration.RegistrationData
import org.thoughtcrime.securesms.registration.RegistrationRepository
import org.thoughtcrime.securesms.registration.RegistrationUtil
import org.thoughtcrime.securesms.registration.VerifyResponse
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.ServiceResponseProcessor
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import java.util.UUID
object TestUsers {
private var generatedOthers: Int = 0
fun setupSelf(): Recipient {
val application: Application = ApplicationDependencies.getApplication()
DeviceTransferBlockingInterceptor.getInstance().blockNetwork()
PreferenceManager.getDefaultSharedPreferences(application).edit().putBoolean("pref_prompted_push_registration", true).commit()
val masterSecret = MasterSecretUtil.generateMasterSecret(application, MasterSecretUtil.UNENCRYPTED_PASSPHRASE)
MasterSecretUtil.generateAsymmetricMasterSecret(application, masterSecret)
val preferences: SharedPreferences = application.getSharedPreferences(MasterSecretUtil.PREFERENCES_NAME, 0)
preferences.edit().putBoolean("passphrase_initialized", true).commit()
val registrationRepository = RegistrationRepository(application)
val registrationData = RegistrationData(
code = "123123",
e164 = "+15555550101",
password = Util.getSecret(18),
registrationId = registrationRepository.registrationId,
profileKey = registrationRepository.getProfileKey("+15555550101"),
fcmToken = "fcm-token",
pniRegistrationId = registrationRepository.pniRegistrationId,
recoveryPassword = "asdfasdfasdfasdf"
)
val verifyResponse = VerifyResponse(VerifyAccountResponse(UUID.randomUUID().toString(), UUID.randomUUID().toString(), false), null, null)
AccountManagerFactory.setInstance(DummyAccountManagerFactory())
val response: ServiceResponse<VerifyResponse> = registrationRepository.registerAccount(
registrationData,
verifyResponse,
false
).blockingGet()
ServiceResponseProcessor.DefaultProcessor(response).resultOrThrow
SignalStore.kbsValues().optOut()
RegistrationUtil.maybeMarkRegistrationComplete()
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson"))
return Recipient.self()
}
fun setupTestRecipient(): RecipientId {
return setupTestRecipients(1).first()
}
fun setupTestRecipients(othersCount: Int): List<RecipientId> {
val others = mutableListOf<RecipientId>()
synchronized(this) {
if (generatedOthers + othersCount !in 0 until 1000) {
throw IllegalArgumentException("$othersCount must be between 0 and 1000")
}
for (i in generatedOthers until generatedOthers + othersCount) {
val aci = ACI.from(UUID.randomUUID())
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true, true, true, true, true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()
ApplicationDependencies.getProtocolStore().aci().saveIdentity(SignalProtocolAddress(aci.toString(), 0), otherIdentity.publicKey)
others += recipientId
}
generatedOthers += othersCount
}
return others
}
}

View File

@@ -0,0 +1,14 @@
package org.thoughtcrime.securesms.database
import android.content.ContentValues
import org.signal.core.util.SqlUtil.buildArgs
object TestDbUtils {
fun setMessageReceived(messageId: Long, timestamp: Long) {
val database: SQLiteDatabase = SignalDatabase.messages.databaseHelper.signalWritableDatabase
val contentValues = ContentValues()
contentValues.put(MessageTable.DATE_RECEIVED, timestamp)
val rowsUpdated = database.update(MessageTable.TABLE_NAME, contentValues, DatabaseTable.ID_WHERE, buildArgs(messageId))
}
}

View File

@@ -354,6 +354,11 @@
android:windowSoftInputMode="stateAlwaysVisible"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".calls.new.NewCallActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysVisible"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".PushContactSelectionActivity"
android:label="@string/AndroidManifest__select_contacts"
android:windowSoftInputMode="stateHidden"
@@ -368,7 +373,7 @@
android:theme="@style/TextSecure.DarkNoActionBar"
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing"
android:launchMode="singleTop"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"/>
<activity android:name=".conversation.mutiselect.forward.MultiselectForwardActivity"
android:theme="@style/Signal.DayNight.NoActionBar"
@@ -436,6 +441,12 @@
android:windowSoftInputMode="stateAlwaysHidden">
</activity>
<activity android:name=".components.settings.conversation.CallInfoActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|screenLayout|screenSize"
android:theme="@style/Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysHidden">
</activity>
<activity android:name=".badges.gifts.flow.GiftFlowActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|screenLayout|screenSize"
android:theme="@style/Signal.DayNight.NoActionBar"
@@ -503,6 +514,13 @@
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity
android:name=".avatar.photo.PhotoEditorActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
android:label="@string/AndroidManifest__media_preview"
android:theme="@style/TextSecure.DarkNoActionBar"
android:windowSoftInputMode="stateHidden" />
<activity android:name=".mediaoverview.MediaOverviewActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateHidden"
@@ -597,12 +615,6 @@
android:windowSoftInputMode="adjustResize"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".ClearAvatarPromptActivity"
android:theme="@style/Theme.AppCompat.Dialog.Alert"
android:icon="@drawable/clear_profile_avatar"
android:label="@string/AndroidManifest_remove_photo"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".contacts.TurnOffContactJoinedNotificationsActivity"
android:theme="@style/Theme.AppCompat.Dialog.Alert" />

Binary file not shown.

36507
app/src/main/baseline-prof.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,8 @@ package org.signal.glide.common.executor;
import android.os.HandlerThread;
import android.os.Looper;
import org.signal.core.util.ThreadUtil;
import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicInteger;
@@ -39,7 +41,7 @@ public class FrameDecoderExecutor {
public Looper getLooper(int taskId) {
int idx = taskId % sPoolNumber;
if (idx >= mHandlerThreadGroup.size()) {
HandlerThread handlerThread = new HandlerThread("FrameDecoderExecutor-" + idx);
HandlerThread handlerThread = new HandlerThread("FrameDecoderExecutor-" + idx, ThreadUtil.PRIORITY_BACKGROUND_THREAD);
handlerThread.start();
mHandlerThreadGroup.add(handlerThread);

View File

@@ -11,16 +11,16 @@ object AppCapabilities {
@JvmStatic
fun getCapabilities(storageCapable: Boolean): AccountAttributes.Capabilities {
return AccountAttributes.Capabilities(
isUuid = false,
isGv2 = true,
isStorage = storageCapable,
isGv1Migration = true,
isSenderKey = true,
isAnnouncementGroup = true,
isChangeNumber = true,
isStories = true,
isGiftBadges = true,
isPnp = FeatureFlags.phoneNumberPrivacy(),
uuid = false,
gv2 = true,
storage = storageCapable,
gv1Migration = true,
senderKey = true,
announcementGroup = true,
changeNumber = true,
stories = true,
giftBadges = true,
pni = FeatureFlags.phoneNumberPrivacy(),
paymentActivation = true
)
}

View File

@@ -197,6 +197,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addNonBlocking(() -> ApplicationDependencies.getGiphyMp4Cache().onAppStart(this))
.addNonBlocking(this::ensureProfileUploaded)
.addNonBlocking(() -> ApplicationDependencies.getExpireStoriesManager().scheduleIfNecessary())
.addPostRender(() -> ApplicationDependencies.getDeletedCallEventManager().scheduleIfNecessary())
.addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this))
.addPostRender(this::initializeExpiringMessageManager)
.addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this)))
@@ -214,7 +215,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(StoryOnboardingDownloadJob.Companion::enqueueIfNeeded)
.addPostRender(PnpInitializeDevicesJob::enqueueIfNecessary)
.addPostRender(() -> ApplicationDependencies.getExoPlayerPool().getPoolStats().getMaxUnreserved())
.addPostRender(() -> SignalDatabase.groupCallRings().removeOldRings())
.addPostRender(() -> ApplicationDependencies.getRecipientCache().warmUp())
.execute();

View File

@@ -1,48 +0,0 @@
package org.thoughtcrime.securesms;
import android.app.Activity;
import android.content.Intent;
import android.view.ContextThemeWrapper;
import androidx.appcompat.app.AlertDialog;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.util.DynamicTheme;
public final class ClearAvatarPromptActivity extends Activity {
private static final String ARG_TITLE = "arg_title";
public static Intent createForUserProfilePhoto() {
Intent intent = new Intent(ApplicationDependencies.getApplication(), ClearAvatarPromptActivity.class);
intent.putExtra(ARG_TITLE, R.string.ClearProfileActivity_remove_profile_photo);
return intent;
}
public static Intent createForGroupProfilePhoto() {
Intent intent = new Intent(ApplicationDependencies.getApplication(), ClearAvatarPromptActivity.class);
intent.putExtra(ARG_TITLE, R.string.ClearProfileActivity_remove_group_photo);
return intent;
}
@Override
public void onResume() {
super.onResume();
int message = getIntent().getIntExtra(ARG_TITLE, 0);
new AlertDialog.Builder(new ContextThemeWrapper(this, DynamicTheme.isDarkTheme(this) ? R.style.TextSecure_DarkTheme : R.style.TextSecure_LightTheme))
.setMessage(message)
.setNegativeButton(android.R.string.cancel, (dialog, which) -> finish())
.setPositiveButton(R.string.ClearProfileActivity_remove, (dialog, which) -> {
Intent result = new Intent();
result.putExtra("delete", true);
setResult(Activity.RESULT_OK, result);
finish();
})
.setOnCancelListener(dialog -> finish())
.show();
}
}

View File

@@ -125,7 +125,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
}
@Override
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
callback.accept(true);
}

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms
import android.content.Context
import android.view.View
import android.widget.TextView
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
@@ -13,17 +14,19 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
class ContactSelectionListAdapter(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag,
displaySecondaryInformation: DisplaySecondaryInformation,
displayOptions: DisplayOptions,
onClickCallbacks: OnContactSelectionClick,
longClickCallbacks: LongClickCallbacks,
storyContextMenuCallbacks: StoryContextMenuCallbacks
) : ContactSearchAdapter(context, fixedContacts, displayCheckBox, displaySmsTag, displaySecondaryInformation, onClickCallbacks, longClickCallbacks, storyContextMenuCallbacks) {
storyContextMenuCallbacks: StoryContextMenuCallbacks,
callButtonClickCallbacks: CallButtonClickCallbacks
) : ContactSearchAdapter(context, fixedContacts, displayOptions, onClickCallbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) {
init {
registerFactory(NewGroupModel::class.java, LayoutFactory({ NewGroupViewHolder(it, onClickCallbacks::onNewGroupClicked) }, R.layout.contact_selection_new_group_item))
registerFactory(InviteToSignalModel::class.java, LayoutFactory({ InviteToSignalViewHolder(it, onClickCallbacks::onInviteToSignalClicked) }, R.layout.contact_selection_invite_action_item))
registerFactory(RefreshContactsModel::class.java, LayoutFactory({ RefreshContactsViewHolder(it, onClickCallbacks::onRefreshContactsClicked) }, R.layout.contact_selection_refresh_action_item))
registerFactory(MoreHeaderModel::class.java, LayoutFactory({ MoreHeaderViewHolder(it) }, R.layout.contact_search_section_header))
registerFactory(EmptyModel::class.java, LayoutFactory({ EmptyViewHolder(it) }, R.layout.contact_selection_empty_state))
}
class NewGroupModel : MappingModel<NewGroupModel> {
@@ -36,6 +39,17 @@ class ContactSelectionListAdapter(
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
}
class RefreshContactsModel : MappingModel<RefreshContactsModel> {
override fun areItemsTheSame(newItem: RefreshContactsModel): Boolean = true
override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true
}
class MoreHeaderModel : MappingModel<MoreHeaderModel> {
override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true
override fun areContentsTheSame(newItem: MoreHeaderModel): Boolean = true
}
private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<InviteToSignalModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
@@ -52,11 +66,39 @@ class ContactSelectionListAdapter(
override fun bind(model: NewGroupModel) = Unit
}
private class RefreshContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<RefreshContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: RefreshContactsModel) = Unit
}
private class MoreHeaderViewHolder(itemView: View) : MappingViewHolder<MoreHeaderModel>(itemView) {
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
override fun bind(model: MoreHeaderModel) {
headerTextView.setText(R.string.contact_selection_activity__more)
}
}
private class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
private val emptyText: TextView = itemView.findViewById(R.id.search_no_results)
override fun bind(model: EmptyModel) {
emptyText.text = context.getString(R.string.SearchFragment_no_results, model.empty.query)
}
}
class ArbitraryRepository : org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository {
enum class ArbitraryRow(val code: String) {
NEW_GROUP("new-group"),
INVITE_TO_SIGNAL("invite-to-signal");
INVITE_TO_SIGNAL("invite-to-signal"),
MORE_HEADING("more-heading"),
REFRESH_CONTACTS("refresh-contacts");
companion object {
fun fromCode(code: String) = values().first { it.code == code }
@@ -64,7 +106,7 @@ class ContactSelectionListAdapter(
}
override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int {
return if (query.isNullOrEmpty()) section.types.size else 0
return section.types.size
}
override fun getData(section: ContactSearchConfiguration.Section.Arbitrary, query: String?, startIndex: Int, endIndex: Int, totalSearchSize: Int): List<ContactSearchData.Arbitrary> {
@@ -73,10 +115,11 @@ class ContactSelectionListAdapter(
}
override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> {
val code = ArbitraryRow.fromCode(arbitrary.type)
return when (code) {
return when (ArbitraryRow.fromCode(arbitrary.type)) {
ArbitraryRow.NEW_GROUP -> NewGroupModel()
ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
ArbitraryRow.MORE_HEADING -> MoreHeaderModel()
ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel()
}
}
}
@@ -84,5 +127,6 @@ class ContactSelectionListAdapter(
interface OnContactSelectionClick : ClickCallbacks {
fun onNewGroupClicked()
fun onInviteToSignalClicked()
fun onRefreshContactsClicked()
}
}

View File

@@ -47,8 +47,6 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import androidx.transition.AutoTransition;
import androidx.transition.TransitionManager;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.pnikosis.materialishprogress.ProgressWheel;
@@ -74,6 +72,7 @@ import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UsernameUtil;
@@ -98,8 +97,7 @@ import kotlin.Unit;
*
* @author Moxie Marlinspike
*/
public final class ContactSelectionListFragment extends LoggingFragment
{
public final class ContactSelectionListFragment extends LoggingFragment {
@SuppressWarnings("unused")
private static final String TAG = Log.tag(ContactSelectionListFragment.class);
@@ -119,41 +117,47 @@ public final class ContactSelectionListFragment extends LoggingFragment
public static final String RV_PADDING_BOTTOM = "recycler_view_padding_bottom";
public static final String RV_CLIP = "recycler_view_clipping";
private ConstraintLayout constraintLayout;
private TextView emptyText;
private OnContactSelectedListener onContactSelectedListener;
private SwipeRefreshLayout swipeRefresh;
private View showContactsLayout;
private Button showContactsButton;
private TextView showContactsDescription;
private ProgressWheel showContactsProgress;
private String cursorFilter;
private RecyclerView recyclerView;
private RecyclerViewFastScroller fastScroller;
private RecyclerView chipRecycler;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
private MappingAdapter contactChipAdapter;
private ContactChipViewModel contactChipViewModel;
private LifecycleDisposable lifecycleDisposable;
private HeaderActionProvider headerActionProvider;
private TextView headerActionView;
private ContactSearchMediator contactSearchMediator;
private ConstraintLayout constraintLayout;
private TextView emptyText;
private OnContactSelectedListener onContactSelectedListener;
private SwipeRefreshLayout swipeRefresh;
private View showContactsLayout;
private Button showContactsButton;
private TextView showContactsDescription;
private ProgressWheel showContactsProgress;
private String cursorFilter;
private RecyclerView recyclerView;
private RecyclerViewFastScroller fastScroller;
private RecyclerView chipRecycler;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
private MappingAdapter contactChipAdapter;
private ContactChipViewModel contactChipViewModel;
private LifecycleDisposable lifecycleDisposable;
private HeaderActionProvider headerActionProvider;
private TextView headerActionView;
private ContactSearchMediator contactSearchMediator;
@Nullable private ListCallback listCallback;
@Nullable private ScrollCallback scrollCallback;
@Nullable private OnItemLongClickListener onItemLongClickListener;
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
private Set<RecipientId> currentSelection;
private boolean isMulti;
private boolean canSelectSelf;
private ListClickListener listClickListener = new ListClickListener();
@Nullable private NewConversationCallback newConversationCallback;
@Nullable private NewCallCallback newCallCallback;
@Nullable private ScrollCallback scrollCallback;
@Nullable private OnItemLongClickListener onItemLongClickListener;
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
private Set<RecipientId> currentSelection;
private boolean isMulti;
private boolean canSelectSelf;
private ListClickListener listClickListener = new ListClickListener();
@Nullable private SwipeRefreshLayout.OnRefreshListener onRefreshListener;
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
if (context instanceof ListCallback) {
listCallback = (ListCallback) context;
if (context instanceof NewConversationCallback) {
newConversationCallback = (NewConversationCallback) context;
}
if (context instanceof NewCallCallback) {
newCallCallback = (NewCallCallback) context;
}
if (getParentFragment() instanceof ScrollCallback) {
@@ -234,17 +238,17 @@ public final class ContactSelectionListFragment extends LoggingFragment
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false);
emptyText = view.findViewById(android.R.id.empty);
recyclerView = view.findViewById(R.id.recycler_view);
swipeRefresh = view.findViewById(R.id.swipe_refresh);
fastScroller = view.findViewById(R.id.fast_scroller);
showContactsLayout = view.findViewById(R.id.show_contacts_container);
showContactsButton = view.findViewById(R.id.show_contacts_button);
showContactsDescription = view.findViewById(R.id.show_contacts_description);
showContactsProgress = view.findViewById(R.id.progress);
chipRecycler = view.findViewById(R.id.chipRecycler);
constraintLayout = view.findViewById(R.id.container);
headerActionView = view.findViewById(R.id.header_action);
emptyText = view.findViewById(android.R.id.empty);
recyclerView = view.findViewById(R.id.recycler_view);
swipeRefresh = view.findViewById(R.id.swipe_refresh);
fastScroller = view.findViewById(R.id.fast_scroller);
showContactsLayout = view.findViewById(R.id.show_contacts_container);
showContactsButton = view.findViewById(R.id.show_contacts_button);
showContactsDescription = view.findViewById(R.id.show_contacts_description);
showContactsProgress = view.findViewById(R.id.progress);
chipRecycler = view.findViewById(R.id.chipRecycler);
constraintLayout = view.findViewById(R.id.container);
headerActionView = view.findViewById(R.id.header_action);
final LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
@@ -337,9 +341,13 @@ public final class ContactSelectionListFragment extends LoggingFragment
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
.collect(java.util.stream.Collectors.toSet()),
selectionLimit,
isMulti,
ContactSearchAdapter.DisplaySmsTag.DEFAULT,
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
new ContactSearchAdapter.DisplayOptions(
isMulti,
ContactSearchAdapter.DisplaySmsTag.DEFAULT,
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
newCallCallback != null,
false
),
this::mapStateToConfiguration,
new ContactSearchMediator.SimpleCallbacks() {
@Override
@@ -348,21 +356,33 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
},
false,
(context, fixedContacts, displayCheckBox, displaySmsTag, displaySecondaryInformation, callbacks, longClickCallbacks, storyContextMenuCallbacks) -> new ContactSelectionListAdapter(
(context, fixedContacts, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) -> new ContactSelectionListAdapter(
context,
fixedContacts,
displayCheckBox,
displaySmsTag,
displaySecondaryInformation,
displayOptions,
new ContactSelectionListAdapter.OnContactSelectionClick() {
@Override
public void onRefreshContactsClicked() {
if (onRefreshListener != null) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
@Override
public void onNewGroupClicked() {
listCallback.onNewGroup(false);
newConversationCallback.onNewGroup(false);
}
@Override
public void onInviteToSignalClicked() {
listCallback.onInvite();
if (newConversationCallback != null) {
newConversationCallback.onInvite();
}
if (newCallCallback != null) {
newCallCallback.onInvite();
}
}
@Override
@@ -386,7 +406,9 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
storyContextMenuCallbacks
storyContextMenuCallbacks,
new CallButtonClickCallbacks()
),
new ContactSelectionListAdapter.ArbitraryRepository()
);
@@ -398,6 +420,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
public void onDestroyView() {
super.onDestroyView();
constraintLayout = null;
onRefreshListener = null;
}
private @NonNull Bundle safeArguments() {
@@ -620,6 +643,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
private class ListClickListener {
public void onItemClick(ContactSearchKey contact) {
boolean isUnknown = contact instanceof ContactSearchKey.UnknownRecipientKey;
SelectedContact selectedContact = contact.requireSelectedContact();
if (!canSelectSelf && !selectedContact.hasUsername() && Recipient.self().getId().equals(selectedContact.getOrCreateRecipientId(requireContext()))) {
@@ -650,7 +674,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), username);
if (onContactSelectedListener != null) {
onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null, allowed -> {
onContactSelectedListener.onBeforeContactSelected(true, Optional.of(recipient.getId()), null, allowed -> {
if (allowed) {
markContactSelected(selected);
}
@@ -668,7 +692,11 @@ public final class ContactSelectionListFragment extends LoggingFragment
});
} else {
if (onContactSelectedListener != null) {
onContactSelectedListener.onBeforeContactSelected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber(), allowed -> {
onContactSelectedListener.onBeforeContactSelected(
isUnknown,
Optional.ofNullable(selectedContact.getRecipientId()),
selectedContact.getNumber(),
allowed -> {
if (allowed) {
markContactSelected(selectedContact);
}
@@ -783,6 +811,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
public void setOnRefreshListener(SwipeRefreshLayout.OnRefreshListener onRefreshListener) {
this.onRefreshListener = onRefreshListener;
this.swipeRefresh.setOnRefreshListener(onRefreshListener);
}
@@ -805,6 +834,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
boolean includeRecentsHeader = !flagSet(displayMode, ContactSelectionDisplayMode.FLAG_HIDE_RECENT_HEADER);
boolean includeGroupsAfterContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS);
boolean blocked = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_BLOCK);
boolean includeGroupMembers = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUP_MEMBERS);
boolean hasQuery = !TextUtils.isEmpty(contactSearchState.getQuery());
ContactSearchConfiguration.TransportType transportType = resolveTransportType(includePushContacts, includeSmsContacts);
ContactSearchConfiguration.Section.Recents.Mode mode = resolveRecentsMode(transportType, includeActiveGroups);
@@ -813,12 +844,12 @@ public final class ContactSelectionListFragment extends LoggingFragment
return ContactSearchConfiguration.build(builder -> {
builder.setQuery(contactSearchState.getQuery());
if (listCallback != null) {
if (newConversationCallback != null) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode());
}
if (transportType != null) {
if (TextUtils.isEmpty(contactSearchState.getQuery()) && includeRecents) {
if (!hasQuery && includeRecents) {
builder.addSection(new ContactSearchConfiguration.Section.Recents(
25,
mode,
@@ -834,13 +865,13 @@ public final class ContactSelectionListFragment extends LoggingFragment
builder.addSection(new ContactSearchConfiguration.Section.Individuals(
includeSelf,
transportType,
true,
newCallCallback == null,
null,
!hideLetterHeaders()
));
}
if ((includeGroupsAfterContacts || !TextUtils.isEmpty(contactSearchState.getQuery())) && includeActiveGroups) {
if ((includeGroupsAfterContacts || hasQuery) && includeActiveGroups) {
builder.addSection(new ContactSearchConfiguration.Section.Groups(
includeSmsContacts,
includeV1Groups,
@@ -853,18 +884,34 @@ public final class ContactSelectionListFragment extends LoggingFragment
));
}
if (listCallback != null) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
if (hasQuery && includeGroupMembers) {
builder.addSection(new ContactSearchConfiguration.Section.GroupMembers());
}
if (includeNew) {
builder.phone(newRowMode);
builder.username(newRowMode);
}
if (newCallCallback != null || newConversationCallback != null) {
addMoreSection(builder);
builder.withEmptyState(emptyBuilder -> {
emptyBuilder.addSection(ContactSearchConfiguration.Section.Empty.INSTANCE);
addMoreSection(emptyBuilder);
return Unit.INSTANCE;
});
}
return Unit.INSTANCE;
});
}
private void addMoreSection(@NonNull ContactSearchConfiguration.Builder builder) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.MORE_HEADING.getCode());
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.REFRESH_CONTACTS.getCode());
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
}
private static @Nullable ContactSearchConfiguration.TransportType resolveTransportType(boolean includePushContacts, boolean includeSmsContacts) {
if (includePushContacts && includeSmsContacts) {
return ContactSearchConfiguration.TransportType.ALL;
@@ -887,9 +934,11 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
}
private static @NonNull ContactSearchConfiguration.NewRowMode resolveNewRowMode(boolean isBlocked, boolean isActiveGroups) {
private @NonNull ContactSearchConfiguration.NewRowMode resolveNewRowMode(boolean isBlocked, boolean isActiveGroups) {
if (isBlocked) {
return ContactSearchConfiguration.NewRowMode.BLOCK;
} else if (newCallCallback != null) {
return ContactSearchConfiguration.NewRowMode.NEW_CALL;
} else if (isActiveGroups) {
return ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION;
} else {
@@ -901,11 +950,23 @@ public final class ContactSelectionListFragment extends LoggingFragment
return (mode & flag) > 0;
}
private class CallButtonClickCallbacks implements ContactSearchAdapter.CallButtonClickCallbacks {
@Override
public void onVideoCallButtonClicked(@NonNull Recipient recipient) {
CommunicationActions.startVideoCall(ContactSelectionListFragment.this, recipient);
}
@Override
public void onAudioCallButtonClicked(@NonNull Recipient recipient) {
CommunicationActions.startVoiceCall(ContactSelectionListFragment.this, recipient);
}
}
public interface OnContactSelectedListener {
/**
* Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it.
*/
void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Consumer<Boolean> callback);
void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Consumer<Boolean> callback);
void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number);
@@ -918,12 +979,16 @@ public final class ContactSelectionListFragment extends LoggingFragment
void onHardLimitReached(int limit);
}
public interface ListCallback {
public interface NewConversationCallback {
void onInvite();
void onNewGroup(boolean forceV1);
}
public interface NewCallCallback {
void onInvite();
}
public interface ScrollCallback {
void onBeginScroll();
}

View File

@@ -136,7 +136,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
}
@Override
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1);
callback.accept(true);
}

View File

@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.SplashScreenUtil;
import org.thoughtcrime.securesms.util.WindowUtil;
@@ -147,7 +148,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
}
private void updateTabVisibility() {
if (Stories.isFeatureEnabled()) {
if (Stories.isFeatureEnabled() || FeatureFlags.callsTab()) {
findViewById(R.id.conversation_list_tabs).setVisibility(View.VISIBLE);
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorSurface2));
} else {

View File

@@ -69,7 +69,7 @@ import java.util.stream.Stream;
* @author Moxie Marlinspike
*/
public class NewConversationActivity extends ContactSelectionActivity
implements ContactSelectionListFragment.ListCallback, ContactSelectionListFragment.OnItemLongClickListener
implements ContactSelectionListFragment.NewConversationCallback, ContactSelectionListFragment.OnItemLongClickListener
{
@SuppressWarnings("unused")
@@ -102,7 +102,7 @@ public class NewConversationActivity extends ContactSelectionActivity
}
@Override
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
boolean smsSupported = SignalStore.misc().getSmsExportPhase().allowSmsFeatures();
if (recipientId.isPresent()) {

View File

@@ -31,10 +31,9 @@ import android.os.Bundle;
import android.util.Rational;
import android.view.Window;
import android.view.WindowManager;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.content.ContextCompat;
import androidx.core.util.Consumer;
@@ -58,6 +57,7 @@ import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow;
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState;
import org.thoughtcrime.securesms.components.webrtc.CallStateUpdatePopupWindow;
import org.thoughtcrime.securesms.components.webrtc.CallToastPopupWindow;
import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeNotificationUtil;
import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput;
@@ -112,6 +112,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
public static final String EXTRA_STARTED_FROM_FULLSCREEN = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_FULLSCREEN";
private CallParticipantsListUpdatePopupWindow participantUpdateWindow;
private CallStateUpdatePopupWindow callStateUpdatePopupWindow;
private WifiToCellularPopupWindow wifiToCellularPopupWindow;
private DeviceOrientationMonitor deviceOrientationMonitor;
@@ -312,8 +313,9 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
callScreen = findViewById(R.id.callScreen);
callScreen.setControlsListener(new ControlsListener());
participantUpdateWindow = new CallParticipantsListUpdatePopupWindow(callScreen);
wifiToCellularPopupWindow = new WifiToCellularPopupWindow(callScreen);
participantUpdateWindow = new CallParticipantsListUpdatePopupWindow(callScreen);
callStateUpdatePopupWindow = new CallStateUpdatePopupWindow(callScreen);
wifiToCellularPopupWindow = new WifiToCellularPopupWindow(callScreen);
}
private void initializeViewModel(boolean isLandscapeEnabled) {
@@ -356,6 +358,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
addOnPictureInPictureModeChangedListener(info -> {
viewModel.setIsInPipMode(info.isInPictureInPictureMode());
participantUpdateWindow.setEnabled(!info.isInPictureInPictureMode());
callStateUpdatePopupWindow.setEnabled(!info.isInPictureInPictureMode());
});
}
@@ -412,15 +415,19 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
private void handleSetAudioHandset() {
ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.EARPIECE);
ApplicationDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(SignalAudioManager.AudioDevice.EARPIECE));
}
private void handleSetAudioSpeaker() {
ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.SPEAKER_PHONE);
ApplicationDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(SignalAudioManager.AudioDevice.SPEAKER_PHONE));
}
private void handleSetAudioBluetooth() {
ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.BLUETOOTH);
ApplicationDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(SignalAudioManager.AudioDevice.BLUETOOTH));
}
private void handleSetAudioWiredHeadset() {
ApplicationDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(SignalAudioManager.AudioDevice.WIRED_HEADSET));
}
private void handleSetMuteAudio(boolean enabled) {
@@ -565,7 +572,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private void handleNoSuchUser(final @NonNull WebRtcViewModel event) {
if (isFinishing()) return; // XXX Stuart added this check above, not sure why, so I'm repeating in ignorance. - moxie
new AlertDialog.Builder(this)
new MaterialAlertDialogBuilder(this)
.setTitle(R.string.RedPhone_number_not_registered)
.setIcon(R.drawable.ic_warning)
.setMessage(R.string.RedPhone_the_number_you_dialed_does_not_support_secure_voice)
@@ -782,17 +789,26 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
case HANDSET:
handleSetAudioHandset();
break;
case HEADSET:
case BLUETOOTH_HEADSET:
handleSetAudioBluetooth();
break;
case SPEAKER:
handleSetAudioSpeaker();
break;
case WIRED_HEADSET:
handleSetAudioWiredHeadset();
break;
default:
throw new IllegalStateException("Unknown output: " + audioOutput);
}
}
@RequiresApi(31)
@Override
public void onAudioOutputChanged31(@NonNull int audioDeviceInfo) {
ApplicationDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(audioDeviceInfo));
}
@Override
public void onVideoChanged(boolean isVideoEnabled) {
handleSetMuteVideo(!isVideoEnabled);
@@ -800,6 +816,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@Override
public void onMicChanged(boolean isMicEnabled) {
callStateUpdatePopupWindow.onCallStateUpdate(isMicEnabled ? CallStateUpdatePopupWindow.CallStateUpdate.MIC_ON
: CallStateUpdatePopupWindow.CallStateUpdate.MIC_OFF);
handleSetMuteAudio(!isMicEnabled);
}
@@ -832,11 +850,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
}
@Override
public void onShowParticipantsList() {
CallParticipantsListDialog.show(getSupportFragmentManager());
}
@Override
public void onPageChanged(@NonNull CallParticipantsState.SelectedPage page) {
viewModel.setIsViewingFocusedParticipant(page);
@@ -851,11 +864,23 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
public void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed) {
if (ringingAllowed) {
ApplicationDependencies.getSignalCallManager().setRingGroup(ringGroup);
callStateUpdatePopupWindow.onCallStateUpdate(ringGroup ? CallStateUpdatePopupWindow.CallStateUpdate.RINGING_ON
: CallStateUpdatePopupWindow.CallStateUpdate.RINGING_OFF);
} else {
ApplicationDependencies.getSignalCallManager().setRingGroup(false);
Toast.makeText(WebRtcCallActivity.this, R.string.WebRtcCallActivity__group_is_too_large_to_ring_the_participants, Toast.LENGTH_SHORT).show();
callStateUpdatePopupWindow.onCallStateUpdate(CallStateUpdatePopupWindow.CallStateUpdate.RINGING_DISABLED);
}
}
@Override
public void onCallInfoClicked() {
CallParticipantsListDialog.show(getSupportFragmentManager());
}
@Override
public void onNavigateUpClicked() {
onBackPressed();
}
}
private class WindowLayoutInfoConsumer implements Consumer<WindowLayoutInfo> {

View File

@@ -9,8 +9,11 @@ import org.thoughtcrime.securesms.blurhash.BlurHash;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.util.Base64;
import org.whispersystems.signalservice.api.InvalidMessageStructureException;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.util.AttachmentPointerUtil;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
import java.util.LinkedList;
import java.util.List;
@@ -145,4 +148,33 @@ public class PointerAttachment extends Attachment {
null,
null));
}
public static Optional<Attachment> forPointer(SignalServiceProtos.DataMessage.Quote.QuotedAttachment quotedAttachment) {
SignalServiceAttachment thumbnail;
try {
thumbnail = quotedAttachment.hasThumbnail() ? AttachmentPointerUtil.createSignalAttachmentPointer(quotedAttachment.getThumbnail()) : null;
} catch (InvalidMessageStructureException e) {
return Optional.empty();
}
return Optional.of(new PointerAttachment(quotedAttachment.getContentType(),
AttachmentTable.TRANSFER_PROGRESS_PENDING,
thumbnail != null ? thumbnail.asPointer().getSize().orElse(0) : 0,
quotedAttachment.getFileName(),
thumbnail != null ? thumbnail.asPointer().getCdnNumber() : 0,
thumbnail != null ? thumbnail.asPointer().getRemoteId().toString() : "0",
thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeBytes(thumbnail.asPointer().getKey()) : null,
null,
thumbnail != null ? thumbnail.asPointer().getDigest().orElse(null) : null,
null,
false,
false,
false,
thumbnail != null ? thumbnail.asPointer().getWidth() : 0,
thumbnail != null ? thumbnail.asPointer().getHeight() : 0,
thumbnail != null ? thumbnail.asPointer().getUploadTimestamp() : 0,
thumbnail != null ? thumbnail.asPointer().getCaption().orElse(null) : null,
null,
null));
}
}

View File

@@ -7,6 +7,7 @@ import android.media.MediaCodecInfo;
import android.media.MediaFormat;
import android.media.MediaRecorder;
import android.os.ParcelFileDescriptor;
import android.os.Process;
import org.signal.core.util.StreamUtil;
import org.signal.core.util.logging.Log;
@@ -65,6 +66,7 @@ public class AudioCodec implements Recorder {
new Thread(new Runnable() {
@Override
public void run() {
Process.setThreadPriority(Process.THREAD_PRIORITY_AUDIO);
MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
byte[] audioRecordData = new byte[bufferSize];
ByteBuffer[] codecInputBuffers = mediaCodec.getInputBuffers();

View File

@@ -9,6 +9,7 @@ import android.os.ParcelFileDescriptor;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
@@ -25,7 +26,7 @@ public class AudioRecorder {
private static final String TAG = Log.tag(AudioRecorder.class);
private static final ExecutorService executor = SignalExecutors.newCachedSingleThreadExecutor("signal-AudioRecorder");
private static final ExecutorService executor = SignalExecutors.newCachedSingleThreadExecutor("signal-AudioRecorder", ThreadUtil.PRIORITY_UI_BLOCKING_THREAD);
private final Context context;
private final AudioRecordingHandler uiHandler;

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.audio;
import android.media.MediaRecorder;
import android.os.Build;
import android.os.ParcelFileDescriptor;
import org.signal.core.util.logging.Log;
@@ -30,7 +31,7 @@ public class MediaRecorderWrapper implements Recorder {
recorder.setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS);
recorder.setOutputFile(fileDescriptor.getFileDescriptor());
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
recorder.setAudioSamplingRate(SAMPLE_RATE);
recorder.setAudioSamplingRate(getSampleRate());
recorder.setAudioEncodingBitRate(BIT_RATE);
recorder.setAudioChannels(CHANNELS);
recorder.prepare();
@@ -62,4 +63,12 @@ public class MediaRecorderWrapper implements Recorder {
recorder = null;
}
}
private static int getSampleRate() {
if ("Xiaomi".equals(Build.MANUFACTURER) && "Mi 9T".equals(Build.MODEL)) {
// Recordings sound robotic with the standard sample rate.
return 44000;
}
return SAMPLE_RATE;
}
}

View File

@@ -0,0 +1,50 @@
package org.thoughtcrime.securesms.avatar.photo
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContract
import androidx.appcompat.app.AppCompatDelegate
import androidx.fragment.app.Fragment
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarBundler
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
class PhotoEditorActivity : FragmentWrapperActivity() {
override fun attachBaseContext(newBase: Context) {
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
super.attachBaseContext(newBase)
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
supportFragmentManager.setFragmentResultListener(PhotoEditorFragment.REQUEST_KEY_EDIT, this) { _, bundle ->
setResult(Activity.RESULT_OK, Intent().putExtras(bundle))
finishAfterTransition()
}
}
override fun getFragment(): Fragment = PhotoEditorFragment().apply {
arguments = intent.extras
}
class Contract : ActivityResultContract<Avatar.Photo, Avatar.Photo?>() {
override fun createIntent(context: Context, input: Avatar.Photo): Intent {
return Intent(context, PhotoEditorActivity::class.java).apply {
putExtras(PhotoEditorActivityArgs.Builder(AvatarBundler.bundlePhoto(input)).build().toBundle())
}
}
override fun parseResult(resultCode: Int, intent: Intent?): Avatar.Photo? {
val extras = intent?.extras
if (resultCode != Activity.RESULT_OK || extras == null) {
return null
}
return AvatarBundler.extractPhoto(extras)
}
}
}

View File

@@ -5,7 +5,6 @@ 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
@@ -18,7 +17,7 @@ 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 args = PhotoEditorActivityArgs.fromBundle(requireArguments())
val photo = AvatarBundler.extractPhoto(args.photoAvatar)
val imageEditorFragment = ImageEditorFragment.newInstanceForAvatarEdit(photo.uri)
@@ -34,7 +33,7 @@ class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), Ima
}
override fun onDoneEditing() {
val args = PhotoEditorFragmentArgs.fromBundle(requireArguments())
val args = PhotoEditorActivityArgs.fromBundle(requireArguments())
val applicationContext = requireContext().applicationContext
val imageEditorFragment: ImageEditorFragment = childFragmentManager.findFragmentByTag(IMAGE_EDITOR) as ImageEditorFragment
@@ -52,13 +51,12 @@ class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), Ima
ThreadUtil.runOnMain {
setFragmentResult(REQUEST_KEY_EDIT, AvatarBundler.bundlePhoto(newPhoto))
Navigation.findNavController(requireView()).popBackStack()
}
}
}
override fun onCancelEditing() {
Navigation.findNavController(requireView()).popBackStack()
requireActivity().finishAfterTransition()
}
override fun restoreState() {

View File

@@ -8,6 +8,7 @@ import android.view.Gravity
import android.view.View
import android.widget.PopupMenu
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
@@ -20,6 +21,7 @@ import org.signal.core.util.getParcelableExtraCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarBundler
import org.thoughtcrime.securesms.avatar.photo.PhotoEditorActivity
import org.thoughtcrime.securesms.avatar.photo.PhotoEditorFragment
import org.thoughtcrime.securesms.avatar.text.TextAvatarCreationFragment
import org.thoughtcrime.securesms.avatar.vector.VectorAvatarCreationFragment
@@ -50,6 +52,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
private val viewModel: AvatarPickerViewModel by viewModels(factoryProducer = this::createFactory)
private lateinit var recycler: RecyclerView
private lateinit var photoEditorLauncher: ActivityResultLauncher<Avatar.Photo>
private fun createFactory(): AvatarPickerViewModel.Factory {
val args = AvatarPickerFragmentArgs.fromBundle(requireArguments())
@@ -138,8 +141,12 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
}
setFragmentResultListener(PhotoEditorFragment.REQUEST_KEY_EDIT) { _, bundle ->
val photo = AvatarBundler.extractPhoto(bundle)
viewModel.onAvatarEditCompleted(photo)
}
photoEditorLauncher = registerForActivityResult(PhotoEditorActivity.Contract()) { photo ->
if (photo != null) {
viewModel.onAvatarEditCompleted(photo)
}
}
}
@@ -197,8 +204,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
}
private fun openPhotoEditor(photo: Avatar.Photo) {
Navigation.findNavController(requireView())
.safeNavigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToAvatarPhotoEditorFragment(AvatarBundler.bundlePhoto(photo)))
photoEditorLauncher.launch(photo)
}
private fun openVectorEditor(vector: Avatar.Vector) {

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import androidx.core.content.res.use
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.database.model.StoryViewState
@@ -20,10 +21,12 @@ class AvatarView @JvmOverloads constructor(
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private var storyRingScale = 0.8f
init {
inflate(context, R.layout.avatar_view, this)
isClickable = false
storyRingScale = context.theme.obtainStyledAttributes(attrs, R.styleable.AvatarView, 0, 0).use { it.getFloat(R.styleable.AvatarView_storyRingScale, storyRingScale) }
}
private val avatar: AvatarImageView = findViewById<AvatarImageView>(R.id.avatar_image_view).apply {
@@ -40,8 +43,8 @@ class AvatarView @JvmOverloads constructor(
storyRing.visible = true
storyRing.isActivated = hasUnreadStory
avatar.scaleX = 0.8f
avatar.scaleY = 0.8f
avatar.scaleX = storyRingScale
avatar.scaleY = storyRingScale
}
private fun hideStoryRing() {

View File

@@ -282,6 +282,8 @@ public class FullBackupExporter extends FullBackupBase {
{
List<String> tablesInOrder = getTablesToExportInOrder(input);
Log.i(TAG, "Exporting tables in the following order: " + tablesInOrder);
Map<String, String> createStatementsByTable = new HashMap<>();
try (Cursor cursor = input.rawQuery("SELECT sql, name, type FROM sqlite_master WHERE type = 'table' AND sql NOT NULL", null)) {
@@ -328,12 +330,16 @@ public class FullBackupExporter extends FullBackupBase {
.sorted()
.collect(Collectors.toList());
Map<String, Set<String>> dependsOn = new LinkedHashMap<>();
for (String table : tables) {
dependsOn.put(table, SqlUtil.getForeignKeyDependencies(input, table));
}
for (String table : tables) {
Set<String> dependsOnTable = dependsOn.keySet().stream().filter(t -> dependsOn.get(t).contains(table)).collect(Collectors.toSet());
Log.i(TAG, "Tables that depend on " + table + ": " + dependsOnTable);
}
return computeTableOrder(dependsOn);
}
@@ -396,6 +402,8 @@ public class FullBackupExporter extends FullBackupBase {
@NonNull BackupCancellationSignal cancellationSignal)
throws IOException
{
Log.d(TAG, "Exporting table: " + table);
String template = "INSERT INTO " + table + " VALUES ";
try (Cursor cursor = input.rawQuery("SELECT * FROM " + table, null)) {

View File

@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.util.concurrent.TimeUnit
/**
* Landing fragment for sending gifts.
@@ -78,8 +79,9 @@ class GiftFlowStartFragment : DSLSettingsFragment(
space(DimensionUnit.DP.toPixels(16f).toInt())
val days = state.giftBadge?.duration?.let { TimeUnit.MILLISECONDS.toDays(it) } ?: 60L
noPadTextPref(
title = DSLSettingsText.from(resources.getQuantityString(R.plurals.GiftFlowStartFragment__support_signal_by, 30, 30), DSLSettingsText.CenterModifier)
title = DSLSettingsText.from(resources.getQuantityString(R.plurals.GiftFlowStartFragment__support_signal_by, days.toInt(), days), DSLSettingsText.CenterModifier)
)
space(DimensionUnit.DP.toPixels(16f).toInt())

View File

@@ -97,7 +97,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
}
@Override
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
final String displayName = recipientId.map(id -> Recipient.resolved(id).getDisplayName(this)).orElse(number);
AlertDialog confirmationDialog = new MaterialAlertDialogBuilder(this)

View File

@@ -0,0 +1,242 @@
package org.thoughtcrime.securesms.calls.links
import android.content.ActivityNotFoundException
import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Alignment.Companion.End
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.app.ShareCompat
import androidx.fragment.app.viewModels
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dividers
import org.signal.core.ui.Rows
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.util.Util
/**
* Bottom sheet for creating call links
*/
class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment() {
private val viewModel: CreateCallLinkViewModel by viewModels()
override val peekHeightPercentage: Float = 1f
@Composable
override fun SheetContent() {
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize(Alignment.Center)
) {
val callName: String by viewModel.callName
val callLink: String by viewModel.callLink
val approveAllMembers: Boolean by viewModel.approveAllMembers
Handle(modifier = Modifier.align(CenterHorizontally))
Spacer(modifier = Modifier.height(20.dp))
Text(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__create_call_link),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(24.dp))
SignalCallRow(
callName = callName,
callLink = callLink,
onJoinClicked = this@CreateCallLinkBottomSheetDialogFragment::onJoinClicked
)
Spacer(modifier = Modifier.height(12.dp))
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__add_call_name),
modifier = Modifier.clickable(onClick = this@CreateCallLinkBottomSheetDialogFragment::onAddACallNameClicked)
)
Rows.ToggleRow(
checked = approveAllMembers,
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__approve_all_members),
onCheckChanged = viewModel::setApproveAllMembers,
modifier = Modifier.clickable(onClick = viewModel::toggleApproveAllMembers)
)
Dividers.Default()
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link_via_signal),
icon = ImageVector.vectorResource(id = R.drawable.symbol_forward_24),
modifier = Modifier.clickable(onClick = this@CreateCallLinkBottomSheetDialogFragment::onShareViaSignalClicked)
)
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__copy_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_copy_android_24),
modifier = Modifier.clickable(onClick = this@CreateCallLinkBottomSheetDialogFragment::onCopyLinkClicked)
)
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_share_android_24),
modifier = Modifier.clickable(onClick = this@CreateCallLinkBottomSheetDialogFragment::onShareLinkClicked)
)
Buttons.MediumTonal(
onClick = this@CreateCallLinkBottomSheetDialogFragment::onDoneClicked,
modifier = Modifier
.padding(end = dimensionResource(id = R.dimen.core_ui__gutter))
.align(End)
) {
Text(text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__done))
}
Spacer(modifier = Modifier.size(16.dp))
}
}
private fun onAddACallNameClicked() {
EditCallLinkNameDialogFragment().show(childFragmentManager, null)
}
private fun onJoinClicked() {
}
private fun onDoneClicked() {
}
private fun onShareViaSignalClicked() {
val snapshot = viewModel.callLink.value
MultiselectForwardFragment.showFullScreen(
childFragmentManager,
MultiselectForwardFragmentArgs(
canSendToNonPush = false,
multiShareArgs = listOf(
MultiShareArgs.Builder()
.withDraftText(snapshot)
.build()
)
)
)
}
private fun onCopyLinkClicked() {
val snapshot = viewModel.callLink.value
Util.copyToClipboard(requireContext(), snapshot)
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
}
private fun onShareLinkClicked() {
val snapshot = viewModel.callLink.value
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(snapshot)
.setType(mimeType)
.createChooserIntent()
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
try {
startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
}
}
}
@Composable
private fun SignalCallRow(
callName: String,
callLink: String,
onJoinClicked: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
.border(
width = 1.25.dp,
color = MaterialTheme.colorScheme.outline,
shape = RoundedCornerShape(18.dp)
)
.padding(16.dp)
) {
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_video_display_bold_40),
contentScale = ContentScale.Inside,
contentDescription = null,
colorFilter = ColorFilter.tint(Color(0xFF5151F6)),
modifier = Modifier
.size(64.dp)
.background(
color = Color(0xFFE5E5FE),
shape = CircleShape
)
)
Spacer(modifier = Modifier.width(10.dp))
Column(
modifier = Modifier
.weight(1f)
.align(CenterVertically)
) {
Text(
text = callName.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
)
Text(
text = callLink,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.width(10.dp))
Buttons.Small(
onClick = onJoinClicked,
modifier = Modifier.align(CenterVertically)
) {
Text(text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__join))
}
}
}

View File

@@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.calls.links
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
class CreateCallLinkViewModel : ViewModel() {
private val _callName: MutableState<String> = mutableStateOf("")
private val _callLink: MutableState<String> = mutableStateOf("")
private val _approveAllMembers: MutableState<Boolean> = mutableStateOf(false)
val callName: State<String> = _callName
val callLink: State<String> = _callLink
val approveAllMembers: State<Boolean> = _approveAllMembers
fun setApproveAllMembers(approveAllMembers: Boolean) {
_approveAllMembers.value = approveAllMembers
}
fun toggleApproveAllMembers() {
_approveAllMembers.value = !_approveAllMembers.value
}
fun setCallName(callName: String) {
_callName.value = callName
}
fun setCallLink(callLink: String) {
_callLink.value = callLink
}
}

View File

@@ -0,0 +1,114 @@
package org.thoughtcrime.securesms.calls.links
import android.app.Dialog
import android.os.Bundle
import android.view.WindowManager
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.End
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import org.signal.core.ui.Buttons
import org.signal.core.ui.Scaffolds
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
private val viewModel: CreateCallLinkViewModel by viewModels(
ownerProducer = { requireParentFragment() }
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
return dialog
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Composable
override fun DialogContent() {
val viewModelCallName by viewModel.callName
var callName by remember {
mutableStateOf(
TextFieldValue(
text = viewModelCallName,
selection = TextRange(viewModelCallName.length)
)
)
}
Scaffolds.Settings(
title = stringResource(id = R.string.EditCallLinkNameDialogFragment__edit_call_name),
onNavigationClick = this::dismiss,
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { paddingValues ->
val focusRequester = remember { FocusRequester() }
Surface(modifier = Modifier.padding(paddingValues)) {
Column(
modifier = Modifier
.padding(
horizontal = dimensionResource(id = org.signal.core.ui.R.dimen.core_ui__gutter)
)
.padding(top = 20.dp, bottom = 16.dp)
) {
TextField(
value = callName,
label = {
Text(text = stringResource(id = R.string.EditCallLinkNameDialogFragment__call_name))
},
onValueChange = { callName = it },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
Spacer(modifier = Modifier.weight(1f))
Buttons.MediumTonal(
onClick = {
viewModel.setCallName(callName.text)
dismiss()
},
modifier = Modifier.align(End)
) {
Text(text = stringResource(id = R.string.EditCallLinkNameDialogFragment__save))
}
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}
}

View File

@@ -0,0 +1,74 @@
package org.thoughtcrime.securesms.calls.log
import android.content.res.Resources
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.view.ActionMode
import org.thoughtcrime.securesms.R
class CallLogActionMode(
private val callback: Callback
) : ActionMode.Callback {
private var actionMode: ActionMode? = null
private var count: Int = 0
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
mode?.title = getTitle(1)
return true
}
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
return false
}
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
return true
}
override fun onDestroyActionMode(mode: ActionMode?) {
callback.onResetSelectionState()
endIfActive()
}
fun isInActionMode(): Boolean {
return actionMode != null
}
fun getCount(): Int {
return if (actionMode != null) count else 0
}
fun setCount(count: Int) {
this.count = count
actionMode?.title = getTitle(count)
}
fun start() {
actionMode = callback.startActionMode(this)
}
fun end() {
callback.onActionModeWillEnd()
actionMode?.finish()
count = 0
actionMode = null
}
private fun getTitle(callLogsSelected: Int): String {
return callback.getResources().getQuantityString(R.plurals.ConversationListFragment_s_selected, callLogsSelected, callLogsSelected)
}
private fun endIfActive() {
if (actionMode != null) {
end()
}
}
interface Callback {
fun startActionMode(callback: ActionMode.Callback): ActionMode?
fun onActionModeWillEnd()
fun getResources(): Resources
fun onResetSelectionState()
}
}

View File

@@ -0,0 +1,316 @@
package org.thoughtcrime.securesms.calls.log
import android.content.res.ColorStateList
import android.view.View
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.widget.TextViewCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.databinding.CallLogAdapterItemBinding
import org.thoughtcrime.securesms.databinding.CallLogCreateCallLinkItemBinding
import org.thoughtcrime.securesms.databinding.ConversationListItemClearFilterBinding
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.thoughtcrime.securesms.util.setRelativeDrawables
import org.thoughtcrime.securesms.util.visible
import java.util.Locale
/**
* RecyclerView Adapter for the Call Log screen
*/
class CallLogAdapter(
callbacks: Callbacks
) : PagingMappingAdapter<CallLogRow.Id>() {
init {
registerFactory(
CallModel::class.java,
BindingFactory(
creator = {
CallModelViewHolder(
it,
callbacks::onCallClicked,
callbacks::onCallLongClicked,
callbacks::onStartAudioCallClicked,
callbacks::onStartVideoCallClicked
)
},
inflater = CallLogAdapterItemBinding::inflate
)
)
registerFactory(
ClearFilterModel::class.java,
BindingFactory(
creator = { ClearFilterViewHolder(it, callbacks::onClearFilterClicked) },
inflater = ConversationListItemClearFilterBinding::inflate
)
)
registerFactory(
CreateCallLinkModel::class.java,
BindingFactory(
creator = { CreateCallLinkViewHolder(it, callbacks::onCreateACallLinkClicked) },
inflater = CallLogCreateCallLinkItemBinding::inflate
)
)
}
fun submitCallRows(
rows: List<CallLogRow?>,
selectionState: CallLogSelectionState,
stagedDeletion: CallLogStagedDeletion?
): Int {
val filteredRows = rows
.filterNotNull()
.filterNot { stagedDeletion?.isStagedForDeletion(it.id) == true }
.map {
when (it) {
is CallLogRow.Call -> CallModel(it, selectionState, itemCount)
is CallLogRow.ClearFilter -> ClearFilterModel()
is CallLogRow.CreateCallLink -> CreateCallLinkModel()
}
}
submitList(filteredRows)
return filteredRows.size
}
private class CallModel(
val call: CallLogRow.Call,
val selectionState: CallLogSelectionState,
val itemCount: Int
) : MappingModel<CallModel> {
companion object {
const val PAYLOAD_SELECTION_STATE = "PAYLOAD_SELECTION_STATE"
}
override fun areItemsTheSame(newItem: CallModel): Boolean = call.id == newItem.call.id
override fun areContentsTheSame(newItem: CallModel): Boolean {
return call == newItem.call &&
isSelectionStateTheSame(newItem) &&
isItemCountTheSame(newItem)
}
override fun getChangePayload(newItem: CallModel): Any? {
return if (call == newItem.call && (!isSelectionStateTheSame(newItem) || !isItemCountTheSame(newItem))) {
PAYLOAD_SELECTION_STATE
} else {
null
}
}
private fun isSelectionStateTheSame(newItem: CallModel): Boolean {
return selectionState.contains(call.id) == newItem.selectionState.contains(newItem.call.id) &&
selectionState.isNotEmpty(itemCount) == newItem.selectionState.isNotEmpty(newItem.itemCount)
}
private fun isItemCountTheSame(newItem: CallModel): Boolean {
return itemCount == newItem.itemCount
}
}
private class ClearFilterModel : MappingModel<ClearFilterModel> {
override fun areItemsTheSame(newItem: ClearFilterModel): Boolean = true
override fun areContentsTheSame(newItem: ClearFilterModel): Boolean = true
}
private class CreateCallLinkModel : MappingModel<CreateCallLinkModel> {
override fun areItemsTheSame(newItem: CreateCallLinkModel): Boolean = true
override fun areContentsTheSame(newItem: CreateCallLinkModel): Boolean = true
}
private class CallModelViewHolder(
binding: CallLogAdapterItemBinding,
private val onCallClicked: (CallLogRow.Call) -> Unit,
private val onCallLongClicked: (View, CallLogRow.Call) -> Boolean,
private val onStartAudioCallClicked: (Recipient) -> Unit,
private val onStartVideoCallClicked: (Recipient) -> Unit
) : BindingViewHolder<CallModel, CallLogAdapterItemBinding>(binding) {
override fun bind(model: CallModel) {
itemView.setOnClickListener {
onCallClicked(model.call)
}
itemView.setOnLongClickListener {
onCallLongClicked(itemView, model.call)
}
itemView.isSelected = model.selectionState.contains(model.call.id)
binding.callSelected.isChecked = model.selectionState.contains(model.call.id)
binding.callSelected.visible = model.selectionState.isNotEmpty(model.itemCount)
if (payload.contains(CallModel.PAYLOAD_SELECTION_STATE)) {
return
}
val event = model.call.call.event
val direction = model.call.call.direction
binding.callRecipientAvatar.setAvatar(GlideApp.with(binding.callRecipientAvatar), model.call.peer, true)
binding.callRecipientBadge.setBadgeFromRecipient(model.call.peer)
binding.callRecipientName.text = model.call.peer.getDisplayName(context)
presentCallInfo(event, direction, model.call.date)
presentCallType(model)
}
private fun presentCallInfo(event: CallTable.Event, direction: CallTable.Direction, date: Long) {
binding.callInfo.text = context.getString(
R.string.CallLogAdapter__s_dot_s,
context.getString(getCallStateStringRes(event, direction)),
DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), date)
)
binding.callInfo.setRelativeDrawables(
start = getCallStateDrawableRes(event, direction)
)
val color = ContextCompat.getColor(
context,
if (event == CallTable.Event.MISSED) {
R.color.signal_colorError
} else {
R.color.signal_colorOnSurface
}
)
TextViewCompat.setCompoundDrawableTintList(
binding.callInfo,
ColorStateList.valueOf(color)
)
binding.callInfo.setTextColor(color)
}
private fun presentCallType(model: CallModel) {
when (model.call.call.type) {
CallTable.Type.AUDIO_CALL -> {
binding.callType.setImageResource(R.drawable.symbol_phone_24)
binding.callType.setOnClickListener { onStartAudioCallClicked(model.call.peer) }
binding.callType.visible = true
binding.groupCallButton.visible = false
}
CallTable.Type.VIDEO_CALL -> {
binding.callType.setImageResource(R.drawable.symbol_video_24)
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer) }
binding.callType.visible = true
binding.groupCallButton.visible = false
}
CallTable.Type.GROUP_CALL, CallTable.Type.AD_HOC_CALL -> {
binding.callType.setImageResource(R.drawable.symbol_video_24)
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer) }
binding.groupCallButton.setOnClickListener { onStartVideoCallClicked(model.call.peer) }
when (model.call.groupCallState) {
CallLogRow.GroupCallState.NONE, CallLogRow.GroupCallState.FULL -> {
binding.callType.visible = true
binding.groupCallButton.visible = false
}
CallLogRow.GroupCallState.ACTIVE, CallLogRow.GroupCallState.LOCAL_USER_JOINED -> {
binding.callType.visible = false
binding.groupCallButton.visible = true
binding.groupCallButton.setText(
if (model.call.groupCallState == CallLogRow.GroupCallState.LOCAL_USER_JOINED) {
R.string.CallLogAdapter__return
} else {
R.string.CallLogAdapter__join
}
)
}
}
}
}
}
@DrawableRes
private fun getCallStateDrawableRes(callEvent: CallTable.Event, callDirection: CallTable.Direction): Int {
if (callEvent == CallTable.Event.MISSED) {
return R.drawable.symbol_missed_incoming_compact_16
}
return if (callDirection == CallTable.Direction.INCOMING) {
R.drawable.symbol_arrow_downleft_compact_16
} else {
R.drawable.symbol_arrow_upright_compact_16
}
}
@StringRes
private fun getCallStateStringRes(callEvent: CallTable.Event, callDirection: CallTable.Direction): Int {
if (callEvent == CallTable.Event.MISSED) {
return R.string.CallLogAdapter__missed
}
return if (callDirection == CallTable.Direction.INCOMING) {
R.string.CallLogAdapter__incoming
} else {
R.string.CallLogAdapter__outgoing
}
}
}
private class ClearFilterViewHolder(
binding: ConversationListItemClearFilterBinding,
onClearFilterClicked: () -> Unit
) : BindingViewHolder<ClearFilterModel, ConversationListItemClearFilterBinding>(binding) {
init {
binding.clearFilter.setOnClickListener { onClearFilterClicked() }
}
override fun bind(model: ClearFilterModel) = Unit
}
private class CreateCallLinkViewHolder(
binding: CallLogCreateCallLinkItemBinding,
onClick: () -> Unit
) : BindingViewHolder<CreateCallLinkModel, CallLogCreateCallLinkItemBinding>(binding) {
init {
binding.root.setOnClickListener { onClick() }
}
override fun bind(model: CreateCallLinkModel) = Unit
}
interface Callbacks {
/**
* Invoked when 'Create a call link' is clicked
*/
fun onCreateACallLinkClicked()
/**
* Invoked when a call row is clicked
*/
fun onCallClicked(callLogRow: CallLogRow.Call)
/**
* Invoked when a call row is long-clicked
*/
fun onCallLongClicked(itemView: View, callLogRow: CallLogRow.Call): Boolean
/**
* Invoked when the clear filter button is pressed
*/
fun onClearFilterClicked()
/**
* Invoked when user presses the audio icon
*/
fun onStartAudioCallClicked(peer: Recipient)
/**
* Invoked when user presses the video icon
*/
fun onStartVideoCallClicked(peer: Recipient)
}
}

View File

@@ -0,0 +1,106 @@
package org.thoughtcrime.securesms.calls.log
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.util.CommunicationActions
/**
* Context menu for row items on the Call Log screen.
*/
class CallLogContextMenu(
private val fragment: Fragment,
private val callbacks: Callbacks
) {
fun show(anchor: View, call: CallLogRow.Call) {
anchor.isSelected = true
SignalContextMenu.Builder(anchor, anchor.parent as ViewGroup)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
.onDismiss { anchor.isSelected = false }
.show(
listOfNotNull(
getVideoCallActionItem(call),
getAudioCallActionItem(call),
getGoToChatActionItem(call),
getInfoActionItem(call),
getSelectActionItem(call),
getDeleteActionItem(call)
)
)
}
private fun getVideoCallActionItem(call: CallLogRow.Call): ActionItem {
// TODO [alex] -- Need group calling disposition to make this correct
return ActionItem(
iconRes = R.drawable.symbol_video_24,
title = fragment.getString(R.string.CallContextMenu__video_call)
) {
CommunicationActions.startVideoCall(fragment, call.peer)
}
}
private fun getAudioCallActionItem(call: CallLogRow.Call): ActionItem? {
if (call.peer.isGroup) {
return null
}
return ActionItem(
iconRes = R.drawable.symbol_phone_24,
title = fragment.getString(R.string.CallContextMenu__audio_call)
) {
CommunicationActions.startVoiceCall(fragment, call.peer)
}
}
private fun getGoToChatActionItem(call: CallLogRow.Call): ActionItem {
return ActionItem(
iconRes = R.drawable.symbol_open_24,
title = fragment.getString(R.string.CallContextMenu__go_to_chat)
) {
fragment.startActivity(ConversationIntents.createBuilder(fragment.requireContext(), call.peer.id, -1L).build())
}
}
private fun getInfoActionItem(call: CallLogRow.Call): ActionItem {
return ActionItem(
iconRes = R.drawable.symbol_info_24,
title = fragment.getString(R.string.CallContextMenu__info)
) {
val intent = ConversationSettingsActivity.forCall(fragment.requireContext(), call.peer, longArrayOf(call.call.messageId!!))
fragment.startActivity(intent)
}
}
private fun getSelectActionItem(call: CallLogRow.Call): ActionItem {
return ActionItem(
iconRes = R.drawable.symbol_check_circle_24,
title = fragment.getString(R.string.CallContextMenu__select)
) {
callbacks.startSelection(call)
}
}
private fun getDeleteActionItem(call: CallLogRow.Call): ActionItem? {
if (call.call.event == CallTable.Event.ONGOING) {
return null
}
return ActionItem(
iconRes = R.drawable.symbol_trash_24,
title = fragment.getString(R.string.CallContextMenu__delete)
) {
callbacks.deleteCall(call)
}
}
interface Callbacks {
fun startSelection(call: CallLogRow.Call)
fun deleteCall(call: CallLogRow.Call)
}
}

View File

@@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.calls.log
/**
* Allows user to only display certain classes of calls.
*/
enum class CallLogFilter {
/**
* All call logs will be displayed
*/
ALL,
/**
* Only missed calls will be displayed
*/
MISSED
}

View File

@@ -0,0 +1,385 @@
package org.thoughtcrime.securesms.calls.log
import android.annotation.SuppressLint
import android.content.res.Resources
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.Flowables
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.new.NewCallActivity
import org.thoughtcrime.securesms.components.Material3SearchToolbar
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
import org.thoughtcrime.securesms.conversation.SignalBottomActionBarController
import org.thoughtcrime.securesms.conversationlist.ConversationFilterBehavior
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFilterPullView.OnCloseClicked
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFilterPullView.OnFilterStateChanged
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterLerp
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterPullState
import org.thoughtcrime.securesms.databinding.CallLogFragmentBinding
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.main.SearchBinder
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.stories.tabs.ConversationListTab
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.visible
import java.util.Objects
/**
* Call Log tab.
*/
@SuppressLint("DiscouragedApi")
class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Callbacks, CallLogContextMenu.Callbacks {
companion object {
private const val LIST_SMOOTH_SCROLL_TO_TOP_THRESHOLD = 25
}
private val viewModel: CallLogViewModel by viewModels()
private val binding: CallLogFragmentBinding by ViewBinderDelegate(CallLogFragmentBinding::bind)
private val disposables = LifecycleDisposable()
private val callLogContextMenu = CallLogContextMenu(this, this)
private val callLogActionMode = CallLogActionMode(CallLogActionModeCallback())
private lateinit var signalBottomActionBarController: SignalBottomActionBarController
private val tabsViewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() })
private val menuProvider = object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.calls_tab_menu, menu)
}
override fun onPrepareMenu(menu: Menu) {
val isFiltered = viewModel.filterSnapshot == CallLogFilter.MISSED
menu.findItem(R.id.action_clear_missed_call_filter).isVisible = isFiltered
menu.findItem(R.id.action_filter_missed_calls).isVisible = !isFiltered
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.action_settings -> startActivity(AppSettingsActivity.home(requireContext()))
R.id.action_notification_profile -> NotificationProfileSelectionFragment.show(parentFragmentManager)
R.id.action_filter_missed_calls -> filterMissedCalls()
R.id.action_clear_missed_call_filter -> onClearFilterClicked()
}
return true
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
requireActivity().addMenuProvider(menuProvider, viewLifecycleOwner)
val adapter = CallLogAdapter(this)
disposables.bindTo(viewLifecycleOwner)
adapter.setPagingController(viewModel.controller)
disposables += Flowables.combineLatest(viewModel.data, viewModel.selectedAndStagedDeletion)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (data, selected) ->
val filteredCount = adapter.submitCallRows(data, selected.first, selected.second)
binding.emptyState.visible = filteredCount == 0
}
disposables += Flowables.combineLatest(viewModel.selectedAndStagedDeletion, viewModel.totalCount)
.distinctUntilChanged()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (selected, totalCount) ->
if (selected.first.isNotEmpty(totalCount)) {
callLogActionMode.setCount(selected.first.count(totalCount))
} else {
callLogActionMode.end()
}
}
binding.recycler.adapter = adapter
requireListener<Material3OnScrollHelperBinder>().bindScrollHelper(binding.recycler)
binding.fab.setOnClickListener {
startActivity(NewCallActivity.createIntent(requireContext()))
}
binding.pullView.setPillText(R.string.CallLogFragment__filtered_by_missed)
binding.bottomActionBar.setItems(
listOf(
ActionItem(
iconRes = R.drawable.symbol_check_circle_24,
title = getString(R.string.CallLogFragment__select_all)
) {
viewModel.selectAll()
},
ActionItem(
iconRes = R.drawable.symbol_trash_24,
title = getString(R.string.CallLogFragment__delete),
action = this::handleDeleteSelectedRows
)
)
)
initializePullToFilter()
initializeTapToScrollToTop()
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (!closeSearchIfOpen()) {
tabsViewModel.onChatsSelected()
}
}
}
)
signalBottomActionBarController = SignalBottomActionBarController(
binding.bottomActionBar,
binding.recycler,
BottomActionBarControllerCallback()
)
}
override fun onResume() {
super.onResume()
initializeSearchAction()
ApplicationDependencies.getDeletedCallEventManager().scheduleIfNecessary()
}
private fun initializeTapToScrollToTop() {
disposables += tabsViewModel.tabClickEvents
.filter { it == ConversationListTab.CALLS }
.subscribeBy(onNext = {
val layoutManager = binding.recycler.layoutManager as? LinearLayoutManager ?: return@subscribeBy
if (layoutManager.findFirstVisibleItemPosition() <= LIST_SMOOTH_SCROLL_TO_TOP_THRESHOLD) {
binding.recycler.smoothScrollToPosition(0)
} else {
binding.recycler.scrollToPosition(0)
}
})
}
private fun handleDeleteSelectedRows() {
val count = callLogActionMode.getCount()
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, count, count))
.setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ ->
viewModel.stageSelectionDeletion()
callLogActionMode.end()
Snackbar
.make(
binding.root,
resources.getQuantityString(R.plurals.CallLogFragment__d_calls_deleted, count, count),
Snackbar.LENGTH_SHORT
)
.addCallback(SnackbarDeletionCallback())
.setAction(R.string.CallLogFragment__undo) {
viewModel.cancelStagedDeletion()
}
.show()
}
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
private fun initializeSearchAction() {
val searchBinder = requireListener<SearchBinder>()
searchBinder.getSearchAction().setOnClickListener {
searchBinder.onSearchOpened()
searchBinder.getSearchToolbar().get().setSearchInputHint(R.string.SearchToolbar_search)
searchBinder.getSearchToolbar().get().listener = object : Material3SearchToolbar.Listener {
override fun onSearchTextChange(text: String) {
viewModel.setSearchQuery(text.trim())
}
override fun onSearchClosed() {
viewModel.setSearchQuery("")
searchBinder.onSearchClosed()
}
}
}
}
private fun initializePullToFilter() {
val collapsingToolbarLayout = binding.collapsingToolbar
val openHeight = DimensionUnit.DP.toPixels(FilterLerp.FILTER_OPEN_HEIGHT).toInt()
binding.pullView.onFilterStateChanged = OnFilterStateChanged { state: FilterPullState?, source: ConversationFilterSource ->
when (state) {
FilterPullState.CLOSING -> viewModel.setFilter(CallLogFilter.ALL)
FilterPullState.OPENING -> {
ViewUtil.setMinimumHeight(collapsingToolbarLayout, openHeight)
viewModel.setFilter(CallLogFilter.MISSED)
}
FilterPullState.OPEN_APEX -> if (source === ConversationFilterSource.DRAG) {
// TODO[alex] -- hint here? SignalStore.uiHints().incrementNeverDisplayPullToFilterTip()
}
FilterPullState.CLOSE_APEX -> ViewUtil.setMinimumHeight(collapsingToolbarLayout, 0)
else -> Unit
}
}
binding.pullView.onCloseClicked = OnCloseClicked {
onClearFilterClicked()
}
val conversationFilterBehavior = Objects.requireNonNull<ConversationFilterBehavior?>((binding.recyclerCoordinatorAppBar.layoutParams as CoordinatorLayout.LayoutParams).behavior as ConversationFilterBehavior?)
conversationFilterBehavior.callback = object : ConversationFilterBehavior.Callback {
override fun onStopNestedScroll() {
binding.pullView.onUserDragFinished()
}
override fun canStartNestedScroll(): Boolean {
return !callLogActionMode.isInActionMode() || !isSearchOpen() || binding.pullView.isCloseable()
}
}
binding.recyclerCoordinatorAppBar.addOnOffsetChangedListener { layout: AppBarLayout, verticalOffset: Int ->
val progress = 1 - verticalOffset.toFloat() / -layout.height
binding.pullView.onUserDrag(progress)
}
}
override fun onCreateACallLinkClicked() {
findNavController().navigate(R.id.createCallLinkBottomSheet)
}
override fun onCallClicked(callLogRow: CallLogRow.Call) {
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
viewModel.toggleSelected(callLogRow.id)
} else {
val intent = ConversationSettingsActivity.forCall(requireContext(), callLogRow.peer, longArrayOf(callLogRow.call.messageId!!))
startActivity(intent)
}
}
override fun onCallLongClicked(itemView: View, callLogRow: CallLogRow.Call): Boolean {
callLogContextMenu.show(itemView, callLogRow)
return true
}
override fun onClearFilterClicked() {
binding.pullView.toggle()
binding.recyclerCoordinatorAppBar.setExpanded(false, true)
}
override fun onStartAudioCallClicked(peer: Recipient) {
CommunicationActions.startVoiceCall(this, peer)
}
override fun onStartVideoCallClicked(peer: Recipient) {
CommunicationActions.startVideoCall(this, peer)
}
override fun startSelection(call: CallLogRow.Call) {
callLogActionMode.start()
viewModel.toggleSelected(call.id)
}
override fun deleteCall(call: CallLogRow.Call) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
.setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ ->
viewModel.stageCallDeletion(call)
Snackbar
.make(
binding.root,
resources.getQuantityString(R.plurals.CallLogFragment__d_calls_deleted, 1, 1),
Snackbar.LENGTH_SHORT
)
.addCallback(SnackbarDeletionCallback())
.setAction(R.string.CallLogFragment__undo) {
viewModel.cancelStagedDeletion()
}
.show()
}
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
private fun filterMissedCalls() {
binding.pullView.toggle()
binding.recyclerCoordinatorAppBar.setExpanded(false, true)
}
private fun isSearchOpen(): Boolean {
return isSearchVisible() || viewModel.hasSearchQuery
}
private fun closeSearchIfOpen(): Boolean {
if (isSearchOpen()) {
requireListener<SearchBinder>().getSearchToolbar().get().collapse()
requireListener<SearchBinder>().onSearchClosed()
return true
}
return false
}
private fun isSearchVisible(): Boolean {
return requireListener<SearchBinder>().getSearchToolbar().resolved() &&
requireListener<SearchBinder>().getSearchToolbar().get().getVisibility() == View.VISIBLE
}
private inner class BottomActionBarControllerCallback : SignalBottomActionBarController.Callback {
override fun onBottomActionBarVisibilityChanged(visibility: Int) = Unit
}
private inner class CallLogActionModeCallback : CallLogActionMode.Callback {
override fun startActionMode(callback: ActionMode.Callback): ActionMode? {
val actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(callback)
requireListener<Callback>().onMultiSelectStarted()
signalBottomActionBarController.setVisibility(true)
return actionMode
}
override fun onActionModeWillEnd() {
requireListener<Callback>().onMultiSelectFinished()
signalBottomActionBarController.setVisibility(false)
}
override fun getResources(): Resources = resources
override fun onResetSelectionState() {
viewModel.clearSelected()
}
}
private inner class SnackbarDeletionCallback : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
viewModel.commitStagedDeletion()
}
}
interface Callback {
fun onMultiSelectStarted()
fun onMultiSelectFinished()
}
}

View File

@@ -0,0 +1,51 @@
package org.thoughtcrime.securesms.calls.log
import org.signal.paging.PagedDataSource
import org.thoughtcrime.securesms.util.FeatureFlags
class CallLogPagedDataSource(
private val query: String?,
private val filter: CallLogFilter,
private val repository: CallRepository
) : PagedDataSource<CallLogRow.Id, CallLogRow> {
private val hasFilter = filter == CallLogFilter.MISSED
private val hasCallLinkRow = FeatureFlags.adHocCalling() && filter == CallLogFilter.ALL && query.isNullOrEmpty()
private var callsCount = 0
override fun size(): Int {
callsCount = repository.getCallsCount(query, filter)
return callsCount + hasFilter.toInt() + hasCallLinkRow.toInt()
}
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<CallLogRow> {
val calls = mutableListOf<CallLogRow>()
val callLimit = length - hasCallLinkRow.toInt()
if (start == 0 && length >= 1 && hasCallLinkRow) {
calls.add(CallLogRow.CreateCallLink)
}
calls.addAll(repository.getCalls(query, filter, start, callLimit).toMutableList())
if (calls.size < length && hasFilter) {
calls.add(CallLogRow.ClearFilter)
}
return calls
}
override fun getKey(data: CallLogRow): CallLogRow.Id = data.id
override fun load(key: CallLogRow.Id?): CallLogRow = error("Not supported")
private fun Boolean.toInt(): Int {
return if (this) 1 else 0
}
interface CallRepository {
fun getCallsCount(query: String?, filter: CallLogFilter): Int
fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
}
}

View File

@@ -0,0 +1,53 @@
package org.thoughtcrime.securesms.calls.log
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
class CallLogRepository : CallLogPagedDataSource.CallRepository {
override fun getCallsCount(query: String?, filter: CallLogFilter): Int {
return SignalDatabase.calls.getCallsCount(query, filter)
}
override fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow> {
return SignalDatabase.calls.getCalls(start, length, query, filter)
}
fun listenForChanges(): Observable<Unit> {
return Observable.create { emitter ->
fun refresh() {
emitter.onNext(Unit)
}
val databaseObserver = DatabaseObserver.Observer {
refresh()
}
ApplicationDependencies.getDatabaseObserver().registerConversationListObserver(databaseObserver)
ApplicationDependencies.getDatabaseObserver().registerCallUpdateObserver(databaseObserver)
emitter.setCancellable {
ApplicationDependencies.getDatabaseObserver().unregisterObserver(databaseObserver)
}
}
}
fun deleteSelectedCallLogs(
selectedCallIds: Set<Long>
): Completable {
return Completable.fromAction {
SignalDatabase.calls.deleteCallEvents(selectedCallIds)
}.observeOn(Schedulers.io())
}
fun deleteAllCallLogsExcept(
selectedCallIds: Set<Long>
): Completable {
return Completable.fromAction {
SignalDatabase.calls.deleteAllCallEventsExcept(selectedCallIds)
}.observeOn(Schedulers.io())
}
}

View File

@@ -0,0 +1,85 @@
package org.thoughtcrime.securesms.calls.log
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails
import org.thoughtcrime.securesms.recipients.Recipient
/**
* A row to be displayed in the call log
*/
sealed class CallLogRow {
abstract val id: Id
/**
* An incoming, outgoing, or missed call.
*/
data class Call(
val call: CallTable.Call,
val peer: Recipient,
val date: Long,
val groupCallState: GroupCallState,
override val id: Id = Id.Call(call.callId)
) : CallLogRow()
/**
* A row which can be used to clear the current filter.
*/
object ClearFilter : CallLogRow() {
override val id: Id = Id.ClearFilter
}
object CreateCallLink : CallLogRow() {
override val id: Id = Id.CreateCallLink
}
sealed class Id {
data class Call(val callId: Long) : Id()
object ClearFilter : Id()
object CreateCallLink : Id()
}
enum class GroupCallState {
/**
* No group call available.
*/
NONE,
/**
* Active, but the local user is not in the call.
*/
ACTIVE,
/**
* Active and the local user is in the call
*/
LOCAL_USER_JOINED,
/**
* Active but the call is full.
*/
FULL;
companion object {
fun fromDetails(groupCallUpdateDetails: GroupCallUpdateDetails?): GroupCallState {
if (groupCallUpdateDetails == null) {
return NONE
}
if (groupCallUpdateDetails.isCallFull) {
return FULL
}
if (groupCallUpdateDetails.inCallUuidsList.contains(Recipient.self().requireServiceId().uuid().toString())) {
return LOCAL_USER_JOINED
}
return if (groupCallUpdateDetails.inCallUuidsCount > 0) {
ACTIVE
} else {
NONE
}
}
}
}
}

View File

@@ -0,0 +1,81 @@
package org.thoughtcrime.securesms.calls.log
/**
* Selection state object for call logs.
*/
sealed class CallLogSelectionState {
abstract fun contains(callId: CallLogRow.Id): Boolean
abstract fun isNotEmpty(totalCount: Int): Boolean
abstract fun count(totalCount: Int): Int
abstract fun selected(): Set<CallLogRow.Id>
fun isExclusionary(): Boolean = this is Excludes
protected abstract fun select(callId: CallLogRow.Id): CallLogSelectionState
protected abstract fun deselect(callId: CallLogRow.Id): CallLogSelectionState
fun toggle(callId: CallLogRow.Id): CallLogSelectionState {
return if (contains(callId)) {
deselect(callId)
} else {
select(callId)
}
}
/**
* Includes contains an opt-in list of call logs.
*/
data class Includes(private val includes: Set<CallLogRow.Id>) : CallLogSelectionState() {
override fun contains(callId: CallLogRow.Id): Boolean {
return includes.contains(callId)
}
override fun isNotEmpty(totalCount: Int): Boolean {
return includes.isNotEmpty()
}
override fun count(totalCount: Int): Int {
return includes.size
}
override fun select(callId: CallLogRow.Id): CallLogSelectionState {
return Includes(includes + callId)
}
override fun deselect(callId: CallLogRow.Id): CallLogSelectionState {
return Includes(includes - callId)
}
override fun selected(): Set<CallLogRow.Id> {
return includes
}
}
/**
* Excludes contains an opt-out list of call logs.
*/
data class Excludes(private val excluded: Set<CallLogRow.Id>) : CallLogSelectionState() {
override fun contains(callId: CallLogRow.Id): Boolean = !excluded.contains(callId)
override fun isNotEmpty(totalCount: Int): Boolean = excluded.size < totalCount
override fun count(totalCount: Int): Int {
return totalCount - excluded.size
}
override fun select(callId: CallLogRow.Id): CallLogSelectionState {
return Excludes(excluded - callId)
}
override fun deselect(callId: CallLogRow.Id): CallLogSelectionState {
return Excludes(excluded + callId)
}
override fun selected(): Set<CallLogRow.Id> = excluded
}
companion object {
fun empty(): CallLogSelectionState = Includes(emptySet())
fun selectAll(): CallLogSelectionState = Excludes(emptySet())
}
}

View File

@@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.calls.log
import androidx.annotation.MainThread
/**
* Encapsulates a single deletion action
*/
class CallLogStagedDeletion(
private val stateSnapshot: CallLogSelectionState,
private val repository: CallLogRepository
) {
private var isCommitted = false
fun isStagedForDeletion(id: CallLogRow.Id): Boolean {
return stateSnapshot.contains(id)
}
@MainThread
fun cancel() {
isCommitted = true
}
@MainThread
fun commit() {
if (isCommitted) {
return
}
isCommitted = true
val callIds = stateSnapshot.selected()
.filterIsInstance<CallLogRow.Id.Call>()
.map { it.callId }
.toSet()
if (stateSnapshot.isExclusionary()) {
repository.deleteAllCallLogsExcept(callIds).subscribe()
} else {
repository.deleteSelectedCallLogs(callIds).subscribe()
}
}
}

View File

@@ -0,0 +1,158 @@
package org.thoughtcrime.securesms.calls.log
import androidx.annotation.MainThread
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.processors.BehaviorProcessor
import org.signal.paging.ObservablePagedData
import org.signal.paging.PagedData
import org.signal.paging.PagingConfig
import org.signal.paging.ProxyPagingController
import org.thoughtcrime.securesms.util.rx.RxStore
/**
* ViewModel for call log management.
*/
class CallLogViewModel(
private val callLogRepository: CallLogRepository = CallLogRepository()
) : ViewModel() {
private val callLogStore = RxStore(CallLogState())
private val disposables = CompositeDisposable()
private val pagedData: BehaviorProcessor<ObservablePagedData<CallLogRow.Id, CallLogRow>> = BehaviorProcessor.create()
private val distinctQueryFilterPairs = callLogStore
.stateFlowable
.map { (query, filter) -> Pair(query, filter) }
.distinctUntilChanged()
val controller = ProxyPagingController<CallLogRow.Id>()
val data: Flowable<MutableList<CallLogRow?>> = pagedData.switchMap { it.data.toFlowable(BackpressureStrategy.LATEST) }
val selectedAndStagedDeletion: Flowable<Pair<CallLogSelectionState, CallLogStagedDeletion?>> = callLogStore
.stateFlowable
.map { it.selectionState to it.stagedDeletion }
val totalCount: Flowable<Int> = Flowable.combineLatest(distinctQueryFilterPairs, data) { a, _ -> a }
.map { (query, filter) -> callLogRepository.getCallsCount(query, filter) }
val selectionStateSnapshot: CallLogSelectionState
get() = callLogStore.state.selectionState
val filterSnapshot: CallLogFilter
get() = callLogStore.state.filter
val hasSearchQuery: Boolean
get() = !callLogStore.state.query.isNullOrBlank()
private val pagingConfig = PagingConfig.Builder()
.setBufferPages(1)
.setPageSize(20)
.setStartIndex(0)
.build()
init {
disposables.add(callLogStore)
disposables += distinctQueryFilterPairs.subscribe { (query, filter) ->
pagedData.onNext(
PagedData.createForObservable(
CallLogPagedDataSource(query, filter, callLogRepository),
pagingConfig
)
)
}
disposables += pagedData.map { it.controller }.subscribe {
controller.set(it)
}
disposables += callLogRepository.listenForChanges().subscribe {
controller.onDataInvalidated()
}
}
override fun onCleared() {
commitStagedDeletion()
disposables.dispose()
}
fun selectAll() {
callLogStore.update {
val selectionState = CallLogSelectionState.selectAll()
it.copy(selectionState = selectionState)
}
}
fun toggleSelected(callId: CallLogRow.Id) {
callLogStore.update {
val selectionState = it.selectionState.toggle(callId)
it.copy(selectionState = selectionState)
}
}
@MainThread
fun stageCallDeletion(call: CallLogRow.Call) {
callLogStore.state.stagedDeletion?.commit()
callLogStore.update {
it.copy(
stagedDeletion = CallLogStagedDeletion(
CallLogSelectionState.empty().toggle(call.id),
callLogRepository
)
)
}
}
@MainThread
fun stageSelectionDeletion() {
callLogStore.state.stagedDeletion?.commit()
callLogStore.update {
it.copy(
stagedDeletion = CallLogStagedDeletion(
it.selectionState,
callLogRepository
)
)
}
}
fun commitStagedDeletion() {
callLogStore.state.stagedDeletion?.commit()
callLogStore.update {
it.copy(
stagedDeletion = null
)
}
}
fun cancelStagedDeletion() {
callLogStore.state.stagedDeletion?.cancel()
callLogStore.update {
it.copy(
stagedDeletion = null
)
}
}
fun clearSelected() {
callLogStore.update {
it.copy(selectionState = CallLogSelectionState.empty())
}
}
fun setSearchQuery(query: String) {
callLogStore.update { it.copy(query = query) }
}
fun setFilter(filter: CallLogFilter) {
callLogStore.update { it.copy(filter = filter) }
}
private data class CallLogState(
val query: String? = null,
val filter: CallLogFilter = CallLogFilter.ALL,
val selectionState: CallLogSelectionState = CallLogSelectionState.empty(),
val stagedDeletion: CallLogStagedDeletion? = null
)
}

View File

@@ -0,0 +1,127 @@
package org.thoughtcrime.securesms.calls.new
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.app.ActivityCompat
import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.concurrent.SimpleTask
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.ContactSelectionActivity
import org.thoughtcrime.securesms.ContactSelectionListFragment
import org.thoughtcrime.securesms.InviteActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery.refresh
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
import java.io.IOException
import java.util.Optional
import java.util.function.Consumer
class NewCallActivity : ContactSelectionActivity(), ContactSelectionListFragment.NewCallCallback {
override fun onCreate(icicle: Bundle?, ready: Boolean) {
super.onCreate(icicle, ready)
requireNotNull(supportActionBar)
supportActionBar?.setTitle(R.string.NewCallActivity__new_call)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
addMenuProvider(NewCallMenuProvider())
}
override fun onSelectionChanged() = Unit
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId?>, number: String?, callback: Consumer<Boolean?>) {
if (isFromUnknownSearchKey) {
Log.i(TAG, "[onContactSelected] Maybe creating a new recipient.")
if (SignalStore.account().isRegistered) {
Log.i(TAG, "[onContactSelected] Doing contact refresh.")
val progress = SimpleProgressDialog.show(this)
SimpleTask.run<Recipient>(lifecycle, {
var resolved = Recipient.external(this, number!!)
if (!resolved.isRegistered || !resolved.hasServiceId()) {
Log.i(TAG, "[onContactSelected] Not registered or no UUID. Doing a directory refresh.")
resolved = try {
refresh(this, resolved, false)
Recipient.resolved(resolved.id)
} catch (e: IOException) {
Log.w(TAG, "[onContactSelected] Failed to refresh directory for new contact.")
return@run null
}
}
resolved
}) { resolved: Recipient? ->
progress.dismiss()
if (resolved != null) {
if (resolved.isRegistered && resolved.hasServiceId()) {
launch(resolved)
} else {
MaterialAlertDialogBuilder(this)
.setMessage(getString(R.string.NewConversationActivity__s_is_not_a_signal_user, resolved.getDisplayName(this)))
.setPositiveButton(android.R.string.ok, null)
.show()
}
} else {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.NetworkFailure__network_error_check_your_connection_and_try_again)
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
}
}
callback.accept(true)
}
private fun launch(recipient: Recipient) {
if (recipient.isGroup) {
CommunicationActions.startVideoCall(this, recipient)
} else {
CommunicationActions.startVoiceCall(this, recipient)
}
}
companion object {
private val TAG = Log.tag(NewCallActivity::class.java)
fun createIntent(context: Context): Intent {
return Intent(context, NewCallActivity::class.java)
.putExtra(
ContactSelectionListFragment.DISPLAY_MODE,
ContactSelectionDisplayMode.none()
.withPush()
.withActiveGroups()
.withGroupMembers()
.build()
)
}
}
override fun onInvite() {
startActivity(Intent(this, InviteActivity::class.java))
}
private inner class NewCallMenuProvider : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.new_call_menu, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
android.R.id.home -> ActivityCompat.finishAfterTransition(this@NewCallActivity)
R.id.menu_refresh -> onRefresh()
R.id.menu_invite -> startActivity(Intent(this@NewCallActivity, InviteActivity::class.java))
}
return true
}
}
}

View File

@@ -64,8 +64,10 @@ public class AnimatingToggle extends FrameLayout {
public void displayQuick(@Nullable View view) {
if (view == current && current.getVisibility() == View.VISIBLE) return;
if (current != null) current.setVisibility(View.GONE);
if (view != null) view.setVisibility(View.VISIBLE);
if (view != null) {
view.setVisibility(View.VISIBLE);
view.clearAnimation();
}
current = view;
}
}

View File

@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Canvas;
import android.graphics.Typeface;
import android.os.Build;
import android.os.Bundle;
import android.text.Annotation;
@@ -15,11 +14,7 @@ import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.TextUtils.TruncateAt;
import android.text.style.CharacterStyle;
import android.text.style.RelativeSizeSpan;
import android.text.style.StrikethroughSpan;
import android.text.style.StyleSpan;
import android.text.style.TypefaceSpan;
import android.util.AttributeSet;
import android.view.ActionMode;
import android.view.Menu;
@@ -42,6 +37,7 @@ import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.mention.MentionDeleter;
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
import org.thoughtcrime.securesms.components.mention.MentionValidatorWatcher;
import org.thoughtcrime.securesms.components.spoiler.SpoilerRendererDelegate;
import org.thoughtcrime.securesms.conversation.MessageSendType;
import org.thoughtcrime.securesms.conversation.MessageStyler;
import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQuery;
@@ -63,26 +59,19 @@ import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER;
public class ComposeText extends EmojiEditText {
private static final char EMOJI_STARTER = ':';
private static final long EMOJI_KEYWORD_DELAY = 1500;
private static final Pattern TIME_PATTERN = Pattern.compile("^[0-9]{1,2}:[0-9]{1,2}$");
private CharSequence hint;
private SpannableString subHint;
private MentionRendererDelegate mentionRendererDelegate;
private SpoilerRendererDelegate spoilerRendererDelegate;
private MentionValidatorWatcher mentionValidatorWatcher;
@Nullable private InputPanel.MediaListener mediaListener;
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
@Nullable private InlineQueryChangedListener inlineQueryChangedListener;
private final Runnable keywordSearchRunnable = () -> {
Editable text = getText();
if (text != null && enoughToFilter(text, true)) {
performFiltering(text, true);
}
};
public ComposeText(Context context) {
super(context);
initialize();
@@ -164,6 +153,9 @@ public class ComposeText extends EmojiEditText {
try {
mentionRendererDelegate.draw(canvas, getText(), getLayout());
if (spoilerRendererDelegate != null) {
spoilerRendererDelegate.draw(canvas, getText(), getLayout());
}
} finally {
canvas.restoreToCount(checkpoint);
}
@@ -310,6 +302,12 @@ public class ComposeText extends EmojiEditText {
addTextChangedListener(mentionValidatorWatcher);
if (FeatureFlags.textFormatting()) {
if (FeatureFlags.textFormattingSpoilerSend()) {
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
}
addTextChangedListener(new ComposeTextStyleWatcher());
setCustomSelectionActionModeCallback(new ActionMode.Callback() {
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
@@ -326,6 +324,10 @@ public class ComposeText extends EmojiEditText {
menu.add(0, R.id.edittext_strikethrough, largestOrder, getContext().getString(R.string.TextFormatting_strikethrough));
menu.add(0, R.id.edittext_monospace, largestOrder, getContext().getString(R.string.TextFormatting_monospace));
if (FeatureFlags.textFormattingSpoilerSend()) {
menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler));
}
return true;
}
@@ -340,7 +342,8 @@ public class ComposeText extends EmojiEditText {
if (item.getItemId() != R.id.edittext_bold &&
item.getItemId() != R.id.edittext_italic &&
item.getItemId() != R.id.edittext_strikethrough &&
item.getItemId() != R.id.edittext_monospace) {
item.getItemId() != R.id.edittext_monospace &&
item.getItemId() != R.id.edittext_spoiler) {
return false;
}
@@ -349,7 +352,7 @@ public class ComposeText extends EmojiEditText {
CharSequence charSequence = text.subSequence(start, end);
SpannableString replacement = new SpannableString(charSequence);
CharacterStyle style = null;
Object style = null;
if (item.getItemId() == R.id.edittext_bold) {
style = MessageStyler.boldStyle();
@@ -359,10 +362,12 @@ public class ComposeText extends EmojiEditText {
style = MessageStyler.strikethroughStyle();
} else if (item.getItemId() == R.id.edittext_monospace) {
style = MessageStyler.monoStyle();
} else if (item.getItemId() == R.id.edittext_spoiler) {
style = MessageStyler.spoilerStyle(MessageStyler.COMPOSE_ID, start, charSequence.length());
}
if (style != null) {
replacement.setSpan(style, 0, charSequence.length(), Spannable.SPAN_EXCLUSIVE_INCLUSIVE);
replacement.setSpan(style, 0, charSequence.length(), MessageStyler.SPAN_FLAGS);
}
clearComposingText();
@@ -532,6 +537,11 @@ public class ComposeText extends EmojiEditText {
return -1;
}
@Override
protected boolean shouldPersistSignalStylingWhenPasting() {
return true;
}
/**
* Return true if we think the user may be inputting a time.
*/

View File

@@ -0,0 +1,92 @@
package org.thoughtcrime.securesms.components
import android.text.Annotation
import android.text.Editable
import android.text.Spannable
import android.text.Spanned
import android.text.TextUtils
import android.text.TextWatcher
import org.signal.core.util.StringUtil
import org.thoughtcrime.securesms.conversation.MessageStyler
import org.thoughtcrime.securesms.conversation.MessageStyler.isSupportedStyle
/**
* Formatting should only grow when appending until a white space character is entered/pasted.
*
* This watcher observes changes to the text and will shrink supported style ranges as necessary
* to provide the desired behavior.
*/
class ComposeTextStyleWatcher : TextWatcher {
private val markerAnnotation = Annotation("text-formatting", "marker")
private var textSnapshotPriorToChange: CharSequence? = null
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
if (s is Spannable) {
s.removeSpan(markerAnnotation)
}
textSnapshotPriorToChange = s.subSequence(start, start + count)
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
if (s is Spannable) {
s.removeSpan(markerAnnotation)
if (count > 0) {
s.setSpan(markerAnnotation, start, start + count, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
}
override fun afterTextChanged(s: Editable) {
val editStart = s.getSpanStart(markerAnnotation)
val editEnd = s.getSpanEnd(markerAnnotation)
s.removeSpan(markerAnnotation)
try {
if (editStart < 0 || editEnd < 0 || editStart >= editEnd || (editStart == 0 && editEnd == s.length)) {
return
}
val change = s.subSequence(editStart, editEnd)
if (change.isEmpty() || textSnapshotPriorToChange == null || (editEnd - editStart == 1 && !StringUtil.isVisuallyEmpty(change[0])) || TextUtils.equals(textSnapshotPriorToChange, change)) {
textSnapshotPriorToChange = null
return
}
textSnapshotPriorToChange = null
var newEnd = editStart
for (i in change.indices) {
if (StringUtil.isVisuallyEmpty(change[i])) {
newEnd = editStart + i
break
}
}
s.getSpans(editStart, editEnd, Object::class.java)
.filter { it.isSupportedStyle() }
.forEach { style ->
val styleStart = s.getSpanStart(style)
val styleEnd = s.getSpanEnd(style)
if (styleEnd == editEnd && styleStart < styleEnd) {
s.removeSpan(style)
s.setSpan(style, styleStart, newEnd, MessageStyler.SPAN_FLAGS)
} else if (styleStart >= styleEnd) {
s.removeSpan(style)
}
}
} finally {
s.getSpans(editStart, editEnd, Object::class.java)
.filter { it.isSupportedStyle() }
.forEach { style ->
val styleStart = s.getSpanStart(style)
val styleEnd = s.getSpanEnd(style)
if (styleEnd == styleStart || styleStart > styleEnd) {
s.removeSpan(style)
}
}
}
}
}

View File

@@ -22,6 +22,7 @@ class NumericKeyboardView @JvmOverloads constructor(
var listener: Listener? = null
init {
layoutDirection = LAYOUT_DIRECTION_LTR
inflate(context, R.layout.numeric_keyboard_view, this)
findViewById<TextView>(R.id.numeric_keyboard_1).setOnClickListener {

View File

@@ -5,6 +5,7 @@ import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.os.Build;
import android.text.Spannable;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
@@ -27,8 +28,10 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.quotes.QuoteViewColorTheme;
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
import org.thoughtcrime.securesms.conversation.MessageStyler;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
@@ -82,7 +85,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
private ViewGroup mainView;
private ViewGroup footerView;
private TextView authorView;
private TextView bodyView;
private EmojiTextView bodyView;
private View quoteBarView;
private ShapeableImageView thumbnailView;
private View attachmentVideoOverlayView;
@@ -163,6 +166,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
setMessageType(messageType);
bodyView.enableSpoilerFiltering();
dismissView.setOnClickListener(view -> setVisibility(GONE));
}

View File

@@ -62,12 +62,12 @@ public class TypingStatusRepository {
ThreadUtil.cancelRunnableOnMain(timer);
}
timer = () -> onTypingStopped(context, threadId, author, device, false);
timer = () -> onTypingStopped(threadId, author, device, false);
ThreadUtil.runOnMainDelayed(timer, RECIPIENT_TYPING_TIMEOUT);
timers.put(typist, timer);
}
public synchronized void onTypingStopped(@NonNull Context context, long threadId, @NonNull Recipient author, int device, boolean isReplacedByIncomingMessage) {
public synchronized void onTypingStopped(long threadId, @NonNull Recipient author, int device, boolean isReplacedByIncomingMessage) {
if (author.isSelf()) {
return;
}

View File

@@ -1,9 +1,13 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.InputFilter;
import android.text.TextUtils;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
@@ -14,7 +18,9 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.EditTextExtensionsKt;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import java.util.HashSet;
import java.util.Set;
@@ -35,9 +41,9 @@ public class EmojiEditText extends AppCompatEditText {
public EmojiEditText(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
boolean forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
boolean jumboEmoji = a.getBoolean(R.styleable.EmojiTextView_emoji_forceJumbo, false);
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
boolean forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
boolean jumboEmoji = a.getBoolean(R.styleable.EmojiTextView_emoji_forceJumbo, false);
a.recycle();
if (!isInEditMode() && (forceCustom || !SignalStore.settings().isPreferSystemEmoji())) {
@@ -57,8 +63,8 @@ public class EmojiEditText extends AppCompatEditText {
}
public void insertEmoji(String emoji) {
final int start = getSelectionStart();
final int end = getSelectionEnd();
final int start = getSelectionStart();
final int end = getSelectionEnd();
getText().replace(Math.min(start, end), Math.max(start, end), emoji);
setSelection(start + emoji.length());
@@ -66,8 +72,11 @@ public class EmojiEditText extends AppCompatEditText {
@Override
public void invalidateDrawable(@NonNull Drawable drawable) {
if (drawable instanceof EmojiDrawable) invalidate();
else super.invalidateDrawable(drawable);
if (drawable instanceof EmojiDrawable) {
invalidate();
} else {
super.invalidateDrawable(drawable);
}
}
@Override
@@ -95,4 +104,50 @@ public class EmojiEditText extends AppCompatEditText {
return result;
}
@Override
public boolean onTextContextMenuItem(int id) {
if (id == android.R.id.paste) {
ClipData clipData = ServiceUtil.getClipboardManager(getContext()).getPrimaryClip();
if (clipData != null) {
CharSequence label = clipData.getDescription().getLabel();
CharSequence pendingPaste = getTextFromClipData(clipData);
if (TextUtils.equals(Util.COPY_LABEL, label) && shouldPersistSignalStylingWhenPasting()) {
return super.onTextContextMenuItem(id);
} else if (Build.VERSION.SDK_INT >= 23) {
return super.onTextContextMenuItem(android.R.id.pasteAsPlainText);
} else if (pendingPaste != null) {
Util.copyToClipboard(getContext(), pendingPaste.toString());
return super.onTextContextMenuItem(id);
}
}
} else if (id == android.R.id.copy || id == android.R.id.cut) {
boolean originalResult = super.onTextContextMenuItem(id);
ClipboardManager clipboardManager = ServiceUtil.getClipboardManager(getContext());
CharSequence clipText = getTextFromClipData(clipboardManager.getPrimaryClip());
if (clipText != null) {
Util.copyToClipboard(getContext(), clipText);
return true;
}
return originalResult;
}
return super.onTextContextMenuItem(id);
}
private @Nullable CharSequence getTextFromClipData(@Nullable ClipData data) {
if (data != null && data.getItemCount() > 0) {
return data.getItemAt(0).coerceToText(getContext());
} else {
return null;
}
}
protected boolean shouldPersistSignalStylingWhenPasting() {
return false;
}
}

View File

@@ -10,4 +10,5 @@ public final class EmojiStrings {
public static final String STICKER = "\u2B50";
public static final String GIFT = "\uD83C\uDF81";
public static final String CARD = "\uD83D\uDCB3";
public static final String FAILED_STORY = "\u2757";
}

View File

@@ -7,6 +7,8 @@ import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.Annotation;
import android.text.Layout;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextDirectionHeuristic;
@@ -31,8 +33,10 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
import org.thoughtcrime.securesms.components.spoiler.SpoilerRendererDelegate;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable;
import org.thoughtcrime.securesms.util.Util;
import java.util.Arrays;
@@ -65,8 +69,11 @@ public class EmojiTextView extends AppCompatTextView {
private TextDirectionHeuristic textDirection;
private boolean isJumbomoji;
private boolean forceJumboEmoji;
private boolean isInOnDraw;
private MentionRendererDelegate mentionRendererDelegate;
private MentionRendererDelegate mentionRendererDelegate;
private final SpoilerRendererDelegate spoilerRendererDelegate;
private SpoilerFilteringSpannableFactory spoilerFilteringSpannableFactory;
public EmojiTextView(Context context) {
this(context, null);
@@ -88,31 +95,56 @@ public class EmojiTextView extends AppCompatTextView {
forceJumboEmoji = a.getBoolean(R.styleable.EmojiTextView_emoji_forceJumbo, false);
a.recycle();
a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize});
a = context.obtainStyledAttributes(attrs, new int[] { android.R.attr.textSize });
originalFontSize = a.getDimensionPixelSize(0, 0);
a.recycle();
if (renderMentions) {
mentionRendererDelegate = new MentionRendererDelegate(getContext(), ContextCompat.getColor(getContext(), R.color.transparent_black_20));
}
spoilerRendererDelegate = new SpoilerRendererDelegate(this);
textDirection = getLayoutDirection() == LAYOUT_DIRECTION_LTR ? TextDirectionHeuristics.FIRSTSTRONG_RTL : TextDirectionHeuristics.ANYRTL_LTR;
setEmojiCompatEnabled(useSystemEmoji());
}
public void enableSpoilerFiltering() {
spoilerFilteringSpannableFactory = new SpoilerFilteringSpannableFactory();
setSpannableFactory(spoilerFilteringSpannableFactory);
}
@Override
protected void onDraw(Canvas canvas) {
if (renderMentions && getText() instanceof Spanned && getLayout() != null) {
int checkpoint = canvas.save();
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
try {
mentionRendererDelegate.draw(canvas, (Spanned) getText(), getLayout());
} finally {
canvas.restoreToCount(checkpoint);
}
isInOnDraw = true;
boolean hasSpannedText = getText() instanceof Spanned;
boolean hasLayout = getLayout() != null;
if (hasSpannedText && hasLayout) {
drawSpecialRenderers(canvas, mentionRendererDelegate, spoilerRendererDelegate);
}
super.onDraw(canvas);
if (hasSpannedText && !hasLayout && getLayout() != null) {
drawSpecialRenderers(canvas, null, spoilerRendererDelegate);
}
isInOnDraw = false;
}
private void drawSpecialRenderers(@NonNull Canvas canvas, @Nullable MentionRendererDelegate mentionDelegate, @NonNull SpoilerRendererDelegate spoilerDelegate) {
int checkpoint = canvas.save();
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
try {
if (mentionDelegate != null) {
mentionDelegate.draw(canvas, (Spanned) getText(), getLayout());
}
spoilerDelegate.draw(canvas, (Spanned) getText(), getLayout());
} finally {
canvas.restoreToCount(checkpoint);
}
}
@Override
@@ -144,13 +176,18 @@ public class EmojiTextView extends AppCompatTextView {
useSystemEmoji = useSystemEmoji();
previousTransformationMethod = getTransformationMethod();
Spannable textToSet;
if (useSystemEmoji || candidates == null || candidates.size() == 0) {
super.setText(new SpannableStringBuilder(Optional.ofNullable(text).orElse("")), BufferType.SPANNABLE);
textToSet = new SpannableStringBuilder(Optional.ofNullable(text).orElse(""));
} else {
CharSequence emojified = EmojiProvider.emojify(candidates, text, this, isJumbomoji || forceJumboEmoji);
super.setText(new SpannableStringBuilder(emojified), BufferType.SPANNABLE);
textToSet = new SpannableStringBuilder(EmojiProvider.emojify(candidates, text, this, isJumbomoji || forceJumboEmoji));
}
if (spoilerFilteringSpannableFactory != null) {
textToSet = spoilerFilteringSpannableFactory.wrap(textToSet);
}
super.setText(textToSet, BufferType.SPANNABLE);
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
// We ellipsize them ourselves by manually truncating the appropriate section.
if (getText() != null && getText().length() > 0 && isEllipsizedAtEnd()) {
@@ -192,7 +229,8 @@ public class EmojiTextView extends AppCompatTextView {
int start = layout.getLineStart(lines - 1);
if ((getLayoutDirection() == LAYOUT_DIRECTION_LTR && textDirection.isRtl(text, 0, text.length())) ||
(getLayoutDirection() == LAYOUT_DIRECTION_RTL && !textDirection.isRtl(text, 0, text.length()))) {
(getLayoutDirection() == LAYOUT_DIRECTION_RTL && !textDirection.isRtl(text, 0, text.length())))
{
lastLineWidth = getMeasuredWidth();
} else {
lastLineWidth = (int) getPaint().measureText(text, start, text.length());
@@ -278,12 +316,19 @@ public class EmojiTextView extends AppCompatTextView {
if (maxLength > 0 && getText().length() > maxLength + 1) {
SpannableStringBuilder newContent = new SpannableStringBuilder();
CharSequence shortenedText = getText().subSequence(0, maxLength);
if (shortenedText instanceof Spanned) {
Spanned spanned = (Spanned) shortenedText;
List<Annotation> mentionAnnotations = MentionAnnotation.getMentionAnnotations(spanned, maxLength - 1, maxLength);
if (!mentionAnnotations.isEmpty()) {
shortenedText = shortenedText.subSequence(0, spanned.getSpanStart(mentionAnnotations.get(0)));
SpannableString shortenedText = new SpannableString(getText().subSequence(0, maxLength));
List<Annotation> mentionAnnotations = MentionAnnotation.getMentionAnnotations(shortenedText, maxLength - 1, maxLength);
if (!mentionAnnotations.isEmpty()) {
shortenedText = new SpannableString(shortenedText.subSequence(0, shortenedText.getSpanStart(mentionAnnotations.get(0))));
}
Object[] endSpans = shortenedText.getSpans(shortenedText.length() - 1, shortenedText.length(), Object.class);
for (Object span : endSpans) {
if (shortenedText.getSpanFlags(span) == Spanned.SPAN_EXCLUSIVE_INCLUSIVE) {
int start = shortenedText.getSpanStart(span);
int end = shortenedText.getSpanEnd(span);
shortenedText.removeSpan(span);
shortenedText.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
@@ -293,12 +338,18 @@ public class EmojiTextView extends AppCompatTextView {
EmojiParser.CandidateList newCandidates = isInEditMode() ? null : EmojiProvider.getCandidates(newContent);
Spannable newTextToSet;
if (useSystemEmoji || newCandidates == null || newCandidates.size() == 0) {
super.setText(newContent, BufferType.SPANNABLE);
newTextToSet = newContent;
} else {
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
super.setText(emojified, BufferType.SPANNABLE);
newTextToSet = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
}
if (spoilerFilteringSpannableFactory != null) {
spoilerFilteringSpannableFactory.wrap(newTextToSet);
}
super.setText(newContent, BufferType.SPANNABLE);
}
}
@@ -318,10 +369,10 @@ public class EmojiTextView extends AppCompatTextView {
return;
}
int overflowEnd = getLayout().getLineEnd(maxLines - 1);
CharSequence overflow = getText().subSequence(overflowStart, overflowEnd);
float adjust = overflowText != null ? getPaint().measureText(overflowText, 0, overflowText.length()) : 0f;
CharSequence ellipsized = StringUtil.trim(TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END));
int overflowEnd = getLayout().getLineEnd(maxLines - 1);
CharSequence overflow = getText().subSequence(overflowStart, overflowEnd);
float adjust = overflowText != null ? getPaint().measureText(overflowText, 0, overflowText.length()) : 0f;
CharSequence ellipsized = StringUtil.trim(TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END));
SpannableStringBuilder newContent = new SpannableStringBuilder();
newContent.append(getText().subSequence(0, overflowStart))
@@ -352,16 +403,16 @@ public class EmojiTextView extends AppCompatTextView {
}
private boolean unchanged(CharSequence text, CharSequence overflowText, BufferType bufferType) {
return Util.equals(previousText, text) &&
return Util.equals(previousText, text) &&
Util.equals(previousOverflowText, overflowText) &&
Util.equals(previousBufferType, bufferType) &&
useSystemEmoji == useSystemEmoji() &&
!sizeChangeInProgress &&
Util.equals(previousBufferType, bufferType) &&
useSystemEmoji == useSystemEmoji() &&
!sizeChangeInProgress &&
previousTransformationMethod == getTransformationMethod();
}
private boolean useSystemEmoji() {
return isInEditMode() || (!forceCustom && SignalStore.settings().isPreferSystemEmoji());
return isInEditMode() || (!forceCustom && SignalStore.settings().isPreferSystemEmoji());
}
@Override
@@ -378,7 +429,13 @@ public class EmojiTextView extends AppCompatTextView {
@Override
public void invalidateDrawable(@NonNull Drawable drawable) {
if (drawable instanceof EmojiProvider.EmojiDrawable) invalidate();
else super.invalidateDrawable(drawable);
else super.invalidateDrawable(drawable);
}
@Override
public void setTextColor(int color) {
super.setTextColor(color);
spoilerRendererDelegate.updateFromTextColor();
}
@Override
@@ -397,4 +454,15 @@ public class EmojiTextView extends AppCompatTextView {
mentionRendererDelegate.setTint(mentionBackgroundTint);
}
}
private class SpoilerFilteringSpannableFactory extends Spannable.Factory {
@Override
public @NonNull Spannable newSpannable(CharSequence source) {
return wrap(super.newSpannable(source));
}
@NonNull SpoilerFilteringSpannable wrap(Spannable source) {
return new SpoilerFilteringSpannable(source, () -> isInOnDraw);
}
}
}

View File

@@ -1,9 +1,12 @@
package org.thoughtcrime.securesms.components.emoji
import android.content.Context
import android.graphics.Canvas
import android.text.Spanned
import android.text.TextUtils
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import org.thoughtcrime.securesms.components.spoiler.SpoilerRendererDelegate
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.ThrottledDebouncer
import java.util.Optional
@@ -16,9 +19,24 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
private var bufferType: BufferType? = null
private val sizeChangeDebouncer: ThrottledDebouncer = ThrottledDebouncer(200)
private val spoilerRendererDelegate: SpoilerRendererDelegate
init {
isEmojiCompatEnabled = isInEditMode || SignalStore.settings().isPreferSystemEmoji
spoilerRendererDelegate = SpoilerRendererDelegate(this)
}
override fun onDraw(canvas: Canvas) {
if (text is Spanned && layout != null) {
val checkpoint = canvas.save()
canvas.translate(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat())
try {
spoilerRendererDelegate.draw(canvas, (text as Spanned), layout)
} finally {
canvas.restoreToCount(checkpoint)
}
}
super.onDraw(canvas)
}
override fun setText(text: CharSequence?, type: BufferType?) {

View File

@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components.settings
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.annotation.Discouraged
import androidx.navigation.NavController
import androidx.navigation.Navigation
import androidx.navigation.fragment.NavHostFragment
@@ -11,7 +10,10 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
@Discouraged("The DSL API can be completely replaced by compose. See ComposeFragment or ComposeBottomSheetFragment for an alternative to this API")
/**
* The DSL API can be completely replaced by compose.
* See ComposeFragment or ComposeBottomSheetFragment for an alternative to this API"
*/
open class DSLSettingsActivity : PassphraseRequiredActivity() {
protected open val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme()

View File

@@ -13,7 +13,7 @@ import androidx.annotation.CallSuper
import androidx.annotation.Discouraged
import androidx.core.content.ContextCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.switchmaterial.SwitchMaterial
import com.google.android.material.materialswitch.MaterialSwitch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.models.AsyncSwitch
@@ -205,12 +205,19 @@ class MultiSelectListPreferenceViewHolder(itemView: View) : PreferenceViewHolder
class SwitchPreferenceViewHolder(itemView: View) : PreferenceViewHolder<SwitchPreference>(itemView) {
private val switchWidget: SwitchMaterial = itemView.findViewById(R.id.switch_widget)
private val switchWidget: MaterialSwitch = itemView.findViewById(R.id.switch_widget)
override fun bind(model: SwitchPreference) {
super.bind(model)
switchWidget.setOnCheckedChangeListener(null)
switchWidget.isEnabled = model.isEnabled
switchWidget.isChecked = model.isChecked
switchWidget.setOnCheckedChangeListener { _, _ ->
model.onClick()
}
itemView.setOnClickListener {
model.onClick()
}

View File

@@ -6,7 +6,6 @@ import android.os.Bundle
import android.view.View
import android.widget.EdgeEffect
import androidx.annotation.CallSuper
import androidx.annotation.Discouraged
import androidx.annotation.LayoutRes
import androidx.annotation.MenuRes
import androidx.annotation.StringRes
@@ -21,7 +20,10 @@ import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import java.lang.UnsupportedOperationException
@Discouraged("The DSL API can be completely replaced by compose. See ComposeFragment or ComposeBottomSheetFragment for an alternative to this API")
/**
* The DSL API can be completely replaced by compose.
* See ComposeFragment or ComposeBottomSheetFragment for an alternative to this API
*/
abstract class DSLSettingsFragment(
@StringRes private val titleId: Int = -1,
@MenuRes private val menuId: Int = -1,

View File

@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity
import org.thoughtcrime.securesms.lock.v2.KbsConstants
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.pin.RegistrationLockV2Dialog
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -121,6 +122,15 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
}
)
if (FeatureFlags.exportAccountData()) {
clickPref(
title = DSLSettingsText.from(R.string.AccountSettingsFragment__request_account_data),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_exportAccountFragment)
}
)
}
clickPref(
title = DSLSettingsText.from(R.string.preferences__delete_account, ContextCompat.getColor(requireContext(), R.color.signal_alert_primary)),
onClick = {

View File

@@ -0,0 +1,235 @@
package org.thoughtcrime.securesms.components.settings.app.account.export
import android.os.Bundle
import android.view.View
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.Center
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.app.ShareCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Rows
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.Texts
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.SpanUtil
class ExportAccountDataFragment : ComposeFragment() {
companion object {
val TAG = Log.tag(ExportAccountDataFragment::class.java)
}
private val viewModel: ExportAccountDataViewModel by viewModels()
private val disposables = LifecycleDisposable()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
disposables.bindTo(viewLifecycleOwner)
}
private fun exportReport() {
disposables += viewModel.onGenerateReport()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { report ->
ShareCompat.IntentBuilder(requireContext())
.setStream(report.uri)
.setType(report.mimeType)
.startChooser()
}
}
private fun dismissExportDialog() {
viewModel.dismissExportConfirmationDialog()
}
private fun dismissDownloadErrorDialog() {
viewModel.dismissDownloadErrorDialog()
}
@Preview
@Composable
override fun FragmentContent() {
val state: ExportAccountDataState by viewModel.state
val onNavigationClick: () -> Unit = remember {
{ findNavController().popBackStack() }
}
Scaffolds.Settings(
title = stringResource(id = R.string.AccountSettingsFragment__request_account_data),
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { contentPadding ->
Surface(
modifier = Modifier
.padding(contentPadding)
.wrapContentSize()
) {
LazyColumn(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
item {
Image(
painter = painterResource(id = R.drawable.export_account_data),
contentDescription = stringResource(R.string.ExportAccountDataFragment__your_account_data),
modifier = Modifier.padding(top = 47.dp)
)
}
item {
Text(
text = stringResource(id = R.string.ExportAccountDataFragment__your_account_data),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 16.dp)
)
}
item {
val learnMore = stringResource(R.string.ExportAccountDataFragment__learn_more)
val explanation = stringResource(R.string.ExportAccountDataFragment__export_explanation, learnMore)
Texts.LinkifiedText(
textWithUrlSpans = SpanUtil.urlSubsequence(explanation, learnMore, stringResource(R.string.export_account_data_url)),
onUrlClick = { url ->
CommunicationActions.openBrowserLink(requireContext(), url)
},
modifier = Modifier.padding(top = 12.dp, start = 32.dp, end = 32.dp, bottom = 20.dp),
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center)
)
}
item {
ExportReportOptions(exportAsJson = state.exportAsJson)
}
}
if (state.downloadInProgress) {
DownloadProgressDialog()
} else if (state.showDownloadFailedDialog) {
DownloadFailedDialog()
} else if (state.showExportDialog) {
ExportReportConfirmationDialog()
}
}
}
}
@Composable
private fun DownloadProgressDialog() {
Dialog(
onDismissRequest = {},
DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
) {
Card {
Box(contentAlignment = Alignment.Center) {
Column(
verticalArrangement = Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(
modifier = Modifier
.padding(top = 50.dp, bottom = 18.dp)
.size(42.dp)
)
Text(text = stringResource(R.string.ExportAccountDataFragment__download_progress), Modifier.padding(bottom = 48.dp, start = 35.dp, end = 35.dp))
}
}
}
}
}
@Composable
private fun DownloadFailedDialog() {
Dialogs.SimpleMessageDialog(
message = stringResource(id = R.string.ExportAccountDataFragment__check_network),
dismiss = stringResource(id = R.string.ExportAccountDataFragment__ok_action),
title = stringResource(id = R.string.ExportAccountDataFragment__report_generation_failed),
onDismiss = this::dismissDownloadErrorDialog
)
}
@Composable
private fun ExportReportConfirmationDialog() {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.ExportAccountDataFragment__export_report_confirmation),
body = stringResource(R.string.ExportAccountDataFragment__export_report_confirmation_message),
confirm = stringResource(R.string.ExportAccountDataFragment__export_report_action),
dismiss = stringResource(R.string.ExportAccountDataFragment__cancel_action),
onConfirm = this::exportReport,
onDismiss = this::dismissExportDialog
)
}
@Composable
private fun ExportReportOptions(exportAsJson: Boolean) {
Rows.RadioRow(
selected = !exportAsJson,
text = stringResource(id = R.string.ExportAccountDataFragment__export_as_txt),
label = stringResource(id = R.string.ExportAccountDataFragment__export_as_txt_label),
modifier = Modifier
.clickable(onClick = viewModel::setExportAsTxt)
.padding(horizontal = 16.dp)
)
Rows.RadioRow(
selected = exportAsJson,
text = stringResource(id = R.string.ExportAccountDataFragment__export_as_json),
label = stringResource(id = R.string.ExportAccountDataFragment__export_as_json_label),
modifier = Modifier
.clickable(onClick = viewModel::setExportAsJson)
.padding(horizontal = 16.dp)
)
Buttons.LargeTonal(
onClick = viewModel::showExportConfirmationDialog,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp, start = 32.dp, end = 32.dp)
) {
Text(
text = stringResource(R.string.ExportAccountDataFragment__export_report),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Text(
text = stringResource(id = R.string.ExportAccountDataFragment__report_not_stored_disclaimer),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Start,
modifier = Modifier.padding(top = 16.dp, start = 24.dp, end = 28.dp, bottom = 20.dp)
)
}
}

View File

@@ -0,0 +1,57 @@
package org.thoughtcrime.securesms.components.settings.app.account.export
import android.net.Uri
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.node.ObjectNode
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.util.JsonUtils
import org.whispersystems.signalservice.api.SignalServiceAccountManager
import java.io.IOException
class ExportAccountDataRepository(
private val accountManager: SignalServiceAccountManager = ApplicationDependencies.getSignalServiceAccountManager()
) {
fun downloadAccountDataReport(exportAsJson: Boolean): Single<ExportedReport> {
return Single.create {
try {
it.onSuccess(generateAccountDataReport(accountManager.accountDataReport, exportAsJson))
} catch (e: IOException) {
it.onError(e)
}
}.subscribeOn(Schedulers.io())
}
private fun generateAccountDataReport(report: String, exportAsJson: Boolean): ExportedReport {
val mimeType: String
val fileName: String
if (exportAsJson) {
mimeType = "application/json"
fileName = "account-data.json"
} else {
mimeType = "text/plain"
fileName = "account-data.txt"
}
val tree: JsonNode = JsonUtils.getMapper().readTree(report)
val dataStr = if (exportAsJson) {
(tree as ObjectNode).remove("text")
tree.toString()
} else {
tree["text"].asText()
}
val uri = BlobProvider.getInstance()
.forData(dataStr.encodeToByteArray())
.withMimeType(mimeType)
.withFileName(fileName)
.createForSingleUseInMemory()
return ExportedReport(mimeType = mimeType, uri = uri)
}
data class ExportedReport(val mimeType: String, val uri: Uri)
}

View File

@@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.components.settings.app.account.export
data class ExportAccountDataState(
val downloadInProgress: Boolean,
val exportAsJson: Boolean,
val showDownloadFailedDialog: Boolean = false,
val showExportDialog: Boolean = false
)

View File

@@ -0,0 +1,68 @@
package org.thoughtcrime.securesms.components.settings.app.account.export
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.subjects.MaybeSubject
import org.signal.core.util.logging.Log
class ExportAccountDataViewModel(
private val repository: ExportAccountDataRepository = ExportAccountDataRepository()
) : ViewModel() {
companion object {
private val TAG = Log.tag(ExportAccountDataViewModel::class.java)
}
private val disposables = CompositeDisposable()
private val _state = mutableStateOf(
ExportAccountDataState(downloadInProgress = false, exportAsJson = false)
)
val state: State<ExportAccountDataState> = _state
fun onGenerateReport(): Maybe<ExportAccountDataRepository.ExportedReport> {
_state.value = _state.value.copy(downloadInProgress = true)
val maybe = MaybeSubject.create<ExportAccountDataRepository.ExportedReport>()
disposables += repository.downloadAccountDataReport(state.value.exportAsJson)
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ report ->
_state.value = _state.value.copy(downloadInProgress = false)
maybe.onSuccess(report)
}, { throwable ->
Log.e(TAG, throwable)
_state.value = _state.value.copy(downloadInProgress = false, showDownloadFailedDialog = true)
maybe.onComplete()
})
return maybe
}
fun setExportAsJson() {
_state.value = _state.value.copy(exportAsJson = true)
}
fun setExportAsTxt() {
_state.value = _state.value.copy(exportAsJson = false)
}
fun dismissDownloadErrorDialog() {
_state.value = _state.value.copy(showDownloadFailedDialog = false)
}
fun showExportConfirmationDialog() {
_state.value = _state.value.copy(showExportDialog = true)
}
fun dismissExportConfirmationDialog() {
_state.value = _state.value.copy(showExportDialog = false)
}
override fun onCleared() {
disposables.dispose()
}
}

View File

@@ -18,7 +18,7 @@ class SmsSettingsRepository(
@WorkerThread
private fun checkInsecureMessageCount(): SmsExportState? {
val totalSmsMmsCount = smsDatabase.insecureMessageCount + mmsDatabase.insecureMessageCount
val totalSmsMmsCount = smsDatabase.getInsecureMessageCount() + mmsDatabase.getInsecureMessageCount()
return if (totalSmsMmsCount == 0) {
SmsExportState.NO_SMS_MESSAGES_IN_DATABASE
@@ -29,7 +29,7 @@ class SmsSettingsRepository(
@WorkerThread
private fun checkUnexportedInsecureMessageCount(): SmsExportState {
val totalUnexportedCount = smsDatabase.unexportedInsecureMessagesCount + mmsDatabase.unexportedInsecureMessagesCount
val totalUnexportedCount = smsDatabase.getUnexportedInsecureMessagesCount() + mmsDatabase.getUnexportedInsecureMessagesCount()
return if (totalUnexportedCount > 0) {
SmsExportState.HAS_UNEXPORTED_MESSAGES

View File

@@ -15,7 +15,7 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.switchmaterial.SwitchMaterial
import com.google.android.material.materialswitch.MaterialSwitch
import com.google.android.material.timepicker.MaterialTimePicker
import com.google.android.material.timepicker.TimeFormat
import io.reactivex.rxjava3.kotlin.subscribeBy
@@ -69,7 +69,7 @@ class EditNotificationProfileScheduleFragment : LoggingFragment(R.layout.fragmen
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
val enableToggle: SwitchMaterial = view.findViewById(R.id.edit_notification_profile_schedule_switch)
val enableToggle: MaterialSwitch = view.findViewById(R.id.edit_notification_profile_schedule_switch)
enableToggle.setOnClickListener { viewModel.setEnabled(enableToggle.isChecked) }
val startTime: TextView = view.findViewById(R.id.edit_notification_profile_schedule_start_time)

View File

@@ -113,7 +113,7 @@ class SelectRecipientsFragment : LoggingFragment(), ContactSelectionListFragment
return mode or ContactSelectionDisplayMode.FLAG_HIDE_GROUPS_V1
}
override fun onBeforeContactSelected(recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) {
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) {
if (recipientId.isPresent) {
viewModel.select(recipientId.get())
callback.accept(true)

View File

@@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.notifications.profile
import android.view.View
import com.airbnb.lottie.SimpleColorFilter
import com.google.android.material.switchmaterial.SwitchMaterial
import com.google.android.material.materialswitch.MaterialSwitch
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
@@ -34,15 +34,17 @@ object NotificationProfilePreference {
private class ViewHolder(itemView: View) : PreferenceViewHolder<Model>(itemView) {
private val switchWidget: SwitchMaterial = itemView.findViewById(R.id.switch_widget)
private val switchWidget: MaterialSwitch = itemView.findViewById(R.id.switch_widget)
override fun bind(model: Model) {
super.bind(model)
itemView.setOnClickListener { model.onClick() }
switchWidget.setOnCheckedChangeListener(null)
switchWidget.visible = model.showSwitch
switchWidget.isEnabled = model.isEnabled
switchWidget.isChecked = model.isOn
iconView.background.colorFilter = SimpleColorFilter(model.color.colorInt())
switchWidget.setOnCheckedChangeListener { _, _ -> model.onClick() }
}
}
}

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.privacy.pnp
import android.os.Bundle
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
@@ -10,6 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -22,15 +24,27 @@ import org.signal.core.ui.Scaffolds
import org.signal.core.ui.Texts
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.StatusBarColorNestedScrollConnection
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberListingMode
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberSharingMode
class PhoneNumberPrivacySettingsFragment : ComposeFragment() {
private val viewModel: PhoneNumberPrivacySettingsViewModel by viewModels()
private lateinit var statusBarNestedScrollConnection: StatusBarColorNestedScrollConnection
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
statusBarNestedScrollConnection = StatusBarColorNestedScrollConnection(requireActivity())
}
override fun onResume() {
super.onResume()
statusBarNestedScrollConnection.setColorImmediate()
}
@Composable
override fun SheetContent() {
override fun FragmentContent() {
val state: PhoneNumberPrivacySettingsState by viewModel.state
val onNavigationClick: () -> Unit = remember {
{ findNavController().popBackStack() }
@@ -40,7 +54,8 @@ class PhoneNumberPrivacySettingsFragment : ComposeFragment() {
title = stringResource(id = R.string.preferences_app_protection__phone_number),
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close),
modifier = Modifier.nestedScroll(statusBarNestedScrollConnection)
) { contentPadding ->
Box(modifier = Modifier.padding(contentPadding)) {
LazyColumn {

View File

@@ -13,7 +13,7 @@ import androidx.navigation.fragment.findNavController
import com.airbnb.lottie.LottieAnimationView
import com.airbnb.lottie.LottieDrawable
import com.google.android.material.button.MaterialButton
import com.google.android.material.switchmaterial.SwitchMaterial
import com.google.android.material.materialswitch.MaterialSwitch
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
@@ -31,7 +31,7 @@ class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSh
override val peekHeightPercentage: Float = 1f
private lateinit var switch: SwitchMaterial
private lateinit var switch: MaterialSwitch
private lateinit var heading: TextView
private lateinit var badgeRepository: BadgeRepository

View File

@@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.components.settings.conversation
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
class CallInfoActivity : ConversationSettingsActivity(), ConversationSettingsFragment.Callback {
override val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme()
}

View File

@@ -13,11 +13,12 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.ParcelableGroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.DynamicConversationSettingsTheme
import org.thoughtcrime.securesms.util.DynamicTheme
class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettingsFragment.Callback {
open class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettingsFragment.Callback {
override val dynamicTheme: DynamicTheme = DynamicConversationSettingsTheme()
@@ -66,7 +67,7 @@ class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettings
@JvmStatic
fun forGroup(context: Context, groupId: GroupId): Intent {
val startBundle = ConversationSettingsFragmentArgs.Builder(null, ParcelableGroupId.from(groupId))
val startBundle = ConversationSettingsFragmentArgs.Builder(null, ParcelableGroupId.from(groupId), null)
.build()
.toBundle()
@@ -76,7 +77,7 @@ class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettings
@JvmStatic
fun forRecipient(context: Context, recipientId: RecipientId): Intent {
val startBundle = ConversationSettingsFragmentArgs.Builder(recipientId, null)
val startBundle = ConversationSettingsFragmentArgs.Builder(recipientId, null, null)
.build()
.toBundle()
@@ -84,6 +85,21 @@ class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettings
.putExtra(ARG_START_BUNDLE, startBundle)
}
@JvmStatic
fun forCall(context: Context, callPeer: Recipient, callMessageIds: LongArray): Intent {
val startBundleBuilder = if (callPeer.isGroup) {
ConversationSettingsFragmentArgs.Builder(null, ParcelableGroupId.from(callPeer.requireGroupId()), callMessageIds)
} else {
ConversationSettingsFragmentArgs.Builder(callPeer.id, null, callMessageIds)
}
val startBundle = startBundleBuilder.build().toBundle()
return getIntent(context)
.setClass(context, CallInfoActivity::class.java)
.putExtra(ARG_START_BUNDLE, startBundle)
}
private fun getIntent(context: Context): Intent {
return Intent(context, ConversationSettingsActivity::class.java)
.putExtra(ARG_NAV_GRAPH, R.navigation.conversation_settings)

View File

@@ -19,6 +19,7 @@ import androidx.core.content.ContextCompat
import androidx.core.view.doOnPreDraw
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import app.cash.exhaustive.Exhaustive
@@ -49,6 +50,7 @@ import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.conversation.preferences.AvatarPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.BioTextPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.CallPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.GroupDescriptionPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.InternalPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LargeIconClickPreference
@@ -83,6 +85,7 @@ import org.thoughtcrime.securesms.stories.viewer.AddToGroupStoryDelegate
import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.ExpirationUtil
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
@@ -92,6 +95,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity
import java.util.Locale
private const val REQUEST_CODE_VIEW_CONTACT = 1
private const val REQUEST_CODE_ADD_CONTACT = 2
@@ -103,6 +107,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
menuId = R.menu.conversation_settings
) {
private val args: ConversationSettingsFragmentArgs by navArgs()
private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) }
private val blockIcon by lazy {
ContextUtil.requireDrawable(requireContext(), R.drawable.ic_block_tinted_24).apply {
@@ -122,12 +127,12 @@ class ConversationSettingsFragment : DSLSettingsFragment(
private val viewModel by viewModels<ConversationSettingsViewModel>(
factoryProducer = {
val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
val groupId = args.groupId as? ParcelableGroupId
ConversationSettingsViewModel.Factory(
recipientId = args.recipientId,
groupId = ParcelableGroupId.get(groupId),
callMessageIds = args.callMessageIds ?: longArrayOf(),
repository = ConversationSettingsRepository(requireContext())
)
}
@@ -180,6 +185,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
progress.dismiss()
}
}
REQUEST_CODE_RETURN_FROM_MEDIA -> viewModel.refreshSharedMedia()
REQUEST_CODE_ADD_CONTACT -> viewModel.refreshRecipient()
REQUEST_CODE_VIEW_CONTACT -> viewModel.refreshRecipient()
@@ -219,6 +225,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
InternalPreference.register(adapter)
GroupDescriptionPreference.register(adapter)
LegacyGroupPreference.register(adapter)
CallPreference.register(adapter)
val recipientId = args.recipientId
if (recipientId != null) {
@@ -376,6 +383,13 @@ class ConversationSettingsFragment : DSLSettingsFragment(
customPref(
ButtonStripPreference.Model(
state = state.buttonStripState,
onMessageClick = {
val intent = ConversationIntents
.createBuilder(requireContext(), state.recipient.id, state.threadId)
.build()
startActivity(intent)
},
onAddToStoryClick = {
if (state.recipient.isPushV2Group && state.requireGroupSettingsState().isAnnouncementGroup && !state.requireGroupSettingsState().isSelfAdmin) {
MaterialAlertDialogBuilder(requireContext())
@@ -428,6 +442,17 @@ class ConversationSettingsFragment : DSLSettingsFragment(
dividerPref()
if (state.calls.isNotEmpty()) {
val firstCall = state.calls.first()
sectionHeaderPref(DSLSettingsText.from(DateUtils.formatDate(Locale.getDefault(), firstCall.record.timestamp)))
for (call in state.calls) {
customPref(call)
}
dividerPref()
}
val summary = DSLSettingsText.from(formatDisappearingMessagesLifespan(state.disappearingMessagesLifespan))
val icon = if (state.disappearingMessagesLifespan <= 0 || state.recipient.isBlocked) {
R.drawable.ic_update_timer_disabled_16
@@ -491,6 +516,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
}
)
}
ContactLinkState.ADD -> {
@Suppress("DEPRECATION")
clickPref(
@@ -505,6 +531,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
}
)
}
ContactLinkState.NONE -> {
}
}

View File

@@ -5,16 +5,19 @@ import android.database.Cursor
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.database.MediaTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupId
@@ -37,6 +40,20 @@ class ConversationSettingsRepository(
private val groupManagementRepository: GroupManagementRepository = GroupManagementRepository(context)
) {
fun getCallEvents(callMessageIds: LongArray): Single<List<Pair<CallTable.Call, MessageRecord>>> {
return if (callMessageIds.isEmpty()) {
Single.just(emptyList())
} else {
Single.fromCallable {
val callMap = SignalDatabase.calls.getCalls(callMessageIds.toList())
SignalDatabase.messages.getMessages(callMessageIds.toList()).iterator().asSequence()
.filter { callMap.containsKey(it.id) }
.map { callMap[it.id]!! to it }
.toList()
}
}
}
@WorkerThread
fun getThreadMedia(threadId: Long): Optional<Cursor> {
return if (threadId <= 0) {

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.conversation
import android.database.Cursor
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.CallPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.database.model.StoryViewState
@@ -19,6 +20,7 @@ data class ConversationSettingsState(
val sharedMedia: Cursor? = null,
val sharedMediaIds: List<Long> = listOf(),
val displayInternalRecipientDetails: Boolean = false,
val calls: List<CallPreference.Model> = emptyList(),
private val sharedMediaLoaded: Boolean = false,
private val specificSettingsState: SpecificSettingsState
) {

View File

@@ -16,6 +16,7 @@ import org.signal.core.util.CursorUtil
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.CallPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.RecipientTable
@@ -33,6 +34,7 @@ import org.thoughtcrime.securesms.util.livedata.Store
import java.util.Optional
sealed class ConversationSettingsViewModel(
private val callMessageIds: LongArray,
private val repository: ConversationSettingsRepository,
specificSettingsState: SpecificSettingsState
) : ViewModel() {
@@ -64,6 +66,10 @@ sealed class ConversationSettingsViewModel(
repository.getThreadMedia(tId)
}
store.update(repository.getCallEvents(callMessageIds).toObservable()) { callRecords, state ->
state.copy(calls = callRecords.map { (call, messageRecord) -> CallPreference.Model(call, messageRecord) })
}
store.update(sharedMedia) { cursor, state ->
if (!cleared) {
if (cursor.isPresent) {
@@ -128,8 +134,10 @@ sealed class ConversationSettingsViewModel(
private class RecipientSettingsViewModel(
private val recipientId: RecipientId,
private val callMessageIds: LongArray,
private val repository: ConversationSettingsRepository
) : ConversationSettingsViewModel(
callMessageIds,
repository,
SpecificSettingsState.RecipientSettingsState()
) {
@@ -151,12 +159,13 @@ sealed class ConversationSettingsViewModel(
state.copy(
recipient = recipient,
buttonStripState = ButtonStripPreference.State(
isMessageAvailable = callMessageIds.isNotEmpty(),
isVideoAvailable = recipient.registered == RecipientTable.RegisteredState.REGISTERED && !recipient.isSelf && !recipient.isBlocked && !recipient.isReleaseNotes,
isAudioAvailable = isAudioAvailable,
isAudioSecure = recipient.registered == RecipientTable.RegisteredState.REGISTERED,
isMuted = recipient.isMuted,
isMuteAvailable = !recipient.isSelf,
isSearchAvailable = true
isSearchAvailable = callMessageIds.isEmpty()
),
disappearingMessagesLifespan = recipient.expiresInSeconds,
canModifyBlockedState = !recipient.isSelf && RecipientUtil.isBlockable(recipient),
@@ -256,8 +265,9 @@ sealed class ConversationSettingsViewModel(
private class GroupSettingsViewModel(
private val groupId: GroupId,
private val callMessageIds: LongArray,
private val repository: ConversationSettingsRepository
) : ConversationSettingsViewModel(repository, SpecificSettingsState.GroupSettingsState(groupId)) {
) : ConversationSettingsViewModel(callMessageIds, repository, SpecificSettingsState.GroupSettingsState(groupId)) {
private val liveGroup = LiveGroup(groupId)
@@ -271,12 +281,13 @@ sealed class ConversationSettingsViewModel(
state.copy(
recipient = recipient,
buttonStripState = ButtonStripPreference.State(
isMessageAvailable = callMessageIds.isNotEmpty(),
isVideoAvailable = recipient.isPushV2Group && !recipient.isBlocked && isActive,
isAudioAvailable = false,
isAudioSecure = recipient.isPushV2Group,
isMuted = recipient.isMuted,
isMuteAvailable = true,
isSearchAvailable = true,
isSearchAvailable = callMessageIds.isEmpty(),
isAddToStoryAvailable = recipient.isPushV2Group && !recipient.isBlocked && isActive && !SignalStore.storyValues().isFeatureDisabled
),
canModifyBlockedState = RecipientUtil.isBlockable(recipient),
@@ -479,6 +490,7 @@ sealed class ConversationSettingsViewModel(
class Factory(
private val recipientId: RecipientId? = null,
private val groupId: GroupId? = null,
private val callMessageIds: LongArray,
private val repository: ConversationSettingsRepository
) : ViewModelProvider.Factory {
@@ -486,8 +498,8 @@ sealed class ConversationSettingsViewModel(
return requireNotNull(
modelClass.cast(
when {
recipientId != null -> RecipientSettingsViewModel(recipientId, repository)
groupId != null -> GroupSettingsViewModel(groupId, repository)
recipientId != null -> RecipientSettingsViewModel(recipientId, callMessageIds, repository)
groupId != null -> GroupSettingsViewModel(groupId, callMessageIds, repository)
else -> error("One of RecipientId or GroupId required.")
}
)

View File

@@ -0,0 +1,86 @@
package org.thoughtcrime.securesms.components.settings.conversation.preferences
import androidx.annotation.DrawableRes
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.database.MessageTypes
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.databinding.ConversationSettingsCallPreferenceItemBinding
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import java.util.Locale
/**
* Renders a single call preference row when displaying call info.
*/
object CallPreference {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, ConversationSettingsCallPreferenceItemBinding::inflate))
}
class Model(
val call: CallTable.Call,
val record: MessageRecord
) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = record.id == newItem.record.id
override fun areContentsTheSame(newItem: Model): Boolean {
return call == newItem.call &&
record.type == newItem.record.type &&
record.isOutgoing == newItem.record.isOutgoing &&
record.timestamp == newItem.record.timestamp &&
record.id == newItem.record.id
}
}
private class ViewHolder(binding: ConversationSettingsCallPreferenceItemBinding) : BindingViewHolder<Model, ConversationSettingsCallPreferenceItemBinding>(binding) {
override fun bind(model: Model) {
binding.callIcon.setImageResource(getCallIcon(model.call))
binding.callType.text = getCallType(model.call)
binding.callTime.text = getCallTime(model.record)
}
@DrawableRes
private fun getCallIcon(call: CallTable.Call): Int {
return when (call.messageType) {
MessageTypes.MISSED_VIDEO_CALL_TYPE, MessageTypes.MISSED_AUDIO_CALL_TYPE -> R.drawable.symbol_missed_incoming_24
MessageTypes.INCOMING_AUDIO_CALL_TYPE, MessageTypes.INCOMING_VIDEO_CALL_TYPE -> R.drawable.symbol_arrow_downleft_24
MessageTypes.OUTGOING_AUDIO_CALL_TYPE, MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.drawable.symbol_arrow_upright_24
MessageTypes.GROUP_CALL_TYPE -> when {
call.event == CallTable.Event.MISSED -> R.drawable.symbol_missed_incoming_24
call.direction == CallTable.Direction.INCOMING -> R.drawable.symbol_arrow_downleft_24
call.direction == CallTable.Direction.OUTGOING -> R.drawable.symbol_arrow_upright_24
else -> throw AssertionError()
}
else -> error("Unexpected type ${call.type}")
}
}
private fun getCallType(call: CallTable.Call): String {
val id = when (call.messageType) {
MessageTypes.MISSED_VIDEO_CALL_TYPE -> R.string.MessageRecord_missed_voice_call
MessageTypes.MISSED_AUDIO_CALL_TYPE -> R.string.MessageRecord_missed_video_call
MessageTypes.INCOMING_AUDIO_CALL_TYPE -> R.string.MessageRecord_incoming_voice_call
MessageTypes.INCOMING_VIDEO_CALL_TYPE -> R.string.MessageRecord_incoming_video_call
MessageTypes.OUTGOING_AUDIO_CALL_TYPE -> R.string.MessageRecord_outgoing_voice_call
MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.string.MessageRecord_outgoing_video_call
MessageTypes.GROUP_CALL_TYPE -> when {
call.event == CallTable.Event.MISSED -> R.string.CallPreference__missed_group_call
call.direction == CallTable.Direction.INCOMING -> R.string.CallPreference__incoming_group_call
call.direction == CallTable.Direction.OUTGOING -> R.string.CallPreference__outgoing_group_call
else -> throw AssertionError()
}
else -> error("Unexpected type ${call.messageType}")
}
return context.getString(id)
}
private fun getCallTime(messageRecord: MessageRecord): String {
return DateUtils.getOnlyTimeString(context, Locale.getDefault(), messageRecord.timestamp)
}
}
}

View File

@@ -20,7 +20,10 @@ fun configure(init: DSLConfiguration.() -> Unit): DSLConfiguration {
return configuration
}
@Discouraged("The DSL API can be completely replaced by compose. See ComposeFragment or ComposeBottomSheetFragment for an alternative to this API")
/**
* The DSL API can be completely replaced by compose.
* See ComposeFragment or ComposeBottomSheetFragment for an alternative to this API
*/
class DSLConfiguration {
private val children = arrayListOf<MappingModel<*>>()

View File

@@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.components.settings.models
import android.view.View
import android.widget.ViewSwitcher
import com.google.android.material.switchmaterial.SwitchMaterial
import com.google.android.material.materialswitch.MaterialSwitch
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
@@ -33,23 +33,32 @@ object AsyncSwitch {
}
class ViewHolder(itemView: View) : PreferenceViewHolder<Model>(itemView) {
private val switchWidget: SwitchMaterial = itemView.findViewById(R.id.switch_widget)
private val switchWidget: MaterialSwitch = itemView.findViewById(R.id.switch_widget)
private val switcher: ViewSwitcher = itemView.findViewById(R.id.switcher)
override fun bind(model: Model) {
super.bind(model)
switchWidget.setOnCheckedChangeListener(null)
switchWidget.isEnabled = model.isEnabled
switchWidget.isChecked = model.isChecked
itemView.isEnabled = !model.isProcessing && model.isEnabled
switcher.displayedChild = if (model.isProcessing) 1 else 0
itemView.setOnClickListener {
fun onClick() {
if (!model.isProcessing) {
itemView.isEnabled = false
switcher.displayedChild = 1
model.onClick()
}
}
itemView.setOnClickListener {
onClick()
}
switchWidget.setOnCheckedChangeListener { _, _ ->
onClick()
}
}
}
}

View File

@@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.components.settings.models
import android.view.View
import android.widget.TextView
import com.google.android.material.switchmaterial.SwitchMaterial
import com.google.android.material.materialswitch.MaterialSwitch
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
@@ -35,7 +35,7 @@ object OutlinedSwitch {
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val text: TextView = findViewById(R.id.outlined_switch_control_text)
private val switch: SwitchMaterial = findViewById(R.id.outlined_switch_switch)
private val switch: MaterialSwitch = findViewById(R.id.outlined_switch_switch)
override fun bind(model: Model) {
text.text = model.text.resolve(context)

View File

@@ -0,0 +1,70 @@
package org.thoughtcrime.securesms.components.spoiler
import android.graphics.Color
import android.text.Annotation
import android.text.Spanned
import android.text.TextPaint
import android.text.style.ClickableSpan
import android.view.View
/**
* Helper for applying spans to text that should be rendered as a spoiler. Also
* tracks spoilers that have been revealed or not.
*/
object SpoilerAnnotation {
private const val SPOILER_ANNOTATION = "spoiler"
private val revealedSpoilers = mutableSetOf<String>()
@JvmStatic
fun spoilerAnnotation(hash: Int): Annotation {
return Annotation(SPOILER_ANNOTATION, hash.toString())
}
@JvmStatic
fun isSpoilerAnnotation(annotation: Any): Boolean {
return SPOILER_ANNOTATION == (annotation as? Annotation)?.key
}
fun getSpoilerAndClickAnnotations(spanned: Spanned, start: Int = 0, end: Int = spanned.length): Map<Annotation, SpoilerClickableSpan?> {
val spoilerAnnotations: Map<Pair<Int, Int>, Annotation> = spanned.getSpans(start, end, Annotation::class.java)
.filter { isSpoilerAnnotation(it) }
.associateBy { (spanned.getSpanStart(it) to spanned.getSpanEnd(it)) }
val spoilerClickSpans: Map<Pair<Int, Int>, SpoilerClickableSpan> = spanned.getSpans(start, end, SpoilerClickableSpan::class.java)
.associateBy { (spanned.getSpanStart(it) to spanned.getSpanEnd(it)) }
return spoilerAnnotations
.map { (position, annotation) ->
annotation to spoilerClickSpans[position]
}
.toMap()
}
@JvmStatic
fun getSpoilerAnnotations(spanned: Spanned, start: Int, end: Int): List<Annotation> {
return spanned
.getSpans(start, end, Annotation::class.java)
.filter { isSpoilerAnnotation(it) }
}
@JvmStatic
fun resetRevealedSpoilers() {
revealedSpoilers.clear()
}
class SpoilerClickableSpan(private val spoiler: Annotation) : ClickableSpan() {
val spoilerRevealed
get() = revealedSpoilers.contains(spoiler.value)
override fun onClick(widget: View) {
revealedSpoilers.add(spoiler.value)
}
override fun updateDrawState(ds: TextPaint) {
if (!spoilerRevealed) {
ds.color = Color.TRANSPARENT
}
}
}
}

View File

@@ -0,0 +1,148 @@
package org.thoughtcrime.securesms.components.spoiler
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Rect
import android.graphics.drawable.Drawable
import androidx.annotation.ColorInt
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.Util
import kotlin.random.Random
/**
* Drawable that animates a sparkle effect for spoilers.
*/
class SpoilerDrawable(@ColorInt color: Int) : Drawable() {
private val alphaStrength = arrayOf(0.9f, 0.7f, 0.5f)
private val paints = listOf(Paint(), Paint(), Paint())
private var lastDrawTime: Long = 0
private var particleCount = 60
private var allParticles = Array(3) { Array(particleCount) { Particle(random) } }
private var allPoints = Array(3) { FloatArray(particleCount * 2) { 0f } }
init {
for (paint in paints) {
paint.strokeCap = Paint.Cap.ROUND
paint.strokeWidth = DimensionUnit.DP.toPixels(1.5f)
}
alpha = 255
colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
}
override fun onBoundsChange(bounds: Rect) {
val pixelArea = (bounds.right - bounds.left) * (bounds.bottom - bounds.top)
val newParticleCount = (pixelArea.toFloat() * PARTICLES_PER_PIXEL).toInt()
if (newParticleCount != particleCount) {
if (newParticleCount > allParticles[0].size) {
allParticles = Array(3) { i ->
Array(newParticleCount) { particleIndex ->
allParticles[i].getOrNull(particleIndex) ?: Particle(random)
}
}
allPoints = Array(3) { i ->
FloatArray(newParticleCount * 2) { pointIndex ->
allPoints[i].getOrNull(pointIndex) ?: 0f
}
}
}
particleCount = newParticleCount
}
}
override fun draw(canvas: Canvas) {
val left = bounds.left
val top = bounds.top
val right = bounds.right
val bottom = bounds.bottom
val now = System.currentTimeMillis()
val dt = now - lastDrawTime
lastDrawTime = now
for (allIndex in allParticles.indices) {
val particles = allParticles[allIndex]
for (index in 0 until particleCount) {
val particle = particles[index]
particle.timeRemaining = particle.timeRemaining - dt
if (particle.timeRemaining < 0 || !bounds.contains(particle.x.toInt(), particle.y.toInt())) {
particle.x = (random.nextFloat() * (right - left)) + left
particle.y = (random.nextFloat() * (bottom - top)) + top
particle.xVel = nextDirection()
particle.yVel = nextDirection()
particle.timeRemaining = 350 + 750 * random.nextFloat()
} else {
val change = dt * velocity
particle.x += particle.xVel * change
particle.y += particle.yVel * change
}
allPoints[allIndex][index * 2] = particle.x
allPoints[allIndex][index * 2 + 1] = particle.y
}
}
canvas.drawPoints(allPoints[0], 0, particleCount * 2, paints[0])
canvas.drawPoints(allPoints[1], 0, particleCount * 2, paints[1])
canvas.drawPoints(allPoints[2], 0, particleCount * 2, paints[2])
}
override fun setAlpha(alpha: Int) {
paints.forEachIndexed { index, paint ->
paint.alpha = (alpha * alphaStrength[index]).toInt()
}
}
override fun setColorFilter(colorFilter: ColorFilter?) {
for (paint in paints) {
paint.colorFilter = colorFilter
}
}
@Deprecated("Deprecated in Java", ReplaceWith("PixelFormat.TRANSPARENT", "android.graphics.PixelFormat"))
override fun getOpacity(): Int {
return PixelFormat.TRANSPARENT
}
data class Particle(
var x: Float,
var y: Float,
var xVel: Float,
var yVel: Float,
var timeRemaining: Float
) {
constructor(random: Random) : this(
-1f,
-1f,
if (random.nextFloat() < 0.5f) 1f else -1f,
if (random.nextFloat() < 0.5f) 1f else -1f,
500 + 1000 * random.nextFloat()
)
}
companion object {
private val PARTICLES_PER_PIXEL = if (Util.isLowMemory(ApplicationDependencies.getApplication())) 0.002f else 0.005f
private val velocity: Float = DimensionUnit.DP.toPixels(16f) / 1000f
private val random = Random(System.currentTimeMillis())
fun nextDirection(): Float {
val rand = random.nextFloat()
return if (rand < 0.5f) {
0.1f + 0.9f * rand
} else {
-0.1f - 0.9f * (rand - 0.5f)
}
}
}
}

View File

@@ -0,0 +1,121 @@
package org.thoughtcrime.securesms.components.spoiler
import android.graphics.Canvas
import android.text.Layout
import org.thoughtcrime.securesms.util.LayoutUtil
import kotlin.math.max
import kotlin.math.min
/**
* Handles drawing the spoiler sparkles for a TextView.
*/
abstract class SpoilerRenderer {
abstract fun draw(
canvas: Canvas,
layout: Layout,
startLine: Int,
endLine: Int,
startOffset: Int,
endOffset: Int,
spoilerDrawables: List<SpoilerDrawable>
)
protected fun getLineTop(layout: Layout, line: Int): Int {
return LayoutUtil.getLineTopWithoutPadding(layout, line)
}
protected fun getLineBottom(layout: Layout, line: Int): Int {
return LayoutUtil.getLineBottomWithoutPadding(layout, line)
}
protected inline fun MutableMap<Int, Int>.get(line: Int, layout: Layout, default: () -> Int): Int {
return getOrPut(line * 31 + layout.hashCode() * 31, default)
}
class SingleLineSpoilerRenderer : SpoilerRenderer() {
private val lineTopCache = HashMap<Int, Int>()
private val lineBottomCache = HashMap<Int, Int>()
override fun draw(
canvas: Canvas,
layout: Layout,
startLine: Int,
endLine: Int,
startOffset: Int,
endOffset: Int,
spoilerDrawables: List<SpoilerDrawable>
) {
val lineTop = lineTopCache.get(startLine, layout) { getLineTop(layout, startLine) }
val lineBottom = lineBottomCache.get(startLine, layout) { getLineBottom(layout, startLine) }
val left = startOffset.coerceAtMost(endOffset)
val right = startOffset.coerceAtLeast(endOffset)
spoilerDrawables[0].setBounds(left, lineTop, right, lineBottom)
spoilerDrawables[0].draw(canvas)
}
}
class MultiLineSpoilerRenderer : SpoilerRenderer() {
private val lineTopCache = HashMap<Int, Int>()
private val lineBottomCache = HashMap<Int, Int>()
override fun draw(
canvas: Canvas,
layout: Layout,
startLine: Int,
endLine: Int,
startOffset: Int,
endOffset: Int,
spoilerDrawables: List<SpoilerDrawable>
) {
val paragraphDirection = layout.getParagraphDirection(startLine)
val lineEndOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineLeft(startLine) else layout.getLineRight(startLine)
var lineBottom = lineBottomCache.get(startLine, layout) { getLineBottom(layout, startLine) }
var lineTop = lineTopCache.get(startLine, layout) { getLineTop(layout, startLine) }
drawStart(canvas, startOffset, lineTop, lineEndOffset.toInt(), lineBottom, spoilerDrawables)
if (startLine + 1 < endLine) {
var left = Int.MAX_VALUE
var right = -1
lineTop = Int.MAX_VALUE
lineBottom = -1
for (line in startLine + 1 until endLine) {
left = min(left, layout.getLineLeft(line).toInt())
right = max(right, layout.getLineRight(line).toInt())
lineTop = min(lineTop, lineTopCache.get(line, layout) { getLineTop(layout, line) })
lineBottom = max(lineBottom, lineBottomCache.get(line, layout) { getLineBottom(layout, line) })
}
spoilerDrawables[1].setBounds(left, lineTop, right, lineBottom)
spoilerDrawables[1].draw(canvas)
}
val lineStartOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineRight(startLine) else layout.getLineLeft(startLine)
lineBottom = lineBottomCache.get(endLine, layout) { getLineBottom(layout, endLine) }
lineTop = lineTopCache.get(endLine, layout) { getLineTop(layout, endLine) }
drawEnd(canvas, lineStartOffset.toInt(), lineTop, endOffset, lineBottom, spoilerDrawables)
}
private fun drawStart(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int, spoilerDrawables: List<SpoilerDrawable>) {
if (start > end) {
spoilerDrawables[2].setBounds(end, top, start, bottom)
spoilerDrawables[2].draw(canvas)
} else {
spoilerDrawables[0].setBounds(start, top, end, bottom)
spoilerDrawables[0].draw(canvas)
}
}
private fun drawEnd(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int, spoilerDrawables: List<SpoilerDrawable>) {
if (start > end) {
spoilerDrawables[0].setBounds(end, top, start, bottom)
spoilerDrawables[0].draw(canvas)
} else {
spoilerDrawables[2].setBounds(start, top, end, bottom)
spoilerDrawables[2].draw(canvas)
}
}
}
}

View File

@@ -0,0 +1,121 @@
package org.thoughtcrime.securesms.components.spoiler
import android.animation.ValueAnimator
import android.graphics.Canvas
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.text.Annotation
import android.text.Layout
import android.text.Spanned
import android.view.animation.LinearInterpolator
import android.widget.TextView
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation.SpoilerClickableSpan
import org.thoughtcrime.securesms.components.spoiler.SpoilerRenderer.MultiLineSpoilerRenderer
import org.thoughtcrime.securesms.components.spoiler.SpoilerRenderer.SingleLineSpoilerRenderer
/**
* Performs initial calculation on how to render spoilers and then delegates to the single line or
* multi-line version of actually drawing the spoiler sparkles.
*/
class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextView, private val renderForComposing: Boolean = false) {
private val single: SpoilerRenderer
private val multi: SpoilerRenderer
private var animatorRunning = false
private var textColor: Int
private var spoilerDrawablePool = mutableMapOf<Annotation, List<SpoilerDrawable>>()
private var nextSpoilerDrawablePool = mutableMapOf<Annotation, List<SpoilerDrawable>>()
private val cachedAnnotations = HashMap<Int, Map<Annotation, SpoilerClickableSpan?>>()
private val cachedMeasurements = HashMap<Int, SpanMeasurements>()
private val animator = ValueAnimator.ofInt(0, 100).apply {
duration = 1000
interpolator = LinearInterpolator()
addUpdateListener { view.invalidate() }
repeatCount = ValueAnimator.INFINITE
repeatMode = ValueAnimator.REVERSE
}
init {
single = SingleLineSpoilerRenderer()
multi = MultiLineSpoilerRenderer()
textColor = view.textColors.defaultColor
}
fun updateFromTextColor() {
val color = view.textColors.defaultColor
if (color != textColor) {
spoilerDrawablePool
.values
.flatten()
.forEach { it.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) }
textColor = color
}
}
fun draw(canvas: Canvas, text: Spanned, layout: Layout) {
var hasSpoilersToRender = false
val annotations: Map<Annotation, SpoilerClickableSpan?> = cachedAnnotations.getFromCache(text) { SpoilerAnnotation.getSpoilerAndClickAnnotations(text) }
nextSpoilerDrawablePool.clear()
for ((annotation, clickSpan) in annotations.entries) {
if (clickSpan?.spoilerRevealed == true) {
continue
}
val spanStart: Int = text.getSpanStart(annotation)
val spanEnd: Int = text.getSpanEnd(annotation)
if (spanStart >= spanEnd) {
continue
}
val measurements = cachedMeasurements.getFromCache(annotation.value, layout) {
val startLine = layout.getLineForOffset(spanStart)
val endLine = layout.getLineForOffset(spanEnd)
SpanMeasurements(
startLine = startLine,
endLine = endLine,
startOffset = (layout.getPrimaryHorizontal(spanStart) + -1 * layout.getParagraphDirection(startLine)).toInt(),
endOffset = (layout.getPrimaryHorizontal(spanEnd) + layout.getParagraphDirection(endLine)).toInt()
)
}
val renderer: SpoilerRenderer = if (measurements.startLine == measurements.endLine) single else multi
val drawables: List<SpoilerDrawable> = spoilerDrawablePool[annotation] ?: listOf(SpoilerDrawable(textColor), SpoilerDrawable(textColor), SpoilerDrawable(textColor))
renderer.draw(canvas, layout, measurements.startLine, measurements.endLine, measurements.startOffset, measurements.endOffset, drawables)
nextSpoilerDrawablePool[annotation] = drawables
hasSpoilersToRender = true
}
val temporaryPool = spoilerDrawablePool
spoilerDrawablePool = nextSpoilerDrawablePool
nextSpoilerDrawablePool = temporaryPool
if (hasSpoilersToRender) {
if (!animatorRunning) {
animator.start()
animatorRunning = true
}
} else {
animator.pause()
animatorRunning = false
}
}
private inline fun <V> MutableMap<Int, V>.getFromCache(vararg keys: Any, default: () -> V): V {
if (renderForComposing) {
return default()
}
return getOrPut(keys.contentHashCode(), default)
}
private data class SpanMeasurements(
val startLine: Int,
val endLine: Int,
val startOffset: Int,
val endOffset: Int
)
}

View File

@@ -17,7 +17,6 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.badges.BadgeImageView;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.HashSet;
import java.util.Iterator;
@@ -41,7 +40,7 @@ public class CallParticipantsListUpdatePopupWindow extends PopupWindow {
public CallParticipantsListUpdatePopupWindow(@NonNull ViewGroup parent) {
super(LayoutInflater.from(parent.getContext()).inflate(R.layout.call_participant_list_update, parent, false),
ViewGroup.LayoutParams.MATCH_PARENT,
ViewUtil.dpToPx(94));
ViewGroup.LayoutParams.WRAP_CONTENT);
this.parent = parent;
this.avatarImageView = getContentView().findViewById(R.id.avatar);

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