Compare commits

..

203 Commits

Author SHA1 Message Date
Michelle Tang
daf87915d6 Bump version to 7.41.1 2025-04-28 16:49:36 -04:00
Michelle Tang
06996540cd Update translations and other static files. 2025-04-28 16:43:54 -04:00
andrew-signal
58ad3c746a Don't call single.onError with IOException in LibSignalChatConnection::sendRequest. 2025-04-28 12:46:52 -06:00
Sagar
a7ebe41570 Fix MediaSelectionViewModel crash. 2025-04-28 19:30:58 +05:30
Michelle Tang
b6cc702107 Add more logging for chat folders during storage sync. 2025-04-25 16:11:21 -04:00
Greyson Parrelli
9163c0ca4d Improve envelope timestamp validation. 2025-04-24 16:45:26 -04:00
Cody Henthorne
18290c1301 Bump version to 7.41.0 2025-04-24 16:15:11 -04:00
Cody Henthorne
347abe14ae Update baseline profile. 2025-04-24 16:09:51 -04:00
Cody Henthorne
eba55755ff Update translations and other static files. 2025-04-24 16:04:38 -04:00
Michelle Tang
7043558657 Add fixes for streamable videos. 2025-04-24 15:55:40 -04:00
Alex Hart
3aefd3bdc6 Prevent search state from clearing if user did not send a message. 2025-04-24 15:55:40 -04:00
Sagar
d6eb675fd0 Trim text before performing username search. 2025-04-24 15:55:40 -04:00
Alex Hart
ae90b2ecd9 Add support for conversation intent routing to MainActivity. 2025-04-24 15:55:39 -04:00
Jeffrey Starke
9d593bcaff Fix chat folders flickering during drag and drop.
Fixes the UI flickering that occurs when reordering chat folders. The issue was caused by the ViewModel updating the database each time a list item position changes when we were already updating list order in the UI state manually at the same time.
2025-04-24 15:55:39 -04:00
Jeffrey Starke
62ed823e42 Sticker management v2 - Implement drag and drop.
Adds the ability to use drag and drop to rearrange installed sticker packs.
2025-04-24 15:55:39 -04:00
Cody Henthorne
a53479e50d Do not process messages while pending restore decision. 2025-04-24 15:55:39 -04:00
Cody Henthorne
91140c41fd Revert "Depend on libsignal-net's connection backoff instead of duplicating at app-level."
This reverts commit 1aed82d5b7.
2025-04-24 15:55:39 -04:00
Cody Henthorne
68f567b0b7 Fix a few random crashes when using libsignal-net. 2025-04-24 15:55:39 -04:00
Cody Henthorne
501e169210 Make e164 formatter more leinent with + prefix. 2025-04-24 15:55:39 -04:00
Greyson Parrelli
09b818b048 Limit work that happens in LiveRecipientCache lock. 2025-04-24 15:55:39 -04:00
Sagar
7b3897cac6 Fix incorrect span indices for normalised search text. 2025-04-24 15:55:39 -04:00
Alex Hart
64239962fc Implement activated state for conversation list items. 2025-04-24 15:55:39 -04:00
Alex Hart
dac3a332d7 Remove main-thread usage of Recipient.self. 2025-04-24 15:55:39 -04:00
Sagar
83bbcd0618 Avoid message click listeners in Scheduled messages sheet. 2025-04-24 15:55:39 -04:00
andrew-signal
c7c0374c11 Add remote config for libsignal-net rollout. 2025-04-24 15:55:39 -04:00
Sagar
847f3bf08c Pause and play video correctly on TimeBar scrub drag. 2025-04-24 15:55:39 -04:00
Cody Henthorne
d02c610237 Fix unreads for new unread count scheme. 2025-04-24 15:55:39 -04:00
Cody Henthorne
8007045ca8 Convert change number back to WebSocket. 2025-04-24 15:55:39 -04:00
Sagar
901b4b469d Show correct time for Story view item. 2025-04-24 15:55:39 -04:00
Sagar
fa50696815 Ensure story viewed list in proper alphabetical order. 2025-04-24 15:55:39 -04:00
Alex Hart
be035456f7 Ensures chat folder is remembered when we leave page. 2025-04-24 15:55:39 -04:00
Sagar
252a4afa79 Update banner message for debug log. 2025-04-24 15:55:39 -04:00
Sagar
f5f56536bc Fix unread count for edited messages.
Co-authored-by: Cody Henthorne <cody@signal.org>
2025-04-24 15:55:39 -04:00
Michelle Tang
9e89d688f1 Send error message after cancelling a link+sync. 2025-04-24 15:55:39 -04:00
Sagar
2bb94089f7 Move to quoted message on quote preview click. 2025-04-24 15:55:39 -04:00
Jeffrey Starke
3fc386d4a3 Add StickerPackId and StickerPackKey value classes. 2025-04-24 15:55:39 -04:00
Sagar
3779dfd290 Open keyboard for a Draft message. 2025-04-24 15:55:39 -04:00
Jeffrey Starke
a5f766a333 Sticker management v2 - Implement sticker pack installation.
Adds the ability to install sticker packs using `StickerManagementActivityV2`.

When the install button is clicked, it will morph into an indeterminate progress bar, which will then animate into a checkmark once the installation completes successfully. Then a couple seconds later, the sticker pack row will be removed from the available sticker packs list.
2025-04-24 15:55:39 -04:00
Sagar
9f40bfc645 Replace glyphs in group update messages. 2025-04-24 15:55:39 -04:00
Greyson Parrelli
919f03522a Upgrade to mobilecoin to 6.1.2 for 16kb alignment. 2025-04-24 15:55:39 -04:00
Cody Henthorne
8aa6d0bbca Include AEP in link device provisioning message. 2025-04-24 15:55:39 -04:00
Cody Henthorne
4304ae2a96 Add notification profile id for backupsv2. 2025-04-24 15:55:39 -04:00
Sagar
b4a9189068 Add close icon in search toolbar. 2025-04-24 15:55:39 -04:00
Greyson Parrelli
ec6448bd1b Address possible invalid e164's in storage service splits. 2025-04-24 15:55:39 -04:00
Greyson Parrelli
8c5811581e Add additional logging around storage batch sizes. 2025-04-24 15:55:39 -04:00
Greyson Parrelli
4b4d3d33b1 Add additional safeguards around storage sync types. 2025-04-24 15:55:39 -04:00
Jeffrey Starke
dd6c39f7eb Update TransferProgressIndicator to support indeterminate progress.
Showing exact progress for sticker pack downloads is more complicated than necessary. This PR updates `TransferProgressIndicator` to support displaying indeterminate progress.

#### Changeset
- Display indeterminate progress when installing a sticker pack.
- Remove cancel button from `AvailableStickerPackRow`.
- Decrease progress indicator size to match updated design.
2025-04-24 15:55:39 -04:00
Sagar
b246e62504 Avoid setting blank folder name. 2025-04-24 15:55:39 -04:00
Sagar
ba08399d35 Add accessibility labels for MainToolbar. 2025-04-24 15:55:39 -04:00
Sagar
3f1bb7eac7 Improve choose chats save button enabled state. 2025-04-24 15:55:39 -04:00
Greyson Parrelli
a2a10fb0c1 Filter out bad E164s from GV1 groups. 2025-04-24 15:55:39 -04:00
Greyson Parrelli
e45eabc714 Convert avatar migration to just be a force push. 2025-04-24 15:55:39 -04:00
Alex Hart
138dae0484 Align pin reminder skip behavior with iOS. 2025-04-24 15:55:39 -04:00
Alex Hart
893725e304 Dynamic split pane support via internal setting. 2025-04-24 15:55:39 -04:00
Jeffrey Starke
2cfe321274 Convert StickerManagementRepository to kotlin.
Converts `StickerManagementRepository` to kotlin, so `getStickerPacks()` can return a `Flow` that emits updates after the database is changed.

This change simplifies the implementation of `StickerManagmentViewModelV2`, since `StickerManagementRepository.getStickerPacks()` will now automatically register and unregister the database observer.
2025-04-24 15:55:39 -04:00
Sagar
050dcb3eb1 Show correct message for empty archived screen. 2025-04-24 15:55:39 -04:00
Alex Hart
6ce01c6b0e Return an empty list instead of crashing when calling participantAcis. 2025-04-24 15:55:39 -04:00
Sagar
d2f44fee87 Avoid opening Media preview for not sent media. 2025-04-24 15:55:39 -04:00
Sagar
1228da8665 Fix transfer controls logic for checking isUpload. 2025-04-24 15:55:39 -04:00
Sagar
479632d6a8 Fix message info screen updates. 2025-04-24 15:55:39 -04:00
Greyson Parrelli
619d2997f6 Add additional local metrics around storage service writes/reads. 2025-04-24 15:55:39 -04:00
Alex Hart
c5e795b176 Wire up nav rail fabs and fix animation playing on leaving a tab. 2025-04-24 15:55:39 -04:00
andrew-signal
8b7b184224 Tweak Network.transformAndSetRemoteConfig to match changes to libsignal's RemoteConfig spec. 2025-04-24 15:55:39 -04:00
Jeffrey Starke
48d26beb77 Add TransferProgressIndicator composable.
Adds a composable version of `TransferProgressView`.
2025-04-24 15:55:39 -04:00
Jeffrey Starke
3d1895500c Sticker management v2 - Display available and installed stickers. 2025-04-24 15:55:39 -04:00
Alex Hart
e442c27555 Separate sheet that requires payment flow. 2025-04-24 15:55:39 -04:00
Alex Hart
c3d61bece1 Add MainContentLayoutData object and proper scaffolding directive. 2025-04-24 15:55:39 -04:00
Alex Hart
49853b2cca Move background color selection into theme. 2025-04-24 15:55:39 -04:00
Sagar
cd838c4bee Fix Video call screen bottom sheet weird animation and height. 2025-04-24 15:55:39 -04:00
Greyson Parrelli
2e50699a2d Make system keyboard sticker detection more reliable. 2025-04-24 15:55:39 -04:00
Michelle Tang
fe97c969ae Ensure keystore operations happen on the same thread. 2025-04-24 15:55:39 -04:00
Alex Hart
c70a8d48a8 Hide keyboard when navigating back to chat list via toolbar. 2025-04-24 15:55:39 -04:00
Alex Hart
322ea97377 Add logging to BackupsSettingsViewModel to help track down data loading race. 2025-04-24 15:55:39 -04:00
Sagar
e3a402394f Avoid message failed notification when bubble thread is visible. 2025-04-24 15:55:39 -04:00
Sagar
16b4b3b6b7 Fix SearchView loosing focus. 2025-04-24 15:55:39 -04:00
Jim Gustafson
cd98ccbf00 Update to RingRTC v2.50.6 2025-04-24 15:55:39 -04:00
Sagar
eecb18b436 Add correct dialog message while blocking group. 2025-04-24 15:55:39 -04:00
Sagar
d13a803dcd Fix resend button visibility logic. 2025-04-24 15:55:39 -04:00
Alex Hart
bd03f21cdf Allow specification of whether we utilize windowTypes to lay out inset guidelines. 2025-04-24 15:55:39 -04:00
Alex Hart
b46d891183 Dialog color fixes. 2025-04-24 15:55:38 -04:00
Alex Hart
54191433e0 Remove ConversationTabs* and migrate to MainActivity. 2025-04-24 15:55:38 -04:00
Sagar
462fcdce16 Add glyph icons and SignalSymbol methods. 2025-04-24 15:55:38 -04:00
Greyson Parrelli
f68bb2dc88 Add storage service optimization to avoid manifest reads. 2025-04-24 15:55:38 -04:00
andrew-signal
fe70637140 Bump to libsignal v0.70.0 2025-04-24 15:55:38 -04:00
Greyson Parrelli
1028d293a0 Temporarily remove bad assertion in MessageBackupsFlowViewModel. 2025-04-24 15:55:38 -04:00
andrew-signal
74c6e76808 Add system HTTP proxy support to libsignal-net.
Co-authored-by: Cody Henthorne <cody@signal.org>
2025-04-24 15:55:38 -04:00
Greyson Parrelli
8e880fe117 Fix another syncing crash when no archived media exists. 2025-04-24 15:55:38 -04:00
Greyson Parrelli
6525662071 Fix syncing crash when no archived media exists. 2025-04-24 15:55:38 -04:00
Alex Hart
94d07f7012 Decouple InlineQueryViewModelV2 instance from activity, parent to fragment instead. 2025-04-24 15:55:38 -04:00
Sagar
e3297ab593 Add accessibility labels for GIF categories and correct emoji labels. 2025-04-24 15:55:38 -04:00
Sagar
3ff7f89ef6 Support hiding image caption with press and hold. 2025-04-24 15:55:38 -04:00
Sagar
ac1165c8fd Avoid blocking yourself. 2025-04-24 15:55:38 -04:00
Sagar
69153cf339 Support drag multi-selection for media gallery. 2025-04-16 11:22:23 -03:00
Sagar
852541c361 Avoid setting blank custom story name. 2025-04-16 11:22:23 -03:00
Sagar
399a613c25 Avoid sending blank story. 2025-04-16 11:22:23 -03:00
Sagar
003c1082a9 Avoid setting blank group names. 2025-04-16 11:22:23 -03:00
Jeffrey Starke
885588db86 Create new sticker management screen with tabbed interface.
Adds a skeleton implementation of `StickerManagementActivityV2`. This new activity is not currently connected to anything, but once complete it will replace `StickerManagementActivity`.
2025-04-16 11:22:23 -03:00
Milan Stevanovic
90a356b29d Fix incorrect embedded druation in certain MP4 files.
The root cause:
- some MP4 files come with H.264/H.265 streams which explicitly
  state their timescale. In such cases, it is wise that MP4 muxer
  adopts these values
- unfortunately, the recent trend has been that such values coming
  from video stream SPS (vui_parameters/timing info) are exorbitantly
  high - instead of being FPS *1000, they tend to be FPS * 100,000,000
- when trying to express the duration of the movie, the MP4 muxer
  normally tries to find the adequate timescale value which will
  fit both audio and video timescaling domains. The most suitable
  approach is that the LCM (least common multiplier) value is taken
  which mathematically will be the least disruptive.

HOWEVER:
- in cases when video and timescale numeric values are mutually 'odd',
  say 30*100,000,000 and 44100, the LCM ends up being a huge number,
  which outgrows the 32-bit storage capacity granted by the ISO MP4
  spec (MVHD box).

Problem solution:
1) identifying when the LCM timescale exceeds 32-bit storage space
2) scaling down its value by nearest larger 10X factor, which will
   guarantee its value fitting the 32-bit space. Given the afore
   mentioned video timescale factors, dividing by 10X is harmless
3) rescaling the duration 64-bit value based on the new timescale
2025-04-16 11:22:23 -03:00
Greyson Parrelli
597623d23a Update conscrypt to 2.5.3 2025-04-16 11:22:23 -03:00
Greyson Parrelli
2028afc941 Update aesgcmprovider to 0.0.4 2025-04-16 11:22:23 -03:00
Greyson Parrelli
915580ddd3 Enable backups v2 for internal users. 2025-04-16 11:22:23 -03:00
Greyson Parrelli
9432cca14a Fix some media not appearing in the gallery picker.
Works around the glide issue by using the straight URI when possible,
which allows glide to not have to keep a buffer. However, as soon as you
select it, it'll be an encrypted file, and we'll run into this same
issue where glide needs to keep a buffer for the input stream.

Related to #11014
2025-04-16 11:22:23 -03:00
Sagar
4e07ac0300 Fix InputAwareLayout incorrectly overriding height in bubble mode. 2025-04-16 11:22:23 -03:00
Sagar
ad21c349cd Update quote icon for scheduled send. 2025-04-16 11:22:23 -03:00
Greyson Parrelli
383da335d8 Do not send sync messages if we have no linked devices. 2025-04-16 11:22:23 -03:00
Jim Gustafson
ebdffc171e Update to RingRTC v2.50.5 2025-04-16 11:22:23 -03:00
Cody Henthorne
721b70b7b7 Fallback to local reglock data if available when registering a previously verified session. 2025-04-16 11:22:23 -03:00
Greyson Parrelli
556bcda58a Bump version to 7.40.2 2025-04-15 17:20:29 -04:00
Greyson Parrelli
4cb5bd9edd Fix potential bad state with change numbers. 2025-04-15 17:20:29 -04:00
Cody Henthorne
193f6460b0 Convert change number back to REST. 2025-04-15 17:05:19 -04:00
Alex Hart
f8d8c8af2d Add internal preference for large screen UI. 2025-04-15 15:29:37 -03:00
Alex Hart
efac6990c8 Apply display cutout for chat list. 2025-04-15 15:24:09 -03:00
Alex Hart
250ac481c8 Move cancel and retry to bg thread. 2025-04-15 13:15:16 -03:00
Michelle Tang
44bfa514a5 Fix member count description. 2025-04-15 11:05:58 -04:00
Alex Hart
74cedf99d8 Fix snackbar vertical offset on archive screen. 2025-04-15 11:33:11 -03:00
Alex Hart
4c81c321be Utilize root window insets for grabbing system bar sizes. 2025-04-15 11:14:16 -03:00
Alex Hart
d00fbcd886 Fix snackbar dismissal. 2025-04-15 10:25:47 -03:00
Alex Hart
416f80e745 Fix bad assumption about attachment count in validator. 2025-04-15 10:14:54 -03:00
Michelle Tang
6805826472 Bump version to 7.40.1 2025-04-11 11:03:55 -04:00
Michelle Tang
ce5d234186 Update translations and other static files. 2025-04-11 10:57:41 -04:00
Michelle Tang
c95c6e6ef0 Schedule storage sync job for add/remove from folder. 2025-04-11 10:49:33 -04:00
Cody Henthorne
904f8da8af Update settings for unregistered state. 2025-04-11 08:34:10 -04:00
Alex Hart
645e9bf16a Allow back press to return from archive to converation list. 2025-04-10 14:26:35 -03:00
Alex Hart
35235509ca Prevent wallpaper mode from overwriting navbar color. 2025-04-10 14:12:04 -03:00
Michelle Tang
021330a25d Fix adding to chats for chat folders. 2025-04-10 12:47:13 -04:00
Alex Hart
6613d5fccb Fix nav bar spacing and coloring. 2025-04-10 13:10:37 -03:00
Alex Hart
9d6e7560f0 Fix touch target for app toolbar avatar. 2025-04-10 12:14:18 -03:00
Alex Hart
09e36e0ed8 Fix 3 button nav styling. 2025-04-10 11:47:40 -03:00
Alex Hart
8dde5ccd2e Fix padding below toolbar in search mode with chat folders enabled. 2025-04-10 10:13:23 -03:00
Alex Hart
f1ed2156e3 Prevent scaffold from being used if we do not have flag enabled. 2025-04-10 10:00:58 -03:00
Michelle Tang
40b9a60f6c Bump version to 7.40.0 2025-04-09 16:54:03 -04:00
Michelle Tang
59a135a1db Update translations and other static files. 2025-04-09 16:53:42 -04:00
Michelle Tang
0123c17e7e Remove unnecessary boolean return for conversations. 2025-04-09 15:51:10 -04:00
Cody Henthorne
ac36eeb84d Use unauth WebSocket after quick restore for transfer mode decision. 2025-04-09 15:29:55 -04:00
Alex Hart
143b2b5bd5 Move live state into if statement for AvatarImage. 2025-04-09 15:29:54 -04:00
Michelle Tang
6006c047d8 Remove old deleted folders from storage service. 2025-04-09 15:29:54 -04:00
Alex Hart
94d5fe3e43 Fix how navigation bar colors are set and interacted with in MainActivity. 2025-04-09 15:29:54 -04:00
Alex Hart
e0ba8a1d60 Fix color issue on call toast popup. 2025-04-09 15:29:54 -04:00
Alex Hart
2f8b0ff3a8 Set corner radius of nav bar icons to half height. 2025-04-09 15:29:54 -04:00
Greyson Parrelli
4700846fad Align the libnative-utils to 16kb pages. 2025-04-09 15:29:54 -04:00
Greyson Parrelli
6ddf2ab5f8 Update to NDK r28 for 16kb page support. 2025-04-09 15:29:54 -04:00
Alex Hart
545a26ff04 Fix conversation nav bar click. 2025-04-09 15:29:54 -04:00
Sagar
f0f6b80f43 Prevent child clickable in message selection state. 2025-04-09 15:29:54 -04:00
Alex Hart
0227af199b Clear window insets listener when view is detached from window. 2025-04-09 15:29:54 -04:00
Alex Hart
970f5f2480 Add progress dialog support to bottom snackbar. 2025-04-09 15:29:54 -04:00
Sagar
13d0d25f77 Notify conversations for deleted stories. 2025-04-09 15:29:54 -04:00
Alex Hart
b64f3a48bf Add proper adaptive material app scaffolding. 2025-04-09 15:29:54 -04:00
Sagar
86ea3e8572 Fix thumbPositon jitter while editing video. 2025-04-09 15:29:54 -04:00
andrew-signal
f15a67c8b2 Remove outdated config check affecting proximity lock behavior during calling. 2025-04-09 15:29:54 -04:00
Alex Hart
659ae75a20 Fix content width shrinking megaphones. 2025-04-09 15:29:54 -04:00
Sagar
0d686b2f44 Fix Image expanded caption scroll to top. 2025-04-09 15:29:54 -04:00
andrew-signal
0d611cf4c9 Bump libsignal to v0.69.1. 2025-04-09 15:29:54 -04:00
Sagar
6afeb45f43 Remove duplicate error handling in MediaSelection. 2025-04-09 15:29:54 -04:00
Alex Hart
d81616d23c Prevent conversation re-launch on reconfiguration of screen. 2025-04-09 15:29:54 -04:00
Sagar
6ea63f3e34 Avoid sending blank replies and do not clear input when sending reactions. 2025-04-09 15:29:54 -04:00
Sagar
af52765821 Support opening scheduled document files. 2025-04-09 15:29:54 -04:00
Cody Henthorne
acbab9e736 Allow long text to be sent via notification replies. 2025-04-09 15:29:54 -04:00
Alex Hart
5bce2884a7 Add predictive back gesture support to MainActivity. 2025-04-09 15:29:52 -04:00
Alex Hart
b92998be13 Fix image loading for megaphones. 2025-04-09 15:27:46 -04:00
Michelle Tang
1339929de4 Update chat folder tests. 2025-04-09 15:27:46 -04:00
Alex Hart
b0cd27e203 Add compose megaphone stuff to MainBottomChrome composable. 2025-04-09 15:27:46 -04:00
Sagar
65e7c4c053 Support zoom for avatar preview. 2025-04-09 15:27:46 -04:00
Sagar
8d8519b52e Linkify story captions. 2025-04-09 15:27:46 -04:00
Sagar
9c95cfd64b Fix donation pills UI for large Font and other UI improvements. 2025-04-09 15:27:46 -04:00
Sagar
b0a903b17d Make FABs stack scrollable for small height in landscape mode. 2025-04-09 15:27:46 -04:00
Alex Hart
855b315067 Reimplement megaphone UI in compose. 2025-04-09 15:27:46 -04:00
Jeffrey Starke
aa7b61ecb1 Consolidate duplicated logic to retrieve groups in common.
Merges all of these into GroupsInCommonRepository:
- ConversationSettingsRepository.getGroupsInCommon()
- CallLinkIncomingRequestRepository.getGroupsInCommon()
- ContactSearchPagedDataSourceRepository.getGroupsInCommon()
- ReviewUtil.getGroupsInCommonCount()
- AboutSheetRepository.getGroupsInCommonCount()
2025-04-09 15:27:46 -04:00
Alex Hart
c9795141df Pass InAppPayments around by ID instead of passing the entire object. 2025-04-09 15:27:46 -04:00
andrew-signal
1aed82d5b7 Depend on libsignal-net's connection backoff instead of duplicating at app-level. 2025-04-09 15:27:46 -04:00
Michelle Tang
752ed93b6f Update blocked string for groups. 2025-04-09 15:27:46 -04:00
Ciphreon
de3088f706 Show "declined" for declined voice and video calls instead of "missed".
Closes #14081
Fixes #14080
2025-04-09 15:27:46 -04:00
Jeffrey Starke
2608e9165c Fix group member review avatar and "other groups in common" copy. (#4813)
- Fixes `ReviewBannerView` erroneously using the note to self icon instead of the current user's profile photo.
- Fixes the "other groups in common" copy, which was missing the word "other".
2025-04-09 15:27:46 -04:00
Cody Henthorne
1e0e165eaf Fix decryptionDrained flag if race for WebSocket state emission is lost. 2025-04-09 15:27:46 -04:00
Michelle Tang
eff90aaa64 Fix job checks when syncing folders with storage service id. 2025-04-09 15:27:46 -04:00
Jeffrey Starke
77078e1844 Add the ability to navigate to conversations by tapping groups in common rows. 2025-04-09 15:27:46 -04:00
Michelle Tang
5929021166 Fix null storageIds in chat folder crash. 2025-04-09 15:27:46 -04:00
andrew-signal
8317e2e055 Correct RemoteConfig to enable libsignalWebSocket for nightly builds. 2025-04-09 15:27:46 -04:00
Michelle Tang
eb1cf8d62f Add chat folder support to storage service. 2025-04-09 15:27:46 -04:00
Cody Henthorne
f6ecb572b1 Fix lint for IAP test and main toolbar. 2025-04-09 15:27:46 -04:00
Alex Bakon
8b9fc30b97 Migrate calls to deprecated libsignal methods. 2025-04-09 15:27:46 -04:00
Sagar
d65954c26f Improve AvatarImage to update on recipient changes. 2025-04-09 15:27:46 -04:00
Cody Henthorne
8a0e260061 Re-migrate delete account to WebSocket. 2025-04-09 15:27:46 -04:00
Jeffrey Starke
bb608dbfa7 Fix missing timestamps on undownloaded media messages. 2025-04-09 15:27:46 -04:00
Michelle Tang
ec5a7e1e48 Prevent recipient hot loop on main thread. 2025-04-09 15:27:46 -04:00
Sagar
6251dad6e0 Update MyStoryItem on profile change. 2025-04-09 15:27:46 -04:00
Sagar
3982f5a4db Remove prefix before username aci fetch. 2025-04-09 15:27:46 -04:00
Sagar
a8f8760a11 Support scroll for call link screens. 2025-04-09 15:27:46 -04:00
Sagar
fb571ffdbf fixup! Update profile initials after name change. 2025-04-09 15:27:46 -04:00
Sagar
dc2956d05b Update quoteIds for edited message and ignore stale messages in isQuoted. 2025-04-09 15:27:46 -04:00
Jeffrey Starke
85b19bfe23 Fix incorrectly oriented back navigation icons for top app bar RTL layouts.
Replace `symbol_arrow_left_24` with `symbol_arrow_start_24` (which has auto-mirroring enabled) for top app bar navigation back icons to properly support RTL layouts.
2025-04-09 15:27:46 -04:00
andrew-signal
5b04107447 Update to and integrate with libsignal v0.69.0. 2025-04-09 15:27:46 -04:00
Cody Henthorne
7a5790a6ce Attempt to reclaim username in more places during/after registration. 2025-04-09 15:27:46 -04:00
Jeffrey Starke
9d3f4ffa08 Add groups in common screen.
Adds a new screen to show which groups the user has in common with another user.
2025-04-09 15:27:46 -04:00
Sagar
bc2d4a0415 Fix badge bottomsheet color and scrolling. 2025-04-09 15:27:46 -04:00
Alex Hart
cc346351f7 Use state to support back pressed callback. 2025-04-09 15:27:46 -04:00
Cody Henthorne
fcc6032ee0 Generalize preventing WebSocket from connecting in various app states. 2025-04-09 15:27:46 -04:00
Cody Henthorne
ecb040ce98 Convert donations apis to WebSocket. 2025-04-09 15:27:46 -04:00
Sagar
2f9692a1a0 Prevent wrong closing animation for stories. 2025-04-09 15:27:46 -04:00
Sagar
042ab95738 Fix EmojiView scroll in bottomsheet. 2025-04-09 15:27:45 -04:00
Sagar
13be8d511c Focus on correct textfield when adding a description. 2025-04-09 15:27:45 -04:00
Cody Henthorne
7bdfec77ca Remove message send REST fallback. 2025-04-09 15:27:45 -04:00
Alex Hart
bc176b8c50 Fix application crash when failing to download backup types. 2025-04-09 15:27:45 -04:00
Alex Hart
68c0307b73 Upgrade compose bom to latest stable. 2025-04-09 15:27:45 -04:00
508 changed files with 20632 additions and 8150 deletions

View File

@@ -8,7 +8,7 @@ permissions:
pull-requests: write # to comment on PR
env:
NDK_VERSION: '27.2.12479018'
NDK_VERSION: '28.0.13004108'
jobs:
assemble-base:

View File

@@ -21,8 +21,8 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1535
val canonicalVersionName = "7.39.4"
val canonicalVersionCode = 1540
val canonicalVersionName = "7.41.1"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -370,6 +370,7 @@ android {
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
buildConfigField("String", "APK_UPDATE_MANIFEST_URL", "\"${apkUpdateManifestUrl}\"")
buildConfigField("String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\"")
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "true")
}
create("prod") {

View File

@@ -21,8 +21,6 @@ import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.runBlocking
@@ -42,12 +40,10 @@ import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.NetworkResult
import java.math.BigDecimal
import java.util.Currency
@@ -72,11 +68,6 @@ class MessageBackupsCheckoutActivityTest {
coEvery { AppDependencies.billingApi.queryProduct() } returns BillingProduct(price = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")))
coEvery { AppDependencies.billingApi.launchBillingFlow(any()) } returns Unit
mockkObject(SignalNetwork)
every { SignalNetwork.archive } returns mockk {
every { triggerBackupIdReservation(any(), any(), any()) } returns NetworkResult.Success(Unit)
}
mockkStatic(RemoteConfig::class)
every { RemoteConfig.messageBackups } returns true
}

View File

@@ -11,7 +11,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import okhttp3.mockwebserver.MockResponse
import io.mockk.every
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -20,15 +20,13 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.Delete
import org.thoughtcrime.securesms.testing.Get
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.actions.RecyclerViewScrollToBottomAction
import org.thoughtcrime.securesms.testing.success
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.math.BigDecimal
@@ -118,32 +116,28 @@ class CheckoutFlowActivityTest__RecurringDonations {
InAppPaymentsRepository.setSubscriber(subscriber)
SignalStore.inAppPayments.setRecurringDonationCurrency(currency)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/subscription/${subscriber.subscriberId.serialize()}") {
MockResponse().success(
ActiveSubscription(
ActiveSubscription.Subscription(
200,
currency.currencyCode,
BigDecimal.ONE,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
true,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
false,
"active",
"STRIPE",
"CARD",
false
),
null
)
AppDependencies.donationsApi.apply {
every { getSubscription(subscriber.subscriberId) } returns NetworkResult.Success(
ActiveSubscription(
ActiveSubscription.Subscription(
200,
currency.currencyCode,
BigDecimal.ONE,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
true,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
false,
"active",
"STRIPE",
"CARD",
false
),
null
)
},
Delete("/v1/subscription/${subscriber.subscriberId.serialize()}") {
Thread.sleep(10000)
MockResponse().success()
}
)
)
every { deleteSubscription(subscriber.subscriberId) } returns NetworkResult.Success(Unit)
}
}
private fun initialisePendingSubscription() {
@@ -160,27 +154,25 @@ class CheckoutFlowActivityTest__RecurringDonations {
InAppPaymentsRepository.setSubscriber(subscriber)
SignalStore.inAppPayments.setRecurringDonationCurrency(currency)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/subscription/${subscriber.subscriberId.serialize()}") {
MockResponse().success(
ActiveSubscription(
ActiveSubscription.Subscription(
200,
currency.currencyCode,
BigDecimal.ONE,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
false,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
false,
"incomplete",
"STRIPE",
"CARD",
false
),
null
)
AppDependencies.donationsApi.apply {
every { getSubscription(subscriber.subscriberId) } returns NetworkResult.Success(
ActiveSubscription(
ActiveSubscription.Subscription(
200,
currency.currencyCode,
BigDecimal.ONE,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
false,
System.currentTimeMillis().milliseconds.inWholeSeconds + 30.days.inWholeSeconds,
false,
"incomplete",
"STRIPE",
"CARD",
false
),
null
)
}
)
)
}
}
}

View File

@@ -6,16 +6,26 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderId
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.storage.SignalChatFolderRecord
import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.api.util.UuidUtil
import java.util.UUID
import org.whispersystems.signalservice.internal.storage.protos.ChatFolderRecord as RemoteChatFolderRecord
@RunWith(AndroidJUnit4::class)
class ChatFolderTablesTest {
@@ -31,15 +41,19 @@ class ChatFolderTablesTest {
private lateinit var folder2: ChatFolderRecord
private lateinit var folder3: ChatFolderRecord
private lateinit var recipientIds: List<RecipientId>
private var aliceThread: Long = 0
private var bobThread: Long = 0
private var charlieThread: Long = 0
@Before
fun setUp() {
alice = harness.others[1]
bob = harness.others[2]
charlie = harness.others[3]
recipientIds = createRecipients(5)
alice = recipientIds[0]
bob = recipientIds[1]
charlie = recipientIds[2]
aliceThread = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(alice))
bobThread = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(bob))
@@ -48,32 +62,40 @@ class ChatFolderTablesTest {
folder1 = ChatFolderRecord(
id = 2,
name = "folder1",
position = 1,
position = 0,
includedChats = listOf(aliceThread, bobThread),
excludedChats = listOf(charlieThread),
showUnread = true,
showMutedChats = true,
showIndividualChats = true,
folderType = ChatFolderRecord.FolderType.CUSTOM
folderType = ChatFolderRecord.FolderType.CUSTOM,
chatFolderId = ChatFolderId.generate(),
storageServiceId = StorageId.forChatFolder(byteArrayOf(1, 2, 3))
)
folder2 = ChatFolderRecord(
name = "folder2",
position = 2,
includedChats = listOf(bobThread),
showUnread = true,
showMutedChats = true,
showIndividualChats = true,
folderType = ChatFolderRecord.FolderType.INDIVIDUAL
folderType = ChatFolderRecord.FolderType.INDIVIDUAL,
chatFolderId = ChatFolderId.generate(),
storageServiceId = StorageId.forChatFolder(byteArrayOf(2, 3, 4))
)
folder3 = ChatFolderRecord(
name = "folder3",
position = 3,
includedChats = listOf(bobThread),
excludedChats = listOf(aliceThread, charlieThread),
showUnread = true,
showMutedChats = true,
showGroupChats = true,
folderType = ChatFolderRecord.FolderType.GROUP
folderType = ChatFolderRecord.FolderType.GROUP,
chatFolderId = ChatFolderId.generate(),
storageServiceId = StorageId.forChatFolder(byteArrayOf(3, 4, 5))
)
SignalDatabase.chatFolders.writableDatabase.deleteAll(ChatFolderTables.ChatFolderTable.TABLE_NAME)
@@ -83,7 +105,7 @@ class ChatFolderTablesTest {
@Test
fun givenChatFolder_whenIGetFolder_thenIExpectFolderWithChats() {
SignalDatabase.chatFolders.createFolder(folder1)
val actualFolders = SignalDatabase.chatFolders.getChatFolders()
val actualFolders = SignalDatabase.chatFolders.getCurrentChatFolders()
assertEquals(listOf(folder1), actualFolders)
}
@@ -91,7 +113,7 @@ class ChatFolderTablesTest {
@Test
fun givenChatFolder_whenIUpdateFolder_thenIExpectUpdatedFolderWithChats() {
SignalDatabase.chatFolders.createFolder(folder2)
val folder = SignalDatabase.chatFolders.getChatFolders().first()
val folder = SignalDatabase.chatFolders.getCurrentChatFolders().first()
val updatedFolder = folder.copy(
name = "updatedFolder2",
position = 1,
@@ -100,7 +122,7 @@ class ChatFolderTablesTest {
)
SignalDatabase.chatFolders.updateFolder(updatedFolder)
val actualFolder = SignalDatabase.chatFolders.getChatFolders().first()
val actualFolder = SignalDatabase.chatFolders.getCurrentChatFolders().first()
assertEquals(updatedFolder, actualFolder)
}
@@ -109,11 +131,77 @@ class ChatFolderTablesTest {
fun givenADeletedChatFolder_whenIGetFolders_thenIExpectAListWithoutThatFolder() {
SignalDatabase.chatFolders.createFolder(folder1)
SignalDatabase.chatFolders.createFolder(folder2)
val folders = SignalDatabase.chatFolders.getChatFolders()
val folders = SignalDatabase.chatFolders.getCurrentChatFolders()
SignalDatabase.chatFolders.deleteChatFolder(folders.last())
val actualFolders = SignalDatabase.chatFolders.getChatFolders()
val actualFolders = SignalDatabase.chatFolders.getCurrentChatFolders()
assertEquals(listOf(folder1), actualFolders)
}
@Test
fun givenChatFolders_whenIUpdateTheirStorageSyncIds_thenIExpectAnUpdatedList() {
val existingMap = SignalDatabase.chatFolders.getStorageSyncIdsMap()
existingMap.forEach { (id, _) ->
SignalDatabase.chatFolders.applyStorageIdUpdate(id, StorageId.forChatFolder(StorageSyncHelper.generateKey()))
}
val updatedMap = SignalDatabase.chatFolders.getStorageSyncIdsMap()
existingMap.forEach { (id, storageId) ->
assertNotEquals(storageId, updatedMap[id])
}
}
@Test
fun givenARemoteFolder_whenIInsertLocally_thenIExpectAListWithThatFolder() {
val remoteRecord =
SignalChatFolderRecord(
folder1.storageServiceId!!,
RemoteChatFolderRecord(
identifier = UuidUtil.toByteArray(folder1.chatFolderId.uuid).toByteString(),
name = folder1.name,
position = folder1.position,
showOnlyUnread = folder1.showUnread,
showMutedChats = folder1.showMutedChats,
includeAllIndividualChats = folder1.showIndividualChats,
includeAllGroupChats = folder1.showGroupChats,
folderType = RemoteChatFolderRecord.FolderType.CUSTOM,
deletedAtTimestampMs = folder1.deletedTimestampMs,
includedRecipients = listOf(
RemoteChatFolderRecord.Recipient(RemoteChatFolderRecord.Recipient.Contact(Recipient.resolved(alice).serviceId.get().toString())),
RemoteChatFolderRecord.Recipient(RemoteChatFolderRecord.Recipient.Contact(Recipient.resolved(bob).serviceId.get().toString()))
),
excludedRecipients = listOf(
RemoteChatFolderRecord.Recipient(RemoteChatFolderRecord.Recipient.Contact(Recipient.resolved(charlie).serviceId.get().toString()))
)
)
)
SignalDatabase.chatFolders.insertChatFolderFromStorageSync(remoteRecord)
val actualFolders = SignalDatabase.chatFolders.getCurrentChatFolders()
assertEquals(listOf(folder1), actualFolders)
}
@Test
fun givenADeletedChatFolder_whenIGetPositions_thenIExpectPositionsToStillBeConsecutive() {
SignalDatabase.chatFolders.createFolder(folder1)
SignalDatabase.chatFolders.createFolder(folder2)
SignalDatabase.chatFolders.createFolder(folder3)
val folders = SignalDatabase.chatFolders.getCurrentChatFolders()
SignalDatabase.chatFolders.deleteChatFolder(folders[1])
val actualFolders = SignalDatabase.chatFolders.getCurrentChatFolders()
actualFolders.forEachIndexed { index, folder ->
assertEquals(folder.position, index)
}
}
private fun createRecipients(count: Int): List<RecipientId> {
return (1..count).map {
SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID()))
}
}
}

View File

@@ -26,10 +26,13 @@ import org.thoughtcrime.securesms.testing.runSync
import org.thoughtcrime.securesms.testing.success
import org.whispersystems.signalservice.api.SignalServiceDataStore
import org.whispersystems.signalservice.api.SignalServiceMessageSender
import org.whispersystems.signalservice.api.archive.ArchiveApi
import org.whispersystems.signalservice.api.attachment.AttachmentApi
import org.whispersystems.signalservice.api.donations.DonationsApi
import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.message.MessageApi
import org.whispersystems.signalservice.api.push.TrustStore
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl
import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
@@ -94,6 +97,7 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
networkInterceptors = emptyList(),
dns = Optional.of(SignalServiceNetworkAccess.DNS),
signalProxy = Optional.empty(),
systemHttpProxy = Optional.empty(),
zkGroupServerPublicParams = Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS),
genericServerPublicParams = Base64.decode(BuildConfig.GENERIC_SERVER_PUBLIC_PARAMS),
backupServerPublicParams = Base64.decode(BuildConfig.BACKUP_SERVER_PUBLIC_PARAMS),
@@ -122,6 +126,14 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
return recipientCache
}
override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): ArchiveApi {
return mockk()
}
override fun provideDonationsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): DonationsApi {
return mockk()
}
override fun provideSignalServiceMessageSender(
protocolStore: SignalServiceDataStore,
pushServiceSocket: PushServiceSocket,

View File

@@ -6,10 +6,11 @@
package org.thoughtcrime.securesms.testing
import androidx.test.platform.app.InstrumentationRegistry
import okhttp3.mockwebserver.MockResponse
import io.mockk.every
import org.junit.rules.ExternalResource
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.JsonUtils
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
/**
@@ -23,29 +24,25 @@ class InAppPaymentsRule : ExternalResource() {
}
private fun initialiseConfigurationResponse() {
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/subscription/configuration") {
val assets = InstrumentationRegistry.getInstrumentation().context.resources.assets
assets.open("inAppPaymentsTests/configuration.json").use { stream ->
MockResponse().success(JsonUtils.fromJson(stream, SubscriptionsConfiguration::class.java))
}
}
)
val assets = InstrumentationRegistry.getInstrumentation().context.resources.assets
val response = assets.open("inAppPaymentsTests/configuration.json").use { stream ->
NetworkResult.Success(JsonUtils.fromJson(stream, SubscriptionsConfiguration::class.java))
}
AppDependencies.donationsApi.apply {
every { getDonationsConfiguration(any()) } returns response
}
}
private fun initialisePutSubscription() {
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Put("/v1/subscription/") {
MockResponse().success()
}
)
AppDependencies.donationsApi.apply {
every { putSubscription(any()) } returns NetworkResult.Success(Unit)
}
}
private fun initialiseSetArchiveBackupId() {
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Put("/v1/archives/backupid") {
MockResponse().success()
}
)
AppDependencies.archiveApi.apply {
every { triggerBackupIdReservation(any(), any(), any()) } returns NetworkResult.Success(Unit)
}
}
}

View File

@@ -67,7 +67,7 @@ private fun Content(
Scaffolds.Settings(
title = "Conversation Test Springboard",
onNavigationClick = onBackPressed,
navigationIconPainter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.symbol_arrow_left_24))
navigationIconPainter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.symbol_arrow_start_24))
) {
Column(modifier = Modifier.padding(it)) {
Rows.TextRow(

View File

@@ -101,6 +101,7 @@
android:supportsRtl="true"
android:resizeableActivity="true"
android:fullBackupOnly="false"
android:enableOnBackInvokedCallback="false"
android:allowBackup="true"
android:backupAgent=".absbackup.SignalBackupAgent"
android:theme="@style/TextSecure.LightTheme"
@@ -894,6 +895,10 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".stickers.StickerManagementActivityV2"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar" />
<activity android:name=".logsubmit.SubmitDebugLogActivity"
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
@@ -1069,8 +1074,10 @@
android:exported="false"/>
<activity android:name=".MainActivity"
android:enableOnBackInvokedCallback="true"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout"
android:windowSoftInputMode="stateUnchanged"
android:resizeableActivity="true"
android:exported="false"/>
@@ -1148,6 +1155,10 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".groups.ui.incommon.GroupsInCommonActivity"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar" />
<service
android:enabled="true"
android:exported="false"

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,7 @@ import org.signal.core.util.logging.Log;
import org.signal.core.util.logging.Scrubber;
import org.signal.core.util.tracing.Tracer;
import org.signal.glide.SignalGlideCodecs;
import org.signal.libsignal.net.ChatServiceException;
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider;
import org.signal.ringrtc.CallManager;
import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
@@ -102,6 +103,7 @@ import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
@@ -188,6 +190,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
.addBlocking("tracer", this::initializeTracer)
.addNonBlocking(() -> RegistrationUtil.maybeMarkRegistrationComplete())
.addNonBlocking(() -> Glide.get(this))
.addNonBlocking(ConversationUtil::refreshRecipientShortcuts)
.addNonBlocking(this::cleanAvatarStorage)
.addNonBlocking(this::initializeRevealableMessageManager)
.addNonBlocking(this::initializePendingRetryReceiptManager)
@@ -363,7 +366,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
e = e.getCause();
}
if (wasWrapped && (e instanceof SocketException || e instanceof InterruptedException || e instanceof InterruptedIOException)) {
if (wasWrapped && (e instanceof SocketException || e instanceof InterruptedException || e instanceof InterruptedIOException || e instanceof ChatServiceException)) {
return;
}

View File

@@ -9,7 +9,6 @@ import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.transition.TransitionInflater;
import android.view.View;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -26,6 +25,7 @@ import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.target.CustomTarget;
import com.bumptech.glide.request.target.Target;
import com.bumptech.glide.request.transition.Transition;
import com.github.chrisbanes.photoview.PhotoView;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar;
@@ -46,6 +46,12 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
private static final String RECIPIENT_ID_EXTRA = "recipient_id";
private static final int ZOOM_TRANSITION_DURATION = 300;
private static final float ZOOM_LEVEL_MIN = 1.0f;
private static final float SMALL_IMAGES_ZOOM_LEVEL_MID = 3.0f;
private static final float SMALL_IMAGES_ZOOM_LEVEL_MAX = 8.0f;
public static @NonNull Intent intentFromRecipientId(@NonNull Context context,
@NonNull RecipientId recipientId)
{
@@ -78,7 +84,10 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
Toolbar toolbar = findViewById(R.id.toolbar);
EmojiTextView title = findViewById(R.id.title);
ImageView avatar = findViewById(R.id.avatar);
PhotoView avatar = findViewById(R.id.avatar);
avatar.setZoomTransitionDuration(ZOOM_TRANSITION_DURATION);
avatar.setScaleLevels(ZOOM_LEVEL_MIN, SMALL_IMAGES_ZOOM_LEVEL_MID, SMALL_IMAGES_ZOOM_LEVEL_MAX);
setSupportActionBar(toolbar);
@@ -134,7 +143,7 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
FullscreenHelper fullscreenHelper = new FullscreenHelper(this);
findViewById(android.R.id.content).setOnClickListener(v -> fullscreenHelper.toggleUiVisibility());
avatar.setOnClickListener(v -> fullscreenHelper.toggleUiVisibility());
fullscreenHelper.configureToolbarLayout(findViewById(R.id.toolbar_cutout_spacer), toolbar);

View File

@@ -17,9 +17,11 @@ public interface BindableConversationListItem extends Unbindable {
@NonNull ThreadRecord thread,
@NonNull RequestManager requestManager, @NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull ConversationSet selectedConversations);
@NonNull ConversationSet selectedConversations,
long activeThreadId);
void setSelectedConversations(@NonNull ConversationSet conversations);
void setActiveThreadId(long activeThreadId);
void updateTypingIndicator(@NonNull Set<Long> typingThreads);
void updateTimestamp();
}

View File

@@ -5,35 +5,62 @@
package org.thoughtcrime.securesms
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MotionEvent
import android.view.View
import android.view.ViewTreeObserver
import android.widget.Toast
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.compose.ui.draw.clip
import androidx.fragment.app.DialogFragment
import androidx.fragment.compose.AndroidFragment
import androidx.fragment.compose.rememberFragmentState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getSerializableCompat
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
import org.thoughtcrime.securesms.calls.log.CallLogFilter
import org.thoughtcrime.securesms.calls.new.NewCallActivity
import org.thoughtcrime.securesms.components.ConnectivityWarningBottomSheet
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment
import org.thoughtcrime.securesms.components.DeviceSpecificNotificationBottomSheet
@@ -43,36 +70,56 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity.Co
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.conversation.v2.MotionEventRelay
import org.thoughtcrime.securesms.conversation.v2.ShareDataTimestampViewModel
import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment
import org.thoughtcrime.securesms.conversationlist.RestoreCompleteBottomSheetDialog
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.main.MainActivityListHostFragment
import org.thoughtcrime.securesms.main.MainNavigationDestination
import org.thoughtcrime.securesms.main.MainBottomChrome
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
import org.thoughtcrime.securesms.main.MainBottomChromeState
import org.thoughtcrime.securesms.main.MainContentLayoutData
import org.thoughtcrime.securesms.main.MainMegaphoneState
import org.thoughtcrime.securesms.main.MainNavigationBar
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationRail
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.main.MainToolbar
import org.thoughtcrime.securesms.main.MainToolbarCallback
import org.thoughtcrime.securesms.main.MainToolbarMode
import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.NavigationBarSpacerCompat
import org.thoughtcrime.securesms.main.SnackbarState
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.megaphone.Megaphone
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController
import org.thoughtcrime.securesms.megaphone.Megaphones
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
import org.thoughtcrime.securesms.notifications.VitalsViewModel
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.profiles.manage.UsernameEditFragment
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsFragment
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel
import org.thoughtcrime.securesms.util.AppForegroundObserver
import org.thoughtcrime.securesms.util.AppStartup
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
import org.thoughtcrime.securesms.util.viewModel
import org.thoughtcrime.securesms.window.AppScaffold
import org.thoughtcrime.securesms.window.WindowSizeClass
class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, MainNavigator.NavigatorProvider {
@@ -87,23 +134,23 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
@JvmStatic
fun clearTopAndOpenTab(context: Context, startingTab: MainNavigationDestination): Intent {
fun clearTopAndOpenTab(context: Context, startingTab: MainNavigationListLocation): Intent {
return clearTop(context).putExtra(KEY_STARTING_TAB, startingTab)
}
}
private val dynamicTheme = DynamicNoActionBarTheme()
private val navigator = MainNavigator(this)
private val lifecycleDisposable = LifecycleDisposable()
private lateinit var mediaController: VoiceNoteMediaController
private lateinit var navigator: MainNavigator
override val voiceNoteMediaController: VoiceNoteMediaController
get() = mediaController
private val conversationListTabsViewModel: ConversationListTabsViewModel by viewModel {
val startingTab = intent.extras?.getSerializableCompat(KEY_STARTING_TAB, MainNavigationDestination::class.java)
ConversationListTabsViewModel(startingTab ?: MainNavigationDestination.CHATS, ConversationListTabRepository())
private val mainNavigationViewModel: MainNavigationViewModel by viewModel {
val startingTab = intent.extras?.getSerializableCompat(KEY_STARTING_TAB, MainNavigationListLocation::class.java)
MainNavigationViewModel(startingTab ?: MainNavigationListLocation.CHATS)
}
private val vitalsViewModel: VitalsViewModel by viewModel {
@@ -118,57 +165,192 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
private val toolbarViewModel: MainToolbarViewModel by viewModels()
private val toolbarCallback = ToolbarCallback()
private val shareDataTimestampViewModel: ShareDataTimestampViewModel by viewModels()
private val motionEventRelay: MotionEventRelay by viewModels()
private var onFirstRender = false
private val mainBottomChromeCallback = BottomChromeCallback()
private val megaphoneActionController = MainMegaphoneActionController()
private val mainNavigationCallback = MainNavigationCallback()
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return motionEventRelay.offer(ev) || super.dispatchTouchEvent(ev)
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
enableEdgeToEdge()
AppStartup.getInstance().onCriticalRenderEventStart()
enableEdgeToEdge(
navigationBarStyle = if (DynamicTheme.isDarkTheme(this)) {
SystemBarStyle.dark(0)
} else {
SystemBarStyle.light(0, 0)
}
)
super.onCreate(savedInstanceState, ready)
conversationListTabsViewModel
navigator = MainNavigator(this, mainNavigationViewModel)
setContent {
val navState = rememberFragmentState()
val listHostState = rememberFragmentState()
val detailLocation by navigator.viewModel.detailLocation.collectAsStateWithLifecycle()
AppForegroundObserver.addListener(object : AppForegroundObserver.Listener {
override fun onForeground() {
mainNavigationViewModel.getNextMegaphone()
}
})
LaunchedEffect(detailLocation) {
if (detailLocation is MainNavigationDetailLocation.Conversation) {
startActivity((detailLocation as MainNavigationDetailLocation.Conversation).intent)
overridePendingTransition(R.anim.slide_from_end, R.anim.fade_scale_out)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
mainNavigationViewModel.navigationEvents.collectLatest {
when (it) {
MainNavigationViewModel.NavigationEvent.STORY_CAMERA_FIRST -> {
mainBottomChromeCallback.onCameraClick(MainNavigationListLocation.STORIES)
}
}
}
}
}
AppScaffold(
bottomNavContent = {
AndroidFragment(
clazz = ConversationListTabsFragment::class.java,
fragmentState = navState
)
},
navRailContent = {
AndroidFragment(
clazz = ConversationListTabsFragment::class.java,
fragmentState = navState
)
}
) {
Column {
val state by toolbarViewModel.state.collectAsStateWithLifecycle()
shareDataTimestampViewModel.setTimestampFromActivityCreation(savedInstanceState, intent)
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)) {
MainToolbar(
state = state,
callback = toolbarCallback
)
setContent {
val listHostState = rememberFragmentState()
val detailLocation by mainNavigationViewModel.detailLocationRequests.collectAsStateWithLifecycle()
val snackbar by mainNavigationViewModel.snackbar.collectAsStateWithLifecycle()
val mainToolbarState by toolbarViewModel.state.collectAsStateWithLifecycle()
val megaphone by mainNavigationViewModel.megaphone.collectAsStateWithLifecycle()
val mainNavigationState by mainNavigationViewModel.mainNavigationState.collectAsStateWithLifecycle()
val isNavigationVisible = remember(mainToolbarState.mode) {
mainToolbarState.mode == MainToolbarMode.FULL
}
val mainBottomChromeState = remember(mainToolbarState.destination, snackbar, mainToolbarState.mode, megaphone) {
MainBottomChromeState(
destination = mainToolbarState.destination,
snackbarState = snackbar,
mainToolbarMode = mainToolbarState.mode,
megaphoneState = MainMegaphoneState(
megaphone = megaphone,
mainToolbarMode = mainToolbarState.mode
)
)
}
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
val contentLayoutData = MainContentLayoutData.rememberContentLayoutData()
MainContainer {
val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator<Any>(
scaffoldDirective = calculatePaneScaffoldDirective(
currentWindowAdaptiveInfo()
).copy(
maxHorizontalPartitions = if (windowSizeClass.isSplitPane()) 2 else 1,
horizontalPartitionSpacerSize = contentLayoutData.partitionWidth,
defaultPanePreferredWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
)
)
LaunchedEffect(detailLocation) {
if (detailLocation is MainNavigationDetailLocation.Conversation) {
if (SignalStore.internal.largeScreenUi) {
scaffoldNavigator.navigateTo(ThreePaneScaffoldRole.Primary, detailLocation)
} else {
startActivity((detailLocation as MainNavigationDetailLocation.Conversation).intent)
}
}
AndroidFragment(
clazz = MainActivityListHostFragment::class.java,
fragmentState = listHostState,
modifier = Modifier.fillMaxSize()
)
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Empty)
}
AppScaffold(
navigator = scaffoldNavigator,
bottomNavContent = {
if (isNavigationVisible) {
Column(
modifier = Modifier
.clip(contentLayoutData.navigationBarShape)
.background(color = SignalTheme.colors.colorSurface2)
) {
MainNavigationBar(
state = mainNavigationState,
onDestinationSelected = mainNavigationCallback
)
if (!windowSizeClass.isSplitPane()) {
NavigationBarSpacerCompat()
}
}
}
},
navRailContent = {
if (isNavigationVisible) {
MainNavigationRail(
state = mainNavigationState,
mainFloatingActionButtonsCallback = mainBottomChromeCallback,
onDestinationSelected = mainNavigationCallback
)
}
},
listContent = {
val listContainerColor = if (windowSizeClass.isMedium()) {
SignalTheme.colors.colorSurface1
} else {
MaterialTheme.colorScheme.surface
}
Column(
modifier = Modifier
.padding(start = contentLayoutData.listPaddingStart)
.fillMaxSize()
.background(listContainerColor)
.clip(contentLayoutData.shape)
) {
MainToolbar(
state = mainToolbarState,
callback = toolbarCallback
)
Box(
modifier = Modifier.weight(1f)
) {
AndroidFragment(
clazz = MainActivityListHostFragment::class.java,
fragmentState = listHostState,
modifier = Modifier.fillMaxSize()
)
MainBottomChrome(
state = mainBottomChromeState,
callback = mainBottomChromeCallback,
megaphoneActionController = megaphoneActionController,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
},
detailContent = {
when (val destination = scaffoldNavigator.currentDestination?.contentKey) {
is MainNavigationDetailLocation.Conversation -> {
val fragmentState = key(destination) { rememberFragmentState() }
AndroidFragment(
clazz = ConversationFragment::class.java,
fragmentState = fragmentState,
arguments = requireNotNull(destination.intent.extras) { "Handed null Conversation intent arguments." },
modifier = Modifier
.padding(end = contentLayoutData.detailPaddingEnd)
.clip(contentLayoutData.shape)
.background(color = MaterialTheme.colorScheme.surface)
.fillMaxSize()
)
}
}
},
paneExpansionDragHandle = if (contentLayoutData.hasDragHandle()) {
{ }
} else null
)
}
}
@@ -191,11 +373,37 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
handleDeepLinkIntent(intent)
CachedInflater.from(this).clear()
updateNavigationBarColor()
lifecycleDisposable += vitalsViewModel.vitalsState.subscribe(this::presentVitalsState)
}
@Composable
private fun MainContainer(content: @Composable BoxWithConstraintsScope.() -> Unit) {
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(this)) {
val backgroundColor = if (windowSizeClass.isCompact()) {
MaterialTheme.colorScheme.surface
} else {
SignalTheme.colors.colorSurface1
}
val modifier = if (windowSizeClass.isSplitPane()) {
Modifier.systemBarsPadding().displayCutoutPadding()
} else {
Modifier
}
BoxWithConstraints(
modifier = Modifier
.background(color = backgroundColor)
.then(modifier)
) {
content()
}
}
}
override fun getIntent(): Intent {
return super.getIntent().setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
@@ -205,14 +413,14 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
handleDeepLinkIntent(intent)
val extras = intent.extras ?: return
val startingTab = extras.getSerializableCompat(KEY_STARTING_TAB, MainNavigationDestination::class.java)
val startingTab = extras.getSerializableCompat(KEY_STARTING_TAB, MainNavigationListLocation::class.java)
when (startingTab) {
MainNavigationDestination.CHATS -> conversationListTabsViewModel.onChatsSelected()
MainNavigationDestination.CALLS -> conversationListTabsViewModel.onCallsSelected()
MainNavigationDestination.STORIES -> {
MainNavigationListLocation.CHATS -> mainNavigationViewModel.onChatsSelected()
MainNavigationListLocation.CALLS -> mainNavigationViewModel.onCallsSelected()
MainNavigationListLocation.STORIES -> {
if (Stories.isFeatureEnabled()) {
conversationListTabsViewModel.onStoriesSelected()
mainNavigationViewModel.onStoriesSelected()
}
}
@@ -250,9 +458,8 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
.show()
}
updateNavigationBarColor()
vitalsViewModel.checkSlowNotificationHeuristics()
mainNavigationViewModel.refreshNavigationBarState()
}
override fun onStop() {
@@ -260,10 +467,8 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
SplashScreenUtil.setSplashScreenThemeIfNecessary(this, SignalStore.settings.theme)
}
override fun onBackPressed() {
if (!navigator.onBackPressed()) {
super.onBackPressed()
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray, deviceId: Int) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@@ -271,6 +476,20 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
if (requestCode == MainNavigator.REQUEST_CONFIG_CHANGES && resultCode == RESULT_CONFIG_CHANGED) {
recreate()
}
if (resultCode == RESULT_OK && requestCode == CreateSvrPinActivity.REQUEST_NEW_PIN) {
mainNavigationViewModel.setSnackbar(SnackbarState(message = getString(R.string.ConfirmKbsPinFragment__pin_created)))
mainNavigationViewModel.onMegaphoneCompleted(Megaphones.Event.PINS_FOR_ALL)
}
if (resultCode == RESULT_OK && requestCode == UsernameEditFragment.REQUEST_CODE) {
val snackbarString = getString(R.string.ConversationListFragment_username_recovered_toast, SignalStore.account.username)
mainNavigationViewModel.setSnackbar(
SnackbarState(
message = snackbarString
)
)
}
}
override fun onFirstRender() {
@@ -282,6 +501,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
private fun handleDeepLinkIntent(intent: Intent) {
handleConversationIntent(intent)
handleGroupLinkInIntent(intent)
handleProxyInIntent(intent)
handleSignalMeIntent(intent)
@@ -289,10 +509,6 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
handleDonateReturnIntent(intent)
}
private fun updateNavigationBarColor() {
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorSurface2))
}
@SuppressLint("NewApi")
private fun presentVitalsState(state: VitalsViewModel.State) {
when (state) {
@@ -306,6 +522,12 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
}
private fun handleConversationIntent(intent: Intent) {
if (ConversationIntents.isConversationIntent(intent)) {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(intent))
}
}
private fun handleGroupLinkInIntent(intent: Intent) {
intent.data?.let { data ->
CommunicationActions.handlePotentialGroupLinkUrl(this, data.toString())
@@ -418,4 +640,96 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
toolbarViewModel.setShowNotificationProfilesTooltip(false)
}
}
inner class BottomChromeCallback : MainBottomChromeCallback {
override fun onNewChatClick() {
startActivity(Intent(this@MainActivity, NewConversationActivity::class.java))
}
override fun onNewCallClick() {
startActivity(NewCallActivity.createIntent(this@MainActivity))
}
override fun onCameraClick(destination: MainNavigationListLocation) {
val onGranted = {
startActivity(
MediaSelectionActivity.camera(
context = this@MainActivity,
isStory = destination == MainNavigationListLocation.STORIES
)
)
}
if (CameraXUtil.isSupported()) {
onGranted()
} else {
Permissions.with(this@MainActivity)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.withRationaleDialog(getString(R.string.CameraXFragment_allow_access_camera), getString(R.string.CameraXFragment_to_capture_photos_and_video_allow_camera), R.drawable.symbol_camera_24)
.withPermanentDenialDialog(
getString(R.string.CameraXFragment_signal_needs_camera_access_capture_photos),
null,
R.string.CameraXFragment_allow_access_camera,
R.string.CameraXFragment_to_capture_photos_videos,
supportFragmentManager
)
.onAllGranted(onGranted)
.onAnyDenied { Toast.makeText(this@MainActivity, R.string.CameraXFragment_signal_needs_camera_access_capture_photos, Toast.LENGTH_LONG).show() }
.execute()
}
}
override fun onMegaphoneVisible(megaphone: Megaphone) {
mainNavigationViewModel.onMegaphoneVisible(megaphone)
}
override fun onSnackbarDismissed() {
mainNavigationViewModel.setSnackbar(null)
}
}
inner class MainMegaphoneActionController : MegaphoneActionController {
override fun onMegaphoneNavigationRequested(intent: Intent) {
startActivity(intent)
}
override fun onMegaphoneNavigationRequested(intent: Intent, requestCode: Int) {
startActivityForResult(intent, requestCode)
}
override fun onMegaphoneToastRequested(string: String) {
mainNavigationViewModel.setSnackbar(
SnackbarState(
message = string
)
)
}
override fun getMegaphoneActivity(): Activity {
return this@MainActivity
}
override fun onMegaphoneSnooze(event: Megaphones.Event) {
mainNavigationViewModel.onMegaphoneSnoozed(event)
}
override fun onMegaphoneCompleted(event: Megaphones.Event) {
mainNavigationViewModel.onMegaphoneCompleted(event)
}
override fun onMegaphoneDialogFragmentRequested(dialogFragment: DialogFragment) {
dialogFragment.show(supportFragmentManager, "megaphone_dialog")
}
}
private inner class MainNavigationCallback : (MainNavigationListLocation) -> Unit {
override fun invoke(location: MainNavigationListLocation) {
when (location) {
MainNavigationListLocation.CHATS -> mainNavigationViewModel.onChatsSelected()
MainNavigationListLocation.CALLS -> mainNavigationViewModel.onCallsSelected()
MainNavigationListLocation.STORIES -> mainNavigationViewModel.onStoriesSelected()
}
}
}
}

View File

@@ -3,13 +3,9 @@ package org.thoughtcrime.securesms;
import android.app.Activity;
import android.content.Intent;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.ViewModelProvider;
import androidx.lifecycle.viewmodel.internal.ViewModelProviders;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
@@ -27,25 +23,16 @@ public class MainNavigator {
private final AppCompatActivity activity;
private final LifecycleDisposable lifecycleDisposable;
private final MainNavigationViewModel viewModel;
private MainNavigationViewModel viewModel;
public MainNavigator(@NonNull AppCompatActivity activity) {
public MainNavigator(@NonNull AppCompatActivity activity, @NonNull MainNavigationViewModel viewModel) {
this.activity = activity;
this.lifecycleDisposable = new LifecycleDisposable();
this.viewModel = viewModel;
lifecycleDisposable.bindTo(activity);
}
@MainThread
public @NonNull MainNavigationViewModel getViewModel() {
if (viewModel == null) {
viewModel = new ViewModelProvider(activity).get(MainNavigationViewModel.class);
}
return viewModel;
}
public static MainNavigator get(@NonNull Activity activity) {
if (!(activity instanceof MainActivity)) {
throw new IllegalArgumentException("Activity must be an instance of MainActivity!");
@@ -54,20 +41,6 @@ public class MainNavigator {
return ((NavigatorProvider) activity).getNavigator();
}
/**
* @return True if the back pressed was handled in our own custom way, false if it should be given
* to the system to do the default behavior.
*/
public boolean onBackPressed() {
Fragment fragment = getFragmentManager().findFragmentById(R.id.fragment_container);
if (fragment instanceof BackHandler) {
return ((BackHandler) fragment).onBackPressed();
}
return false;
}
public void goToConversation(@NonNull RecipientId recipientId, long threadId, int distributionType, int startingPosition) {
Disposable disposable = ConversationIntents.createBuilder(activity, recipientId, threadId)
.map(builder -> builder.withDistributionType(distributionType)

View File

@@ -9,12 +9,18 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.map
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.database.model.ProfileAvatarFileDetails
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.NameUtil
@Composable
fun AvatarImage(
@@ -28,15 +34,24 @@ fun AvatarImage(
.background(color = Color.Red, shape = CircleShape)
)
} else {
val context = LocalContext.current
val state = recipient.live().liveData.map { AvatarImageState(NameUtil.getAbbreviation(it.getDisplayName(context)), it, AvatarHelper.getAvatarFileDetails(context, it.id)) }.observeAsState().value ?: return
AndroidView(
factory = ::AvatarImageView,
modifier = modifier.background(color = Color.Transparent, shape = CircleShape)
) {
if (useProfile) {
it.setAvatarUsingProfile(recipient)
it.setAvatarUsingProfile(state.self)
} else {
it.setAvatar(recipient)
it.setAvatar(state.self)
}
}
}
}
private data class AvatarImageState(
val displayName: String?,
val self: Recipient,
val avatarFileDetails: ProfileAvatarFileDetails
)

View File

@@ -183,6 +183,14 @@ object ImportSkips {
return log(sentTimestamp, "Failed to find a threadId for the provided chatId. ChatId in backup: $chatId")
}
fun chatFolderIdNotFound(): String {
return log(0, "Failed to parse chatFolderId for the provided chat folder.")
}
fun notificationProfileIdNotFound(): String {
return log(0, "Failed to parse notificationProfileId for the provided notification profile.")
}
private fun log(sentTimestamp: Long, message: String): String {
return "[SKIP][$sentTimestamp] $message"
}

View File

@@ -6,10 +6,13 @@
package org.thoughtcrime.securesms.backup.v2.processor
import androidx.core.content.contentValuesOf
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64
import org.signal.core.util.SqlUtil
import org.signal.core.util.insertInto
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.ImportSkips
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.proto.ChatFolder
import org.thoughtcrime.securesms.backup.v2.proto.Frame
@@ -19,6 +22,8 @@ import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderMembership
import org.thoughtcrime.securesms.database.ChatFolderTables.ChatFolderTable
import org.thoughtcrime.securesms.database.ChatFolderTables.MembershipType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.whispersystems.signalservice.api.util.UuidUtil
import org.thoughtcrime.securesms.backup.v2.proto.ChatFolder as ChatFolderProto
/**
@@ -31,7 +36,7 @@ object ChatFolderProcessor {
fun export(db: SignalDatabase, exportState: ExportState, emitter: BackupFrameEmitter) {
val folders = db
.chatFoldersTable
.getChatFolders()
.getCurrentChatFolders()
.sortedBy { it.position }
if (folders.isEmpty()) {
@@ -66,6 +71,12 @@ object ChatFolderProcessor {
}
fun import(chatFolder: ChatFolderProto, importState: ImportState) {
val chatFolderUuid = UuidUtil.parseOrNull(chatFolder.id)
if (chatFolderUuid == null) {
ImportSkips.chatFolderIdNotFound()
return
}
val chatFolderId = SignalDatabase
.writableDatabase
.insertInto(ChatFolderTable.TABLE_NAME)
@@ -76,7 +87,9 @@ object ChatFolderProcessor {
ChatFolderTable.SHOW_MUTED to chatFolder.showMutedChats,
ChatFolderTable.SHOW_INDIVIDUAL to chatFolder.includeAllIndividualChats,
ChatFolderTable.SHOW_GROUPS to chatFolder.includeAllGroupChats,
ChatFolderTable.FOLDER_TYPE to chatFolder.folderType.toLocal().value
ChatFolderTable.FOLDER_TYPE to chatFolder.folderType.toLocal().value,
ChatFolderTable.CHAT_FOLDER_ID to chatFolderUuid.toString(),
ChatFolderTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(StorageSyncHelper.generateKey())
)
.run()
@@ -110,7 +123,8 @@ private fun ChatFolderRecord.toBackupFrame(includedRecipientIds: List<Long>, exc
else -> throw IllegalStateException("Only ALL or CUSTOM should be in the db")
},
includedRecipientIds = includedRecipientIds,
excludedRecipientIds = excludedRecipientIds
excludedRecipientIds = excludedRecipientIds,
id = UuidUtil.toByteArray(this.chatFolderId.uuid).toByteString()
)
return Frame(chatFolder = chatFolder)

View File

@@ -5,10 +5,12 @@
package org.thoughtcrime.securesms.backup.v2.processor
import okio.ByteString.Companion.toByteString
import org.signal.core.util.insertInto
import org.signal.core.util.logging.Log
import org.signal.core.util.toInt
import org.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.ImportSkips
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
@@ -20,7 +22,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.serialize
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.RecipientId
import java.lang.IllegalStateException
import org.whispersystems.signalservice.api.util.UuidUtil
import java.time.DayOfWeek
import org.thoughtcrime.securesms.backup.v2.proto.NotificationProfile as NotificationProfileProto
@@ -41,6 +43,12 @@ object NotificationProfileProcessor {
}
fun import(profile: NotificationProfileProto, importState: ImportState) {
val notificationProfileUuid = UuidUtil.parseOrNull(profile.id)
if (notificationProfileUuid == null) {
ImportSkips.notificationProfileIdNotFound()
return
}
val profileId = SignalDatabase
.writableDatabase
.insertInto(NotificationProfileTable.TABLE_NAME)
@@ -50,7 +58,8 @@ object NotificationProfileProcessor {
NotificationProfileTable.COLOR to (AvatarColor.fromColor(profile.color) ?: AvatarColor.random()).serialize(),
NotificationProfileTable.CREATED_AT to profile.createdAtMs,
NotificationProfileTable.ALLOW_ALL_CALLS to profile.allowAllCalls.toInt(),
NotificationProfileTable.ALLOW_ALL_MENTIONS to profile.allowAllMentions.toInt()
NotificationProfileTable.ALLOW_ALL_MENTIONS to profile.allowAllMentions.toInt(),
NotificationProfileTable.NOTIFICATION_PROFILE_ID to notificationProfileUuid.toString()
)
.run()
@@ -89,6 +98,7 @@ object NotificationProfileProcessor {
private fun NotificationProfile.toBackupFrame(includeRecipient: (RecipientId) -> Boolean): Frame {
val profile = NotificationProfileProto(
id = UuidUtil.toByteArray(this.notificationProfileId.uuid).toByteString(),
name = this.name,
emoji = this.emoji.takeIf { it.isNotBlank() },
color = this.color.colorInt(),

View File

@@ -28,16 +28,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
@@ -47,6 +42,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.os.BundleCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.parcelize.Parcelize
import org.signal.core.ui.compose.BottomSheets
@@ -56,25 +52,20 @@ import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.billing.launchManageBackupsSubscription
import org.thoughtcrime.securesms.billing.upgrade.UpgradeToPaidTierBottomSheet
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.PlayStoreUtil
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import org.signal.core.ui.R as CoreUiR
/**
* Notifies the user of an issue with their backup.
*/
class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.75f
@@ -82,8 +73,12 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
private const val ARG_ALERT = "alert"
@JvmStatic
fun create(backupAlert: BackupAlert): BackupAlertBottomSheet {
return BackupAlertBottomSheet().apply {
fun create(backupAlert: BackupAlert): DialogFragment {
return if (backupAlert is BackupAlert.MediaBackupsAreOff) {
MediaBackupsAreOffBottomSheet()
} else {
BackupAlertBottomSheet()
}.apply {
arguments = bundleOf(ARG_ALERT to backupAlert)
}
}
@@ -94,34 +89,20 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
}
@Composable
override fun UpgradeSheetContent(
paidBackupType: MessageBackupsType.Paid,
freeBackupType: MessageBackupsType.Free,
isSubscribeEnabled: Boolean,
onSubscribeClick: () -> Unit
) {
var pricePerMonth by remember { mutableStateOf("-") }
val resources = LocalContext.current.resources
LaunchedEffect(paidBackupType.pricePerMonth) {
pricePerMonth = FiatMoneyUtil.format(resources, paidBackupType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
}
val performPrimaryAction = remember(onSubscribeClick) {
createPrimaryAction(onSubscribeClick)
override fun SheetContent() {
val performPrimaryAction = remember(backupAlert) {
createPrimaryAction()
}
BackupAlertSheetContent(
backupAlert = backupAlert,
isSubscribeEnabled = isSubscribeEnabled,
mediaTtl = paidBackupType.mediaTtl,
onPrimaryActionClick = performPrimaryAction,
onSecondaryActionClick = this::performSecondaryAction
)
}
@Stable
private fun createPrimaryAction(onSubscribeClick: () -> Unit): () -> Unit = {
private fun createPrimaryAction(): () -> Unit = {
when (backupAlert) {
is BackupAlert.CouldNotCompleteBackup -> {
BackupMessagesJob.enqueue()
@@ -129,9 +110,7 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
}
BackupAlert.FailedToRenew -> launchManageBackupsSubscription()
is BackupAlert.MediaBackupsAreOff -> {
onSubscribeClick()
}
is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.")
BackupAlert.MediaWillBeDeletedToday -> {
performFullMediaDownload()
@@ -152,7 +131,7 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
when (backupAlert) {
is BackupAlert.CouldNotCompleteBackup -> Unit
BackupAlert.FailedToRenew -> Unit
is BackupAlert.MediaBackupsAreOff -> Unit
is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.")
BackupAlert.MediaWillBeDeletedToday -> {
displayLastChanceDialog()
}
@@ -212,11 +191,8 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
}
@Composable
private fun BackupAlertSheetContent(
fun BackupAlertSheetContent(
backupAlert: BackupAlert,
pricePerMonth: String = "",
isSubscribeEnabled: Boolean = true,
mediaTtl: Duration,
onPrimaryActionClick: () -> Unit = {},
onSecondaryActionClick: () -> Unit = {}
) {
@@ -231,7 +207,8 @@ private fun BackupAlertSheetContent(
Spacer(modifier = Modifier.size(26.dp))
when (backupAlert) {
BackupAlert.FailedToRenew, is BackupAlert.MediaBackupsAreOff -> {
is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.")
BackupAlert.FailedToRenew -> {
Box {
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.image_signal_backups),
@@ -276,29 +253,27 @@ private fun BackupAlertSheetContent(
)
BackupAlert.FailedToRenew -> PaymentProcessingBody()
is BackupAlert.MediaBackupsAreOff -> MediaBackupsAreOffBody(backupAlert.endOfPeriodSeconds, mediaTtl)
BackupAlert.MediaWillBeDeletedToday -> MediaWillBeDeletedTodayBody()
is BackupAlert.DiskFull -> DiskFullBody(requiredSpace = backupAlert.requiredSpace)
BackupAlert.BackupFailed -> BackupFailedBody()
BackupAlert.CouldNotRedeemBackup -> CouldNotRedeemBackup()
is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.")
}
val secondaryActionResource = rememberSecondaryActionResource(backupAlert = backupAlert)
val padBottom = if (secondaryActionResource > 0) 16.dp else 56.dp
Buttons.LargeTonal(
enabled = isSubscribeEnabled,
onClick = onPrimaryActionClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = padBottom)
) {
Text(text = primaryActionString(backupAlert = backupAlert, pricePerMonth = pricePerMonth))
Text(text = primaryActionString(backupAlert = backupAlert))
}
if (secondaryActionResource > 0) {
TextButton(
enabled = isSubscribeEnabled,
onClick = onSecondaryActionClick,
modifier = Modifier.padding(bottom = 32.dp)
) {
@@ -381,28 +356,6 @@ private fun PaymentProcessingBody() {
)
}
@Composable
private fun MediaBackupsAreOffBody(
endOfPeriodSeconds: Long,
mediaTtl: Duration
) {
val daysUntilDeletion = remember { endOfPeriodSeconds.days + mediaTtl }.inWholeDays.toInt()
Text(
text = pluralStringResource(id = R.plurals.BackupAlertBottomSheet__your_backup_plan_has_expired, daysUntilDeletion, daysUntilDeletion),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 24.dp)
)
Text(
text = stringResource(id = R.string.BackupAlertBottomSheet__you_can_begin_paying_for_backups_again),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 36.dp)
)
}
@Composable
private fun MediaWillBeDeletedTodayBody() {
Text(
@@ -473,13 +426,12 @@ private fun titleString(backupAlert: BackupAlert): String {
@Composable
private fun primaryActionString(
backupAlert: BackupAlert,
pricePerMonth: String
backupAlert: BackupAlert
): String {
return when (backupAlert) {
is BackupAlert.CouldNotCompleteBackup -> stringResource(R.string.BackupAlertBottomSheet__back_up_now)
BackupAlert.FailedToRenew -> stringResource(R.string.BackupAlertBottomSheet__manage_subscription)
is BackupAlert.MediaBackupsAreOff -> stringResource(R.string.BackupAlertBottomSheet__subscribe_for_s_month, pricePerMonth)
is BackupAlert.MediaBackupsAreOff -> error("Not supported.")
BackupAlert.MediaWillBeDeletedToday -> stringResource(R.string.BackupAlertBottomSheet__download_media_now)
is BackupAlert.DiskFull -> stringResource(R.string.BackupAlertBottomSheet__got_it)
is BackupAlert.BackupFailed -> stringResource(R.string.BackupAlertBottomSheet__check_for_update)
@@ -493,7 +445,7 @@ private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int {
when (backupAlert) {
is BackupAlert.CouldNotCompleteBackup -> R.string.BackupAlertBottomSheet__try_later
BackupAlert.FailedToRenew -> R.string.BackupAlertBottomSheet__not_now
is BackupAlert.MediaBackupsAreOff -> R.string.BackupAlertBottomSheet__not_now
is BackupAlert.MediaBackupsAreOff -> error("Not supported.")
BackupAlert.MediaWillBeDeletedToday -> R.string.BackupAlertBottomSheet__dont_download_media
is BackupAlert.DiskFull -> R.string.BackupAlertBottomSheet__skip_restore
is BackupAlert.BackupFailed -> R.string.BackupAlertBottomSheet__learn_more
@@ -507,8 +459,7 @@ private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int {
private fun BackupAlertSheetContentPreviewGeneric() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.CouldNotCompleteBackup(daysSinceLastBackup = 7),
mediaTtl = 60.days
backupAlert = BackupAlert.CouldNotCompleteBackup(daysSinceLastBackup = 7)
)
}
}
@@ -518,20 +469,7 @@ private fun BackupAlertSheetContentPreviewGeneric() {
private fun BackupAlertSheetContentPreviewPayment() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.FailedToRenew,
mediaTtl = 60.days
)
}
}
@SignalPreview
@Composable
private fun BackupAlertSheetContentPreviewMedia() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.MediaBackupsAreOff(endOfPeriodSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds),
pricePerMonth = "$2.99",
mediaTtl = 60.days
backupAlert = BackupAlert.FailedToRenew
)
}
}
@@ -541,8 +479,7 @@ private fun BackupAlertSheetContentPreviewMedia() {
private fun BackupAlertSheetContentPreviewDelete() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.MediaWillBeDeletedToday,
mediaTtl = 60.days
backupAlert = BackupAlert.MediaWillBeDeletedToday
)
}
}
@@ -552,8 +489,7 @@ private fun BackupAlertSheetContentPreviewDelete() {
private fun BackupAlertSheetContentPreviewDiskFull() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.DiskFull(requiredSpace = "12GB"),
mediaTtl = 60.days
backupAlert = BackupAlert.DiskFull(requiredSpace = "12GB")
)
}
}
@@ -563,8 +499,7 @@ private fun BackupAlertSheetContentPreviewDiskFull() {
private fun BackupAlertSheetContentPreviewBackupFailed() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.BackupFailed,
mediaTtl = 60.days
backupAlert = BackupAlert.BackupFailed
)
}
}
@@ -574,8 +509,7 @@ private fun BackupAlertSheetContentPreviewBackupFailed() {
private fun BackupAlertSheetContentPreviewCouldNotRedeemBackup() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.CouldNotRedeemBackup,
mediaTtl = 60.days
backupAlert = BackupAlert.CouldNotRedeemBackup
)
}
}

View File

@@ -0,0 +1,171 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.os.BundleCompat
import org.signal.core.ui.R
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.util.gibiBytes
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.billing.upgrade.UpgradeToPaidTierBottomSheet
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import java.math.BigDecimal
import java.util.Currency
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class MediaBackupsAreOffBottomSheet : UpgradeToPaidTierBottomSheet() {
companion object {
private const val ARG_ALERT = "alert"
}
private val backupAlert: BackupAlert by lazy(LazyThreadSafetyMode.NONE) {
BundleCompat.getParcelable(requireArguments(), ARG_ALERT, BackupAlert::class.java)!!
}
@Composable
override fun UpgradeSheetContent(
paidBackupType: MessageBackupsType.Paid,
freeBackupType: MessageBackupsType.Free,
isSubscribeEnabled: Boolean,
onSubscribeClick: () -> Unit
) {
SheetContent(
backupAlert as BackupAlert.MediaBackupsAreOff,
paidBackupType,
isSubscribeEnabled,
onSubscribeClick,
onNotNowClick = { dismissAllowingStateLoss() }
)
}
}
@Composable
private fun SheetContent(
mediaBackupsAreOff: BackupAlert.MediaBackupsAreOff,
paidBackupType: MessageBackupsType.Paid,
isSubscribeEnabled: Boolean,
onSubscribeClick: () -> Unit,
onNotNowClick: () -> Unit
) {
val resources = LocalContext.current.resources
val pricePerMonth = remember(paidBackupType) {
FiatMoneyUtil.format(resources, paidBackupType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(id = R.dimen.gutter))
) {
BottomSheets.Handle()
Spacer(modifier = Modifier.size(26.dp))
Box {
Image(
imageVector = ImageVector.vectorResource(id = org.thoughtcrime.securesms.R.drawable.image_signal_backups),
contentDescription = null,
modifier = Modifier
.size(80.dp)
.padding(2.dp)
)
Icon(
imageVector = ImageVector.vectorResource(org.thoughtcrime.securesms.R.drawable.symbol_error_circle_fill_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.align(Alignment.TopEnd)
)
}
val daysUntilDeletion = remember(mediaBackupsAreOff.endOfPeriodSeconds, paidBackupType.mediaTtl) {
((System.currentTimeMillis().milliseconds - mediaBackupsAreOff.endOfPeriodSeconds.seconds) + paidBackupType.mediaTtl).inWholeDays.toInt()
}
Text(
text = pluralStringResource(id = org.thoughtcrime.securesms.R.plurals.BackupAlertBottomSheet__your_backup_plan_has_expired, daysUntilDeletion, daysUntilDeletion),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 24.dp)
)
Text(
text = stringResource(id = org.thoughtcrime.securesms.R.string.BackupAlertBottomSheet__you_can_begin_paying_for_backups_again),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 36.dp)
)
Buttons.LargeTonal(
enabled = isSubscribeEnabled,
onClick = onSubscribeClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 16.dp)
) {
Text(text = stringResource(org.thoughtcrime.securesms.R.string.BackupAlertBottomSheet__subscribe_for_s_month, pricePerMonth))
}
TextButton(
enabled = isSubscribeEnabled,
onClick = onNotNowClick,
modifier = Modifier.padding(bottom = 32.dp)
) {
Text(text = stringResource(id = org.thoughtcrime.securesms.R.string.BackupAlertBottomSheet__not_now))
}
}
}
@SignalPreview
@Composable
private fun BackupAlertSheetContentPreviewMedia() {
Previews.BottomSheetPreview {
SheetContent(
mediaBackupsAreOff = BackupAlert.MediaBackupsAreOff(endOfPeriodSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds),
paidBackupType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")),
mediaTtl = 30.days,
storageAllowanceBytes = 1.gibiBytes.inWholeBytes
),
isSubscribeEnabled = true,
onSubscribeClick = {},
onNotNowClick = {}
)
}
}

View File

@@ -24,7 +24,6 @@ import org.signal.core.util.billing.BillingPurchaseResult
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue
@@ -85,10 +84,15 @@ class MessageBackupsFlowViewModel(
}
viewModelScope.launch {
val availableBackupTypes = withContext(SignalDispatchers.IO) {
BackupRepository.getAvailableBackupsTypes(
if (!RemoteConfig.messageBackups) emptyList() else listOf(MessageBackupTier.FREE, MessageBackupTier.PAID)
)
val availableBackupTypes = try {
withContext(SignalDispatchers.IO) {
BackupRepository.getAvailableBackupsTypes(
if (!RemoteConfig.messageBackups) emptyList() else listOf(MessageBackupTier.FREE, MessageBackupTier.PAID)
)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to download available backup types.", e)
emptyList()
}
internalStateFlow.update {
@@ -133,12 +137,12 @@ class MessageBackupsFlowViewModel(
}
} catch (e: Exception) {
Log.d(TAG, "Failed to handle purchase.", e)
InAppPaymentsRepository.handlePipelineError(
inAppPaymentId = id,
donationErrorSource = DonationErrorSource.BACKUPS,
paymentSourceType = PaymentSourceType.GooglePlayBilling,
error = e
)
withContext(SignalDispatchers.IO) {
InAppPaymentsRepository.handlePipelineError(
inAppPaymentId = id,
error = e
)
}
internalStateFlow.update {
it.copy(

View File

@@ -39,7 +39,7 @@ fun MessageBackupsKeyEducationScreen(
) {
Scaffolds.Settings(
title = "",
navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
navigationIconPainter = painterResource(R.drawable.symbol_arrow_start_24),
onNavigationClick = onNavigationClick
) {
Column(

View File

@@ -55,7 +55,7 @@ fun MessageBackupsKeyRecordScreen(
Scaffolds.Settings(
title = "",
navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
navigationIconPainter = painterResource(R.drawable.symbol_arrow_start_24),
onNavigationClick = onNavigationClick
) { paddingValues ->
Column(

View File

@@ -87,7 +87,7 @@ fun MessageBackupsKeyVerifyScreen(
Scaffolds.Settings(
title = stringResource(R.string.MessageBackupsKeyVerifyScreen__confirm_your_backup_key),
navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
navigationIconPainter = painterResource(R.drawable.symbol_arrow_start_24),
onNavigationClick = onNavigationClick
) { paddingValues ->

View File

@@ -55,7 +55,7 @@ import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.fonts.SignalSymbols
import org.thoughtcrime.securesms.fonts.SignalSymbols.SignalSymbol
import org.thoughtcrime.securesms.fonts.SignalSymbols.signalSymbolText
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.ByteUnit
import java.math.BigDecimal
@@ -82,7 +82,7 @@ fun MessageBackupsTypeSelectionScreen(
Scaffolds.Settings(
title = "",
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_start_24)
) { paddingValues ->
Column(
modifier = Modifier
@@ -260,11 +260,10 @@ fun MessageBackupsTypeBlock(
) {
if (isCurrent) {
Text(
text = buildAnnotatedString {
SignalSymbol(weight = SignalSymbols.Weight.REGULAR, glyph = SignalSymbols.Glyph.CHECKMARK)
append(" ")
append(stringResource(R.string.MessageBackupsTypeSelectionScreen__current_plan))
},
text = signalSymbolText(
text = stringResource(R.string.MessageBackupsTypeSelectionScreen__current_plan),
glyphStart = SignalSymbols.Glyph.CHECK
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(bottom = 12.dp)

View File

@@ -15,7 +15,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.MainActivity
@@ -58,10 +57,6 @@ class GiftFlowConfirmationFragment :
EmojiSearchFragment.Callback,
InAppPaymentCheckoutDelegate.Callback {
companion object {
private val TAG = Log.tag(GiftFlowConfirmationFragment::class.java)
}
private val viewModel: GiftFlowViewModel by viewModels(
ownerProducer = { requireActivity() }
)
@@ -118,7 +113,7 @@ class GiftFlowConfirmationFragment :
lifecycleDisposable += viewModel.insertInAppPayment().subscribe { inAppPayment ->
findNavController().safeNavigate(
GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToGatewaySelectorBottomSheet(
inAppPayment
inAppPayment.id
)
)
}
@@ -266,8 +261,7 @@ class GiftFlowConfirmationFragment :
findNavController().safeNavigate(
GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToStripePaymentInProgressFragment(
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT,
inAppPayment,
inAppPayment.type
inAppPayment.id
)
)
}
@@ -276,15 +270,14 @@ class GiftFlowConfirmationFragment :
findNavController().safeNavigate(
GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToPaypalPaymentInProgressFragment(
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT,
inAppPayment,
inAppPayment.type
inAppPayment.id
)
)
}
override fun navigateToCreditCardForm(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(inAppPayment)
GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(inAppPayment.id)
)
}

View File

@@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
@@ -38,6 +39,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
private BlockedUsersViewModel viewModel;
private View container;
private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable();
@@ -57,7 +59,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
Toolbar toolbar = findViewById(R.id.toolbar);
ContactFilterView contactFilterView = findViewById(R.id.contact_filter_edit_text);
View container = findViewById(R.id.fragment_container);
container = findViewById(R.id.fragment_container);
toolbar.setNavigationOnClickListener(unused -> onBackPressed());
contactFilterView.setOnFilterChangedListener(query -> {
@@ -99,11 +101,41 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
@Override
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
final String displayName = recipientId.map(id -> Recipient.resolved(id).getDisplayName(this)).orElse(number);
Optional<Recipient> resolvedRecipient = recipientId.map(Recipient::resolved);
AlertDialog confirmationDialog = new MaterialAlertDialogBuilder(this)
.setTitle(R.string.BlockedUsersActivity__block_user)
.setMessage(getString(R.string.BlockedUserActivity__s_will_not_be_able_to, displayName))
final String displayName = resolvedRecipient
.map(r -> r.getDisplayName(this))
.orElse(number);
boolean isSelf = resolvedRecipient
.map(Recipient::isSelf)
.orElseGet(() -> Optional.ofNullable(number)
.map(Recipient::external)
.map(Recipient::isSelf)
.orElse(false));
if (isSelf) {
Snackbar.make(container, getString(R.string.BlockedUsersActivity__cannot_block_yourself), Snackbar.LENGTH_SHORT).show();
return;
}
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
if (resolvedRecipient.isPresent() && resolvedRecipient.get().isGroup()) {
Recipient recipient = resolvedRecipient.get();
if (SignalDatabase.groups().isActive(recipient.requireGroupId())) {
builder.setTitle(getString(R.string.BlockUnblockDialog_block_and_leave_s, displayName));
builder.setMessage(R.string.BlockUnblockDialog_you_will_no_longer_receive_messages_or_updates);
} else {
builder.setTitle(getString(R.string.BlockUnblockDialog_block_s, displayName));
builder.setMessage(R.string.BlockUnblockDialog_group_members_wont_be_able_to_add_you);
}
} else {
builder.setTitle(R.string.BlockedUsersActivity__block_user);
builder.setMessage(getString(R.string.BlockedUserActivity__s_will_not_be_able_to, displayName));
}
AlertDialog confirmationDialog = builder
.setPositiveButton(R.string.BlockedUsersActivity__block, (dialog, which) -> {
if (recipientId.isPresent()) {
viewModel.block(recipientId.get());

View File

@@ -18,6 +18,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -242,6 +244,7 @@ private fun CreateCallLinkBottomSheetContent(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize(Alignment.Center)
.verticalScroll(rememberScrollState())
) {
BottomSheets.Handle(modifier = Modifier.align(Alignment.CenterHorizontally))

View File

@@ -12,6 +12,8 @@ import android.view.View
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -287,7 +289,11 @@ private fun CallLinkDetails(
return@Settings
}
Column(modifier = Modifier.padding(paddingValues)) {
Column(
modifier = Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
SignalCallRow(
callLink = state.callLink,
callLinkPeekInfo = state.peekInfo,

View File

@@ -9,20 +9,15 @@ import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.compose.material3.SnackbarDuration
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.SharedElementCallback
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.TransitionInflater
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.kotlin.Flowables
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.DimensionUnit
@@ -31,9 +26,7 @@ import org.signal.core.util.concurrent.addTo
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.MainNavigator
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.calls.new.NewCallActivity
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
import org.thoughtcrime.securesms.components.ViewBinderDelegate
@@ -50,19 +43,19 @@ import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterLerp
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterPullState
import org.thoughtcrime.securesms.databinding.CallLogFragmentBinding
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.main.MainNavigationDestination
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.main.MainToolbarMode
import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.main.SnackbarState
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.doAfterNextLayout
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.visible
import java.util.Objects
import java.util.concurrent.TimeUnit
/**
* Call Log tab.
@@ -89,12 +82,10 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
private lateinit var signalBottomActionBarController: SignalBottomActionBarController
private val viewModel: CallLogViewModel by activityViewModels()
private val tabsViewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() })
private val mainToolbarViewModel: MainToolbarViewModel by activityViewModels()
private val mainNavigationViewModel: MainNavigationViewModel by activityViewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
initializeSharedElementTransition()
viewLifecycleOwner.lifecycle.addObserver(conversationUpdateTick)
viewLifecycleOwner.lifecycle.addObserver(viewModel.callLogPeekHelper)
@@ -150,9 +141,6 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
this.callLogAdapter = callLogAdapter
requireListener<Material3OnScrollHelperBinder>().bindScrollHelper(binding.recycler, viewLifecycleOwner)
binding.fab.setOnClickListener {
startActivity(NewCallActivity.createIntent(requireContext()))
}
binding.pullView.setPillText(R.string.CallLogFragment__filtered_by_missed)
@@ -180,7 +168,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (!closeSearchIfOpen()) {
tabsViewModel.onChatsSelected()
mainNavigationViewModel.onChatsSelected()
}
}
}
@@ -204,29 +192,9 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
callLogAdapter?.onTimestampTick()
}
private fun initializeSharedElementTransition() {
ViewCompat.setTransitionName(binding.fab, "new_convo_fab")
ViewCompat.setTransitionName(binding.fabSharedElementTarget, "camera_fab")
sharedElementEnterTransition = TransitionInflater.from(requireContext()).inflateTransition(R.transition.change_transform_fabs)
setEnterSharedElementCallback(object : SharedElementCallback() {
override fun onSharedElementStart(sharedElementNames: MutableList<String>?, sharedElements: MutableList<View>?, sharedElementSnapshots: MutableList<View>?) {
if (sharedElementNames?.contains("camera_fab") == true) {
this@CallLogFragment.binding.fab.setImageResource(R.drawable.symbol_edit_24)
disposables += Single.timer(200, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
this@CallLogFragment.binding.fab.setImageResource(R.drawable.symbol_phone_plus_24)
this@CallLogFragment.binding.fabSharedElementTarget.alpha = 0f
}
}
}
})
}
private fun initializeTapToScrollToTop(scrollToPositionDelegate: ScrollToPositionDelegate) {
disposables += tabsViewModel.tabClickEvents
.filter { it == MainNavigationDestination.CALLS }
disposables += mainNavigationViewModel.tabClickEvents
.filter { it == MainNavigationListLocation.CALLS }
.subscribeBy(onNext = {
scrollToPositionDelegate.resetScrollPosition()
})
@@ -363,14 +331,22 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
override fun onStartAudioCallClicked(recipient: Recipient) {
CommunicationActions.startVoiceCall(this, recipient) {
YouAreAlreadyInACallSnackbar.show(requireView())
mainNavigationViewModel.setSnackbar(
SnackbarState(
getString(R.string.CommunicationActions__you_are_already_in_a_call)
)
)
}
}
override fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: Boolean) {
if (canUserBeginCall) {
CommunicationActions.startVideoCall(this, recipient) {
YouAreAlreadyInACallSnackbar.show(requireView())
mainNavigationViewModel.setSnackbar(
SnackbarState(
getString(R.string.CommunicationActions__you_are_already_in_a_call)
)
)
}
} else {
ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
@@ -461,13 +437,12 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
CallLogDeletionResult.Success -> {
Snackbar
.make(
binding.root,
snackbarMessage,
Snackbar.LENGTH_SHORT
mainNavigationViewModel.setSnackbar(
SnackbarState(
message = snackbarMessage,
duration = SnackbarDuration.Short
)
.show()
)
}
is CallLogDeletionResult.UnknownFailure -> {
@@ -488,14 +463,12 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
val actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(callback)
requireListener<Callback>().onMultiSelectStarted()
signalBottomActionBarController.setVisibility(true)
binding.fab.visible = false
return actionMode
}
override fun onActionModeWillEnd() {
requireListener<Callback>().onMultiSelectFinished()
signalBottomActionBarController.setVisibility(false)
binding.fab.visible = true
}
override fun getResources(): Resources = resources

View File

@@ -46,7 +46,7 @@ import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
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.NameUtil;
import java.util.Collections;
import java.util.List;
@@ -62,12 +62,13 @@ public final class AvatarImageView extends AppCompatImageView {
private final RequestListener<Drawable> redownloadRequestListener = new RedownloadRequestListener();
private int size;
private boolean inverted;
private OnClickListener listener;
private boolean blurred;
private ChatColors chatColors;
private FixedSizeTarget fixedSizeTarget;
private int size;
private boolean inverted;
private OnClickListener listener;
private boolean blurred;
private ChatColors chatColors;
private String initials;
private FixedSizeTarget fixedSizeTarget;
private @Nullable RecipientContactPhoto recipientContactPhoto;
private @NonNull Drawable unknownRecipientDrawable;
@@ -100,6 +101,7 @@ public final class AvatarImageView extends AppCompatImageView {
unknownRecipientDrawable = new FallbackAvatarDrawable(context, new FallbackAvatar.Resource.Person(AvatarColor.UNKNOWN)).circleCrop();
blurred = false;
chatColors = null;
initials = null;
}
@Override
@@ -123,12 +125,7 @@ public final class AvatarImageView extends AppCompatImageView {
* Shows self as the actual profile picture.
*/
public void setRecipient(@NonNull Recipient recipient, boolean quickContactEnabled) {
if (recipient.isSelf()) {
setAvatar(Glide.with(this), null, quickContactEnabled);
AvatarUtil.loadIconIntoImageView(recipient, this);
} else {
setAvatar(Glide.with(this), recipient, quickContactEnabled);
}
setAvatar(Glide.with(this), recipient, quickContactEnabled, recipient.isSelf());
}
public AvatarOptions.Builder buildOptions() {
@@ -177,10 +174,12 @@ public final class AvatarImageView extends AppCompatImageView {
boolean shouldBlur = recipient.getShouldBlurAvatar();
ChatColors chatColors = recipient.getChatColors();
String initials = NameUtil.getAbbreviation(recipient.getDisplayName(getContext()));
if (!photo.equals(recipientContactPhoto) || shouldBlur != blurred || !Objects.equals(chatColors, this.chatColors)) {
if (!photo.equals(recipientContactPhoto) || shouldBlur != blurred || !Objects.equals(chatColors, this.chatColors) || !Objects.equals(initials, this.initials)) {
requestManager.clear(this);
this.chatColors = chatColors;
this.chatColors = chatColors;
this.initials = initials;
recipientContactPhoto = photo;
FallbackAvatarProvider activeFallbackPhotoProvider = this.fallbackAvatarProvider;
@@ -189,7 +188,7 @@ public final class AvatarImageView extends AppCompatImageView {
@Override
public @NonNull FallbackAvatar getFallbackAvatar(@NonNull Recipient recipient) {
if (recipient.isSelf()) {
return new FallbackAvatar.Resource.Person(recipient.getAvatarColor());
return FallbackAvatar.forTextOrDefault(recipient.getDisplayName(getContext()), recipient.getAvatarColor());
}
return FallbackAvatarProvider.super.getFallbackAvatar(recipient);

View File

@@ -15,12 +15,10 @@ import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.DrawableRes;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import com.airbnb.lottie.LottieAnimationView;
import com.airbnb.lottie.LottieProperty;
@@ -283,9 +281,9 @@ public class ConversationItemFooter extends ConstraintLayout {
private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale, @NonNull ConversationItemDisplayMode displayMode) {
dateView.forceLayout();
if (MessageRecordUtil.isScheduled(messageRecord)) {
if (MessageRecordUtil.isScheduled(messageRecord)) {
dateView.setText(DateUtils.getOnlyTimeString(getContext(), ((MmsMessageRecord) messageRecord).getScheduledDate()));
} else if (messageRecord.isMediaPending()) {
} else if (messageRecord.isMediaPending() && messageRecord.isOutgoing() && !messageRecord.isSent()) {
dateView.setText(null);
} else if (messageRecord.isFailed()) {
int errorMsg;

View File

@@ -187,7 +187,7 @@ public class DocumentView extends FrameLayout {
@Override
public void onClick(View v) {
if (!slide.isPendingDownload() && !slide.isInProgress() && viewListener != null) {
if (slide.hasDocument() && slide.getUri()!=null && viewListener != null) {
viewListener.onClick(v, slide);
}
}

View File

@@ -220,6 +220,9 @@ public class InputPanel extends ConstraintLayout
@NonNull QuoteModel.Type quoteType)
{
this.quoteView.setQuote(requestManager, id, author, body, false, attachments, null, quoteType);
if (listener != null) {
this.quoteView.setOnClickListener(v -> listener.onQuoteClicked(id, author.getId()));
}
int originalHeight = this.quoteView.getVisibility() == VISIBLE ? this.quoteView.getMeasuredHeight()
: 0;
@@ -784,13 +787,17 @@ public class InputPanel extends ConstraintLayout
}
private void updateVisibility() {
if (hideForGroupState || hideForBlockedState || hideForSearch || hideForSelection || hideForMessageRequestState) {
if (isHidden()) {
setVisibility(GONE);
} else {
setVisibility(VISIBLE);
}
}
public boolean isHidden() {
return hideForGroupState || hideForBlockedState || hideForSearch || hideForSelection || hideForMessageRequestState;
}
public @Nullable MessageRecord getEditMessage() {
return messageToEdit;
}
@@ -813,6 +820,7 @@ public class InputPanel extends ConstraintLayout
void onStickerSuggestionSelected(@NonNull StickerRecord sticker);
void onQuoteChanged(long id, @NonNull RecipientId author);
void onQuoteCleared();
void onQuoteClicked(long quoteId, RecipientId authorId);
void onEnterEditMode();
void onExitEditMode();
void onQuickCameraToggleClicked();

View File

@@ -5,6 +5,7 @@ import android.os.Build
import android.util.AttributeSet
import android.util.DisplayMetrics
import android.view.Surface
import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.Guideline
import androidx.core.content.withStyledAttributes
@@ -63,25 +64,63 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
private val displayMetrics = DisplayMetrics()
private var overridingKeyboard: Boolean = false
private var previousKeyboardHeight: Int = 0
private var applyRootInsets: Boolean = false
private var insets: WindowInsetsCompat? = null
private var windowTypes: Int = InsetAwareConstraintLayout.windowTypes
private val windowInsetsListener = androidx.core.view.OnApplyWindowInsetsListener { _, insets ->
this.insets = insets
applyInsets(windowInsets = insets.getInsets(windowTypes), keyboardInsets = insets.getInsets(keyboardType))
insets
}
val isKeyboardShowing: Boolean
get() = previousKeyboardHeight > 0
init {
ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInsetsCompat ->
applyInsets(windowInsets = windowInsetsCompat.getInsets(windowTypes), keyboardInsets = windowInsetsCompat.getInsets(keyboardType))
windowInsetsCompat
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
ViewCompat.setOnApplyWindowInsetsListener(insetTarget(), windowInsetsListener)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
ViewCompat.setOnApplyWindowInsetsListener(insetTarget(), null)
}
init {
if (attrs != null) {
context.withStyledAttributes(attrs, R.styleable.InsetAwareConstraintLayout) {
applyRootInsets = getBoolean(R.styleable.InsetAwareConstraintLayout_applyRootInsets, false)
if (getBoolean(R.styleable.InsetAwareConstraintLayout_animateKeyboardChanges, false)) {
ViewCompat.setWindowInsetsAnimationCallback(this@InsetAwareConstraintLayout, keyboardAnimator)
ViewCompat.setWindowInsetsAnimationCallback(insetTarget(), keyboardAnimator)
}
}
}
}
private fun insetTarget(): View = if (applyRootInsets) rootView else this
/**
* Specifies whether or not window insets should be accounted for when applying
* insets. This is useful when choosing whether to display the content in this
* constraint layout as a full-window view or as a framed view.
*/
fun setUseWindowTypes(useWindowTypes: Boolean) {
windowTypes = if (useWindowTypes) {
InsetAwareConstraintLayout.windowTypes
} else {
0
}
if (insets != null) {
applyInsets(insets!!.getInsets(windowTypes), insets!!.getInsets(keyboardType))
}
}
fun addKeyboardStateListener(listener: KeyboardStateListener) {
keyboardStateListeners += listener
}
@@ -115,10 +154,12 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
if (keyboardInsets.bottom > 0) {
setKeyboardHeight(keyboardInsets.bottom)
if (!keyboardAnimator.animating) {
keyboardGuideline?.setGuidelineEnd(keyboardInsets.bottom)
} else {
keyboardAnimator.endingGuidelineEnd = keyboardInsets.bottom
if (!overridingKeyboard) {
if (!keyboardAnimator.animating) {
keyboardGuideline?.setGuidelineEnd(keyboardInsets.bottom)
} else {
keyboardAnimator.endingGuidelineEnd = keyboardInsets.bottom
}
}
} else if (!overridingKeyboard) {
if (!keyboardAnimator.animating) {
@@ -153,6 +194,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
protected fun resetKeyboardGuideline() {
clearKeyboardGuidelineOverride()
keyboardGuideline?.setGuidelineEnd(navigationBarGuideline.guidelineEnd)
keyboardAnimator.endingGuidelineEnd = navigationBarGuideline.guidelineEnd
}
private fun getKeyboardHeight(): Int {

View File

@@ -47,6 +47,7 @@ import org.thoughtcrime.securesms.components.transfercontrols.TransferControlVie
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.ImageSlide;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
@@ -488,7 +489,12 @@ public class ThumbnailView extends FrameLayout {
transferControlViewStub.setVisibility(View.GONE);
RequestBuilder<Drawable> request = requestManager.load(new DecryptableUri(uri))
Object glideModel = uri;
if (PartAuthority.isLocalUri(uri)) {
glideModel = new DecryptableUri(uri);
}
RequestBuilder<Drawable> request = requestManager.load(glideModel)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.downsample(SignalDownsampleStrategy.CENTER_OUTSIDE_NO_UPSCALE)
.listener(listener);

View File

@@ -9,6 +9,7 @@ import android.view.View;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.exifinterface.media.ExifInterface;
import com.bumptech.glide.RequestManager;
@@ -80,6 +81,12 @@ public class ZoomingImageView extends FrameLayout {
this.subsamplingImageView.setOnClickListener(v -> ZoomingImageView.this.callOnClick());
}
@Override
public void setOnLongClickListener(@Nullable OnLongClickListener l) {
this.gifView.setOnLongClickListener(l);
this.subsamplingImageView.setOnLongClickListener(l);
}
@SuppressLint("StaticFieldLeak")
public void setImageUri(@NonNull RequestManager requestManager, @NonNull Uri uri, @NonNull String contentType, @NonNull Runnable onMediaReady) {
if (MediaUtil.isGif(contentType)) {

View File

@@ -112,6 +112,12 @@ public class EmojiPageView extends RecyclerView implements VariationSelectorList
addItemDecoration(new EmojiItemDecoration(allowVariations, drawable));
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
getParent().requestDisallowInterceptTouchEvent(true);
return super.dispatchTouchEvent(ev);
}
public void presentForEmojiKeyboard() {
setPadding(getPaddingLeft(),
getPaddingTop(),

View File

@@ -181,7 +181,7 @@ private fun AppSettingsContent(
Scaffolds.Settings(
title = stringResource(R.string.text_secure_normal__menu_settings),
navigationContentDescription = stringResource(R.string.CallScreenTopBar__go_back),
navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
navigationIconPainter = painterResource(R.drawable.symbol_arrow_start_24),
onNavigationClick = callbacks::onNavigationClick
) { contentPadding ->
Column(

View File

@@ -125,6 +125,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
clickPref(
title = DSLSettingsText.from(R.string.preferences_chats__transfer_account),
summary = DSLSettingsText.from(R.string.preferences_chats__transfer_account_to_a_new_android_device),
isEnabled = state.isDeprecatedOrUnregistered(),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_oldDeviceTransferActivity)
}

View File

@@ -114,7 +114,7 @@ private fun BackupsSettingsContent(
) {
Scaffolds.Settings(
title = stringResource(R.string.preferences_chats__backups),
navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
navigationIconPainter = painterResource(R.drawable.symbol_arrow_start_24),
onNavigationClick = onNavigationClick
) { paddingValues ->
LazyColumn(

View File

@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow
import kotlinx.coroutines.withContext
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.backup.v2.BackupRepository
@@ -48,30 +50,34 @@ class BackupsSettingsViewModel : ViewModel() {
.filter { it }
.drop(1)
.collect {
refreshState()
Log.d(TAG, "Triggering refresh from internet reconnect.")
loadEnabledState()
}
}
}
fun refreshState() {
Log.d(TAG, "Refreshing state.")
loadEnabledState()
Log.d(TAG, "Refreshing state from manual call.")
viewModelScope.launch {
loadEnabledState()
}
}
private fun loadEnabledState() {
viewModelScope.launch(Dispatchers.IO) {
private suspend fun loadEnabledState() {
withContext(SignalDispatchers.IO) {
if (!RemoteConfig.messageBackups || !AppDependencies.billingApi.isApiAvailable()) {
Log.w(TAG, "Paid backups are not available on this device.")
internalStateFlow.update { it.copy(enabledState = BackupsSettingsState.EnabledState.NotAvailable, showBackupTierInternalOverride = false) }
return@launch
}
} else {
val enabledState = when (SignalStore.backup.backupTier) {
MessageBackupTier.FREE -> getEnabledStateForFreeTier()
MessageBackupTier.PAID -> getEnabledStateForPaidTier()
null -> getEnabledStateForNoTier()
}
val enabledState = when (SignalStore.backup.backupTier) {
MessageBackupTier.FREE -> getEnabledStateForFreeTier()
MessageBackupTier.PAID -> getEnabledStateForPaidTier()
null -> getEnabledStateForNoTier()
Log.d(TAG, "Found enabled state $enabledState. Updating UI state.")
internalStateFlow.update { it.copy(enabledState = enabledState, showBackupTierInternalOverride = RemoteConfig.internalUser, backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride) }
}
internalStateFlow.update { it.copy(enabledState = enabledState, showBackupTierInternalOverride = RemoteConfig.internalUser, backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride) }
}
}
@@ -82,10 +88,14 @@ class BackupsSettingsViewModel : ViewModel() {
private suspend fun getEnabledStateForFreeTier(): BackupsSettingsState.EnabledState {
return try {
Log.d(TAG, "Attempting to grab enabled state for free tier.")
val backupType = BackupRepository.getBackupsType(MessageBackupTier.FREE)!!
Log.d(TAG, "Retrieved backup type. Returning active state...")
BackupsSettingsState.EnabledState.Active(
expiresAt = 0.seconds,
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
type = BackupRepository.getBackupsType(MessageBackupTier.FREE)!!
type = backupType
)
} catch (e: Exception) {
Log.w(TAG, "Failed to build enabled state.", e)
@@ -95,8 +105,13 @@ class BackupsSettingsViewModel : ViewModel() {
private suspend fun getEnabledStateForPaidTier(): BackupsSettingsState.EnabledState {
return try {
Log.d(TAG, "Attempting to grab enabled state for paid tier.")
val backupType = BackupRepository.getBackupsType(MessageBackupTier.PAID) as MessageBackupsType.Paid
Log.d(TAG, "Retrieved backup type. Grabbing active subscription...")
val activeSubscription = RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrThrow()
Log.d(TAG, "Retrieved subscription. Active? ${activeSubscription.isActive}")
if (activeSubscription.isActive) {
BackupsSettingsState.EnabledState.Active(
expiresAt = activeSubscription.activeSubscription.endOfCurrentPeriod.seconds,
@@ -120,6 +135,7 @@ class BackupsSettingsViewModel : ViewModel() {
}
private fun getEnabledStateForNoTier(): BackupsSettingsState.EnabledState {
Log.d(TAG, "Grabbing enabled state for no tier.")
return if (SignalStore.uiHints.hasEverEnabledRemoteBackups) {
BackupsSettingsState.EnabledState.Inactive
} else {

View File

@@ -31,8 +31,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -99,7 +97,7 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.fonts.SignalSymbols
import org.thoughtcrime.securesms.fonts.SignalSymbols.SignalSymbol
import org.thoughtcrime.securesms.fonts.SignalSymbols.signalSymbolText
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
@@ -363,7 +361,7 @@ private fun RemoteBackupsSettingsContent(
Scaffolds.Settings(
title = stringResource(id = R.string.RemoteBackupsSettingsFragment__signal_backups),
onNavigationClick = contentCallbacks::onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24),
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_start_24),
snackbarHost = {
Snackbars.Host(snackbarHostState = snackbarHostState)
}
@@ -687,14 +685,10 @@ private fun BackupCard(
}
Text(
text = buildAnnotatedString {
if (backupState.isActive()) {
SignalSymbol(SignalSymbols.Weight.REGULAR, SignalSymbols.Glyph.CHECKMARK)
append(" ")
}
append(title)
},
text = signalSymbolText(
text = title,
glyphStart = if (backupState.isActive()) SignalSymbols.Glyph.CHECK else null
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
@@ -1183,8 +1177,8 @@ private fun CircularProgressDialog(
)
) {
Surface(
shape = AlertDialogDefaults.shape,
color = AlertDialogDefaults.containerColor
shape = Dialogs.Defaults.shape,
color = Dialogs.Defaults.containerColor
) {
Box(
contentAlignment = Alignment.Center,
@@ -1206,17 +1200,16 @@ private fun BackupFrequencyDialog(
onSelected: (BackupFrequency) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
BasicAlertDialog(
onDismissRequest = onDismiss
) {
Surface {
Surface(
color = Dialogs.Defaults.containerColor,
shape = Dialogs.Defaults.shape,
shadowElevation = Dialogs.Defaults.TonalElevation
) {
Column(
modifier = Modifier
.background(
color = AlertDialogDefaults.containerColor,
shape = AlertDialogDefaults.shape
)
.fillMaxWidth()
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_frequency),

View File

@@ -46,11 +46,11 @@ import org.whispersystems.signalservice.api.push.ServiceIdType
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity
import org.whispersystems.signalservice.internal.push.MismatchedDevices
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
import org.whispersystems.signalservice.internal.push.SyncMessage
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException
import java.io.IOException
import java.security.MessageDigest
import java.security.SecureRandom
@@ -270,9 +270,11 @@ class ChangeNumberRepository(
result = SignalNetwork.account.changeNumber(request)
}
val possibleError = result.getCause() as? MismatchedDevicesException
if (possibleError != null) {
messageSender.handleChangeNumberMismatchDevices(possibleError.mismatchedDevices)
if (result is NetworkResult.StatusCodeError && result.code == 409) {
val mismatchedDevices: MismatchedDevices? = result.parseJsonBody()
if (mismatchedDevices != null) {
messageSender.handleChangeNumberMismatchDevices(mismatchedDevices)
}
attempts++
} else {
completed = true

View File

@@ -9,13 +9,10 @@ import org.thoughtcrime.securesms.pin.SvrWrongPinException
import org.thoughtcrime.securesms.registration.data.network.RegistrationResult
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException
import org.whispersystems.signalservice.api.push.exceptions.IncorrectRegistrationRecoveryPasswordException
import org.whispersystems.signalservice.api.push.exceptions.MalformedRequestException
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.api.svr.Svr3Credentials
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.LockedException
import org.whispersystems.signalservice.internal.push.PushServiceSocket.RegistrationLockFailure
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
/**
@@ -29,27 +26,29 @@ sealed class ChangeNumberResult(cause: Throwable?) : RegistrationResult(cause) {
is NetworkResult.ApplicationError -> UnknownError(networkResult.throwable)
is NetworkResult.NetworkError -> UnknownError(networkResult.exception)
is NetworkResult.StatusCodeError -> {
when (val cause = networkResult.exception) {
is IncorrectRegistrationRecoveryPasswordException -> IncorrectRecoveryPassword(cause)
is AuthorizationFailedException -> AuthorizationFailed(cause)
is MalformedRequestException -> MalformedRequest(cause)
is RateLimitException -> createRateLimitProcessor(cause)
is LockedException -> RegistrationLocked(cause = cause, timeRemaining = cause.timeRemaining, svr2Credentials = cause.svr2Credentials, svr3Credentials = cause.svr3Credentials)
else -> {
if (networkResult.code == 422) {
ValidationError(cause)
when (networkResult.code) {
403 -> IncorrectRecoveryPassword(networkResult.exception)
401 -> AuthorizationFailed(networkResult.exception)
400 -> MalformedRequest(networkResult.exception)
429 -> createRateLimitProcessor(networkResult.exception, networkResult.header("retry-after")?.toLongOrNull())
423 -> {
val registrationLockFailure: RegistrationLockFailure? = networkResult.parseJsonBody()
if (registrationLockFailure != null) {
RegistrationLocked(cause = networkResult.exception, timeRemaining = registrationLockFailure.timeRemaining, svr2Credentials = registrationLockFailure.svr2Credentials, svr3Credentials = registrationLockFailure.svr3Credentials)
} else {
UnknownError(cause)
UnknownError(networkResult.exception)
}
}
422 -> ValidationError(networkResult.exception)
else -> UnknownError(networkResult.exception)
}
}
}
}
private fun createRateLimitProcessor(exception: RateLimitException): ChangeNumberResult {
return if (exception.retryAfterMilliseconds.isPresent) {
RateLimited(exception, exception.retryAfterMilliseconds.get())
private fun createRateLimitProcessor(exception: NonSuccessfulResponseCodeException, retryAfter: Long?): ChangeNumberResult {
return if (retryAfter != null) {
RateLimited(exception, retryAfter)
} else {
AttemptsExhausted(exception)
}

View File

@@ -0,0 +1,31 @@
package org.thoughtcrime.securesms.components.settings.app.chats.folders
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.whispersystems.signalservice.api.util.UuidUtil
import java.util.UUID
/**
* UUID wrapper class for chat folders, used in storage service
*/
@Parcelize
data class ChatFolderId(val uuid: UUID) : Parcelable {
companion object {
fun from(id: String): ChatFolderId {
return ChatFolderId(UuidUtil.parseOrThrow(id))
}
fun from(uuid: UUID): ChatFolderId {
return ChatFolderId(uuid)
}
fun generate(): ChatFolderId {
return ChatFolderId(UUID.randomUUID())
}
}
override fun toString(): String {
return uuid.toString()
}
}

View File

@@ -1,7 +1,9 @@
package org.thoughtcrime.securesms.components.settings.app.chats.folders
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.whispersystems.signalservice.api.storage.StorageId
/**
* Represents an entry in the [org.thoughtcrime.securesms.database.ChatFolderTables].
@@ -17,7 +19,12 @@ data class ChatFolderRecord(
val showMutedChats: Boolean = true,
val showIndividualChats: Boolean = false,
val showGroupChats: Boolean = false,
val folderType: FolderType = FolderType.CUSTOM
val folderType: FolderType = FolderType.CUSTOM,
val chatFolderId: ChatFolderId = ChatFolderId.generate(),
@IgnoredOnParcel
val storageServiceId: StorageId? = null,
val storageServiceProto: ByteArray? = null,
val deletedTimestampMs: Long = 0
) : Parcelable {
enum class FolderType(val value: Int) {
/** Folder containing all chats */

View File

@@ -50,6 +50,7 @@ import org.signal.core.ui.compose.DropdownMenus
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.copied.androidx.compose.DragAndDropEvent
import org.signal.core.ui.compose.copied.androidx.compose.DraggableItem
import org.signal.core.ui.compose.copied.androidx.compose.dragContainer
import org.signal.core.ui.compose.copied.androidx.compose.rememberDragDropState
@@ -100,7 +101,13 @@ class ChatFoldersFragment : ComposeFragment() {
onDeleteDismissed = {
viewModel.showDeleteDialog(false)
},
onPositionUpdated = { fromIndex, toIndex -> viewModel.updatePosition(fromIndex, toIndex) }
onDragAndDropEvent = { event ->
when (event) {
is DragAndDropEvent.OnItemMove -> viewModel.updateItemPosition(event.fromIndex, event.toIndex)
is DragAndDropEvent.OnItemDrop -> viewModel.saveItemPositions()
is DragAndDropEvent.OnDragCancel -> {}
}
}
)
}
}
@@ -116,15 +123,12 @@ fun FoldersScreen(
onDeleteClicked: (ChatFolderRecord) -> Unit = {},
onDeleteConfirmed: () -> Unit = {},
onDeleteDismissed: () -> Unit = {},
onPositionUpdated: (Int, Int) -> Unit = { _, _ -> }
onDragAndDropEvent: (DragAndDropEvent) -> Unit = {}
) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val isRtl = ViewUtil.isRtl(LocalContext.current)
val listState = rememberLazyListState()
val dragDropState =
rememberDragDropState(listState, includeHeader = true, includeFooter = true) { fromIndex, toIndex ->
onPositionUpdated(fromIndex, toIndex)
}
val dragDropState = rememberDragDropState(listState, includeHeader = true, includeFooter = true, onEvent = onDragAndDropEvent)
LaunchedEffect(Unit) {
if (!SignalStore.uiHints.hasSeenChatFoldersEducationSheet) {

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.chats.folders
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
/**
* Repository for chat folders that handles creation, deletion, listing, etc.,
@@ -9,7 +10,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
object ChatFoldersRepository {
fun getCurrentFolders(): List<ChatFolderRecord> {
return SignalDatabase.chatFolders.getChatFolders()
return SignalDatabase.chatFolders.getCurrentChatFolders()
}
fun getUnreadCountAndMutedStatusForFolders(folders: List<ChatFolderRecord>): HashMap<Long, Pair<Int, Boolean>> {
@@ -25,6 +26,7 @@ object ChatFoldersRepository {
)
SignalDatabase.chatFolders.createFolder(updatedFolder)
StorageSyncHelper.scheduleSyncForDataChange()
}
fun updateFolder(folder: ChatFolderRecord, includedRecipients: Set<Recipient>, excludedRecipients: Set<Recipient>) {
@@ -36,21 +38,29 @@ object ChatFoldersRepository {
)
SignalDatabase.chatFolders.updateFolder(updatedFolder)
scheduleSync(updatedFolder.id)
}
fun deleteFolder(folder: ChatFolderRecord) {
SignalDatabase.chatFolders.deleteChatFolder(folder)
scheduleSync(folder.id)
}
fun updatePositions(folders: List<ChatFolderRecord>) {
SignalDatabase.chatFolders.updatePositions(folders)
folders.forEach { scheduleSync(it.id) }
}
fun getFolder(id: Long): ChatFolderRecord {
return SignalDatabase.chatFolders.getChatFolder(id)
return SignalDatabase.chatFolders.getChatFolder(id)!!
}
fun getFolderCount(): Int {
return SignalDatabase.chatFolders.getFolderCount()
}
private fun scheduleSync(id: Long) {
SignalDatabase.chatFolders.markNeedsSync(id)
StorageSyncHelper.scheduleSyncForDataChange()
}
}

View File

@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.util.swap
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ChatType
import org.thoughtcrime.securesms.database.SignalDatabase
@@ -177,17 +178,22 @@ class ChatFoldersViewModel : ViewModel() {
}
}
fun updatePosition(fromIndex: Int, toIndex: Int) {
viewModelScope.launch(Dispatchers.IO) {
val folders = state.value.folders.toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
val updatedFolders = folders.mapIndexed { index, chatFolderRecord ->
chatFolderRecord.copy(position = index)
}
ChatFoldersRepository.updatePositions(updatedFolders)
fun updateItemPosition(fromIndex: Int, toIndex: Int) {
val folders = state.value.folders.swap(fromIndex, toIndex)
val updatedFolders = folders.mapIndexed { index, chatFolderRecord ->
chatFolderRecord.copy(position = index)
}
internalState.update {
it.copy(folders = updatedFolders)
}
}
internalState.update {
it.copy(folders = updatedFolders)
}
fun saveItemPositions() {
val updatedFolders = state.value.folders.mapIndexed { index, chatFolderRecord ->
chatFolderRecord.copy(position = index)
}
viewModelScope.launch(Dispatchers.IO) {
ChatFoldersRepository.updatePositions(updatedFolders)
}
}
@@ -325,12 +331,6 @@ class ChatFoldersViewModel : ViewModel() {
}
}
fun enableButton(): Boolean {
return internalState.value.pendingIncludedRecipients.isNotEmpty() ||
internalState.value.pendingChatTypes.isNotEmpty() ||
internalState.value.pendingExcludedRecipients.isNotEmpty()
}
fun hasChanges(): Boolean {
val currentFolder = state.value.currentFolder
val originalFolder = state.value.originalFolder
@@ -356,4 +356,14 @@ class ChatFoldersViewModel : ViewModel() {
fun hasEmptyName(): Boolean {
return state.value.currentFolder.folderRecord.name.isEmpty()
}
fun shouldSetInitialFolder(): Boolean {
val original = state.value.originalFolder
val current = state.value.currentFolder
return original.folderRecord.id == current.folderRecord.id &&
original.folderRecord.showIndividualChats == current.folderRecord.showIndividualChats &&
original.folderRecord.showGroupChats == current.folderRecord.showGroupChats &&
original.includedRecipients == current.includedRecipients &&
original.excludedRecipients == current.excludedRecipients
}
}

View File

@@ -84,7 +84,7 @@ class ChooseChatsFragment : LoggingFragment(), ContactSelectionListFragment.OnCo
viewModel.savePendingChats()
findNavController().popBackStack()
}
updateEnabledButton()
doneButton.isEnabled = false
}
override fun onStart() {
@@ -120,7 +120,7 @@ class ChooseChatsFragment : LoggingFragment(), ContactSelectionListFragment.OnCo
} else {
callback.accept(false)
}
updateEnabledButton()
doneButton.isEnabled = true
}
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>) {
@@ -133,7 +133,7 @@ class ChooseChatsFragment : LoggingFragment(), ContactSelectionListFragment.OnCo
} else if (chatType.isPresent) {
viewModel.removeChatType(chatType.get())
}
updateEnabledButton()
doneButton.isEnabled = true
}
override fun onSelectionChanged() = Unit
@@ -147,10 +147,6 @@ class ChooseChatsFragment : LoggingFragment(), ContactSelectionListFragment.OnCo
ContactSelectionDisplayMode.FLAG_SELF
}
private fun updateEnabledButton() {
doneButton.isEnabled = viewModel.enableButton()
}
companion object {
private val TAG = Log.tag(ChooseChatsFragment::class.java)
private val KEY_INCLUDE_CHATS = "include_chats"

View File

@@ -97,7 +97,7 @@ class CreateFoldersFragment : ComposeFragment() {
val isNewFolder = state.originalFolder.folderRecord.id == -1L
LaunchedEffect(Unit) {
if (state.originalFolder == state.currentFolder) {
if (viewModel.shouldSetInitialFolder()) {
viewModel.setCurrentFolderId(arguments?.getLong(KEY_FOLDER_ID) ?: -1)
viewModel.addThreadsToFolder(arguments?.getLongArray(KEY_THREAD_IDS))
}
@@ -366,12 +366,12 @@ fun CreateFolderScreen(
Buttons.MediumTonal(
colors = ButtonDefaults.filledTonalButtonColors(
contentColor = if (state.currentFolder.folderRecord.name.isEmpty()) {
contentColor = if (state.currentFolder.folderRecord.name.isBlank()) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
} else {
MaterialTheme.colorScheme.onSurface
},
containerColor = if (state.currentFolder.folderRecord.name.isEmpty()) {
containerColor = if (state.currentFolder.folderRecord.name.isBlank()) {
MaterialTheme.colorScheme.surfaceVariant
} else {
MaterialTheme.colorScheme.primaryContainer
@@ -380,7 +380,7 @@ fun CreateFolderScreen(
),
enabled = hasChanges,
onClick = {
if (state.currentFolder.folderRecord.name.isEmpty()) {
if (state.currentFolder.folderRecord.name.isBlank()) {
onShowToast()
} else {
onCreateConfirmed()

View File

@@ -109,7 +109,7 @@ private fun Content(
) {
Scaffolds.Settings(
title = "One-time donation state",
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24),
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_start_24),
navigationContentDescription = null,
onNavigationClick = onNavigationClick
) {

View File

@@ -163,6 +163,28 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("App UI"))
switchPref(
title = DSLSettingsText.from("Enable new split pane UI."),
summary = DSLSettingsText.from("Warning: Some bugs and non functional buttons are expected. App will restart."),
isChecked = state.largeScreenUi,
onClick = {
viewModel.setUseLargeScreenUi(!state.largeScreenUi)
AppUtil.restart(requireContext())
}
)
switchPref(
isEnabled = state.largeScreenUi,
title = DSLSettingsText.from("Force split pane UI on landscape phones."),
summary = DSLSettingsText.from("This setting requires split pane UI to be enabled."),
isChecked = state.forceSplitPaneOnCompactLandscape,
onClick = {
viewModel.setForceSplitPaneOnCompactLandscape(!state.forceSplitPaneOnCompactLandscape)
}
)
sectionHeaderPref(DSLSettingsText.from("Playgrounds"))
clickPref(

View File

@@ -25,5 +25,7 @@ data class InternalSettingsState(
val useConversationItemV2ForMedia: Boolean,
val hasPendingOneTimeDonation: Boolean,
val hevcEncoding: Boolean,
val newCallingUi: Boolean
val newCallingUi: Boolean,
val largeScreenUi: Boolean,
val forceSplitPaneOnCompactLandscape: Boolean
)

View File

@@ -166,7 +166,9 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
useConversationItemV2ForMedia = SignalStore.internal.useConversationItemV2Media,
hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null,
hevcEncoding = SignalStore.internal.hevcEncoding,
newCallingUi = SignalStore.internal.newCallingUi
newCallingUi = SignalStore.internal.newCallingUi,
largeScreenUi = SignalStore.internal.largeScreenUi,
forceSplitPaneOnCompactLandscape = SignalStore.internal.forceSplitPaneOnCompactLandscape
)
fun onClearOnboardingState() {
@@ -182,6 +184,16 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun setUseLargeScreenUi(largeScreenUi: Boolean) {
SignalStore.internal.largeScreenUi = largeScreenUi
refresh()
}
fun setForceSplitPaneOnCompactLandscape(forceSplitPaneOnCompactLandscape: Boolean) {
SignalStore.internal.forceSplitPaneOnCompactLandscape = forceSplitPaneOnCompactLandscape
refresh()
}
class Factory(private val repository: InternalSettingsRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(InternalSettingsViewModel(repository)))

View File

@@ -285,7 +285,7 @@ fun Tabs(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(R.drawable.symbol_arrow_left_24),
painter = painterResource(R.drawable.symbol_arrow_start_24),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null
)

View File

@@ -85,7 +85,7 @@ private fun Screen(
navigationIcon = {
IconButton(onClick = onBackPressed) {
Icon(
painter = painterResource(R.drawable.symbol_arrow_left_24),
painter = painterResource(R.drawable.symbol_arrow_start_24),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null
)

View File

@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
@@ -48,6 +49,7 @@ import org.signal.core.ui.compose.SignalPreview
import org.signal.core.util.Hex
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.internal.storage.InternalStorageServicePlaygroundViewModel.OneOffEvent
import org.thoughtcrime.securesms.components.settings.app.internal.storage.InternalStorageServicePlaygroundViewModel.StorageInsights
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.StorageForcePushJob
@@ -70,6 +72,7 @@ class InternalStorageServicePlaygroundFragment : ComposeFragment() {
override fun FragmentContent() {
val manifest by viewModel.manifest
val storageRecords by viewModel.storageRecords
val storageInsights by viewModel.storageInsights
val oneOffEvent by viewModel.oneOffEvents
var forceSsreToggled by remember { mutableStateOf(SignalStore.internal.forceSsre2Capability) }
@@ -77,6 +80,7 @@ class InternalStorageServicePlaygroundFragment : ComposeFragment() {
onBackPressed = { findNavController().popBackStack() },
manifest = manifest,
storageRecords = storageRecords,
storageInsights = storageInsights,
oneOffEvent = oneOffEvent,
forceSsreCapability = forceSsreToggled,
onForceSsreToggled = { checked ->
@@ -93,6 +97,7 @@ class InternalStorageServicePlaygroundFragment : ComposeFragment() {
fun Screen(
manifest: SignalStorageManifest,
storageRecords: List<SignalStorageRecord>,
storageInsights: StorageInsights,
forceSsreCapability: Boolean,
oneOffEvent: OneOffEvent,
onForceSsreToggled: (Boolean) -> Unit = {},
@@ -110,7 +115,7 @@ fun Screen(
navigationIcon = {
IconButton(onClick = onBackPressed) {
Icon(
painter = painterResource(R.drawable.symbol_arrow_left_24),
painter = painterResource(R.drawable.symbol_arrow_start_24),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null
)
@@ -143,6 +148,7 @@ fun Screen(
1 -> ViewScreen(
manifest = manifest,
storageRecords = storageRecords,
storageInsights = storageInsights,
oneOffEvent = oneOffEvent
)
}
@@ -160,7 +166,7 @@ fun ToolScreen(
modifier = Modifier.fillMaxWidth()
) {
ActionRow("Enqueue StorageSyncJob", "Just a normal syncing operation.") {
AppDependencies.jobManager.add(StorageSyncJob())
AppDependencies.jobManager.add(StorageSyncJob.forLocalChange())
}
ActionRow("Enqueue StorageForcePushJob", "Forces your local state over the remote.") {
@@ -191,6 +197,7 @@ fun ToolScreen(
fun ViewScreen(
manifest: SignalStorageManifest,
storageRecords: List<SignalStorageRecord>,
storageInsights: StorageInsights,
oneOffEvent: OneOffEvent
) {
val context = LocalContext.current
@@ -217,6 +224,10 @@ fun ViewScreen(
ManifestRow(manifest)
Dividers.Default()
}
item(key = "insights") {
InsightsRow(storageInsights)
Dividers.Default()
}
storageRecords.forEach { record ->
item(key = Hex.toStringCondensed(record.id.raw)) {
StorageRecordRow(record)
@@ -236,10 +247,46 @@ private fun ManifestRow(manifest: SignalStorageManifest) {
}
}
@Composable
private fun InsightsRow(insights: StorageInsights) {
Column {
ManifestItemRow("Total Manifest Size", insights.totalManifestSize.toUnitString())
ManifestItemRow("Total Record Size", insights.totalRecordSize.toUnitString())
Spacer(Modifier.height(16.dp))
ManifestItemRow("Total Account Record Size", insights.totalAccountRecordSize.toUnitString())
ManifestItemRow("Total Contact Record Size", insights.totalContactSize.toUnitString())
ManifestItemRow("Total GroupV1 Record Size", insights.totalGroupV1Size.toUnitString())
ManifestItemRow("Total GroupV2 Record Size", insights.totalGroupV2Size.toUnitString())
ManifestItemRow("Total Call Link Record Size", insights.totalCallLinkSize.toUnitString())
ManifestItemRow("Total Distribution List Record Size", insights.totalDistributionListSize.toUnitString())
ManifestItemRow("Total Chat Folder Record Size", insights.totalChatFolderSize.toUnitString())
ManifestItemRow("Total Unknown Record Size", insights.totalUnknownSize.toUnitString())
Spacer(Modifier.height(16.dp))
if (listOf(
insights.totalContactSize,
insights.totalGroupV1Size,
insights.totalGroupV2Size,
insights.totalAccountRecordSize,
insights.totalCallLinkSize,
insights.totalDistributionListSize,
insights.totalChatFolderSize
).sumOf { it.bytes } != insights.totalRecordSize.bytes
) {
Text("Mismatch! Sum of record sizes does not match our total record size!")
} else {
Text("Everything adds up \uD83D\uDC4D")
}
}
}
@Composable
private fun ManifestItemRow(title: String, value: String) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(title + ":", fontWeight = FontWeight.Bold)
Text("$title:", fontWeight = FontWeight.Bold)
Spacer(Modifier.width(6.dp))
Text(value)
}
@@ -285,6 +332,12 @@ private fun StorageRecordRow(record: SignalStorageRecord) {
ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw))
}
}
record.proto.chatFolder != null -> {
Column {
Text("Chat Folder", fontWeight = FontWeight.Bold)
ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw))
}
}
else -> {
Column {
Text("Unknown!")
@@ -323,6 +376,7 @@ fun ScreenPreview() {
forceSsreCapability = true,
manifest = SignalStorageManifest.EMPTY,
storageRecords = emptyList(),
storageInsights = StorageInsights(),
oneOffEvent = OneOffEvent.None
)
}
@@ -355,6 +409,7 @@ fun ViewScreenPreview() {
storageIds = storageRecords.map { it.id }
),
storageRecords = storageRecords,
storageInsights = StorageInsights(),
oneOffEvent = OneOffEvent.None
)
}

View File

@@ -13,6 +13,8 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.ByteSize
import org.signal.core.util.bytes
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -34,6 +36,10 @@ class InternalStorageServicePlaygroundViewModel : ViewModel() {
val storageRecords: State<List<SignalStorageRecord>>
get() = _storageItems
private val _storageInsights: MutableState<StorageInsights> = mutableStateOf(StorageInsights())
val storageInsights: State<StorageInsights>
get() = _storageInsights
private val _oneOffEvents: MutableState<OneOffEvent> = mutableStateOf(OneOffEvent.None)
val oneOffEvents: State<OneOffEvent>
get() = _oneOffEvents
@@ -69,11 +75,44 @@ class InternalStorageServicePlaygroundViewModel : ViewModel() {
}
_storageItems.value = records
// TODO get total manifest size -- we need the raw proto, which we don't have
val insights = StorageInsights(
totalManifestSize = manifest.protoByteSize,
totalRecordSize = records.sumOf { it.sizeInBytes() }.bytes,
totalContactSize = records.filter { it.proto.contact != null }.sumOf { it.sizeInBytes() }.bytes,
totalGroupV1Size = records.filter { it.proto.groupV1 != null }.sumOf { it.sizeInBytes() }.bytes,
totalGroupV2Size = records.filter { it.proto.groupV2 != null }.sumOf { it.sizeInBytes() }.bytes,
totalAccountRecordSize = records.filter { it.proto.account != null }.sumOf { it.sizeInBytes() }.bytes,
totalCallLinkSize = records.filter { it.proto.callLink != null }.sumOf { it.sizeInBytes() }.bytes,
totalDistributionListSize = records.filter { it.proto.storyDistributionList != null }.sumOf { it.sizeInBytes() }.bytes,
totalChatFolderSize = records.filter { it.proto.chatFolder != null }.sumOf { it.sizeInBytes() }.bytes,
totalUnknownSize = records.filter { it.isUnknown }.sumOf { it.sizeInBytes() }.bytes
)
_storageInsights.value = insights
}
}
}
private fun SignalStorageRecord.sizeInBytes(): Int {
return this.proto.encode().size
}
enum class OneOffEvent {
None, ManifestDecryptionError, StorageRecordDecryptionError, ManifestNotFoundError
}
data class StorageInsights(
val totalManifestSize: ByteSize = 0.bytes,
val totalRecordSize: ByteSize = 0.bytes,
val totalContactSize: ByteSize = 0.bytes,
val totalGroupV1Size: ByteSize = 0.bytes,
val totalGroupV2Size: ByteSize = 0.bytes,
val totalAccountRecordSize: ByteSize = 0.bytes,
val totalCallLinkSize: ByteSize = 0.bytes,
val totalDistributionListSize: ByteSize = 0.bytes,
val totalChatFolderSize: ByteSize = 0.bytes,
val totalUnknownSize: ByteSize = 0.bytes
)
}

View File

@@ -64,6 +64,7 @@ private fun BadgeImage(
},
update = {
it.setBadge(badge)
it.isClickable = false
},
modifier = modifier
)

View File

@@ -16,6 +16,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -23,6 +24,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.signal.core.ui.compose.BottomSheets
@@ -36,6 +38,7 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.viewModel
/**
* Displayed after the user completes the donation flow for a bank transfer.
@@ -43,13 +46,19 @@ import org.thoughtcrime.securesms.util.SpanUtil
class DonationPendingBottomSheet : ComposeBottomSheetDialogFragment() {
private val args: DonationPendingBottomSheetArgs by navArgs()
private val viewModel: DonationPendingBottomSheetViewModel by viewModel {
DonationPendingBottomSheetViewModel(args.inAppPaymentId)
}
@Composable
override fun SheetContent() {
DonationPendingBottomSheetContent(
badge = Badges.fromDatabaseBadge(args.inAppPayment.data.badge!!),
onDoneClick = this::onDoneClick
)
val inAppPayment by viewModel.inAppPayment.collectAsStateWithLifecycle()
if (inAppPayment != null)
DonationPendingBottomSheetContent(
badge = Badges.fromDatabaseBadge(inAppPayment!!.data.badge!!),
onDoneClick = this::onDoneClick
)
}
private fun onDoneClick() {
@@ -59,7 +68,8 @@ class DonationPendingBottomSheet : ComposeBottomSheetDialogFragment() {
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (!args.inAppPayment.type.recurring) {
val iap = viewModel.inAppPayment.value
if (iap != null && !iap.type.recurring) {
findNavController().popBackStack()
} else {
requireActivity().finish()

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.concurrent.SignalDispatchers
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
class DonationPendingBottomSheetViewModel(
inAppPaymentId: InAppPaymentTable.InAppPaymentId
) : ViewModel() {
private val internalInAppPayment = MutableStateFlow<InAppPaymentTable.InAppPayment?>(null)
val inAppPayment: StateFlow<InAppPaymentTable.InAppPayment?> = internalInAppPayment
init {
viewModelScope.launch {
val inAppPayment = withContext(SignalDispatchers.IO) {
SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
}
internalInAppPayment.update { inAppPayment }
}
}
}

View File

@@ -112,12 +112,15 @@ object InAppPaymentsRepository {
* Common logic for handling errors coming from the Rx chains that handle payments. These errors
* are analyzed and then either written to the database or dispatched to the temporary error processor.
*/
@WorkerThread
fun handlePipelineError(
inAppPaymentId: InAppPaymentTable.InAppPaymentId,
donationErrorSource: DonationErrorSource,
paymentSourceType: PaymentSourceType,
error: Throwable
) {
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
val donationErrorSource = inAppPayment.type.toErrorSource()
val paymentSourceType = inAppPayment.data.paymentMethodType.toPaymentSourceType()
if (error is InAppPaymentError) {
setErrorIfNotPresent(inAppPaymentId, error.inAppPaymentDataError)
return
@@ -132,7 +135,7 @@ object InAppPaymentsRepository {
val inAppPaymentError = InAppPaymentError.fromDonationError(donationError)?.inAppPaymentDataError
if (inAppPaymentError != null) {
Log.w(TAG, "Detected a terminal error.")
setErrorIfNotPresent(inAppPaymentId, inAppPaymentError).subscribe()
setErrorIfNotPresent(inAppPaymentId, inAppPaymentError)
} else {
Log.w(TAG, "Detected a temporary error.")
temporaryErrorProcessor.onNext(inAppPaymentId to donationError)
@@ -150,20 +153,19 @@ object InAppPaymentsRepository {
/**
* Writes the given error to the database, if and only if there is not already an error set.
*/
private fun setErrorIfNotPresent(inAppPaymentId: InAppPaymentTable.InAppPaymentId, error: InAppPaymentData.Error?): Completable {
return Completable.fromAction {
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
if (inAppPayment.data.error == null) {
Log.d(TAG, "Setting error on InAppPayment[$inAppPaymentId]")
SignalDatabase.inAppPayments.update(
inAppPayment.copy(
notified = false,
state = InAppPaymentTable.State.END,
data = inAppPayment.data.copy(error = error)
)
@WorkerThread
private fun setErrorIfNotPresent(inAppPaymentId: InAppPaymentTable.InAppPaymentId, error: InAppPaymentData.Error?) {
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
if (inAppPayment.data.error == null) {
Log.d(TAG, "Setting error on InAppPayment[$inAppPaymentId]")
SignalDatabase.inAppPayments.update(
inAppPayment.copy(
notified = false,
state = InAppPaymentTable.State.END,
data = inAppPayment.data.copy(error = error)
)
}
}.subscribeOn(Schedulers.io())
)
}
}
/**
@@ -522,6 +524,7 @@ object InAppPaymentsRepository {
nonVerifiedMonthlyDonation = inAppPayment.toNonVerifiedMonthlyDonation()
)
}
InAppPaymentTable.State.PENDING, InAppPaymentTable.State.TRANSACTING, InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED -> {
if (inAppPayment.data.redemption?.keepAlive == true) {
DonationRedemptionJobStatus.PendingKeepAlive
@@ -531,6 +534,7 @@ object InAppPaymentsRepository {
DonationRedemptionJobStatus.PendingReceiptRequest
}
}
InAppPaymentTable.State.END -> {
if (type.recurring && inAppPayment.data.error != null) {
DonationRedemptionJobStatus.FailedSubscription

View File

@@ -79,6 +79,7 @@ object RecurringInAppPaymentRepository {
@WorkerThread
fun getActiveSubscriptionSync(type: InAppPaymentSubscriberRecord.Type): Result<ActiveSubscription> {
if (type == InAppPaymentSubscriberRecord.Type.BACKUP && SignalStore.backup.backupTierInternalOverride == MessageBackupTier.PAID) {
Log.d(TAG, "Returning mock paid subscription.")
return Result.success(MOCK_PAID_SUBSCRIPTION)
}

View File

@@ -89,7 +89,7 @@ class InAppPaymentsBottomSheetDelegate(
private fun handleLegacyVerifiedMonthlyDonationSheets() {
SignalStore.inAppPayments.consumeVerifiedSubscription3DSData()?.also {
DonationPendingBottomSheet().apply {
arguments = DonationPendingBottomSheetArgs.Builder(it.inAppPayment).build().toBundle()
arguments = DonationPendingBottomSheetArgs.Builder(it.inAppPayment.id).build().toBundle()
}.show(fragmentManager, null)
}
}
@@ -108,7 +108,7 @@ class InAppPaymentsBottomSheetDelegate(
.show(fragmentManager, null)
} else if (payment.data.error != null && payment.state == InAppPaymentTable.State.PENDING) {
DonationPendingBottomSheet().apply {
arguments = DonationPendingBottomSheetArgs.Builder(payment).build().toBundle()
arguments = DonationPendingBottomSheetArgs.Builder(payment.id).build().toBundle()
}.show(fragmentManager, null)
} else if (isUnexpectedCancellation(payment.state, payment.data) && SignalStore.inAppPayments.showMonthlyDonationCanceledDialog) {
MonthlyDonationCanceledBottomSheetDialogFragment.show(fragmentManager)

View File

@@ -165,7 +165,7 @@ class DonateToSignalFragment :
is DonateToSignalAction.DisplayGatewaySelectorDialog -> {
Log.d(TAG, "Presenting gateway selector for ${action.inAppPayment.id}")
val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToGatewaySelectorBottomSheetDialog(action.inAppPayment)
val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToGatewaySelectorBottomSheetDialog(action.inAppPayment.id)
findNavController().safeNavigate(navAction)
}
@@ -173,8 +173,7 @@ class DonateToSignalFragment :
is DonateToSignalAction.CancelSubscription -> {
val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
InAppPaymentProcessorAction.CANCEL_SUBSCRIPTION,
null,
InAppPaymentType.RECURRING_DONATION
null
)
findNavController().safeNavigate(navAction)
@@ -184,16 +183,14 @@ class DonateToSignalFragment :
if (action.inAppPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.PAYPAL) {
val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToPaypalPaymentInProgressFragment(
InAppPaymentProcessorAction.UPDATE_SUBSCRIPTION,
action.inAppPayment,
action.inAppPayment.type
action.inAppPayment.id
)
findNavController().safeNavigate(navAction)
} else {
val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
InAppPaymentProcessorAction.UPDATE_SUBSCRIPTION,
action.inAppPayment,
action.inAppPayment.type
action.inAppPayment.id
)
findNavController().safeNavigate(navAction)
@@ -339,6 +336,8 @@ class DonateToSignalFragment :
}
)
}
space(24.dp)
}
}
@@ -477,8 +476,7 @@ class DonateToSignalFragment :
findNavController().safeNavigate(
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT,
inAppPayment,
inAppPayment.type
inAppPayment.id
)
)
}
@@ -487,22 +485,21 @@ class DonateToSignalFragment :
findNavController().safeNavigate(
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToPaypalPaymentInProgressFragment(
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT,
inAppPayment,
inAppPayment.type
inAppPayment.id
)
)
}
override fun navigateToCreditCardForm(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(inAppPayment))
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(inAppPayment.id))
}
override fun navigateToIdealDetailsFragment(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToIdealTransferDetailsFragment(inAppPayment))
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToIdealTransferDetailsFragment(inAppPayment.id))
}
override fun navigateToBankTransferMandate(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(inAppPayment))
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(inAppPayment.id))
}
override fun onPaymentComplete(inAppPayment: InAppPaymentTable.InAppPayment) {
@@ -523,7 +520,7 @@ class DonateToSignalFragment :
}
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToDonationPendingBottomSheet(inAppPayment))
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToDonationPendingBottomSheet(inAppPayment.id))
}
override fun exitCheckoutFlow() {

View File

@@ -9,6 +9,7 @@ import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.navigation.navGraphViewModels
import com.google.android.gms.wallet.PaymentData
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -17,7 +18,10 @@ import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.logging.Log
import org.signal.donations.GooglePayApi
@@ -126,12 +130,17 @@ class InAppPaymentCheckoutDelegate(
}
private fun handleSuccessfulDonationProcessorActionResult(result: InAppPaymentProcessorActionResult) {
setActivityResult(result.action, result.inAppPaymentType)
if (result.action == InAppPaymentProcessorAction.CANCEL_SUBSCRIPTION) {
callback.onSubscriptionCancelled(result.inAppPaymentType)
setActivityResult(result.action, InAppPaymentType.RECURRING_DONATION)
callback.onSubscriptionCancelled(InAppPaymentType.RECURRING_DONATION)
} else {
callback.onPaymentComplete(result.inAppPayment!!)
fragment.lifecycleScope.launch {
val inAppPayment = withContext(SignalDispatchers.IO) {
SignalDatabase.inAppPayments.getById(result.inAppPaymentId!!)!!
}
callback.onPaymentComplete(inAppPayment)
}
}
}

View File

@@ -2,14 +2,12 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.database.InAppPaymentTable
@Parcelize
class InAppPaymentProcessorActionResult(
val action: InAppPaymentProcessorAction,
val inAppPayment: InAppPaymentTable.InAppPayment?,
val inAppPaymentType: InAppPaymentType,
val inAppPaymentId: InAppPaymentTable.InAppPaymentId?,
val status: Status
) : Parcelable {
enum class Status {

View File

@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError.BadgeRedemptionError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.InAppPaymentPayPalOneTimeSetupJob
@@ -47,17 +48,17 @@ object SharedInAppPaymentPipeline {
* This method will enqueue the proper setup job based off the type of [InAppPaymentTable.InAppPayment] and then
* await for either [InAppPaymentTable.State.PENDING], [InAppPaymentTable.State.REQUIRES_ACTION] or [InAppPaymentTable.State.END]
* before moving further, handling each state appropriately.
*
* @param requiredActionHandler Dispatch method for handling PayPal input, 3DS, iDEAL, etc.
*/
@CheckResult
fun awaitTransaction(
inAppPayment: InAppPaymentTable.InAppPayment,
inAppPaymentId: InAppPaymentTable.InAppPaymentId,
paymentSource: PaymentSource,
requiredActionHandler: RequiredActionHandler
): Completable {
return InAppPaymentsRepository.observeUpdates(inAppPayment.id)
oneTimeRequiredActionHandler: RequiredActionHandler,
monthlyRequiredActionHandler: RequiredActionHandler
): Single<InAppPaymentTable.InAppPayment> {
return InAppPaymentsRepository.observeUpdates(inAppPaymentId)
.doOnSubscribe {
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
val job = if (inAppPayment.type.recurring) {
if (inAppPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.PAYPAL) {
InAppPaymentPayPalRecurringSetupJob.create(inAppPayment, paymentSource)
@@ -76,25 +77,27 @@ object SharedInAppPaymentPipeline {
}
.skipWhile { it.state != InAppPaymentTable.State.PENDING && it.state != InAppPaymentTable.State.REQUIRES_ACTION && it.state != InAppPaymentTable.State.END }
.firstOrError()
.flatMapCompletable { iap ->
.flatMap { iap ->
when (iap.state) {
InAppPaymentTable.State.PENDING -> {
Log.w(TAG, "Payment of type ${inAppPayment.type} is pending. Awaiting completion.")
Log.w(TAG, "Payment of type ${iap.type} is pending. Awaiting completion.")
awaitRedemption(iap, paymentSource.type)
}
InAppPaymentTable.State.REQUIRES_ACTION -> {
Log.d(TAG, "Payment of type ${inAppPayment.type} requires user action to set up.", true)
requiredActionHandler(iap.id).andThen(awaitTransaction(iap, paymentSource, requiredActionHandler))
Log.d(TAG, "Payment of type ${iap.type} requires user action to set up.", true)
val requiredActionHandler = if (iap.type.recurring) monthlyRequiredActionHandler else oneTimeRequiredActionHandler
requiredActionHandler(iap.id).andThen(awaitTransaction(iap.id, paymentSource, oneTimeRequiredActionHandler, monthlyRequiredActionHandler))
}
InAppPaymentTable.State.END -> {
if (iap.data.error != null) {
Log.d(TAG, "IAP error detected.", true)
Completable.error(InAppPaymentError(iap.data.error))
Single.error(InAppPaymentError(iap.data.error))
} else {
Log.d(TAG, "Unexpected early end state. Possible payment failure.", true)
Completable.error(DonationError.genericPaymentFailure(inAppPayment.type.toErrorSource()))
Single.error(DonationError.genericPaymentFailure(iap.type.toErrorSource()))
}
}
@@ -107,7 +110,7 @@ object SharedInAppPaymentPipeline {
* Waits 10 seconds for the redemption to complete, and fails with a temporary error afterwards.
*/
@CheckResult
fun awaitRedemption(inAppPayment: InAppPaymentTable.InAppPayment, paymentSourceType: PaymentSourceType): Completable {
fun awaitRedemption(inAppPayment: InAppPaymentTable.InAppPayment, paymentSourceType: PaymentSourceType): Single<InAppPaymentTable.InAppPayment> {
val isLongRunning = paymentSourceType.isBankTransfer
val errorSource = when (inAppPayment.type) {
InAppPaymentType.UNKNOWN -> error("Unsupported type UNKNOWN.")
@@ -131,19 +134,6 @@ object SharedInAppPaymentPipeline {
throw InAppPaymentError(it.data.error)
}
it
}.firstOrError().timeout(10, TimeUnit.SECONDS, Single.error(timeoutError)).ignoreElement()
}
/**
* Generic error handling for donations.
*/
fun handleError(
throwable: Throwable,
inAppPaymentId: InAppPaymentTable.InAppPaymentId,
paymentSourceType: PaymentSourceType,
donationErrorSource: DonationErrorSource
) {
Log.w(TAG, "Failure in $donationErrorSource payment pipeline...", throwable, true)
InAppPaymentsRepository.handlePipelineError(inAppPaymentId, donationErrorSource, paymentSourceType, throwable)
}.firstOrError().timeout(10, TimeUnit.SECONDS, Single.error(timeoutError))
}
}

View File

@@ -10,10 +10,14 @@ import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.navGraphViewModels
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableCompat
import org.signal.donations.InAppPaymentType
@@ -30,12 +34,16 @@ import org.thoughtcrime.securesms.databinding.CreditCardFragmentBinding
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
private val binding by ViewBinderDelegate(CreditCardFragmentBinding::bind)
private val args: CreditCardFragmentArgs by navArgs()
private val viewModel: CreditCardViewModel by viewModels()
private val viewModel: CreditCardViewModel by viewModel {
CreditCardViewModel(args.inAppPaymentId)
}
private val lifecycleDisposable = LifecycleDisposable()
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
R.id.checkout_flow
@@ -43,7 +51,7 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
TemporaryScreenshotSecurity.bindToViewLifecycleOwner(this)
InAppPaymentCheckoutDelegate.ErrorHandler().attach(this, null, args.inAppPayment.id)
InAppPaymentCheckoutDelegate.ErrorHandler().attach(this, null, args.inAppPaymentId)
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
val result: InAppPaymentProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, InAppPaymentProcessorActionResult::class.java)!!
@@ -53,21 +61,27 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
}
}
binding.continueButton.text = when (args.inAppPayment.type) {
InAppPaymentType.RECURRING_DONATION -> {
getString(
R.string.CreditCardFragment__donate_s_month,
FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
}
InAppPaymentType.RECURRING_BACKUP -> {
getString(
R.string.CreditCardFragment__pay_s_month,
FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
}
else -> {
getString(R.string.CreditCardFragment__donate_s, FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney()))
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.inAppPayment.collectLatest { inAppPayment ->
binding.continueButton.text = when (inAppPayment.type) {
InAppPaymentType.RECURRING_DONATION -> {
getString(
R.string.CreditCardFragment__donate_s_month,
FiatMoneyUtil.format(resources, inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
}
InAppPaymentType.RECURRING_BACKUP -> {
getString(
R.string.CreditCardFragment__pay_s_month,
FiatMoneyUtil.format(resources, inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
}
else -> {
getString(R.string.CreditCardFragment__donate_s, FiatMoneyUtil.format(resources, inAppPayment.data.amount!!.toFiatMoney()))
}
}
}
}
}
@@ -119,8 +133,7 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
findNavController().safeNavigate(
CreditCardFragmentDirections.actionCreditCardFragmentToStripePaymentInProgressFragment(
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT,
args.inAppPayment,
args.inAppPayment.type
args.inAppPaymentId
)
)
}

View File

@@ -1,27 +1,50 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.processors.BehaviorProcessor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.util.rx.RxStore
import java.util.Calendar
class CreditCardViewModel : ViewModel() {
class CreditCardViewModel(
inAppPaymentId: InAppPaymentTable.InAppPaymentId
) : ViewModel() {
private val formStore = RxStore(CreditCardFormState())
private val validationProcessor: BehaviorProcessor<CreditCardValidationState> = BehaviorProcessor.create()
private val currentYear: Int
private val currentMonth: Int
private val internalInAppPayment = MutableStateFlow<InAppPaymentTable.InAppPayment?>(null)
val inAppPayment: Flow<InAppPaymentTable.InAppPayment> = internalInAppPayment.filterNotNull()
private val disposables = CompositeDisposable()
init {
val calendar = Calendar.getInstance()
viewModelScope.launch {
val inAppPayment = withContext(Dispatchers.IO) {
SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
}
internalInAppPayment.update { inAppPayment }
}
currentYear = calendar.get(Calendar.YEAR)
currentMonth = calendar.get(Calendar.MONTH) + 1

View File

@@ -4,7 +4,6 @@ import android.content.Context
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import io.reactivex.rxjava3.kotlin.subscribeBy
@@ -31,6 +30,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.viewModel
/**
* Entry point to capturing the necessary payment token to pay for a donation
@@ -41,9 +41,9 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
private val args: GatewaySelectorBottomSheetArgs by navArgs()
private val viewModel: GatewaySelectorViewModel by viewModels(factoryProducer = {
GatewaySelectorViewModel.Factory(args, requireListener<GooglePayComponent>().googlePayRepository)
})
private val viewModel: GatewaySelectorViewModel by viewModel {
GatewaySelectorViewModel(args, requireListener<GooglePayComponent>().googlePayRepository)
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
BadgeDisplay112.register(adapter)
@@ -59,44 +59,48 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
}
private fun getConfiguration(state: GatewaySelectorState): DSLConfiguration {
return configure {
customPref(
BadgeDisplay112.Model(
badge = state.inAppPayment.data.badge!!.let { Badges.fromDatabaseBadge(it) },
withDisplayText = false
)
)
space(12.dp)
presentTitleAndSubtitle(requireContext(), state.inAppPayment)
space(16.dp)
if (state.loading) {
space(16.dp)
customPref(IndeterminateLoadingCircle)
space(16.dp)
return@configure
}
state.gatewayOrderStrategy.orderedGateways.forEach { gateway ->
when (gateway) {
InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING -> error("Unsupported payment method.")
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> renderGooglePayButton(state)
InAppPaymentData.PaymentMethodType.PAYPAL -> renderPayPalButton(state)
InAppPaymentData.PaymentMethodType.CARD -> renderCreditCardButton(state)
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> renderSEPADebitButton(state)
InAppPaymentData.PaymentMethodType.IDEAL -> renderIDEALButton(state)
InAppPaymentData.PaymentMethodType.UNKNOWN -> error("Unsupported payment method.")
return when (state) {
GatewaySelectorState.Loading -> {
configure {
space(16.dp)
customPref(IndeterminateLoadingCircle)
space(16.dp)
}
}
is GatewaySelectorState.Ready -> {
configure {
customPref(
BadgeDisplay112.Model(
badge = state.inAppPayment.data.badge!!.let { Badges.fromDatabaseBadge(it) },
withDisplayText = false
)
)
space(16.dp)
space(12.dp)
presentTitleAndSubtitle(requireContext(), state.inAppPayment)
space(16.dp)
state.gatewayOrderStrategy.orderedGateways.forEach { gateway ->
when (gateway) {
InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING -> error("Unsupported payment method.")
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> renderGooglePayButton(state)
InAppPaymentData.PaymentMethodType.PAYPAL -> renderPayPalButton(state)
InAppPaymentData.PaymentMethodType.CARD -> renderCreditCardButton(state)
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> renderSEPADebitButton(state)
InAppPaymentData.PaymentMethodType.IDEAL -> renderIDEALButton(state)
InAppPaymentData.PaymentMethodType.UNKNOWN -> error("Unsupported payment method.")
}
}
space(16.dp)
}
}
}
}
private fun DSLConfiguration.renderGooglePayButton(state: GatewaySelectorState) {
private fun DSLConfiguration.renderGooglePayButton(state: GatewaySelectorState.Ready) {
if (state.isGooglePayAvailable) {
space(16.dp)
@@ -115,7 +119,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
}
}
private fun DSLConfiguration.renderPayPalButton(state: GatewaySelectorState) {
private fun DSLConfiguration.renderPayPalButton(state: GatewaySelectorState.Ready) {
if (state.isPayPalAvailable) {
space(16.dp)
@@ -134,7 +138,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
}
}
private fun DSLConfiguration.renderCreditCardButton(state: GatewaySelectorState) {
private fun DSLConfiguration.renderCreditCardButton(state: GatewaySelectorState.Ready) {
if (state.isCreditCardAvailable) {
space(16.dp)
@@ -153,7 +157,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
}
}
private fun DSLConfiguration.renderSEPADebitButton(state: GatewaySelectorState) {
private fun DSLConfiguration.renderSEPADebitButton(state: GatewaySelectorState.Ready) {
if (state.isSEPADebitAvailable) {
space(16.dp)
@@ -162,7 +166,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
icon = DSLSettingsIcon.from(R.drawable.bank_transfer),
disableOnClick = true,
onClick = {
val price = args.inAppPayment.data.amount!!.toFiatMoney()
val price = state.inAppPayment.data.amount!!.toFiatMoney()
if (state.sepaEuroMaximum != null &&
price.currency == CurrencyUtil.EURO &&
price.amount > state.sepaEuroMaximum.amount
@@ -181,7 +185,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
}
}
private fun DSLConfiguration.renderIDEALButton(state: GatewaySelectorState) {
private fun DSLConfiguration.renderIDEALButton(state: GatewaySelectorState.Ready) {
if (state.isIDEALAvailable) {
space(16.dp)

View File

@@ -7,17 +7,15 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.getAvaila
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import java.util.Locale
class GatewaySelectorRepository(
private val donationsService: DonationsService
) {
object GatewaySelectorRepository {
fun getAvailableGatewayConfiguration(currencyCode: String): Single<GatewayConfiguration> {
return Single.fromCallable {
donationsService.getDonationsConfiguration(Locale.getDefault())
AppDependencies.donationsService.getDonationsConfiguration(Locale.getDefault())
}.flatMap { it.flattenResult() }
.map { configuration ->
val available = configuration.getAvailablePaymentMethods(currencyCode).map {

View File

@@ -3,14 +3,17 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.g
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.database.InAppPaymentTable
data class GatewaySelectorState(
val gatewayOrderStrategy: GatewayOrderStrategy,
val inAppPayment: InAppPaymentTable.InAppPayment,
val loading: Boolean = true,
val isGooglePayAvailable: Boolean = false,
val isPayPalAvailable: Boolean = false,
val isCreditCardAvailable: Boolean = false,
val isSEPADebitAvailable: Boolean = false,
val isIDEALAvailable: Boolean = false,
val sepaEuroMaximum: FiatMoney? = null
)
sealed interface GatewaySelectorState {
data object Loading : GatewaySelectorState
data class Ready(
val gatewayOrderStrategy: GatewayOrderStrategy,
val inAppPayment: InAppPaymentTable.InAppPayment,
val isGooglePayAvailable: Boolean = false,
val isPayPalAvailable: Boolean = false,
val isCreditCardAvailable: Boolean = false,
val isSEPADebitAvailable: Boolean = false,
val isIDEALAvailable: Boolean = false,
val sepaEuroMaximum: FiatMoney? = null
) : GatewaySelectorState
}

View File

@@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
@@ -10,47 +9,38 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.rx.RxStore
class GatewaySelectorViewModel(
args: GatewaySelectorBottomSheetArgs,
repository: GooglePayRepository,
private val gatewaySelectorRepository: GatewaySelectorRepository
repository: GooglePayRepository
) : ViewModel() {
private val store = RxStore(
GatewaySelectorState(
gatewayOrderStrategy = GatewayOrderStrategy.getStrategy(),
inAppPayment = args.inAppPayment,
isCreditCardAvailable = InAppDonations.isDonationsPaymentSourceAvailable(PaymentSourceType.Stripe.CreditCard, args.inAppPayment.type),
isGooglePayAvailable = InAppDonations.isDonationsPaymentSourceAvailable(PaymentSourceType.Stripe.GooglePay, args.inAppPayment.type),
isPayPalAvailable = InAppDonations.isDonationsPaymentSourceAvailable(PaymentSourceType.PayPal, args.inAppPayment.type),
isSEPADebitAvailable = InAppDonations.isDonationsPaymentSourceAvailable(PaymentSourceType.Stripe.SEPADebit, args.inAppPayment.type),
isIDEALAvailable = InAppDonations.isDonationsPaymentSourceAvailable(PaymentSourceType.Stripe.IDEAL, args.inAppPayment.type)
)
)
private val store = RxStore<GatewaySelectorState>(GatewaySelectorState.Loading)
private val disposables = CompositeDisposable()
val state = store.stateFlowable
init {
val inAppPayment = InAppPaymentsRepository.requireInAppPayment(args.inAppPaymentId)
val isGooglePayAvailable = repository.isGooglePayAvailable().toSingleDefault(true).onErrorReturnItem(false)
val gatewayConfiguration = gatewaySelectorRepository.getAvailableGatewayConfiguration(currencyCode = args.inAppPayment.data.amount!!.currencyCode)
val gatewayConfiguration = inAppPayment.flatMap { GatewaySelectorRepository.getAvailableGatewayConfiguration(currencyCode = it.data.amount!!.currencyCode) }
disposables += Single.zip(isGooglePayAvailable, gatewayConfiguration, ::Pair).subscribeBy { (googlePayAvailable, gatewayConfiguration) ->
disposables += Single.zip(inAppPayment, isGooglePayAvailable, gatewayConfiguration, ::Triple).subscribeBy { (inAppPayment, googlePayAvailable, gatewayConfiguration) ->
SignalStore.inAppPayments.isGooglePayReady = googlePayAvailable
store.update {
it.copy(
loading = false,
isCreditCardAvailable = it.isCreditCardAvailable && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.CARD),
isGooglePayAvailable = it.isGooglePayAvailable && googlePayAvailable && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.GOOGLE_PAY),
isPayPalAvailable = it.isPayPalAvailable && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.PAYPAL),
isSEPADebitAvailable = it.isSEPADebitAvailable && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.SEPA_DEBIT),
isIDEALAvailable = it.isIDEALAvailable && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.IDEAL),
GatewaySelectorState.Ready(
gatewayOrderStrategy = GatewayOrderStrategy.getStrategy(),
inAppPayment = inAppPayment,
isCreditCardAvailable = InAppDonations.isDonationsPaymentSourceAvailable(PaymentSourceType.Stripe.CreditCard, inAppPayment.type) && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.CARD),
isGooglePayAvailable = InAppDonations.isDonationsPaymentSourceAvailable(PaymentSourceType.Stripe.GooglePay, inAppPayment.type) && googlePayAvailable && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.GOOGLE_PAY),
isPayPalAvailable = InAppDonations.isDonationsPaymentSourceAvailable(PaymentSourceType.PayPal, inAppPayment.type) && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.PAYPAL),
isSEPADebitAvailable = InAppDonations.isDonationsPaymentSourceAvailable(PaymentSourceType.Stripe.SEPADebit, inAppPayment.type) && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.SEPA_DEBIT),
isIDEALAvailable = InAppDonations.isDonationsPaymentSourceAvailable(PaymentSourceType.Stripe.IDEAL, inAppPayment.type) && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.IDEAL),
sepaEuroMaximum = gatewayConfiguration.sepaEuroMaximum
)
}
@@ -63,16 +53,8 @@ class GatewaySelectorViewModel(
}
fun updateInAppPaymentMethod(inAppPaymentMethodType: InAppPaymentData.PaymentMethodType): Single<InAppPaymentTable.InAppPayment> {
return gatewaySelectorRepository.setInAppPaymentMethodType(store.state.inAppPayment, inAppPaymentMethodType).observeOn(AndroidSchedulers.mainThread())
}
val state = store.state as GatewaySelectorState.Ready
class Factory(
private val args: GatewaySelectorBottomSheetArgs,
private val repository: GooglePayRepository,
private val gatewaySelectorRepository: GatewaySelectorRepository = GatewaySelectorRepository(AppDependencies.donationsService)
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(GatewaySelectorViewModel(args, repository, gatewaySelectorRepository)) as T
}
return GatewaySelectorRepository.setInAppPaymentMethodType(state.inAppPayment, inAppPaymentMethodType).observeOn(AndroidSchedulers.mainThread())
}
}

View File

@@ -3,8 +3,12 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.p
import android.content.DialogInterface
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.Badges
@@ -15,6 +19,8 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFrag
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet.Companion.presentTitleAndSubtitle
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
/**
* Bottom sheet for final order confirmation from PayPal
@@ -32,7 +38,13 @@ class PayPalCompleteOrderBottomSheet : DSLSettingsBottomSheetFragment() {
BadgeDisplay112.register(adapter)
PayPalCompleteOrderPaymentItem.register(adapter)
adapter.submitList(getConfiguration().toMappingModelList())
lifecycleScope.launch {
val inAppPayment = withContext(SignalDispatchers.IO) {
SignalDatabase.inAppPayments.getById(args.inAppPaymentId)!!
}
adapter.submitList(getConfiguration(inAppPayment).toMappingModelList())
}
}
override fun onDismiss(dialog: DialogInterface) {
@@ -40,18 +52,18 @@ class PayPalCompleteOrderBottomSheet : DSLSettingsBottomSheetFragment() {
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to didConfirmOrder))
}
private fun getConfiguration(): DSLConfiguration {
private fun getConfiguration(inAppPayment: InAppPaymentTable.InAppPayment): DSLConfiguration {
return configure {
customPref(
BadgeDisplay112.Model(
badge = Badges.fromDatabaseBadge(args.inAppPayment.data.badge!!),
badge = Badges.fromDatabaseBadge(inAppPayment.data.badge!!),
withDisplayText = false
)
)
space(12.dp)
presentTitleAndSubtitle(requireContext(), args.inAppPayment)
presentTitleAndSubtitle(requireContext(), inAppPayment)
space(24.dp)

View File

@@ -21,10 +21,8 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toErrorSource
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorAction
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorActionResult
@@ -32,6 +30,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.In
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.databinding.DonationInProgressFragmentBinding
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -50,9 +49,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
private val binding by ViewBinderDelegate(DonationInProgressFragmentBinding::bind)
private val args: PayPalPaymentInProgressFragmentArgs by navArgs()
private val viewModel: PayPalPaymentInProgressViewModel by navGraphViewModels(R.id.checkout_flow, factoryProducer = {
PayPalPaymentInProgressViewModel.Factory()
})
private val viewModel: PayPalPaymentInProgressViewModel by navGraphViewModels(R.id.checkout_flow)
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
isCancelable = false
@@ -67,21 +64,18 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
when (args.action) {
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT -> {
viewModel.processNewDonation(
args.inAppPayment!!,
if (args.inAppPaymentType.recurring) {
this::monthlyConfirmationPipeline
} else {
this::oneTimeConfirmationPipeline
}
args.inAppPaymentId!!,
this::oneTimeConfirmationPipeline,
this::monthlyConfirmationPipeline
)
}
InAppPaymentProcessorAction.UPDATE_SUBSCRIPTION -> {
viewModel.updateSubscription(args.inAppPayment!!)
viewModel.updateSubscription(args.inAppPaymentId!!)
}
InAppPaymentProcessorAction.CANCEL_SUBSCRIPTION -> {
viewModel.cancelSubscription(args.inAppPaymentType.requireSubscriberType())
viewModel.cancelSubscription(InAppPaymentSubscriberRecord.Type.DONATION)
}
}
}
@@ -104,8 +98,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
bundleOf(
REQUEST_KEY to InAppPaymentProcessorActionResult(
action = args.action,
inAppPayment = args.inAppPayment,
inAppPaymentType = args.inAppPaymentType,
inAppPaymentId = args.inAppPaymentId,
status = InAppPaymentProcessorActionResult.Status.FAILURE
)
)
@@ -120,8 +113,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
bundleOf(
REQUEST_KEY to InAppPaymentProcessorActionResult(
action = args.action,
inAppPayment = args.inAppPayment,
inAppPaymentType = args.inAppPaymentType,
inAppPaymentId = args.inAppPaymentId,
status = InAppPaymentProcessorActionResult.Status.SUCCESS
)
)
@@ -133,11 +125,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
}
private fun getProcessingStatus(): String {
return if (args.inAppPaymentType == InAppPaymentType.RECURRING_BACKUP) {
getString(R.string.InAppPaymentInProgressFragment__processing_payment)
} else {
getString(R.string.InAppPaymentInProgressFragment__processing_donation)
}
return getString(R.string.InAppPaymentInProgressFragment__processing_donation)
}
private fun oneTimeConfirmationPipeline(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Completable {
@@ -209,7 +197,9 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
if (result != null) {
emitter.onSuccess(result.copy(paymentId = createPaymentIntentResponse.paymentId))
} else {
emitter.onError(DonationError.UserCancelledPaymentError(args.inAppPaymentType.toErrorSource()))
disposables += viewModel.getInAppPaymentType(args.inAppPaymentId!!).subscribeBy {
emitter.onError(DonationError.UserCancelledPaymentError(it.toErrorSource()))
}
}
}
@@ -237,7 +227,9 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
if (result) {
emitter.onSuccess(PayPalPaymentMethodId(createPaymentIntentResponse.token))
} else {
emitter.onError(DonationError.UserCancelledPaymentError(args.inAppPaymentType.toErrorSource()))
disposables += viewModel.getInAppPaymentType(args.inAppPaymentId!!).subscribeBy {
emitter.onError(DonationError.UserCancelledPaymentError(it.toErrorSource()))
}
}
}

View File

@@ -1,39 +1,34 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.core.SingleSource
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
import org.signal.donations.PayPalPaymentSource
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toErrorSource
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.PayPalRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorStage
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.RequiredActionHandler
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.SharedInAppPaymentPipeline
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.util.Preconditions
class PayPalPaymentInProgressViewModel(
private val payPalRepository: PayPalRepository
) : ViewModel() {
class PayPalPaymentInProgressViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(PayPalPaymentInProgressViewModel::class.java)
@@ -63,26 +58,36 @@ class PayPalPaymentInProgressViewModel(
disposables.clear()
}
fun updateSubscription(inAppPayment: InAppPaymentTable.InAppPayment) {
fun getInAppPaymentType(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Single<InAppPaymentType> {
return InAppPaymentsRepository.requireInAppPayment(inAppPaymentId).map { it.type }.observeOn(AndroidSchedulers.mainThread())
}
fun updateSubscription(inAppPaymentId: InAppPaymentTable.InAppPaymentId) {
Log.d(TAG, "Beginning subscription update...", true)
store.update { InAppPaymentProcessorStage.PAYMENT_PIPELINE }
disposables += RecurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType()).andThen(
SingleSource<InAppPaymentTable.InAppPayment> {
val freshPayment = SignalDatabase.inAppPayments.moveToTransacting(inAppPayment.id)!!
RecurringInAppPaymentRepository.setSubscriptionLevelSync(freshPayment)
val iap = InAppPaymentsRepository.requireInAppPayment(inAppPaymentId)
disposables += iap.flatMap { inAppPayment ->
RecurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType()).andThen(
SingleSource<InAppPaymentTable.InAppPayment> {
val freshPayment = SignalDatabase.inAppPayments.moveToTransacting(inAppPayment.id)!!
RecurringInAppPaymentRepository.setSubscriptionLevelSync(freshPayment)
}
).flatMap {
SharedInAppPaymentPipeline.awaitRedemption(it, PaymentSourceType.PayPal)
}
).flatMapCompletable {
SharedInAppPaymentPipeline.awaitRedemption(it, PaymentSourceType.PayPal)
}.subscribeBy(
onComplete = {
onSuccess = {
Log.w(TAG, "Completed subscription update", true)
store.update { InAppPaymentProcessorStage.COMPLETE }
},
onError = { throwable ->
Log.w(TAG, "Failed to update subscription", throwable, true)
store.update { InAppPaymentProcessorStage.FAILED }
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.MONTHLY, PaymentSourceType.PayPal, throwable)
SignalExecutors.BOUNDED_IO.execute {
InAppPaymentsRepository.handlePipelineError(inAppPaymentId, throwable)
}
}
)
}
@@ -106,34 +111,29 @@ class PayPalPaymentInProgressViewModel(
)
}
fun processNewDonation(inAppPayment: InAppPaymentTable.InAppPayment, requiredActionHandler: RequiredActionHandler) {
Log.d(TAG, "Proceeding with InAppPayment::${inAppPayment.id} of type ${inAppPayment.type}...", true)
check(inAppPayment.data.paymentMethodType.toPaymentSourceType() == PaymentSourceType.PayPal)
fun processNewDonation(
inAppPaymentId: InAppPaymentTable.InAppPaymentId,
oneTimeActionHandler: RequiredActionHandler,
monthlyActionHandler: RequiredActionHandler
) {
store.update { InAppPaymentProcessorStage.PAYMENT_PIPELINE }
disposables += SharedInAppPaymentPipeline.awaitTransaction(
inAppPayment,
inAppPaymentId,
PayPalPaymentSource(),
requiredActionHandler
oneTimeActionHandler,
monthlyActionHandler
).subscribeOn(Schedulers.io()).subscribeBy(
onComplete = {
Log.d(TAG, "Finished ${inAppPayment.type} payment pipeline...", true)
onSuccess = {
Log.d(TAG, "Finished ${it.type} payment pipeline...", true)
store.update { InAppPaymentProcessorStage.COMPLETE }
},
onError = {
store.update { InAppPaymentProcessorStage.FAILED }
SharedInAppPaymentPipeline.handleError(it, inAppPayment.id, PaymentSourceType.PayPal, inAppPayment.type.toErrorSource())
SignalExecutors.BOUNDED_IO.execute {
InAppPaymentsRepository.handlePipelineError(inAppPaymentId, it)
}
}
)
}
class Factory(
private val payPalRepository: PayPalRepository = PayPalRepository(AppDependencies.donationsService)
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(PayPalPaymentInProgressViewModel(payPalRepository)) as T
}
}
}

View File

@@ -86,7 +86,7 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
)
)
if (RemoteConfig.internalUser && args.inAppPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.IDEAL) {
if (RemoteConfig.internalUser && args.waitingForAuthPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.IDEAL) {
val openApp = MaterialButton(requireContext()).apply {
text = "Open App"
layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
@@ -119,7 +119,7 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
progress.show(parentFragmentManager, null)
withContext(Dispatchers.IO) {
SignalDatabase.inAppPayments.update(args.inAppPayment)
SignalDatabase.inAppPayments.update(args.waitingForAuthPayment)
}
progress.dismissAllowingStateLoss()

View File

@@ -21,7 +21,6 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
import org.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.R
@@ -35,6 +34,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.In
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.databinding.DonationInProgressFragmentBinding
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -67,15 +67,15 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
viewModel.onBeginNewAction()
when (args.action) {
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT -> {
viewModel.processNewDonation(args.inAppPayment!!, this::handleRequiredAction)
viewModel.processNewDonation(args.inAppPaymentId!!, this::handleRequiredAction, this::handleRequiredAction)
}
InAppPaymentProcessorAction.UPDATE_SUBSCRIPTION -> {
viewModel.updateSubscription(args.inAppPayment!!)
viewModel.updateSubscription(args.inAppPaymentId!!)
}
InAppPaymentProcessorAction.CANCEL_SUBSCRIPTION -> {
viewModel.cancelSubscription(args.inAppPaymentType.requireSubscriberType())
viewModel.cancelSubscription(InAppPaymentSubscriberRecord.Type.DONATION)
}
}
}
@@ -98,8 +98,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
bundleOf(
REQUEST_KEY to InAppPaymentProcessorActionResult(
action = args.action,
inAppPayment = args.inAppPayment,
inAppPaymentType = args.inAppPaymentType,
inAppPaymentId = args.inAppPaymentId,
status = InAppPaymentProcessorActionResult.Status.FAILURE
)
)
@@ -114,8 +113,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
bundleOf(
REQUEST_KEY to InAppPaymentProcessorActionResult(
action = args.action,
inAppPayment = args.inAppPayment,
inAppPaymentType = args.inAppPaymentType,
inAppPaymentId = args.inAppPaymentId,
status = InAppPaymentProcessorActionResult.Status.SUCCESS
)
)
@@ -127,11 +125,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
}
private fun getProcessingStatus(): String {
return if (args.inAppPaymentType == InAppPaymentType.RECURRING_BACKUP) {
getString(R.string.InAppPaymentInProgressFragment__processing_payment)
} else {
getString(R.string.InAppPaymentInProgressFragment__processing_donation)
}
return getString(R.string.InAppPaymentInProgressFragment__processing_donation)
}
private fun handleRequiredAction(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Completable {
@@ -183,11 +177,13 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
if (result != null) {
emitter.onSuccess(result)
} else {
val didLaunchExternal = bundle.getBoolean(Stripe3DSDialogFragment.LAUNCHED_EXTERNAL, false)
if (didLaunchExternal) {
emitter.onError(DonationError.UserLaunchedExternalApplication(args.inAppPaymentType.toErrorSource()))
} else {
emitter.onError(DonationError.UserCancelledPaymentError(args.inAppPaymentType.toErrorSource()))
disposables += viewModel.getInAppPaymentType(args.inAppPaymentId!!).subscribeBy { inAppPaymentType ->
val didLaunchExternal = bundle.getBoolean(Stripe3DSDialogFragment.LAUNCHED_EXTERNAL, false)
if (didLaunchExternal) {
emitter.onError(DonationError.UserLaunchedExternalApplication(inAppPaymentType.toErrorSource()))
} else {
emitter.onError(DonationError.UserCancelledPaymentError(inAppPaymentType.toErrorSource()))
}
}
}
}

View File

@@ -12,13 +12,13 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.signal.donations.GooglePayPaymentSource
import org.signal.donations.InAppPaymentType
import org.signal.donations.PaymentSource
import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toErrorSource
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorStage
@@ -64,29 +64,29 @@ class StripePaymentInProgressViewModel : ViewModel() {
disposables.clear()
}
fun processNewDonation(inAppPayment: InAppPaymentTable.InAppPayment, requiredActionHandler: RequiredActionHandler) {
Log.d(TAG, "Proceeding with InAppPayment::${inAppPayment.id} of type ${inAppPayment.type}...", true)
val paymentSourceProvider: PaymentSourceProvider = resolvePaymentSourceProvider(inAppPayment.type.toErrorSource())
check(inAppPayment.data.paymentMethodType.toPaymentSourceType() == paymentSourceProvider.paymentSourceType)
fun processNewDonation(inAppPaymentId: InAppPaymentTable.InAppPaymentId, oneTimeRequiredActionHandler: RequiredActionHandler, monthlyRequiredActionHandler: RequiredActionHandler) {
store.update { InAppPaymentProcessorStage.PAYMENT_PIPELINE }
val iap = InAppPaymentsRepository.requireInAppPayment(inAppPaymentId)
disposables += paymentSourceProvider.paymentSource.flatMapCompletable { paymentSource ->
SharedInAppPaymentPipeline.awaitTransaction(
inAppPayment,
paymentSource,
requiredActionHandler
)
disposables += iap.flatMap { inAppPayment ->
resolvePaymentSourceProvider(inAppPayment.type.toErrorSource()).paymentSource.flatMap { paymentSource ->
SharedInAppPaymentPipeline.awaitTransaction(
inAppPaymentId,
paymentSource,
oneTimeRequiredActionHandler,
monthlyRequiredActionHandler
)
}
}.subscribeOn(Schedulers.io()).subscribeBy(
onComplete = {
Log.d(TAG, "Finished ${inAppPayment.type} payment pipeline...", true)
onSuccess = {
Log.d(TAG, "Finished ${it.type} payment pipeline...", true)
store.update { InAppPaymentProcessorStage.COMPLETE }
},
onError = {
store.update { InAppPaymentProcessorStage.FAILED }
SharedInAppPaymentPipeline.handleError(it, inAppPayment.id, paymentSourceProvider.paymentSourceType, inAppPayment.type.toErrorSource())
SignalExecutors.BOUNDED_IO.execute {
InAppPaymentsRepository.handlePipelineError(inAppPaymentId, it)
}
}
)
}
@@ -137,6 +137,10 @@ class StripePaymentInProgressViewModel : ViewModel() {
this.stripePaymentData = StripePaymentData.IDEAL(bankData)
}
fun getInAppPaymentType(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Single<InAppPaymentType> {
return InAppPaymentsRepository.requireInAppPayment(inAppPaymentId).map { it.type }.observeOn(AndroidSchedulers.mainThread())
}
private fun requireNoPaymentInformation() {
require(stripePaymentData == null)
}
@@ -162,22 +166,25 @@ class StripePaymentInProgressViewModel : ViewModel() {
)
}
fun updateSubscription(inAppPayment: InAppPaymentTable.InAppPayment) {
fun updateSubscription(inAppPaymentId: InAppPaymentTable.InAppPaymentId) {
Log.d(TAG, "Beginning subscription update...", true)
store.update { InAppPaymentProcessorStage.PAYMENT_PIPELINE }
disposables += RecurringInAppPaymentRepository
.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType())
.andThen(RecurringInAppPaymentRepository.getPaymentSourceTypeOfLatestSubscription(inAppPayment.type.requireSubscriberType()))
.flatMapCompletable { paymentSourceType ->
val freshPayment = SignalDatabase.inAppPayments.moveToTransacting(inAppPayment.id)!!
val iap = InAppPaymentsRepository.requireInAppPayment(inAppPaymentId)
disposables += iap.flatMap { inAppPayment ->
RecurringInAppPaymentRepository
.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType())
.andThen(RecurringInAppPaymentRepository.getPaymentSourceTypeOfLatestSubscription(inAppPayment.type.requireSubscriberType()))
.flatMap { paymentSourceType ->
val freshPayment = SignalDatabase.inAppPayments.moveToTransacting(inAppPayment.id)!!
Single.fromCallable {
RecurringInAppPaymentRepository.setSubscriptionLevelSync(freshPayment)
}.flatMapCompletable { SharedInAppPaymentPipeline.awaitRedemption(it, paymentSourceType) }
}
Single.fromCallable {
RecurringInAppPaymentRepository.setSubscriptionLevelSync(freshPayment)
}.flatMap { SharedInAppPaymentPipeline.awaitRedemption(it, paymentSourceType) }
}
}
.subscribeOn(Schedulers.io())
.subscribeBy(
onComplete = {
onSuccess = {
Log.w(TAG, "Completed subscription update", true)
store.update { InAppPaymentProcessorStage.COMPLETE }
},
@@ -185,8 +192,7 @@ class StripePaymentInProgressViewModel : ViewModel() {
Log.w(TAG, "Failed to update subscription", throwable, true)
store.update { InAppPaymentProcessorStage.FAILED }
SignalExecutors.BOUNDED_IO.execute {
val paymentSourceType = InAppPaymentsRepository.getLatestPaymentMethodType(inAppPayment.type.requireSubscriberType()).toPaymentSourceType()
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.MONTHLY, paymentSourceType, throwable)
InAppPaymentsRepository.handlePipelineError(inAppPaymentId, throwable)
}
}
)

View File

@@ -42,9 +42,9 @@ import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.navGraphViewModels
@@ -68,6 +68,7 @@ import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
/**
* Collects SEPA Debit bank transfer details from the user to proceed with donation.
@@ -75,7 +76,10 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
class BankTransferDetailsFragment : ComposeFragment(), InAppPaymentCheckoutDelegate.ErrorHandlerCallback {
private val args: BankTransferDetailsFragmentArgs by navArgs()
private val viewModel: BankTransferDetailsViewModel by viewModels()
private val viewModel: BankTransferDetailsViewModel by viewModel {
BankTransferDetailsViewModel(args.inAppPaymentId)
}
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
R.id.checkout_flow
@@ -84,7 +88,7 @@ class BankTransferDetailsFragment : ComposeFragment(), InAppPaymentCheckoutDeleg
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
TemporaryScreenshotSecurity.bindToViewLifecycleOwner(this)
InAppPaymentCheckoutDelegate.ErrorHandler().attach(this, this, args.inAppPayment.id)
InAppPaymentCheckoutDelegate.ErrorHandler().attach(this, this, args.inAppPaymentId)
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
val result: InAppPaymentProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, InAppPaymentProcessorActionResult::class.java)!!
@@ -98,17 +102,33 @@ class BankTransferDetailsFragment : ComposeFragment(), InAppPaymentCheckoutDeleg
@Composable
override fun FragmentContent() {
val state: BankTransferDetailsState by viewModel.state
val inAppPayment by viewModel.inAppPayment.collectAsStateWithLifecycle(null)
val donateLabel = remember(args.inAppPayment) {
if (args.inAppPayment.type.recurring) { // TODO [message-requests] backups copy
if (inAppPayment != null) {
ReadyContent(
state,
viewModel,
inAppPayment!!
)
}
}
@Composable
private fun ReadyContent(
state: BankTransferDetailsState,
viewModel: BankTransferDetailsViewModel,
inAppPayment: InAppPaymentTable.InAppPayment
) {
val donateLabel = remember(inAppPayment) {
if (inAppPayment.type.recurring) { // TODO [message-requests] backups copy
getString(
R.string.BankTransferDetailsFragment__donate_s_month,
FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
FiatMoneyUtil.format(resources, inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
} else {
getString(
R.string.BankTransferDetailsFragment__donate_s,
FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney())
FiatMoneyUtil.format(resources, inAppPayment.data.amount!!.toFiatMoney())
)
}
}
@@ -142,8 +162,7 @@ class BankTransferDetailsFragment : ComposeFragment(), InAppPaymentCheckoutDeleg
findNavController().safeNavigate(
BankTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToStripePaymentInProgressFragment(
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT,
args.inAppPayment,
args.inAppPayment.type
args.inAppPaymentId
)
)
}
@@ -202,7 +221,7 @@ private fun BankTransferDetailsContent(
Scaffolds.Settings(
title = "Bank transfer",
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_start_24)
) {
Column(
horizontalAlignment = CenterHorizontally,

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