Compare commits

...

318 Commits

Author SHA1 Message Date
Greyson Parrelli
6c5ceab4e5 Bump version to 5.41.2 2022-06-15 11:57:02 -04:00
Greyson Parrelli
f2dc454727 Updated language translations. 2022-06-15 11:57:02 -04:00
Greyson Parrelli
8cb0898f1f Capitalize log field. 2022-06-15 11:57:02 -04:00
Greyson Parrelli
2a2809c17c Update send button color after chat color change. 2022-06-15 11:57:02 -04:00
Greyson Parrelli
9eeecaa73d Initialize WAL mode earlier. 2022-06-15 11:57:02 -04:00
Alex Hart
c83a888ed0 Fix banner input overlap in some situations. 2022-06-15 11:57:02 -04:00
Alex Hart
6854632fec Allow separate specification of status and toolbar active/inactive coloring. 2022-06-15 09:45:37 -03:00
Greyson Parrelli
e6cc49368e Update some dialogs to MaterialAlertDialog. 2022-06-15 08:32:20 -04:00
Greyson Parrelli
18bf00eb7a Bump version to 5.41.1 2022-06-14 17:47:08 -04:00
Greyson Parrelli
fcef6f965d Fix crash that can occur when using non-standard font sizes. 2022-06-14 17:32:26 -04:00
Greyson Parrelli
fb9a9b7c96 Bump version to 5.41.0 2022-06-14 15:20:43 -04:00
Greyson Parrelli
d662bddeb1 Updated language translations. 2022-06-14 15:20:43 -04:00
Greyson Parrelli
c5afeb6d71 Update contact photo syncing for linked devices. 2022-06-14 15:20:43 -04:00
Greyson Parrelli
c66a2b8c61 Add autoVerify to some intent filters. 2022-06-14 15:20:43 -04:00
Alex Hart
88a66b49ff Apply new story list ordering rules.
Co-authored-by: Cody Henthorne <cody@signal.org>
2022-06-14 15:20:43 -04:00
Alex Hart
3b07f4a8ca Do not wait on content to launch story viewer. 2022-06-14 15:20:43 -04:00
Alex Hart
f6fd1e1c91 Fix strange scale behaviour on long press of conversation item. 2022-06-14 15:20:42 -04:00
Alex Hart
2412f6f63a Fix outgoing quote over media. 2022-06-14 15:20:42 -04:00
Greyson Parrelli
ce1983a3b1 Updated libphonenumber to 8.12.50 2022-06-14 15:20:42 -04:00
Greyson Parrelli
523f9c7409 Be more resistent to android disallowing service starts. 2022-06-14 15:20:42 -04:00
Cody Henthorne
d5d7c73ebf Remove bad quantity strings. 2022-06-14 15:20:42 -04:00
Cody Henthorne
ce93537fee Update incoming call handling.
* Fix crash with incoming ringer when custom ringtone isn't found.
* Stop notification profiles from terminating calls on linked devices.
2022-06-14 15:20:42 -04:00
Cody Henthorne
5df20d755a Fix FCM not initialized crash. 2022-06-14 15:20:42 -04:00
Alex Hart
2eb933c2d4 Implement animated color lerp for material toolbars. 2022-06-14 15:20:42 -04:00
Alex Hart
ef3c776b4b Fix reaction pill background color. 2022-06-14 15:20:42 -04:00
Alex Hart
bf156ad7d2 Apply Material3 spec to dialogs. 2022-06-14 15:20:42 -04:00
Alex Hart
56a2b27745 Refactor reactions dialog to match Material3 spec. 2022-06-14 15:20:42 -04:00
Rashad Sookram
0e7ace0da4 Remove unused libsignal files from APK. 2022-06-09 12:00:24 -04:00
Alex Hart
6743861630 Account for archival and meaningful message status in unread count query. 2022-06-09 12:40:35 -03:00
Alex Hart
92c6a84075 Ensure shared background for all generated text stories in a set. 2022-06-09 09:20:04 -03:00
Alex Hart
b8a7748dc1 Update verify safety number display fragment. 2022-06-08 17:00:51 -03:00
Alex Hart
8b5c630303 Adjust padding below indicator. 2022-06-08 16:42:08 -03:00
Alex Hart
9ac5db2f0c Remove more solid icons. 2022-06-08 16:37:56 -03:00
Alex Hart
a7380b33c7 Add proper padding and outline to invite sheet. 2022-06-08 16:30:55 -03:00
Alex Hart
4779096ac5 Update reaction pill colors. 2022-06-08 16:26:05 -03:00
Alex Hart
43be54ec42 Fix padding on expired messages save button. 2022-06-08 15:41:08 -03:00
Alex Hart
7010985be8 Update colors for scroll-to buttons to match material3 spec. 2022-06-06 13:20:19 -03:00
Alex Hart
5080dd4c4b Update emoji keyboard to be aligned with Material3 spec. 2022-06-06 13:08:17 -03:00
Alex Hart
1b1acf0aa5 Modify colorOnSurfaceVariant for dark themes. 2022-06-06 12:23:19 -03:00
Alex Hart
5527269283 Update quoteview background colors. 2022-06-06 12:21:39 -03:00
Alex Hart
af32e156c2 Update message details fragment with material3 spec. 2022-06-06 12:12:13 -03:00
Felix Nüsse
9c7c94b2d4 Allow camera to rotate even when screen is locked
Fixes #8611
Closes #12247

Signed-off-by: Felix Nüsse <felix.nuesse@t-online.de>
2022-06-06 08:51:03 -04:00
Alex Hart
796e5f6f86 Add proper tinting to typing indicator. 2022-06-06 09:45:31 -03:00
Sgn-32
b282b775d0 Add LogSectionSMS to debug log.
Closes #12273
2022-06-05 12:25:26 -04:00
Greyson Parrelli
4da422fd3c Refactor how message send types are selected. 2022-06-03 18:07:29 -04:00
Jim Gustafson
bf90909496 Update to RingRTC v2.20.8 2022-06-03 08:22:23 -07:00
Alex Hart
7aa99ce9a7 Remove a few more unnecessary styles. 2022-06-02 11:52:10 -04:00
Alex Hart
b6767b02ed Remove several old and unnecessary styles. 2022-06-02 11:52:10 -04:00
Alex Hart
cf5f7ef634 Update styles on several group bottom sheets. 2022-06-02 11:52:10 -04:00
Alex Hart
3ca4ff9a94 Update tonal buttons to utilize primaryContainer. 2022-06-02 11:52:10 -04:00
Alex Hart
28edd18e55 Fix quote text sizing. 2022-06-02 11:52:10 -04:00
Alex Hart
7bf2ae3d5e Fix-up iconography in recipient bottom sheet. 2022-06-02 11:52:10 -04:00
Alex Hart
7896a525f2 Fix devicelistfragment and remove two dependencies. 2022-06-02 11:52:10 -04:00
Alex Hart
f2d5bfe51d Fix overlap of multiselect in toolbar. 2022-06-02 11:52:10 -04:00
Alex Hart
b2b6f98294 Fix launch responsiveness of story viewer. 2022-06-02 11:52:10 -04:00
Alex Hart
4758369f79 Fix background of sticker management row item. 2022-06-02 11:52:10 -04:00
Alex Hart
e10e629d13 Add proper text size to LabelMedium. 2022-06-02 11:52:10 -04:00
Alex Hart
0e9e39a4eb Bump ConversationListItem avatar down by 4dp. 2022-06-02 11:52:10 -04:00
Alex Hart
93e5052d6b Fix bad donor badge input behaviour. 2022-06-02 11:52:10 -04:00
Alex Hart
1b471e163d Implement new Material3 spec. 2022-06-02 11:52:10 -04:00
Greyson Parrelli
556e480b06 Bump version to 5.40.4 2022-06-02 11:50:22 -04:00
Greyson Parrelli
d08bee3413 Updated language translations. 2022-06-02 11:49:57 -04:00
Cody Henthorne
e83cb6fa8b Fix QR scanning bug when using camerax. 2022-06-02 11:42:25 -04:00
Greyson Parrelli
499cdd9f29 Make Github action build a specific variant. 2022-06-02 08:50:21 -04:00
Greyson Parrelli
13aa150206 Bump version to 5.40.3 2022-06-02 00:32:17 -04:00
Greyson Parrelli
63d6bab7d6 Updated language translations. 2022-06-02 00:31:39 -04:00
Cody Henthorne
d6108fbbf3 Add force legacy QR scanning switch. 2022-06-01 16:38:15 -04:00
Cody Henthorne
4c44f1ee02 Tweak new QR processing some more. 2022-06-01 14:39:42 -04:00
Greyson Parrelli
f4c728f57c Bump version to 5.40.2 2022-05-31 10:16:30 -04:00
Greyson Parrelli
58cebf7346 Updated language translations. 2022-05-31 10:15:40 -04:00
Cody Henthorne
2446792c62 Tweak QR code capture configuration. 2022-05-30 15:37:01 -04:00
Cody Henthorne
259a86b605 Fix lost scroll position in conversation list bug. 2022-05-30 14:33:04 -04:00
Jim Gustafson
fafe795f39 Update to RingRTC v2.20.7 2022-05-27 13:50:00 -07:00
Alex Hart
9a9636b58f Bump version to 5.40.1 2022-05-27 16:57:05 -03:00
Alex Hart
d48a686d98 Updated language translations. 2022-05-27 16:56:13 -03:00
Cody Henthorne
d69d1c8967 Fix IAE crash in link device transition. 2022-05-27 09:51:20 -04:00
Alex Hart
60b6a9ff3f Prevent crash when ConversationListFragment list is nullified. 2022-05-27 09:55:12 -03:00
Alex Hart
f85803c1fe Bump version to 5.40.0 2022-05-26 14:24:57 -03:00
Alex Hart
9dea815fce Updated language translations. 2022-05-26 14:24:57 -03:00
Cody Henthorne
4e01336b2f Fix unclosed streams during backup export. 2022-05-26 14:24:57 -03:00
Cody Henthorne
9fbc7c0f65 Fix stories viewed not updating in UI. 2022-05-26 14:24:57 -03:00
Cody Henthorne
4d028d1867 Improve messaging around story send failures. 2022-05-26 14:24:57 -03:00
Cody Henthorne
95a46f1ce5 Show user a toast when an unexpected send text story fails. 2022-05-26 14:24:57 -03:00
Cody Henthorne
26a84c5546 Show calling service notification immediately. 2022-05-26 14:24:57 -03:00
Cody Henthorne
652d0d46ed Add foreground service type to WebRtcCallService. 2022-05-26 14:24:57 -03:00
Cody Henthorne
e0f3e34899 Attempt to get service name in start foreground exception stack trace. 2022-05-26 14:24:57 -03:00
Cody Henthorne
4a8083f7b1 Fix Vivo NPE quirk. 2022-05-26 14:24:57 -03:00
Cody Henthorne
08556b111b Fix ISE crash. 2022-05-26 14:24:57 -03:00
Cody Henthorne
7e7bc13b62 Swallow too many pending intents exception. 2022-05-26 14:24:56 -03:00
Cody Henthorne
5115eb125d Fix conversation search not showing after entering via settings. 2022-05-26 14:24:56 -03:00
Ahmad Alturki
3ec55b24f8 Fix swapped group title & avatar on rtl layout.
Fixes #12612
2022-05-26 14:24:56 -03:00
Cody Henthorne
5dba1067d6 Fix conversation list memory leak. 2022-05-26 14:24:56 -03:00
Cody Henthorne
2a91c67c51 Add sending and error states for story group replies. 2022-05-26 14:24:56 -03:00
Alex Hart
a29bc1da8c Add proper hyphenation break to badge name. 2022-05-26 14:24:56 -03:00
Alex Hart
32b4d11a82 Fix crash in onPlaying if fragment is detached. 2022-05-26 14:24:56 -03:00
Jim Gustafson
f013f7357f Update to RingRTC v2.20.6 2022-05-26 14:24:56 -03:00
Alex Hart
e37150e98a Update capabilities logging. 2022-05-26 14:24:56 -03:00
Alex Hart
eaa7262b2f Add debug log entry for video player pool usage. 2022-05-26 14:24:56 -03:00
Alex Hart
63f4f0bcec Fix note to self label in conversation settings. 2022-05-26 14:24:56 -03:00
Alex Hart
6dec6cef27 Add decline code messages into expiration sheet. 2022-05-24 15:03:54 -03:00
Greyson Parrelli
4d8faffb75 Notify recipient changes after bulk registration update. 2022-05-24 15:03:54 -03:00
Alex Hart
fa6bb07e8a Update strings for unclear translations. 2022-05-24 15:03:54 -03:00
Cody Henthorne
d260c48393 Fix device linking issues on newer devices. 2022-05-24 15:03:54 -03:00
Cody Henthorne
cc31417c97 Fix desugar crash on spinner builds. 2022-05-24 15:03:54 -03:00
Alex Hart
4d2af5b536 Rotate gift badges flag. 2022-05-24 15:03:54 -03:00
Alex Hart
6029c8ae4a Bump version to 5.38.3 2022-05-24 14:31:18 -03:00
Alex Hart
3bc18c3300 Updated language translations. 2022-05-24 14:30:15 -03:00
Cody Henthorne
a652bc65cc Fix font version check timeout. 2022-05-23 22:06:18 -04:00
Cody Henthorne
53252aa797 Bump version to 5.39.2 2022-05-19 16:36:06 -04:00
Cody Henthorne
956c1d96af Updated language translations. 2022-05-19 16:31:46 -04:00
Greyson Parrelli
d0ecbda962 Hide attachment keyboard if system keyboard shows. 2022-05-19 08:53:12 -04:00
Greyson Parrelli
5d880e2b2a Fix bug where searching emoji would dismiss the view. 2022-05-19 08:50:40 -04:00
Greyson Parrelli
bb13be1e7a Bump version to 5.39.1 2022-05-18 17:57:56 -04:00
Greyson Parrelli
05975a0068 Fix scrolling to last seen. 2022-05-18 17:51:00 -04:00
Greyson Parrelli
153feb002e Update R8 to 3.3.28 2022-05-18 17:34:04 -04:00
Cody Henthorne
f63ed8f269 Bump version to 5.39.0 2022-05-18 14:18:31 -04:00
Cody Henthorne
139a503403 Updated language translations. 2022-05-18 14:11:46 -04:00
Cody Henthorne
db4d072bd9 Upgrade kotlin to 1.6.21
Also fix a collection of warnings.
2022-05-18 14:05:17 -04:00
Cody Henthorne
42b0842aab Fix ANR when showing media in notifications. 2022-05-18 11:54:17 -04:00
Greyson Parrelli
9ab275195f Add support for CDSI. 2022-05-18 11:54:17 -04:00
Alex Hart
8407f2ff69 Add guard against out of bounds indices in story viewer. 2022-05-18 11:54:17 -04:00
Greyson Parrelli
9d518879dd Update libsignal-client to 0.17.0 2022-05-18 11:54:17 -04:00
Alex Hart
588663b3c2 Add better handling for unexpected cancellations. 2022-05-18 11:54:17 -04:00
Alex Hart
77f8489e51 Scroll to top on chat press when already on that tab. 2022-05-18 11:54:17 -04:00
Cody Henthorne
3c08b070fc Fetch PNI Credential during own profile refresh. 2022-05-18 11:54:17 -04:00
Greyson Parrelli
dda5ce4809 Add basic CDSv2 database writes and unit tests. 2022-05-18 11:54:17 -04:00
Alex Hart
307be5c75e Ensure callback is registered for shaking gifts. 2022-05-18 11:54:17 -04:00
Alex Hart
a0b89051cf Add duration info to gift row item. 2022-05-18 11:54:17 -04:00
Alex Hart
a1025a8e9a Add expiry information to gift conversation items. 2022-05-18 11:54:17 -04:00
Alex Hart
ce2418ce9f Consolidate local badge writes. 2022-05-18 11:54:17 -04:00
Cody Henthorne
ec3540e200 Fix long text in Safety Number Change dialog. 2022-05-18 11:54:17 -04:00
Alex Hart
ff4311d114 Add outline around sent gift reply thumb. 2022-05-18 11:54:17 -04:00
Alex Hart
425a13e68c Mark sent gift viewed when opened. 2022-05-18 11:54:17 -04:00
Alex Hart
15af1d3bd1 Add default animations to gift flow. 2022-05-18 11:54:17 -04:00
Alex Hart
2d57cb4ed0 Enqueue profile refresh sync after badge redemption. 2022-05-18 11:54:17 -04:00
Alex Hart
25788ef751 Do not include self in recents list for gift badging. 2022-05-18 11:54:17 -04:00
Greyson Parrelli
b3086e595f Fix abbreviations with some emoji.
Fixes #12212
2022-05-18 11:54:17 -04:00
Greyson Parrelli
57e233413a Update string for group invite. 2022-05-18 11:54:17 -04:00
Greyson Parrelli
f5777d58fc Fix situation where two keyboards could be showing. 2022-05-18 11:54:17 -04:00
Alex Hart
6b55cd0128 Always pop open keyboard when opening group reply sheet. 2022-05-18 11:54:17 -04:00
Alex Hart
a03c49e12c Implement group story notifications. 2022-05-18 11:54:17 -04:00
Alex Hart
01543dd52b Utilize round outline for deleted messages. 2022-05-18 11:54:17 -04:00
Alex Hart
987f69227a Add polish to story replies button and direct reply sheet. 2022-05-18 11:54:17 -04:00
Alex Hart
e51841a28b Fix freeze of first story first post. 2022-05-18 11:54:17 -04:00
Alex Hart
bcfe2fef72 Hide gift badge row if user does not have capability set and rotate flag. 2022-05-18 11:54:17 -04:00
Alex Hart
9ed3f95ab8 Ignore duplicate stories in sync messages. 2022-05-18 11:54:17 -04:00
Cody Henthorne
0fe0765e63 Bump version to 5.38.5 2022-05-17 14:04:33 -04:00
Cody Henthorne
6e6752cfed Updated language translations. 2022-05-17 14:04:23 -04:00
Cody Henthorne
0107e8e6eb Reduce minimum translation requirement. 2022-05-17 13:41:30 -04:00
Cody Henthorne
7fe5376772 Bump version to 5.38.4 2022-05-17 11:14:42 -04:00
Cody Henthorne
30d2d12f89 Updated language translations. 2022-05-17 11:05:31 -04:00
Cody Henthorne
98ab48f0eb Revert "Fix system UI freeze with image notifications."
This reverts commit 8f2c5d43df.
2022-05-17 11:00:55 -04:00
Cody Henthorne
a181ed0420 Revert "Always try to close the PartProvider open file pipe."
This reverts commit d97184ef60.
2022-05-17 11:00:54 -04:00
Cody Henthorne
dbddb274db Revert "Fix NPE in PartProvider."
This reverts commit 3b16a1d28c.
2022-05-17 11:00:52 -04:00
Cody Henthorne
8502badb6d Bump version to 5.38.3 2022-05-16 12:16:39 -04:00
Cody Henthorne
cb4ba1ccfe Updated language translations. 2022-05-16 12:06:36 -04:00
Cody Henthorne
3b16a1d28c Fix NPE in PartProvider. 2022-05-16 11:32:23 -04:00
Cody Henthorne
ba1473acb9 Revert "Fix Google Camera social share."
This reverts commit c078d08df7.
2022-05-16 11:02:02 -04:00
Cody Henthorne
709c866786 Revert "Fix direct sharing, again."
This reverts commit ad626fe7ee.
2022-05-16 11:02:01 -04:00
Alex Hart
ab4e5b1d7c Bump version to 5.38.2 2022-05-13 16:30:36 -03:00
Alex Hart
7b2552e8f2 Updated language translations. 2022-05-13 16:29:28 -03:00
Cody Henthorne
a501940909 Fix Payment to Help navigation. 2022-05-13 14:27:12 -04:00
Cody Henthorne
a3bbf944e5 Handle bluetooth permission crash during calls. 2022-05-13 12:39:23 -04:00
Greyson Parrelli
97d41fdd1e Small refactor of RecipientDatabase androidTests. 2022-05-13 11:39:43 -04:00
Greyson Parrelli
a9bdc1abfc Only stop the FCM foreground service if it was used. 2022-05-13 09:12:02 -04:00
Cody Henthorne
ad626fe7ee Fix direct sharing, again. 2022-05-13 08:35:39 -04:00
Cody Henthorne
d97184ef60 Always try to close the PartProvider open file pipe. 2022-05-13 08:31:23 -04:00
Alex Hart
b527b2ffb9 Bump version to 5.38.1 2022-05-12 17:30:58 -03:00
Alex Hart
468cda034a Updated language translations. 2022-05-12 17:30:58 -03:00
Cody Henthorne
8f2c5d43df Fix system UI freeze with image notifications. 2022-05-12 17:30:58 -03:00
Cody Henthorne
9bc4dfc3f6 Fix PNI crash in in group processing. 2022-05-12 17:30:58 -03:00
Greyson Parrelli
dc095c9db4 Give recipient resolves their own thread pool. 2022-05-12 17:30:58 -03:00
Greyson Parrelli
ef85b29ddf Fix keyboard icon when opening emoji keyboard. 2022-05-12 17:30:58 -03:00
Alex Hart
392a66ed59 Fix bad toolbar animations when switching to and from archive fragment. 2022-05-12 17:30:58 -03:00
Cody Henthorne
c078d08df7 Fix Google Camera social share. 2022-05-12 11:56:55 -04:00
Alex Hart
c89b818a31 Bump version to 5.38.0 2022-05-12 10:42:21 -03:00
Alex Hart
e495c25687 Updated language translations. 2022-05-12 10:42:21 -03:00
Alex Hart
3b2a3500a1 Do not send viewed receipt to gift sender after redemption. 2022-05-12 10:42:21 -03:00
clauz9
d3d9b95924 Fix navigation for creating a new pin if forgotten or skipped during registration
Co-authored-by: henry <henry.ph2@gmail.com>

Closes #12183
2022-05-12 10:42:21 -03:00
Sgn-32
12d1254d4e Update libphonenumber to 8.12.48 2022-05-12 10:42:21 -03:00
Cody Henthorne
ecc358ef40 Consolidate S3 requests into one interface. 2022-05-12 10:42:21 -03:00
Cody Henthorne
bb963f9210 Add remote megaphone. 2022-05-12 10:42:21 -03:00
Cody Henthorne
820277800b Ignore identity updates for self. 2022-05-12 10:42:21 -03:00
Cody Henthorne
14b2d12895 Reduce disk reads on main thread. 2022-05-12 10:42:21 -03:00
Greyson Parrelli
92a506e4da Add a donate megaphone for Q2 2022. 2022-05-12 10:42:21 -03:00
Cody Henthorne
12e6ebb4df Improve performance of GV2 profile fetch and mentions initialization. 2022-05-12 10:42:21 -03:00
Greyson Parrelli
c0db88960c Make FcmFetchForegroundService stop itself. 2022-05-12 10:42:21 -03:00
Alex Hart
eeb4cdf064 Add strict-mode logging for disk access on Spinner variant. 2022-05-12 10:42:21 -03:00
Greyson Parrelli
85cecbb7e9 Remove the chat colors megaphone. 2022-05-12 10:42:21 -03:00
Alex Hart
33d60ebe14 Implement proper group story reply deletion for remotely deleted group stories. 2022-05-12 10:42:21 -03:00
Greyson Parrelli
9afeb206fc Refactor FCM processing to improve use of foreground services. 2022-05-12 10:42:21 -03:00
Cody Henthorne
06a49b5d5a Force use of system settings to configure notifications on SDK30+. 2022-05-12 10:42:21 -03:00
Alex Hart
68ba3433a3 Always display donation receipts page. 2022-05-12 10:42:21 -03:00
Alex Hart
eaf36be9f6 NotificationThread migration. 2022-05-12 10:42:21 -03:00
Alex Hart
af9465fefe Add sent story syncing. 2022-05-12 10:42:21 -03:00
Alex Hart
8ca0f4baf4 Add support for replying to gift badges. 2022-05-12 10:42:21 -03:00
Winston Cooke
0c1edd6a56 Update CONTRIBUTING.md link from master to main
Fixes #12242
2022-05-12 10:42:21 -03:00
Alex Hart
df88c2fd14 Update ViewModel file template to use RxStore. 2022-05-12 10:42:21 -03:00
Alex Hart
c698bfca44 Fix currency selection disabled state. 2022-05-12 10:42:21 -03:00
Alex Hart
431f5501c6 Do not display keyboard when entering blocked users page.
Fixes #12241
2022-05-12 10:42:21 -03:00
Alex Hart
9a20447993 Add touch delegate for user avatar in conversation list view. 2022-05-12 10:42:21 -03:00
Sgn-32
049e5a1b99 Fix animation for call buttons.
Closes #12240
2022-05-12 10:42:21 -03:00
Sgn-32
4cbacc9804 Change text when blocking/unblocking unregistered recipient.
Closes #12239
2022-05-12 10:42:20 -03:00
Sgn-32
6462d053ae Add divider in ChatSettingsFragment.
Closes #12238
2022-05-11 09:29:14 -03:00
Alex Hart
0f08acbc04 Verify recipient before launching google pay sheet in badge gifting flow. 2022-05-11 09:29:14 -03:00
Alex Hart
dc5f7d0906 Add gift tab in donation receipts page. 2022-05-11 09:29:14 -03:00
Alex Voloshyn
60b20a9b8a Use shorter fog report URI in wallet 2022-05-11 09:29:14 -03:00
Alex Hart
ec361d6349 Update gift badge open animation to use anticipate interpolator. 2022-05-11 09:29:14 -03:00
Alex Hart
1f8f1d433b Add Gift badging bow. 2022-05-11 09:29:14 -03:00
Alex Hart
bc44704f54 Center currency code in selector. 2022-05-11 09:29:14 -03:00
Alex Hart
756eafe3c8 Add slide animation to conversation list to archive. 2022-05-11 09:29:14 -03:00
Alex Hart
e770241ed4 Remove story text posts feature flag. 2022-05-11 09:29:14 -03:00
Cody Henthorne
4b8729c2ae Fix story unavailable emoji render bug. 2022-05-11 09:29:14 -03:00
Alex Hart
8261e21005 Lock story viewer orientation to portrait. 2022-05-11 09:29:14 -03:00
Alex Hart
1b1bbbab7a Add multi-device sync for viewed status of redeemed gift badge. 2022-05-11 09:29:14 -03:00
Alex Hart
964d214434 Remove inset for check circle and update copy for private and group story pickers. 2022-05-11 09:29:14 -03:00
Alex Hart
e2b0079a5c Utilize sending reply instead of reply sent in story reply toast. 2022-05-11 09:29:14 -03:00
Alex Hart
158f77a634 Add thread display body and proper image for gift badges. 2022-05-11 09:29:14 -03:00
Alex Hart
1345413645 Ensure new storage id is synchronized to recipient. 2022-05-11 09:29:14 -03:00
Alex Hart
ee69895123 Bump version to 5.37.4 2022-05-11 09:23:56 -03:00
Alex Hart
f25f47654e Updated language translations. 2022-05-11 09:23:30 -03:00
Alex Hart
8f52f803cf Ensure networking is not performed on main during Subscription creation. 2022-05-11 09:09:07 -03:00
Alex Hart
82d42c03f7 Bump version to 5.37.3 2022-05-09 15:31:04 -03:00
Alex Hart
c0f8e5adbf Updated language translations. 2022-05-09 15:30:26 -03:00
Cody Henthorne
c54c73cb48 Exclude visible thread from notification shown check. 2022-05-09 12:14:30 -04:00
Cody Henthorne
02c8656b92 Fix remove from group bug. 2022-05-09 12:13:52 -04:00
Cody Henthorne
3553a28683 Bump version to 5.37.2 2022-05-06 14:52:07 -04:00
Cody Henthorne
acf4e97578 Updated language translations. 2022-05-06 14:40:09 -04:00
Cody Henthorne
5142c8c58f Fix double divider bug when payments not available. 2022-05-06 13:08:20 -04:00
Cody Henthorne
55919cba59 Add notification not showing debuglog. 2022-05-06 12:59:03 -04:00
Cody Henthorne
1a6bd3d3f2 Add VPN/metered connection status during FCM receives. 2022-05-06 11:47:57 -04:00
Jim Gustafson
100dc54292 Update to RingRTC v2.20.5 2022-05-06 10:11:21 -04:00
Alex Hart
cffbfcb957 Hide receipts item if user has none. 2022-05-06 10:01:14 -03:00
Sgn-32
f73c5dde6b Replace use of AlertDialog.Builder with MaterialAlertDialogBuilder. 2022-05-04 09:48:41 -04:00
Victor Ding
d5a466851a Use the same SmsManager to divide and send a message. 2022-05-04 09:46:19 -04:00
Greyson Parrelli
38836198a1 Bump version to 5.37.1 2022-05-02 22:38:15 -04:00
Greyson Parrelli
52429dcd33 Updated language translations. 2022-05-02 22:30:52 -04:00
Ehren Kret
fae427c09b Revert "Use shorter URLs for MOB FOG"
This reverts commit ef0c6c79cb.
2022-05-02 20:17:33 -05:00
Greyson Parrelli
e22ddb8f96 Bump version to 5.37.0 2022-05-02 15:33:58 -04:00
Greyson Parrelli
19f0722df3 Updated language translations. 2022-05-02 15:33:58 -04:00
Greyson Parrelli
921f7a70b3 Rotate the FCM foreground service flag. 2022-05-02 15:33:58 -04:00
Greyson Parrelli
bb8faebc7d Improve handling of mismatched expiry timers on messages. 2022-05-02 15:25:55 -04:00
Cody Henthorne
5ed6a05eb9 Adjust how preferred variation is handled for reaction customization. 2022-05-02 15:25:55 -04:00
Alex Hart
a4a4665aaa Implement badge gifting behind feature flag. 2022-05-02 15:25:55 -04:00
Alex Hart
5d16d1cd23 Fix story send issues due to insertion of story sends to database. 2022-05-02 15:25:55 -04:00
Rashad Sookram
38b6362b25 Fix enabling video while ringing for an audio-only call.
* Update to RingRTC v2.20.4

Co-authored-by: Jim Gustafson <jim@signal.org>
2022-05-02 15:25:55 -04:00
Ehren Kret
ef0c6c79cb Use shorter URLs for MOB FOG 2022-05-02 15:25:55 -04:00
Cody Henthorne
f10d5651f0 Fix storage sync bug for distribution lists. 2022-05-02 15:25:55 -04:00
Justin Tracey
8a2f89b4f6 Fix .onion link linkification.
Fixes #11458.
2022-05-02 15:25:55 -04:00
Alex Hart
6563ea970f Revert "Change send method for text stories to cover link previews."
This reverts commit 0f9923e2619ec21eec3f2c4a97a3cc0eb4ab5e29.
2022-05-02 15:25:55 -04:00
Greyson Parrelli
f1cb416bda Update workflow to reference main branch. 2022-05-02 15:25:55 -04:00
Greyson Parrelli
df48e5ce92 Fix pluralization possibilities for group invite string.
Fixes #12197
2022-05-02 15:25:55 -04:00
Greyson Parrelli
e710e231ad Remove notification profile megaphone. 2022-05-02 15:25:55 -04:00
Cody Henthorne
9599d3a0b6 Remove announcement group capability checks. 2022-05-02 15:25:55 -04:00
Greyson Parrelli
1fad4d4f65 Handle early read receipt sync messages. 2022-05-02 15:25:55 -04:00
Alex Hart
f57e06677b Change send method for text stories to cover link previews. 2022-05-02 15:25:55 -04:00
Rashad Sookram
f7b9942f11 Stop showing video in group calls when it isn't being forwarded. 2022-05-02 15:25:55 -04:00
Alex Hart
2f1b05f882 Start slider progress at RED. 2022-05-02 15:25:55 -04:00
Alex Hart
6650f41200 Pause voice note playback when starting an audio recording. 2022-05-02 15:25:55 -04:00
Alex Hart
2d8de03e05 Fix crash when ViewStub and contained view shared an id. 2022-05-02 15:25:55 -04:00
Alex Hart
9ffa866907 Ensure proper message ID is passed to Story viewer. 2022-05-02 15:25:55 -04:00
Alex Hart
3c0c5478b5 Fix forward sheet weirdness in full screen activities. 2022-05-02 15:25:55 -04:00
Alex Hart
aae888f5af Reduce opacity of text story hint text. 2022-05-02 15:25:55 -04:00
Alex Hart
e00a3730b4 Remove autosizing of text in story caption and bold styling. 2022-05-02 15:25:55 -04:00
Alex Hart
5c2394aa4f Add corner radius to text story creator. 2022-05-02 15:25:55 -04:00
Alex Hart
c6be273a38 Add signal connection image. 2022-04-28 15:45:52 -03:00
Alex Hart
33236ea8e6 Add retry when user resubscribes after canceling. 2022-04-28 14:33:30 -03:00
Alex Hart
a6f1e0e972 Log out charge failure for pending payment if present. 2022-04-28 09:02:37 -03:00
Jim Gustafson
fa8f8beb56 Add internal setting to disable telecom 2022-04-27 14:57:43 -07:00
Greyson Parrelli
11db59d8a1 Improve the Android 12 splash screen. 2022-04-27 14:44:00 -04:00
Greyson Parrelli
39a11ce26c Ensure message resends are called on a background thread. 2022-04-27 14:24:50 -04:00
Greyson Parrelli
8bb1b2d596 Ignore empty profile fetches in RefreshOwnProfileJob. 2022-04-27 14:15:27 -04:00
Alex Hart
3f1abe05fc Allow users to copy Subscription ID to clipboard. 2022-04-27 12:47:24 -03:00
Greyson Parrelli
08ac99b4c1 Fix crash around unbinding GenericForegroundService. 2022-04-27 10:34:04 -04:00
Greyson Parrelli
ebf2ef65e2 Add log section for the last thread dump during a possible deadlock. 2022-04-27 10:30:30 -04:00
Greyson Parrelli
8cb74fb776 Improve updates to CdsDatabase. 2022-04-26 13:59:51 -04:00
Greyson Parrelli
eccb796199 Ensure that destinationUuid is always populated. 2022-04-26 12:16:58 -04:00
Greyson Parrelli
4635a77fbc Improve logging in base identity store. 2022-04-26 12:16:58 -04:00
Greyson Parrelli
9505c3d070 Prevent failed Spinner transforms from blocking query. 2022-04-26 12:16:58 -04:00
Cody Henthorne
657a9c7b0a Add ability to reject group invite by PNI. 2022-04-26 12:16:58 -04:00
Alex Hart
e22560a794 Add dialog to nav to story or profile photo. 2022-04-26 12:15:50 -04:00
Alex Hart
c081193373 Add helper text at the bottom of the private stories list. 2022-04-26 12:15:50 -04:00
Alex Hart
d23faf4278 Always allow story error slate this->this transition. 2022-04-26 12:15:50 -04:00
Alex Hart
da7e4cefd5 Add properly tinted conversation tab icons. 2022-04-26 12:15:50 -04:00
Sgn-32
0d9a5ef9a6 Fix SMS delivery reports 2022-04-26 12:15:50 -04:00
Victor Ding
e5aea7c49e Replace non-ASCII characters in comments to their ASCII equivalent
Fixes #12201
2022-04-26 12:15:50 -04:00
Greyson Parrelli
b8c42fa57e Filter out invalid phone numbers from system contacts.
Some phones are putting UUIDs in phone number fields. Who knows why.

Fixes #12191
2022-04-26 12:15:50 -04:00
Cody Henthorne
2a086ad574 Prevent VerifiedMessages from altering self. 2022-04-26 12:15:50 -04:00
Cody Henthorne
33346d8033 Fix bug with receiving GV2 message for previously unknown group. 2022-04-26 12:15:50 -04:00
Greyson Parrelli
1446af97a2 Use newer CellService observer when possible. 2022-04-26 12:15:50 -04:00
Alex Hart
64b5dad783 Fix text story preview on incoming 1to1 replies. 2022-04-26 12:15:50 -04:00
Alex Hart
a6e7f9a4c1 Fix incorrect column in query. 2022-04-26 12:15:50 -04:00
Greyson Parrelli
70b0a120f0 Fix partial contact syncs and ignore your own contact info. 2022-04-26 12:12:17 -04:00
Jim Gustafson
4a6569fa1c Update to RingRTC v2.20.2 2022-04-26 12:12:17 -04:00
Greyson Parrelli
f5173fa6f5 Update libsignal-client to 0.16.0 2022-04-26 12:12:17 -04:00
Greyson Parrelli
5478285362 Improve contact sync for individual contacts. 2022-04-26 12:12:17 -04:00
Alex Hart
e2292dfa34 Add handling for story reply sync messages. 2022-04-26 12:12:17 -04:00
Alex Hart
17111abc72 Add support for smarter story downloads. 2022-04-26 12:12:13 -04:00
Cody Henthorne
c4bc2162f2 Bump version to 5.36.3 2022-04-26 11:55:37 -04:00
Cody Henthorne
bfd966217f Updated language translations. 2022-04-26 11:49:27 -04:00
Greyson Parrelli
797c02e893 Improve reliability of launching FCM foreground service.
We were getting weird errors around the service not calling
startForeground() that I couldn't reproduce. I figure it must be
something with how we only sometimes start the FcmFetchService in the
foreground. So I think the safest thing to do is to just use
GenericForegroundService.
2022-04-26 11:22:01 -04:00
Cody Henthorne
65372e547a Auto-decline invites to a group by a blocked user. 2022-04-25 13:41:50 -04:00
Alex Hart
2454b2e0db Fix crash when trying to access media controller after activity is destroyed and reference is nullified. 2022-04-25 10:29:30 -03:00
Alex Hart
0505a46603 Fix crash when item animation ends after we leave fragment. 2022-04-25 10:09:28 -03:00
Alex Hart
7f77cd6a22 Prevent crash when user quickly leaves the share fragment. 2022-04-25 10:04:21 -03:00
Alex Hart
efe7b3099f Bump version to 5.36.2 2022-04-22 16:50:21 -03:00
Alex Hart
26a831b49f Updated language translations. 2022-04-22 16:50:21 -03:00
Alex Hart
a3a5bb8177 Fix direct shares. 2022-04-22 16:50:21 -03:00
Alex Hart
4282f3eb6d Add explicit export to ShareActivity. 2022-04-22 16:50:21 -03:00
Greyson Parrelli
8a49db650a Do not show SMS contacts as shortcuts if we're not the SMS app. 2022-04-22 16:50:21 -03:00
Greyson Parrelli
fadd4ac61e Fix possible index out of bounds exception in ConversationAdapter.
If we're deferring to super.getItem(), then we should be guarding with
super.getItemCount().
2022-04-22 16:50:21 -03:00
Alex Hart
d0c14895d0 Fix crash when parent does not implement optional bottom sheet callback. 2022-04-22 16:50:21 -03:00
Greyson Parrelli
32ee18240b Fix crash that occurs if we don't have permission to add an account. 2022-04-22 14:32:05 -04:00
Cody Henthorne
cd10aa90cc Adjust tap area on forwarding fab. 2022-04-22 13:40:07 -04:00
Rashad Sookram
33d28c4359 Inset audio level indicator by nav bar height. 2022-04-22 12:36:05 -04:00
Greyson Parrelli
530403ec04 Updated emoji to version 14.0 2022-04-22 11:40:07 -04:00
Greyson Parrelli
f15072bc8d Fix other group update description bugs and add tests. 2022-04-22 08:32:07 -04:00
Ehren Kret
8c2db972cf Fix crash if recipient appears multiple times in group update description.
Without starting from start index, if the same recipient appears
multiple times in the recipient list, this function will crash.
2022-04-22 07:55:42 -04:00
1261 changed files with 35197 additions and 13494 deletions

View File

@@ -4,7 +4,7 @@ on:
pull_request:
push:
branches:
- 'master'
- 'main'
- '4.**'
- '5.**'

View File

@@ -15,4 +15,4 @@ jobs:
run: cd reproducible-builds && docker build -t signal-android . && cd ..
- name: Test build
run: docker run --rm -v $(pwd):/project -w /project signal-android ./gradlew clean assembleRelease
run: docker run --rm -v $(pwd):/project -w /project signal-android ./gradlew clean assemblePlayProdRelease

View File

@@ -1,18 +1,18 @@
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.util.livedata.Store
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.thoughtcrime.securesms.util.rx.RxStore
#end
#parse("File Header.java")
class ${NAME}ViewModel : ViewModel() {
private val store = Store(${NAME}State())
private val store = RxStore(${NAME}State())
private val disposables = CompositeDisposable()
val state: LiveData<${NAME}State> = store.stateLiveData
val state: Flowable<${NAME}State> = store.stateFlowable
override fun onCleared() {
disposables.clear()

View File

@@ -28,7 +28,7 @@ https://www.transifex.com/projects/p/signal-android/
## Contributing Code
If you're new to the Signal codebase, we recommend going through our issues and picking out a simple bug to fix (check the "easy" label in our issues) in order to get yourself familiar. Also please have a look at the [CONTRIBUTING.md](https://github.com/signalapp/Signal-Android/blob/master/CONTRIBUTING.md), that might answer some of your questions.
If you're new to the Signal codebase, we recommend going through our issues and picking out a simple bug to fix (check the "easy" label in our issues) in order to get yourself familiar. Also please have a look at the [CONTRIBUTING.md](https://github.com/signalapp/Signal-Android/blob/main/CONTRIBUTING.md), that might answer some of your questions.
For larger changes and feature ideas, we ask that you propose it on the [unofficial Community Forum](https://community.signalusers.org) for a high-level discussion with the wider community before implementation.

View File

@@ -10,12 +10,6 @@ apply plugin: 'kotlin-parcelize'
apply from: 'static-ips.gradle'
repositories {
maven {
url "https://raw.github.com/signalapp/maven/master/circular-progress-button/releases/"
content {
includeGroupByRegex "com\\.github\\.dmytrodanylyk\\.circular-progress-button\\.*"
}
}
maven {
url "https://raw.github.com/signalapp/maven/master/sqlcipher/release/"
content {
@@ -63,8 +57,8 @@ ktlint {
version = "0.43.2"
}
def canonicalVersionCode = 1042
def canonicalVersionName = "5.36.1"
def canonicalVersionCode = 1067
def canonicalVersionName = "5.41.2"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -157,6 +151,8 @@ android {
exclude 'META-INF/LICENSE'
exclude 'META-INF/NOTICE'
exclude 'META-INF/proguard/androidx-annotations.pro'
exclude 'libsignal_jni.dylib'
exclude 'signal_jni.dll'
}
@@ -179,7 +175,7 @@ android {
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2.signal.org\""
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api.directory.signal.org\""
buildConfigField "String", "SIGNAL_CDSH_URL", "\"https://cdsh.staging.signal.org\""
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\""
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\""
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\""
buildConfigField "String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\""
@@ -196,9 +192,8 @@ android {
buildConfigField "String[]", "SIGNAL_SFU_IPS", sfu_ips
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
buildConfigField "String", "CDSH_PUBLIC_KEY", "\"2fe57da347cd62431528daac5fbb290730fff684afc4cfc2ed90995f58cb3b74\""
buildConfigField "String", "CDSH_CODE_HASH", "\"2f79dc6c1599b71c70fc2d14f3ea2e3bc65134436eb87011c88845b137af673a\""
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
buildConfigField "String", "CDSI_MRENCLAVE", "\"42e36b74794abe612d698308b148ff8a7dc5fdc6ad28d99bc5024ed6ece18dfe\""
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"0cedba03535b41b67729ce9924185f831d7767928a1d1689acb689bc079c375f\", " +
"\"187d2739d22be65e74b65f0055e74d31310e4267e5fac2b1246cc8beba81af39\", " +
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\")"
@@ -456,6 +451,7 @@ dependencies {
implementation project(':image-editor')
implementation project(':donations')
implementation project(':contacts')
implementation project(':qr')
implementation libs.libsignal.android
implementation libs.google.protobuf.javalite
@@ -482,7 +478,6 @@ dependencies {
implementation libs.materialish.progress
implementation libs.greenrobot.eventbus
implementation libs.waitingdots
implementation libs.floatingactionbutton
implementation libs.google.zxing.android.integration
implementation libs.time.duration.picker
implementation libs.google.zxing.core
@@ -508,7 +503,6 @@ dependencies {
implementation libs.lottie
implementation libs.stickyheadergrid
implementation libs.circular.progress.button
implementation libs.signal.android.database.sqlcipher
implementation libs.androidx.sqlite
@@ -541,6 +535,9 @@ dependencies {
androidTestImplementation testLibs.androidx.test.ext.junit
androidTestImplementation testLibs.espresso.core
androidTestImplementation testLibs.androidx.test.core
androidTestImplementation testLibs.androidx.test.core.ktx
androidTestImplementation testLibs.androidx.test.ext.junit.ktx
testImplementation testLibs.espresso.core

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.conversation
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog
import org.thoughtcrime.securesms.database.IdentityDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.SignalActivityRule
/**
* Android test to help show SNC dialog quickly with custom data to make sure it displays properly.
*/
@RunWith(AndroidJUnit4::class)
class SafetyNumberChangeDialogPreviewer {
@get:Rule val harness = SignalActivityRule()
@Test
fun testShowLongName() {
val other: Recipient = Recipient.resolved(harness.others.first())
SignalDatabase.recipients.setProfileName(other.id, ProfileName.fromParts("Super really long name like omg", "But seriously it's long like really really long"))
harness.setVerified(other, IdentityDatabase.VerifiedStatus.VERIFIED)
harness.changeIdentityKey(other)
val scenario: ActivityScenario<ConversationActivity> = harness.launchActivity { putExtra("recipient_id", other.id.serialize()) }
scenario.onActivity {
SafetyNumberChangeDialog.show(it.supportFragmentManager, other.id)
}
// Uncomment to make dialog stay on screen, otherwise will show/dismiss immediately
// ThreadUtil.sleep(15000)
}
}

View File

@@ -0,0 +1,188 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId
import java.util.UUID
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class MmsDatabaseTest_gifts {
private lateinit var mms: MmsDatabase
private val localAci = ACI.from(UUID.randomUUID())
private val localPni = PNI.from(UUID.randomUUID())
private lateinit var recipients: List<RecipientId>
@Before
fun setUp() {
mms = SignalDatabase.mms
mms.deleteAllThreads()
SignalStore.account().setAci(localAci)
SignalStore.account().setPni(localPni)
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())) }
}
@Test
fun givenNoSentGifts_whenISetOutgoingGiftsRevealed_thenIExpectEmptyList() {
val result = mms.setOutgoingGiftsRevealed(listOf(1))
assertTrue(result.isEmpty())
}
@Test
fun givenSentGift_whenISetOutgoingGiftsRevealed_thenIExpectNonEmptyListContainingThatGift() {
val messageId = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
val result = mms.setOutgoingGiftsRevealed(listOf(messageId))
assertTrue(result.isNotEmpty())
assertEquals(messageId, result.first().messageId.id)
}
@Test
fun givenViewedSentGift_whenISetOutgoingGiftsRevealed_thenIExpectEmptyList() {
val messageId = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
mms.setOutgoingGiftsRevealed(listOf(messageId))
val result = mms.setOutgoingGiftsRevealed(listOf(messageId))
assertTrue(result.isEmpty())
}
@Test
fun givenMultipleSentGift_whenISetOutgoingGiftsRevealedForOne_thenIExpectNonEmptyListContainingThatGift() {
val messageId = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
val result = mms.setOutgoingGiftsRevealed(listOf(messageId))
assertEquals(1, result.size)
assertEquals(messageId, result.first().messageId.id)
}
@Test
fun givenMultipleSentGift_whenISetOutgoingGiftsRevealedForBoth_thenIExpectNonEmptyListContainingThoseGifts() {
val messageId = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
val messageId2 = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
val result = mms.setOutgoingGiftsRevealed(listOf(messageId, messageId2))
assertEquals(listOf(messageId, messageId2), result.map { it.messageId.id })
}
@Test
fun givenMultipleSentGiftAndNonGift_whenISetOutgoingGiftsRevealedForBothGifts_thenIExpectNonEmptyListContainingJustThoseGifts() {
val messageId = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
val messageId2 = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = null
)
val result = mms.setOutgoingGiftsRevealed(listOf(messageId, messageId2))
assertEquals(listOf(messageId, messageId2), result.map { it.messageId.id })
}
@Test
fun givenMultipleSentGiftAndNonGift_whenISetOutgoingGiftsRevealedForAllThree_thenIExpectNonEmptyListContainingJustThoseGifts() {
val messageId = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
val messageId2 = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
val messageId3 = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = null
)
val result = mms.setOutgoingGiftsRevealed(listOf(messageId, messageId2, messageId3))
assertEquals(listOf(messageId, messageId2), result.map { it.messageId.id })
}
@Test
fun givenMultipleSentGiftAndNonGift_whenISetOutgoingGiftsRevealedForNonGift_thenIExpectEmptyList() {
MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = GiftBadge.getDefaultInstance()
)
val messageId3 = MmsHelper.insert(
recipient = Recipient.resolved(recipients[0]),
sentTimeMillis = 1,
giftBadge = null
)
val result = mms.setOutgoingGiftsRevealed(listOf(messageId3))
assertTrue(result.isEmpty())
}
}

View File

@@ -191,4 +191,51 @@ class MmsDatabaseTest_stories {
assertEquals(unviewedIds.reversed() + interspersedIds.reversed(), resultOrderedIds)
}
@Test
fun givenNoStories_whenICheckIsOutgoingStoryAlreadyInDatabase_thenIExpectFalse() {
// WHEN
val result = mms.isOutgoingStoryAlreadyInDatabase(recipients[0], 200)
// THEN
assertFalse(result)
}
@Test
fun givenNoOutgoingStories_whenICheckIsOutgoingStoryAlreadyInDatabase_thenIExpectFalse() {
// GIVEN
MmsHelper.insert(
IncomingMediaMessage(
from = recipients[0],
sentTimeMillis = 200,
serverTimeMillis = 2,
receivedTimeMillis = 2,
storyType = StoryType.STORY_WITH_REPLIES,
),
-1L
)
// WHEN
val result = mms.isOutgoingStoryAlreadyInDatabase(recipients[0], 200)
// THEN
assertFalse(result)
}
@Test
fun givenOutgoingStoryExistsForRecipientAndTime_whenICheckIsOutgoingStoryAlreadyInDatabase_thenIExpectTrue() {
// GIVEN
MmsHelper.insert(
recipient = myStory,
sentTimeMillis = 200,
storyType = StoryType.STORY_WITH_REPLIES,
threadId = -1L
)
// WHEN
val result = mms.isOutgoingStoryAlreadyInDatabase(myStory.id, 200)
// THEN
assertTrue(result)
}
}

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.database
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
import org.thoughtcrime.securesms.recipients.Recipient
@@ -20,7 +21,8 @@ object MmsHelper {
viewOnce: Boolean = false,
distributionType: Int = ThreadDatabase.DistributionTypes.DEFAULT,
threadId: Long = 1,
storyType: StoryType = StoryType.NONE
storyType: StoryType = StoryType.NONE,
giftBadge: GiftBadge? = null
): Long {
val message = OutgoingMediaMessage(
recipient,
@@ -39,12 +41,13 @@ object MmsHelper {
emptyList(),
emptyList(),
emptySet(),
emptySet()
emptySet(),
giftBadge
)
return insert(
message = message,
threadId = threadId,
threadId = threadId
)
}

View File

@@ -1,6 +1,9 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.hamcrest.MatcherAssert
import org.hamcrest.Matchers
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
@@ -8,25 +11,55 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.CursorUtil
import org.signal.core.util.ThreadUtil
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.libsignal.protocol.state.SessionRecord
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.signal.storageservice.protos.groups.local.DecryptedMember
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.DistributionListRecord
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.jobs.RecipientChangedNumberJob
import org.thoughtcrime.securesms.keyvalue.AccountValues
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
import org.thoughtcrime.securesms.keyvalue.MockKeyValuePersistentStorage
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.IncomingTextMessage
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.util.UuidUtil
import java.util.Optional
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class RecipientDatabaseTest {
class RecipientDatabaseTest_getAndPossiblyMerge {
private lateinit var recipientDatabase: RecipientDatabase
private lateinit var identityDatabase: IdentityDatabase
private lateinit var groupReceiptDatabase: GroupReceiptDatabase
private lateinit var groupDatabase: GroupDatabase
private lateinit var threadDatabase: ThreadDatabase
private lateinit var smsDatabase: MessageDatabase
private lateinit var mmsDatabase: MessageDatabase
private lateinit var sessionDatabase: SessionDatabase
private lateinit var mentionDatabase: MentionDatabase
private lateinit var reactionDatabase: ReactionDatabase
private lateinit var notificationProfileDatabase: NotificationProfileDatabase
private lateinit var distributionListDatabase: DistributionListDatabase
private val localAci = ACI.from(UUID.randomUUID())
private val localPni = PNI.from(UUID.randomUUID())
@@ -34,6 +67,19 @@ class RecipientDatabaseTest {
@Before
fun setup() {
recipientDatabase = SignalDatabase.recipients
recipientDatabase = SignalDatabase.recipients
identityDatabase = SignalDatabase.identities
groupReceiptDatabase = SignalDatabase.groupReceipts
groupDatabase = SignalDatabase.groups
threadDatabase = SignalDatabase.threads
smsDatabase = SignalDatabase.sms
mmsDatabase = SignalDatabase.mms
sessionDatabase = SignalDatabase.sessions
mentionDatabase = SignalDatabase.mentions
reactionDatabase = SignalDatabase.reactions
notificationProfileDatabase = SignalDatabase.notificationProfiles
distributionListDatabase = SignalDatabase.distributionLists
ensureDbEmpty()
SignalStore.account().setAci(localAci)
@@ -478,6 +524,140 @@ class RecipientDatabaseTest {
assert(changeNumberListener.numberChangeWasEnqueued)
}
/** High trust lets you merge two different users into one. You should prefer the ACI user. Not shown: merging threads, dropping e164 sessions, etc. */
@Test
fun getAndPossiblyMerge_merge_general() {
// Setup
val recipientIdAci: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
val recipientIdE164: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
val recipientIdAciB: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_B)
val smsId1: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 0, body = "0")).get().messageId
val smsId2: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdE164, time = 1, body = "1")).get().messageId
val smsId3: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 2, body = "2")).get().messageId
val mmsId1: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 3, body = "3"), -1).get().messageId
val mmsId2: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdE164, time = 4, body = "4"), -1).get().messageId
val mmsId3: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 5, body = "5"), -1).get().messageId
val threadIdAci: Long = threadDatabase.getThreadIdFor(recipientIdAci)!!
val threadIdE164: Long = threadDatabase.getThreadIdFor(recipientIdE164)!!
assertNotEquals(threadIdAci, threadIdE164)
mentionDatabase.insert(threadIdAci, mmsId1, listOf(Mention(recipientIdE164, 0, 1)))
mentionDatabase.insert(threadIdE164, mmsId2, listOf(Mention(recipientIdAci, 0, 1)))
groupReceiptDatabase.insert(listOf(recipientIdAci, recipientIdE164), mmsId1, 0, 3)
val identityKeyAci: IdentityKey = identityKey(1)
val identityKeyE164: IdentityKey = identityKey(2)
identityDatabase.saveIdentity(ACI_A.toString(), recipientIdAci, identityKeyAci, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
identityDatabase.saveIdentity(E164_A, recipientIdE164, identityKeyE164, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
sessionDatabase.store(localAci, SignalProtocolAddress(ACI_A.toString(), 1), SessionRecord())
reactionDatabase.addReaction(MessageId(smsId1, false), ReactionRecord("a", recipientIdAci, 1, 1))
reactionDatabase.addReaction(MessageId(mmsId1, true), ReactionRecord("b", recipientIdE164, 1, 1))
val profile1: NotificationProfile = notificationProfile(name = "Test")
val profile2: NotificationProfile = notificationProfile(name = "Test2")
notificationProfileDatabase.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdAci)
notificationProfileDatabase.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdE164)
notificationProfileDatabase.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdE164)
notificationProfileDatabase.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdAciB)
val distributionListId: DistributionListId = distributionListDatabase.createList("testlist", listOf(recipientIdE164, recipientIdAciB))!!
// Merge
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
val retrievedThreadId: Long = threadDatabase.getThreadIdFor(retrievedId)!!
assertEquals(recipientIdAci, retrievedId)
// Recipient validation
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_A, retrievedRecipient.requireE164())
val existingE164Recipient = Recipient.resolved(recipientIdE164)
assertEquals(retrievedId, existingE164Recipient.id)
// Thread validation
assertEquals(threadIdAci, retrievedThreadId)
Assert.assertNull(threadDatabase.getThreadIdFor(recipientIdE164))
Assert.assertNull(threadDatabase.getThreadRecord(threadIdE164))
// SMS validation
val sms1: MessageRecord = smsDatabase.getMessageRecord(smsId1)!!
val sms2: MessageRecord = smsDatabase.getMessageRecord(smsId2)!!
val sms3: MessageRecord = smsDatabase.getMessageRecord(smsId3)!!
assertEquals(retrievedId, sms1.recipient.id)
assertEquals(retrievedId, sms2.recipient.id)
assertEquals(retrievedId, sms3.recipient.id)
assertEquals(retrievedThreadId, sms1.threadId)
assertEquals(retrievedThreadId, sms2.threadId)
assertEquals(retrievedThreadId, sms3.threadId)
// MMS validation
val mms1: MessageRecord = mmsDatabase.getMessageRecord(mmsId1)!!
val mms2: MessageRecord = mmsDatabase.getMessageRecord(mmsId2)!!
val mms3: MessageRecord = mmsDatabase.getMessageRecord(mmsId3)!!
assertEquals(retrievedId, mms1.recipient.id)
assertEquals(retrievedId, mms2.recipient.id)
assertEquals(retrievedId, mms3.recipient.id)
assertEquals(retrievedThreadId, mms1.threadId)
assertEquals(retrievedThreadId, mms2.threadId)
assertEquals(retrievedThreadId, mms3.threadId)
// Mention validation
val mention1: MentionModel = getMention(mmsId1)
assertEquals(retrievedId, mention1.recipientId)
assertEquals(retrievedThreadId, mention1.threadId)
val mention2: MentionModel = getMention(mmsId2)
assertEquals(retrievedId, mention2.recipientId)
assertEquals(retrievedThreadId, mention2.threadId)
// Group receipt validation
val groupReceipts: List<GroupReceiptDatabase.GroupReceiptInfo> = groupReceiptDatabase.getGroupReceiptInfo(mmsId1)
assertEquals(retrievedId, groupReceipts[0].recipientId)
assertEquals(retrievedId, groupReceipts[1].recipientId)
// Identity validation
assertEquals(identityKeyAci, identityDatabase.getIdentityStoreRecord(ACI_A.toString())!!.identityKey)
Assert.assertNull(identityDatabase.getIdentityStoreRecord(E164_A))
// Session validation
Assert.assertNotNull(sessionDatabase.load(localAci, SignalProtocolAddress(ACI_A.toString(), 1)))
// Reaction validation
val reactionsSms: List<ReactionRecord> = reactionDatabase.getReactions(MessageId(smsId1, false))
val reactionsMms: List<ReactionRecord> = reactionDatabase.getReactions(MessageId(mmsId1, true))
assertEquals(1, reactionsSms.size)
assertEquals(ReactionRecord("a", recipientIdAci, 1, 1), reactionsSms[0])
assertEquals(1, reactionsMms.size)
assertEquals(ReactionRecord("b", recipientIdAci, 1, 1), reactionsMms[0])
// Notification Profile validation
val updatedProfile1: NotificationProfile = notificationProfileDatabase.getProfile(profile1.id)!!
val updatedProfile2: NotificationProfile = notificationProfileDatabase.getProfile(profile2.id)!!
MatcherAssert.assertThat("Notification Profile 1 should now only contain ACI $recipientIdAci", updatedProfile1.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci))
MatcherAssert.assertThat("Notification Profile 2 should now contain ACI A ($recipientIdAci) and ACI B ($recipientIdAciB)", updatedProfile2.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB))
// Distribution List validation
val updatedList: DistributionListRecord = distributionListDatabase.getList(distributionListId)!!
MatcherAssert.assertThat("Distribution list should have updated $recipientIdE164 to $recipientIdAci", updatedList.members, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB))
}
// ==============================================================
// Misc
// ==============================================================
@@ -528,6 +708,53 @@ class RecipientDatabaseTest {
}
}
private fun smsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingTextMessage {
return IncomingTextMessage(sender, 1, time, time, time, body, groupId, 0, true, null)
}
private fun mmsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingMediaMessage {
return IncomingMediaMessage(sender, groupId, body, time, time, time, emptyList(), 0, 0, false, false, true, Optional.empty())
}
private fun identityKey(value: Byte): IdentityKey {
val bytes = ByteArray(33)
bytes[0] = 0x05
bytes[1] = value
return IdentityKey(bytes)
}
private fun notificationProfile(name: String): NotificationProfile {
return (notificationProfileDatabase.createProfile(name = name, emoji = "", color = AvatarColor.A210, System.currentTimeMillis()) as NotificationProfileDatabase.NotificationProfileChangeResult.Success).notificationProfile
}
private fun groupMasterKey(value: Byte): GroupMasterKey {
val bytes = ByteArray(32)
bytes[0] = value
return GroupMasterKey(bytes)
}
private fun decryptedGroup(members: Collection<UUID>): DecryptedGroup {
return DecryptedGroup.newBuilder()
.addAllMembers(members.map { DecryptedMember.newBuilder().setUuid(UuidUtil.toByteString(it)).build() })
.build()
}
private fun getMention(messageId: Long): MentionModel {
SignalDatabase.rawDatabase.rawQuery("SELECT * FROM ${MentionDatabase.TABLE_NAME} WHERE ${MentionDatabase.MESSAGE_ID} = $messageId").use { cursor ->
cursor.moveToFirst()
return MentionModel(
recipientId = RecipientId.from(CursorUtil.requireLong(cursor, MentionDatabase.RECIPIENT_ID)),
threadId = CursorUtil.requireLong(cursor, MentionDatabase.THREAD_ID)
)
}
}
/** The normal mention model doesn't have a threadId, so we need to do it ourselves for the test */
data class MentionModel(
val recipientId: RecipientId,
val threadId: Long
)
private class ChangeNumberListener {
var numberChangeWasEnqueued = false

View File

@@ -1,271 +0,0 @@
package org.thoughtcrime.securesms.database
import android.app.Application
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.CursorUtil
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.libsignal.protocol.state.SessionRecord
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.signal.storageservice.protos.groups.local.DecryptedMember
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.DistributionListRecord
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.IncomingTextMessage
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.util.UuidUtil
import java.util.Optional
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class RecipientDatabaseTest_merges {
private lateinit var recipientDatabase: RecipientDatabase
private lateinit var identityDatabase: IdentityDatabase
private lateinit var groupReceiptDatabase: GroupReceiptDatabase
private lateinit var groupDatabase: GroupDatabase
private lateinit var threadDatabase: ThreadDatabase
private lateinit var smsDatabase: MessageDatabase
private lateinit var mmsDatabase: MessageDatabase
private lateinit var sessionDatabase: SessionDatabase
private lateinit var mentionDatabase: MentionDatabase
private lateinit var reactionDatabase: ReactionDatabase
private lateinit var notificationProfileDatabase: NotificationProfileDatabase
private lateinit var distributionListDatabase: DistributionListDatabase
private val localAci = ACI.from(UUID.randomUUID())
private val localPni = PNI.from(UUID.randomUUID())
@Before
fun setup() {
recipientDatabase = SignalDatabase.recipients
identityDatabase = SignalDatabase.identities
groupReceiptDatabase = SignalDatabase.groupReceipts
groupDatabase = SignalDatabase.groups
threadDatabase = SignalDatabase.threads
smsDatabase = SignalDatabase.sms
mmsDatabase = SignalDatabase.mms
sessionDatabase = SignalDatabase.sessions
mentionDatabase = SignalDatabase.mentions
reactionDatabase = SignalDatabase.reactions
notificationProfileDatabase = SignalDatabase.notificationProfiles
distributionListDatabase = SignalDatabase.distributionLists
SignalStore.account().setAci(localAci)
SignalStore.account().setPni(localPni)
}
/** High trust lets you merge two different users into one. You should prefer the ACI user. Not shown: merging threads, dropping e164 sessions, etc. */
@Test
fun getAndPossiblyMerge_general() {
// Setup
val recipientIdAci: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
val recipientIdE164: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
val recipientIdAciB: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_B)
val smsId1: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 0, body = "0")).get().messageId
val smsId2: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdE164, time = 1, body = "1")).get().messageId
val smsId3: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 2, body = "2")).get().messageId
val mmsId1: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 3, body = "3"), -1).get().messageId
val mmsId2: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdE164, time = 4, body = "4"), -1).get().messageId
val mmsId3: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 5, body = "5"), -1).get().messageId
val threadIdAci: Long = threadDatabase.getThreadIdFor(recipientIdAci)!!
val threadIdE164: Long = threadDatabase.getThreadIdFor(recipientIdE164)!!
assertNotEquals(threadIdAci, threadIdE164)
mentionDatabase.insert(threadIdAci, mmsId1, listOf(Mention(recipientIdE164, 0, 1)))
mentionDatabase.insert(threadIdE164, mmsId2, listOf(Mention(recipientIdAci, 0, 1)))
groupReceiptDatabase.insert(listOf(recipientIdAci, recipientIdE164), mmsId1, 0, 3)
val identityKeyAci: IdentityKey = identityKey(1)
val identityKeyE164: IdentityKey = identityKey(2)
identityDatabase.saveIdentity(ACI_A.toString(), recipientIdAci, identityKeyAci, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
identityDatabase.saveIdentity(E164_A, recipientIdE164, identityKeyE164, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
sessionDatabase.store(localAci, SignalProtocolAddress(ACI_A.toString(), 1), SessionRecord())
reactionDatabase.addReaction(MessageId(smsId1, false), ReactionRecord("a", recipientIdAci, 1, 1))
reactionDatabase.addReaction(MessageId(mmsId1, true), ReactionRecord("b", recipientIdE164, 1, 1))
val profile1: NotificationProfile = notificationProfile(name = "Test")
val profile2: NotificationProfile = notificationProfile(name = "Test2")
notificationProfileDatabase.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdAci)
notificationProfileDatabase.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdE164)
notificationProfileDatabase.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdE164)
notificationProfileDatabase.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdAciB)
val distributionListId: DistributionListId = distributionListDatabase.createList("testlist", listOf(recipientIdE164, recipientIdAciB))!!
// Merge
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
val retrievedThreadId: Long = threadDatabase.getThreadIdFor(retrievedId)!!
assertEquals(recipientIdAci, retrievedId)
// Recipient validation
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
assertEquals(E164_A, retrievedRecipient.requireE164())
val existingE164Recipient = Recipient.resolved(recipientIdE164)
assertEquals(retrievedId, existingE164Recipient.id)
// Thread validation
assertEquals(threadIdAci, retrievedThreadId)
assertNull(threadDatabase.getThreadIdFor(recipientIdE164))
assertNull(threadDatabase.getThreadRecord(threadIdE164))
// SMS validation
val sms1: MessageRecord = smsDatabase.getMessageRecord(smsId1)!!
val sms2: MessageRecord = smsDatabase.getMessageRecord(smsId2)!!
val sms3: MessageRecord = smsDatabase.getMessageRecord(smsId3)!!
assertEquals(retrievedId, sms1.recipient.id)
assertEquals(retrievedId, sms2.recipient.id)
assertEquals(retrievedId, sms3.recipient.id)
assertEquals(retrievedThreadId, sms1.threadId)
assertEquals(retrievedThreadId, sms2.threadId)
assertEquals(retrievedThreadId, sms3.threadId)
// MMS validation
val mms1: MessageRecord = mmsDatabase.getMessageRecord(mmsId1)!!
val mms2: MessageRecord = mmsDatabase.getMessageRecord(mmsId2)!!
val mms3: MessageRecord = mmsDatabase.getMessageRecord(mmsId3)!!
assertEquals(retrievedId, mms1.recipient.id)
assertEquals(retrievedId, mms2.recipient.id)
assertEquals(retrievedId, mms3.recipient.id)
assertEquals(retrievedThreadId, mms1.threadId)
assertEquals(retrievedThreadId, mms2.threadId)
assertEquals(retrievedThreadId, mms3.threadId)
// Mention validation
val mention1: MentionModel = getMention(mmsId1)
assertEquals(retrievedId, mention1.recipientId)
assertEquals(retrievedThreadId, mention1.threadId)
val mention2: MentionModel = getMention(mmsId2)
assertEquals(retrievedId, mention2.recipientId)
assertEquals(retrievedThreadId, mention2.threadId)
// Group receipt validation
val groupReceipts: List<GroupReceiptDatabase.GroupReceiptInfo> = groupReceiptDatabase.getGroupReceiptInfo(mmsId1)
assertEquals(retrievedId, groupReceipts[0].recipientId)
assertEquals(retrievedId, groupReceipts[1].recipientId)
// Identity validation
assertEquals(identityKeyAci, identityDatabase.getIdentityStoreRecord(ACI_A.toString())!!.identityKey)
assertNull(identityDatabase.getIdentityStoreRecord(E164_A))
// Session validation
assertNotNull(sessionDatabase.load(localAci, SignalProtocolAddress(ACI_A.toString(), 1)))
// Reaction validation
val reactionsSms: List<ReactionRecord> = reactionDatabase.getReactions(MessageId(smsId1, false))
val reactionsMms: List<ReactionRecord> = reactionDatabase.getReactions(MessageId(mmsId1, true))
assertEquals(1, reactionsSms.size)
assertEquals(ReactionRecord("a", recipientIdAci, 1, 1), reactionsSms[0])
assertEquals(1, reactionsMms.size)
assertEquals(ReactionRecord("b", recipientIdAci, 1, 1), reactionsMms[0])
// Notification Profile validation
val updatedProfile1: NotificationProfile = notificationProfileDatabase.getProfile(profile1.id)!!
val updatedProfile2: NotificationProfile = notificationProfileDatabase.getProfile(profile2.id)!!
assertThat("Notification Profile 1 should now only contain ACI $recipientIdAci", updatedProfile1.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci))
assertThat("Notification Profile 2 should now contain ACI A ($recipientIdAci) and ACI B ($recipientIdAciB)", updatedProfile2.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB))
// Distribution List validation
val updatedList: DistributionListRecord = distributionListDatabase.getList(distributionListId)!!
assertThat("Distribution list should have updated $recipientIdE164 to $recipientIdAci", updatedList.members, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB))
}
private val context: Application
get() = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
private fun smsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingTextMessage {
return IncomingTextMessage(sender, 1, time, time, time, body, groupId, 0, true, null)
}
private fun mmsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingMediaMessage {
return IncomingMediaMessage(sender, groupId, body, time, time, time, emptyList(), 0, 0, false, false, true, Optional.empty())
}
private fun identityKey(value: Byte): IdentityKey {
val bytes = ByteArray(33)
bytes[0] = 0x05
bytes[1] = value
return IdentityKey(bytes)
}
private fun groupMasterKey(value: Byte): GroupMasterKey {
val bytes = ByteArray(32)
bytes[0] = value
return GroupMasterKey(bytes)
}
private fun decryptedGroup(members: Collection<UUID>): DecryptedGroup {
return DecryptedGroup.newBuilder()
.addAllMembers(members.map { DecryptedMember.newBuilder().setUuid(UuidUtil.toByteString(it)).build() })
.build()
}
private fun getMention(messageId: Long): MentionModel {
SignalDatabase.rawDatabase.rawQuery("SELECT * FROM ${MentionDatabase.TABLE_NAME} WHERE ${MentionDatabase.MESSAGE_ID} = $messageId").use { cursor ->
cursor.moveToFirst()
return MentionModel(
recipientId = RecipientId.from(CursorUtil.requireLong(cursor, MentionDatabase.RECIPIENT_ID)),
threadId = CursorUtil.requireLong(cursor, MentionDatabase.THREAD_ID)
)
}
}
private fun notificationProfile(name: String): NotificationProfile {
return (notificationProfileDatabase.createProfile(name = name, emoji = "", color = AvatarColor.A210, System.currentTimeMillis()) as NotificationProfileDatabase.NotificationProfileChangeResult.Success).notificationProfile
}
/** The normal mention model doesn't have a threadId, so we need to do it ourselves for the test */
data class MentionModel(
val recipientId: RecipientId,
val threadId: Long
)
companion object {
val ACI_A = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
val ACI_B = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
val E164_A = "+12221234567"
val E164_B = "+13331234567"
}
}

View File

@@ -0,0 +1,206 @@
package org.thoughtcrime.securesms.database
import androidx.core.content.contentValuesOf
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.requireLong
import org.signal.core.util.requireString
import org.signal.core.util.select
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class RecipientDatabaseTest_processCdsV2Result {
private lateinit var recipientDatabase: RecipientDatabase
private val localAci = ACI.from(UUID.randomUUID())
private val localPni = PNI.from(UUID.randomUUID())
@Before
fun setup() {
recipientDatabase = SignalDatabase.recipients
ensureDbEmpty()
SignalStore.account().setAci(localAci)
SignalStore.account().setPni(localPni)
}
@Test
fun processCdsV2Result_noMatch() {
// Note that we haven't inserted any test data
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
val record: IdRecord = require(resultId)
assertEquals(resultId, record.id)
assertEquals(E164_A, record.e164)
assertEquals(ACI_A, record.sid)
assertEquals(PNI_A, record.pni)
}
@Test
fun processCdsV2Result_fullMatch() {
val inputId: RecipientId = insert(E164_A, PNI_A, ACI_A)
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
val record: IdRecord = require(resultId)
assertEquals(inputId, record.id)
assertEquals(E164_A, record.e164)
assertEquals(ACI_A, record.sid)
assertEquals(PNI_A, record.pni)
}
@Test
fun processCdsV2Result_onlyE164Matches() {
val inputId: RecipientId = insert(E164_A, null, null)
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
val record: IdRecord = require(resultId)
assertEquals(inputId, record.id)
assertEquals(E164_A, record.e164)
assertEquals(ACI_A, record.sid)
assertEquals(PNI_A, record.pni)
}
@Test
fun processCdsV2Result_e164AndPniMatches() {
val inputId: RecipientId = insert(E164_A, PNI_A, null)
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
val record: IdRecord = require(resultId)
assertEquals(inputId, record.id)
assertEquals(E164_A, record.e164)
assertEquals(ACI_A, record.sid)
assertEquals(PNI_A, record.pni)
}
@Test
fun processCdsV2Result_e164AndAciMatches() {
val inputId: RecipientId = insert(E164_A, null, ACI_A)
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
val record: IdRecord = require(resultId)
assertEquals(inputId, record.id)
assertEquals(E164_A, record.e164)
assertEquals(ACI_A, record.sid)
assertEquals(PNI_A, record.pni)
}
@Test
fun processCdsV2Result_onlyPniMatches() {
val inputId: RecipientId = insert(null, PNI_A, null)
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
val record: IdRecord = require(resultId)
assertEquals(inputId, record.id)
assertEquals(E164_A, record.e164)
assertEquals(ACI_A, record.sid)
assertEquals(PNI_A, record.pni)
}
@Test
fun processCdsV2Result_pniAndAciMatches() {
val inputId: RecipientId = insert(null, PNI_A, ACI_A)
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
val record: IdRecord = require(resultId)
assertEquals(inputId, record.id)
assertEquals(E164_A, record.e164)
assertEquals(ACI_A, record.sid)
assertEquals(PNI_A, record.pni)
}
@Test
fun processCdsV2Result_onlyAciMatches() {
val inputId: RecipientId = insert(null, null, ACI_A)
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
val record: IdRecord = require(resultId)
assertEquals(inputId, record.id)
assertEquals(E164_A, record.e164)
assertEquals(ACI_A, record.sid)
assertEquals(PNI_A, record.pni)
}
private fun insert(e164: String?, pni: PNI?, aci: ACI?): RecipientId {
val id: Long = SignalDatabase.rawDatabase.insert(
RecipientDatabase.TABLE_NAME,
null,
contentValuesOf(
RecipientDatabase.PHONE to e164,
RecipientDatabase.SERVICE_ID to (aci ?: pni)?.toString(),
RecipientDatabase.PNI_COLUMN to pni?.toString(),
RecipientDatabase.REGISTERED to RecipientDatabase.RegisteredState.REGISTERED.id
)
)
return RecipientId.from(id)
}
private fun require(id: RecipientId): IdRecord {
return get(id)!!
}
private fun get(id: RecipientId): IdRecord? {
SignalDatabase.rawDatabase
.select(RecipientDatabase.ID, RecipientDatabase.PHONE, RecipientDatabase.SERVICE_ID, RecipientDatabase.PNI_COLUMN)
.from(RecipientDatabase.TABLE_NAME)
.where("${RecipientDatabase.ID} = ?", id)
.run()
.use { cursor ->
return if (cursor.moveToFirst()) {
IdRecord(
id = RecipientId.from(cursor.requireLong(RecipientDatabase.ID)),
e164 = cursor.requireString(RecipientDatabase.PHONE),
sid = ServiceId.parseOrNull(cursor.requireString(RecipientDatabase.SERVICE_ID)),
pni = PNI.parseOrNull(cursor.requireString(RecipientDatabase.PNI_COLUMN))
)
} else {
null
}
}
}
private fun ensureDbEmpty() {
SignalDatabase.rawDatabase.rawQuery("SELECT COUNT(*) FROM ${RecipientDatabase.TABLE_NAME} WHERE ${RecipientDatabase.DISTRIBUTION_LIST_ID} IS NULL ", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(0, cursor.getLong(0))
}
}
private data class IdRecord(
val id: RecipientId,
val e164: String?,
val sid: ServiceId?,
val pni: PNI?,
)
companion object {
val ACI_A = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
val ACI_B = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
val PNI_A = PNI.from(UUID.fromString("154b8d92-c960-4f6c-8385-671ad2ffb999"))
val PNI_B = PNI.from(UUID.fromString("ba92b1fb-cd55-40bf-adda-c35a85375533"))
const val E164_A = "+12221234567"
const val E164_B = "+13331234567"
}
}

View File

@@ -1,21 +1,41 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import junit.framework.TestCase.assertNull
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.containsInAnyOrder
import org.hamcrest.Matchers.hasSize
import org.hamcrest.Matchers.`is`
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.DistributionId
import org.whispersystems.signalservice.api.push.ServiceId
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class StorySendsDatabaseTest {
private val distributionId1 = DistributionId.from(UUID.randomUUID())
private val distributionId2 = DistributionId.from(UUID.randomUUID())
private val distributionId3 = DistributionId.from(UUID.randomUUID())
private lateinit var distributionList1: DistributionListId
private lateinit var distributionList2: DistributionListId
private lateinit var distributionList3: DistributionListId
private lateinit var distributionListRecipient1: Recipient
private lateinit var distributionListRecipient2: Recipient
private lateinit var distributionListRecipient3: Recipient
private lateinit var recipients1to10: List<RecipientId>
private lateinit var recipients11to20: List<RecipientId>
private lateinit var recipients6to15: List<RecipientId>
@@ -31,22 +51,41 @@ class StorySendsDatabaseTest {
fun setup() {
storySends = SignalDatabase.storySends
messageId1 = MmsHelper.insert(storyType = StoryType.STORY_WITHOUT_REPLIES)
messageId2 = MmsHelper.insert(storyType = StoryType.STORY_WITH_REPLIES)
messageId3 = MmsHelper.insert(storyType = StoryType.STORY_WITHOUT_REPLIES)
recipients1to10 = makeRecipients(10)
recipients11to20 = makeRecipients(10)
distributionList1 = SignalDatabase.distributionLists.createList("1", emptyList(), distributionId = distributionId1)!!
distributionList2 = SignalDatabase.distributionLists.createList("2", emptyList(), distributionId = distributionId2)!!
distributionList3 = SignalDatabase.distributionLists.createList("3", emptyList(), distributionId = distributionId3)!!
distributionListRecipient1 = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(distributionList1))
distributionListRecipient2 = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(distributionList2))
distributionListRecipient3 = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(distributionList3))
messageId1 = MmsHelper.insert(
recipient = distributionListRecipient1,
storyType = StoryType.STORY_WITHOUT_REPLIES,
)
messageId2 = MmsHelper.insert(
recipient = distributionListRecipient2,
storyType = StoryType.STORY_WITH_REPLIES,
)
messageId3 = MmsHelper.insert(
recipient = distributionListRecipient3,
storyType = StoryType.STORY_WITHOUT_REPLIES,
)
recipients6to15 = recipients1to10.takeLast(5) + recipients11to20.take(5)
recipients6to10 = recipients1to10.takeLast(5)
}
@Test
fun getRecipientsToSendTo_noOverlap() {
storySends.insert(messageId1, recipients1to10, 100, false)
storySends.insert(messageId2, recipients11to20, 200, true)
storySends.insert(messageId3, recipients1to10, 300, false)
storySends.insert(messageId1, recipients1to10, 100, false, distributionId1)
storySends.insert(messageId2, recipients11to20, 200, true, distributionId2)
storySends.insert(messageId3, recipients1to10, 300, false, distributionId3)
val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, false)
val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 200, true)
@@ -60,8 +99,8 @@ class StorySendsDatabaseTest {
@Test
fun getRecipientsToSendTo_overlap() {
storySends.insert(messageId1, recipients1to10, 100, false)
storySends.insert(messageId2, recipients6to15, 100, true)
storySends.insert(messageId1, recipients1to10, 100, false, distributionId1)
storySends.insert(messageId2, recipients6to15, 100, true, distributionId2)
val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, false)
val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 100, true)
@@ -78,9 +117,9 @@ class StorySendsDatabaseTest {
val recipient1 = recipients1to10.first()
val recipient2 = recipients11to20.first()
storySends.insert(messageId1, listOf(recipient1, recipient2), 100, false)
storySends.insert(messageId2, listOf(recipient1), 100, true)
storySends.insert(messageId3, listOf(recipient2), 100, true)
storySends.insert(messageId1, listOf(recipient1, recipient2), 100, false, distributionId1)
storySends.insert(messageId2, listOf(recipient1), 100, true, distributionId2)
storySends.insert(messageId3, listOf(recipient2), 100, true, distributionId3)
val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, false)
val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 100, true)
@@ -97,8 +136,8 @@ class StorySendsDatabaseTest {
@Test
fun getRecipientsToSendTo_overlapWithEarlierMessage() {
storySends.insert(messageId1, recipients6to15, 100, true)
storySends.insert(messageId2, recipients1to10, 100, false)
storySends.insert(messageId1, recipients6to15, 100, true, distributionId1)
storySends.insert(messageId2, recipients1to10, 100, false, distributionId2)
val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, true)
val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 100, false)
@@ -112,9 +151,9 @@ class StorySendsDatabaseTest {
@Test
fun getRemoteDeleteRecipients_noOverlap() {
storySends.insert(messageId1, recipients1to10, 100, false)
storySends.insert(messageId2, recipients11to20, 200, true)
storySends.insert(messageId3, recipients1to10, 300, false)
storySends.insert(messageId1, recipients1to10, 100, false, distributionId1)
storySends.insert(messageId2, recipients11to20, 200, true, distributionId2)
storySends.insert(messageId3, recipients1to10, 300, false, distributionId3)
val recipientIdsForMessage1 = storySends.getRemoteDeleteRecipients(messageId1, 100)
val recipientIdsForMessage2 = storySends.getRemoteDeleteRecipients(messageId2, 200)
@@ -128,8 +167,8 @@ class StorySendsDatabaseTest {
@Test
fun getRemoteDeleteRecipients_overlapNoPreviousDeletes() {
storySends.insert(messageId1, recipients1to10, 200, false)
storySends.insert(messageId2, recipients6to15, 200, true)
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients6to15, 200, true, distributionId2)
val recipientIdsForMessage1 = storySends.getRemoteDeleteRecipients(messageId1, 200)
val recipientIdsForMessage2 = storySends.getRemoteDeleteRecipients(messageId2, 200)
@@ -143,10 +182,10 @@ class StorySendsDatabaseTest {
@Test
fun getRemoteDeleteRecipients_overlapWithPreviousDeletes() {
storySends.insert(messageId1, recipients1to10, 200, false)
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
SignalDatabase.mms.markAsRemoteDelete(messageId1)
storySends.insert(messageId2, recipients6to15, 200, true)
storySends.insert(messageId2, recipients6to15, 200, true, distributionId2)
val recipientIdsForMessage2 = storySends.getRemoteDeleteRecipients(messageId2, 200)
@@ -156,7 +195,7 @@ class StorySendsDatabaseTest {
@Test
fun canReply_storyWithReplies() {
storySends.insert(messageId2, recipients1to10, 200, true)
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
val canReply = storySends.canReply(recipients1to10[0], 200)
@@ -165,7 +204,7 @@ class StorySendsDatabaseTest {
@Test
fun canReply_storyWithoutReplies() {
storySends.insert(messageId1, recipients1to10, 200, false)
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
val canReply = storySends.canReply(recipients1to10[0], 200)
@@ -174,8 +213,8 @@ class StorySendsDatabaseTest {
@Test
fun canReply_storyWithAndWithoutRepliesOverlap() {
storySends.insert(messageId1, recipients1to10, 200, false)
storySends.insert(messageId2, recipients6to10, 200, true)
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients6to10, 200, true, distributionId2)
val message1OnlyRecipientCanReply = storySends.canReply(recipients1to10[0], 200)
val message2RecipientCanReply = storySends.canReply(recipients6to10[0], 200)
@@ -184,6 +223,238 @@ class StorySendsDatabaseTest {
assertThat(message2RecipientCanReply, `is`(true))
}
@Test
fun givenASingleStory_whenIGetFullSentStorySyncManifest_thenIExpectNotNull() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)
assertNotNull(manifest)
}
@Test
fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory2_thenIExpectNull() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients1to10, 200, false, distributionId2)
val manifest = storySends.getFullSentStorySyncManifest(messageId2, 200)
assertNull(manifest)
}
@Test
fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory1_thenIExpectOneManifestPerRecipient() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)!!
assertEquals(recipients1to10, manifest.entries.map { it.recipientId })
}
@Test
fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory1_thenIExpectTwoListsPerRecipient() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)!!
manifest.entries.forEach { entry ->
assertEquals(listOf(distributionId1, distributionId2), entry.distributionLists)
}
}
@Test
fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory1_thenIExpectAllRecipientsCanReply() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)!!
manifest.entries.forEach { entry ->
assertTrue(entry.allowedToReply)
}
}
@Test
fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetFullSentStorySyncManifestForStory2_thenIExpectNonNullResult() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
SignalDatabase.mms.markAsRemoteDelete(messageId1)
val manifest = storySends.getFullSentStorySyncManifest(messageId2, 200)!!
assertNotNull(manifest)
}
@Test
fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetRecipientIdsForManifestUpdate_thenIExpectOnlyRecipientsWithStory2() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId1, recipients11to20, 200, false, distributionId1)
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
SignalDatabase.mms.markAsRemoteDelete(messageId1)
val recipientIds = storySends.getRecipientIdsForManifestUpdate(200, messageId1)
assertEquals(recipients1to10.toHashSet(), recipientIds)
}
@Test
fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetPartialSentStorySyncManifest_thenIExpectOnlyRecipientsThatHadStory1() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
storySends.insert(messageId2, recipients11to20, 200, true, distributionId2)
SignalDatabase.mms.markAsRemoteDelete(messageId1)
val recipientIds = storySends.getRecipientIdsForManifestUpdate(200, messageId1)
val results = storySends.getSentStorySyncManifestForUpdate(200, recipientIds)
val manifestRecipients = results.entries.map { it.recipientId }
assertEquals(recipients1to10, manifestRecipients)
}
@Test
fun givenTwoStoriesAndTheOneThatAllowedRepliesIsRemoteDeleted_whenIGetPartialSentStorySyncManifest_thenIExpectAllowRepliesToBeTrue() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
SignalDatabase.mms.markAsRemoteDelete(messageId2)
val recipientIds = storySends.getRecipientIdsForManifestUpdate(200, messageId1)
val results = storySends.getSentStorySyncManifestForUpdate(200, recipientIds)
assertTrue(results.entries.all { it.allowedToReply })
}
@Test
fun givenEmptyManifest_whenIApplyRemoteManifest_thenNothingChanges() {
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
val expected = storySends.getFullSentStorySyncManifest(messageId1, 200)
val emptyManifest = SentStorySyncManifest(emptyList())
storySends.applySentStoryManifest(emptyManifest, 200)
val result = storySends.getFullSentStorySyncManifest(messageId1, 200)
assertEquals(expected, result)
}
@Test
fun givenAnIdenticalManifest_whenIApplyRemoteManifest_thenNothingChanges() {
val messageId4 = MmsHelper.insert(
recipient = distributionListRecipient1,
storyType = StoryType.STORY_WITHOUT_REPLIES,
sentTimeMillis = 200
)
storySends.insert(messageId4, recipients1to10, 200, false, distributionId1)
val expected = storySends.getFullSentStorySyncManifest(messageId4, 200)
storySends.applySentStoryManifest(expected!!, 200)
val result = storySends.getFullSentStorySyncManifest(messageId4, 200)
assertEquals(expected, result)
}
@Test
fun givenAManifest_whenIApplyRemoteManifestWithoutOneList_thenIExpectMessageToBeMarkedRemoteDeleted() {
val messageId4 = MmsHelper.insert(
recipient = distributionListRecipient1,
storyType = StoryType.STORY_WITHOUT_REPLIES,
sentTimeMillis = 200
)
val messageId5 = MmsHelper.insert(
recipient = distributionListRecipient2,
storyType = StoryType.STORY_WITHOUT_REPLIES,
sentTimeMillis = 200
)
storySends.insert(messageId4, recipients1to10, 200, false, distributionId1)
val remote = storySends.getFullSentStorySyncManifest(messageId4, 200)!!
storySends.insert(messageId5, recipients1to10, 200, false, distributionId2)
storySends.applySentStoryManifest(remote, 200)
assertTrue(SignalDatabase.mms.getMessageRecord(messageId5).isRemoteDelete)
}
@Test
fun givenAManifest_whenIApplyRemoteManifestWithoutOneList_thenIExpectSharedMessageToNotBeMarkedRemoteDeleted() {
val messageId4 = MmsHelper.insert(
recipient = distributionListRecipient1,
storyType = StoryType.STORY_WITHOUT_REPLIES,
sentTimeMillis = 200
)
val messageId5 = MmsHelper.insert(
recipient = distributionListRecipient2,
storyType = StoryType.STORY_WITHOUT_REPLIES,
sentTimeMillis = 200
)
storySends.insert(messageId4, recipients1to10, 200, false, distributionId1)
val remote = storySends.getFullSentStorySyncManifest(messageId4, 200)!!
storySends.insert(messageId5, recipients1to10, 200, false, distributionId2)
storySends.applySentStoryManifest(remote, 200)
assertFalse(SignalDatabase.mms.getMessageRecord(messageId4).isRemoteDelete)
}
@Test
fun givenNoLocalEntries_whenIApplyRemoteManifest_thenIExpectLocalManifestToMatch() {
val messageId4 = MmsHelper.insert(
recipient = distributionListRecipient1,
storyType = StoryType.STORY_WITHOUT_REPLIES,
sentTimeMillis = 2000
)
val remote = SentStorySyncManifest(
recipients1to10.map {
SentStorySyncManifest.Entry(
recipientId = it,
allowedToReply = true,
distributionLists = listOf(distributionId1)
)
}
)
storySends.applySentStoryManifest(remote, 2000)
val local = storySends.getFullSentStorySyncManifest(messageId4, 2000)
assertEquals(remote, local)
}
@Test
fun givenNonStoryMessageAtSentTimestamp_whenIApplyRemoteManifest_thenIExpectLocalManifestToMatchAndNoCrashes() {
val messageId4 = MmsHelper.insert(
recipient = distributionListRecipient1,
storyType = StoryType.STORY_WITHOUT_REPLIES,
sentTimeMillis = 2000
)
MmsHelper.insert(
recipient = Recipient.resolved(recipients1to10.first()),
sentTimeMillis = 2000
)
val remote = SentStorySyncManifest(
recipients1to10.map {
SentStorySyncManifest.Entry(
recipientId = it,
allowedToReply = true,
distributionLists = listOf(distributionId1)
)
}
)
storySends.applySentStoryManifest(remote, 2000)
val local = storySends.getFullSentStorySyncManifest(messageId4, 2000)
assertEquals(remote, local)
}
private fun makeRecipients(count: Int): List<RecipientId> {
return (1..count).map {
SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID()))

View File

@@ -0,0 +1,119 @@
package org.thoughtcrime.securesms.testing
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.preference.PreferenceManager
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.rules.ExternalResource
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.IdentityDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.registration.RegistrationData
import org.thoughtcrime.securesms.registration.RegistrationRepository
import org.thoughtcrime.securesms.registration.RegistrationUtil
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import java.util.UUID
/**
* Test rule to use that sets up the application in a mostly registered state. Enough so that most
* activities should be launchable directly.
*
* To use: `@get:Rule val harness = SignalActivityRule()`
*/
class SignalActivityRule : ExternalResource() {
val application: Application = ApplicationDependencies.getApplication()
lateinit var context: Context
private set
lateinit var self: Recipient
private set
lateinit var others: List<RecipientId>
private set
override fun before() {
context = InstrumentationRegistry.getInstrumentation().targetContext
self = setupSelf()
others = setupOthers()
}
private fun setupSelf(): Recipient {
DeviceTransferBlockingInterceptor.getInstance().blockNetwork()
PreferenceManager.getDefaultSharedPreferences(application).edit().putBoolean("pref_prompted_push_registration", true).commit()
val masterSecret = MasterSecretUtil.generateMasterSecret(application, MasterSecretUtil.UNENCRYPTED_PASSPHRASE)
MasterSecretUtil.generateAsymmetricMasterSecret(application, masterSecret)
val preferences: SharedPreferences = application.getSharedPreferences(MasterSecretUtil.PREFERENCES_NAME, 0)
preferences.edit().putBoolean("passphrase_initialized", true).commit()
val registrationRepository = RegistrationRepository(application)
registrationRepository.registerAccountWithoutRegistrationLock(
RegistrationData(
code = "123123",
e164 = "+15554045550101",
password = Util.getSecret(18),
registrationId = registrationRepository.registrationId,
profileKey = registrationRepository.getProfileKey("+15554045550101"),
fcmToken = null
),
VerifyAccountResponse(UUID.randomUUID().toString(), UUID.randomUUID().toString(), false)
).blockingGet()
SignalStore.kbsValues().optOut()
RegistrationUtil.maybeMarkRegistrationComplete(application)
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson"))
return Recipient.self()
}
private fun setupOthers(): List<RecipientId> {
val others = mutableListOf<RecipientId>()
for (i in 0..4) {
val aci = ACI.from(UUID.randomUUID())
val recipientId = RecipientId.from(aci, "+1555555101$i")
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true, true, true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
ApplicationDependencies.getProtocolStore().aci().saveIdentity(SignalProtocolAddress(aci.toString(), 0), IdentityKeyUtil.generateIdentityKeyPair().publicKey)
others += recipientId
}
return others
}
inline fun <reified T : Activity> launchActivity(initIntent: Intent.() -> Unit): ActivityScenario<T> {
return androidx.test.core.app.launchActivity(Intent(context, T::class.java).apply(initIntent))
}
fun changeIdentityKey(recipient: Recipient, identityKey: IdentityKey = IdentityKeyUtil.generateIdentityKeyPair().publicKey) {
ApplicationDependencies.getProtocolStore().aci().saveIdentity(SignalProtocolAddress(recipient.requireServiceId().toString(), 0), identityKey)
}
fun getIdentity(recipient: Recipient): IdentityKey {
return ApplicationDependencies.getProtocolStore().aci().identities().getIdentity(SignalProtocolAddress(recipient.requireServiceId().toString(), 0))
}
fun setVerified(recipient: Recipient, status: IdentityDatabase.VerifiedStatus) {
ApplicationDependencies.getProtocolStore().aci().identities().setVerified(recipient.id, getIdentity(recipient), IdentityDatabase.VerifiedStatus.VERIFIED)
}
}

View File

@@ -184,6 +184,7 @@
android:excludeFromRecents="true"
android:taskAffinity=""
android:windowSoftInputMode="stateHidden"
android:exported="true"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">
<intent-filter>
<action android:name="android.intent.action.SEND" />
@@ -223,7 +224,7 @@
<data android:scheme="sgnl"
android:host="addstickers" />
</intent-filter>
<intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
@@ -257,7 +258,7 @@
android:noHistory="true"
android:theme="@style/Signal.Transparent">
<intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
@@ -274,7 +275,7 @@
android:host="signal.group"/>
</intent-filter>
<intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
@@ -284,7 +285,7 @@
android:host="signal.tube" />
</intent-filter>
<intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
@@ -402,9 +403,17 @@
<activity
android:name=".stories.viewer.StoryViewerActivity"
android:screenOrientation="portrait"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:theme="@style/TextSecure.DarkNoActionBar.StoryViewer"
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
android:launchMode="singleTask"
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing"
android:parentActivityName=".MainActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.MainActivity" />
</activity>
<activity android:name=".components.settings.app.changenumber.ChangeNumberLockActivity"
android:theme="@style/Signal.DayNight.NoActionBar"
@@ -416,6 +425,11 @@
android:windowSoftInputMode="stateAlwaysHidden">
</activity>
<activity android:name=".badges.gifts.flow.GiftFlowActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|screenLayout|screenSize"
android:theme="@style/Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysHidden">
</activity>
<activity android:name=".wallpaper.ChatWallpaperActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
@@ -457,6 +471,7 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".DeviceActivity"
android:screenOrientation="portrait"
android:label="@string/AndroidManifest__linked_devices"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
@@ -534,6 +549,7 @@
<activity android:name=".blocked.BlockedUsersActivity"
android:theme="@style/TextSecure.LightTheme"
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".scribbles.ImageEditorStickerSelectActivity"
@@ -644,7 +660,7 @@
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<service android:enabled="true" android:name=".service.webrtc.WebRtcCallService"/>
<service android:enabled="true" android:name=".service.webrtc.WebRtcCallService" android:foregroundServiceType="camera|microphone"/>
<service android:enabled="true" android:name=".service.ApplicationMigrationService"/>
<service android:enabled="true" android:exported="false" android:name=".service.KeyCachingService"/>
<service android:enabled="true" android:name=".messages.IncomingMessageObserver$ForegroundService"/>
@@ -698,7 +714,9 @@
<service android:name=".service.GenericForegroundService"/>
<service android:name=".gcm.FcmFetchService" />
<service android:name=".gcm.FcmFetchBackgroundService" />
<service android:name=".gcm.FcmFetchForegroundService" />
<service android:name=".gcm.FcmReceiveService">
<intent-filter>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because one or more lines are too long

View File

@@ -20,6 +20,6 @@ public final class AppCapabilities {
* asking if the user has set a Signal PIN or not.
*/
public static AccountAttributes.Capabilities getCapabilities(boolean storageCapable) {
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER, FeatureFlags.stories());
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER, FeatureFlags.stories(), FeatureFlags.giftBadges());
}
}

View File

@@ -60,7 +60,7 @@ import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.jobs.RetrieveReleaseChannelJob;
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob;
import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
@@ -198,7 +198,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()))
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
.addPostRender(RetrieveReleaseChannelJob::enqueue)
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
.addPostRender(() -> AndroidTelecomUtil.registerPhoneAccount())
.addPostRender(() -> ApplicationDependencies.getJobManager().add(new FontDownloaderJob()))
.addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary)
@@ -218,7 +218,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
ApplicationDependencies.getFrameRateTracker().start();
ApplicationDependencies.getMegaphoneRepository().onAppForegrounded();
ApplicationDependencies.getDeadlockDetector().start();
SubscriptionKeepAliveJob.launchSubscriberIdKeepAliveJobIfNecessary();
SubscriptionKeepAliveJob.enqueueAndTrackTimeIfNecessary();
SignalExecutors.BOUNDED.execute(() -> {
FeatureFlags.refreshIfNecessary();

View File

@@ -103,5 +103,8 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
/** @return true if handled, false if you want to let the normal url handling continue */
boolean onUrlClicked(@NonNull String url);
void onViewGiftBadgeClicked(@NonNull MessageRecord messageRecord);
void onGiftBadgeRevealed(@NonNull MessageRecord messageRecord);
}
}

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms;
import androidx.annotation.NonNull;
import androidx.lifecycle.LifecycleOwner;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
@@ -11,7 +12,8 @@ import java.util.Set;
public interface BindableConversationListItem extends Unbindable {
void bind(@NonNull ThreadRecord thread,
void bind(@NonNull LifecycleOwner lifecycleOwner,
@NonNull ThreadRecord thread,
@NonNull GlideRequests glideRequests, @NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull ConversationSet selectedConversations);

View File

@@ -83,7 +83,8 @@ public final class BlockUnblockDialog {
builder.setNegativeButton(android.R.string.cancel, null);
} else {
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_s, recipient.getDisplayName(context)));
builder.setMessage(R.string.BlockUnblockDialog_blocked_people_wont_be_able_to_call_you_or_send_you_messages);
builder.setMessage(recipient.isRegistered() ? R.string.BlockUnblockDialog_blocked_people_wont_be_able_to_call_you_or_send_you_messages
: R.string.BlockUnblockDialog_blocked_people_wont_be_able_to_send_you_messages);
if (onBlockAndReportSpam != null) {
builder.setNeutralButton(android.R.string.cancel, null);
@@ -128,7 +129,8 @@ public final class BlockUnblockDialog {
builder.setNegativeButton(android.R.string.cancel, null);
} else {
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_unblock_s, recipient.getDisplayName(context)));
builder.setMessage(R.string.BlockUnblockDialog_you_will_be_able_to_call_and_message_each_other);
builder.setMessage(recipient.isRegistered() ? R.string.BlockUnblockDialog_you_will_be_able_to_call_and_message_each_other
: R.string.BlockUnblockDialog_you_will_be_able_to_message_each_other);
builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run()));
builder.setNegativeButton(android.R.string.cancel, null);
}

View File

@@ -58,9 +58,9 @@ import com.google.android.material.chip.ChipGroup;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.pnikosis.materialishprogress.ProgressWheel;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper;
import org.thoughtcrime.securesms.contacts.AbstractContactsCursorLoader;
import org.thoughtcrime.securesms.contacts.ContactChip;
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter;
@@ -85,7 +85,6 @@ import org.thoughtcrime.securesms.util.UsernameUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.FixedViewsAdapter;
import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapterStickyHeader;
import org.signal.core.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
@@ -138,8 +137,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
private HorizontalScrollView chipGroupScrollContainer;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
private View shadowView;
private ToolbarShadowAnimationHelper toolbarShadowAnimationHelper;
private HeaderActionProvider headerActionProvider;
private TextView headerActionView;
@@ -253,14 +251,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
chipGroup = view.findViewById(R.id.chipGroup);
chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer);
constraintLayout = view.findViewById(R.id.container);
shadowView = view.findViewById(R.id.toolbar_shadow);
headerActionView = view.findViewById(R.id.header_action);
toolbarShadowAnimationHelper = new ToolbarShadowAnimationHelper(shadowView);
final LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
recyclerView.addOnScrollListener(toolbarShadowAnimationHelper);
recyclerView.setLayoutManager(layoutManager);
recyclerView.setItemAnimator(new DefaultItemAnimator() {
@Override

View File

@@ -25,11 +25,11 @@ import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.ecc.Curve;
import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.qr.kitkat.ScanListener;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.qr.ScanListener;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;

View File

@@ -14,36 +14,34 @@ import android.widget.ImageView;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.core.view.ViewCompat;
import org.thoughtcrime.securesms.components.camera.CameraView;
import org.thoughtcrime.securesms.qr.ScanListener;
import org.thoughtcrime.securesms.qr.ScanningThread;
import org.signal.qr.QrScannerView;
import org.signal.qr.kitkat.ScanListener;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.ViewUtil;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
public class DeviceAddFragment extends LoggingFragment {
private ViewGroup container;
private LinearLayout overlay;
private ImageView devicesImage;
private CameraView scannerView;
private ScanningThread scanningThread;
private ScanListener scanListener;
private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable();
private ImageView devicesImage;
private ScanListener scanListener;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.device_add_fragment);
this.overlay = this.container.findViewById(R.id.overlay);
this.scannerView = this.container.findViewById(R.id.scanner);
this.devicesImage = this.container.findViewById(R.id.devices);
ViewGroup container = ViewUtil.inflate(inflater, viewGroup, R.layout.device_add_fragment);
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
this.overlay.setOrientation(LinearLayout.HORIZONTAL);
} else {
this.overlay.setOrientation(LinearLayout.VERTICAL);
}
QrScannerView scannerView = container.findViewById(R.id.scanner);
this.devicesImage = container.findViewById(R.id.devices);
ViewCompat.setTransitionName(devicesImage, "devices");
if (Build.VERSION.SDK_INT >= 21) {
this.container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
@TargetApi(21)
@Override
public void onLayoutChange(View v, int left, int top, int right, int bottom,
@@ -59,52 +57,30 @@ public class DeviceAddFragment extends LoggingFragment {
});
}
return this.container;
scannerView.start(getViewLifecycleOwner(), FeatureFlags.useQrLegacyScan());
lifecycleDisposable.bindTo(getViewLifecycleOwner());
Disposable qrDisposable = scannerView
.getQrData()
.distinctUntilChanged()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(qrData -> {
if (scanListener != null) {
scanListener.onQrDataFound(qrData);
}
});
lifecycleDisposable.add(qrDisposable);
return container;
}
@Override
public void onResume() {
super.onResume();
this.scanningThread = new ScanningThread();
this.scanningThread.setScanListener(scanListener);
this.scannerView.onResume();
this.scannerView.setPreviewCallback(scanningThread);
this.scanningThread.start();
}
@Override
public void onPause() {
super.onPause();
this.scannerView.onPause();
this.scanningThread.stopScanning();
}
@Override
public void onConfigurationChanged(@NonNull Configuration newConfiguration) {
super.onConfigurationChanged(newConfiguration);
this.scannerView.onPause();
if (newConfiguration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
overlay.setOrientation(LinearLayout.HORIZONTAL);
} else {
overlay.setOrientation(LinearLayout.VERTICAL);
}
this.scannerView.onResume();
this.scannerView.setPreviewCallback(scanningThread);
}
public ImageView getDevicesImage() {
return devicesImage;
}
public void setScanListener(ScanListener scanListener) {
this.scanListener = scanListener;
if (this.scanningThread != null) {
this.scanningThread.setScanListener(scanListener);
}
}
}

View File

@@ -9,6 +9,7 @@ import android.view.ViewGroup;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.core.view.ViewCompat;
import androidx.fragment.app.Fragment;
public class DeviceLinkFragment extends Fragment implements View.OnClickListener {
@@ -21,6 +22,7 @@ public class DeviceLinkFragment extends Fragment implements View.OnClickListener
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
this.container = (LinearLayout) inflater.inflate(R.layout.device_link_fragment, container, false);
this.container.findViewById(R.id.link_device).setOnClickListener(this);
ViewCompat.setTransitionName(container.findViewById(R.id.devices), "devices");
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
container.setOrientation(LinearLayout.HORIZONTAL);

View File

@@ -21,7 +21,7 @@ import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.melnykov.fab.FloatingActionButton;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.loaders.DeviceListLoader;

View File

@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.SplashScreenUtil;
import org.thoughtcrime.securesms.util.WindowUtil;
public class MainActivity extends PassphraseRequiredActivity implements VoiceNoteMediaControllerOwner {
@@ -100,6 +101,12 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
updateTabVisibility();
}
@Override
protected void onStop() {
super.onStop();
SplashScreenUtil.setSplashScreenThemeIfNecessary(this, SignalStore.settings().getTheme());
}
@Override
public void onBackPressed() {
if (!navigator.onBackPressed()) {
@@ -118,10 +125,10 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
private void updateTabVisibility() {
if (Stories.isFeatureEnabled()) {
findViewById(R.id.conversation_list_tabs).setVisibility(View.VISIBLE);
WindowUtil.setNavigationBarColor(getWindow(), ContextCompat.getColor(this, R.color.signal_colorSecondaryContainer));
WindowUtil.setNavigationBarColor(getWindow(), ContextCompat.getColor(this, R.color.signal_colorSurface2));
} else {
findViewById(R.id.conversation_list_tabs).setVisibility(View.GONE);
WindowUtil.setNavigationBarColor(getWindow(), ContextCompat.getColor(this, R.color.signal_background_primary));
WindowUtil.setNavigationBarColor(getWindow(), ContextCompat.getColor(this, R.color.signal_colorBackground));
conversationListTabsViewModel.onChatsSelected();
}
}

View File

@@ -171,6 +171,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return STATE_WELCOME_PUSH_SCREEN;
} else if (SignalStore.storageService().needsAccountRestore()) {
return STATE_ENTER_SIGNAL_PIN;
} else if (userHasSkippedOrForgottenPin()) {
return STATE_CREATE_SIGNAL_PIN;
} else if (userMustSetProfileName()) {
return STATE_CREATE_PROFILE_NAME;
} else if (userMustCreateSignalPin()) {
@@ -190,6 +192,10 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().lastPinCreateFailed() && !SignalStore.kbsValues().hasOptedOut();
}
private boolean userHasSkippedOrForgottenPin() {
return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().hasOptedOut() && SignalStore.kbsValues().isPinForgottenOrSkipped();
}
private boolean userMustSetProfileName() {
return !SignalStore.registrationValues().isRegistrationComplete() && Recipient.self().getProfileName().isEmpty();
}

View File

@@ -1,149 +0,0 @@
package org.thoughtcrime.securesms;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.util.CharacterCalculator;
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
import java.util.Optional;
public class TransportOption implements Parcelable {
public enum Type {
SMS,
TEXTSECURE
}
private final int drawable;
private final int backgroundColor;
private final @NonNull String text;
private final @NonNull Type type;
private final @NonNull String composeHint;
private final @NonNull CharacterCalculator characterCalculator;
private final @NonNull Optional<CharSequence> simName;
private final @NonNull Optional<Integer> simSubscriptionId;
public TransportOption(@NonNull Type type,
@DrawableRes int drawable,
int backgroundColor,
@NonNull String text,
@NonNull String composeHint,
@NonNull CharacterCalculator characterCalculator)
{
this(type, drawable, backgroundColor, text, composeHint, characterCalculator,
Optional.empty(), Optional.empty());
}
public TransportOption(@NonNull Type type,
@DrawableRes int drawable,
int backgroundColor,
@NonNull String text,
@NonNull String composeHint,
@NonNull CharacterCalculator characterCalculator,
@NonNull Optional<CharSequence> simName,
@NonNull Optional<Integer> simSubscriptionId)
{
this.type = type;
this.drawable = drawable;
this.backgroundColor = backgroundColor;
this.text = text;
this.composeHint = composeHint;
this.characterCalculator = characterCalculator;
this.simName = simName;
this.simSubscriptionId = simSubscriptionId;
}
TransportOption(Parcel in) {
this(Type.valueOf(in.readString()),
in.readInt(),
in.readInt(),
in.readString(),
in.readString(),
CharacterCalculator.readFromParcel(in),
Optional.ofNullable(TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in)),
in.readInt() == 1 ? Optional.of(in.readInt()) : Optional.empty());
}
public @NonNull Type getType() {
return type;
}
public boolean isType(Type type) {
return this.type == type;
}
public boolean isSms() {
return type == Type.SMS;
}
public CharacterState calculateCharacters(String messageBody) {
return characterCalculator.calculateCharacters(messageBody);
}
public @DrawableRes int getDrawable() {
return drawable;
}
public int getBackgroundColor() {
return backgroundColor;
}
public @NonNull String getComposeHint() {
return composeHint;
}
public @NonNull String getDescription() {
return text;
}
@NonNull
public Optional<CharSequence> getSimName() {
return simName;
}
@NonNull
public Optional<Integer> getSimSubscriptionId() {
return simSubscriptionId;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(type.name());
dest.writeInt(drawable);
dest.writeInt(backgroundColor);
dest.writeString(text);
dest.writeString(composeHint);
CharacterCalculator.writeToParcel(dest, characterCalculator);
TextUtils.writeToParcel(simName.orElse(null), dest, flags);
if (simSubscriptionId.isPresent()) {
dest.writeInt(1);
dest.writeInt(simSubscriptionId.get());
} else {
dest.writeInt(0);
}
}
public static final Creator<TransportOption> CREATOR = new Creator<TransportOption>() {
@Override
public TransportOption createFromParcel(Parcel in) {
return new TransportOption(in);
}
@Override
public TransportOption[] newArray(int size) {
return new TransportOption[size];
}
};
}

View File

@@ -1,226 +0,0 @@
package org.thoughtcrime.securesms;
import android.Manifest;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.util.CharacterCalculator;
import org.thoughtcrime.securesms.util.MmsCharacterCalculator;
import org.thoughtcrime.securesms.util.PushCharacterCalculator;
import org.thoughtcrime.securesms.util.SmsCharacterCalculator;
import org.thoughtcrime.securesms.util.dualsim.SubscriptionInfoCompat;
import org.thoughtcrime.securesms.util.dualsim.SubscriptionManagerCompat;
import org.whispersystems.signalservice.api.util.OptionalUtil;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Optional;
import static org.thoughtcrime.securesms.TransportOption.Type;
public class TransportOptions {
private static final String TAG = Log.tag(TransportOptions.class);
private final List<OnTransportChangedListener> listeners = new LinkedList<>();
private final Context context;
private final List<TransportOption> enabledTransports;
private Type defaultTransportType = Type.SMS;
private Optional<Integer> defaultSubscriptionId = Optional.empty();
private Optional<TransportOption> selectedOption = Optional.empty();
private final Optional<Integer> systemSubscriptionId;
public TransportOptions(Context context, boolean media) {
this.context = context;
this.enabledTransports = initializeAvailableTransports(media);
this.systemSubscriptionId = new SubscriptionManagerCompat(context).getPreferredSubscriptionId();
}
public void reset(boolean media) {
List<TransportOption> transportOptions = initializeAvailableTransports(media);
this.enabledTransports.clear();
this.enabledTransports.addAll(transportOptions);
if (selectedOption.isPresent() && !isEnabled(selectedOption.get())) {
setSelectedTransport(null);
} else {
this.defaultTransportType = Type.SMS;
this.defaultSubscriptionId = Optional.empty();
notifyTransportChangeListeners();
}
}
public void setDefaultTransport(Type type) {
this.defaultTransportType = type;
if (!selectedOption.isPresent()) {
notifyTransportChangeListeners();
}
}
public void setDefaultSubscriptionId(Optional<Integer> subscriptionId) {
if (defaultSubscriptionId.equals(subscriptionId)) {
return;
}
this.defaultSubscriptionId = subscriptionId;
if (!selectedOption.isPresent()) {
notifyTransportChangeListeners();
}
}
public void setSelectedTransport(@Nullable TransportOption transportOption) {
this.selectedOption = Optional.ofNullable(transportOption);
notifyTransportChangeListeners();
}
public boolean isManualSelection() {
return this.selectedOption.isPresent();
}
public @NonNull TransportOption getSelectedTransport() {
if (selectedOption.isPresent()) return selectedOption.get();
if (defaultTransportType == Type.SMS) {
TransportOption transportOption = findEnabledSmsTransportOption(OptionalUtil.or(defaultSubscriptionId, systemSubscriptionId));
if (transportOption != null) {
return transportOption;
}
}
for (TransportOption transportOption : enabledTransports) {
if (transportOption.getType() == defaultTransportType) {
return transportOption;
}
}
throw new AssertionError("No options of default type!");
}
public static @NonNull TransportOption getPushTransportOption(@NonNull Context context) {
return new TransportOption(Type.TEXTSECURE,
R.drawable.ic_send_lock_24,
context.getResources().getColor(R.color.core_ultramarine),
context.getString(R.string.ConversationActivity_transport_signal),
context.getString(R.string.conversation_activity__type_message_push),
new PushCharacterCalculator());
}
private @Nullable TransportOption findEnabledSmsTransportOption(Optional<Integer> subscriptionId) {
if (subscriptionId.isPresent()) {
final int subId = subscriptionId.get();
for (TransportOption transportOption : enabledTransports) {
if (transportOption.getType() == Type.SMS &&
subId == transportOption.getSimSubscriptionId().orElse(-1)) {
return transportOption;
}
}
}
return null;
}
public void disableTransport(Type type) {
TransportOption selected = selectedOption.orElse(null);
Iterator<TransportOption> iterator = enabledTransports.iterator();
while (iterator.hasNext()) {
TransportOption option = iterator.next();
if (option.isType(type)) {
if (selected == option) {
setSelectedTransport(null);
}
iterator.remove();
}
}
}
public List<TransportOption> getEnabledTransports() {
return enabledTransports;
}
public void addOnTransportChangedListener(OnTransportChangedListener listener) {
this.listeners.add(listener);
}
private List<TransportOption> initializeAvailableTransports(boolean isMediaMessage) {
List<TransportOption> results = new LinkedList<>();
if (isMediaMessage) {
results.addAll(getTransportOptionsForSimCards(context.getString(R.string.ConversationActivity_transport_insecure_mms),
context.getString(R.string.conversation_activity__type_message_mms_insecure),
new MmsCharacterCalculator()));
} else {
results.addAll(getTransportOptionsForSimCards(context.getString(R.string.ConversationActivity_transport_insecure_sms),
context.getString(R.string.conversation_activity__type_message_sms_insecure),
new SmsCharacterCalculator()));
}
results.add(getPushTransportOption(context));
return results;
}
private @NonNull List<TransportOption> getTransportOptionsForSimCards(@NonNull String text,
@NonNull String composeHint,
@NonNull CharacterCalculator characterCalculator)
{
List<TransportOption> results = new LinkedList<>();
SubscriptionManagerCompat subscriptionManager = new SubscriptionManagerCompat(context);
Collection<SubscriptionInfoCompat> subscriptions;
if (Permissions.hasAll(context, Manifest.permission.READ_PHONE_STATE)) {
subscriptions = subscriptionManager.getActiveAndReadySubscriptionInfos();
} else {
subscriptions = Collections.emptyList();
}
if (subscriptions.size() < 2) {
results.add(new TransportOption(Type.SMS, R.drawable.ic_send_unlock_24,
context.getResources().getColor(R.color.core_grey_50),
text, composeHint, characterCalculator));
} else {
for (SubscriptionInfoCompat subscriptionInfo : subscriptions) {
results.add(new TransportOption(Type.SMS, R.drawable.ic_send_unlock_24,
context.getResources().getColor(R.color.core_grey_50),
text, composeHint, characterCalculator,
Optional.of(subscriptionInfo.getDisplayName()),
Optional.of(subscriptionInfo.getSubscriptionId())));
}
}
return results;
}
private void notifyTransportChangeListeners() {
for (OnTransportChangedListener listener : listeners) {
listener.onChange(getSelectedTransport(), selectedOption.isPresent());
}
}
private boolean isEnabled(TransportOption transportOption) {
for (TransportOption option : enabledTransports) {
if (option.equals(transportOption)) return true;
}
return false;
}
public interface OnTransportChangedListener {
public void onChange(TransportOption newTransport, boolean manuallySelected);
}
}

View File

@@ -1,73 +0,0 @@
package org.thoughtcrime.securesms;
import android.content.Context;
import android.graphics.PorterDuff.Mode;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import java.util.List;
public class TransportOptionsAdapter extends BaseAdapter {
private final LayoutInflater inflater;
private List<TransportOption> enabledTransports;
public TransportOptionsAdapter(@NonNull Context context,
@NonNull List<TransportOption> enabledTransports)
{
super();
this.inflater = LayoutInflater.from(context);
this.enabledTransports = enabledTransports;
}
public void setEnabledTransports(List<TransportOption> enabledTransports) {
this.enabledTransports = enabledTransports;
}
@Override
public int getCount() {
return enabledTransports.size();
}
@Override
public Object getItem(int position) {
return enabledTransports.get(position);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
if (convertView == null) {
convertView = inflater.inflate(R.layout.transport_selection_list_item, parent, false);
}
TransportOption transport = (TransportOption) getItem(position);
ImageView imageView = convertView.findViewById(R.id.icon);
TextView textView = convertView.findViewById(R.id.text);
TextView subtextView = convertView.findViewById(R.id.subtext);
imageView.getBackground().setColorFilter(transport.getBackgroundColor(), Mode.MULTIPLY);
imageView.setImageResource(transport.getDrawable());
textView.setText(transport.getDescription());
if (transport.getSimName().isPresent()) {
subtextView.setText(transport.getSimName().get());
subtextView.setVisibility(View.VISIBLE);
} else {
subtextView.setVisibility(View.GONE);
}
return convertView;
}
}

View File

@@ -1,50 +0,0 @@
package org.thoughtcrime.securesms;
import android.content.Context;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ListView;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.ListPopupWindow;
import java.util.LinkedList;
import java.util.List;
public class TransportOptionsPopup extends ListPopupWindow implements ListView.OnItemClickListener {
private final TransportOptionsAdapter adapter;
private final SelectedListener listener;
public TransportOptionsPopup(@NonNull Context context, @NonNull View anchor, @NonNull SelectedListener listener) {
super(context);
this.listener = listener;
this.adapter = new TransportOptionsAdapter(context, new LinkedList<TransportOption>());
setVerticalOffset(context.getResources().getDimensionPixelOffset(R.dimen.transport_selection_popup_yoff));
setHorizontalOffset(context.getResources().getDimensionPixelOffset(R.dimen.transport_selection_popup_xoff));
setInputMethodMode(ListPopupWindow.INPUT_METHOD_NOT_NEEDED);
setModal(true);
setAnchorView(anchor);
setAdapter(adapter);
setContentWidth(context.getResources().getDimensionPixelSize(R.dimen.transport_selection_popup_width));
setOnItemClickListener(this);
}
public void display(List<TransportOption> enabledTransports) {
adapter.setEnabledTransports(enabledTransports);
adapter.notifyDataSetChanged();
show();
}
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
listener.onSelected((TransportOption)adapter.getItem(position));
}
public interface SelectedListener {
void onSelected(TransportOption option);
}
}

View File

@@ -39,11 +39,13 @@ import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.content.ContextCompat;
import androidx.core.util.Consumer;
import androidx.lifecycle.ViewModelProviders;
import androidx.lifecycle.ViewModelProvider;
import androidx.window.DisplayFeature;
import androidx.window.FoldingFeature;
import androidx.window.WindowLayoutInfo;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
@@ -112,6 +114,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private TooltipPopup videoTooltip;
private WebRtcCallViewModel viewModel;
private boolean enableVideoIfAvailable;
private boolean hasWarnedAboutBluetooth;
private androidx.window.WindowManager windowManager;
private WindowLayoutInfoConsumer windowLayoutInfoConsumer;
private ThrottledDebouncer requestNewSizesThrottle;
@@ -167,9 +170,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
ephemeralStateDisposable = ApplicationDependencies.getSignalCallManager()
.ephemeralStates()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(state -> {
viewModel.updateFromEphemeralState(state);
});
.subscribe(viewModel::updateFromEphemeralState);
}
@Override
@@ -306,7 +307,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
WebRtcCallViewModel.Factory factory = new WebRtcCallViewModel.Factory(deviceOrientationMonitor);
viewModel = ViewModelProviders.of(this, factory).get(WebRtcCallViewModel.class);
viewModel = new ViewModelProvider(this, factory).get(WebRtcCallViewModel.class);
viewModel.setIsLandscapeEnabled(isLandscapeEnabled);
viewModel.setIsInPipMode(isInPipMode());
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
@@ -688,6 +689,17 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
enableVideoIfAvailable = false;
handleSetMuteVideo(false);
}
if (event.getBluetoothPermissionDenied() && !hasWarnedAboutBluetooth && !isFinishing()) {
new MaterialAlertDialogBuilder(this)
.setTitle(R.string.WebRtcCallActivity__bluetooth_permission_denied)
.setMessage(R.string.WebRtcCallActivity__please_enable_the_nearby_devices_permission_to_use_bluetooth_during_a_call)
.setPositiveButton(R.string.WebRtcCallActivity__open_settings, (d, w) -> startActivity(Permissions.getApplicationSettingsIntent(this)))
.setNegativeButton(R.string.WebRtcCallActivity__not_now, null)
.show();
hasWarnedAboutBluetooth = true;
}
}
private void handleCallPreJoin(@NonNull WebRtcViewModel event) {

View File

@@ -81,14 +81,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
}
clearButton.visible = state.canClear
val wasEnabled = saveButton.isEnabled
saveButton.isEnabled = state.canSave
if (wasEnabled != state.canSave) {
val alpha = if (state.canSave) 1f else 0.5f
saveButton.animate().cancel()
saveButton.animate().alpha(alpha)
}
saveButton.isClickable = state.canSave
val items = state.selectableAvatars.map { AvatarPickerItem.Model(it, it == state.currentAvatar) }
val selectedPosition = items.indexOfFirst { it.isSelected }
@@ -104,6 +97,11 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
photoButton.setOnIconClickedListener { openGallery() }
textButton.setOnIconClickedListener { openTextEditor(null) }
saveButton.setOnClickListener { v ->
if (!saveButton.isEnabled) {
return@setOnClickListener
}
saveButton.isEnabled = false
viewModel.save(
{
setFragmentResult(

View File

@@ -183,7 +183,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
private val isNewGroup: Boolean,
private val groupAvatarMedia: Media?
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val viewModel = if (groupId == null && !isNewGroup) {
SelfAvatarPickerViewModel(repository)
} else if (groupId == null) {

View File

@@ -33,7 +33,7 @@ class TextAvatarCreationViewModel(initialText: Avatar.Text) : ViewModel() {
}
class Factory(private val initialText: Avatar.Text) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(TextAvatarCreationViewModel(initialText)))
}
}

View File

@@ -20,7 +20,7 @@ class VectorAvatarCreationViewModel(initialAvatar: Avatar.Vector) : ViewModel()
fun getCurrentAvatar() = store.state.currentAvatar
class Factory(private val initialAvatar: Avatar.Vector) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(VectorAvatarCreationViewModel(initialAvatar)))
}
}

View File

@@ -159,12 +159,12 @@ public class BackupDialog {
public static void showVerifyBackupPassphraseDialog(@NonNull Context context) {
View view = LayoutInflater.from(context).inflate(R.layout.enter_backup_passphrase_dialog, null);
EditText prompt = view.findViewById(R.id.restore_passphrase_input);
AlertDialog dialog = new AlertDialog.Builder(context)
.setTitle(R.string.BackupDialog_enter_backup_passphrase_to_verify)
.setView(view)
.setPositiveButton(R.string.BackupDialog_verify, null)
.setNegativeButton(android.R.string.cancel, null)
.show();
AlertDialog dialog = new MaterialAlertDialogBuilder(context)
.setTitle(R.string.BackupDialog_enter_backup_passphrase_to_verify)
.setView(view)
.setPositiveButton(R.string.BackupDialog_verify, null)
.setNegativeButton(android.R.string.cancel, null)
.show();
Button positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
positiveButton.setEnabled(false);

View File

@@ -197,7 +197,9 @@ public class FullBackupExporter extends FullBackupBase {
throwIfCanceled(cancellationSignal);
if (avatar != null) {
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(avatar.getFilename(), avatar.getInputStream(), avatar.getLength());
try (InputStream inputStream = avatar.getInputStream()) {
outputStream.write(avatar.getFilename(), inputStream, avatar.getLength());
}
}
}
@@ -377,6 +379,7 @@ public class FullBackupExporter extends FullBackupBase {
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(new AttachmentId(rowId, uniqueId), inputStream, size);
inputStream.close();
}
} catch (IOException e) {
Log.w(TAG, e);
@@ -395,8 +398,9 @@ public class FullBackupExporter extends FullBackupBase {
if (!TextUtils.isEmpty(data) && size > 0) {
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
outputStream.writeSticker(rowId, inputStream, size);
try (InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0)) {
outputStream.writeSticker(rowId, inputStream, size);
}
}
} catch (IOException e) {
Log.w(TAG, e);

View File

@@ -8,9 +8,12 @@ import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.glide.GiftBadgeModel
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.ScreenDensity
import org.thoughtcrime.securesms.util.ThemeUtil
class BadgeImageView @JvmOverloads constructor(
@@ -77,6 +80,20 @@ class BadgeImageView @JvmOverloads constructor(
}
}
fun setGiftBadge(badge: GiftBadge?, glideRequests: GlideRequests) {
if (badge != null) {
glideRequests
.load(GiftBadgeModel(badge))
.downsample(DownsampleStrategy.NONE)
.transform(BadgeSpriteTransformation(BadgeSpriteTransformation.Size.fromInteger(badgeSize), ScreenDensity.getBestDensityBucketForDevice(), ThemeUtil.isDarkTheme(context)))
.into(this)
} else {
glideRequests
.clear(this)
clearDrawable()
}
}
private fun clearDrawable() {
setImageDrawable(null)
isClickable = false

View File

@@ -1,41 +1,83 @@
package org.thoughtcrime.securesms.badges
import android.content.Context
import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.ProfileUtil
import java.io.IOException
class BadgeRepository(context: Context) {
companion object {
private val TAG = Log.tag(BadgeRepository::class.java)
}
private val context = context.applicationContext
/**
* Sets the visibility for each badge on a user's profile, and uploads them to the server.
* Does not write to the local database. The caller must either do that themselves or schedule
* a refresh own profile job.
*
* @return A list of the badges, properly modified to either visible or not visible, according to user preferences.
*/
@Throws(IOException::class)
@WorkerThread
fun setVisibilityForAllBadgesSync(
displayBadgesOnProfile: Boolean,
selfBadges: List<Badge>
): List<Badge> {
Log.d(TAG, "[setVisibilityForAllBadgesSync] Setting badge visibility...", true)
val recipientDatabase: RecipientDatabase = SignalDatabase.recipients
val badges = selfBadges.map { it.copy(visible = displayBadgesOnProfile) }
Log.d(TAG, "[setVisibilityForAllBadgesSync] Uploading profile...", true)
ProfileUtil.uploadProfileWithBadges(context, badges)
SignalStore.donationsValues().setDisplayBadgesOnProfile(displayBadgesOnProfile)
recipientDatabase.markNeedsSync(Recipient.self().id)
Log.d(TAG, "[setVisibilityForAllBadgesSync] Requesting data change sync...", true)
StorageSyncHelper.scheduleSyncForDataChange()
return badges
}
fun setVisibilityForAllBadges(
displayBadgesOnProfile: Boolean,
selfBadges: List<Badge> = Recipient.self().badges
): Completable = Completable.fromAction {
val recipientDatabase: RecipientDatabase = SignalDatabase.recipients
val badges = selfBadges.map { it.copy(visible = displayBadgesOnProfile) }
setVisibilityForAllBadgesSync(displayBadgesOnProfile, selfBadges)
ProfileUtil.uploadProfileWithBadges(context, badges)
SignalStore.donationsValues().setDisplayBadgesOnProfile(displayBadgesOnProfile)
recipientDatabase.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
recipientDatabase.setBadges(Recipient.self().id, badges)
Log.d(TAG, "[setVisibilityForAllBadges] Enqueueing profile refresh...", true)
ApplicationDependencies.getJobManager()
.startChain(RefreshOwnProfileJob())
.then(MultiDeviceProfileContentUpdateJob())
.enqueue()
}.subscribeOn(Schedulers.io())
fun setFeaturedBadge(featuredBadge: Badge): Completable = Completable.fromAction {
val badges = Recipient.self().badges
val reOrderedBadges = listOf(featuredBadge.copy(visible = true)) + (badges.filterNot { it.id == featuredBadge.id })
Log.d(TAG, "[setFeaturedBadge] Uploading profile with reordered badges...", true)
ProfileUtil.uploadProfileWithBadges(context, reOrderedBadges)
val recipientDatabase: RecipientDatabase = SignalDatabase.recipients
recipientDatabase.setBadges(Recipient.self().id, reOrderedBadges)
Log.d(TAG, "[setFeaturedBadge] Enqueueing profile refresh...", true)
ApplicationDependencies.getJobManager()
.startChain(RefreshOwnProfileJob())
.then(MultiDeviceProfileContentUpdateJob())
.enqueue()
}.subscribeOn(Schedulers.io())
}

View File

@@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.util.ScreenDensity
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import java.math.BigDecimal
import java.sql.Timestamp
import java.util.concurrent.TimeUnit
object Badges {
@@ -93,7 +94,8 @@ object Badges {
Uri.parse(badge.imageUrl),
badge.imageDensity,
badge.expiration,
badge.visible
badge.visible,
0L
)
}
@@ -122,7 +124,8 @@ object Badges {
uriAndDensity.first(),
uriAndDensity.second(),
serviceBadge.expiration?.let { getTimestamp(it) } ?: 0,
serviceBadge.isVisible
serviceBadge.isVisible,
TimeUnit.SECONDS.toMillis(serviceBadge.duration)
)
}
}

View File

@@ -0,0 +1,53 @@
package org.thoughtcrime.securesms.badges.gifts
import android.os.Bundle
import androidx.fragment.app.FragmentManager
import org.thoughtcrime.securesms.badges.gifts.ExpiredGiftSheetConfiguration.forExpiredBadge
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
/**
* Displays expired gift information and gives the user the option to start a recurring monthly donation.
*/
class ExpiredGiftSheet : DSLSettingsBottomSheetFragment() {
companion object {
private const val ARG_BADGE = "arg.badge"
fun show(fragmentManager: FragmentManager, badge: Badge) {
ExpiredGiftSheet().apply {
arguments = Bundle().apply {
putParcelable(ARG_BADGE, badge)
}
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
private val badge: Badge
get() = requireArguments().getParcelable(ARG_BADGE)!!
override fun bindAdapter(adapter: DSLSettingsAdapter) {
ExpiredGiftSheetConfiguration.register(adapter)
adapter.submitList(
configure {
forExpiredBadge(
badge = badge,
onMakeAMonthlyDonation = {
requireListener<Callback>().onMakeAMonthlyDonation()
},
onNotNow = {
dismissAllowingStateLoss()
}
)
}.toMappingModelList()
)
}
interface Callback {
fun onMakeAMonthlyDonation()
}
}

View File

@@ -0,0 +1,81 @@
package org.thoughtcrime.securesms.badges.gifts
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
/**
* Contains shared DSL layout for expired gifts, creatable using a GiftBadge or a Badge.
*/
object ExpiredGiftSheetConfiguration {
fun register(mappingAdapter: MappingAdapter) {
BadgeDisplay112.register(mappingAdapter)
}
fun DSLConfiguration.forExpiredBadge(badge: Badge, onMakeAMonthlyDonation: () -> Unit, onNotNow: () -> Unit) {
customPref(BadgeDisplay112.Model(badge, withDisplayText = false))
expiredSheet(onMakeAMonthlyDonation, onNotNow)
}
fun DSLConfiguration.forExpiredGiftBadge(giftBadge: GiftBadge, onMakeAMonthlyDonation: () -> Unit, onNotNow: () -> Unit) {
customPref(BadgeDisplay112.GiftModel(giftBadge))
expiredSheet(onMakeAMonthlyDonation, onNotNow)
}
private fun DSLConfiguration.expiredSheet(onMakeAMonthlyDonation: () -> Unit, onNotNow: () -> Unit) {
textPref(
title = DSLSettingsText.from(
stringId = R.string.ExpiredGiftSheetConfiguration__your_gift_badge_has_expired,
DSLSettingsText.CenterModifier, DSLSettingsText.TitleLargeModifier
)
)
textPref(
title = DSLSettingsText.from(
stringId = R.string.ExpiredGiftSheetConfiguration__your_gift_badge_has_expired_and_is,
DSLSettingsText.CenterModifier
)
)
if (SignalStore.donationsValues().isLikelyASustainer()) {
primaryButton(
text = DSLSettingsText.from(
stringId = android.R.string.ok
),
onClick = {
onNotNow()
}
)
} else {
textPref(
title = DSLSettingsText.from(
stringId = R.string.ExpiredGiftSheetConfiguration__to_continue,
DSLSettingsText.CenterModifier
)
)
primaryButton(
text = DSLSettingsText.from(
stringId = R.string.ExpiredGiftSheetConfiguration__make_a_monthly_donation
),
onClick = {
onMakeAMonthlyDonation()
}
)
secondaryButtonNoOutline(
text = DSLSettingsText.from(
stringId = R.string.ExpiredGiftSheetConfiguration__not_now
),
onClick = {
onNotNow()
}
)
}
}
}

View File

@@ -0,0 +1,111 @@
package org.thoughtcrime.securesms.badges.gifts
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.util.AttributeSet
import android.widget.FrameLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.content.res.use
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import com.google.android.material.button.MaterialButton
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.badges.gifts.Gifts.formatExpiry
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.mms.GlideRequests
/**
* Displays a gift badge sent to or received from a user, and allows the user to
* perform an action based off the badge's redemption state.
*/
class GiftMessageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
init {
inflate(context, R.layout.gift_message_view, this)
}
private val badgeView: BadgeImageView = findViewById(R.id.gift_message_view_badge)
private val titleView: TextView = findViewById(R.id.gift_message_view_title)
private val descriptionView: TextView = findViewById(R.id.gift_message_view_description)
private val actionView: MaterialButton = findViewById(R.id.gift_message_view_action)
init {
context.obtainStyledAttributes(attrs, R.styleable.GiftMessageView).use {
val textColor = it.getColor(R.styleable.GiftMessageView_giftMessageView__textColor, Color.RED)
titleView.setTextColor(textColor)
descriptionView.setTextColor(textColor)
val buttonTextColor = it.getColor(R.styleable.GiftMessageView_giftMessageView__buttonTextColor, Color.RED)
actionView.setTextColor(buttonTextColor)
actionView.iconTint = ColorStateList.valueOf(buttonTextColor)
val buttonBackgroundTint = it.getColor(R.styleable.GiftMessageView_giftMessageView__buttonBackgroundTint, Color.RED)
actionView.backgroundTintList = ColorStateList.valueOf(buttonBackgroundTint)
}
}
fun setGiftBadge(glideRequests: GlideRequests, giftBadge: GiftBadge, isOutgoing: Boolean, callback: Callback) {
titleView.setText(R.string.GiftMessageView__gift_badge)
descriptionView.text = giftBadge.formatExpiry(context)
actionView.icon = null
actionView.setOnClickListener { callback.onViewGiftBadgeClicked() }
actionView.isEnabled = true
if (isOutgoing) {
actionView.setText(R.string.GiftMessageView__view)
} else {
when (giftBadge.redemptionState) {
GiftBadge.RedemptionState.REDEEMED -> {
stopAnimationIfNeeded()
actionView.setIconResource(R.drawable.ic_check_circle_24)
}
GiftBadge.RedemptionState.STARTED -> actionView.icon = CircularProgressDrawable(context).apply {
actionView.isEnabled = false
setColorSchemeColors(ContextCompat.getColor(context, R.color.core_ultramarine))
strokeWidth = DimensionUnit.DP.toPixels(2f)
start()
}
else -> {
stopAnimationIfNeeded()
actionView.icon = null
}
}
actionView.setText(
when (giftBadge.redemptionState ?: GiftBadge.RedemptionState.UNRECOGNIZED) {
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
}
)
}
badgeView.setGiftBadge(giftBadge, glideRequests)
}
fun onGiftNotOpened() {
actionView.isClickable = false
}
fun onGiftOpened() {
actionView.isClickable = true
}
private fun stopAnimationIfNeeded() {
val icon = actionView.icon
if (icon is CircularProgressDrawable) {
icon.stop()
}
}
interface Callback {
fun onViewGiftBadgeClicked()
}
}

View File

@@ -0,0 +1,86 @@
package org.thoughtcrime.securesms.badges.gifts
import android.content.Context
import org.signal.libsignal.zkgroup.InvalidInputException
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.Base64
import java.lang.Integer.min
import java.util.concurrent.TimeUnit
/**
* Helper object for Gift badges
*/
object Gifts {
/**
* Request Code for getting token from Google Pay
*/
const val GOOGLE_PAY_REQUEST_CODE = 3000
/**
* Creates an OutgoingSecureMediaMessage which contains the given gift badge.
*/
fun createOutgoingGiftMessage(
recipient: Recipient,
giftBadge: GiftBadge,
sentTimestamp: Long,
expiresIn: Long
): OutgoingMediaMessage {
return OutgoingSecureMediaMessage(
recipient,
Base64.encodeBytes(giftBadge.toByteArray()),
listOf(),
sentTimestamp,
ThreadDatabase.DistributionTypes.CONVERSATION,
expiresIn,
false,
StoryType.NONE,
null,
false,
null,
listOf(),
listOf(),
listOf(),
giftBadge
)
}
/**
* @return the expiration time from the redemption token, in UNIX epoch seconds.
*/
private fun GiftBadge.getExpiry(): Long {
return try {
ReceiptCredentialPresentation(redemptionToken.toByteArray()).receiptExpirationTime
} catch (e: InvalidInputException) {
return 0L
}
}
fun GiftBadge.formatExpiry(context: Context): String {
val expiry = getExpiry()
val timeRemaining = TimeUnit.SECONDS.toMillis(expiry) - System.currentTimeMillis()
if (timeRemaining <= 0) {
return context.getString(R.string.Gifts__expired)
}
val days = TimeUnit.MILLISECONDS.toDays(timeRemaining).toInt()
if (days > 0) {
return context.resources.getQuantityString(R.plurals.Gifts__d_days_remaining, days, days)
}
val hours = TimeUnit.MILLISECONDS.toHours(timeRemaining).toInt()
if (hours > 0) {
return context.resources.getQuantityString(R.plurals.Gifts__d_hours_remaining, hours, hours)
}
val minutes = min(1, TimeUnit.MILLISECONDS.toMinutes(timeRemaining).toInt())
return context.resources.getQuantityString(R.plurals.Gifts__d_minutes_remaining, minutes, minutes)
}
}

View File

@@ -0,0 +1,28 @@
package org.thoughtcrime.securesms.badges.gifts
import org.thoughtcrime.securesms.util.Projection
/**
* Notes that a given item can have a gift box drawn over it.
*/
interface OpenableGift {
/**
* Returns a projection to draw a top, or null to not do so.
*/
fun getOpenableGiftProjection(isAnimating: Boolean): Projection?
/**
* Returns a unique id assosicated with this gift.
*/
fun getGiftId(): Long
/**
* Registers a callback to start the open animation
*/
fun setOpenGiftCallback(openGift: (OpenableGift) -> Unit)
/**
* Clears any callback created to start the open animation
*/
fun clearOpenGiftCallback()
}

View File

@@ -0,0 +1,255 @@
package org.thoughtcrime.securesms.badges.gifts
import android.animation.FloatEvaluator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.provider.Settings
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.AnticipateInterpolator
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.core.graphics.toRect
import androidx.core.graphics.withSave
import androidx.core.graphics.withTranslation
import androidx.core.view.children
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.Projection
import kotlin.math.PI
import kotlin.math.max
import kotlin.math.sin
/**
* Controls the gift box top and related animations for Gift bubbles.
*/
class OpenableGiftItemDecoration(context: Context) : RecyclerView.ItemDecoration(), DefaultLifecycleObserver {
private val animatorDurationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f)
private val messageIdsShakenThisSession = mutableSetOf<Long>()
private val messageIdsOpenedThisSession = mutableSetOf<Long>()
private val animationState = mutableMapOf<Long, GiftAnimationState>()
private val rect = RectF()
private val lineWidth = DimensionUnit.DP.toPixels(16f).toInt()
private val boxPaint = Paint().apply {
isAntiAlias = true
color = ContextCompat.getColor(context, R.color.core_ultramarine)
}
private val bowPaint = Paint().apply {
isAntiAlias = true
color = Color.WHITE
}
private val bowWidth = DimensionUnit.DP.toPixels(80f)
private val bowHeight = DimensionUnit.DP.toPixels(60f)
private val bowDrawable: Drawable = AppCompatResources.getDrawable(context, R.drawable.ic_gift_bow)!!
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
animationState.clear()
}
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
var needsInvalidation = false
val openableChildren = parent.children.filterIsInstance(OpenableGift::class.java)
val deadKeys = animationState.keys.filterNot { giftId -> openableChildren.any { it.getGiftId() == giftId } }
deadKeys.forEach {
animationState.remove(it)
}
val notAnimated = openableChildren.filterNot { animationState.containsKey(it.getGiftId()) }
notAnimated.filterNot { messageIdsOpenedThisSession.contains(it.getGiftId()) }.forEach { child ->
val projection = child.getOpenableGiftProjection(false)
if (projection != null) {
child.setOpenGiftCallback {
child.clearOpenGiftCallback()
val proj = it.getOpenableGiftProjection(true)
if (proj != null) {
messageIdsOpenedThisSession.add(it.getGiftId())
startOpenAnimation(it)
parent.invalidate()
}
}
if (messageIdsShakenThisSession.contains(child.getGiftId())) {
drawGiftBox(c, projection)
drawGiftBow(c, projection)
} else {
messageIdsShakenThisSession.add(child.getGiftId())
startShakeAnimation(child)
drawGiftBox(c, projection)
drawGiftBow(c, projection)
needsInvalidation = true
}
projection.release()
}
}
openableChildren.filter { animationState.containsKey(it.getGiftId()) }.forEach { child ->
val runningAnimation = animationState[child.getGiftId()]!!
c.withSave {
val isThisAnimationRunning = runningAnimation.update(
animatorDurationScale = animatorDurationScale,
canvas = c,
drawBox = this@OpenableGiftItemDecoration::drawGiftBox,
drawBow = this@OpenableGiftItemDecoration::drawGiftBow
)
if (!isThisAnimationRunning) {
animationState.remove(child.getGiftId())
}
needsInvalidation = true
}
}
if (needsInvalidation) {
parent.invalidate()
}
}
private fun drawGiftBox(canvas: Canvas, projection: Projection) {
canvas.drawPath(projection.path, boxPaint)
rect.set(
projection.x + (projection.width / 2) - lineWidth / 2,
projection.y,
projection.x + (projection.width / 2) + lineWidth / 2,
projection.y + projection.height
)
canvas.drawRect(rect, bowPaint)
rect.set(
projection.x,
projection.y + (projection.height / 2) - lineWidth / 2,
projection.x + projection.width,
projection.y + (projection.height / 2) + lineWidth / 2
)
canvas.drawRect(rect, bowPaint)
}
private fun drawGiftBow(canvas: Canvas, projection: Projection) {
rect.set(
projection.x + (projection.width / 2) - (bowWidth / 2),
projection.y,
projection.x + (projection.width / 2) + (bowWidth / 2),
projection.y + bowHeight
)
val padTop = (projection.height - rect.height()) * (48f / 89f)
bowDrawable.bounds = rect.toRect()
canvas.withTranslation(y = padTop) {
bowDrawable.draw(canvas)
}
}
private fun startShakeAnimation(child: OpenableGift) {
animationState[child.getGiftId()] = GiftAnimationState.ShakeAnimationState(child, System.currentTimeMillis())
}
private fun startOpenAnimation(child: OpenableGift) {
animationState[child.getGiftId()] = GiftAnimationState.OpenAnimationState(child, System.currentTimeMillis())
}
sealed class GiftAnimationState(val openableGift: OpenableGift, val startTime: Long, val duration: Long) {
/**
* Shakes the gift box to the left and right, slightly revealing the contents underneath.
* Uses a lag value to keep the bow one "frame" behind the box, to give it the effect of
* following behind.
*/
class ShakeAnimationState(openableGift: OpenableGift, startTime: Long) : GiftAnimationState(openableGift, startTime, SHAKE_DURATION_MILLIS) {
override fun update(canvas: Canvas, projection: Projection, progress: Float, lastFrameProgress: Float, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit) {
canvas.withTranslation(x = getTranslation(progress).toFloat()) {
drawBox(canvas, projection)
}
canvas.withTranslation(x = getTranslation(lastFrameProgress).toFloat()) {
drawBow(canvas, projection)
}
}
private fun getTranslation(progress: Float): Double {
val interpolated = INTERPOLATOR.getInterpolation(progress)
val evaluated = EVALUATOR.evaluate(interpolated, 0f, 360f)
return 0.25f * sin(4 * evaluated * PI / 180f) * 180f / PI
}
}
class OpenAnimationState(openableGift: OpenableGift, startTime: Long) : GiftAnimationState(openableGift, startTime, OPEN_DURATION_MILLIS) {
override fun update(canvas: Canvas, projection: Projection, progress: Float, lastFrameProgress: Float, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit) {
val interpolatedProgress = INTERPOLATOR.getInterpolation(progress)
val evaluatedValue = EVALUATOR.evaluate(interpolatedProgress, 0f, DimensionUnit.DP.toPixels(161f))
val interpolatedY = TRANSLATION_Y_INTERPOLATOR.getInterpolation(progress)
val evaluatedY = EVALUATOR.evaluate(interpolatedY, 0f, DimensionUnit.DP.toPixels(355f))
canvas.translate(evaluatedValue, evaluatedY)
drawBox(canvas, projection)
drawBow(canvas, projection)
}
}
fun update(animatorDurationScale: Float, canvas: Canvas, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit): Boolean {
val projection = openableGift.getOpenableGiftProjection(true) ?: return false
if (animatorDurationScale <= 0f) {
update(canvas, projection, 0f, 0f, drawBox, drawBow)
projection.release()
return false
}
val currentFrameTime = System.currentTimeMillis()
val lastFrameProgress = max(0f, (currentFrameTime - startTime - ONE_FRAME_RELATIVE_TO_30_FPS_MILLIS) / (duration.toFloat() * animatorDurationScale))
val progress = (currentFrameTime - startTime) / (duration.toFloat() * animatorDurationScale)
if (progress > 1f) {
update(canvas, projection, 1f, 1f, drawBox, drawBow)
projection.release()
return false
}
update(canvas, projection, progress, lastFrameProgress, drawBox, drawBow)
projection.release()
return true
}
protected abstract fun update(
canvas: Canvas,
projection: Projection,
progress: Float,
lastFrameProgress: Float,
drawBox: (Canvas, Projection) -> Unit,
drawBow: (Canvas, Projection) -> Unit
)
}
companion object {
private val TRANSLATION_Y_INTERPOLATOR = AnticipateInterpolator(3f)
private val INTERPOLATOR = AccelerateDecelerateInterpolator()
private val EVALUATOR = FloatEvaluator()
private const val SHAKE_DURATION_MILLIS = 1000L
private const val OPEN_DURATION_MILLIS = 700L
private const val ONE_FRAME_RELATIVE_TO_30_FPS_MILLIS = 33
}
}

View File

@@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import org.signal.core.util.money.FiatMoney
/**
* Convenience wrapper for a gift at a particular price point.
*/
data class Gift(val level: Long, val price: FiatMoney)

View File

@@ -0,0 +1,46 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import android.content.Intent
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.Fragment
import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
/**
* Activity which houses the gift flow.
*/
class GiftFlowActivity : FragmentWrapperActivity(), DonationPaymentComponent {
override val donationPaymentRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
override val googlePayResultPublisher: Subject<DonationPaymentComponent.GooglePayResult> = PublishSubject.create()
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
onBackPressedDispatcher.addCallback(this, OnBackPressed())
}
override fun getFragment(): Fragment {
return NavHostFragment.create(R.navigation.gift_flow)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
googlePayResultPublisher.onNext(DonationPaymentComponent.GooglePayResult(requestCode, resultCode, data))
}
private inner class OnBackPressed : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (!findNavController(R.id.fragment_container).popBackStack()) {
finish()
}
}
}
}

View File

@@ -0,0 +1,279 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import android.content.DialogInterface
import android.view.KeyEvent
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.InputAwareLayout
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
import org.thoughtcrime.securesms.components.settings.models.TextInput
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.keyboard.KeyboardPage
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.fragments.requireListener
/**
* Allows the user to confirm details about a gift, add a message, and finally make a payment.
*/
class GiftFlowConfirmationFragment :
DSLSettingsFragment(
titleId = R.string.GiftFlowConfirmationFragment__confirm_gift,
layoutId = R.layout.gift_flow_confirmation_fragment
),
EmojiKeyboardPageFragment.Callback,
EmojiEventListener,
EmojiSearchFragment.Callback {
companion object {
private val TAG = Log.tag(GiftFlowConfirmationFragment::class.java)
}
private val viewModel: GiftFlowViewModel by viewModels(
ownerProducer = { requireActivity() }
)
private val keyboardPagerViewModel: KeyboardPagerViewModel by viewModels(
ownerProducer = { requireActivity() }
)
private lateinit var inputAwareLayout: InputAwareLayout
private lateinit var emojiKeyboard: MediaKeyboard
private val lifecycleDisposable = LifecycleDisposable()
private var errorDialog: DialogInterface? = null
private lateinit var processingDonationPaymentDialog: AlertDialog
private lateinit var verifyingRecipientDonationPaymentDialog: AlertDialog
private lateinit var donationPaymentComponent: DonationPaymentComponent
private lateinit var textInputViewHolder: TextInput.MultilineViewHolder
private val eventPublisher = PublishSubject.create<TextInput.TextInputEvent>()
private val debouncer = Debouncer(100L)
override fun bindAdapter(adapter: DSLSettingsAdapter) {
RecipientPreference.register(adapter)
GiftRowItem.register(adapter)
keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI)
donationPaymentComponent = requireListener()
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
.setView(R.layout.processing_payment_dialog)
.setCancelable(false)
.create()
verifyingRecipientDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
.setView(R.layout.verifying_recipient_payment_dialog)
.setCancelable(false)
.create()
inputAwareLayout = requireView().findViewById(R.id.input_aware_layout)
emojiKeyboard = requireView().findViewById(R.id.emoji_drawer)
emojiKeyboard.setFragmentManager(childFragmentManager)
val googlePayButton = requireView().findViewById<GooglePayButton>(R.id.google_pay_button)
googlePayButton.setOnGooglePayClickListener {
viewModel.requestTokenFromGooglePay(getString(R.string.preferences__one_time))
}
val textInput = requireView().findViewById<FrameLayout>(R.id.text_input)
val emojiToggle = textInput.findViewById<ImageView>(R.id.emoji_toggle)
textInputViewHolder = TextInput.MultilineViewHolder(textInput, eventPublisher)
textInputViewHolder.onAttachedToWindow()
inputAwareLayout.addOnKeyboardShownListener {
if (emojiKeyboard.isEmojiSearchMode) {
return@addOnKeyboardShownListener
}
inputAwareLayout.hideAttachedInput(true)
emojiToggle.setImageResource(R.drawable.ic_emoji_smiley_24)
}
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (inputAwareLayout.isInputOpen) {
inputAwareLayout.hideAttachedInput(true)
emojiToggle.setImageResource(R.drawable.ic_emoji_smiley_24)
} else {
findNavController().popBackStack()
}
}
}
)
textInputViewHolder.bind(
TextInput.MultilineModel(
text = viewModel.snapshot.additionalMessage,
hint = DSLSettingsText.from(R.string.GiftFlowConfirmationFragment__add_a_message),
onTextChanged = {
viewModel.setAdditionalMessage(it)
},
onEmojiToggleClicked = {
if (inputAwareLayout.isKeyboardOpen || (!inputAwareLayout.isKeyboardOpen && !inputAwareLayout.isInputOpen)) {
inputAwareLayout.show(it, emojiKeyboard)
emojiToggle.setImageResource(R.drawable.ic_keyboard_24)
} else {
inputAwareLayout.showSoftkey(it)
emojiToggle.setImageResource(R.drawable.ic_emoji_smiley_24)
}
}
)
)
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
if (state.stage == GiftFlowState.Stage.RECIPIENT_VERIFICATION) {
debouncer.publish { verifyingRecipientDonationPaymentDialog.show() }
} else {
debouncer.clear()
verifyingRecipientDonationPaymentDialog.dismiss()
}
if (state.stage == GiftFlowState.Stage.PAYMENT_PIPELINE) {
processingDonationPaymentDialog.show()
} else {
processingDonationPaymentDialog.dismiss()
}
}
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += DonationError
.getErrorsForSource(DonationErrorSource.GIFT)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { donationError ->
onPaymentError(donationError)
}
lifecycleDisposable += viewModel.events.observeOn(AndroidSchedulers.mainThread()).subscribe { donationEvent ->
when (donationEvent) {
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed()
DonationEvent.RequestTokenSuccess -> Log.i(TAG, "Successfully got request token from Google Pay")
DonationEvent.SubscriptionCancelled -> Unit
is DonationEvent.SubscriptionCancellationFailed -> Unit
}
}
lifecycleDisposable += donationPaymentComponent.googlePayResultPublisher.subscribe {
viewModel.onActivityResult(it.requestCode, it.resultCode, it.data)
}
}
override fun onDestroyView() {
super.onDestroyView()
textInputViewHolder.onDetachedFromWindow()
processingDonationPaymentDialog.dismiss()
debouncer.clear()
verifyingRecipientDonationPaymentDialog.dismiss()
}
private fun getConfiguration(giftFlowState: GiftFlowState): DSLConfiguration {
return configure {
if (giftFlowState.giftBadge != null) {
giftFlowState.giftPrices[giftFlowState.currency]?.let {
customPref(
GiftRowItem.Model(
giftBadge = giftFlowState.giftBadge,
price = it
)
)
}
}
sectionHeaderPref(R.string.GiftFlowConfirmationFragment__send_to)
customPref(
RecipientPreference.Model(
recipient = giftFlowState.recipient!!
)
)
textPref(
summary = DSLSettingsText.from(R.string.GiftFlowConfirmationFragment__your_gift_will_be_sent_in)
)
}
}
private fun onPaymentConfirmed() {
val mainActivityIntent = MainActivity.clearTop(requireContext())
val conversationIntent = ConversationIntents
.createBuilder(requireContext(), viewModel.snapshot.recipient!!.id, -1L)
.withGiftBadge(viewModel.snapshot.giftBadge!!)
.build()
requireActivity().startActivities(arrayOf(mainActivityIntent, conversationIntent))
}
private fun onPaymentError(throwable: Throwable?) {
Log.w(TAG, "onPaymentError", throwable, true)
if (errorDialog != null) {
Log.i(TAG, "Already displaying an error dialog. Skipping.")
return
}
errorDialog = DonationErrorDialogs.show(
requireContext(), throwable,
object : DonationErrorDialogs.DialogCallback() {
override fun onDialogDismissed() {
requireActivity().finish()
}
}
)
}
override fun onToolbarNavigationClicked() {
findNavController().popBackStack()
}
override fun openEmojiSearch() {
emojiKeyboard.onOpenEmojiSearch()
}
override fun closeEmojiSearch() {
emojiKeyboard.onCloseEmojiSearch()
}
override fun onEmojiSelected(emoji: String?) {
if (emoji?.isNotEmpty() == true) {
eventPublisher.onNext(TextInput.TextInputEvent.OnEmojiEvent(emoji))
}
}
override fun onKeyEvent(keyEvent: KeyEvent?) {
if (keyEvent != null) {
eventPublisher.onNext(TextInput.TextInputEvent.OnKeyEvent(keyEvent))
}
}
}

View File

@@ -0,0 +1,94 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import android.graphics.Color
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.conversation.mutiselect.forward.SearchConfigurationProvider
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Allows the user to select a recipient to send a gift to.
*/
class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient_selection_fragment), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
private val viewModel: GiftFlowViewModel by viewModels(
ownerProducer = { requireActivity() }
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() }
if (savedInstanceState == null) {
childFragmentManager.beginTransaction()
.replace(
R.id.multiselect_container,
MultiselectForwardFragment.create(
MultiselectForwardFragmentArgs(
canSendToNonPush = false,
multiShareArgs = emptyList(),
forceDisableAddMessage = true,
selectSingleRecipient = true
)
)
)
.commit()
}
}
override fun getSearchConfiguration(fragmentManager: FragmentManager, contactSearchState: ContactSearchState): ContactSearchConfiguration {
return ContactSearchConfiguration.build {
query = contactSearchState.query
if (query.isNullOrEmpty()) {
addSection(
ContactSearchConfiguration.Section.Recents(
includeSelf = false,
includeHeader = true,
mode = ContactSearchConfiguration.Section.Recents.Mode.INDIVIDUALS
)
)
}
addSection(
ContactSearchConfiguration.Section.Individuals(
includeSelf = false,
transportType = ContactSearchConfiguration.TransportType.PUSH,
includeHeader = true
)
)
}
}
override fun onFinishForwardAction() = Unit
override fun exitFlow() = Unit
override fun onSearchInputFocused() = Unit
override fun setResult(bundle: Bundle) {
val parcelableContacts: List<ContactSearchKey.ParcelableRecipientSearchKey> = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION)!!
val contacts = parcelableContacts.map { it.asRecipientSearchKey() }
if (contacts.isNotEmpty()) {
viewModel.setSelectedContact(contacts.first())
findNavController().safeNavigate(R.id.action_giftFlowRecipientSelectionFragment_to_giftFlowConfirmationFragment)
}
}
override fun getContainer(): ViewGroup = requireView() as ViewGroup
override fun getDialogBackgroundColor(): Int = Color.TRANSPARENT
}

View File

@@ -0,0 +1,41 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import org.whispersystems.signalservice.internal.ServiceResponse
import java.util.Currency
import java.util.Locale
/**
* Repository for grabbing gift badges and supported currency information.
*/
class GiftFlowRepository {
fun getGiftBadge(): Single<Pair<Long, Badge>> {
return ApplicationDependencies.getDonationsService()
.getGiftBadges(Locale.getDefault())
.flatMap(ServiceResponse<Map<Long, SignalServiceProfile.Badge>>::flattenResult)
.map { gifts -> gifts.map { it.key to Badges.fromServiceBadge(it.value) } }
.map { it.first() }
.subscribeOn(Schedulers.io())
}
fun getGiftPricing(): Single<Map<Currency, FiatMoney>> {
return ApplicationDependencies.getDonationsService()
.giftAmount
.subscribeOn(Schedulers.io())
.flatMap { it.flattenResult() }
.map { result ->
result
.filter { PlatformCurrencyUtil.getAvailableCurrencyCodes().contains(it.key) }
.mapKeys { (code, _) -> Currency.getInstance(code) }
.mapValues { (currency, price) -> FiatMoney(price, currency) }
}
}
}

View File

@@ -0,0 +1,95 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import android.view.View
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Landing fragment for sending gifts.
*/
class GiftFlowStartFragment : DSLSettingsFragment(
layoutId = R.layout.gift_flow_start_fragment
) {
private val viewModel: GiftFlowViewModel by viewModels(
ownerProducer = { requireActivity() },
factoryProducer = { GiftFlowViewModel.Factory(GiftFlowRepository(), requireListener<DonationPaymentComponent>().donationPaymentRepository) }
)
private val lifecycleDisposable = LifecycleDisposable()
override fun bindAdapter(adapter: DSLSettingsAdapter) {
CurrencySelection.register(adapter)
GiftRowItem.register(adapter)
NetworkFailure.register(adapter)
IndeterminateLoadingCircle.register(adapter)
val next = requireView().findViewById<View>(R.id.next)
next.setOnClickListener {
findNavController().safeNavigate(R.id.action_giftFlowStartFragment_to_giftFlowRecipientSelectionFragment)
}
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
next.isEnabled = state.stage == GiftFlowState.Stage.READY
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
override fun onResume() {
super.onResume()
ViewUtil.hideKeyboard(requireContext(), requireView())
}
private fun getConfiguration(state: GiftFlowState): DSLConfiguration {
return configure {
customPref(
CurrencySelection.Model(
selectedCurrency = state.currency,
isEnabled = state.stage == GiftFlowState.Stage.READY,
onClick = {
val action = GiftFlowStartFragmentDirections.actionGiftFlowStartFragmentToSetCurrencyFragment(true, viewModel.getSupportedCurrencyCodes().toTypedArray())
findNavController().safeNavigate(action)
}
)
)
@Suppress("CascadeIf")
if (state.stage == GiftFlowState.Stage.FAILURE) {
customPref(
NetworkFailure.Model(
onRetryClick = {
viewModel.retry()
}
)
)
} else if (state.stage == GiftFlowState.Stage.INIT) {
customPref(IndeterminateLoadingCircle)
} else if (state.giftBadge != null) {
state.giftPrices[state.currency]?.let {
customPref(
GiftRowItem.Model(
giftBadge = state.giftBadge,
price = it
)
)
}
}
}
}
}

View File

@@ -0,0 +1,28 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.recipients.Recipient
import java.util.Currency
/**
* State maintained by the GiftFlowViewModel
*/
data class GiftFlowState(
val currency: Currency,
val giftLevel: Long? = null,
val giftBadge: Badge? = null,
val giftPrices: Map<Currency, FiatMoney> = emptyMap(),
val stage: Stage = Stage.INIT,
val recipient: Recipient? = null,
val additionalMessage: CharSequence? = null
) {
enum class Stage {
INIT,
READY,
RECIPIENT_VERIFICATION,
TOKEN_REQUEST,
PAYMENT_PIPELINE,
FAILURE;
}
}

View File

@@ -0,0 +1,251 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.google.android.gms.wallet.PaymentData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.donations.GooglePayApi
import org.thoughtcrime.securesms.badges.gifts.Gifts
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.InternetConnectionObserver
import org.thoughtcrime.securesms.util.rx.RxStore
import java.util.Currency
/**
* Maintains state as a user works their way through the gift flow.
*/
class GiftFlowViewModel(
val repository: GiftFlowRepository,
val donationPaymentRepository: DonationPaymentRepository
) : ViewModel() {
private var giftToPurchase: Gift? = null
private val store = RxStore(
GiftFlowState(
currency = SignalStore.donationsValues().getOneTimeCurrency()
)
)
private val disposables = CompositeDisposable()
private val eventPublisher: PublishSubject<DonationEvent> = PublishSubject.create()
private val networkDisposable: Disposable
val state: Flowable<GiftFlowState> = store.stateFlowable
val events: Observable<DonationEvent> = eventPublisher
val snapshot: GiftFlowState get() = store.state
init {
refresh()
networkDisposable = InternetConnectionObserver
.observe()
.distinctUntilChanged()
.subscribe { isConnected ->
if (isConnected) {
retry()
}
}
}
fun retry() {
if (!disposables.isDisposed && store.state.stage == GiftFlowState.Stage.FAILURE) {
store.update { it.copy(stage = GiftFlowState.Stage.INIT) }
refresh()
}
}
fun refresh() {
disposables.clear()
disposables += SignalStore.donationsValues().observableOneTimeCurrency.subscribe { currency ->
store.update {
it.copy(
currency = currency
)
}
}
disposables += repository.getGiftPricing().subscribe { giftPrices ->
store.update {
it.copy(
giftPrices = giftPrices,
stage = getLoadState(it, giftPrices = giftPrices)
)
}
}
disposables += repository.getGiftBadge().subscribeBy(
onSuccess = { (giftLevel, giftBadge) ->
store.update {
it.copy(
giftLevel = giftLevel,
giftBadge = giftBadge,
stage = getLoadState(it, giftBadge = giftBadge)
)
}
},
onError = { throwable ->
Log.w(TAG, "Could not load gift badge", throwable)
store.update {
it.copy(
stage = GiftFlowState.Stage.FAILURE
)
}
}
)
}
override fun onCleared() {
disposables.clear()
}
fun setSelectedContact(selectedContact: ContactSearchKey.RecipientSearchKey) {
store.update {
it.copy(recipient = Recipient.resolved(selectedContact.recipientId))
}
}
fun getSupportedCurrencyCodes(): List<String> {
return store.state.giftPrices.keys.map { it.currencyCode }
}
fun requestTokenFromGooglePay(label: String) {
val giftLevel = store.state.giftLevel ?: return
val giftPrice = store.state.giftPrices[store.state.currency] ?: return
val giftRecipient = store.state.recipient?.id ?: return
this.giftToPurchase = Gift(giftLevel, giftPrice)
store.update { it.copy(stage = GiftFlowState.Stage.RECIPIENT_VERIFICATION) }
disposables += donationPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(giftRecipient)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(
onComplete = {
store.update { it.copy(stage = GiftFlowState.Stage.TOKEN_REQUEST) }
donationPaymentRepository.requestTokenFromGooglePay(giftToPurchase!!.price, label, Gifts.GOOGLE_PAY_REQUEST_CODE)
},
onError = this::onPaymentFlowError
)
}
fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?
) {
val gift = giftToPurchase
giftToPurchase = null
val recipient = store.state.recipient?.id
donationPaymentRepository.onActivityResult(
requestCode, resultCode, data, Gifts.GOOGLE_PAY_REQUEST_CODE,
object : GooglePayApi.PaymentRequestCallback {
override fun onSuccess(paymentData: PaymentData) {
if (gift != null && recipient != null) {
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
store.update { it.copy(stage = GiftFlowState.Stage.PAYMENT_PIPELINE) }
donationPaymentRepository.continuePayment(gift.price, paymentData, recipient, store.state.additionalMessage?.toString(), gift.level).subscribeBy(
onError = this@GiftFlowViewModel::onPaymentFlowError,
onComplete = {
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.giftBadge!!))
}
)
} else {
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
}
}
override fun onError(googlePayException: GooglePayApi.GooglePayException) {
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
DonationError.routeDonationError(ApplicationDependencies.getApplication(), DonationError.getGooglePayRequestTokenError(DonationErrorSource.GIFT, googlePayException))
}
override fun onCancelled() {
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
}
}
)
}
private fun onPaymentFlowError(throwable: Throwable) {
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
val donationError: DonationError = if (throwable is DonationError) {
throwable
} else {
Log.w(TAG, "Failed to complete payment or redemption", throwable, true)
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.GIFT)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
}
private fun getLoadState(
oldState: GiftFlowState,
giftPrices: Map<Currency, FiatMoney>? = null,
giftBadge: Badge? = null,
): GiftFlowState.Stage {
if (oldState.stage != GiftFlowState.Stage.INIT) {
return oldState.stage
}
if (giftPrices?.isNotEmpty() == true) {
return if (oldState.giftBadge != null) {
GiftFlowState.Stage.READY
} else {
GiftFlowState.Stage.INIT
}
}
if (giftBadge != null) {
return if (oldState.giftPrices.isNotEmpty()) {
GiftFlowState.Stage.READY
} else {
GiftFlowState.Stage.INIT
}
}
return GiftFlowState.Stage.INIT
}
fun setAdditionalMessage(additionalMessage: CharSequence) {
store.update { it.copy(additionalMessage = additionalMessage) }
}
companion object {
private val TAG = Log.tag(GiftFlowViewModel::class.java)
}
class Factory(
private val repository: GiftFlowRepository,
private val donationPaymentRepository: DonationPaymentRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(
GiftFlowViewModel(
repository,
donationPaymentRepository
)
) as T
}
}
}

View File

@@ -0,0 +1,58 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import android.view.View
import android.widget.TextView
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
import java.util.concurrent.TimeUnit
/**
* A line item for gifts, displayed in the Gift flow's start and confirmation fragments.
*/
object GiftRowItem {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.subscription_preference))
}
class Model(val giftBadge: Badge, val price: FiatMoney) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = giftBadge.id == newItem.giftBadge.id
override fun areContentsTheSame(newItem: Model): Boolean = giftBadge == newItem.giftBadge && price == newItem.price
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val badgeView = itemView.findViewById<BadgeImageView>(R.id.badge)
private val titleView = itemView.findViewById<TextView>(R.id.title)
private val checkView = itemView.findViewById<View>(R.id.check)
private val taglineView = itemView.findViewById<TextView>(R.id.tagline)
private val priceView = itemView.findViewById<TextView>(R.id.price)
override fun bind(model: Model) {
checkView.visible = false
badgeView.setBadge(model.giftBadge)
titleView.text = model.giftBadge.name
taglineView.setText(R.string.GiftRowItem__send_a_gift_badge)
val price = FiatMoneyUtil.format(
context.resources,
model.price,
FiatMoneyUtil.formatOptions()
.trimZerosAfterDecimal()
.withDisplayTime(false)
)
val duration = TimeUnit.MILLISECONDS.toDays(model.giftBadge.duration)
priceView.text = context.resources.getQuantityString(R.plurals.GiftRowItem_s_dot_d_day_duration, duration.toInt(), price, duration)
}
}
}

View File

@@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.badges.gifts.flow
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import org.thoughtcrime.securesms.R
/**
* Wraps the google pay button in a convenient frame layout.
*/
class GooglePayButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
init {
inflate(context, R.layout.donate_with_googlepay_button, this)
}
fun setOnGooglePayClickListener(action: () -> Unit) {
getChildAt(0).setOnClickListener { action() }
}
}

View File

@@ -0,0 +1,80 @@
package org.thoughtcrime.securesms.badges.gifts.thanks
import android.os.Bundle
import androidx.fragment.app.FragmentManager
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.BadgePreview
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.LifecycleDisposable
/**
* Displays a "Thank you" message in a conversation when redirected
* there after purchasing and sending a gift badge.
*/
class GiftThanksSheet : DSLSettingsBottomSheetFragment() {
companion object {
private const val ARGS_RECIPIENT_ID = "args.recipient.id"
private const val ARGS_BADGE = "args.badge"
@JvmStatic
fun show(fragmentManager: FragmentManager, recipientId: RecipientId, badge: Badge) {
GiftThanksSheet().apply {
arguments = Bundle().apply {
putParcelable(ARGS_RECIPIENT_ID, recipientId)
putParcelable(ARGS_BADGE, badge)
}
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
private val lifecycleDisposable = LifecycleDisposable()
private val recipientId: RecipientId
get() = requireArguments().getParcelable(ARGS_RECIPIENT_ID)!!
private val badge: Badge
get() = requireArguments().getParcelable(ARGS_BADGE)!!
override fun bindAdapter(adapter: DSLSettingsAdapter) {
BadgePreview.register(adapter)
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += Recipient.observable(recipientId).observeOn(AndroidSchedulers.mainThread()).subscribe {
adapter.submitList(getConfiguration(it).toMappingModelList())
}
}
private fun getConfiguration(recipient: Recipient): DSLConfiguration {
return configure {
textPref(
title = DSLSettingsText.from(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_your_support, DSLSettingsText.TitleLargeModifier, DSLSettingsText.CenterModifier)
)
noPadTextPref(
title = DSLSettingsText.from(getString(R.string.GiftThanksSheet__youve_gifted_a_badge_to_s, recipient.getDisplayName(requireContext())))
)
space(DimensionUnit.DP.toPixels(37f).toInt())
customPref(
BadgePreview.BadgeModel.GiftedBadgeModel(
badge = badge,
recipient = recipient
)
)
space(DimensionUnit.DP.toPixels(60f).toInt())
}
}
}

View File

@@ -0,0 +1,53 @@
package org.thoughtcrime.securesms.badges.gifts.viewgift
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import java.util.Locale
/**
* Shared repository for getting information about a particular gift.
*/
class ViewGiftRepository {
fun getBadge(giftBadge: GiftBadge): Single<Badge> {
val presentation = ReceiptCredentialPresentation(giftBadge.redemptionToken.toByteArray())
return ApplicationDependencies
.getDonationsService()
.getGiftBadge(Locale.getDefault(), presentation.receiptLevel)
.flatMap { it.flattenResult() }
.map { Badges.fromServiceBadge(it) }
.subscribeOn(Schedulers.io())
}
fun getGiftBadge(messageId: Long): Observable<GiftBadge> {
return Observable.create { emitter ->
fun refresh() {
val record = SignalDatabase.mms.getMessageRecord(messageId)
val giftBadge: GiftBadge = (record as MmsMessageRecord).giftBadge!!
emitter.onNext(giftBadge)
}
val messageObserver = DatabaseObserver.MessageObserver {
if (it.mms && messageId == it.id) {
refresh()
}
}
ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(messageObserver)
emitter.setCancellable {
ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageObserver)
}
refresh()
}
}
}

View File

@@ -0,0 +1,281 @@
package org.thoughtcrime.securesms.badges.gifts.viewgift.received
import android.content.DialogInterface
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.signal.libsignal.zkgroup.InvalidInputException
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.badges.gifts.ExpiredGiftSheetConfiguration.forExpiredGiftBadge
import org.thoughtcrime.securesms.badges.gifts.viewgift.ViewGiftRepository
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
import org.thoughtcrime.securesms.badges.models.BadgeDisplay160
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.components.settings.models.OutlinedSwitch
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.LifecycleDisposable
import java.util.concurrent.TimeUnit
/**
* Handles all interactions for received gift badges.
*/
class ViewReceivedGiftBottomSheet : DSLSettingsBottomSheetFragment() {
companion object {
private val TAG = Log.tag(ViewReceivedGiftBottomSheet::class.java)
private const val ARG_GIFT_BADGE = "arg.gift.badge"
private const val ARG_SENT_FROM = "arg.sent.from"
private const val ARG_MESSAGE_ID = "arg.message.id"
@JvmField
val REQUEST_KEY: String = TAG
const val RESULT_NOT_NOW = "result.not.now"
@JvmStatic
fun show(fragmentManager: FragmentManager, messageRecord: MmsMessageRecord) {
ViewReceivedGiftBottomSheet().apply {
arguments = Bundle().apply {
putParcelable(ARG_SENT_FROM, messageRecord.recipient.id)
putByteArray(ARG_GIFT_BADGE, messageRecord.giftBadge!!.toByteArray())
putLong(ARG_MESSAGE_ID, messageRecord.id)
}
show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}
private val lifecycleDisposable = LifecycleDisposable()
private val sentFrom: RecipientId
get() = requireArguments().getParcelable(ARG_SENT_FROM)!!
private val messageId: Long
get() = requireArguments().getLong(ARG_MESSAGE_ID)
private val viewModel: ViewReceivedGiftViewModel by viewModels(
factoryProducer = { ViewReceivedGiftViewModel.Factory(sentFrom, messageId, ViewGiftRepository(), BadgeRepository(requireContext())) }
)
private var errorDialog: DialogInterface? = null
private lateinit var progressDialog: AlertDialog
override fun bindAdapter(adapter: DSLSettingsAdapter) {
BadgeDisplay112.register(adapter)
OutlinedSwitch.register(adapter)
BadgeDisplay160.register(adapter)
IndeterminateLoadingCircle.register(adapter)
progressDialog = MaterialAlertDialogBuilder(requireContext())
.setView(R.layout.redeeming_gift_dialog)
.setCancelable(false)
.create()
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += DonationError
.getErrorsForSource(DonationErrorSource.GIFT_REDEMPTION)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { donationError ->
onRedemptionError(donationError)
}
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
override fun onDestroy() {
super.onDestroy()
progressDialog.hide()
}
private fun onRedemptionError(throwable: Throwable?) {
Log.w(TAG, "onRedemptionError", throwable, true)
if (errorDialog != null) {
Log.i(TAG, "Already displaying an error dialog. Skipping.")
return
}
errorDialog = DonationErrorDialogs.show(
requireContext(), throwable,
object : DonationErrorDialogs.DialogCallback() {
override fun onDialogDismissed() {
findNavController().popBackStack()
}
}
)
}
private fun getConfiguration(state: ViewReceivedGiftState): DSLConfiguration {
return configure {
if (state.giftBadge == null) {
customPref(IndeterminateLoadingCircle)
} else if (isGiftBadgeExpired(state.giftBadge)) {
forExpiredGiftBadge(
giftBadge = state.giftBadge,
onMakeAMonthlyDonation = {
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
requireActivity().finish()
},
onNotNow = {
dismissAllowingStateLoss()
}
)
} else {
if (state.giftBadge.redemptionState == GiftBadge.RedemptionState.STARTED) {
progressDialog.show()
} else {
progressDialog.hide()
}
if (state.recipient != null && !isGiftBadgeRedeemed(state.giftBadge)) {
noPadTextPref(
title = DSLSettingsText.from(
charSequence = requireContext().getString(R.string.ViewReceivedGiftBottomSheet__s_sent_you_a_gift, state.recipient.getShortDisplayName(requireContext())),
DSLSettingsText.CenterModifier, DSLSettingsText.TitleLargeModifier
)
)
space(DimensionUnit.DP.toPixels(12f).toInt())
presentSubheading(state.recipient)
space(DimensionUnit.DP.toPixels(37f).toInt())
}
if (state.badge != null && state.controlState != null) {
presentForUnexpiredGiftBadge(state, state.giftBadge, state.controlState, state.badge)
space(DimensionUnit.DP.toPixels(16f).toInt())
}
}
}
}
private fun DSLConfiguration.presentSubheading(recipient: Recipient) {
noPadTextPref(
title = DSLSettingsText.from(
charSequence = requireContext().getString(R.string.ViewReceivedGiftBottomSheet__youve_received_a_gift_badge, recipient.getDisplayName(requireContext())),
DSLSettingsText.CenterModifier
)
)
}
private fun DSLConfiguration.presentForUnexpiredGiftBadge(
state: ViewReceivedGiftState,
giftBadge: GiftBadge,
controlState: ViewReceivedGiftState.ControlState,
badge: Badge
) {
when (giftBadge.redemptionState) {
GiftBadge.RedemptionState.REDEEMED -> {
customPref(
BadgeDisplay160.Model(
badge = badge
)
)
state.recipient?.run {
presentSubheading(this)
}
}
else -> {
customPref(
BadgeDisplay112.Model(
badge = badge
)
)
customPref(
OutlinedSwitch.Model(
text = DSLSettingsText.from(
when (controlState) {
ViewReceivedGiftState.ControlState.DISPLAY -> R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile
ViewReceivedGiftState.ControlState.FEATURE -> R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__make_featured_badge
}
),
isEnabled = giftBadge.redemptionState != GiftBadge.RedemptionState.STARTED,
isChecked = state.getControlChecked(),
onClick = {
viewModel.setChecked(!it.isChecked)
}
)
)
if (state.hasOtherBadges && state.displayingOtherBadges) {
noPadTextPref(DSLSettingsText.from(R.string.ThanksForYourSupportBottomSheetFragment__when_you_have_more))
}
space(DimensionUnit.DP.toPixels(36f).toInt())
primaryButton(
text = DSLSettingsText.from(R.string.ViewReceivedGiftSheet__redeem),
isEnabled = giftBadge.redemptionState != GiftBadge.RedemptionState.STARTED,
onClick = {
lifecycleDisposable += viewModel.redeem().subscribeBy(
onComplete = {
dismissAllowingStateLoss()
},
onError = {
onRedemptionError(it)
}
)
}
)
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.ViewReceivedGiftSheet__not_now),
isEnabled = giftBadge.redemptionState != GiftBadge.RedemptionState.STARTED,
onClick = {
setFragmentResult(
REQUEST_KEY,
Bundle().apply {
putBoolean(RESULT_NOT_NOW, true)
}
)
dismissAllowingStateLoss()
}
)
}
}
}
private fun isGiftBadgeRedeemed(giftBadge: GiftBadge): Boolean {
return giftBadge.redemptionState == GiftBadge.RedemptionState.REDEEMED
}
private fun isGiftBadgeExpired(giftBadge: GiftBadge): Boolean {
return try {
val receiptCredentialPresentation = ReceiptCredentialPresentation(giftBadge.redemptionToken.toByteArray())
receiptCredentialPresentation.receiptExpirationTime <= TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())
} catch (e: InvalidInputException) {
Log.w(TAG, "Failed to check expiration of given badge.", e)
true
}
}
}

View File

@@ -0,0 +1,36 @@
package org.thoughtcrime.securesms.badges.gifts.viewgift.received
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.recipients.Recipient
data class ViewReceivedGiftState(
val recipient: Recipient? = null,
val giftBadge: GiftBadge? = null,
val badge: Badge? = null,
val controlState: ControlState? = null,
val hasOtherBadges: Boolean = false,
val displayingOtherBadges: Boolean = false,
val userCheckSelection: Boolean? = false,
val redemptionState: RedemptionState = RedemptionState.NONE
) {
fun getControlChecked(): Boolean {
return when {
userCheckSelection != null -> userCheckSelection
controlState == ControlState.FEATURE -> false
!displayingOtherBadges -> false
else -> true
}
}
enum class ControlState {
DISPLAY,
FEATURE
}
enum class RedemptionState {
NONE,
IN_PROGRESS
}
}

View File

@@ -0,0 +1,150 @@
package org.thoughtcrime.securesms.badges.gifts.viewgift.received
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.badges.gifts.viewgift.ViewGiftRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.DonationReceiptRedemptionJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.rx.RxStore
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class ViewReceivedGiftViewModel(
sentFrom: RecipientId,
private val messageId: Long,
repository: ViewGiftRepository,
val badgeRepository: BadgeRepository
) : ViewModel() {
companion object {
private val TAG = Log.tag(ViewReceivedGiftViewModel::class.java)
}
private val store = RxStore(ViewReceivedGiftState())
private val disposables = CompositeDisposable()
val state: Flowable<ViewReceivedGiftState> = store.stateFlowable
init {
disposables += Recipient.observable(sentFrom).subscribe { recipient ->
store.update { it.copy(recipient = recipient) }
}
disposables += repository.getGiftBadge(messageId).subscribe { giftBadge ->
store.update {
it.copy(giftBadge = giftBadge)
}
}
disposables += repository
.getGiftBadge(messageId)
.firstOrError()
.flatMap { repository.getBadge(it) }
.subscribe { badge ->
val otherBadges = Recipient.self().badges.filterNot { it.id == badge.id }
val hasOtherBadges = otherBadges.isNotEmpty()
val displayingBadges = SignalStore.donationsValues().getDisplayBadgesOnProfile()
val displayingOtherBadges = hasOtherBadges && displayingBadges
store.update {
it.copy(
badge = badge,
hasOtherBadges = hasOtherBadges,
displayingOtherBadges = displayingOtherBadges,
controlState = if (displayingBadges) ViewReceivedGiftState.ControlState.FEATURE else ViewReceivedGiftState.ControlState.DISPLAY
)
}
}
}
override fun onCleared() {
disposables.dispose()
}
fun setChecked(isChecked: Boolean) {
store.update { state ->
state.copy(
userCheckSelection = isChecked
)
}
}
fun redeem(): Completable {
val snapshot = store.state
return if (snapshot.controlState != null && snapshot.badge != null) {
if (snapshot.controlState == ViewReceivedGiftState.ControlState.DISPLAY) {
badgeRepository.setVisibilityForAllBadges(snapshot.getControlChecked()).andThen(awaitRedemptionCompletion(false))
} else if (snapshot.getControlChecked()) {
awaitRedemptionCompletion(true)
} else {
awaitRedemptionCompletion(false)
}
} else {
Completable.error(Exception("Cannot enqueue a redemption without a control state or badge."))
}
}
private fun awaitRedemptionCompletion(setAsPrimary: Boolean): Completable {
return Completable.create {
Log.i(TAG, "Enqueuing gift redemption and awaiting result...", true)
var finalJobState: JobTracker.JobState? = null
val countDownLatch = CountDownLatch(1)
DonationReceiptRedemptionJob.createJobChainForGift(messageId, setAsPrimary).enqueue { _, state ->
if (state.isComplete) {
finalJobState = state
countDownLatch.countDown()
}
}
try {
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
when (finalJobState) {
JobTracker.JobState.SUCCESS -> {
Log.d(TAG, "Gift redemption job chain succeeded.", true)
it.onComplete()
}
JobTracker.JobState.FAILURE -> {
Log.d(TAG, "Gift redemption job chain failed permanently.", true)
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.GIFT_REDEMPTION))
}
else -> {
Log.w(TAG, "Gift redemption job chain ignored due to in-progress jobs.", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.GIFT_REDEMPTION))
}
}
} else {
Log.w(TAG, "Timeout awaiting for gift token redemption and profile refresh", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.GIFT_REDEMPTION))
}
} catch (e: InterruptedException) {
Log.w(TAG, "Interrupted awaiting for gift token redemption and profile refresh", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.GIFT_REDEMPTION))
}
}
}
class Factory(
private val sentFrom: RecipientId,
private val messageId: Long,
private val repository: ViewGiftRepository,
private val badgeRepository: BadgeRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ViewReceivedGiftViewModel(sentFrom, messageId, repository, badgeRepository)) as T
}
}
}

View File

@@ -0,0 +1,95 @@
package org.thoughtcrime.securesms.badges.gifts.viewgift.sent
import android.os.Bundle
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.gifts.viewgift.ViewGiftRepository
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.LifecycleDisposable
/**
* Handles all interactions for received gift badges.
*/
class ViewSentGiftBottomSheet : DSLSettingsBottomSheetFragment() {
companion object {
private const val ARG_GIFT_BADGE = "arg.gift.badge"
private const val ARG_SENT_TO = "arg.sent.to"
@JvmStatic
fun show(fragmentManager: FragmentManager, messageRecord: MmsMessageRecord) {
ViewSentGiftBottomSheet().apply {
arguments = Bundle().apply {
putParcelable(ARG_SENT_TO, messageRecord.recipient.id)
putByteArray(ARG_GIFT_BADGE, messageRecord.giftBadge!!.toByteArray())
}
show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}
private val sentTo: RecipientId
get() = requireArguments().getParcelable(ARG_SENT_TO)!!
private val giftBadge: GiftBadge
get() = GiftBadge.parseFrom(requireArguments().getByteArray(ARG_GIFT_BADGE))
private val lifecycleDisposable = LifecycleDisposable()
private val viewModel: ViewSentGiftViewModel by viewModels(
factoryProducer = { ViewSentGiftViewModel.Factory(sentTo, giftBadge, ViewGiftRepository()) }
)
override fun bindAdapter(adapter: DSLSettingsAdapter) {
BadgeDisplay112.register(adapter)
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
private fun getConfiguration(state: ViewSentGiftState): DSLConfiguration {
return configure {
noPadTextPref(
title = DSLSettingsText.from(
stringId = R.string.ViewSentGiftBottomSheet__thanks_for_your_support,
DSLSettingsText.CenterModifier, DSLSettingsText.TitleLargeModifier
)
)
space(DimensionUnit.DP.toPixels(8f).toInt())
if (state.recipient != null) {
noPadTextPref(
title = DSLSettingsText.from(
charSequence = getString(R.string.ViewSentGiftBottomSheet__youve_gifted_a_badge, state.recipient.getDisplayName(requireContext())),
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(30f).toInt())
}
if (state.badge != null) {
customPref(
BadgeDisplay112.Model(
badge = state.badge
)
)
}
}
}
}

View File

@@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.badges.gifts.viewgift.sent
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.recipients.Recipient
data class ViewSentGiftState(
val recipient: Recipient? = null,
val badge: Badge? = null
)

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.badges.gifts.viewgift.sent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.thoughtcrime.securesms.badges.gifts.viewgift.ViewGiftRepository
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.rx.RxStore
class ViewSentGiftViewModel(
sentFrom: RecipientId,
giftBadge: GiftBadge,
repository: ViewGiftRepository
) : ViewModel() {
private val store = RxStore(ViewSentGiftState())
private val disposables = CompositeDisposable()
val state: Flowable<ViewSentGiftState> = store.stateFlowable
init {
disposables += Recipient.observable(sentFrom).subscribe { recipient ->
store.update { it.copy(recipient = recipient) }
}
disposables += repository.getBadge(giftBadge).subscribe { badge ->
store.update {
it.copy(
badge = badge
)
}
}
}
override fun onCleared() {
disposables.dispose()
}
class Factory(
private val sentFrom: RecipientId,
private val giftBadge: GiftBadge,
private val repository: ViewGiftRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ViewSentGiftViewModel(sentFrom, giftBadge, repository)) as T
}
}
}

View File

@@ -35,10 +35,13 @@ data class Badge(
val imageDensity: String,
val expirationTimestamp: Long,
val visible: Boolean,
val duration: Long
) : Parcelable, Key {
fun isExpired(): Boolean = expirationTimestamp < System.currentTimeMillis() && expirationTimestamp > 0
fun isBoost(): Boolean = id == BOOST_BADGE_ID
fun isGift(): Boolean = id == GIFT_BADGE_ID
fun isSubscription(): Boolean = !isBoost() && !isGift()
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(id.toByteArray(Key.CHARSET))
@@ -162,6 +165,7 @@ data class Badge(
companion object {
const val BOOST_BADGE_ID = "BOOST"
const val GIFT_BADGE_ID = "GIFT"
private val SELECTION_CHANGED = Any()

View File

@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.badges.models
import android.view.View
import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
/**
* Displays a 112dp badge.
*/
object BadgeDisplay112 {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.badge_display_112))
mappingAdapter.registerFactory(GiftModel::class.java, LayoutFactory(::GiftViewHolder, R.layout.badge_display_112))
}
class Model(val badge: Badge, val withDisplayText: Boolean = true) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = badge.id == newItem.badge.id
override fun areContentsTheSame(newItem: Model): Boolean = badge == newItem.badge && withDisplayText == newItem.withDisplayText
}
class GiftModel(val giftBadge: GiftBadge) : MappingModel<GiftModel> {
override fun areItemsTheSame(newItem: GiftModel): Boolean = giftBadge.redemptionToken == newItem.giftBadge.redemptionToken
override fun areContentsTheSame(newItem: GiftModel): Boolean = giftBadge == newItem.giftBadge
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val badgeImageView: BadgeImageView = itemView.findViewById(R.id.badge)
private val titleView: TextView = itemView.findViewById(R.id.name)
override fun bind(model: Model) {
titleView.text = model.badge.name
titleView.visible = model.withDisplayText
badgeImageView.setBadge(model.badge)
}
}
class GiftViewHolder(itemView: View) : MappingViewHolder<GiftModel>(itemView) {
private val badgeImageView: BadgeImageView = itemView.findViewById(R.id.badge)
private val titleView: TextView = itemView.findViewById(R.id.name)
override fun bind(model: GiftModel) {
titleView.visible = false
badgeImageView.setGiftBadge(model.giftBadge, GlideApp.with(badgeImageView))
}
}
}

View File

@@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.badges.models
import android.view.View
import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
/**
* Displays a 160dp badge.
*/
object BadgeDisplay160 {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.badge_display_160))
}
class Model(val badge: Badge) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = badge.id == newItem.badge.id
override fun areContentsTheSame(newItem: Model): Boolean = badge == newItem.badge
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val badgeImageView: BadgeImageView = itemView.findViewById(R.id.badge)
private val titleView: TextView = itemView.findViewById(R.id.name)
override fun bind(model: Model) {
titleView.text = model.badge.name
badgeImageView.setBadge(model.badge)
}
}
}

View File

@@ -4,48 +4,40 @@ import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
object BadgePreview {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.featured_badge_preview_preference))
mappingAdapter.registerFactory(SubscriptionModel::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.subscription_flow_badge_preview_preference))
mappingAdapter.registerFactory(BadgeModel.FeaturedModel::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.featured_badge_preview_preference))
mappingAdapter.registerFactory(BadgeModel.SubscriptionModel::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.subscription_flow_badge_preview_preference))
mappingAdapter.registerFactory(BadgeModel.GiftedBadgeModel::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.gift_badge_preview_preference))
}
abstract class BadgeModel<T : BadgeModel<T>> : PreferenceModel<T>() {
sealed class BadgeModel<T : BadgeModel<T>> : MappingModel<T> {
abstract val badge: Badge?
}
abstract val recipient: Recipient
data class Model(override val badge: Badge?) : BadgeModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return true
data class FeaturedModel(override val badge: Badge?) : BadgeModel<FeaturedModel>() {
override val recipient: Recipient = Recipient.self()
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && badge == newItem.badge
data class SubscriptionModel(override val badge: Badge?) : BadgeModel<SubscriptionModel>() {
override val recipient: Recipient = Recipient.self()
}
override fun getChangePayload(newItem: Model): Any? {
return Unit
}
}
data class GiftedBadgeModel(override val badge: Badge?, override val recipient: Recipient) : BadgeModel<GiftedBadgeModel>()
data class SubscriptionModel(override val badge: Badge?) : BadgeModel<SubscriptionModel>() {
override fun areItemsTheSame(newItem: SubscriptionModel): Boolean {
return true
override fun areItemsTheSame(newItem: T): Boolean {
return badge?.id == newItem.badge?.id && recipient.id == newItem.recipient.id
}
override fun areContentsTheSame(newItem: SubscriptionModel): Boolean {
return super.areContentsTheSame(newItem) && badge == newItem.badge
}
override fun getChangePayload(newItem: SubscriptionModel): Any? {
return Unit
override fun areContentsTheSame(newItem: T): Boolean {
return badge == newItem.badge && recipient.hasSameContent(newItem.recipient)
}
}
@@ -56,7 +48,7 @@ object BadgePreview {
override fun bind(model: T) {
if (payload.isEmpty()) {
avatar.setRecipient(Recipient.self())
avatar.setRecipient(model.recipient)
avatar.disableQuickContact()
}

View File

@@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.badges.self.expired
import androidx.fragment.app.FragmentManager
import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.signal.donations.StripeDeclineCode
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.ExpiredBadge
@@ -11,9 +13,13 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFrag
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.mapToErrorStringResource
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.shouldRouteToGooglePay
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
/**
* Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again.
@@ -30,11 +36,13 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
private fun getConfiguration(): DSLConfiguration {
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
val badge: Badge = args.badge
val cancellationReason: UnexpectedSubscriptionCancellation? = UnexpectedSubscriptionCancellation.fromStatus(args.cancelationReason)
val cancellationReason = UnexpectedSubscriptionCancellation.fromStatus(args.cancelationReason)
val declineCode: StripeDeclineCode? = args.chargeFailure?.let { StripeDeclineCode.getFromCode(it) }
val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer()
val inactive = cancellationReason == UnexpectedSubscriptionCancellation.INACTIVE
Log.d(TAG, "Displaying Expired Badge Fragment with bundle: ${requireArguments()}", true)
return configure {
customPref(ExpiredBadge.Model(badge))
@@ -55,6 +63,12 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
DSLSettingsText.from(
if (badge.isBoost()) {
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_boost_badge_has_expired_and)
} else if (declineCode != null) {
getString(
R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled_s,
getString(declineCode.mapToErrorStringResource()),
badge.name
)
} else if (inactive) {
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_automatically, badge.name)
} else {
@@ -66,22 +80,33 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
space(DimensionUnit.DP.toPixels(16f).toInt())
noPadTextPref(
DSLSettingsText.from(
if (badge.isBoost()) {
if (isLikelyASustainer) {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_reactivate
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_keep
}
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can
},
DSLSettingsText.CenterModifier
)
)
if (badge.isSubscription() && declineCode?.shouldRouteToGooglePay() == true) {
space(DimensionUnit.DP.toPixels(68f).toInt())
space(DimensionUnit.DP.toPixels(92f).toInt())
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__go_to_google_pay),
onClick = {
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.google_pay_url))
}
)
} else {
noPadTextPref(
DSLSettingsText.from(
if (badge.isBoost()) {
if (isLikelyASustainer) {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_reactivate
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_keep
}
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can
},
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(92f).toInt())
}
primaryButton(
text = DSLSettingsText.from(
@@ -115,9 +140,16 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
}
companion object {
private val TAG = Log.tag(ExpiredBadgeBottomSheetDialogFragment::class.java)
@JvmStatic
fun show(badge: Badge, cancellationReason: UnexpectedSubscriptionCancellation?, fragmentManager: FragmentManager) {
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.Builder(badge, cancellationReason?.status).build()
fun show(
badge: Badge,
cancellationReason: UnexpectedSubscriptionCancellation?,
chargeFailure: ActiveSubscription.ChargeFailure?,
fragmentManager: FragmentManager
) {
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.Builder(badge, cancellationReason?.status, chargeFailure?.code).build()
val fragment = ExpiredBadgeBottomSheetDialogFragment()
fragment.arguments = args.toBundle()

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.badges.self.featured
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
@@ -11,13 +12,12 @@ import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.Badges.displayBadges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.BadgePreview
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
/**
* Fragment which allows user to select one of their badges to be their "Featured" badge.
@@ -46,8 +46,8 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
}
}
override fun getOnScrollAnimationHelper(toolbarShadow: View): OnScrollAnimationHelper {
return ToolbarShadowAnimationHelper(scrollShadow)
override fun getMaterial3OnScrollHelper(toolbar: Toolbar?): Material3OnScrollHelper? {
return Material3OnScrollHelper(requireActivity(), scrollShadow)
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
@@ -58,7 +58,7 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
}
val previewView: View = requireView().findViewById(R.id.preview)
val previewViewHolder = BadgePreview.ViewHolder<BadgePreview.Model>(previewView)
val previewViewHolder = BadgePreview.ViewHolder<BadgePreview.BadgeModel.FeaturedModel>(previewView)
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
lifecycleDisposable += viewModel.events.subscribe { event: SelectFeaturedBadgeEvent ->
@@ -79,7 +79,7 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
hasBoundPreview = true
}
previewViewHolder.bind(BadgePreview.Model(state.selectedBadge))
previewViewHolder.bind(BadgePreview.BadgeModel.FeaturedModel(state.selectedBadge))
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}

View File

@@ -66,7 +66,7 @@ class SelectFeaturedBadgeViewModel(private val repository: BadgeRepository) : Vi
}
class Factory(private val badgeRepository: BadgeRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(SelectFeaturedBadgeViewModel(badgeRepository)))
}
}

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.badges.self.none
import android.content.Intent
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import org.signal.core.util.DimensionUnit
@@ -34,13 +35,13 @@ class BecomeASustainerFragment : DSLSettingsBottomSheetFragment() {
private fun getConfiguration(state: BecomeASustainerState): DSLConfiguration {
return configure {
customPref(BadgePreview.Model(badge = state.badge))
customPref(BadgePreview.BadgeModel.FeaturedModel(badge = state.badge))
sectionHeaderPref(
title = DSLSettingsText.from(
R.string.BecomeASustainerFragment__get_badges,
DSLSettingsText.CenterModifier,
DSLSettingsText.Title2BoldModifier
DSLSettingsText.TitleLargeModifier
)
)
@@ -49,13 +50,15 @@ class BecomeASustainerFragment : DSLSettingsBottomSheetFragment() {
noPadTextPref(
title = DSLSettingsText.from(
R.string.BecomeASustainerFragment__signal_is_a_non_profit,
DSLSettingsText.CenterModifier
DSLSettingsText.CenterModifier,
DSLSettingsText.TextAppearanceModifier(R.style.Signal_Text_BodyMedium),
DSLSettingsText.ColorModifier(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant))
)
)
space(DimensionUnit.DP.toPixels(77f).toInt())
space(DimensionUnit.DP.toPixels(32f).toInt())
primaryButton(
tonalButton(
text = DSLSettingsText.from(
R.string.BecomeASustainerMegaphone__become_a_sustainer
),
@@ -65,7 +68,7 @@ class BecomeASustainerFragment : DSLSettingsBottomSheetFragment() {
}
)
space(DimensionUnit.DP.toPixels(8f).toInt())
space(DimensionUnit.DP.toPixels(32f).toInt())
}
}

View File

@@ -38,7 +38,7 @@ class BecomeASustainerViewModel(subscriptionsRepository: SubscriptionsRepository
}
class Factory(private val subscriptionsRepository: SubscriptionsRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(BecomeASustainerViewModel(subscriptionsRepository))!!
}
}

View File

@@ -38,7 +38,7 @@ class BadgesOverviewFragment : DSLSettingsFragment(
override fun bindAdapter(adapter: DSLSettingsAdapter) {
Badge.register(adapter) { badge, _, isFaded ->
if (badge.isExpired() || isFaded) {
findNavController().safeNavigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge, null))
findNavController().safeNavigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge, null, null))
} else {
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge)
}

View File

@@ -91,7 +91,7 @@ class BadgesOverviewViewModel(
private val badgeRepository: BadgeRepository,
private val subscriptionsRepository: SubscriptionsRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository, subscriptionsRepository)))
}
}

View File

@@ -52,7 +52,7 @@ class ViewBadgeViewModel(
private val recipientId: RecipientId,
private val repository: BadgeRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(ViewBadgeViewModel(startBadge, recipientId, repository)))
}
}

View File

@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Optional;
import java.util.function.Consumer;
@@ -69,6 +70,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
contactFilterView.focusAndShowKeyboard();
} else {
contactFilterView.setVisibility(View.GONE);
ViewUtil.hideKeyboard(this, contactFilterView);
}
});
@@ -169,6 +171,6 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
throw new IllegalArgumentException("Unsupported event type " + event);
}
Snackbar.make(view, getString(messageResId, displayName), Snackbar.LENGTH_SHORT).setTextColor(Color.WHITE).show();
Snackbar.make(view, getString(messageResId, displayName), Snackbar.LENGTH_SHORT).show();
}
}

View File

@@ -1,5 +1,8 @@
package org.thoughtcrime.securesms.blurhash;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -9,7 +12,7 @@ import java.util.Objects;
* A BlurHash is a compact string representation of a blurred image that we can use to show fast
* image previews.
*/
public class BlurHash {
public class BlurHash implements Parcelable {
private final String hash;
@@ -17,6 +20,20 @@ public class BlurHash {
this.hash = hash;
}
protected BlurHash(Parcel in) {
hash = in.readString();
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(hash);
}
@Override
public int describeContents() {
return 0;
}
public static @Nullable BlurHash parseOrNull(@Nullable String hash) {
if (Base83.isValid(hash)) {
return new BlurHash(hash);
@@ -40,4 +57,16 @@ public class BlurHash {
public int hashCode() {
return Objects.hash(hash);
}
public static final Creator<BlurHash> CREATOR = new Creator<BlurHash>() {
@Override
public BlurHash createFromParcel(Parcel in) {
return new BlurHash(in);
}
@Override
public BlurHash[] newArray(int size) {
return new BlurHash[size];
}
};
}

View File

@@ -3,9 +3,6 @@ package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
@@ -42,7 +39,6 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment;
import org.thoughtcrime.securesms.util.AvatarUtil;
import org.thoughtcrime.securesms.util.BlurTransformation;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -58,24 +54,8 @@ public final class AvatarImageView extends AppCompatImageView {
@SuppressWarnings("unused")
private static final String TAG = Log.tag(AvatarImageView.class);
private static final Paint LIGHT_THEME_OUTLINE_PAINT = new Paint();
private static final Paint DARK_THEME_OUTLINE_PAINT = new Paint();
static {
LIGHT_THEME_OUTLINE_PAINT.setColor(Color.argb((int) (255 * 0.2), 0, 0, 0));
LIGHT_THEME_OUTLINE_PAINT.setStyle(Paint.Style.STROKE);
LIGHT_THEME_OUTLINE_PAINT.setStrokeWidth(1);
LIGHT_THEME_OUTLINE_PAINT.setAntiAlias(true);
DARK_THEME_OUTLINE_PAINT.setColor(Color.argb((int) (255 * 0.2), 255, 255, 255));
DARK_THEME_OUTLINE_PAINT.setStyle(Paint.Style.STROKE);
DARK_THEME_OUTLINE_PAINT.setStrokeWidth(1);
DARK_THEME_OUTLINE_PAINT.setAntiAlias(true);
}
private int size;
private boolean inverted;
private Paint outlinePaint;
private OnClickListener listener;
private Recipient.FallbackPhotoProvider fallbackPhotoProvider;
private boolean blurred;
@@ -105,8 +85,6 @@ public final class AvatarImageView extends AppCompatImageView {
typedArray.recycle();
}
outlinePaint = ThemeUtil.isDarkTheme(context) ? DARK_THEME_OUTLINE_PAINT : LIGHT_THEME_OUTLINE_PAINT;
unknownRecipientDrawable = new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20).asDrawable(context, AvatarColor.UNKNOWN, inverted);
blurred = false;
chatColors = null;
@@ -117,20 +95,6 @@ public final class AvatarImageView extends AppCompatImageView {
super.setClipBounds(clipBounds);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
float width = getWidth() - getPaddingRight() - getPaddingLeft();
float height = getHeight() - getPaddingBottom() - getPaddingTop();
float cx = width / 2f;
float cy = height / 2f;
float radius = Math.min(cx, cy) - (outlinePaint.getStrokeWidth() / 2f);
canvas.translate(getPaddingLeft(), getPaddingTop());
canvas.drawCircle(cx, cy, radius, outlinePaint);
}
@Override
public void setOnClickListener(OnClickListener listener) {
this.listener = listener;

View File

@@ -28,12 +28,12 @@ import androidx.core.view.inputmethod.InputContentInfoCompat;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.components.emoji.EmojiEditText;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.mention.MentionDeleter;
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
import org.thoughtcrime.securesms.components.mention.MentionValidatorWatcher;
import org.thoughtcrime.securesms.conversation.MessageSendType;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -201,13 +201,13 @@ public class ComposeText extends EmojiEditText {
return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
}
public void setTransport(TransportOption transport) {
public void setMessageSendType(MessageSendType messageSendType) {
final boolean useSystemEmoji = SignalStore.settings().isPreferSystemEmoji();
int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND;
int inputType = getInputType();
if (isLandscape()) setImeActionLabel(transport.getComposeHint(), EditorInfo.IME_ACTION_SEND);
if (isLandscape()) setImeActionLabel(getContext().getString(messageSendType.getComposeHintRes()), EditorInfo.IME_ACTION_SEND);
else setImeActionLabel(null, 0);
if (useSystemEmoji) {
@@ -215,9 +215,9 @@ public class ComposeText extends EmojiEditText {
}
setImeOptions(imeOptions);
setHint(transport.getComposeHint(),
transport.getSimName().isPresent()
? getContext().getString(R.string.conversation_activity__from_sim_name, transport.getSimName().get())
setHint(getContext().getString(messageSendType.getComposeHintRes()),
messageSendType.getSimName() != null
? getContext().getString(R.string.conversation_activity__from_sim_name, messageSendType.getSimName())
: null);
setInputType(inputType);
}

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