Compare commits

..

169 Commits

Author SHA1 Message Date
Alex Hart
9e3d100599 Bump version to 6.36.3 2023-10-13 14:37:13 -03:00
Alex Hart
a7193e321c Updated baseline profile. 2023-10-13 14:24:26 -03:00
Alex Hart
fa15469696 Update translations and other static files. 2023-10-13 14:19:43 -03:00
Cody Henthorne
58b9cdf28f Fix deadlock in JobManager initialization. 2023-10-13 13:02:03 -04:00
Nicholas
8e05fe3b0c Rotate incremental MAC proto field. 2023-10-13 11:43:42 -04:00
Nicholas
af063b2e9e Transfer Control View Improvements. 2023-10-13 10:03:42 -04:00
Alex Hart
5cc85cc860 Fix issue with chat colors not updating properly. 2023-10-13 10:37:09 -03:00
Nicholas Tinsley
eafa1eabee Adjust transfer control view insets. 2023-10-11 16:01:55 -04:00
Nicholas Tinsley
34a1838668 Make blurred thumbnails fill the view. 2023-10-11 16:00:15 -04:00
Alex Hart
df83c94180 Bump version to 6.36.2 2023-10-11 16:28:39 -03:00
Alex Hart
e102b60923 Updated baseline profile. 2023-10-11 16:24:06 -03:00
Alex Hart
02900eaa6d Update translations and other static files. 2023-10-11 16:18:23 -03:00
Nicholas Tinsley
5ed4c51582 Do not check incremental MAC in Glide. 2023-10-11 16:11:30 -03:00
Nicholas Tinsley
81e928f94e Disable incremental MAC changes. 2023-10-11 14:31:06 -04:00
Alex Hart
985b569d29 Fix wacky layout while scrolling in thread. 2023-10-11 14:52:30 -03:00
Nicholas Tinsley
d2d000ef16 Log device type when failing to set audio device. 2023-10-11 10:25:16 -04:00
Alex Hart
520b3a14bc Handle donation-driven 440 errors more gracefully. 2023-10-11 09:56:09 -03:00
Nicholas Tinsley
157d194cc5 Fix downloading outgoing media view. 2023-10-10 17:52:54 -04:00
Cody Henthorne
2785609481 Fix bug with dangling notification clear. 2023-10-10 12:06:15 -04:00
Nicholas Tinsley
6e5e60173b Bump version to 6.36.1 2023-10-06 19:30:50 -04:00
Nicholas Tinsley
f37e938f17 Update translations and other static files. 2023-10-06 19:30:39 -04:00
Nicholas Tinsley
da645acd1c Updated baseline profile. 2023-10-06 19:22:31 -04:00
Nicholas Tinsley
17205b2baf Remove vestigial relayout calls. 2023-10-06 18:24:58 -04:00
Greyson Parrelli
b5ba4d3570 Fix progress text wrapping in TransferControlView. 2023-10-06 17:05:40 -04:00
Alex Hart
17b24d3c24 Add handling for no-bubble outgoing messages without wallpaper. 2023-10-06 16:13:18 -03:00
Alex Hart
044454dca2 Fix story start position when in a mixed read/unread state. 2023-10-06 10:32:47 -03:00
Nicholas Tinsley
88bff9ab6c Bump version to 6.36.0 2023-10-05 19:23:24 -04:00
Nicholas Tinsley
203fde60d6 Update translations and other static files. 2023-10-05 19:23:01 -04:00
Nicholas
82956c4149 New attachment download UI. 2023-10-05 19:13:19 -04:00
Greyson Parrelli
1f41b9e481 Include microbenchmark compilation check in qa. 2023-10-05 19:13:19 -04:00
Greyson Parrelli
945921fa9a Fix compilation of microbenchmarks. 2023-10-05 19:13:19 -04:00
Greyson Parrelli
7d5786ea93 Add a core-util-jvm module.
This is basically a location where we can put common utils that can also
be imported by libsignal-service (which is java-only, no android
dependency).
2023-10-05 19:13:19 -04:00
Cody Henthorne
6be1413d7d Fix link preview overriding edit message with media bug. 2023-10-05 19:13:19 -04:00
Cody Henthorne
fd07ab10ee Fix ISE crash in compose text watcher. 2023-10-05 19:13:19 -04:00
Cody Henthorne
6232656ad4 Fix dangling notifications after clear message history. 2023-10-05 19:13:19 -04:00
Cody Henthorne
8493c7ffe5 Enable split-window support for key activites.
Fixes #13182
2023-10-05 19:13:19 -04:00
Alex Hart
15700b85cb Implement underpinnings of SEPA debit transfer support for donations. 2023-10-05 19:13:19 -04:00
Cody Henthorne
3dfd1c98ba Re-download profile avatars if they fail to load. 2023-10-04 15:00:52 -04:00
Nicholas Tinsley
9a249b0dec Make voice note playback log statement more readable. 2023-10-04 10:32:23 -04:00
Cody Henthorne
b74a431ac9 Prevent incorrect state changes during vanity camera switchover. 2023-10-03 11:27:33 -04:00
Cody Henthorne
880ce18fd0 Pluralize chat length limit custom setting. 2023-10-03 10:16:05 -04:00
Alex Hart
6279149cb8 Add SEPA API endpoints. 2023-10-03 10:00:42 -04:00
Alex Hart
f5c5a34798 CallLink profile sharing via ProfileKeySendJob. 2023-10-03 10:00:42 -04:00
Alex Hart
e9a616c68d Add error handling for PayPal decline codes. 2023-10-03 10:00:42 -04:00
Nicholas Tinsley
f5ee7160cb Bump version to 6.35.3 2023-10-02 21:33:58 -04:00
Nicholas Tinsley
cea671aab5 Update translations and other static files. 2023-10-02 21:21:18 -04:00
Nicholas
da84cde6da Read first frame of backup to validate before proceeding.
Addresses #11952.
2023-10-02 20:30:39 -04:00
Cody Henthorne
e9fbce4e28 Add missing GV2 state update on conversation open. 2023-10-02 14:49:52 -04:00
Alex Hart
913605a065 Fix state snapshot in LinkPreviewViewModelV2. 2023-10-02 13:59:07 -03:00
Alex Hart
4bf49df6fa Fix horizontal ReactionView margins. 2023-10-02 13:53:24 -03:00
Cody Henthorne
91a9d6c68f Fix NPE in group access control. 2023-10-02 12:01:56 -04:00
Cody Henthorne
a477c3c4d9 Fix incorrect assertion for syncing pni only contacts. 2023-10-02 12:00:15 -04:00
Cody Henthorne
0cdd56e0ac Bump version to 6.35.2 2023-09-30 09:24:52 -04:00
Cody Henthorne
abefb894cc Updated baseline profile. 2023-09-30 09:23:24 -04:00
Cody Henthorne
97d482c1ad Update translations and other static files. 2023-09-30 09:20:50 -04:00
Cody Henthorne
d3e9303d6d Fix incorrect data migration. 2023-09-30 09:15:37 -04:00
Cody Henthorne
df7bb13752 Bump version to 6.35.1 2023-09-29 20:27:48 -04:00
Cody Henthorne
d28f6f5922 Updated baseline profile. 2023-09-29 20:23:45 -04:00
Cody Henthorne
c90ad7c1e2 Fix bugs around PNI only contacts and storage service. 2023-09-29 20:15:34 -04:00
Alex Hart
7fbdcb8a88 Add several SavedStateHandle delegates. 2023-09-29 11:28:36 -03:00
Alex Hart
d46daed49a Add SavedStateHandle support to LinkPreviewViewModelV2. 2023-09-29 09:25:17 -03:00
Nicholas Tinsley
f18a03ee6d Add incremental mac chunk size to attachment pointer. 2023-09-28 21:12:05 -04:00
Cody Henthorne
1d052e7c1b Bump version to 6.35.0 2023-09-28 20:10:05 -04:00
Cody Henthorne
2611165f21 Updated baseline profile. 2023-09-28 20:05:46 -04:00
Cody Henthorne
f059aa7407 Update translations and other static files. 2023-09-28 20:01:19 -04:00
Jim Gustafson
ac27df1f0e Update to RingRTC v2.33.0 2023-09-28 19:57:33 -04:00
Alex Hart
76b28593ea Suppress dialog if error is regarding user cancellation. 2023-09-28 19:57:33 -04:00
Alex Hart
0940c88c20 CallLink NullMessage sending. 2023-09-28 19:57:33 -04:00
Clark Chen
c3408040fc Skip optimized notifications check if flag disabled. 2023-09-28 19:57:33 -04:00
Alex Hart
d2ffc11749 Allow MediaStore permission check to function with only images enabled. 2023-09-28 19:57:32 -04:00
Alex Hart
4d640ec467 Donation CreatePaymentMethod 409 error recovery. 2023-09-28 19:57:32 -04:00
Alex Hart
c409d49f14 Hide call link warning card when entering call. 2023-09-28 19:57:32 -04:00
Nicholas Tinsley
2c0dbf1062 Condense BubbleUtil debug info to a single line. 2023-09-28 19:57:32 -04:00
Alex Hart
25f0208e61 Upgrade AndroidX Core to 1.12.0 2023-09-28 19:57:32 -04:00
Nicholas
d063cfe36a Upgrade libsignal to 0.32.1 2023-09-28 19:57:32 -04:00
Cody Henthorne
5c089e1d77 Fix crash on poorly formatted group change update. 2023-09-28 19:44:46 -04:00
Nicholas Tinsley
867006d29c Increase Bubble diagnostic logging. 2023-09-28 19:44:46 -04:00
Greyson Parrelli
6a974c48ef Add a log viewer to Spinner.
This is more of a proof-of-concept/demo for using a websocket with
Spinner. Gives an example of how we could push live updates to the
webapp.

Also, the logger is actually nice. Guaranteed to never get cluttered
with system logs. Looks basically identical to our other log viewers.
Filtering is basic but fast. And we could build much better tooling on
top of this.
2023-09-28 19:44:46 -04:00
Jim Gustafson
c314918c6b Update to RingRTC v2.32.0 2023-09-28 19:44:46 -04:00
Greyson Parrelli
e2e2a076c7 Fix error log in Spinner console. 2023-09-28 19:44:46 -04:00
Greyson Parrelli
8ee12b9f26 Fix compile issue with some sample apps. 2023-09-28 19:44:46 -04:00
Cody Henthorne
7377293f81 Bump version to 6.34.5 2023-09-28 19:43:18 -04:00
Cody Henthorne
29ae49b5f1 Updated baseline profile. 2023-09-28 19:40:19 -04:00
Cody Henthorne
195d967b3f Update translations and other static files. 2023-09-28 19:37:34 -04:00
Cody Henthorne
eac74bf9c1 Fix NPE crash in group permissions screen. 2023-09-28 19:32:40 -04:00
Alex Hart
9f2dbf7b6c Fix context usage in ConversationDataSource. 2023-09-28 19:24:12 -04:00
Cody Henthorne
9e836ba586 Bump version to 6.34.4 2023-09-26 20:03:14 -04:00
Cody Henthorne
cc6dc1b3a2 Updated baseline profile. 2023-09-26 19:57:24 -04:00
Cody Henthorne
f49da2c9bf Update translations and other static files. 2023-09-26 19:52:35 -04:00
Cody Henthorne
96c1077238 Revert "Add more logging to forwarding bottom sheet."
This reverts commit 3fc26733ad.
2023-09-26 19:43:48 -04:00
Cody Henthorne
8d72b27e1d Fix gboard gif playback. 2023-09-26 19:41:49 -04:00
Cody Henthorne
0ea0d139dd Fix odd scaling issues during decoding. 2023-09-26 19:34:55 -04:00
Nicholas Tinsley
b81ff4d672 Increase prominence of network errors during re-registration. 2023-09-26 10:43:57 -04:00
Alex Hart
f380ac5e43 Fix username search issue for non-alpha-underscore characters. 2023-09-26 10:05:38 -03:00
Alex Hart
962d42292d Remove deprecated API endpoint call for setting the default payment method. 2023-09-26 09:11:42 -03:00
Alex Hart
15df15556d Always display footer underneath if text has mixed directions. 2023-09-26 09:08:22 -03:00
Cody Henthorne
6b29841cc8 Bump version to 6.34.3 2023-09-25 21:42:21 -04:00
Cody Henthorne
4f4c1a9bb8 Updated baseline profile. 2023-09-25 21:36:27 -04:00
Cody Henthorne
5f7630b906 Update translations and other static files. 2023-09-25 21:31:44 -04:00
Cody Henthorne
8a831889f9 Decode using aspect ratio preserving scaling. 2023-09-25 21:25:00 -04:00
Nicholas Tinsley
bce133ac28 Add more logging around missing RecipientId. 2023-09-25 21:25:00 -04:00
Alex Hart
f5215d715a Utilize timestamp from table instead of relying on getCurrentMillis. 2023-09-25 21:25:00 -04:00
Alex Hart
fde0f3bba1 Fix call log clear history error handling. 2023-09-25 21:25:00 -04:00
Alex Hart
e7b18bd3a2 Tie CallLogViewModel lifecycle to the activity. 2023-09-25 21:25:00 -04:00
Alex Hart
e5e86e639a Update getAdapterPosition to utilize the binding adapter position instead of absolute. 2023-09-25 21:25:00 -04:00
Alex Hart
f44b44a354 Fix timestamp update on conversation re-entry from background. 2023-09-25 21:25:00 -04:00
Alex Hart
b3399b5242 Fix RTL display of CIV2 bubble corners. 2023-09-25 21:25:00 -04:00
Alex Hart
7d4ebd9d3b Fix strange padding on some CIV2 items. 2023-09-25 10:16:03 -03:00
Cody Henthorne
3bb2131375 Fix crash when prompting for debuglogs. 2023-09-24 21:29:01 -04:00
Cody Henthorne
d7314ec2a4 Fix NPE in group change message processing. 2023-09-24 21:20:54 -04:00
Cody Henthorne
cc6c724ee8 Fix crash if pixels are null. 2023-09-24 20:57:24 -04:00
Cody Henthorne
d3b0559b72 Fix link preview processing when missing a date. 2023-09-23 23:06:50 -04:00
Cody Henthorne
1e24caec31 Fix SignalServiceGroupV2 proto parsing. 2023-09-23 23:00:36 -04:00
Cody Henthorne
65cdc143da Fix incorrect handling of hangup message. 2023-09-23 22:40:46 -04:00
Cody Henthorne
5d612f020c Bump version to 6.34.2 2023-09-22 16:19:23 -04:00
Cody Henthorne
ccef2cc178 Use https for submodule. 2023-09-22 16:16:21 -04:00
Alex Hart
9337160583 Bump version to 6.34.1 2023-09-22 17:02:41 -03:00
Alex Hart
bf9d570c3d Updated baseline profile. 2023-09-22 17:02:20 -03:00
Alex Hart
306b0096be Update translations and other static files. 2023-09-22 16:56:56 -03:00
Alex Hart
45583ea469 Revert "Instant Video Playback UI"
This reverts commit f8283acfae.
2023-09-22 16:50:20 -03:00
Cody Henthorne
15c6c372ba Fix quoted mentioned showing in regular message bug. 2023-09-22 16:50:20 -03:00
Ehren Kret
770a89507a Fix background color on internal search fragment. 2023-09-22 16:50:20 -03:00
Alex Hart
ddc9aa7506 Remove unused padding in ContactSelectionListFragment. 2023-09-22 16:50:20 -03:00
Cody Henthorne
a7d9fd19d9 Enable WebP decoding in Signal using libwebp v1.3.2
Co-authored-by: Greyson Parrelli <greyson@signal.org>
Co-authored-by: Greyson Parrelli <greyson@pop-os.localdomain>
2023-09-22 16:50:20 -03:00
Alex Hart
091f7c49ab Fix issue where story contact list would reset when selecting contacts.
Fixes #13174
2023-09-22 16:50:20 -03:00
Cody Henthorne
b443f59078 Rebuild wire-handler-1.0.0.jar without extra logging. 2023-09-22 16:50:20 -03:00
Clark Chen
27bcf92e9b Update remote delete send threshold. 2023-09-22 16:50:20 -03:00
Alex Hart
31100c3d82 Fix bug causing WifiDirect transfers to not initialize.
Fixes #13173
2023-09-22 16:50:20 -03:00
Alex Hart
119da2e76e Fix crash in welcome fragment click handling. 2023-09-22 16:50:20 -03:00
Greyson Parrelli
588a6cf74f Remove PNP flag checks in some areas. 2023-09-22 16:50:20 -03:00
Greyson Parrelli
eb6394eb6a Fix SSE event bug and make the assertion guarded by a separate flag. 2023-09-21 15:56:03 -04:00
Alex Hart
76de183ec2 Bump version to 6.34.0 2023-09-21 16:29:16 -03:00
Alex Hart
ba31ceb3e7 Updated baseline profile. 2023-09-21 16:24:23 -03:00
Alex Hart
e94e0f8a6b Update translations and other static files. 2023-09-21 16:19:36 -03:00
Nicholas
f8283acfae Instant Video Playback UI 2023-09-21 15:12:11 -04:00
Alex Hart
f8cb26ca74 Replace TypingIndicatorItemDecoration with TypingIndicatorAdapter. 2023-09-21 14:05:49 -03:00
Alex Hart
190b9da6c7 Fix icon alignment in CIV2 footer. 2023-09-21 13:59:52 -03:00
Alex Hart
f84b46148c Show delivery status in forced footers for CIV2. 2023-09-21 13:59:52 -03:00
Alex Hart
12db8b5ee1 Fix swipe to reply positioning in CIV2. 2023-09-21 13:59:52 -03:00
Alex Hart
05b5078aa9 Hide footer end pad in CIV2 non-end items. 2023-09-21 13:59:52 -03:00
Alex Hart
85b7ee85f3 Display date in forced footer for CIV2. 2023-09-21 13:59:52 -03:00
Alex Hart
326b728d4b Always show expiration timer if there is one. 2023-09-21 13:59:52 -03:00
Alex Hart
2e45e131b1 Fix tinting of CIV2 expiration icon. 2023-09-21 13:59:52 -03:00
Alex Hart
1aa95c057b Fix edit message label. 2023-09-21 13:59:52 -03:00
Alex Hart
6de7a849b3 Increment CIV2 feature flag. 2023-09-21 13:59:52 -03:00
Nicholas
268091b10e Close media preview upon remote delete. 2023-09-21 13:59:52 -03:00
Alex Hart
3920c85ab7 Increment edit message feature flag. 2023-09-21 13:59:52 -03:00
Alex Hart
524565f0bb Add animations for add name fragment in call links. 2023-09-21 13:59:52 -03:00
Nicholas Tinsley
69c1c856d9 Prevent crash from toolbar subtitle in call view. 2023-09-21 13:59:52 -03:00
Nicholas Tinsley
dd62d92ffb Don't stop playback on seek. 2023-09-21 13:59:52 -03:00
Nicholas Tinsley
f7e89d75a4 Deduplicate audio devices by name. 2023-09-21 13:59:52 -03:00
Nicholas Tinsley
023f31eadd Set recipients name in safety number verification screen.
Addresses #13171.
2023-09-21 13:59:52 -03:00
Alex Hart
da8df5beac Avoid triggering requestLayout during measure pass for setBubbleWidth. 2023-09-21 13:59:52 -03:00
Alex Hart
f3a8825cb9 Revert "Add proper tinting to delivery status icon."
This reverts commit c4ac63ea7a89e44f478b0321901eaf43e2745502.
2023-09-21 13:59:52 -03:00
Cody Henthorne
835fd47482 Fix crashes related to activity starts. 2023-09-21 13:59:52 -03:00
Cody Henthorne
efbd5cab85 Convert SignalService, Database, Group, Payment, and other remaining protos to wire. 2023-09-21 13:59:52 -03:00
Alex Hart
a6b7d0bcc5 Set outgoing download tint to onCustom. 2023-09-21 13:59:52 -03:00
Alex Hart
e06126d889 Fix pulse on quote press. 2023-09-21 13:59:51 -03:00
Alex Hart
4bf8e2c488 Fix auto-update timestamps. 2023-09-21 13:59:51 -03:00
Alex Hart
1c55ad21a3 Add background to group sender name in CIV2. 2023-09-21 13:59:51 -03:00
Alex Hart
3a601e1e65 Rename binding fields for CIV2. 2023-09-21 13:59:51 -03:00
Alex Hart
c953003c2f Fix footer background sizing. 2023-09-21 13:59:51 -03:00
Alex Hart
18de51a531 Add proper tinting to delivery status icon. 2023-09-21 13:59:51 -03:00
Alex Hart
ab6d3b5e8d Set bubble width in onMeasure. 2023-09-21 13:59:51 -03:00
Alex Hart
151980c6de Bump version to 6.33.3 2023-09-21 13:51:58 -03:00
Alex Hart
375527b765 Updated baseline profile. 2023-09-21 13:42:50 -03:00
Alex Hart
2978e567d4 Update translations and other static files. 2023-09-21 13:37:48 -03:00
Alex Hart
8ad50ab61c Check for database initialisation in AvatarProvider#openFile. 2023-09-21 13:13:52 -03:00
Cody Henthorne
2145ded2f2 Improve network reliability. 2023-09-21 12:10:27 -04:00
716 changed files with 19210 additions and 11055 deletions

View File

@@ -18,6 +18,8 @@ jobs:
steps:
- uses: actions/checkout@v3
with:
submodules: true
- name: set up JDK 17
uses: actions/setup-java@v3

View File

@@ -15,6 +15,7 @@ jobs:
steps:
- uses: actions/checkout@v3
with:
submodules: true
ref: ${{ github.event.pull_request.base.sha }}
- name: set up JDK 17
@@ -45,6 +46,7 @@ jobs:
- uses: actions/checkout@v3
with:
submodules: true
clean: 'false'
- name: Build with Gradle

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "libwebp"]
path = libwebp
url = https://github.com/webmproject/libwebp.git

View File

@@ -15,6 +15,12 @@ Truths which we believe to be self-evident:
1. **There is no such thing as time.** Protocol ideas that require synchronized clocks are doomed to failure.
## Building
1. You'll need to get the `libwebp` submodule after checking out the repository with `git submodule init && git submodule update`
1. Most things are pretty straightforward, and opening the project in Android Studio should get you most of the way there.
1. Depending on your configuration, you'll also likely need to install additional SDK Tool components, namely the versions of NDK and CMake we are currently using in our [Docker](https://github.com/signalapp/Signal-Android/blob/main/reproducible-builds/Dockerfile#L30) configuration.
## Issues
### Useful bug reports

View File

@@ -3,7 +3,6 @@ import com.android.build.api.dsl.ManagedVirtualDevice
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'com.google.protobuf'
id 'androidx.navigation.safeargs'
id 'org.jlleitschuh.gradle.ktlint'
id 'org.jetbrains.kotlin.android'
@@ -16,21 +15,6 @@ plugins {
apply from: 'static-ips.gradle'
protobuf {
protoc {
artifact = 'com.google.protobuf:protoc:3.18.0'
}
generateProtoTasks {
all().each { task ->
task.builtins {
java {
option "lite"
}
}
}
}
}
wire {
kotlin {
javaInterop = true
@@ -49,8 +33,8 @@ ktlint {
version = "0.49.1"
}
def canonicalVersionCode = 1330
def canonicalVersionName = "6.33.2"
def canonicalVersionCode = 1345
def canonicalVersionName = "6.36.3"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -536,13 +520,11 @@ dependencies {
implementation project(':sms-exporter')
implementation project(':sticky-header-grid')
implementation project(':photoview')
implementation project(':glide-webp')
implementation libs.libsignal.android
implementation libs.google.protobuf.javalite
implementation(libs.mobilecoin) {
exclude group: 'com.google.protobuf'
}
implementation libs.mobilecoin
implementation libs.signal.ringrtc

View File

@@ -26,9 +26,7 @@ class SignalInstrumentationApplicationContext : ApplicationContext() {
}
override fun initializeLogging() {
persistentLogger = PersistentLogger(this)
Log.initialize({ true }, AndroidLogger(), persistentLogger, inMemoryLogger)
Log.initialize({ true }, AndroidLogger(), PersistentLogger(this), inMemoryLogger)
SignalProtocolLoggerProvider.setProvider(CustomSignalProtocolLogger())

View File

@@ -144,6 +144,7 @@ class ConversationItemPreviewer {
1024,
Optional.empty(),
Optional.empty(),
0,
Optional.of("/not-there.jpg"),
false,
false,

View File

@@ -47,7 +47,6 @@ class V2ConversationItemShapeTest {
val expected = V2ConversationItemShape.MessageShape.SINGLE
val actual = testSubject.setMessageShape(
isLtr = true,
currentMessage = getMessageRecord(),
isGroupThread = false,
adapterPosition = 5
@@ -69,7 +68,6 @@ class V2ConversationItemShapeTest {
val expected = V2ConversationItemShape.MessageShape.END
val actual = testSubject.setMessageShape(
isLtr = true,
currentMessage = getMessageRecord(now),
isGroupThread = false,
adapterPosition = 5
@@ -91,7 +89,6 @@ class V2ConversationItemShapeTest {
val expected = V2ConversationItemShape.MessageShape.START
val actual = testSubject.setMessageShape(
isLtr = true,
currentMessage = getMessageRecord(prev),
isGroupThread = false,
adapterPosition = 5
@@ -115,7 +112,6 @@ class V2ConversationItemShapeTest {
val expected = V2ConversationItemShape.MessageShape.MIDDLE
val actual = testSubject.setMessageShape(
isLtr = true,
currentMessage = getMessageRecord(now),
isGroupThread = false,
adapterPosition = 5
@@ -137,7 +133,6 @@ class V2ConversationItemShapeTest {
val expected = V2ConversationItemShape.MessageShape.SINGLE
val actual = testSubject.setMessageShape(
isLtr = true,
currentMessage = getMessageRecord(now),
isGroupThread = false,
adapterPosition = 5
@@ -159,7 +154,6 @@ class V2ConversationItemShapeTest {
val expected = V2ConversationItemShape.MessageShape.SINGLE
val actual = testSubject.setMessageShape(
isLtr = true,
currentMessage = getMessageRecord(prev),
isGroupThread = false,
adapterPosition = 5
@@ -183,7 +177,6 @@ class V2ConversationItemShapeTest {
val expected = V2ConversationItemShape.MessageShape.SINGLE
val actual = testSubject.setMessageShape(
isLtr = true,
currentMessage = getMessageRecord(now),
isGroupThread = false,
adapterPosition = 5

View File

@@ -293,22 +293,22 @@ class GroupTableTest {
private fun insertPushGroup(
members: List<DecryptedMember> = listOf(
DecryptedMember.newBuilder()
.setAciBytes(harness.self.requireAci().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
DecryptedMember.Builder()
.aciBytes(harness.self.requireAci().toByteString())
.joinedAtRevision(0)
.role(Member.Role.DEFAULT)
.build(),
DecryptedMember.newBuilder()
.setAciBytes(Recipient.resolved(harness.others[0]).requireAci().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
DecryptedMember.Builder()
.aciBytes(Recipient.resolved(harness.others[0]).requireAci().toByteString())
.joinedAtRevision(0)
.role(Member.Role.DEFAULT)
.build()
)
): GroupId {
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
val decryptedGroupState = DecryptedGroup.newBuilder()
.addAllMembers(members)
.setRevision(0)
val decryptedGroupState = DecryptedGroup.Builder()
.members(members)
.revision(0)
.build()
return groupTable.create(groupMasterKey, decryptedGroupState)!!
@@ -317,23 +317,23 @@ class GroupTableTest {
private fun insertPushGroupWithSelfAndOthers(others: List<RecipientId>): GroupId {
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
val selfMember: DecryptedMember = DecryptedMember.newBuilder()
.setAciBytes(harness.self.requireAci().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
val selfMember: DecryptedMember = DecryptedMember.Builder()
.aciBytes(harness.self.requireAci().toByteString())
.joinedAtRevision(0)
.role(Member.Role.DEFAULT)
.build()
val otherMembers: List<DecryptedMember> = others.map { id ->
DecryptedMember.newBuilder()
.setAciBytes(Recipient.resolved(id).requireAci().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
DecryptedMember.Builder()
.aciBytes(Recipient.resolved(id).requireAci().toByteString())
.joinedAtRevision(0)
.role(Member.Role.DEFAULT)
.build()
}
val decryptedGroupState = DecryptedGroup.newBuilder()
.addAllMembers(listOf(selfMember) + otherMembers)
.setRevision(0)
val decryptedGroupState = DecryptedGroup.Builder()
.members(listOf(selfMember) + otherMembers)
.revision(0)
.build()
return groupTable.create(groupMasterKey, decryptedGroupState)!!

View File

@@ -48,7 +48,7 @@ class MessageTableTest_gifts {
val messageId = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
giftBadge = GiftBadge()
)
val result = mms.setOutgoingGiftsRevealed(listOf(messageId))
@@ -62,7 +62,7 @@ class MessageTableTest_gifts {
val messageId = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
giftBadge = GiftBadge()
)
mms.setOutgoingGiftsRevealed(listOf(messageId))
@@ -76,13 +76,13 @@ class MessageTableTest_gifts {
val messageId = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
giftBadge = GiftBadge()
)
MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 2,
giftBadge = GiftBadge.getDefaultInstance()
giftBadge = GiftBadge()
)
val result = mms.setOutgoingGiftsRevealed(listOf(messageId))
@@ -96,13 +96,13 @@ class MessageTableTest_gifts {
val messageId = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
giftBadge = GiftBadge()
)
val messageId2 = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 2,
giftBadge = GiftBadge.getDefaultInstance()
giftBadge = GiftBadge()
)
val result = mms.setOutgoingGiftsRevealed(listOf(messageId, messageId2))
@@ -115,13 +115,13 @@ class MessageTableTest_gifts {
val messageId = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
giftBadge = GiftBadge()
)
val messageId2 = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 2,
giftBadge = GiftBadge.getDefaultInstance()
giftBadge = GiftBadge()
)
MmsHelper.insert(
@@ -140,13 +140,13 @@ class MessageTableTest_gifts {
val messageId = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
giftBadge = GiftBadge()
)
val messageId2 = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 2,
giftBadge = GiftBadge.getDefaultInstance()
giftBadge = GiftBadge()
)
val messageId3 = MmsHelper.insert(
@@ -165,13 +165,13 @@ class MessageTableTest_gifts {
MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
giftBadge = GiftBadge()
)
MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 2,
giftBadge = GiftBadge.getDefaultInstance()
giftBadge = GiftBadge()
)
val messageId3 = MmsHelper.insert(

View File

@@ -502,6 +502,18 @@ class RecipientTableTest_getAndPossiblyMerge {
expectNoSessionSwitchoverEvent()
}
test("steal, e164+pni+aci * pni+aci, all provided, aci sessions but not pni sessions, no SSE expected") {
given(E164_A, PNI_A, ACI_A, createThread = true, aciSession = true, pniSession = false)
given(null, PNI_B, ACI_B, createThread = false, aciSession = true, pniSession = false)
process(E164_A, PNI_B, ACI_A)
expect(E164_A, PNI_B, ACI_A)
expect(null, null, ACI_B)
expectNoSessionSwitchoverEvent()
}
test("merge, e164 & pni & aci, all provided") {
given(E164_A, null, null)
given(null, PNI_A, null)
@@ -1228,7 +1240,7 @@ class RecipientTableTest_getAndPossiblyMerge {
.use { cursor: Cursor ->
if (cursor.moveToFirst()) {
val bytes = Base64.decode(cursor.requireNonNullString(MessageTable.BODY))
ThreadMergeEvent.parseFrom(bytes)
ThreadMergeEvent.ADAPTER.decode(bytes)
} else {
null
}
@@ -1246,7 +1258,7 @@ class RecipientTableTest_getAndPossiblyMerge {
.use { cursor: Cursor ->
if (cursor.moveToFirst()) {
val bytes = Base64.decode(cursor.requireNonNullString(MessageTable.BODY))
SessionSwitchoverEvent.parseFrom(bytes)
SessionSwitchoverEvent.ADAPTER.decode(bytes)
} else {
null
}

View File

@@ -22,8 +22,9 @@ import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assertIs
import org.thoughtcrime.securesms.util.MessageTableTestUtils
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.EditMessage
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.EditMessage
import org.whispersystems.signalservice.internal.push.SyncMessage
import kotlin.time.Duration.Companion.seconds
@RunWith(AndroidJUnit4::class)
@@ -67,16 +68,17 @@ class EditMessageSyncProcessorTest {
val content = MessageContentFuzzer.fuzzTextMessage()
val metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, toRecipient.id)
val syncContent = SignalServiceProtos.Content.newBuilder().setSyncMessage(
SignalServiceProtos.SyncMessage.newBuilder().setSent(
SignalServiceProtos.SyncMessage.Sent.newBuilder()
.setDestinationServiceId(metadata.destinationServiceId.toString())
.setTimestamp(originalTimestamp)
.setExpirationStartTimestamp(originalTimestamp)
.setMessage(content.dataMessage)
)
val syncContent = Content.Builder().syncMessage(
SyncMessage.Builder().sent(
SyncMessage.Sent.Builder()
.destinationServiceId(metadata.destinationServiceId.toString())
.timestamp(originalTimestamp)
.expirationStartTimestamp(originalTimestamp)
.message(content.dataMessage)
.build()
).build()
).build()
SignalDatabase.recipients.setExpireMessages(toRecipient.id, content.dataMessage.expireTimer)
SignalDatabase.recipients.setExpireMessages(toRecipient.id, content.dataMessage?.expireTimer ?: 0)
val syncTextMessage = TestMessage(
envelope = MessageContentFuzzer.envelope(originalTimestamp),
content = syncContent,
@@ -86,18 +88,20 @@ class EditMessageSyncProcessorTest {
val editTimestamp = originalTimestamp + 200
val editedContent = MessageContentFuzzer.fuzzTextMessage()
val editSyncContent = SignalServiceProtos.Content.newBuilder().setSyncMessage(
SignalServiceProtos.SyncMessage.newBuilder().setSent(
SignalServiceProtos.SyncMessage.Sent.newBuilder()
.setDestinationServiceId(metadata.destinationServiceId.toString())
.setTimestamp(editTimestamp)
.setExpirationStartTimestamp(editTimestamp)
.setEditMessage(
EditMessage.newBuilder()
.setDataMessage(editedContent.dataMessage)
.setTargetSentTimestamp(originalTimestamp)
val editSyncContent = Content.Builder().syncMessage(
SyncMessage.Builder().sent(
SyncMessage.Sent.Builder()
.destinationServiceId(metadata.destinationServiceId.toString())
.timestamp(editTimestamp)
.expirationStartTimestamp(editTimestamp)
.editMessage(
EditMessage.Builder()
.dataMessage(editedContent.dataMessage)
.targetSentTimestamp(originalTimestamp)
.build()
)
)
.build()
).build()
).build()
val syncEditMessage = TestMessage(
@@ -109,38 +113,38 @@ class EditMessageSyncProcessorTest {
testResult.runSync(listOf(syncTextMessage, syncEditMessage))
SignalDatabase.recipients.setExpireMessages(toRecipient.id, content.dataMessage.expireTimer / 1000)
SignalDatabase.recipients.setExpireMessages(toRecipient.id, (content.dataMessage?.expireTimer ?: 0) / 1000)
val originalTextMessage = OutgoingMessage(
threadRecipient = toRecipient,
sentTimeMillis = originalTimestamp,
body = content.dataMessage.body,
expiresIn = content.dataMessage.expireTimer.seconds.inWholeMilliseconds,
body = content.dataMessage?.body ?: "",
expiresIn = content.dataMessage?.expireTimer?.seconds?.inWholeMilliseconds ?: 0,
isUrgent = true,
isSecure = true,
bodyRanges = content.dataMessage.bodyRangesList.toBodyRangeList()
bodyRanges = content.dataMessage?.bodyRanges.toBodyRangeList()
)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(toRecipient)
val originalMessageId = SignalDatabase.messages.insertMessageOutbox(originalTextMessage, threadId, false, null)
SignalDatabase.messages.markAsSent(originalMessageId, true)
if (content.dataMessage.expireTimer > 0) {
if ((content.dataMessage?.expireTimer ?: 0) > 0) {
SignalDatabase.messages.markExpireStarted(originalMessageId, originalTimestamp)
}
val editMessage = OutgoingMessage(
threadRecipient = toRecipient,
sentTimeMillis = editTimestamp,
body = editedContent.dataMessage.body,
expiresIn = content.dataMessage.expireTimer.seconds.inWholeMilliseconds,
body = editedContent.dataMessage?.body ?: "",
expiresIn = content.dataMessage?.expireTimer?.seconds?.inWholeMilliseconds ?: 0,
isUrgent = true,
isSecure = true,
bodyRanges = editedContent.dataMessage.bodyRangesList.toBodyRangeList(),
bodyRanges = editedContent.dataMessage?.bodyRanges.toBodyRangeList(),
messageToEdit = originalMessageId
)
val editMessageId = SignalDatabase.messages.insertMessageOutbox(editMessage, threadId, false, null)
SignalDatabase.messages.markAsSent(editMessageId, true)
if (content.dataMessage.expireTimer > 0) {
if ((content.dataMessage?.expireTimer ?: 0) > 0) {
SignalDatabase.messages.markExpireStarted(editMessageId, originalTimestamp)
}
testResult.collectLocal()
@@ -167,7 +171,7 @@ class EditMessageSyncProcessorTest {
fun runSync(messages: List<TestMessage>) {
messages.forEach { (envelope, content, metadata, serverDeliveredTimestamp) ->
if (content.hasSyncMessage()) {
if (content.syncMessage != null) {
processorV2.process(
envelope,
content,

View File

@@ -1,13 +1,13 @@
package org.thoughtcrime.securesms.messages
import androidx.test.ext.junit.runners.AndroidJUnit4
import okio.ByteString.Companion.toByteString
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.database.GroupReceiptTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.toProtoByteString
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
import org.thoughtcrime.securesms.testing.GroupTestingUtils
import org.thoughtcrime.securesms.testing.GroupTestingUtils.asMember
@@ -15,8 +15,8 @@ import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assertIs
import org.thoughtcrime.securesms.util.MessageTableTestUtils
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2
import org.whispersystems.signalservice.internal.push.DataMessage
import org.whispersystems.signalservice.internal.push.GroupContextV2
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
@@ -41,9 +41,9 @@ class MessageContentProcessor__recipientStatusTest {
@Test
fun syncGroupSentTextMessageWithRecipientUpdateFollowup() {
val (groupId, masterKey, groupRecipientId) = GroupTestingUtils.insertGroup(revision = 0, harness.self.asMember(), harness.others[0].asMember(), harness.others[1].asMember())
val groupContextV2 = GroupContextV2.newBuilder().setRevision(0).setMasterKey(masterKey.serialize().toProtoByteString()).build()
val groupContextV2 = GroupContextV2.Builder().revision(0).masterKey(masterKey.serialize().toByteString()).build()
val initialTextMessage = DataMessage.newBuilder().buildWith {
val initialTextMessage = DataMessage.Builder().buildWith {
body = MessageContentFuzzer.string()
groupV2 = groupContextV2
timestamp = envelopeTimestamp

View File

@@ -6,7 +6,6 @@ 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
@@ -26,7 +25,7 @@ import org.thoughtcrime.securesms.testing.Entry
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.push.Envelope
import org.whispersystems.signalservice.internal.websocket.WebSocketMessage
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import java.util.regex.Pattern
@@ -93,7 +92,7 @@ class MessageProcessingPerformanceTest {
val messageCount = 100
val envelopes = generateInboundEnvelopes(bobClient, messageCount)
val firstTimestamp = envelopes.first().timestamp
val lastTimestamp = envelopes.last().timestamp
val lastTimestamp = envelopes.last().timestamp ?: 0
// Inject the envelopes into the websocket
Thread {
@@ -190,7 +189,7 @@ class MessageProcessingPerformanceTest {
path = "/api/v1/message",
id = Random(System.currentTimeMillis()).nextLong(),
headers = listOf("X-Signal-Timestamp: ${this.timestamp}"),
body = this.toByteArray().toByteString()
body = this.encodeByteString()
)
).encodeByteString()
}

View File

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

View File

@@ -5,7 +5,8 @@ import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.testing.LogPredicate
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.Envelope
class TimingMessageContentProcessor(context: Context) : MessageContentProcessor(context) {
companion object {
@@ -19,9 +20,9 @@ class TimingMessageContentProcessor(context: Context) : MessageContentProcessor(
fun endTag(timestamp: Long) = "$timestamp end"
}
override fun process(envelope: SignalServiceProtos.Envelope, content: SignalServiceProtos.Content, metadata: EnvelopeMetadata, serverDeliveredTimestamp: Long, processingEarlyContent: Boolean, localMetric: SignalLocalMetrics.MessageReceive?) {
Log.d(TAG, startTag(envelope.timestamp))
override fun process(envelope: Envelope, content: Content, metadata: EnvelopeMetadata, serverDeliveredTimestamp: Long, processingEarlyContent: Boolean, localMetric: SignalLocalMetrics.MessageReceive?) {
Log.d(TAG, startTag(envelope.timestamp!!))
super.process(envelope, content, metadata, serverDeliveredTimestamp, processingEarlyContent, localMetric)
Log.d(TAG, endTag(envelope.timestamp))
Log.d(TAG, endTag(envelope.timestamp!!))
}
}

View File

@@ -11,7 +11,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.FakeClientHelpers.toEnvelope
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
import org.whispersystems.signalservice.internal.push.Envelope
/**
* Welcome to Alice's Client.

View File

@@ -31,11 +31,10 @@ import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
import org.whispersystems.signalservice.api.push.DistributionId
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import org.whispersystems.signalservice.internal.push.Envelope
import java.util.Optional
import java.util.UUID
import java.util.concurrent.locks.ReentrantLock
import kotlin.UnsupportedOperationException
/**
* Welcome to Bob's Client.
@@ -61,7 +60,7 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
}
/** Inspired by SignalServiceMessageSender#getEncryptedMessage */
fun encrypt(now: Long): SignalServiceProtos.Envelope {
fun encrypt(now: Long): Envelope {
val envelopeContent = FakeClientHelpers.encryptedTextMessage(now)
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, null)
@@ -72,10 +71,10 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
}
return cipher.encrypt(getAliceProtocolAddress(), getAliceUnidentifiedAccess(), envelopeContent)
.toEnvelope(envelopeContent.content.get().dataMessage.timestamp, getAliceServiceId())
.toEnvelope(envelopeContent.content.get().dataMessage!!.timestamp!!, getAliceServiceId())
}
fun decrypt(envelope: SignalServiceProtos.Envelope, serverDeliveredTimestamp: Long) {
fun decrypt(envelope: Envelope, serverDeliveredTimestamp: Long) {
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, UnidentifiedAccessUtil.getCertificateValidator())
cipher.decrypt(envelope, serverDeliveredTimestamp)
}

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.testing
import okio.ByteString.Companion.toByteString
import org.signal.libsignal.internal.Native
import org.signal.libsignal.internal.NativeHandleGuard
import org.signal.libsignal.metadata.certificate.CertificateValidator
@@ -9,15 +10,16 @@ import org.signal.libsignal.protocol.ecc.Curve
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.database.model.toProtoByteString
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
import org.whispersystems.signalservice.api.crypto.ContentHint
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.DataMessage
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
import org.whispersystems.util.Base64
import java.util.Optional
import java.util.UUID
@@ -52,9 +54,9 @@ object FakeClientHelpers {
}
fun encryptedTextMessage(now: Long, message: String = "Test body message"): EnvelopeContent {
val content = SignalServiceProtos.Content.newBuilder().apply {
setDataMessage(
SignalServiceProtos.DataMessage.newBuilder().apply {
val content = Content.Builder().apply {
dataMessage(
DataMessage.Builder().buildWith {
body = message
timestamp = now
}
@@ -64,16 +66,16 @@ object FakeClientHelpers {
}
fun OutgoingPushMessage.toEnvelope(timestamp: Long, destination: ServiceId): Envelope {
return Envelope.newBuilder()
.setType(Envelope.Type.valueOf(this.type))
.setSourceDevice(1)
.setTimestamp(timestamp)
.setServerTimestamp(timestamp + 1)
.setDestinationServiceId(destination.toString())
.setServerGuid(UUID.randomUUID().toString())
.setContent(Base64.decode(this.content).toProtoByteString())
.setUrgent(true)
.setStory(false)
return Envelope.Builder()
.type(Envelope.Type.fromValue(this.type))
.sourceDevice(1)
.timestamp(timestamp)
.serverTimestamp(timestamp + 1)
.destinationServiceId(destination.toString())
.serverGuid(UUID.randomUUID().toString())
.content(Base64.decode(this.content).toByteString())
.urgent(true)
.story(false)
.build()
}
}

View File

@@ -16,19 +16,19 @@ import kotlin.random.Random
*/
object GroupTestingUtils {
fun member(aci: ACI, revision: Int = 0, role: Member.Role = Member.Role.ADMINISTRATOR): DecryptedMember {
return DecryptedMember.newBuilder()
.setAciBytes(aci.toByteString())
.setJoinedAtRevision(revision)
.setRole(role)
return DecryptedMember.Builder()
.aciBytes(aci.toByteString())
.joinedAtRevision(revision)
.role(role)
.build()
}
fun insertGroup(revision: Int = 0, vararg members: DecryptedMember): TestGroupInfo {
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
val decryptedGroupState = DecryptedGroup.newBuilder()
.addAllMembers(members.toList())
.setRevision(revision)
.setTitle(MessageContentFuzzer.string())
val decryptedGroupState = DecryptedGroup.Builder()
.members(members.toList())
.revision(revision)
.title(MessageContentFuzzer.string())
.build()
val groupId = SignalDatabase.groups.create(groupMasterKey, decryptedGroupState)!!

View File

@@ -1,21 +1,20 @@
package org.thoughtcrime.securesms.testing
import com.google.protobuf.ByteString
import org.thoughtcrime.securesms.database.model.toProtoByteString
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
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 org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage
import org.whispersystems.signalservice.internal.push.AttachmentPointer
import org.whispersystems.signalservice.internal.push.BodyRange
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.DataMessage
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.push.GroupContextV2
import org.whispersystems.signalservice.internal.push.SyncMessage
import java.util.UUID
import kotlin.random.Random
import kotlin.random.nextInt
@@ -35,10 +34,10 @@ object MessageContentFuzzer {
* Create an [Envelope].
*/
fun envelope(timestamp: Long): Envelope {
return Envelope.newBuilder()
.setTimestamp(timestamp)
.setServerTimestamp(timestamp + 5)
.setServerGuidBytes(UuidUtil.toByteString(UUID.randomUUID()))
return Envelope.Builder()
.timestamp(timestamp)
.serverTimestamp(timestamp + 5)
.serverGuid(UUID.randomUUID().toString())
.build()
}
@@ -62,20 +61,22 @@ object MessageContentFuzzer {
* - Bold style body ranges
*/
fun fuzzTextMessage(groupContextV2: GroupContextV2? = null): Content {
return Content.newBuilder()
.setDataMessage(
DataMessage.newBuilder().buildWith {
return Content.Builder()
.dataMessage(
DataMessage.Builder().buildWith {
body = string()
if (random.nextBoolean()) {
expireTimer = random.nextInt(0..28.days.inWholeSeconds.toInt())
}
if (random.nextBoolean()) {
addBodyRanges(
SignalServiceProtos.BodyRange.newBuilder().buildWith {
start = 0
length = 1
style = SignalServiceProtos.BodyRange.Style.BOLD
}
bodyRanges(
listOf(
BodyRange.Builder().buildWith {
start = 0
length = 1
style = BodyRange.Style.BOLD
}
)
)
}
if (groupContextV2 != null) {
@@ -95,16 +96,16 @@ object MessageContentFuzzer {
recipientUpdate: Boolean = false
): Content {
return Content
.newBuilder()
.setSyncMessage(
SyncMessage.newBuilder().buildWith {
sent = SyncMessage.Sent.newBuilder().buildWith {
.Builder()
.syncMessage(
SyncMessage.Builder().buildWith {
sent = SyncMessage.Sent.Builder().buildWith {
timestamp = textMessage.timestamp
message = textMessage
isRecipientUpdate = recipientUpdate
addAllUnidentifiedStatus(
unidentifiedStatus(
deliveredTo.map {
SyncMessage.Sent.UnidentifiedDeliveryStatus.newBuilder().buildWith {
SyncMessage.Sent.UnidentifiedDeliveryStatus.Builder().buildWith {
destinationServiceId = Recipient.resolved(it).requireServiceId().toString()
unidentified = true
}
@@ -123,9 +124,9 @@ object MessageContentFuzzer {
* - A message with 0-2 attachment pointers and may contain a text body
*/
fun fuzzMediaMessageWithBody(quoteAble: List<TestMessage> = emptyList()): Content {
return Content.newBuilder()
.setDataMessage(
DataMessage.newBuilder().buildWith {
return Content.Builder()
.dataMessage(
DataMessage.Builder().buildWith {
if (random.nextBoolean()) {
body = string()
}
@@ -133,28 +134,28 @@ object MessageContentFuzzer {
if (random.nextBoolean() && quoteAble.isNotEmpty()) {
body = string()
val quoted = quoteAble.random(random)
quote = DataMessage.Quote.newBuilder().buildWith {
quote = DataMessage.Quote.Builder().buildWith {
id = quoted.envelope.timestamp
authorAci = quoted.metadata.sourceServiceId.toString()
text = quoted.content.dataMessage.body
addAllAttachments(quoted.content.dataMessage.attachmentsList)
addAllBodyRanges(quoted.content.dataMessage.bodyRangesList)
text = quoted.content.dataMessage?.body
attachments(quoted.content.dataMessage?.attachments ?: emptyList())
bodyRanges(quoted.content.dataMessage?.bodyRanges ?: emptyList())
type = DataMessage.Quote.Type.NORMAL
}
}
if (random.nextFloat() < 0.1 && quoteAble.isNotEmpty()) {
val quoted = quoteAble.random(random)
quote = DataMessage.Quote.newBuilder().buildWith {
id = random.nextLong(quoted.envelope.timestamp - 1000000, quoted.envelope.timestamp)
quote = DataMessage.Quote.Builder().buildWith {
id = random.nextLong(quoted.envelope.timestamp!! - 1000000, quoted.envelope.timestamp!!)
authorAci = quoted.metadata.sourceServiceId.toString()
text = quoted.content.dataMessage.body
text = quoted.content.dataMessage?.body
}
}
if (random.nextFloat() < 0.25) {
val total = random.nextInt(1, 2)
(0..total).forEach { _ -> addAttachments(attachmentPointer()) }
attachments((0..total).map { attachmentPointer() })
}
}
)
@@ -166,12 +167,12 @@ object MessageContentFuzzer {
* - A reaction to a prior message
*/
fun fuzzMediaMessageNoContent(previousMessages: List<TestMessage> = emptyList()): Content {
return Content.newBuilder()
.setDataMessage(
DataMessage.newBuilder().buildWith {
return Content.Builder()
.dataMessage(
DataMessage.Builder().buildWith {
if (random.nextFloat() < 0.25) {
val reactTo = previousMessages.random(random)
reaction = DataMessage.Reaction.newBuilder().buildWith {
reaction = DataMessage.Reaction.Builder().buildWith {
emoji = emojis.random(random)
remove = false
targetAuthorAci = reactTo.metadata.sourceServiceId.toString()
@@ -187,15 +188,15 @@ object MessageContentFuzzer {
* - A sticker
*/
fun fuzzMediaMessageNoText(previousMessages: List<TestMessage> = emptyList()): Content {
return Content.newBuilder()
.setDataMessage(
DataMessage.newBuilder().buildWith {
return Content.Builder()
.dataMessage(
DataMessage.Builder().buildWith {
if (random.nextFloat() < 0.9) {
sticker = DataMessage.Sticker.newBuilder().buildWith {
sticker = DataMessage.Sticker.Builder().buildWith {
packId = byteString(length = 24)
packKey = byteString(length = 128)
stickerId = random.nextInt()
data = attachmentPointer()
data_ = attachmentPointer()
emoji = emojis.random(random)
}
}
@@ -223,14 +224,14 @@ object MessageContentFuzzer {
* Generate a random [ByteString].
*/
fun byteString(length: Int = 512): ByteString {
return random.nextBytes(length).toProtoByteString()
return random.nextBytes(length).toByteString()
}
/**
* Generate a random [AttachmentPointer].
*/
fun attachmentPointer(): AttachmentPointer {
return AttachmentPointer.newBuilder().run {
return AttachmentPointer.Builder().run {
cdnKey = string()
contentType = mediaTypes.random(random)
key = byteString()

View File

@@ -1,70 +0,0 @@
package org.thoughtcrime.securesms.testing
import com.google.protobuf.ByteString
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2
import org.whispersystems.signalservice.internal.serialize.protos.AddressProto
import org.whispersystems.signalservice.internal.serialize.protos.MetadataProto
import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto
import java.util.UUID
import kotlin.random.Random
class TestProtos private constructor() {
fun address(
uuid: UUID = UUID.randomUUID()
): AddressProto.Builder {
return AddressProto.newBuilder()
.setUuid(ACI.from(uuid).toByteString())
}
fun metadata(
address: AddressProto = address().build()
): MetadataProto.Builder {
return MetadataProto.newBuilder()
.setAddress(address)
}
fun groupContextV2(
revision: Int = 0,
masterKeyBytes: ByteArray = Random.Default.nextBytes(GroupMasterKey.SIZE)
): GroupContextV2.Builder {
return GroupContextV2.newBuilder()
.setRevision(revision)
.setMasterKey(ByteString.copyFrom(masterKeyBytes))
}
fun storyContext(
sentTimestamp: Long = Random.nextLong(),
authorUuid: String = UUID.randomUUID().toString()
): DataMessage.StoryContext.Builder {
return DataMessage.StoryContext.newBuilder()
.setAuthorAci(authorUuid)
.setSentTimestamp(sentTimestamp)
}
fun dataMessage(): DataMessage.Builder {
return DataMessage.newBuilder()
}
fun content(): SignalServiceProtos.Content.Builder {
return SignalServiceProtos.Content.newBuilder()
}
fun serviceContent(
localAddress: AddressProto = address().build(),
metadata: MetadataProto = metadata().build()
): SignalServiceContentProto.Builder {
return SignalServiceContentProto.newBuilder()
.setLocalAddress(localAddress)
.setMetadata(metadata)
}
companion object {
fun <T> build(buildFn: TestProtos.() -> T): T {
return TestProtos().buildFn()
}
}
}

View File

@@ -149,6 +149,7 @@ object TestMessages {
1024,
Optional.empty(),
Optional.empty(),
0,
Optional.of("/not-there.jpg"),
false,
false,
@@ -171,6 +172,7 @@ object TestMessages {
1024,
Optional.empty(),
Optional.empty(),
0,
Optional.of("/not-there.aac"),
true,
false,

View File

@@ -252,6 +252,7 @@
<activity-alias android:name=".RoutingActivity"
android:targetActivity=".MainActivity"
android:resizeableActivity="true"
android:exported="true">
<intent-filter>
@@ -630,6 +631,7 @@
android:launchMode="singleTask"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:parentActivityName=".MainActivity"
android:resizeableActivity="true"
android:exported="false">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
@@ -1029,6 +1031,7 @@
<activity android:name=".MainActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:resizeableActivity="true"
android:exported="false"/>
<activity android:name=".pin.PinRestoreActivity"

File diff suppressed because it is too large Load Diff

View File

@@ -90,8 +90,6 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.PowerManagerCompat;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -124,9 +122,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
private static final String TAG = Log.tag(ApplicationContext.class);
@VisibleForTesting
protected PersistentLogger persistentLogger;
public static ApplicationContext getInstance(Context context) {
return (ApplicationContext)context.getApplicationContext();
}
@@ -265,10 +260,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
MemoryTracker.stop();
}
public PersistentLogger getPersistentLogger() {
return persistentLogger;
}
public void checkBuildExpiration() {
if (Util.getTimeUntilBuildExpiry() <= 0 && !SignalStore.misc().isClientDeprecated()) {
Log.w(TAG, "Build expired!");
@@ -295,8 +286,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
@VisibleForTesting
protected void initializeLogging() {
persistentLogger = new PersistentLogger(this);
org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), persistentLogger);
Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), new PersistentLogger(this));
SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());

View File

@@ -6,7 +6,6 @@ import android.content.Intent;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.transition.TransitionInflater;
import android.view.View;

View File

@@ -5,7 +5,6 @@ import android.content.Intent;
import android.content.res.Configuration;
import android.os.Bundle;
import android.view.View;
import android.view.WindowManager;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
@@ -18,7 +17,6 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.ConfigurationUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.WindowUtil;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;

View File

@@ -35,7 +35,6 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.appcompat.app.AlertDialog;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
@@ -50,6 +49,7 @@ import androidx.transition.TransitionManager;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.pnikosis.materialishprogress.ProgressWheel;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
@@ -73,7 +73,6 @@ 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.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UsernameUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -85,6 +84,7 @@ import java.io.IOException;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
@@ -519,6 +519,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public void setQueryFilter(String filter) {
if (Objects.equals(filter, this.cursorFilter)) {
return;
}
this.cursorFilter = filter;
contactSearchMediator.onFilterChanged(filter);
}
@@ -542,10 +546,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
headerActionView.setVisibility(View.GONE);
}
public void setRecyclerViewPaddingBottom(@Px int paddingBottom) {
ViewUtil.setPaddingBottom(recyclerView, paddingBottom);
}
private void onLoadFinished(int count) {
swipeRefresh.setVisibility(View.VISIBLE);
showContactsLayout.setVisibility(View.GONE);

View File

@@ -5,13 +5,11 @@ import android.annotation.SuppressLint;
import android.content.Context;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Vibrator;
import android.text.TextUtils;
import android.transition.TransitionInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.widget.Button;

View File

@@ -117,9 +117,9 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
PromptBatterySaverDialogFragment.show(getSupportFragmentManager());
break;
case PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS:
DebugLogsPromptDialogFragment.show(this, getSupportFragmentManager(), DebugLogsPromptDialogFragment.Purpose.NOTIFICATIONS);
DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.NOTIFICATIONS);
case PROMPT_DEBUGLOGS_FOR_CRASH:
DebugLogsPromptDialogFragment.show(this, getSupportFragmentManager(), DebugLogsPromptDialogFragment.Purpose.CRASH);
DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.CRASH);
break;
}
}

View File

@@ -21,7 +21,6 @@ import android.app.KeyguardManager;
import android.content.Context;
import android.content.Intent;
import android.graphics.PorterDuff;
import android.os.Build;
import android.os.Bundle;
import android.text.Editable;
import android.text.InputType;

View File

@@ -53,11 +53,12 @@ import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
import org.thoughtcrime.securesms.components.webrtc.CallLinkInfoSheet;
import org.thoughtcrime.securesms.components.webrtc.CallLinkProfileKeySender;
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow;
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState;
import org.thoughtcrime.securesms.components.webrtc.CallStateUpdatePopupWindow;
@@ -99,6 +100,7 @@ import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState;
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@@ -775,6 +777,10 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
return;
}
if (state.isCallLink()) {
CallLinkProfileKeySender.onSendAnyway(new HashSet<>(changedRecipients));
}
if (state.getGroupCallState().isConnected()) {
ApplicationDependencies.getSignalCallManager().groupApproveSafetyChange(changedRecipients);
} else {

View File

@@ -1,10 +1,10 @@
package org.thoughtcrime.securesms.absbackup.backupables
import com.google.protobuf.InvalidProtocolBufferException
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.absbackup.AndroidBackupItem
import org.thoughtcrime.securesms.absbackup.protos.SvrAuthToken
import org.thoughtcrime.securesms.keyvalue.SignalStore
import java.io.IOException
/**
* This backs up the not-secret KBS Auth tokens, which can be combined with a PIN to prove ownership of a phone number in order to complete the registration process.
@@ -30,7 +30,7 @@ object SvrAuthTokens : AndroidBackupItem {
val proto = SvrAuthToken.ADAPTER.decode(data)
SignalStore.svr().putAuthTokenList(proto.tokens)
} catch (e: InvalidProtocolBufferException) {
} catch (e: IOException) {
Log.w(TAG, "Cannot restore KbsAuthToken from backup service.")
}
}

View File

@@ -1,17 +1,28 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.attachments;
import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.os.ParcelCompat;
import org.thoughtcrime.securesms.audio.AudioHash;
import org.thoughtcrime.securesms.blurhash.BlurHash;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.util.ParcelUtil;
public abstract class Attachment {
import java.util.Objects;
public abstract class Attachment implements Parcelable {
@NonNull
private final String contentType;
@@ -48,6 +59,7 @@ public abstract class Attachment {
private final int height;
private final boolean quote;
private final long uploadTimestamp;
private final int incrementalMacChunkSize;
@Nullable
private final String caption;
@@ -80,6 +92,7 @@ public abstract class Attachment {
boolean videoGif,
int width,
int height,
int incrementalMacChunkSize,
boolean quote,
long uploadTimestamp,
@Nullable String caption,
@@ -88,31 +101,95 @@ public abstract class Attachment {
@Nullable AudioHash audioHash,
@Nullable TransformProperties transformProperties)
{
this.contentType = contentType;
this.transferState = transferState;
this.size = size;
this.fileName = fileName;
this.cdnNumber = cdnNumber;
this.location = location;
this.key = key;
this.relay = relay;
this.digest = digest;
this.incrementalDigest = incrementalDigest;
this.fastPreflightId = fastPreflightId;
this.voiceNote = voiceNote;
this.borderless = borderless;
this.videoGif = videoGif;
this.width = width;
this.height = height;
this.quote = quote;
this.uploadTimestamp = uploadTimestamp;
this.stickerLocator = stickerLocator;
this.caption = caption;
this.blurHash = blurHash;
this.audioHash = audioHash;
this.transformProperties = transformProperties != null ? transformProperties : TransformProperties.empty();
this.contentType = contentType;
this.transferState = transferState;
this.size = size;
this.fileName = fileName;
this.cdnNumber = cdnNumber;
this.location = location;
this.key = key;
this.relay = relay;
this.digest = digest;
this.incrementalDigest = incrementalDigest;
this.fastPreflightId = fastPreflightId;
this.voiceNote = voiceNote;
this.borderless = borderless;
this.videoGif = videoGif;
this.width = width;
this.height = height;
this.incrementalMacChunkSize = incrementalMacChunkSize;
this.quote = quote;
this.uploadTimestamp = uploadTimestamp;
this.stickerLocator = stickerLocator;
this.caption = caption;
this.blurHash = blurHash;
this.audioHash = audioHash;
this.transformProperties = transformProperties != null ? transformProperties : TransformProperties.empty();
}
protected Attachment(Parcel in) {
this.contentType = Objects.requireNonNull(in.readString());
this.transferState = in.readInt();
this.size = in.readLong();
this.fileName = in.readString();
this.cdnNumber = in.readInt();
this.location = in.readString();
this.key = in.readString();
this.relay = in.readString();
this.digest = ParcelUtil.readByteArray(in);
this.incrementalDigest = ParcelUtil.readByteArray(in);
this.fastPreflightId = in.readString();
this.voiceNote = ParcelUtil.readBoolean(in);
this.borderless = ParcelUtil.readBoolean(in);
this.videoGif = ParcelUtil.readBoolean(in);
this.width = in.readInt();
this.height = in.readInt();
this.incrementalMacChunkSize = in.readInt();
this.quote = ParcelUtil.readBoolean(in);
this.uploadTimestamp = in.readLong();
this.stickerLocator = ParcelCompat.readParcelable(in, StickerLocator.class.getClassLoader(), StickerLocator.class);
this.caption = in.readString();
this.blurHash = ParcelCompat.readParcelable(in, BlurHash.class.getClassLoader(), BlurHash.class);
this.audioHash = ParcelCompat.readParcelable(in, AudioHash.class.getClassLoader(), AudioHash.class);
this.transformProperties = Objects.requireNonNull(ParcelCompat.readParcelable(in, TransformProperties.class.getClassLoader(), TransformProperties.class));
}
@Override
public void writeToParcel(@NonNull Parcel dest, int flags) {
AttachmentCreator.writeSubclass(dest, this);
dest.writeString(contentType);
dest.writeInt(transferState);
dest.writeLong(size);
dest.writeString(fileName);
dest.writeInt(cdnNumber);
dest.writeString(location);
dest.writeString(key);
dest.writeString(relay);
ParcelUtil.writeByteArray(dest, digest);
ParcelUtil.writeByteArray(dest, incrementalDigest);
dest.writeString(fastPreflightId);
ParcelUtil.writeBoolean(dest, voiceNote);
ParcelUtil.writeBoolean(dest, borderless);
ParcelUtil.writeBoolean(dest, videoGif);
dest.writeInt(width);
dest.writeInt(height);
dest.writeInt(incrementalMacChunkSize);
ParcelUtil.writeBoolean(dest, quote);
dest.writeLong(uploadTimestamp);
dest.writeParcelable(stickerLocator, 0);
dest.writeString(caption);
dest.writeParcelable(blurHash, 0);
dest.writeParcelable(audioHash, 0);
dest.writeParcelable(transformProperties, 0);
}
@Override
public int describeContents() {
return 0;
}
public static final Creator<Attachment> CREATOR = AttachmentCreator.INSTANCE;
@Nullable
public abstract Uri getUri();
@@ -172,7 +249,11 @@ public abstract class Attachment {
@Nullable
public byte[] getIncrementalDigest() {
return incrementalDigest;
if (incrementalDigest != null && incrementalDigest.length > 0) {
return incrementalDigest;
} else {
return null;
}
}
@Nullable
@@ -200,6 +281,10 @@ public abstract class Attachment {
return height;
}
public int getIncrementalMacChunkSize() {
return incrementalMacChunkSize;
}
public boolean isQuote() {
return quote;
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.attachments
import android.os.Parcel
import android.os.Parcelable
/**
* Parcelable Creator for Attachments. Encapsulates the logic around dealing with
* subclasses, since Attachment is abstract and has several children.
*/
object AttachmentCreator : Parcelable.Creator<Attachment> {
enum class Subclass(val clazz: Class<out Attachment>, val code: String) {
DATABASE(DatabaseAttachment::class.java, "database"),
MMS_NOTIFICATION(MmsNotificationAttachment::class.java, "mms_notification"),
POINTER(PointerAttachment::class.java, "pointer"),
TOMBSTONE(TombstoneAttachment::class.java, "tombstone"),
URI(UriAttachment::class.java, "uri")
}
@JvmStatic
fun writeSubclass(dest: Parcel, instance: Attachment) {
val subclass = Subclass.values().firstOrNull { it.clazz == instance::class.java } ?: error("Unexpected subtype ${instance::class.java.simpleName}")
dest.writeString(subclass.code)
}
override fun createFromParcel(source: Parcel): Attachment {
val rawCode = source.readString()!!
return when (Subclass.values().first { rawCode == it.code }) {
Subclass.DATABASE -> DatabaseAttachment(source)
Subclass.MMS_NOTIFICATION -> MmsNotificationAttachment(source)
Subclass.POINTER -> PointerAttachment(source)
Subclass.TOMBSTONE -> TombstoneAttachment(source)
Subclass.URI -> UriAttachment(source)
}
}
override fun newArray(size: Int): Array<Attachment?> = arrayOfNulls(size)
}

View File

@@ -1,8 +1,11 @@
package org.thoughtcrime.securesms.attachments;
import android.net.Uri;
import android.os.Parcel;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.os.ParcelCompat;
import org.thoughtcrime.securesms.audio.AudioHash;
import org.thoughtcrime.securesms.blurhash.BlurHash;
@@ -10,6 +13,7 @@ import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.ParcelUtil;
import java.util.Comparator;
@@ -35,6 +39,7 @@ public class DatabaseAttachment extends Attachment {
String relay,
byte[] digest,
byte[] incrementalDigest,
int incrementalMacChunkSize,
String fastPreflightId,
boolean voiceNote,
boolean borderless,
@@ -50,7 +55,7 @@ public class DatabaseAttachment extends Attachment {
int displayOrder,
long uploadTimestamp)
{
super(contentType, transferProgress, size, fileName, cdnNumber, location, key, relay, digest, incrementalDigest, fastPreflightId, voiceNote, borderless, videoGif, width, height, quote, uploadTimestamp, caption, stickerLocator, blurHash, audioHash, transformProperties);
super(contentType, transferProgress, size, fileName, cdnNumber, location, key, relay, digest, incrementalDigest, fastPreflightId, voiceNote, borderless, videoGif, width, height, incrementalMacChunkSize, quote, uploadTimestamp, caption, stickerLocator, blurHash, audioHash, transformProperties);
this.attachmentId = attachmentId;
this.hasData = hasData;
this.hasThumbnail = hasThumbnail;
@@ -58,6 +63,25 @@ public class DatabaseAttachment extends Attachment {
this.displayOrder = displayOrder;
}
protected DatabaseAttachment(Parcel in) {
super(in);
this.attachmentId = ParcelCompat.readParcelable(in, AttachmentId.class.getClassLoader(), AttachmentId.class);
this.hasData = ParcelUtil.readBoolean(in);
this.hasThumbnail = ParcelUtil.readBoolean(in);
this.mmsId = in.readLong();
this.displayOrder = in.readInt();
}
@Override
public void writeToParcel(@NonNull Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeParcelable(attachmentId, 0);
ParcelUtil.writeBoolean(dest, hasData);
ParcelUtil.writeBoolean(dest, hasThumbnail);
dest.writeLong(mmsId);
dest.writeInt(displayOrder);
}
@Override
@Nullable
public Uri getUri() {

View File

@@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.attachments;
import android.net.Uri;
import android.os.Parcel;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.database.AttachmentTable;
@@ -11,7 +13,11 @@ import org.thoughtcrime.securesms.database.MessageTable;
public class MmsNotificationAttachment extends Attachment {
public MmsNotificationAttachment(int status, long size) {
super("application/mms", getTransferStateFromStatus(status), size, null, 0, null, null, null, null, null, null, false, false, false, 0, 0, false, 0, null, null, null, null, null);
super("application/mms", getTransferStateFromStatus(status), size, null, 0, null, null, null, null, null, null, false, false, false, 0, 0, 0, false, 0, null, null, null, null, null);
}
protected MmsNotificationAttachment(Parcel in) {
super(in);
}
@Nullable

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.attachments;
import android.net.Uri;
import android.os.Parcel;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -13,7 +14,7 @@ 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 org.whispersystems.signalservice.internal.push.DataMessage;
import java.util.LinkedList;
import java.util.List;
@@ -31,6 +32,7 @@ public class PointerAttachment extends Attachment {
@Nullable String relay,
@Nullable byte[] digest,
@Nullable byte[] incrementalDigest,
int incrementalMacChunkSize,
@Nullable String fastPreflightId,
boolean voiceNote,
boolean borderless,
@@ -42,7 +44,11 @@ public class PointerAttachment extends Attachment {
@Nullable StickerLocator stickerLocator,
@Nullable BlurHash blurHash)
{
super(contentType, transferState, size, fileName, cdnNumber, location, key, relay, digest, incrementalDigest, fastPreflightId, voiceNote, borderless, videoGif, width, height, false, uploadTimestamp, caption, stickerLocator, blurHash, null, null);
super(contentType, transferState, size, fileName, cdnNumber, location, key, relay, digest, incrementalDigest, fastPreflightId, voiceNote, borderless, videoGif, width, height, incrementalMacChunkSize, false, uploadTimestamp, caption, stickerLocator, blurHash, null, null);
}
protected PointerAttachment(Parcel in) {
super(in);
}
@Nullable
@@ -111,9 +117,11 @@ public class PointerAttachment extends Attachment {
pointer.get().asPointer().getFileName().orElse(null),
pointer.get().asPointer().getCdnNumber(),
pointer.get().asPointer().getRemoteId().toString(),
encodedKey, null,
encodedKey,
null,
pointer.get().asPointer().getDigest().orElse(null),
pointer.get().asPointer().getIncrementalDigest().orElse(null),
pointer.get().asPointer().getIncrementalMacChunkSize(),
fastPreflightId,
pointer.get().asPointer().getVoiceNote(),
pointer.get().asPointer().isBorderless(),
@@ -140,6 +148,7 @@ public class PointerAttachment extends Attachment {
null,
thumbnail != null ? thumbnail.asPointer().getDigest().orElse(null) : null,
thumbnail != null ? thumbnail.asPointer().getIncrementalDigest().orElse(null) : null,
thumbnail != null ? thumbnail.asPointer().getIncrementalMacChunkSize() : 0,
null,
false,
false,
@@ -152,24 +161,25 @@ public class PointerAttachment extends Attachment {
null));
}
public static Optional<Attachment> forPointer(SignalServiceProtos.DataMessage.Quote.QuotedAttachment quotedAttachment) {
public static Optional<Attachment> forPointer(DataMessage.Quote.QuotedAttachment quotedAttachment) {
SignalServiceAttachment thumbnail;
try {
thumbnail = quotedAttachment.hasThumbnail() ? AttachmentPointerUtil.createSignalAttachmentPointer(quotedAttachment.getThumbnail()) : null;
thumbnail = quotedAttachment.thumbnail != null ? AttachmentPointerUtil.createSignalAttachmentPointer(quotedAttachment.thumbnail) : null;
} catch (InvalidMessageStructureException e) {
return Optional.empty();
}
return Optional.of(new PointerAttachment(quotedAttachment.getContentType(),
return Optional.of(new PointerAttachment(quotedAttachment.contentType,
AttachmentTable.TRANSFER_PROGRESS_PENDING,
thumbnail != null ? thumbnail.asPointer().getSize().orElse(0) : 0,
quotedAttachment.getFileName(),
quotedAttachment.fileName,
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,
thumbnail != null ? thumbnail.asPointer().getIncrementalDigest().orElse(null) : null,
thumbnail != null ? thumbnail.asPointer().getIncrementalMacChunkSize() : 0,
null,
false,
false,

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.attachments;
import android.net.Uri;
import android.os.Parcel;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -16,7 +17,11 @@ import org.thoughtcrime.securesms.database.AttachmentTable;
public class TombstoneAttachment extends Attachment {
public TombstoneAttachment(@NonNull String contentType, boolean quote) {
super(contentType, AttachmentTable.TRANSFER_PROGRESS_DONE, 0, null, 0, null, null, null, null, null, null, false, false, false, 0, 0, quote, 0, null, null, null, null, null);
super(contentType, AttachmentTable.TRANSFER_PROGRESS_DONE, 0, null, 0, null, null, null, null, null, null, false, false, false, 0, 0, 0, quote, 0, null, null, null, null, null);
}
protected TombstoneAttachment(Parcel in) {
super(in);
}
@Override

View File

@@ -1,9 +1,11 @@
package org.thoughtcrime.securesms.attachments;
import android.net.Uri;
import android.os.Parcel;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.os.ParcelCompat;
import org.thoughtcrime.securesms.audio.AudioHash;
import org.thoughtcrime.securesms.blurhash.BlurHash;
@@ -52,10 +54,21 @@ public class UriAttachment extends Attachment {
@Nullable AudioHash audioHash,
@Nullable TransformProperties transformProperties)
{
super(contentType, transferState, size, fileName, 0, null, null, null, null, null, fastPreflightId, voiceNote, borderless, videoGif, width, height, quote, 0, caption, stickerLocator, blurHash, audioHash, transformProperties);
super(contentType, transferState, size, fileName, 0, null, null, null, null, null, fastPreflightId, voiceNote, borderless, videoGif, width, height, 0, quote, 0, caption, stickerLocator, blurHash, audioHash, transformProperties);
this.dataUri = Objects.requireNonNull(dataUri);
}
protected UriAttachment(Parcel in) {
super(in);
this.dataUri = Objects.requireNonNull(ParcelCompat.readParcelable(in, Uri.class.getClassLoader(), Uri.class));
}
@Override
public void writeToParcel(@NonNull Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeParcelable(dataUri, 0);
}
@Override
@NonNull
public Uri getUri() {

View File

@@ -2,19 +2,19 @@ package org.thoughtcrime.securesms.audio;
import androidx.annotation.NonNull;
import com.google.protobuf.ByteString;
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
import java.util.concurrent.TimeUnit;
import okio.ByteString;
public class AudioFileInfo {
private final long durationUs;
private final byte[] waveFormBytes;
private final float[] waveForm;
public static @NonNull AudioFileInfo fromDatabaseProtobuf(@NonNull AudioWaveFormData audioWaveForm) {
return new AudioFileInfo(audioWaveForm.getDurationUs(), audioWaveForm.getWaveForm().toByteArray());
return new AudioFileInfo(audioWaveForm.durationUs, audioWaveForm.waveForm.toByteArray());
}
AudioFileInfo(long durationUs, byte[] waveFormBytes) {
@@ -37,9 +37,9 @@ public class AudioFileInfo {
}
public @NonNull AudioWaveFormData toDatabaseProtobuf() {
return AudioWaveFormData.newBuilder()
.setDurationUs(durationUs)
.setWaveForm(ByteString.copyFrom(waveFormBytes))
.build();
return new AudioWaveFormData.Builder()
.durationUs(durationUs)
.waveForm(ByteString.of(waveFormBytes))
.build();
}
}

View File

@@ -1,17 +1,22 @@
package org.thoughtcrime.securesms.audio;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
import org.thoughtcrime.securesms.util.ParcelUtil;
import org.whispersystems.util.Base64;
import java.io.IOException;
import java.util.Objects;
/**
* An AudioHash is a compact string representation of the wave form and duration for an audio file.
*/
public final class AudioHash {
public final class AudioHash implements Parcelable {
@NonNull private final String hash;
@NonNull private final AudioWaveFormData audioWaveForm;
@@ -22,13 +27,46 @@ public final class AudioHash {
}
public AudioHash(@NonNull AudioWaveFormData audioWaveForm) {
this(Base64.encodeBytes(audioWaveForm.toByteArray()), audioWaveForm);
this(Base64.encodeBytes(audioWaveForm.encode()), audioWaveForm);
}
protected AudioHash(Parcel in) {
hash = Objects.requireNonNull(in.readString());
try {
audioWaveForm = AudioWaveFormData.ADAPTER.decode(Objects.requireNonNull(ParcelUtil.readByteArray(in)));
} catch (IOException e) {
throw new AssertionError();
}
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(hash);
ParcelUtil.writeByteArray(dest, audioWaveForm.encode());
}
@Override
public int describeContents() {
return 0;
}
public static final Creator<AudioHash> CREATOR = new Creator<>() {
@Override
public AudioHash createFromParcel(Parcel in) {
return new AudioHash(in);
}
@Override
public AudioHash[] newArray(int size) {
return new AudioHash[size];
}
};
public static @Nullable AudioHash parseOrNull(@Nullable String hash) {
if (hash == null) return null;
try {
return new AudioHash(hash, AudioWaveFormData.parseFrom(Base64.decode(hash)));
return new AudioHash(hash, AudioWaveFormData.ADAPTER.decode(Base64.decode(hash)));
} catch (IOException e) {
return null;
}

View File

@@ -107,7 +107,7 @@ object AudioWaveForms {
private fun generateWaveForm(context: Context, uri: Uri, cacheKey: String, attachmentId: AttachmentId): CacheCheckResult {
try {
val startTime = System.currentTimeMillis()
SignalDatabase.attachments.writeAudioHash(attachmentId, AudioWaveFormData.getDefaultInstance())
SignalDatabase.attachments.writeAudioHash(attachmentId, AudioWaveFormData())
Log.i(TAG, "Starting wave form generation ($cacheKey)")
val fileInfo: AudioFileInfo = AudioWaveFormGenerator.generateWaveForm(context, uri)

View File

@@ -4,6 +4,7 @@ import androidx.annotation.NonNull;
import org.signal.core.util.Conversions;
import org.signal.core.util.StreamUtil;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.kdf.HKDF;
import org.signal.libsignal.protocol.util.ByteUtil;
import org.thoughtcrime.securesms.backup.proto.BackupFrame;
@@ -27,6 +28,9 @@ import javax.crypto.spec.SecretKeySpec;
class BackupRecordInputStream extends FullBackupBase.BackupStream {
private final String TAG = Log.tag(BackupRecordInputStream.class);
private final int MAX_BUFFER_SIZE = 8192;
private final int version;
private final InputStream in;
private final Cipher cipher;
@@ -92,6 +96,35 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
return readFrame(in);
}
boolean validateFrame() throws InvalidAlgorithmParameterException, IOException, InvalidKeyException {
int frameLength = decryptFrameLength(in);
if (frameLength <= 0) {
Log.i(TAG, "Backup frame is not valid due to negative frame length. This is likely because the decryption passphrase was wrong.");
return false;
}
int bufferSize = Math.min(MAX_BUFFER_SIZE, frameLength);
byte[] buffer = new byte[bufferSize];
byte[] theirMac = new byte[10];
while (frameLength > 0) {
int read = in.read(buffer, 0, Math.min(buffer.length, frameLength));
if (read == -1) return false;
if (read < MAX_BUFFER_SIZE) {
final int frameEndIndex = read - 10;
mac.update(buffer, 0, frameEndIndex);
System.arraycopy(buffer, frameEndIndex, theirMac, 0, theirMac.length);
} else {
mac.update(buffer, 0, read);
}
frameLength -= read;
}
byte[] ourMac = ByteUtil.trim(mac.doFinal(), 10);
return MessageDigest.isEqual(ourMac, theirMac);
}
void readAttachmentTo(OutputStream out, int length) throws IOException {
try {
Conversions.intToByteArray(iv, 0, counter++);
@@ -142,24 +175,7 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
private BackupFrame readFrame(InputStream in) throws IOException {
try {
byte[] length = new byte[4];
StreamUtil.readFully(in, length);
Conversions.intToByteArray(iv, 0, counter++);
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
int frameLength;
if (BackupVersions.isFrameLengthEncrypted(version)) {
mac.update(length);
// this depends upon cipher being a stream cipher mode in order to get back the length without needing a full AES block-size input
byte[] decryptedLength = cipher.update(length);
if (decryptedLength.length != length.length) {
throw new IOException("Cipher was not a stream cipher!");
}
frameLength = Conversions.byteArrayToInt(decryptedLength);
} else {
frameLength = Conversions.byteArrayToInt(length);
}
int frameLength = decryptFrameLength(in);
byte[] frame = new byte[frameLength];
StreamUtil.readFully(in, frame);
@@ -182,5 +198,27 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
}
}
private int decryptFrameLength(InputStream inputStream) throws IOException, InvalidAlgorithmParameterException, InvalidKeyException {
byte[] length = new byte[4];
StreamUtil.readFully(inputStream, length);
Conversions.intToByteArray(iv, 0, counter++);
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
int frameLength;
if (BackupVersions.isFrameLengthEncrypted(version)) {
mac.update(length);
// this depends upon cipher being a stream cipher mode in order to get back the length without needing a full AES block-size input
byte[] decryptedLength = cipher.update(length);
if (decryptedLength.length != length.length) {
throw new IOException("Cipher was not a stream cipher!");
}
frameLength = Conversions.byteArrayToInt(decryptedLength);
} else {
frameLength = Conversions.byteArrayToInt(length);
}
return frameLength;
}
static class BadMacException extends IOException {}
}

View File

@@ -45,6 +45,8 @@ import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
@@ -63,6 +65,24 @@ public class FullBackupImporter extends FullBackupBase {
@SuppressWarnings("unused")
private static final String TAG = Log.tag(FullBackupImporter.class);
public static boolean validatePassphrase(@NonNull Context context,
@NonNull Uri uri,
@NonNull String passphrase)
throws IOException
{
try (InputStream is = getInputStream(context, uri)) {
BackupRecordInputStream inputStream = new BackupRecordInputStream(is, passphrase);
return inputStream.validateFrame();
} catch (InvalidAlgorithmParameterException e) {
Log.w(TAG, "Invalid algorithm parameter exception in backup passphrase validation.", e);
return false;
} catch (InvalidKeyException e) {
Log.w(TAG, "Invalid key exception in backup passphrase validation.", e);
return false;
}
}
public static void importFile(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret,
@NonNull SQLiteDatabase db, @NonNull Uri uri, @NonNull String passphrase)
throws IOException

View File

@@ -101,16 +101,16 @@ object Badges {
@JvmStatic
fun toDatabaseBadge(badge: Badge): BadgeList.Badge {
return BadgeList.Badge.newBuilder()
.setId(badge.id)
.setCategory(badge.category.code)
.setDescription(badge.description)
.setExpiration(badge.expirationTimestamp)
.setVisible(badge.visible)
.setName(badge.name)
.setImageUrl(badge.imageUrl.toString())
.setImageDensity(badge.imageDensity)
.build()
return BadgeList.Badge(
id = badge.id,
category = badge.category.code,
description = badge.description,
expiration = badge.expirationTimestamp,
visible = badge.visible,
name = badge.name,
imageUrl = badge.imageUrl.toString(),
imageDensity = badge.imageDensity
)
}
@JvmStatic

View File

@@ -79,12 +79,11 @@ class GiftMessageView @JvmOverloads constructor(
}
actionView.setText(
when (giftBadge.redemptionState ?: GiftBadge.RedemptionState.UNRECOGNIZED) {
when (giftBadge.redemptionState) {
GiftBadge.RedemptionState.PENDING -> R.string.GiftMessageView__redeem
GiftBadge.RedemptionState.STARTED -> R.string.GiftMessageView__redeeming
GiftBadge.RedemptionState.REDEEMED -> R.string.GiftMessageView__redeemed
GiftBadge.RedemptionState.FAILED -> R.string.GiftMessageView__redeem
GiftBadge.RedemptionState.UNRECOGNIZED -> R.string.GiftMessageView__redeem
}
)
}

View File

@@ -32,7 +32,7 @@ object Gifts {
): OutgoingMessage {
return OutgoingMessage(
threadRecipient = recipient,
body = Base64.encodeBytes(giftBadge.toByteArray()),
body = Base64.encodeBytes(giftBadge.encode()),
isSecure = true,
sentTimeMillis = sentTimestamp,
expiresIn = expiresIn,

View File

@@ -83,7 +83,7 @@ class GiftFlowConfirmationFragment :
keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI)
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, DonationErrorSource.GIFT)
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, viewModel.uiSessionKey, DonationErrorSource.GIFT)
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
.setView(R.layout.processing_payment_dialog)
@@ -106,6 +106,7 @@ class GiftFlowConfirmationFragment :
GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToGatewaySelectorBottomSheet(
with(viewModel.snapshot) {
GatewayRequest(
uiSessionKey = viewModel.uiSessionKey,
donateToSignalType = DonateToSignalType.GIFT,
badge = giftBadge!!,
label = getString(R.string.preferences__one_time),
@@ -262,6 +263,10 @@ class GiftFlowConfirmationFragment :
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(gatewayRequest))
}
override fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToBankTransferMandateFragment(gatewayRequest))
}
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
val mainActivityIntent = MainActivity.clearTop(requireContext())

View File

@@ -39,6 +39,7 @@ class GiftFlowViewModel(
val state: Flowable<GiftFlowState> = store.stateFlowable
val events: Observable<DonationEvent> = eventPublisher
val snapshot: GiftFlowState get() = store.state
val uiSessionKey: Long = System.currentTimeMillis()
init {
refresh()

View File

@@ -63,7 +63,7 @@ class ViewReceivedGiftBottomSheet : DSLSettingsBottomSheetFragment() {
ViewReceivedGiftBottomSheet().apply {
arguments = Bundle().apply {
putParcelable(ARG_SENT_FROM, messageRecord.fromRecipient.id)
putByteArray(ARG_GIFT_BADGE, messageRecord.giftBadge!!.toByteArray())
putByteArray(ARG_GIFT_BADGE, messageRecord.giftBadge!!.encode())
putLong(ARG_MESSAGE_ID, messageRecord.id)
}
show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)

View File

@@ -34,7 +34,7 @@ class ViewSentGiftBottomSheet : DSLSettingsBottomSheetFragment() {
ViewSentGiftBottomSheet().apply {
arguments = Bundle().apply {
putParcelable(ARG_SENT_TO, messageRecord.toRecipient.id)
putByteArray(ARG_GIFT_BADGE, messageRecord.giftBadge!!.toByteArray())
putByteArray(ARG_GIFT_BADGE, messageRecord.giftBadge!!.encode())
}
show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
@@ -45,7 +45,7 @@ class ViewSentGiftBottomSheet : DSLSettingsBottomSheetFragment() {
get() = requireArguments().getParcelableCompat(ARG_SENT_TO, RecipientId::class.java)!!
private val giftBadge: GiftBadge
get() = GiftBadge.parseFrom(requireArguments().getByteArray(ARG_GIFT_BADGE))
get() = GiftBadge.ADAPTER.decode(requireArguments().getByteArray(ARG_GIFT_BADGE)!!)
private val lifecycleDisposable = LifecycleDisposable()

View File

@@ -17,6 +17,7 @@ import androidx.core.app.SharedElementCallback
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.transition.TransitionInflater
@@ -57,6 +58,7 @@ 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.FeatureFlags
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.doAfterNextLayout
import org.thoughtcrime.securesms.util.fragments.requireListener
@@ -74,7 +76,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
private val TAG = Log.tag(CallLogFragment::class.java)
}
private val viewModel: CallLogViewModel by viewModels()
private val viewModel: CallLogViewModel by activityViewModels()
private val binding: CallLogFragmentBinding by ViewBinderDelegate(CallLogFragmentBinding::bind)
private val disposables = LifecycleDisposable()
private val callLogContextMenu = CallLogContextMenu(this, this)
@@ -230,6 +232,13 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
val count = callLogActionMode.getCount()
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, count, count))
.setMessage(
if (FeatureFlags.adHocCalling()) {
getString(R.string.CallLogFragment__call_links_youve_created)
} else {
null
}
)
.setPositiveButton(R.string.CallLogFragment__delete) { _, _ ->
performDeletion(count, viewModel.stageSelectionDeletion())
callLogActionMode.end()
@@ -303,6 +312,12 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
val progress = 1 - verticalOffset.toFloat() / -layout.height
binding.pullView.onUserDrag(progress)
}
if (viewModel.filterSnapshot != CallLogFilter.ALL) {
binding.root.doAfterNextLayout {
binding.pullView.openImmediate()
}
}
}
override fun onCreateACallLinkClicked() {
@@ -363,6 +378,13 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
override fun deleteCall(call: CallLogRow) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
.setMessage(
if (FeatureFlags.adHocCalling()) {
getString(R.string.CallLogFragment__call_links_youve_created)
} else {
null
}
)
.setPositiveButton(R.string.CallLogFragment__delete) { _, _ ->
performDeletion(1, viewModel.stageCallDeletion(call))
}

View File

@@ -86,18 +86,22 @@ class CallLogRepository(
/**
* Delete all call events / unowned links and enqueue clear history job, and then
* emit a clear history message.
*
* This explicitly drops failed call link revocations of call links, and those call links
* will remain visible to the user. This is safe because the clear history sync message should
* only clear local history and then poll link status from the server.
*/
fun deleteAllCallLogsOnOrBeforeNow(): Single<Int> {
return Single.fromCallable {
SignalDatabase.rawDatabase.withinTransaction {
val now = System.currentTimeMillis()
SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(now)
SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(now)
ApplicationDependencies.getJobManager().add(CallLogEventSendJob.forClearHistory(now))
val latestTimestamp = SignalDatabase.calls.getLatestTimestamp()
SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(latestTimestamp)
SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(latestTimestamp)
ApplicationDependencies.getJobManager().add(CallLogEventSendJob.forClearHistory(latestTimestamp))
}
SignalDatabase.callLinks.getAllAdminCallLinksExcept(emptySet())
}.flatMap(this::revokeAndCollectResults).map { -1 }.subscribeOn(Schedulers.io())
}.flatMap(this::revokeAndCollectResults).map { 0 }.subscribeOn(Schedulers.io())
}
/**

View File

@@ -93,11 +93,11 @@ sealed class CallLogRow {
return FULL
}
if (groupCallUpdateDetails.inCallUuidsList.contains(Recipient.self().requireAci().rawUuid.toString())) {
if (groupCallUpdateDetails.inCallUuids.contains(Recipient.self().requireAci().rawUuid.toString())) {
return LOCAL_USER_JOINED
}
return if (groupCallUpdateDetails.inCallUuidsCount > 0) {
return if (groupCallUpdateDetails.inCallUuids.isNotEmpty()) {
ACTIVE
} else {
NONE

View File

@@ -1,3 +1,8 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components;
import android.content.Context;
@@ -12,10 +17,12 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.views.Stub;
import java.util.List;
@@ -24,13 +31,15 @@ public class AlbumThumbnailView extends FrameLayout {
private @Nullable SlideClickListener thumbnailClickListener;
private @Nullable SlidesClickedListener downloadClickListener;
private @Nullable SlidesClickedListener cancelDownloadClickListener;
private @Nullable SlideClickListener playVideoClickListener;
private int currentSizeClass;
private final int[] corners = new int[4];
private ViewGroup albumCellContainer;
private Stub<TransferControlView> transferControls;
private final ViewGroup albumCellContainer;
private final Stub<TransferControlView> transferControlsStub;
private final SlideClickListener defaultThumbnailClickListener = (v, slide) -> {
if (thumbnailClickListener != null) {
@@ -42,19 +51,18 @@ public class AlbumThumbnailView extends FrameLayout {
public AlbumThumbnailView(@NonNull Context context) {
super(context);
initialize();
inflate(getContext(), R.layout.album_thumbnail_view, this);
albumCellContainer = findViewById(R.id.album_cell_container);
transferControlsStub = new Stub<>(findViewById(R.id.album_transfer_controls_stub));
}
public AlbumThumbnailView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
initialize();
}
private void initialize() {
inflate(getContext(), R.layout.album_thumbnail_view, this);
albumCellContainer = findViewById(R.id.album_cell_container);
transferControls = new Stub<>(findViewById(R.id.album_transfer_controls_stub));
albumCellContainer = findViewById(R.id.album_cell_container);
transferControlsStub = new Stub<>(findViewById(R.id.album_transfer_controls_stub));
}
public void setSlides(@NonNull GlideRequests glideRequests, @NonNull List<Slide> slides, boolean showControls) {
@@ -63,16 +71,17 @@ public class AlbumThumbnailView extends FrameLayout {
}
if (showControls) {
transferControls.get().setShowDownloadText(true);
transferControls.get().setSlides(slides);
transferControls.get().setDownloadClickListener(v -> {
if (downloadClickListener != null) {
downloadClickListener.onClick(v, slides);
}
});
transferControlsStub.get().setShowSecondaryText(true);
transferControlsStub.get().setDownloadClickListener(
v -> {
if (downloadClickListener != null) {
downloadClickListener.onClick(v, slides);
}
});
transferControlsStub.get().setSlides(slides);
} else {
if (transferControls.resolved()) {
transferControls.get().setVisibility(GONE);
if (transferControlsStub.resolved()) {
transferControlsStub.get().setVisibility(GONE);
}
}
@@ -85,6 +94,7 @@ public class AlbumThumbnailView extends FrameLayout {
showSlides(glideRequests, slides);
applyCorners();
forceLayout();
}
public void setCellBackgroundColor(@ColorInt int color) {
@@ -101,10 +111,19 @@ public class AlbumThumbnailView extends FrameLayout {
thumbnailClickListener = listener;
}
public void setDownloadClickListener(@Nullable SlidesClickedListener listener) {
downloadClickListener = listener;
public void setDownloadClickListener(SlidesClickedListener listener) {
this.downloadClickListener = listener;
}
public void setCancelDownloadClickListener(SlidesClickedListener listener) {
this.cancelDownloadClickListener = listener;
}
public void setPlayVideoClickListener(SlideClickListener listener) {
this.playVideoClickListener = listener;
}
public void setRadii(int topLeft, int topRight, int bottomRight, int bottomLeft) {
corners[0] = topLeft;
corners[1] = topRight;
@@ -117,23 +136,46 @@ public class AlbumThumbnailView extends FrameLayout {
private void inflateLayout(int sizeClass) {
albumCellContainer.removeAllViews();
int resId;
switch (sizeClass) {
case 2:
inflate(getContext(), R.layout.album_thumbnail_2, albumCellContainer);
resId = R.layout.album_thumbnail_2;
break;
case 3:
inflate(getContext(), R.layout.album_thumbnail_3, albumCellContainer);
resId = R.layout.album_thumbnail_3;
break;
case 4:
inflate(getContext(), R.layout.album_thumbnail_4, albumCellContainer);
resId = R.layout.album_thumbnail_4;
break;
case 5:
inflate(getContext(), R.layout.album_thumbnail_5, albumCellContainer);
resId = R.layout.album_thumbnail_5;
break;
default:
inflate(getContext(), R.layout.album_thumbnail_many, albumCellContainer);
resId = R.layout.album_thumbnail_many;
break;
}
inflate(getContext(), resId, albumCellContainer);
if (transferControlsStub.resolved()) {
int size;
switch (sizeClass) {
case 2:
size = R.dimen.album_2_total_height;
break;
case 3:
size = R.dimen.album_3_total_height;
break;
case 4:
size = R.dimen.album_4_total_height;
break;
default:
size = R.dimen.album_5_total_height;
break;
}
ViewGroup.LayoutParams params = transferControlsStub.get().getLayoutParams();
params.height = getContext().getResources().getDimensionPixelSize(size);
transferControlsStub.get().setLayoutParams(params);
}
}
private void applyCorners() {
@@ -214,19 +256,20 @@ public class AlbumThumbnailView extends FrameLayout {
}
private void showSlides(@NonNull GlideRequests glideRequests, @NonNull List<Slide> slides) {
setSlide(glideRequests, slides.get(0), R.id.album_cell_1);
setSlide(glideRequests, slides.get(1), R.id.album_cell_2);
boolean showControls = TransferControlView.containsPlayableSlides(slides);
setSlide(glideRequests, slides.get(0), R.id.album_cell_1, showControls);
setSlide(glideRequests, slides.get(1), R.id.album_cell_2, showControls);
if (slides.size() >= 3) {
setSlide(glideRequests, slides.get(2), R.id.album_cell_3);
setSlide(glideRequests, slides.get(2), R.id.album_cell_3, showControls);
}
if (slides.size() >= 4) {
setSlide(glideRequests, slides.get(3), R.id.album_cell_4);
setSlide(glideRequests, slides.get(3), R.id.album_cell_4, showControls);
}
if (slides.size() >= 5) {
setSlide(glideRequests, slides.get(4), R.id.album_cell_5);
setSlide(glideRequests, slides.get(4), R.id.album_cell_5, showControls && slides.size() == 5);
}
if (slides.size() > 5) {
@@ -235,11 +278,17 @@ public class AlbumThumbnailView extends FrameLayout {
}
}
private void setSlide(@NonNull GlideRequests glideRequests, @NonNull Slide slide, @IdRes int id) {
private void setSlide(@NonNull GlideRequests glideRequests, @NonNull Slide slide, @IdRes int id, boolean showControls) {
ThumbnailView cell = findViewById(id);
cell.setImageResource(glideRequests, slide, false, false);
cell.showSecondaryText(false);
cell.setThumbnailClickListener(defaultThumbnailClickListener);
cell.setDownloadClickListener(downloadClickListener);
cell.setCancelDownloadClickListener(cancelDownloadClickListener);
if (MediaUtil.isInstantVideoSupported(slide)) {
cell.setPlayVideoClickListener(playVideoClickListener);
}
cell.setOnLongClickListener(defaultLongClickListener);
cell.setImageResource(glideRequests, slide, showControls, false);
}
private int sizeClass(int size) {

View File

@@ -15,12 +15,16 @@ import androidx.annotation.Px;
import androidx.appcompat.widget.AppCompatImageView;
import androidx.fragment.app.FragmentActivity;
import com.bumptech.glide.load.DataSource;
import com.bumptech.glide.load.MultiTransformation;
import com.bumptech.glide.load.Transformation;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.engine.GlideException;
import com.bumptech.glide.load.resource.bitmap.CircleCrop;
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy;
import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.target.SimpleTarget;
import com.bumptech.glide.request.target.Target;
import com.bumptech.glide.request.transition.Transition;
import org.signal.core.util.logging.Log;
@@ -32,6 +36,7 @@ import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequest;
import org.thoughtcrime.securesms.mms.GlideRequests;
@@ -54,6 +59,8 @@ public final class AvatarImageView extends AppCompatImageView {
@SuppressWarnings("unused")
private static final String TAG = Log.tag(AvatarImageView.class);
private final RequestListener<Drawable> redownloadRequestListener = new RedownloadRequestListener();
private int size;
private boolean inverted;
private OnClickListener listener;
@@ -198,7 +205,8 @@ public final class AvatarImageView extends AppCompatImageView {
.error(fallbackContactPhotoDrawable)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.downsample(DownsampleStrategy.CENTER_INSIDE)
.transform(new MultiTransformation<>(transforms));
.transform(new MultiTransformation<>(transforms))
.addListener(redownloadRequestListener);
if (avatarOptions.fixedSize > 0) {
fixedSizeTarget = new FixedSizeTarget(avatarOptions.fixedSize);
@@ -363,4 +371,19 @@ public final class AvatarImageView extends AppCompatImageView {
}
}
}
private static class RedownloadRequestListener implements RequestListener<Drawable> {
@Override
public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<Drawable> target, boolean isFirstResource) {
if (model instanceof ProfileContactPhoto) {
RetrieveProfileAvatarJob.enqueueForceUpdate(((ProfileContactPhoto) model).getRecipient());
}
return false;
}
@Override
public boolean onResourceReady(Drawable resource, Object model, Target<Drawable> target, DataSource dataSource, boolean isFirstResource) {
return false;
}
}
}

View File

@@ -1,3 +1,8 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components
import android.content.Context
@@ -86,7 +91,7 @@ class ConversationItemThumbnail @JvmOverloads constructor(
}
}
override fun onSaveInstanceState(): Parcelable? {
override fun onSaveInstanceState(): Parcelable {
val root = super.onSaveInstanceState()
return bundleOf(
STATE_ROOT to root,
@@ -255,9 +260,19 @@ class ConversationItemThumbnail @JvmOverloads constructor(
state.applyState(thumbnail, album)
}
fun setProgressWheelClickListener(listener: SlideClickListener?) {
fun setPlayVideoClickListener(listener: SlideClickListener?) {
state = state.copy(
thumbnailViewState = state.thumbnailViewState.copy(progressWheelClickListener = listener)
thumbnailViewState = state.thumbnailViewState.copy(playVideoClickListener = listener),
albumViewState = state.albumViewState.copy(playVideoClickListener = listener)
)
state.applyState(thumbnail, album)
}
fun setCancelDownloadClickListener(listener: SlidesClickedListener?) {
state = state.copy(
thumbnailViewState = state.thumbnailViewState.copy(cancelDownloadClickListener = listener),
albumViewState = state.albumViewState.copy(cancelDownloadClickListener = listener)
)
state.applyState(thumbnail, album)

View File

@@ -1,3 +1,8 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components
import android.graphics.Color
@@ -31,7 +36,9 @@ data class ConversationItemThumbnailState(
@IgnoredOnParcel
private val downloadClickListener: SlidesClickedListener? = null,
@IgnoredOnParcel
private val progressWheelClickListener: SlideClickListener? = null,
private val cancelDownloadClickListener: SlidesClickedListener? = null,
@IgnoredOnParcel
private val playVideoClickListener: SlideClickListener? = null,
@IgnoredOnParcel
private val longClickListener: OnLongClickListener? = null,
private val visibility: Int = View.GONE,
@@ -57,7 +64,8 @@ data class ConversationItemThumbnailState(
thumbnailView.get().setRadii(cornerTopLeft, cornerTopRight, cornerBottomRight, cornerBottomLeft)
thumbnailView.get().setThumbnailClickListener(clickListener)
thumbnailView.get().setDownloadClickListener(downloadClickListener)
thumbnailView.get().setProgressWheelClickListener(progressWheelClickListener)
thumbnailView.get().setCancelDownloadClickListener(cancelDownloadClickListener)
thumbnailView.get().setPlayVideoClickListener(playVideoClickListener)
thumbnailView.get().setOnLongClickListener(longClickListener)
thumbnailView.get().setBounds(minWidth, maxWidth, minHeight, maxHeight)
}
@@ -72,6 +80,10 @@ data class ConversationItemThumbnailState(
@IgnoredOnParcel
private val downloadClickListener: SlidesClickedListener? = null,
@IgnoredOnParcel
private val cancelDownloadClickListener: SlidesClickedListener? = null,
@IgnoredOnParcel
private val playVideoClickListener: SlideClickListener? = null,
@IgnoredOnParcel
private val longClickListener: OnLongClickListener? = null,
private val visibility: Int = View.GONE,
private val cellBackgroundColor: Int = Color.TRANSPARENT,
@@ -92,6 +104,8 @@ data class ConversationItemThumbnailState(
albumView.get().setRadii(cornerTopLeft, cornerTopRight, cornerBottomRight, cornerBottomLeft)
albumView.get().setThumbnailClickListener(clickListener)
albumView.get().setDownloadClickListener(downloadClickListener)
albumView.get().setCancelDownloadClickListener(cancelDownloadClickListener)
albumView.get().setPlayVideoClickListener(playVideoClickListener)
albumView.get().setOnLongClickListener(longClickListener)
albumView.get().setCellBackgroundColor(cellBackgroundColor)
}

View File

@@ -5,15 +5,15 @@
package org.thoughtcrime.securesms.components
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import org.signal.core.util.ResourceUtil
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.R
@@ -31,13 +31,17 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
private const val KEY_PURPOSE = "purpose"
@JvmStatic
fun show(context: Context, fragmentManager: FragmentManager, purpose: Purpose) {
if (NetworkUtil.isConnected(context) && fragmentManager.findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) == null) {
fun show(activity: AppCompatActivity, purpose: Purpose) {
if (!activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
return
}
if (NetworkUtil.isConnected(activity) && activity.supportFragmentManager.findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) == null) {
DebugLogsPromptDialogFragment().apply {
arguments = bundleOf(
KEY_PURPOSE to purpose.serialized
)
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}.show(activity.supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
when (purpose) {
Purpose.NOTIFICATIONS -> SignalStore.uiHints().lastNotificationLogsPrompt = System.currentTimeMillis()

View File

@@ -5,13 +5,11 @@ import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.graphics.drawable.Drawable;
import android.text.SpannableStringBuilder;
import android.text.style.CharacterStyle;
import android.util.AttributeSet;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import org.signal.core.util.BreakIteratorCompat;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.SimpleEmojiTextView;
@@ -20,7 +18,6 @@ import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Iterator;
import java.util.Objects;
public class FromTextView extends SimpleEmojiTextView {

View File

@@ -37,7 +37,6 @@ import org.thoughtcrime.securesms.util.ViewUtil;
import java.lang.reflect.Field;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* LinearLayout that, when a view container, will report back when it thinks a soft keyboard

View File

@@ -217,7 +217,7 @@ public class LinkPreviewView extends FrameLayout {
thumbnail.setVisibility(VISIBLE);
thumbnailState.applyState(thumbnail);
thumbnail.get().setImageResource(glideRequests, new ImageSlide(linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION && !scheduleMessageMode, false);
thumbnail.get().showDownloadText(false);
thumbnail.get().showSecondaryText(false);
} else if (callLinkRootKey != null) {
thumbnail.setVisibility(VISIBLE);
thumbnailState.applyState(thumbnail);
@@ -228,7 +228,7 @@ public class LinkPreviewView extends FrameLayout {
.asDrawable(getContext(),
AvatarColorHash.forCallLink(callLinkRootKey.getKeyBytes()))
);
thumbnail.get().showDownloadText(false);
thumbnail.get().showSecondaryText(false);
} else {
thumbnail.setVisibility(GONE);
}

View File

@@ -12,7 +12,6 @@ import android.view.ViewGroup
import androidx.annotation.RequiresApi
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.ViewModelProvider
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.databinding.PromptBatterySaverBottomSheetBinding
@@ -41,8 +40,6 @@ class PromptBatterySaverDialogFragment : FixedRoundedCornerBottomSheetDialogFrag
private val binding by ViewBinderDelegate(PromptBatterySaverBottomSheetBinding::bind)
private lateinit var viewModel: PromptLogsViewModel
private val disposables: LifecycleDisposable = LifecycleDisposable()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@@ -52,7 +49,6 @@ class PromptBatterySaverDialogFragment : FixedRoundedCornerBottomSheetDialogFrag
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
disposables.bindTo(viewLifecycleOwner)
viewModel = ViewModelProvider(this)[PromptLogsViewModel::class.java]
binding.continueButton.setOnClickListener {
PowerManagerCompat.requestIgnoreBatteryOptimizations(requireContext())
}

View File

@@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.components;
import android.annotation.TargetApi;
import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor;

View File

@@ -0,0 +1,65 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components
import android.animation.LayoutTransition
import android.view.View
import android.view.View.OnAttachStateChangeListener
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
/**
* Helps manage layout transition state inside a RecyclerView.
*
* Because of how RecyclerViews scroll, we need to be very careful about LayoutTransition
* usage inside of them. This class helps wrap up the pattern of finding and listening to
* the scroll events of a parent recycler view, so that we don't need to manually wire
* the scroll state in everywhere.
*/
class RecyclerViewParentTransitionController(
private val child: ViewGroup,
private val transition: LayoutTransition = LayoutTransition()
) : RecyclerView.OnScrollListener(), OnAttachStateChangeListener {
private var recyclerViewParent: RecyclerView? = null
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
child.layoutTransition = transition
} else {
child.layoutTransition = null
}
}
override fun onViewAttachedToWindow(v: View) {
val parent = findRecyclerParent()
if (parent != null) {
onScrollStateChanged(parent, parent.scrollState)
}
parent?.addOnScrollListener(this)
recyclerViewParent = parent
}
override fun onViewDetachedFromWindow(v: View) {
recyclerViewParent?.removeOnScrollListener(this)
child.layoutTransition = null
}
private fun findRecyclerParent(): RecyclerView? {
var target: ViewGroup? = child.parent as? ViewGroup
while (target != null) {
if (target is RecyclerView) {
return target
}
target = target.parent as? ViewGroup
}
return null
}
}

View File

@@ -1,3 +1,8 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components;
import android.content.Context;
@@ -31,6 +36,7 @@ import org.signal.core.util.logging.Log;
import org.signal.glide.transforms.SignalDownsampleStrategy;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.blurhash.BlurHash;
import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequest;
@@ -41,7 +47,6 @@ import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.stories.StoryTextPostModel;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
@@ -50,6 +55,7 @@ import org.thoughtcrime.securesms.util.views.Stub;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.ExecutionException;
@@ -80,12 +86,12 @@ public class ThumbnailView extends FrameLayout {
private final CornerMask cornerMask;
private ThumbnailViewTransferControlsState transferControlsState = new ThumbnailViewTransferControlsState();
private Stub<TransferControlView> transferControlViewStub;
private SlideClickListener thumbnailClickListener = null;
private SlidesClickedListener downloadClickListener = null;
private SlideClickListener progressWheelClickListener = null;
private Slide slide = null;
private final Stub<TransferControlView> transferControlViewStub;
private SlideClickListener thumbnailClickListener = null;
private SlidesClickedListener downloadClickListener = null;
private SlidesClickedListener cancelDownloadClickListener = null;
private SlideClickListener playVideoClickListener = null;
private Slide slide = null;
public ThumbnailView(Context context) {
@@ -278,15 +284,13 @@ public class ThumbnailView extends FrameLayout {
@Override
public void setFocusable(boolean focusable) {
super.setFocusable(focusable);
transferControlsState = transferControlsState.withFocusable(focusable);
transferControlsState.applyState(transferControlViewStub);
transferControlViewStub.get().setFocusable(focusable);
}
@Override
public void setClickable(boolean clickable) {
super.setClickable(clickable);
transferControlsState = transferControlsState.withClickable(clickable);
transferControlsState.applyState(transferControlViewStub);
transferControlViewStub.get().setClickable(clickable);
}
public @Nullable Drawable getImageDrawable() {
@@ -359,24 +363,15 @@ public class ThumbnailView extends FrameLayout {
}
if (showControls) {
int transferState = TransferControlView.getTransferState(Collections.singletonList(slide));
if (transferState == AttachmentTable.TRANSFER_PROGRESS_DONE || transferState == AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE) {
transferControlViewStub.setVisibility(View.GONE);
} else {
transferControlViewStub.setVisibility(View.VISIBLE);
transferControlViewStub.get().setDownloadClickListener(new DownloadClickDispatcher());
transferControlViewStub.get().setCancelClickListener(new CancelClickDispatcher());
if (MediaUtil.isInstantVideoSupported(slide)) {
transferControlViewStub.get().setInstantPlaybackClickListener(new InstantVideoClickDispatcher());
}
transferControlsState = transferControlsState.withSlide(slide)
.withDownloadClickListener(new DownloadClickDispatcher());
if (FeatureFlags.instantVideoPlayback()) {
transferControlsState = transferControlsState.withProgressWheelClickListener(new ProgressWheelClickDispatcher());
}
transferControlsState.applyState(transferControlViewStub);
} else {
transferControlViewStub.setVisibility(View.GONE);
transferControlViewStub.get().setSlides(List.of(slide));
}
int transferState = TransferControlView.getTransferState(List.of(slide));
transferControlViewStub.get().setVisible(showControls && transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE);
if (slide.getUri() != null && slide.hasPlayOverlay() &&
(slide.getTransferState() == AttachmentTable.TRANSFER_PROGRESS_DONE || isPreview))
@@ -525,8 +520,41 @@ public class ThumbnailView extends FrameLayout {
this.downloadClickListener = listener;
}
public void setProgressWheelClickListener(SlideClickListener listener) {
this.progressWheelClickListener = listener;
public void setCancelDownloadClickListener(SlidesClickedListener listener) {
this.cancelDownloadClickListener = listener;
}
public void setPlayVideoClickListener(SlideClickListener listener) {
this.playVideoClickListener = listener;
}
private static boolean hasSameContents(@Nullable Slide slide, @Nullable Slide other) {
if (Util.equals(slide, other)) {
if (slide != null && other != null) {
byte[] digestLeft = slide.asAttachment().getDigest();
byte[] digestRight = other.asAttachment().getDigest();
return Arrays.equals(digestLeft, digestRight);
}
}
return false;
}
private GlideRequest<Drawable> buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
GlideRequest<Drawable> request = applySizing(glideRequests.load(new DecryptableUri(Objects.requireNonNull(slide.getUri())))
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.downsample(SignalDownsampleStrategy.CENTER_OUTSIDE_NO_UPSCALE)
.transition(withCrossFade()));
boolean doNotShowMissingThumbnailImage = Build.VERSION.SDK_INT < 23;
if (slide.isInProgress() || doNotShowMissingThumbnailImage) {
return request;
} else {
return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture));
}
}
public void clear(GlideRequests glideRequests) {
@@ -543,13 +571,12 @@ public class ThumbnailView extends FrameLayout {
slide = null;
}
public void showDownloadText(boolean showDownloadText) {
transferControlsState = transferControlsState.withDownloadText(showDownloadText);
transferControlsState.applyState(transferControlViewStub);
public void showSecondaryText(boolean showSecondaryText) {
transferControlViewStub.get().setShowSecondaryText(showSecondaryText);
}
public void showProgressSpinner() {
transferControlViewStub.get().showProgressSpinner();
transferControlViewStub.get().setVisible(true);
}
public void setScaleType(@NonNull ImageView.ScaleType scaleType) {
@@ -566,20 +593,6 @@ public class ThumbnailView extends FrameLayout {
invalidate();
}
private GlideRequest<Drawable> buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
GlideRequest<Drawable> request = applySizing(glideRequests.load(new DecryptableUri(Objects.requireNonNull(slide.getUri())))
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.downsample(SignalDownsampleStrategy.CENTER_OUTSIDE_NO_UPSCALE)
.transition(withCrossFade()));
boolean doNotShowMissingThumbnailImage = Build.VERSION.SDK_INT < 23;
if (slide.isInProgress() || doNotShowMissingThumbnailImage) {
return request;
} else {
return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture));
}
}
private RequestBuilder<Bitmap> buildPlaceholderGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
GlideRequest<Bitmap> bitmap = glideRequests.asBitmap();
@@ -591,7 +604,12 @@ public class ThumbnailView extends FrameLayout {
bitmap = bitmap.load(slide.getPlaceholderRes(getContext().getTheme()));
}
return applySizing(bitmap.diskCacheStrategy(DiskCacheStrategy.NONE));
final GlideRequest<Bitmap> resizedRequest = applySizing(bitmap.diskCacheStrategy(DiskCacheStrategy.NONE));
if (placeholderBlur != null) {
return resizedRequest.centerCrop();
} else {
return resizedRequest;
}
}
private <TranscodeType> GlideRequest<TranscodeType> applySizing(@NonNull GlideRequest<TranscodeType> request) {
@@ -621,19 +639,6 @@ public class ThumbnailView extends FrameLayout {
return 0;
}
private static boolean hasSameContents(@Nullable Slide slide, @Nullable Slide other) {
if (Util.equals(slide, other)) {
if (slide != null && other != null) {
byte[] digestLeft = slide.asAttachment().getDigest();
byte[] digestRight = other.asAttachment().getDigest();
return Arrays.equals(digestLeft, digestRight);
}
}
return false;
}
public interface ThumbnailRequestListener extends RequestListener<Drawable> {
void onLoadCanceled();
@@ -670,14 +675,26 @@ public class ThumbnailView extends FrameLayout {
}
}
private class ProgressWheelClickDispatcher implements View.OnClickListener {
private class CancelClickDispatcher implements View.OnClickListener {
@Override
public void onClick(View view) {
Log.i(TAG, "onClick() for progress wheel");
if (progressWheelClickListener != null && slide != null) {
progressWheelClickListener.onClick(view, slide);
Log.i(TAG, "onClick() for cancel button");
if (cancelDownloadClickListener != null && slide != null) {
cancelDownloadClickListener.onClick(view, Collections.singletonList(slide));
} else {
Log.w(TAG, "Received a progress wheel click, but unable to execute it. slide: " + slide + " progressWheelClickListener: " + progressWheelClickListener);
Log.w(TAG, "Received a cancel button click, but unable to execute it. slide: " + slide + " cancelDownloadClickListener: " + cancelDownloadClickListener);
}
}
}
private class InstantVideoClickDispatcher implements View.OnClickListener {
@Override
public void onClick(View view) {
Log.i(TAG, "onClick() for instant video playback");
if (playVideoClickListener != null && slide != null) {
playVideoClickListener.onClick(view, slide);
} else {
Log.w(TAG, "Received an instant video click, but unable to execute it. slide: " + slide + " playVideoClickListener: " + playVideoClickListener);
}
}
}

View File

@@ -1,38 +0,0 @@
package org.thoughtcrime.securesms.components
import android.view.View.OnClickListener
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.util.views.Stub
/**
* State object for transfer controls.
*/
data class ThumbnailViewTransferControlsState(
val isFocusable: Boolean = true,
val isClickable: Boolean = true,
val slide: Slide? = null,
val downloadClickedListener: OnClickListener? = null,
val progressWheelClickedListener: OnClickListener? = null,
val showDownloadText: Boolean = true
) {
fun withFocusable(isFocusable: Boolean): ThumbnailViewTransferControlsState = copy(isFocusable = isFocusable)
fun withClickable(isClickable: Boolean): ThumbnailViewTransferControlsState = copy(isClickable = isClickable)
fun withSlide(slide: Slide?): ThumbnailViewTransferControlsState = copy(slide = slide)
fun withDownloadClickListener(downloadClickedListener: OnClickListener): ThumbnailViewTransferControlsState = copy(downloadClickedListener = downloadClickedListener)
fun withProgressWheelClickListener(progressWheelClickedListener: OnClickListener): ThumbnailViewTransferControlsState = copy(progressWheelClickedListener = progressWheelClickedListener)
fun withDownloadText(showDownloadText: Boolean): ThumbnailViewTransferControlsState = copy(showDownloadText = showDownloadText)
fun applyState(transferControlView: Stub<TransferControlView>) {
if (transferControlView.resolved()) {
transferControlView.get().isFocusable = isFocusable
transferControlView.get().isClickable = isClickable
if (slide != null) {
transferControlView.get().setSlide(slide)
}
transferControlView.get().setDownloadClickListener(downloadClickedListener)
transferControlView.get().setProgressWheelClickListener(progressWheelClickedListener)
transferControlView.get().setShowDownloadText(showDownloadText)
}
}
}

View File

@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.graphics.PorterDuff;
import android.os.Build;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;

View File

@@ -1,273 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.animation.LayoutTransition;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import com.annimon.stream.Stream;
import com.pnikosis.materialishprogress.ProgressWheel;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.events.PartProgressEvent;
import org.thoughtcrime.securesms.mms.Slide;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class TransferControlView extends FrameLayout {
private static final String TAG = "TransferControlView";
private static final int UPLOAD_TASK_WEIGHT = 1;
/**
* A weighting compared to {@link #UPLOAD_TASK_WEIGHT}
*/
private static final int COMPRESSION_TASK_WEIGHT = 3;
@Nullable private List<Slide> slides;
@Nullable private View current;
private final ProgressWheel progressWheel;
private final View downloadDetails;
private final TextView downloadDetailsText;
private final Map<Attachment, Float> networkProgress;
private final Map<Attachment, Float> compresssionProgress;
public TransferControlView(Context context) {
this(context, null);
}
public TransferControlView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public TransferControlView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
inflate(context, R.layout.transfer_controls_view, this);
setLongClickable(false);
setBackground(ContextCompat.getDrawable(context, R.drawable.transfer_controls_background));
setVisibility(GONE);
setLayoutTransition(new LayoutTransition());
this.networkProgress = new HashMap<>();
this.compresssionProgress = new HashMap<>();
this.progressWheel = findViewById(R.id.progress_wheel);
this.downloadDetails = findViewById(R.id.download_details);
this.downloadDetailsText = findViewById(R.id.download_details_text);
}
@Override
public void setFocusable(boolean focusable) {
super.setFocusable(focusable);
downloadDetails.setFocusable(focusable);
}
@Override
public void setClickable(boolean clickable) {
super.setClickable(clickable);
downloadDetails.setClickable(clickable);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this);
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
EventBus.getDefault().unregister(this);
}
public void setSlide(final @NonNull Slide slides) {
setSlides(Collections.singletonList(slides));
}
public void setSlides(final @NonNull List<Slide> slides) {
if (slides.isEmpty()) {
throw new IllegalArgumentException("Must provide at least one slide.");
}
this.slides = slides;
if (!isUpdateToExistingSet(slides)) {
networkProgress.clear();
compresssionProgress.clear();
Stream.of(slides).forEach(s -> networkProgress.put(s.asAttachment(), 0f));
}
for (Slide slide : slides) {
if (slide.asAttachment().getTransferState() == AttachmentTable.TRANSFER_PROGRESS_DONE) {
networkProgress.put(slide.asAttachment(), 1f);
}
}
switch (getTransferState(slides)) {
case AttachmentTable.TRANSFER_PROGRESS_STARTED:
showProgressSpinner(calculateProgress(networkProgress, compresssionProgress));
break;
case AttachmentTable.TRANSFER_PROGRESS_PENDING:
case AttachmentTable.TRANSFER_PROGRESS_FAILED:
String downloadText = getDownloadText(this.slides);
if (!Objects.equals(downloadText, downloadDetailsText.getText().toString())) {
downloadDetailsText.setText(getDownloadText(this.slides));
}
display(downloadDetails);
break;
default:
display(null);
break;
}
}
public void showProgressSpinner() {
showProgressSpinner(calculateProgress(networkProgress, compresssionProgress));
}
public void showProgressSpinner(float progress) {
if (progress == 0) {
progressWheel.spin();
} else {
progressWheel.setInstantProgress(progress);
}
display(progressWheel);
}
public void setDownloadClickListener(final @Nullable OnClickListener listener) {
downloadDetails.setOnClickListener(listener);
}
public void setProgressWheelClickListener(final @Nullable OnClickListener listener) {
progressWheel.setOnClickListener(listener);
}
public void clear() {
clearAnimation();
setVisibility(GONE);
if (current != null) {
current.clearAnimation();
current.setVisibility(GONE);
}
current = null;
slides = null;
}
public void setShowDownloadText(boolean showDownloadText) {
downloadDetailsText.setVisibility(showDownloadText ? VISIBLE : GONE);
forceLayout();
}
private boolean isUpdateToExistingSet(@NonNull List<Slide> slides) {
if (slides.size() != networkProgress.size()) {
return false;
}
for (Slide slide : slides) {
if (!networkProgress.containsKey(slide.asAttachment())) {
return false;
}
}
return true;
}
static int getTransferState(@NonNull List<Slide> slides) {
int transferState = AttachmentTable.TRANSFER_PROGRESS_DONE;
boolean allFailed = true;
for (Slide slide : slides) {
if (slide.getTransferState() != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE) {
allFailed = false;
if (slide.getTransferState() == AttachmentTable.TRANSFER_PROGRESS_PENDING && transferState == AttachmentTable.TRANSFER_PROGRESS_DONE) {
transferState = slide.getTransferState();
} else {
transferState = Math.max(transferState, slide.getTransferState());
}
}
}
return allFailed ? AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE : transferState;
}
private String getDownloadText(@NonNull List<Slide> slides) {
if (slides.size() == 1) {
return slides.get(0).getContentDescription(getContext());
} else {
int downloadCount = Stream.of(slides).reduce(0, (count, slide) -> slide.getTransferState() != AttachmentTable.TRANSFER_PROGRESS_DONE ? count + 1 : count);
return getContext().getResources().getQuantityString(R.plurals.TransferControlView_n_items, downloadCount, downloadCount);
}
}
private void display(@Nullable final View view) {
if (current == view) {
return;
}
if (current != null) {
current.setVisibility(GONE);
}
if (view != null) {
view.setVisibility(VISIBLE);
setVisibility(VISIBLE);
} else {
setVisibility(GONE);
}
current = view;
}
private static float calculateProgress(@NonNull Map<Attachment, Float> uploadDownloadProgress, Map<Attachment, Float> compresssionProgress) {
float totalDownloadProgress = 0;
float totalCompressionProgress = 0;
for (float progress : uploadDownloadProgress.values()) {
totalDownloadProgress += progress;
}
for (float progress : compresssionProgress.values()) {
totalCompressionProgress += progress;
}
float weightedProgress = UPLOAD_TASK_WEIGHT * totalDownloadProgress + COMPRESSION_TASK_WEIGHT * totalCompressionProgress;
float weightedTotal = UPLOAD_TASK_WEIGHT * uploadDownloadProgress.size() + COMPRESSION_TASK_WEIGHT * compresssionProgress.size();
return weightedProgress / weightedTotal;
}
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
public void onEventAsync(final PartProgressEvent event) {
final Attachment attachment = event.attachment;
if (networkProgress.containsKey(attachment)) {
float proportionCompleted = ((float) event.progress) / event.total;
if (event.type == PartProgressEvent.Type.COMPRESSION) {
compresssionProgress.put(attachment, proportionCompleted);
} else {
networkProgress.put(attachment, proportionCompleted);
}
progressWheel.setInstantProgress(calculateProgress(networkProgress, compresssionProgress));
}
}
}

View File

@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.util.AttributeSet;
import android.view.MotionEvent;
@@ -10,13 +9,9 @@ import android.view.View;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.exifinterface.media.ExifInterface;
import com.bumptech.glide.load.DataSource;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.engine.GlideException;
import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.target.Target;
import com.davemorrissey.labs.subscaleview.ImageSource;
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView;

View File

@@ -4,6 +4,7 @@ import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import okio.ByteString.Companion.toByteString
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.SignalProtocolAddress
@@ -17,7 +18,6 @@ import org.thoughtcrime.securesms.crypto.PreKeyUtil
import org.thoughtcrime.securesms.database.IdentityTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingChangeNumberMetadata
import org.thoughtcrime.securesms.database.model.toProtoByteString
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.keyvalue.CertificateType
@@ -42,7 +42,7 @@ import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage
import org.whispersystems.signalservice.internal.push.SyncMessage
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException
@@ -367,13 +367,13 @@ class ChangeNumberRepository(
// Device Messages
if (deviceId != primaryDeviceId) {
val pniChangeNumber = SyncMessage.PniChangeNumber.newBuilder()
.setIdentityKeyPair(pniIdentity.serialize().toProtoByteString())
.setSignedPreKey(signedPreKeyRecord.serialize().toProtoByteString())
.setLastResortKyberPreKey(lastResortKyberPreKeyRecord.serialize().toProtoByteString())
.setRegistrationId(pniRegistrationId)
.setNewE164(newE164)
.build()
val pniChangeNumber = SyncMessage.PniChangeNumber(
identityKeyPair = pniIdentity.serialize().toByteString(),
signedPreKey = signedPreKeyRecord.serialize().toByteString(),
lastResortKyberPreKey = lastResortKyberPreKeyRecord.serialize().toByteString(),
registrationId = pniRegistrationId,
newE164 = newE164
)
deviceMessages += messageSender.getEncryptedSyncPniInitializeDeviceMessage(deviceId, pniChangeNumber)
}
@@ -391,12 +391,12 @@ class ChangeNumberRepository(
pniRegistrationIds.mapKeys { it.key.toString() }
)
val metadata = PendingChangeNumberMetadata.newBuilder()
.setPreviousPni(SignalStore.account().pni!!.toByteString())
.setPniIdentityKeyPair(pniIdentity.serialize().toProtoByteString())
.setPniRegistrationId(pniRegistrationIds[primaryDeviceId]!!)
.setPniSignedPreKeyId(devicePniSignedPreKeys[primaryDeviceId]!!.keyId)
.build()
val metadata = PendingChangeNumberMetadata(
previousPni = SignalStore.account().pni!!.toByteString(),
pniIdentityKeyPair = pniIdentity.serialize().toByteString(),
pniRegistrationId = pniRegistrationIds[primaryDeviceId]!!,
pniSignedPreKeyId = devicePniSignedPreKeys[primaryDeviceId]!!.keyId
)
return ChangeNumberRequestData(request, metadata)
}

View File

@@ -725,7 +725,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
private fun enqueueSubscriptionRedemption() {
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue()
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(-1L).enqueue()
}
private fun enqueueSubscriptionKeepAlive() {

View File

@@ -32,7 +32,7 @@ class InternalSettingsRepository(context: Context) {
val title = "Release Note Title"
val bodyText = "Release note body. Aren't I awesome?"
val body = "$title\n\n$bodyText"
val bodyRangeList = BodyRangeList.newBuilder()
val bodyRangeList = BodyRangeList.Builder()
.addStyle(BodyRangeList.BodyRange.Style.BOLD, 0, title.length)
val recipientId = SignalStore.releaseChannelValues().releaseChannelRecipientId!!

View File

@@ -8,17 +8,20 @@
package org.thoughtcrime.securesms.components.settings.app.internal.search
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -52,15 +55,21 @@ class InternalSearchFragment : ComposeFragment() {
@Composable
fun InternalSearchFragmentScreen(query: String, results: ImmutableList<InternalSearchResult>, onSearchUpdated: (String) -> Unit, modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier.fillMaxWidth()
) {
item(key = -1) {
SearchBar(query, onSearchUpdated)
}
results.forEach { recipient ->
item(key = recipient.id) {
ResultItem(recipient)
val backgroundColor = MaterialTheme.colorScheme.surface
CompositionLocalProvider(LocalContentColor provides contentColorFor(backgroundColor = backgroundColor)) {
LazyColumn(
modifier = modifier
.fillMaxWidth()
.background(backgroundColor)
) {
item(key = -1) {
SearchBar(query, onSearchUpdated)
}
results.forEach { recipient ->
item(key = recipient.id) {
ResultItem(recipient)
}
}
}
}
@@ -85,7 +94,9 @@ fun ResultItem(result: InternalSearchResult, modifier: Modifier = Modifier) {
.fillMaxWidth()
.clickable {
if (activity != null) {
RecipientBottomSheetDialogFragment.create(result.id, result.groupId).show(activity.supportFragmentManager, "TAG")
RecipientBottomSheetDialogFragment
.create(result.id, result.groupId)
.show(activity.supportFragmentManager, "TAG")
}
}
.padding(8.dp)
@@ -101,9 +112,7 @@ fun ResultItem(result: InternalSearchResult, modifier: Modifier = Modifier) {
@Composable
fun InternalSearchScreenPreviewLightTheme() {
SignalTheme(isDarkMode = false) {
Surface {
InternalSearchScreenPreview()
}
InternalSearchScreenPreview()
}
}
@@ -111,9 +120,7 @@ fun InternalSearchScreenPreviewLightTheme() {
@Composable
fun InternalSearchScreenPreviewDarkTheme() {
SignalTheme(isDarkMode = true) {
Surface {
InternalSearchScreenPreview()
}
InternalSearchScreenPreview()
}
}

View File

@@ -14,6 +14,7 @@ import java.util.Currency
private const val CARD = "CARD"
private const val PAYPAL = "PAYPAL"
private const val SEPA_DEBIT = "SEPA_DEBIT"
/**
* Transforms the DonationsConfiguration into a Set<FiatMoney> which has been properly filtered
@@ -116,6 +117,7 @@ private fun DonationsConfiguration.getFilteredCurrencies(paymentMethodAvailabili
interface PaymentMethodAvailability {
fun isPayPalAvailable(): Boolean
fun isGooglePayOrCreditCardAvailable(): Boolean
fun isSEPADebitAvailable(): Boolean
fun toSet(): Set<String> {
val set = mutableSetOf<String>()
@@ -127,6 +129,10 @@ interface PaymentMethodAvailability {
set.add(CARD)
}
if (isSEPADebitAvailable()) {
set.add(SEPA_DEBIT)
}
return set
}
}
@@ -134,4 +140,5 @@ interface PaymentMethodAvailability {
private object DefaultPaymentMethodAvailability : PaymentMethodAvailability {
override fun isPayPalAvailable(): Boolean = InAppDonations.isPayPalAvailable()
override fun isGooglePayOrCreditCardAvailable(): Boolean = InAppDonations.isCreditCardAvailable() || InAppDonations.isGooglePayAvailable()
override fun isSEPADebitAvailable(): Boolean = InAppDonations.isSEPADebitAvailable()
}

View File

@@ -16,10 +16,11 @@ object InAppDonations {
*
* - Able to use Credit Cards and is in a region where they are able to be accepted.
* - Able to access Google Play services (and thus possibly able to use Google Pay).
* - Able to use SEPA Debit and is in a region where they are able to be accepted.
* - Able to use PayPal and is in a region where it is able to be accepted.
*/
fun hasAtLeastOnePaymentMethodAvailable(): Boolean {
return isCreditCardAvailable() || isPayPalAvailable() || isGooglePayAvailable()
return isCreditCardAvailable() || isPayPalAvailable() || isGooglePayAvailable() || isSEPADebitAvailable()
}
fun isPaymentSourceAvailable(paymentSourceType: PaymentSourceType, donateToSignalType: DonateToSignalType): Boolean {
@@ -27,6 +28,7 @@ object InAppDonations {
PaymentSourceType.PayPal -> isPayPalAvailableForDonateToSignalType(donateToSignalType)
PaymentSourceType.Stripe.CreditCard -> isCreditCardAvailable()
PaymentSourceType.Stripe.GooglePay -> isGooglePayAvailable()
PaymentSourceType.Stripe.SEPADebit -> isSEPADebitAvailable()
PaymentSourceType.Unknown -> false
}
}
@@ -58,4 +60,11 @@ object InAppDonations {
fun isGooglePayAvailable(): Boolean {
return SignalStore.donationsValues().isGooglePayReady && !LocaleFeatureFlags.isGooglePayDisabled()
}
/**
* Whether the user is in a region which supports SEPA Debit transfers, based off local phone number.
*/
fun isSEPADebitAvailable(): Boolean {
return FeatureFlags.sepaDebitDonations()
}
}

View File

@@ -79,9 +79,29 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
}.subscribeOn(Schedulers.io())
}
fun ensureSubscriberId(): Completable {
Log.d(TAG, "Ensuring SubscriberId exists on Signal service...", true)
val subscriberId = SignalStore.donationsValues().getSubscriber()?.subscriberId ?: SubscriberId.generate()
/**
* Since PayPal and Stripe can't interoperate, we need to be able to rotate the subscriber ID
* in case of failures.
*/
fun rotateSubscriberId(): Completable {
Log.d(TAG, "Rotating SubscriberId due to alternate payment processor...", true)
val cancelCompletable: Completable = if (SignalStore.donationsValues().getSubscriber() != null) {
cancelActiveSubscription().andThen(updateLocalSubscriptionStateAndScheduleDataSync())
} else {
Completable.complete()
}
return cancelCompletable.andThen(ensureSubscriberId(isRotation = true))
}
fun ensureSubscriberId(isRotation: Boolean = false): Completable {
Log.d(TAG, "Ensuring SubscriberId exists on Signal service {isRotation?$isRotation}...", true)
val subscriberId: SubscriberId = if (isRotation) {
SubscriberId.generate()
} else {
SignalStore.donationsValues().getSubscriber()?.subscriberId ?: SubscriberId.generate()
}
return Single
.fromCallable {
donationsService.putSubscription(subscriberId)
@@ -127,7 +147,7 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
}
}
fun setSubscriptionLevel(subscriptionLevel: String): Completable {
fun setSubscriptionLevel(subscriptionLevel: String, uiSessionKey: Long): Completable {
return getOrCreateLevelUpdateOperation(subscriptionLevel)
.flatMapCompletable { levelUpdateOperation ->
val subscriber = SignalStore.donationsValues().requireSubscriber()
@@ -166,7 +186,7 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue { _, jobState ->
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(uiSessionKey).enqueue { _, jobState ->
if (jobState.isComplete) {
finalJobState = jobState
countDownLatch.countDown()
@@ -222,4 +242,18 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
levelUpdateOperation
}
}
/**
* Update local state information and schedule a storage sync for the change. This method
* assumes you've already properly called the DELETE method for the stored ID on the server.
*/
private fun updateLocalSubscriptionStateAndScheduleDataSync(): Completable {
return Completable.fromAction {
Log.d(TAG, "Marking subscription cancelled...", true)
SignalStore.donationsValues().updateLocalStateForManualCancellation()
MultiDeviceSubscriptionSyncRequestJob.enqueue()
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
}
}
}

View File

@@ -111,7 +111,8 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
badgeRecipient: RecipientId,
additionalMessage: String?,
badgeLevel: Long,
donationProcessor: DonationProcessor
donationProcessor: DonationProcessor,
uiSessionKey: Long
): Completable {
val isBoost = badgeRecipient == Recipient.self().id
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
@@ -131,9 +132,9 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
val chain = if (isBoost) {
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor)
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor, uiSessionKey)
} else {
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, badgeRecipient, additionalMessage, badgeLevel, donationProcessor)
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, badgeRecipient, additionalMessage, badgeLevel, donationProcessor, uiSessionKey)
}
chain.enqueue { _, jobState ->

View File

@@ -29,6 +29,8 @@ class PayPalRepository(private val donationsService: DonationsService) {
private val TAG = Log.tag(PayPalRepository::class.java)
}
private val monthlyDonationRepository = MonthlyDonationRepository(donationsService)
fun createOneTimePaymentIntent(
amount: FiatMoney,
badgeRecipient: RecipientId,
@@ -69,7 +71,12 @@ class PayPalRepository(private val donationsService: DonationsService) {
}.flatMap { it.flattenResult() }.subscribeOn(Schedulers.io())
}
fun createPaymentMethod(): Single<PayPalCreatePaymentMethodResponse> {
/**
* Creates the PaymentMethod via the Signal Service. Note that if the operation fails with a 409,
* it means that the PaymentMethod is already tied to a Stripe account. We can retry in this
* situation by simply deleting the old subscriber id on the service and replacing it.
*/
fun createPaymentMethod(retryOn409: Boolean = true): Single<PayPalCreatePaymentMethodResponse> {
return Single.fromCallable {
donationsService.createPayPalPaymentMethod(
Locale.getDefault(),
@@ -77,7 +84,13 @@ class PayPalRepository(private val donationsService: DonationsService) {
MONTHLY_RETURN_URL,
CANCEL_URL
)
}.flatMap { it.flattenResult() }.subscribeOn(Schedulers.io())
}.flatMap { serviceResponse ->
if (retryOn409 && serviceResponse.status == 409) {
monthlyDonationRepository.rotateSubscriberId().andThen(createPaymentMethod(retryOn409 = false))
} else {
serviceResponse.flattenResult()
}
}.subscribeOn(Schedulers.io())
}
fun setDefaultPaymentMethod(paymentMethodId: String): Completable {

View File

@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
@@ -46,7 +47,8 @@ import org.whispersystems.signalservice.internal.ServiceResponse
class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION)
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient())
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient(), StandardUserAgentInterceptor.USER_AGENT)
private val monthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService())
fun isGooglePayAvailable(): Completable {
return googlePayApi.queryIsReadyToPay()
@@ -89,9 +91,11 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
badgeLevel: Long,
paymentSourceType: PaymentSourceType
): Single<StripeIntentAccessor> {
check(paymentSourceType is PaymentSourceType.Stripe)
Log.d(TAG, "Creating payment intent for $price...", true)
return stripeApi.createPaymentIntent(price, badgeLevel)
return stripeApi.createPaymentIntent(price, badgeLevel, paymentSourceType)
.onErrorResumeNext {
OneTimeDonationRepository.handleCreatePaymentIntentError(it, badgeRecipient, paymentSourceType)
}
@@ -109,9 +113,12 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
}.subscribeOn(Schedulers.io())
}
fun createAndConfirmSetupIntent(paymentSource: StripeApi.PaymentSource): Single<StripeApi.Secure3DSAction> {
fun createAndConfirmSetupIntent(
paymentSource: StripeApi.PaymentSource,
paymentSourceType: PaymentSourceType.Stripe
): Single<StripeApi.Secure3DSAction> {
Log.d(TAG, "Continuing subscription setup...", true)
return stripeApi.createSetupIntent()
return stripeApi.createSetupIntent(paymentSourceType)
.flatMap { result ->
Log.d(TAG, "Retrieved SetupIntent, confirming...", true)
stripeApi.confirmSetupIntent(paymentSource, result.setupIntent)
@@ -133,13 +140,13 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
}
}
override fun fetchPaymentIntent(price: FiatMoney, level: Long): Single<StripeIntentAccessor> {
override fun fetchPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): Single<StripeIntentAccessor> {
Log.d(TAG, "Fetching payment intent from Signal service for $price... (Locale.US minimum precision: ${price.minimumUnitPrecisionString})")
return Single
.fromCallable {
ApplicationDependencies
.getDonationsService()
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, level)
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, level, sourceType.paymentMethod)
}
.flatMap(ServiceResponse<StripeClientSecret>::flattenResult)
.map {
@@ -153,17 +160,32 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
}
}
override fun fetchSetupIntent(): Single<StripeIntentAccessor> {
Log.d(TAG, "Fetching setup intent from Signal service...")
/**
* Creates the PaymentMethod via the Signal Service. Note that if the operation fails with a 409,
* it means that the PaymentMethod is already tied to a PayPal account. We can retry in this
* situation by simply deleting the old subscriber id on the service and replacing it.
*/
private fun createPaymentMethod(paymentSourceType: PaymentSourceType.Stripe, retryOn409: Boolean = true): Single<StripeClientSecret> {
return Single.fromCallable { SignalStore.donationsValues().requireSubscriber() }
.flatMap {
Single.fromCallable {
ApplicationDependencies
.getDonationsService()
.createStripeSubscriptionPaymentMethod(it.subscriberId)
.createStripeSubscriptionPaymentMethod(it.subscriberId, paymentSourceType.paymentMethod)
}
}
.flatMap(ServiceResponse<StripeClientSecret>::flattenResult)
.flatMap { serviceResponse ->
if (retryOn409 && serviceResponse.status == 409) {
monthlyDonationRepository.rotateSubscriberId().andThen(createPaymentMethod(paymentSourceType, retryOn409 = false))
} else {
serviceResponse.flattenResult()
}
}
}
override fun fetchSetupIntent(sourceType: PaymentSourceType.Stripe): Single<StripeIntentAccessor> {
Log.d(TAG, "Fetching setup intent from Signal service...")
return createPaymentMethod(sourceType)
.map {
StripeIntentAccessor(
objectType = StripeIntentAccessor.ObjectType.SETUP_INTENT,
@@ -191,6 +213,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
}
StatusAndPaymentMethodId(it.status ?: StripeIntentStatus.SUCCEEDED, it.paymentMethod)
}
StripeIntentAccessor.ObjectType.SETUP_INTENT -> stripeApi.getSetupIntent(stripeIntentAccessor).let {
StatusAndPaymentMethodId(it.status, it.paymentMethod)
}
@@ -229,6 +252,11 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
}
}
fun createSEPADebitPaymentSource(sepaDebitData: StripeApi.SEPADebitData): Single<StripeApi.PaymentSource> {
Log.d(TAG, "Creating SEPA Debit payment source via Stripe api...")
return stripeApi.createPaymentSourceFromSEPADebitData(sepaDebitData)
}
data class StatusAndPaymentMethodId(
val status: StripeIntentStatus,
val paymentMethod: String?

View File

@@ -106,7 +106,7 @@ class DonateToSignalFragment :
}
override fun bindAdapter(adapter: MappingAdapter) {
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, DonationErrorSource.BOOST, DonationErrorSource.SUBSCRIPTION)
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, viewModel.uiSessionKey, DonationErrorSource.BOOST, DonationErrorSource.SUBSCRIPTION)
val recyclerView = this.recyclerView!!
recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS
@@ -417,6 +417,10 @@ class DonateToSignalFragment :
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayRequest))
}
override fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(gatewayRequest))
}
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(gatewayRequest.badge))
}

View File

@@ -58,6 +58,7 @@ class DonateToSignalViewModel(
val state = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
val actions: Observable<DonateToSignalAction> = _actions.observeOn(AndroidSchedulers.mainThread())
val uiSessionKey: Long = System.currentTimeMillis()
init {
initializeOneTimeDonationState(oneTimeDonationRepository)
@@ -178,6 +179,7 @@ class DonateToSignalViewModel(
private fun createGatewayRequest(snapshot: DonateToSignalState): GatewayRequest {
val amount = getAmount(snapshot)
return GatewayRequest(
uiSessionKey = uiSessionKey,
donateToSignalType = snapshot.donateToSignalType,
badge = snapshot.badge!!,
label = snapshot.badge!!.description,

View File

@@ -43,6 +43,7 @@ import java.util.Currency
class DonationCheckoutDelegate(
private val fragment: Fragment,
private val callback: Callback,
private val uiSessionKey: Long,
errorSource: DonationErrorSource,
vararg additionalSources: DonationErrorSource
) : DefaultLifecycleObserver {
@@ -65,7 +66,7 @@ class DonationCheckoutDelegate(
init {
fragment.viewLifecycleOwner.lifecycle.addObserver(this)
ErrorHandler().attach(fragment, callback, errorSource, *additionalSources)
ErrorHandler().attach(fragment, callback, uiSessionKey, errorSource, *additionalSources)
}
override fun onCreate(owner: LifecycleOwner) {
@@ -100,6 +101,7 @@ class DonationCheckoutDelegate(
GatewayResponse.Gateway.GOOGLE_PAY -> launchGooglePay(gatewayResponse)
GatewayResponse.Gateway.PAYPAL -> launchPayPal(gatewayResponse)
GatewayResponse.Gateway.CREDIT_CARD -> launchCreditCard(gatewayResponse)
GatewayResponse.Gateway.SEPA_DEBIT -> launchSEPADebit(gatewayResponse)
}
} else {
error("Unsupported combination! ${gatewayResponse.gateway} ${gatewayResponse.request.donateToSignalType}")
@@ -154,6 +156,10 @@ class DonationCheckoutDelegate(
callback.navigateToCreditCardForm(gatewayResponse.request)
}
private fun launchSEPADebit(gatewayResponse: GatewayResponse) {
callback.navigateToBankTransferMandate(gatewayResponse.request)
}
private fun registerGooglePayCallback() {
disposables += donationPaymentComponent.googlePayResultPublisher.subscribeBy(
onNext = { paymentResult ->
@@ -206,7 +212,7 @@ class DonationCheckoutDelegate(
private var errorDialog: DialogInterface? = null
private var userCancelledFlowCallback: UserCancelledFlowCallback? = null
fun attach(fragment: Fragment, userCancelledFlowCallback: UserCancelledFlowCallback?, errorSource: DonationErrorSource, vararg additionalSources: DonationErrorSource) {
fun attach(fragment: Fragment, userCancelledFlowCallback: UserCancelledFlowCallback?, uiSessionKey: Long, errorSource: DonationErrorSource, vararg additionalSources: DonationErrorSource) {
this.fragment = fragment
this.userCancelledFlowCallback = userCancelledFlowCallback
@@ -218,6 +224,8 @@ class DonationCheckoutDelegate(
additionalSources.forEach { source ->
disposables += registerErrorSource(source)
}
disposables += registerUiSession(uiSessionKey)
}
override fun onDestroy(owner: LifecycleOwner) {
@@ -234,6 +242,14 @@ class DonationCheckoutDelegate(
}
}
private fun registerUiSession(uiSessionKey: Long): Disposable {
return DonationError.getErrorsForUiSessionKey(uiSessionKey)
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
showErrorDialog(it)
}
}
private fun showErrorDialog(throwable: Throwable) {
if (errorDialog != null) {
Log.d(TAG, "Already displaying an error dialog. Skipping.", throwable, true)
@@ -281,6 +297,7 @@ class DonationCheckoutDelegate(
fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest)
fun navigateToPayPalPaymentInProgress(gatewayRequest: GatewayRequest)
fun navigateToCreditCardForm(gatewayRequest: GatewayRequest)
fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest)
fun onPaymentComplete(gatewayRequest: GatewayRequest)
fun onProcessorActionProcessed()
}

View File

@@ -54,7 +54,7 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
}
DonationCheckoutDelegate.ErrorHandler().attach(this, null, errorSource)
DonationCheckoutDelegate.ErrorHandler().attach(this, null, args.request.uiSessionKey, errorSource)
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!

View File

@@ -12,6 +12,7 @@ import java.util.Currency
@Parcelize
data class GatewayRequest(
val uiSessionKey: Long,
val donateToSignalType: DonateToSignalType,
val badge: Badge,
val label: String,

View File

@@ -9,13 +9,15 @@ data class GatewayResponse(val gateway: Gateway, val request: GatewayRequest) :
enum class Gateway {
GOOGLE_PAY,
PAYPAL,
CREDIT_CARD;
CREDIT_CARD,
SEPA_DEBIT;
fun toPaymentSourceType(): PaymentSourceType {
return when (this) {
GOOGLE_PAY -> PaymentSourceType.Stripe.GooglePay
PAYPAL -> PaymentSourceType.PayPal
CREDIT_CARD -> PaymentSourceType.Stripe.CreditCard
SEPA_DEBIT -> PaymentSourceType.Stripe.SEPADebit
}
}
}

View File

@@ -115,6 +115,20 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
)
}
if (state.isSEPADebitAvailable) {
space(8.dp)
primaryButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__bank_transfer),
icon = DSLSettingsIcon.from(R.drawable.credit_card, NO_TINT), // TODO [sepa] -- Final icon
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.SEPA_DEBIT, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
}
)
}
space(16.dp)
}
}

View File

@@ -17,6 +17,7 @@ class GatewaySelectorRepository(
when (it) {
"PAYPAL" -> listOf(GatewayResponse.Gateway.PAYPAL)
"CARD" -> listOf(GatewayResponse.Gateway.CREDIT_CARD, GatewayResponse.Gateway.GOOGLE_PAY)
"SEPA_DEBIT" -> listOf(GatewayResponse.Gateway.SEPA_DEBIT)
else -> listOf()
}
}.flatten().toSet()

View File

@@ -7,5 +7,6 @@ data class GatewaySelectorState(
val badge: Badge,
val isGooglePayAvailable: Boolean = false,
val isPayPalAvailable: Boolean = false,
val isCreditCardAvailable: Boolean = false
val isCreditCardAvailable: Boolean = false,
val isSEPADebitAvailable: Boolean = false
)

View File

@@ -24,7 +24,8 @@ class GatewaySelectorViewModel(
badge = args.request.badge,
isGooglePayAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.GooglePay, args.request.donateToSignalType),
isCreditCardAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.CreditCard, args.request.donateToSignalType),
isPayPalAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.PayPal, args.request.donateToSignalType)
isPayPalAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.PayPal, args.request.donateToSignalType),
isSEPADebitAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.SEPADebit, args.request.donateToSignalType)
)
)
private val disposables = CompositeDisposable()
@@ -41,7 +42,8 @@ class GatewaySelectorViewModel(
loading = false,
isCreditCardAvailable = it.isCreditCardAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.CREDIT_CARD),
isGooglePayAvailable = it.isGooglePayAvailable && googlePayAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.GOOGLE_PAY),
isPayPalAvailable = it.isPayPalAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.PAYPAL)
isPayPalAvailable = it.isPayPalAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.PAYPAL),
isSEPADebitAvailable = it.isSEPADebitAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.SEPA_DEBIT)
)
}
}

View File

@@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.toDonationError
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -28,6 +29,7 @@ import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentInt
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentMethodResponse
import org.whispersystems.signalservice.api.util.Preconditions
import org.whispersystems.signalservice.internal.push.DonationProcessor
import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError
class PayPalPaymentInProgressViewModel(
private val payPalRepository: PayPalRepository,
@@ -43,7 +45,6 @@ class PayPalPaymentInProgressViewModel(
val state: Flowable<DonationProcessorStage> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
private val disposables = CompositeDisposable()
override fun onCleared() {
store.dispose()
disposables.clear()
@@ -82,7 +83,7 @@ class PayPalPaymentInProgressViewModel(
Log.d(TAG, "Beginning subscription update...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString()))
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
.subscribeBy(
onComplete = {
Log.w(TAG, "Completed subscription update", true)
@@ -90,10 +91,10 @@ class PayPalPaymentInProgressViewModel(
},
onError = { throwable ->
Log.w(TAG, "Failed to update subscription", throwable, true)
val donationError: DonationError = if (throwable is DonationError) {
throwable
} else {
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
val donationError: DonationError = when (throwable) {
is DonationError -> throwable
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.SUBSCRIPTION, PaymentSourceType.PayPal)
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
@@ -157,7 +158,8 @@ class PayPalPaymentInProgressViewModel(
badgeRecipient = request.recipientId,
additionalMessage = request.additionalMessage,
badgeLevel = request.level,
donationProcessor = DonationProcessor.PAYPAL
donationProcessor = DonationProcessor.PAYPAL,
uiSessionKey = request.uiSessionKey
)
}
.subscribeOn(Schedulers.io())
@@ -166,10 +168,10 @@ class PayPalPaymentInProgressViewModel(
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)
store.update { DonationProcessorStage.FAILED }
val donationError: DonationError = if (throwable is DonationError) {
throwable
} else {
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST)
val donationError: DonationError = when (throwable) {
is DonationError -> throwable
is DonationProcessorError -> throwable.toDonationError(request.donateToSignalType.toErrorSource(), PaymentSourceType.PayPal)
else -> DonationError.genericBadgeRedemptionFailure(request.donateToSignalType.toErrorSource())
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
},
@@ -190,16 +192,16 @@ class PayPalPaymentInProgressViewModel(
.flatMapCompletable { payPalRepository.setDefaultPaymentMethod(it.paymentId) }
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it, PaymentSourceType.PayPal)) }
disposables += setup.andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString()))
disposables += setup.andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
.subscribeBy(
onError = { throwable ->
Log.w(TAG, "Failure in monthly payment pipeline...", throwable, true)
store.update { DonationProcessorStage.FAILED }
val donationError: DonationError = if (throwable is DonationError) {
throwable
} else {
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
val donationError: DonationError = when (throwable) {
is DonationError -> throwable
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.SUBSCRIPTION, PaymentSourceType.PayPal)
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
},

View File

@@ -23,12 +23,14 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.toDonationError
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.util.Preconditions
import org.whispersystems.signalservice.internal.push.DonationProcessor
import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError
class StripePaymentInProgressViewModel(
private val stripeRepository: StripeRepository,
@@ -44,8 +46,7 @@ class StripePaymentInProgressViewModel(
val state: Flowable<DonationProcessorStage> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
private val disposables = CompositeDisposable()
private var paymentData: PaymentData? = null
private var cardData: StripeApi.CardData? = null
private var stripePaymentData: StripePaymentData? = null
override fun onCleared() {
disposables.clear()
@@ -87,19 +88,18 @@ class StripePaymentInProgressViewModel(
}
private fun resolvePaymentSourceProvider(errorSource: DonationErrorSource): PaymentSourceProvider {
val paymentData = this.paymentData
val cardData = this.cardData
return when {
paymentData == null && cardData == null -> error("No payment provider available.")
paymentData != null && cardData != null -> error("Too many providers available")
paymentData != null -> PaymentSourceProvider(
return when (val data = stripePaymentData) {
is StripePaymentData.GooglePay -> PaymentSourceProvider(
PaymentSourceType.Stripe.GooglePay,
Single.just<StripeApi.PaymentSource>(GooglePayPaymentSource(paymentData)).doAfterTerminate { clearPaymentInformation() }
Single.just<StripeApi.PaymentSource>(GooglePayPaymentSource(data.paymentData)).doAfterTerminate { clearPaymentInformation() }
)
cardData != null -> PaymentSourceProvider(
is StripePaymentData.CreditCard -> PaymentSourceProvider(
PaymentSourceType.Stripe.CreditCard,
stripeRepository.createCreditCardPaymentSource(errorSource, cardData).doAfterTerminate { clearPaymentInformation() }
stripeRepository.createCreditCardPaymentSource(errorSource, data.cardData).doAfterTerminate { clearPaymentInformation() }
)
is StripePaymentData.SEPADebit -> PaymentSourceProvider(
PaymentSourceType.Stripe.SEPADebit,
stripeRepository.createSEPADebitPaymentSource(data.sepaDebitData).doAfterTerminate { clearPaymentInformation() }
)
else -> error("This should never happen.")
}
@@ -107,29 +107,35 @@ class StripePaymentInProgressViewModel(
fun providePaymentData(paymentData: PaymentData) {
requireNoPaymentInformation()
this.paymentData = paymentData
this.stripePaymentData = StripePaymentData.GooglePay(paymentData)
}
fun provideCardData(cardData: StripeApi.CardData) {
requireNoPaymentInformation()
this.cardData = cardData
this.stripePaymentData = StripePaymentData.CreditCard(cardData)
}
fun provideSEPADebitData(bankData: StripeApi.SEPADebitData) {
requireNoPaymentInformation()
this.stripePaymentData = StripePaymentData.SEPADebit(bankData)
}
private fun requireNoPaymentInformation() {
require(paymentData == null)
require(cardData == null)
require(stripePaymentData == null)
}
private fun clearPaymentInformation() {
Log.d(TAG, "Cleared payment information.", true)
paymentData = null
cardData = null
stripePaymentData = null
}
private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>) {
val ensureSubscriberId: Completable = monthlyDonationRepository.ensureSubscriberId()
val createAndConfirmSetupIntent: Single<StripeApi.Secure3DSAction> = paymentSourceProvider.paymentSource.flatMap { stripeRepository.createAndConfirmSetupIntent(it) }
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request.level.toString())
val createAndConfirmSetupIntent: Single<StripeApi.Secure3DSAction> = paymentSourceProvider.paymentSource.flatMap {
stripeRepository.createAndConfirmSetupIntent(it, paymentSourceProvider.paymentSourceType as PaymentSourceType.Stripe)
}
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey)
Log.d(TAG, "Starting subscription payment pipeline...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
@@ -144,10 +150,10 @@ class StripePaymentInProgressViewModel(
}
.flatMapCompletable { stripeRepository.setDefaultPaymentMethod(it, paymentSourceProvider.paymentSourceType) }
.onErrorResumeNext {
if (it is DonationError) {
Completable.error(it)
} else {
Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it, paymentSourceProvider.paymentSourceType))
when {
it is DonationError -> Completable.error(it)
it is DonationProcessorError -> Completable.error(it.toDonationError(DonationErrorSource.SUBSCRIPTION, paymentSourceProvider.paymentSourceType))
else -> Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it, paymentSourceProvider.paymentSourceType))
}
}
@@ -198,7 +204,8 @@ class StripePaymentInProgressViewModel(
badgeRecipient = request.recipientId,
additionalMessage = request.additionalMessage,
badgeLevel = request.level,
donationProcessor = DonationProcessor.STRIPE
donationProcessor = DonationProcessor.STRIPE,
uiSessionKey = request.uiSessionKey
)
}
}.subscribeBy(
@@ -206,10 +213,10 @@ class StripePaymentInProgressViewModel(
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)
store.update { DonationProcessorStage.FAILED }
val donationError: DonationError = if (throwable is DonationError) {
throwable
} else {
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST)
val donationError: DonationError = when (throwable) {
is DonationError -> throwable
is DonationProcessorError -> throwable.toDonationError(request.donateToSignalType.toErrorSource(), paymentSourceProvider.paymentSourceType)
else -> DonationError.genericBadgeRedemptionFailure(request.donateToSignalType.toErrorSource())
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
},
@@ -243,7 +250,7 @@ class StripePaymentInProgressViewModel(
Log.d(TAG, "Beginning subscription update...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString()))
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
.subscribeBy(
onComplete = {
Log.w(TAG, "Completed subscription update", true)
@@ -251,10 +258,10 @@ class StripePaymentInProgressViewModel(
},
onError = { throwable ->
Log.w(TAG, "Failed to update subscription", throwable, true)
val donationError: DonationError = if (throwable is DonationError) {
throwable
} else {
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
val donationError: DonationError = when (throwable) {
is DonationError -> throwable
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.SUBSCRIPTION, PaymentSourceType.Stripe.GooglePay)
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
@@ -268,6 +275,12 @@ class StripePaymentInProgressViewModel(
val paymentSource: Single<StripeApi.PaymentSource>
)
private sealed interface StripePaymentData {
class GooglePay(val paymentData: PaymentData) : StripePaymentData
class CreditCard(val cardData: StripeApi.CardData) : StripePaymentData
class SEPADebit(val sepaDebitData: StripeApi.SEPADebitData) : StripePaymentData
}
class Factory(
private val stripeRepository: StripeRepository,
private val monthlyDonationRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),

View File

@@ -0,0 +1,302 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment.Companion.Center
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.navGraphViewModels
import org.signal.core.ui.Buttons
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.Texts
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Collects SEPA Debit bank transfer details from the user to proceed with donation.
*/
class BankTransferDetailsFragment : ComposeFragment() {
private val args: BankTransferDetailsFragmentArgs by navArgs()
private val viewModel: BankTransferDetailsViewModel by viewModels()
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
R.id.donate_to_signal,
factoryProducer = {
StripePaymentInProgressViewModel.Factory(requireListener<DonationPaymentComponent>().stripeRepository)
}
)
@Composable
override fun FragmentContent() {
val state: BankTransferDetailsState by viewModel.state
val donateLabel = remember(args.request) {
if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
getString(
R.string.BankTransferDetailsFragment__donate_s_month,
FiatMoneyUtil.format(resources, args.request.fiat, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
} else {
getString(
R.string.BankTransferDetailsFragment__donate_s,
FiatMoneyUtil.format(resources, args.request.fiat)
)
}
}
BankTransferDetailsContent(
state = state,
onNavigationClick = this::onNavigationClick,
onNameChanged = viewModel::onNameChanged,
onIBANChanged = viewModel::onIBANChanged,
onEmailChanged = viewModel::onEmailChanged,
onFindAccountNumbersClicked = this::onFindAccountNumbersClicked,
onDonateClick = this::onDonateClick,
onIBANFocusChanged = viewModel::onIBANFocusChanged,
donateLabel = donateLabel
)
}
private fun onNavigationClick() {
findNavController().popBackStack()
}
private fun onFindAccountNumbersClicked() {
// TODO [sepa] -- FindAccountNumbersBottomSheet
}
private fun onDonateClick() {
stripePaymentViewModel.provideSEPADebitData(viewModel.state.value.asSEPADebitData())
findNavController().safeNavigate(
BankTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToStripePaymentInProgressFragment(
DonationProcessorAction.PROCESS_NEW_DONATION,
args.request
)
)
}
}
@Preview
@Composable
private fun BankTransferDetailsContentPreview() {
SignalTheme {
BankTransferDetailsContent(
state = BankTransferDetailsState(
name = "Miles Morales"
),
onNavigationClick = {},
onNameChanged = {},
onIBANChanged = {},
onEmailChanged = {},
onFindAccountNumbersClicked = {},
onDonateClick = {},
onIBANFocusChanged = {},
donateLabel = "Donate $5/month"
)
}
}
@Composable
private fun BankTransferDetailsContent(
state: BankTransferDetailsState,
onNavigationClick: () -> Unit,
onNameChanged: (String) -> Unit,
onIBANChanged: (String) -> Unit,
onEmailChanged: (String) -> Unit,
onFindAccountNumbersClicked: () -> Unit,
onDonateClick: () -> Unit,
onIBANFocusChanged: (Boolean) -> Unit,
donateLabel: String
) {
Scaffolds.Settings(
title = "Bank transfer",
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
) {
Column(
horizontalAlignment = CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(it)
) {
val focusManager = LocalFocusManager.current
val focusRequester = remember { FocusRequester() }
LazyColumn(
modifier = Modifier
.weight(1f)
.padding(horizontal = 24.dp)
) {
item {
val learnMore = stringResource(id = R.string.BankTransferDetailsFragment__learn_more)
val fullString = stringResource(id = R.string.BankTransferDetailsFragment__enter_your_bank_details, learnMore)
val context = LocalContext.current
Texts.LinkifiedText(
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [alex] -- final URL
onUrlClick = {
CommunicationActions.openBrowserLink(context, it)
},
style = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
),
modifier = Modifier.padding(vertical = 12.dp)
)
}
item {
TextField(
value = state.iban,
onValueChange = onIBANChanged,
label = {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__iban))
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Characters,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
isError = state.ibanValidity.isError,
supportingText = {
if (state.ibanValidity.isError) {
Text(
text = when (state.ibanValidity) {
IBANValidator.Validity.TOO_SHORT -> stringResource(id = R.string.BankTransferDetailsFragment__iban_nubmer_is_too_short)
IBANValidator.Validity.TOO_LONG -> stringResource(id = R.string.BankTransferDetailsFragment__iban_nubmer_is_too_long)
IBANValidator.Validity.INVALID_COUNTRY -> stringResource(id = R.string.BankTransferDetailsFragment__iban_country_code_is_not_supported)
IBANValidator.Validity.INVALID_CHARACTERS -> stringResource(id = R.string.BankTransferDetailsFragment__invalid_iban_nubmer)
IBANValidator.Validity.INVALID_MOD_97 -> stringResource(id = R.string.BankTransferDetailsFragment__invalid_iban_nubmer)
else -> error("Unexpected error.")
}
)
}
},
visualTransformation = IBANVisualTransformation,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
.onFocusChanged { onIBANFocusChanged(it.hasFocus) }
.focusRequester(focusRequester)
)
}
item {
TextField(
value = state.name,
onValueChange = onNameChanged,
label = {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__name_on_bank_account))
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
}
item {
TextField(
value = state.email,
onValueChange = onEmailChanged,
label = {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__email))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { onDonateClick() }
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
}
item {
Box(
contentAlignment = Center,
modifier = Modifier.fillMaxWidth()
) {
TextButton(
onClick = onFindAccountNumbersClicked
) {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__find_account_numbers))
}
}
}
}
Buttons.LargeTonal(
enabled = state.canProceed,
onClick = onDonateClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 16.dp)
) {
Text(text = donateLabel)
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
import org.signal.donations.StripeApi
data class BankTransferDetailsState(
val name: String = "",
val iban: String = "",
val email: String = "",
val ibanValidity: IBANValidator.Validity = IBANValidator.Validity.POTENTIALLY_VALID
) {
val canProceed = name.isNotEmpty() && email.isNotEmpty() && ibanValidity == IBANValidator.Validity.COMPLETELY_VALID
fun asSEPADebitData(): StripeApi.SEPADebitData {
return StripeApi.SEPADebitData(
iban = iban,
name = name,
email = email
)
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
class BankTransferDetailsViewModel : ViewModel() {
companion object {
private const val IBAN_MAX_CHARACTER_COUNT = 34
}
private val internalState = mutableStateOf(BankTransferDetailsState())
val state: State<BankTransferDetailsState> = internalState
fun onNameChanged(name: String) {
internalState.value = internalState.value.copy(
name = name
)
}
fun onIBANFocusChanged(isFocused: Boolean) {
internalState.value = internalState.value.copy(
ibanValidity = IBANValidator.validate(internalState.value.iban, isFocused)
)
}
fun onIBANChanged(iban: String) {
internalState.value = internalState.value.copy(
iban = iban.take(IBAN_MAX_CHARACTER_COUNT).uppercase(),
ibanValidity = IBANValidator.validate(internalState.value.iban, true)
)
}
fun onEmailChanged(email: String) {
internalState.value = internalState.value.copy(
email = email
)
}
}

View File

@@ -0,0 +1,161 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
import java.math.BigInteger
object IBANValidator {
private val countryCodeToLength: Map<String, Int> by lazy {
mapOf(
"AL" to 28,
"AD" to 24,
"AT" to 20,
"AZ" to 28,
"BH" to 22,
"BY" to 28,
"BE" to 16,
"BA" to 20,
"BR" to 29,
"BG" to 22,
"CR" to 22,
"HR" to 21,
"CY" to 28,
"CZ" to 24,
"DK" to 18,
"DO" to 28,
"TL" to 23,
"EG" to 29,
"SV" to 28,
"EE" to 20,
"FO" to 18,
"FI" to 18,
"FR" to 27,
"GE" to 22,
"DE" to 22,
"GI" to 23,
"GR" to 27,
"GL" to 18,
"GT" to 28,
"HU" to 28,
"IS" to 26,
"IQ" to 23,
"IE" to 22,
"IL" to 23,
"IT" to 27,
"JO" to 30,
"KZ" to 20,
"XK" to 20,
"KW" to 30,
"LV" to 21,
"LB" to 28,
"LY" to 25,
"LI" to 21,
"LT" to 20,
"LU" to 20,
"MT" to 31,
"MR" to 27,
"MU" to 30,
"MC" to 27,
"MD" to 24,
"ME" to 22,
"NL" to 18,
"MK" to 19,
"NO" to 15,
"PK" to 24,
"PS" to 29,
"PL" to 28,
"PT" to 25,
"QA" to 29,
"RO" to 24,
"RU" to 33,
"LC" to 32,
"SM" to 27,
"ST" to 25,
"SA" to 24,
"RS" to 22,
"SC" to 31,
"SK" to 24,
"SI" to 19,
"ES" to 24,
"SD" to 18,
"SE" to 24,
"CH" to 21,
"TN" to 24,
"TR" to 26,
"UA" to 29,
"AE" to 23,
"GB" to 22,
"VA" to 22,
"VG" to 24
)
}
fun validate(iban: String, isIBANFieldFocused: Boolean): Validity {
if (iban.isEmpty()) {
return Validity.POTENTIALLY_VALID
}
val lengthValidity = validateLength(iban, isIBANFieldFocused)
if (lengthValidity != Validity.COMPLETELY_VALID) {
return lengthValidity
}
val countryAndCheck = iban.take(4)
val rearranged = iban.drop(4) + countryAndCheck
val expanded = rearranged.map {
if (it.isLetter()) {
(it - 'A') + 10
} else if (it.isDigit()) {
it.digitToInt()
} else {
return Validity.INVALID_CHARACTERS
}
}.joinToString("")
val bigInteger = BigInteger(expanded)
if (bigInteger.mod(BigInteger.valueOf(97L)) == BigInteger.ONE) {
return Validity.COMPLETELY_VALID
}
return Validity.INVALID_MOD_97
}
private fun validateLength(iban: String, isIBANFieldFocused: Boolean): Validity {
if (iban.length < 2) {
return if (isIBANFieldFocused) {
Validity.POTENTIALLY_VALID
} else {
Validity.TOO_SHORT
}
}
val countryCode = iban.take(2)
val requiredLength = countryCodeToLength[countryCode] ?: -1
if (requiredLength == -1) {
return Validity.INVALID_COUNTRY
}
if (requiredLength > iban.length) {
return if (isIBANFieldFocused) Validity.POTENTIALLY_VALID else Validity.TOO_SHORT
}
if (requiredLength < iban.length) {
return Validity.TOO_LONG
}
return Validity.COMPLETELY_VALID
}
enum class Validity(val isError: Boolean) {
TOO_SHORT(true),
TOO_LONG(true),
INVALID_COUNTRY(true),
INVALID_CHARACTERS(true),
INVALID_MOD_97(true),
POTENTIALLY_VALID(false),
COMPLETELY_VALID(false)
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
/**
* Transforms the given input string to an IBAN representative format:
*
* AB1234567890 becomes AB12 3456 7890
*/
object IBANVisualTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
var output = ""
for (i in text.take(34).indices) {
output += text[i]
if (i % 4 == 3) {
output += " "
}
}
return TransformedText(
text = AnnotatedString(output),
offsetMapping = IBANOffsetMapping
)
}
private object IBANOffsetMapping : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
return offset + (offset / 4)
}
override fun transformedToOriginal(offset: Int): Int {
return offset - (offset / 4)
}
}
}

View File

@@ -0,0 +1,175 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
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.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dividers
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.Texts
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Displays Bank Transfer legal mandate users must agree to to move forward.
*/
class BankTransferMandateFragment : ComposeFragment() {
private val args: BankTransferMandateFragmentArgs by navArgs()
private val viewModel: BankTransferMandateViewModel by viewModels()
@Composable
override fun FragmentContent() {
val mandate by viewModel.mandate
BankTransferScreen(
bankMandate = mandate,
onNavigationClick = this::onNavigationClick,
onContinueClick = this::onContinueClick
)
}
private fun onNavigationClick() {
findNavController().popBackStack()
}
private fun onContinueClick() {
findNavController().safeNavigate(
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToBankTransferDetailsFragment(args.request)
)
}
}
@Preview
@Composable
fun BankTransferScreenPreview() {
SignalTheme {
BankTransferScreen(
bankMandate = "Test ".repeat(500),
onNavigationClick = {},
onContinueClick = {}
)
}
}
@Composable
fun BankTransferScreen(
bankMandate: String,
onNavigationClick: () -> Unit,
onContinueClick: () -> Unit
) {
Scaffolds.Settings(
title = "",
onNavigationClick = onNavigationClick,
navigationIconPainter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.symbol_arrow_left_24))
) {
Column(
horizontalAlignment = CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
LazyColumn(
horizontalAlignment = CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(top = 64.dp)
) {
item {
Image(
painter = painterResource(id = R.drawable.credit_card), // TODO [alex] -- final asset
contentScale = ContentScale.Inside,
contentDescription = null,
modifier = Modifier
.size(72.dp)
.background(
SignalTheme.colors.colorSurface2,
CircleShape
)
)
}
item {
Text(
text = stringResource(id = R.string.BankTransferMandateFragment__bank_transfer),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 12.dp, bottom = 15.dp)
)
}
item {
val learnMore = stringResource(id = R.string.BankTransferMandateFragment__learn_more)
val fullString = stringResource(id = R.string.BankTransferMandateFragment__stripe_processes_donations, learnMore)
val context = LocalContext.current
Texts.LinkifiedText(
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [alex] -- final URL
onUrlClick = {
CommunicationActions.openBrowserLink(context, it)
},
style = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
),
modifier = Modifier.padding(bottom = 12.dp, start = 32.dp, end = 32.dp)
)
}
item {
Dividers.Default()
}
item {
Text(
text = bankMandate,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 16.dp)
)
}
}
Buttons.LargeTonal(
onClick = onContinueClick,
modifier = Modifier
.padding(top = 16.dp, bottom = 46.dp)
.defaultMinSize(minWidth = 220.dp)
) {
Text(text = stringResource(id = R.string.BankTransferMandateFragment__continue))
}
}
}
}

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