Compare commits

...

391 Commits

Author SHA1 Message Date
Michelle Tang
393b88fb1f Bump version to 7.41.2 2025-04-30 15:55:00 -04:00
Michelle Tang
639c3ef883 Update translations and other static files. 2025-04-30 15:44:25 -04:00
Sagar
ad4142db1a Fix class cast crash for banners. 2025-04-30 15:38:41 -04:00
Cody Henthorne
5182987735 Fix cds crash by translating libsignal-net CDS protocol exception to IOException. 2025-04-30 15:37:47 -04:00
Michelle Tang
7f5bfc210b Fix story text previews. 2025-04-30 15:25:14 -04:00
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
Michelle Tang
bc3d533b5f Bump version to 7.39.4 2025-04-09 14:49:18 -04:00
Michelle Tang
84bbac22cb Update translations and other static files. 2025-04-09 14:43:52 -04:00
Alex Hart
4d6c620f51 Sync toolbar state in tabs fragment. 2025-04-09 15:20:58 -03:00
Michelle Tang
fa7d19e474 Bump version to 7.39.3 2025-04-07 15:18:56 -04:00
Michelle Tang
3a7f9a1985 Update translations and other static files. 2025-04-07 15:11:49 -04:00
Sagar
e8ff5b19f9 Fix video remainingTime calculation. 2025-04-07 12:44:05 -04:00
Alex Hart
05701fde00 Fade in fabs after search is closed. 2025-04-07 13:12:40 -03:00
Alex Hart
94d1669363 Fix lineheight. 2025-04-07 12:54:55 -03:00
Greyson Parrelli
7f42f358a5 Bump version to 7.39.2 2025-04-03 21:48:29 -04:00
Greyson Parrelli
e9c3209322 Update baseline profile. 2025-04-03 21:48:29 -04:00
Greyson Parrelli
006a01b7f2 Update translations and other static files. 2025-04-03 21:48:29 -04:00
Alex Hart
9ddd24566d Fix action mode status bar coloring. 2025-04-03 21:48:29 -04:00
Alex Hart
a3166a8c73 Fix toolbar coloring behavior. 2025-04-03 21:48:29 -04:00
Alex Hart
117c2ad5dd Fix crash coming back from archive fragment. 2025-04-03 12:37:13 -03:00
Alex Hart
5e156c8576 Fix action mode rendering. 2025-04-03 12:21:59 -03:00
Michelle Tang
a073785407 Update system default language when switching. 2025-04-02 15:39:54 -04:00
Alex Hart
45ea0c0c97 Fix errant toolbar method. 2025-04-02 11:41:31 -03:00
Greyson Parrelli
43dcaacdaf Bump version to 7.39.1 2025-04-01 18:34:05 -04:00
Greyson Parrelli
aa89cd3d6c Update translations and other static files. 2025-04-01 18:33:46 -04:00
Greyson Parrelli
137ebd27eb Update to libsignal v0.68.1 2025-04-01 18:28:32 -04:00
Alex Hart
6323cd0fd4 Inline flag to enable auto-lower-hand for everyone. 2025-04-01 12:54:14 -03:00
Alex Hart
62305c6910 Fix content color in search field. 2025-04-01 12:15:02 -03:00
Alex Hart
681d38611d Hide stories if feature is disabled. 2025-04-01 12:10:09 -03:00
Alex Hart
38dedae8dd Fix navigation bar lottie animation duration. 2025-04-01 12:03:11 -03:00
Greyson Parrelli
ac39821841 Bump version to 7.39.0 2025-03-31 15:36:17 -04:00
Greyson Parrelli
50aef6c3bc Update baseline profile. 2025-03-31 15:28:52 -04:00
Greyson Parrelli
ad67d931df Update translations and other static files. 2025-03-31 14:59:06 -04:00
Greyson Parrelli
c9308f05ca Disable the configuration cache (for now). 2025-03-31 14:46:59 -04:00
Sagar
d1ba4fa19f Update profile initials after name change. 2025-03-31 14:44:13 -04:00
Sagar
48f8ebd0a8 Prevent showing Empty MediaPreview screen and avoid Camera/Gallery Stack. 2025-03-31 14:44:13 -04:00
Sagar
5237568bec Fix buttons weight distribution in QualitySelectorBottomSheet. 2025-03-31 14:44:13 -04:00
Alex Hart
49fcf08331 Move toolbar into main activity composable. 2025-03-31 14:44:13 -04:00
Sagar
23af6e2bf9 Improve Media Player current and remaining time logic. 2025-03-31 14:44:13 -04:00
Cody Henthorne
eb44dd4318 Provide retry UX for tier restore network failures. 2025-03-31 14:44:13 -04:00
Greyson Parrelli
9b527f7c6c Add additional validations around quote authors during export. 2025-03-31 14:44:13 -04:00
Sagar
1f95e0dd39 Hide scheduled media from conversation settings. 2025-03-31 14:44:13 -04:00
Sagar
02ee1c794b Fix play console crash reports. 2025-03-31 14:44:13 -04:00
Sagar
63c00e638d Fix witness verification for windows. 2025-03-31 14:44:13 -04:00
Greyson Parrelli
a421b5c6bb Add extra validation around shared contact backups. 2025-03-31 14:44:13 -04:00
Greyson Parrelli
42e7f5f4fc Do not allow invalid thread merge events in backups. 2025-03-31 14:44:13 -04:00
Michelle Tang
cffba71186 Add language support for Belarusian. 2025-03-31 14:44:13 -04:00
Alex Hart
10141717bd Add adaptive-layout and adaptive-navigation libraries. 2025-03-31 14:44:13 -04:00
Alex Hart
eb190f5f00 Remove ConversationListTabs enum. 2025-03-31 14:44:13 -04:00
Greyson Parrelli
0b315220ee Quiet excessive warnings. 2025-03-31 14:44:13 -04:00
Alex Hart
2b94489721 Fix witness verifications for OSX. 2025-03-31 14:44:13 -04:00
Alex Hart
7fe4816087 Add compose bottom chrome. 2025-03-31 14:44:13 -04:00
Greyson Parrelli
80bc2bdc89 Fix some lint errors. 2025-03-31 14:44:06 -04:00
Greyson Parrelli
8a2e15b76b Update to gradle 8.9.0 2025-03-31 14:44:06 -04:00
Alex Hart
c31c75d189 Temporary fix for notification profile icon. 2025-03-31 14:44:06 -04:00
Greyson Parrelli
17216316f6 Dynamically compute MediaName and MediaId. 2025-03-31 14:44:06 -04:00
Alex Hart
f1985cf506 Reimplement main activity toolbars in compose. 2025-03-31 14:44:06 -04:00
Michelle Tang
5f7ce0d96d Fix wrong language being selected for system default. 2025-03-31 14:44:06 -04:00
andrew-signal
88fd8fb36b Add handling for AppExpiredConnection in LibSignalChatConnection:connect.
Co-authored-by: Cody Henthorne <cody@signal.org>
2025-03-31 14:44:06 -04:00
andrew-signal
defe94c4fa Update to libsignal v0.68.0 2025-03-31 14:44:06 -04:00
andrew-signal
2a8be22160 Enable libsgnal-net for all nightly builds. 2025-03-31 14:44:06 -04:00
Greyson Parrelli
f48a13afc0 Fix handling of missing files during archive upload. 2025-03-31 14:44:06 -04:00
Greyson Parrelli
d1accfff82 Fixed internal setting behavior for forcing backup type. 2025-03-28 10:34:39 -04:00
Cody Henthorne
d87b313fb3 Only allow ACI auth for websocket at this time. 2025-03-28 10:30:05 -04:00
Cody Henthorne
ca8aa78705 Catch additional CDS exceptions to prevent crash. 2025-03-28 10:03:42 -04:00
Sagar
b5987feab3 Fix DateTime conversions for search datePicker. 2025-03-28 19:33:01 +05:30
Sagar
36c50d7dde Reset player position when video is finished. 2025-03-28 19:26:00 +05:30
Cody Henthorne
dea73e808c Fix pinned chat ordering sent to storage service. 2025-03-28 09:49:33 -04:00
Sagar
6847e0e4da Update conversation list for scheduled group message. 2025-03-28 00:10:59 +05:30
Alex Hart
ca82a99b9a Do not disable camera if undergoing a configuration change. 2025-03-27 14:43:32 -03:00
Greyson Parrelli
944e861594 Temporarily ignore flaky network test. 2025-03-27 13:27:44 -04:00
Greyson Parrelli
b5da07762c Fix unit of retry-after header. 2025-03-27 13:13:19 -04:00
Sagar
ad6c89bc01 Add support to dynamically update Recipient data in group settings. 2025-03-27 22:02:20 +05:30
Sagar
6b86a33f2a Support stop download and upload for documents. 2025-03-27 21:53:04 +05:30
Greyson Parrelli
bde791e03e Make the stopship task more reliable. 2025-03-27 10:25:34 -04:00
Sagar
857306d7a3 Update blur face button state on clear all while image editing. 2025-03-27 00:43:46 +05:30
Sagar
8f5f1b8906 Update dim button state on reset wallpaper. 2025-03-27 00:42:19 +05:30
Alex Hart
0358631029 Add ability to Self-heal SEPA bug. 2025-03-26 16:11:49 -03:00
Sagar
de54ff304d Improve byteCountEstimate calculation for videos. 2025-03-26 13:59:12 -04:00
Cody Henthorne
03614b32e4 Fix missing thumbnail background color. 2025-03-26 13:59:12 -04:00
Alex Hart
c0113436a2 Fix instrumentation tests for payments. 2025-03-26 13:59:12 -04:00
Tito
71a44e1ebd Update README.md copyright.
Closes #14060
2025-03-26 13:59:12 -04:00
Greyson Parrelli
c4131bb440 Inline the attachment backfill flag. 2025-03-26 13:59:12 -04:00
Jeffrey Starke
0dfe71ca8f Fix crash when changing username on Android API < 24.
Basically, the crash was caused by using `Collections.unmodifiableList()`, which creates an `UnmodifiableCollection` wrapper around the original `List`.

That `UnmodifiableCollection` wrapper contains methods that return `java.util.stream.Stream` – which was added in Java 8, but can be used on Android API < 24 through [desugaring](https://developer.android.com/studio/write/java8-support-table). 

_However_, it appears that when Jackson is using reflection to serialize `ReserveUsernameRequest`, it examines all of the methods of that class. This results in a `java.lang.NoClassDefFoundError: java.util.stream.Stream`, because the reflection is looking at the actual runtime `UnmodifiableCollection` class and not desugared code.

As far as I can tell, that behavior is unavoidable and the only way to avoid this issue is to avoid using `Collections.unmodifiableList()` in classes that are serialized by Jackson on Android API < 24.
2025-03-26 13:59:12 -04:00
Sagar
dc66da0667 Fix BubbleBanner dismiss click listener. 2025-03-26 13:59:12 -04:00
Cody Henthorne
e3044b3c97 Update restore complete banner ux. 2025-03-26 13:59:12 -04:00
Cody Henthorne
dd205e31a9 Fix incorrect state by Cycling restore decision state key. 2025-03-26 13:59:12 -04:00
Jeffrey Starke
0ef627b864 Use AttachmentSaver to save media overview files to device storage. 2025-03-26 13:59:12 -04:00
Sagar
18328079c8 Support dynamically updating common groups in recipient settings. 2025-03-26 13:59:12 -04:00
Sagar
114d9f21ed Support scroll for Scheduled Message dialog sheets. 2025-03-26 13:59:12 -04:00
Greyson Parrelli
7fa1403cff Fix nightly-url logic. 2025-03-26 13:59:12 -04:00
Alex Hart
276285ebef Replace main activity xml with AppScaffold. 2025-03-26 13:59:12 -04:00
Greyson Parrelli
8053d567f2 Update lint-baseline.xml 2025-03-26 13:59:12 -04:00
andrew-signal
9c48e669ee Remove unneeded test timeout. 2025-03-26 13:59:12 -04:00
Cody Henthorne
75e24ff7d5 Add and use special monospace typeface for AEP. 2025-03-26 13:59:12 -04:00
Sagar
e88db06c8b Add "Tap to remove" option for emoji in ReactionsBottomSheet. 2025-03-26 13:59:12 -04:00
Sagar
bcc11b9fbc Fix emoji picker weird scrolling in RTL. 2025-03-26 13:59:12 -04:00
Sagar
b416c34fa8 Add support for audio to start from a seek position. 2025-03-26 13:59:12 -04:00
Alex Hart
bf83914357 Convert MainActivity to Kotlin. 2025-03-26 13:59:12 -04:00
Alex Hart
e22c403b10 Adjust footer offset on collapsed text items. 2025-03-26 13:59:12 -04:00
Sagar
59af304002 Fix Re-register screen UI. 2025-03-26 13:59:12 -04:00
Sagar
480fc84b8b Fix Save/Reset chat wallpaper crashes. 2025-03-26 13:59:12 -04:00
Ciphreon
c8c0146fd0 Migrate Help Settings Fragment to Compose 2025-03-26 13:59:12 -04:00
Cody Henthorne
3e1edfbc67 Fix gv2 create/join bugs when profile credential is invalid. 2025-03-26 13:59:12 -04:00
Greyson Parrelli
eba5c5ceeb Add support for configuration cache. 2025-03-26 13:59:12 -04:00
Jeffrey Starke
0f72c6face Fix missing toast message after write external storage permission is denied while saving an attachment.
`AttachmentSaver` was missing logic to show a toast message after the user denies `WRITE_EXTERNAL_STORAGE` permission.

#### Changeset
- Add missing toast after write external storage permission is denied.
- Add unit test coverage for `AttachmentSaver` result messages.
- Rename `AttachmentSaver` string resource names so they all have the same prefix.
2025-03-26 13:59:12 -04:00
Alex Hart
b6f98521c8 Implement auto-lower-hand. 2025-03-26 13:59:12 -04:00
Alex Hart
32b710a3ca Rewrite bottom navigation in compose. 2025-03-26 13:59:12 -04:00
Greyson Parrelli
a9ed6b6154 Fix ktlinformat on windows. 2025-03-26 13:59:12 -04:00
Jeffrey Starke
9db5f6ddd2 Use AttachmentSaver to save media preview files to device storage. 2025-03-26 13:59:12 -04:00
Jeffrey Starke
a26377db6c Update MockK to v1.13.17
To get access to the fix for mockk/mockk#939.
2025-03-26 13:59:12 -04:00
Jeffrey Starke
f0bb74a187 Use AttachmentSaver to save image editor files to device storage. 2025-03-26 13:59:12 -04:00
Dipesh Pal
b1ff5dc5ef Preserve search term when refreshing contacts.
Fixes #14051
2025-03-26 13:59:12 -04:00
mosfet80
773b116a83 Update benchmark-libs.versions.toml
Fix cve2020-15250
https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-15250
2025-03-26 13:59:12 -04:00
mosfet80
765d1cc8ec Update android.yml
Update libs
Fix node.js<20 deprecation

deprecated by gradle/actions/wrapper-validation.
2025-03-26 13:59:12 -04:00
Greyson Parrelli
fbcf6e11ef Update rules for expiring messages in backupsV2. 2025-03-26 13:59:11 -04:00
andrew-signal
41783368bd Reject invalid TLS proxy configurations. 2025-03-26 13:59:11 -04:00
andrew-signal
9b98337e82 Adjust LibSignalChatConnection.disconnect() so we only transition to DISCONNECTED once. 2025-03-26 13:59:11 -04:00
Greyson Parrelli
7c9cd8964f Update attachment backfill proto. 2025-03-26 13:59:11 -04:00
Cody Henthorne
e2961a3f6f Add auto-fill backup key support. 2025-03-26 13:59:11 -04:00
andrew-signal
2743bec704 Bump to libsignal v0.67.6 2025-03-26 13:59:11 -04:00
Greyson Parrelli
dd1697de41 Improve display and management of backup progress. 2025-03-26 13:59:11 -04:00
Michelle Tang
5b18f05aa8 Add date to linked device notification. 2025-03-26 13:59:11 -04:00
Michelle Tang
a681d06de5 Fix chevron placement for rtl languages. 2025-03-26 13:59:11 -04:00
Cody Henthorne
cef839d300 Add verify AEP screen. 2025-03-26 13:59:11 -04:00
Michelle Tang
82bb18e218 Clean up chat folder records. 2025-03-26 13:59:11 -04:00
Jeffrey Starke
247c5de140 Add .compose and .view subpackages to core-ui module. 2025-03-26 13:59:11 -04:00
Cody Henthorne
8fc358f0a5 Fix incorrect showing of backups enabled bottom sheet on flow exit. 2025-03-26 13:59:11 -04:00
Jim Gustafson
28481e3aab Update to RingRTC v2.50.3 2025-03-26 13:59:11 -04:00
Jeffrey Starke
bf8f603dcf Pause story playback while saving media.
Story playback was previously paused when the context menu is opened, but resumed while saving the media.

With this change, playback will remain paused while saving media, so the user doesn't potentially miss any stories while clicking through the dialogs to save media to their device storage.
2025-03-26 13:59:11 -04:00
Jeffrey Starke
c876c7847e Use AttachmentSaver to save story images. 2025-03-26 13:59:11 -04:00
Jeffrey Starke
293012c219 Add unit test coverage for AttachmentSaver. 2025-03-26 13:59:11 -04:00
Jeffrey Starke
b9dc5cbe4f Consolidate attachment saving logic into unified AttachmentSaver class.
Introduces `AttachmentSaver` to centralize all of the steps needed to save message attachments to the device storage. It handles the entire workflow including: 
- Showing the save to storage warning/confirmation dialog.
- Requesting `WRITE_EXTERNAL_STORAGE` permission.
- Showing/dismissing media save progress.

Goals of this new class:
- Make it easy to save media attachments anywhere with just a few lines of code (and easier to replace the deprecated `SaveAttachmentTask`).
- Ensure all of the necessary steps are consistently performed at each usage site (which wasn't the case before).
- Make it easier to unit test the save attachment logic.
2025-03-26 13:59:11 -04:00
Alex Hart
86afafac31 Clean up imports in InAppPaumentAuthCheckJob. 2025-03-26 13:59:11 -04:00
Alex Hart
05326acadc Fix potential crash if snackbar action is processed when fragment is not attached. 2025-03-26 13:59:11 -04:00
Alex Hart
80fc40bbc2 Do not show outgoing calls in missed call filter. 2025-03-26 13:59:11 -04:00
Milan Stevanovic
f0e6b2944a Eliminate zero-sized samples from contributing to the audio track.
Eliminating unnecessary and potentially counter-productive zero-sized
samples from the audio trak. The Android MP4 multiplexer tends to add
them at the very end of the audio stream. Their presence may negatively
affect the declared audio stream duration, and pose further complications
down the road.

The changes are verified on Samsung A54 (Android 14) device.
2025-03-26 13:59:11 -04:00
Michelle Tang
c1f96e5bd3 Fix conversation header position. 2025-03-26 13:59:11 -04:00
Cody Henthorne
2d9135da93 Add excluded directories to STOPSHIP task. 2025-03-26 13:59:11 -04:00
Cody Henthorne
095ae82483 Convert remote config apis to WebSocket. 2025-03-26 13:59:11 -04:00
Alex Hart
1e866a1e86 Do not create one-time receipt during auth check. 2025-03-26 13:59:11 -04:00
Alex Hart
6f52851222 Update with newBuilder. 2025-03-26 13:59:11 -04:00
Greyson Parrelli
0efccf67b8 Format backup progress numbers. 2025-03-26 13:59:11 -04:00
Greyson Parrelli
e555802636 Add internal settings button to add dummy messages. 2025-03-26 13:59:11 -04:00
Alex Hart
4b3013984e Add unit tests to check for unregistered state. 2025-03-26 13:59:11 -04:00
Alex Hart
5d4fec2e73 Utilize newBuilder instead of copy in unit test. 2025-03-26 13:59:11 -04:00
Greyson Parrelli
1adcfd5abb Add custom STOPSHIP handling. 2025-03-26 13:59:11 -04:00
Greyson Parrelli
3727a8e1df Add internal setting for forcing backup tier. 2025-03-26 13:59:11 -04:00
Alex Hart
ac4db23709 Add test for unregistered user state to InAppPaymentRecurringContextJobTest. 2025-03-26 13:59:11 -04:00
Alex Hart
e3356163bf Fix unit tests missing isRegistered check. 2025-03-26 13:59:11 -04:00
Alex Hart
f6aa324d41 Add unit testing for SharedInAppPaymentPipeline. 2025-03-26 13:59:11 -04:00
Michelle Tang
ca5754cff3 Use standard avatar blur gradient algorithm. 2025-03-26 13:59:11 -04:00
Alex Hart
629f5a3a3d Add several registered checks. 2025-03-26 13:59:11 -04:00
Jim Gustafson
076b47e695 Improve calling reliability with relay server response cache.
Co-authored-by: Cody Henthorne <cody@signal.org>
2025-03-26 13:59:11 -04:00
Alex Hart
92a28f7103 Ensure payment is in proper state before popping external iDEAL authorization. 2025-03-26 13:59:11 -04:00
andrew-signal
2a767c1e18 Fix races causing flakes in LibSignalChatConnectionTest. 2025-03-26 13:59:11 -04:00
Cody Henthorne
d3f622478f Convert remaining profile apis to use WebSockets and remove REST fallback. 2025-03-26 13:59:11 -04:00
Cody Henthorne
c66819449d Convert provisioning and certificate endpoints to WebSocket and finalize attachments. 2025-03-26 13:59:11 -04:00
Cody Henthorne
aeec3a6f7e Convert prekey requests to WebSocket. 2025-03-26 13:59:11 -04:00
Michelle Tang
da3fc408f8 Update conversation header with group members. 2025-03-26 13:59:11 -04:00
Cody Henthorne
41e0f2193a Convert SVR and GV2 auth requests to WebSocket. 2025-03-26 13:59:11 -04:00
Cody Henthorne
9e9a47f0da Convert WebSocket messaging apis to NetworkResult. 2025-03-26 13:59:11 -04:00
Cody Henthorne
7108d350e6 Fix spoiler rendering in story captions. 2025-03-26 13:59:11 -04:00
Cody Henthorne
e9ae40e749 Fix draft lost bug when leaving media send flow. 2025-03-26 13:59:11 -04:00
Alex Hart
7cc4677120 Migrate paypal and stripe interactions to durable background jobs. 2025-03-26 13:59:11 -04:00
Greyson Parrelli
ad00e7c5ab Fix issue where were were sometimes backing up an empty CDN key. 2025-03-26 13:59:11 -04:00
Greyson Parrelli
a4c30393ee Make the backup remote config field active. 2025-03-26 13:59:11 -04:00
Greyson Parrelli
2147ee77bc Improve logging around setting backup tier. 2025-03-26 13:59:11 -04:00
Alex Hart
0cfa4774ad Do not display backups warnings if backups are disabled in remote configuration. 2025-03-26 13:59:11 -04:00
Alex Hart
14f99bba24 Fix InAppPayments database inconsistency. 2025-03-26 13:59:11 -04:00
Alex Hart
8d53c1b384 Fix message backup checkout e2e tests. 2025-03-26 13:59:11 -04:00
Cody Henthorne
fff74256b5 Prevent duplicate restore local attachment jobs from being enqueue. 2025-03-26 13:59:11 -04:00
Cody Henthorne
f154029eb1 Bump version to 7.38.7 2025-03-26 13:48:06 -04:00
Cody Henthorne
7480124a59 Update baseline profile. 2025-03-26 13:39:38 -04:00
Cody Henthorne
6de816ce86 Update translations and other static files. 2025-03-26 13:33:32 -04:00
Cody Henthorne
d2cc5d54fe Use full version code for version tracking. 2025-03-26 13:23:47 -04:00
Greyson Parrelli
390a03b783 Fix query for thread merge fallback. 2025-03-26 09:09:26 -04:00
Greyson Parrelli
4b326a9875 Bump version to 7.38.6 2025-03-25 09:55:27 -04:00
Greyson Parrelli
0c05bfd756 Update translations and other static files. 2025-03-25 09:54:59 -04:00
Cody Henthorne
b8032378f6 Only deprecate client on 499s from chat service. 2025-03-25 09:45:26 -04:00
Cody Henthorne
2f4669d7eb Fix web socket management related crashes. 2025-03-25 09:44:38 -04:00
Alex Hart
0fb6062db3 Bump version to 7.38.5 2025-03-21 16:47:50 -03:00
Alex Hart
8d0ad52c8a Update baseline profile. 2025-03-21 16:31:42 -03:00
Alex Hart
bc3352148b Update translations and other static files. 2025-03-21 16:25:51 -03:00
Greyson Parrelli
edf5ecf2d6 Fix potential archive export issue around voice notes in revisions. 2025-03-21 13:59:01 -04:00
Greyson Parrelli
f145c20508 Fix potential archive export error around username. 2025-03-21 11:46:29 -04:00
Greyson Parrelli
8b54cea119 Fix potential NPE during archive export. 2025-03-21 11:25:51 -04:00
Greyson Parrelli
2b1f71d3b6 Fix potential constraint violation during thread merge. 2025-03-21 11:20:48 -04:00
Alex Hart
df4c6b59cd Enable DOM storage and enable error messaging for PayPal user actions. 2025-03-21 10:42:58 -03:00
925 changed files with 113230 additions and 34356 deletions

View File

@@ -18,3 +18,14 @@ ktlint_standard_statement-wrapping = disabled
internal:ktlint-suppression = disabled
ktlint_standard_unnecessary-parentheses-before-trailing-lambda = disabled
ktlint_standard_value-parameter-comment = disabled
# Disable ktlint on generated source code, see
# https://github.com/JLLeitschuh/ktlint-gradle/issues/746
[**/build/generated/source/**]
ktlint = disabled
[build/generated/*/main/**]
ktlint = disabled
[**/build/generated-sources/**]
ktlint = disabled

View File

@@ -16,19 +16,19 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
submodules: true
- name: set up JDK 17
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
cache: gradle
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
uses: gradle/wrapper-validation-action@v3
- name: Build with Gradle
run: ./gradlew qa

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

@@ -54,7 +54,7 @@ The form and manner of this distribution makes it eligible for export under the
## License
Copyright 2013-2024 Signal Messenger, LLC
Copyright 2013-2025 Signal Messenger, LLC
Licensed under the GNU AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html

View File

@@ -1,6 +1,7 @@
@file:Suppress("UnstableApiUsage")
import com.android.build.api.dsl.ManagedVirtualDevice
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import java.io.ByteArrayOutputStream
import java.io.FileInputStream
import java.text.SimpleDateFormat
import java.util.Date
@@ -20,8 +21,8 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1527
val canonicalVersionName = "7.38.4"
val canonicalVersionCode = 1541
val canonicalVersionName = "7.41.2"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -90,6 +91,7 @@ android {
kotlinOptions {
jvmTarget = signalKotlinJvmTarget
freeCompilerArgs = listOf("-Xjvm-default=all")
suppressWarnings = true
}
keystores["debug"]?.let { properties ->
@@ -368,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") {
@@ -412,6 +415,8 @@ android {
abortOnError = true
baseline = file("lint-baseline.xml")
checkReleaseBuilds = false
ignoreWarnings = true
quiet = true
disable += "LintError"
}
@@ -608,6 +613,7 @@ dependencies {
testImplementation(testLibs.mockk)
testImplementation(testFixtures(project(":libsignal-service")))
testImplementation(testLibs.espresso.core)
testImplementation(testLibs.kotlinx.coroutines.test)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
@@ -633,39 +639,25 @@ fun assertIsGitRepo() {
fun getLastCommitTimestamp(): String {
assertIsGitRepo()
ByteArrayOutputStream().use { os ->
exec {
executable = "git"
args = listOf("log", "-1", "--pretty=format:%ct")
standardOutput = os
}
return os.toString() + "000"
}
return providers.exec {
commandLine("git", "log", "-1", "--pretty=format:%ct")
}.standardOutput.asText.get() + "000"
}
fun getGitHash(): String {
assertIsGitRepo()
val stdout = ByteArrayOutputStream()
exec {
commandLine = listOf("git", "rev-parse", "HEAD")
standardOutput = stdout
}
return stdout.toString().trim().substring(0, 12)
return providers.exec {
commandLine("git", "rev-parse", "HEAD")
}.standardOutput.asText.get().trim().substring(0, 12)
}
fun getCurrentGitTag(): String? {
assertIsGitRepo()
val stdout = ByteArrayOutputStream()
exec {
commandLine = listOf("git", "tag", "--points-at", "HEAD")
standardOutput = stdout
}
val output: String = stdout.toString().trim()
val output = providers.exec {
commandLine("git", "tag", "--points-at", "HEAD")
}.standardOutput.asText.get().trim()
return if (output.isNotEmpty()) {
val tags = output.split("\n").toList()
@@ -685,19 +677,10 @@ tasks.withType<Test>().configureEach {
}
}
project.tasks.configureEach {
if (name.lowercase().contains("nightly") && name != "checkNightlyParams") {
dependsOn(tasks.getByName("checkNightlyParams"))
}
}
tasks.register("checkNightlyParams") {
doFirst {
if (project.gradle.startParameter.taskNames.any { it.lowercase().contains("nightly") }) {
if (!file("${project.rootDir}/nightly-url.txt").exists()) {
throw GradleException("Cannot find 'nightly-url.txt' for nightly build! It must exist in the root of this project and contain the location of the nightly manifest.")
}
gradle.taskGraph.whenReady {
if (gradle.startParameter.taskNames.any { it.contains("nightly", ignoreCase = true) }) {
if (!file("${project.rootDir}/nightly-url.txt").exists()) {
throw GradleException("Missing required file: nightly-url.txt")
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -43,4 +43,5 @@
</issue>
<issue id="OptionalUsedAsFieldOrParameterType" severity="ignore" />
<issue id="SameParameterValue" severity="ignore" />
</lint>

View File

@@ -11,6 +11,7 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTextInput
import androidx.core.content.ContextCompat
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -23,6 +24,7 @@ import io.mockk.every
import io.mockk.mockkStatic
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.StandardTestDispatcher
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -38,6 +40,7 @@ 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.testing.CoroutineDispatcherRule
import org.thoughtcrime.securesms.testing.InAppPaymentsRule
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.RemoteConfig
@@ -53,6 +56,10 @@ class MessageBackupsCheckoutActivityTest {
@get:Rule val composeTestRule = createEmptyComposeRule()
private val testDispatcher = StandardTestDispatcher()
@get:Rule val coroutineDispatcherRule = CoroutineDispatcherRule(testDispatcher)
private val purchaseResults = MutableSharedFlow<BillingPurchaseResult>()
@Before
@@ -79,6 +86,8 @@ class MessageBackupsCheckoutActivityTest {
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsTypeSelectionScreen__next)).performClick()
composeTestRule.waitForIdle()
testDispatcher.scheduler.advanceUntilIdle()
runBlocking {
purchaseResults.emit(
BillingPurchaseResult.Success(
@@ -94,6 +103,8 @@ class MessageBackupsCheckoutActivityTest {
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("dialog-circular-progress-indicator").assertIsDisplayed()
testDispatcher.scheduler.advanceUntilIdle()
val iap = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)
assertThat(iap?.state).isEqualTo(InAppPaymentTable.State.PENDING)
@@ -139,7 +150,7 @@ class MessageBackupsCheckoutActivityTest {
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)).performClick()
scenario.onActivity {
val backupKeyString = SignalStore.account.accountEntropyPool.value.chunked(4).joinToString(" ")
val backupKeyString = SignalStore.account.accountEntropyPool.displayValue.chunked(4).joinToString(" ")
val clipboardManager = ContextCompat.getSystemService(context, ClipboardManager::class.java)
assertThat(clipboardManager?.primaryClip?.getItemAt(0)?.coerceToText(context)).isEqualTo(backupKeyString)
}
@@ -147,12 +158,18 @@ class MessageBackupsCheckoutActivityTest {
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__next)).assertIsDisplayed()
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__next)).performClick()
// Key verification page
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyVerifyScreen__enter_the_backup_key_that_you_just_recorded)).assertIsDisplayed()
composeTestRule.onNodeWithTag("message-backups-key-verify-screen-backup-key-input-field").performTextInput(
SignalStore.account.accountEntropyPool.displayValue
)
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__next)).assertIsEnabled()
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__next)).performClick()
// Key record bottom sheet
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__keep_your_key_safe)).assertIsDisplayed()
composeTestRule.onNodeWithTag("message-backups-key-record-screen-sheet-content")
.performScrollToNode(hasText(context.getString(R.string.MessageBackupsKeyRecordScreen__continue)))
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__continue)).assertIsNotEnabled()
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__ive_recorded_my_key)).performClick()
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__continue)).assertIsEnabled()
composeTestRule.onNodeWithText(context.getString(R.string.MessageBackupsKeyRecordScreen__continue)).performClick()
@@ -162,8 +179,11 @@ class MessageBackupsCheckoutActivityTest {
}
private fun launchCheckoutFlow(tier: MessageBackupTier? = null): ActivityScenario<MessageBackupsCheckoutActivity> {
return ActivityScenario.launch(
val scenario = ActivityScenario.launch<MessageBackupsCheckoutActivity>(
MessageBackupsCheckoutActivity.Contract().createIntent(InstrumentationRegistry.getInstrumentation().targetContext, tier)
)
testDispatcher.scheduler.advanceUntilIdle()
return scenario
}
}

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

@@ -18,7 +18,6 @@ import org.signal.core.util.update
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getMediaName
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.MediaStream
@@ -30,7 +29,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
import org.whispersystems.signalservice.api.backup.MediaId
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
@@ -734,12 +732,9 @@ class AttachmentTableTest_deduping {
SignalDatabase.attachments.finalizeAttachmentAfterUpload(attachmentId, createUploadResult(attachmentId, uploadTimestamp))
val attachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
SignalDatabase.attachments.setArchiveData(
SignalDatabase.attachments.setArchiveCdn(
attachmentId = attachmentId,
archiveCdn = Cdn.CDN_3.cdnNumber,
archiveMediaName = attachment.getMediaName().name,
archiveThumbnailMediaId = MediaId(Util.getSecretBytes(15)).encode(),
archiveMediaId = MediaId(Util.getSecretBytes(15)).encode()
archiveCdn = Cdn.CDN_3.cdnNumber
)
}
@@ -861,8 +856,6 @@ class AttachmentTableTest_deduping {
val rhsAttachment = SignalDatabase.attachments.getAttachment(rhs)!!
assertEquals(lhsAttachment.archiveCdn, rhsAttachment.archiveCdn)
assertEquals(lhsAttachment.archiveMediaName, rhsAttachment.archiveMediaName)
assertEquals(lhsAttachment.archiveMediaId, rhsAttachment.archiveMediaId)
}
fun assertDoesNotHaveRemoteFields(attachmentId: AttachmentId) {

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.database
import androidx.media3.common.util.Util
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
@@ -9,6 +10,7 @@ import org.junit.runner.RunWith
import org.signal.core.util.count
import org.signal.core.util.readToSingleInt
import org.thoughtcrime.securesms.backup.v2.ArchivedMediaObject
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable.ArchiveMediaItem
import org.thoughtcrime.securesms.testing.SignalActivityRule
@RunWith(AndroidJUnit4::class)
@@ -16,6 +18,7 @@ class BackupMediaSnapshotTableTest {
companion object {
private const val SEQUENCE_COUNT = 100
private const val SEQUENCE_COUNT_WITH_THUMBNAILS = 200
}
@get:Rule
@@ -24,7 +27,7 @@ class BackupMediaSnapshotTableTest {
@Test
fun givenAnEmptyTable_whenIWriteToTable_thenIExpectEmptyTable() {
val pendingSyncTime = 1L
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveObjectSequence(), pendingSyncTime)
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveMediaItemSequence(), pendingSyncTime)
val count = getSyncedItemCount(pendingSyncTime)
@@ -34,22 +37,23 @@ class BackupMediaSnapshotTableTest {
@Test
fun givenAnEmptyTable_whenIWriteToTableAndCommit_thenIExpectFilledTable() {
val pendingSyncTime = 1L
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveObjectSequence(), pendingSyncTime)
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveMediaItemSequence(), pendingSyncTime)
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val count = getSyncedItemCount(pendingSyncTime)
assertThat(count).isEqualTo(SEQUENCE_COUNT)
assertThat(count).isEqualTo(SEQUENCE_COUNT_WITH_THUMBNAILS)
}
@Test
fun givenAFilledTable_whenIInsertSimilarIds_thenIExpectUncommittedOverrides() {
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveObjectSequence(), 1L)
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveMediaItemSequence(), 1L)
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val newPendingTime = 2L
val newObjectCount = 50
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveObjectSequence(newObjectCount), newPendingTime)
val newObjectCountWithThumbnails = newObjectCount * 2
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveMediaItemSequence(newObjectCount), newPendingTime)
val count = SignalDatabase.backupMediaSnapshots.readableDatabase.count()
.from(BackupMediaSnapshotTable.TABLE_NAME)
@@ -57,17 +61,18 @@ class BackupMediaSnapshotTableTest {
.run()
.readToSingleInt(-1)
assertThat(count).isEqualTo(50)
assertThat(count).isEqualTo(newObjectCountWithThumbnails)
}
@Test
fun givenAFilledTable_whenIInsertSimilarIdsAndCommit_thenIExpectCommittedOverrides() {
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveObjectSequence(), 1L)
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveMediaItemSequence(), 1L)
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val newPendingTime = 2L
val newObjectCount = 50
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveObjectSequence(newObjectCount), newPendingTime)
val newObjectCountWithThumbnails = newObjectCount * 2
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveMediaItemSequence(newObjectCount), newPendingTime)
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val count = SignalDatabase.backupMediaSnapshots.readableDatabase.count()
@@ -78,18 +83,19 @@ class BackupMediaSnapshotTableTest {
val total = getTotalItemCount()
assertThat(count).isEqualTo(50)
assertThat(total).isEqualTo(SEQUENCE_COUNT)
assertThat(count).isEqualTo(newObjectCountWithThumbnails)
assertThat(total).isEqualTo(SEQUENCE_COUNT_WITH_THUMBNAILS)
}
@Test
fun givenAFilledTable_whenIInsertSimilarIdsAndCommitThenDelete_thenIExpectOnlyCommittedOverrides() {
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveObjectSequence(), 1L)
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveMediaItemSequence(), 1L)
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val newPendingTime = 2L
val newObjectCount = 50
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveObjectSequence(newObjectCount), newPendingTime)
val newObjectCountWithThumbnails = newObjectCount * 2
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(generateArchiveMediaItemSequence(newObjectCount), newPendingTime)
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val page = SignalDatabase.backupMediaSnapshots.getPageOfOldMediaObjects(currentSyncTime = newPendingTime, pageSize = 100)
@@ -97,7 +103,86 @@ class BackupMediaSnapshotTableTest {
val total = getTotalItemCount()
assertThat(total).isEqualTo(50)
assertThat(total).isEqualTo(newObjectCountWithThumbnails)
}
@Test
fun getMediaObjectsWithNonMatchingCdn_noMismatches() {
val localData = listOf(
createArchiveMediaItem(seed = 1, cdn = 1),
createArchiveMediaItem(seed = 2, cdn = 2)
)
val remoteData = listOf(
createArchiveMediaObject(seed = 1, cdn = 1),
createArchiveMediaObject(seed = 2, cdn = 2)
)
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(localData.asSequence(), 1L)
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val mismatches = SignalDatabase.backupMediaSnapshots.getMediaObjectsWithNonMatchingCdn(remoteData)
assertThat(mismatches.size).isEqualTo(0)
}
@Test
fun getMediaObjectsWithNonMatchingCdn_oneMismatch() {
val localData = listOf(
createArchiveMediaItem(seed = 1, cdn = 1),
createArchiveMediaItem(seed = 2, cdn = 2)
)
val remoteData = listOf(
createArchiveMediaObject(seed = 1, cdn = 1),
createArchiveMediaObject(seed = 2, cdn = 99)
)
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(localData.asSequence(), 1L)
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val mismatches = SignalDatabase.backupMediaSnapshots.getMediaObjectsWithNonMatchingCdn(remoteData)
assertThat(mismatches.size).isEqualTo(1)
assertThat(mismatches.get(0).cdn).isEqualTo(99)
assertThat(mismatches.get(0).digest).isEqualTo(localData.get(1).digest)
}
@Test
fun getMediaObjectsThatCantBeFound_allFound() {
val localData = listOf(
createArchiveMediaItem(seed = 1, cdn = 1),
createArchiveMediaItem(seed = 2, cdn = 2)
)
val remoteData = listOf(
createArchiveMediaObject(seed = 1, cdn = 1),
createArchiveMediaObject(seed = 2, cdn = 2)
)
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(localData.asSequence(), 1L)
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val notFound = SignalDatabase.backupMediaSnapshots.getMediaObjectsThatCantBeFound(remoteData)
assertThat(notFound.size).isEqualTo(0)
}
@Test
fun getMediaObjectsThatCantBeFound_oneMissing() {
val localData = listOf(
createArchiveMediaItem(seed = 1, cdn = 1),
createArchiveMediaItem(seed = 2, cdn = 2)
)
val remoteData = listOf(
createArchiveMediaObject(seed = 1, cdn = 1),
createArchiveMediaObject(seed = 3, cdn = 2)
)
SignalDatabase.backupMediaSnapshots.writePendingMediaObjects(localData.asSequence(), 1L)
SignalDatabase.backupMediaSnapshots.commitPendingRows()
val notFound = SignalDatabase.backupMediaSnapshots.getMediaObjectsThatCantBeFound(remoteData)
assertThat(notFound.size).isEqualTo(1)
assertThat(notFound.first().cdn).isEqualTo(2)
}
private fun getTotalItemCount(): Int {
@@ -112,8 +197,24 @@ class BackupMediaSnapshotTableTest {
.readToSingleInt(-1)
}
private fun generateArchiveObjectSequence(count: Int = SEQUENCE_COUNT): Sequence<ArchivedMediaObject> {
private fun generateArchiveMediaItemSequence(count: Int = SEQUENCE_COUNT): Sequence<ArchiveMediaItem> {
return generateSequence(0) { seed -> if (seed < (count - 1)) seed + 1 else null }
.map { ArchivedMediaObject(mediaId = "media_id_$it", 0) }
.map { createArchiveMediaItem(it) }
}
private fun createArchiveMediaItem(seed: Int, cdn: Int = 0): ArchiveMediaItem {
return ArchiveMediaItem(
mediaId = "media_id_$seed",
thumbnailMediaId = "thumbnail_media_id_$seed",
cdn = cdn,
digest = Util.toByteArray(seed)
)
}
private fun createArchiveMediaObject(seed: Int, cdn: Int = 0): ArchivedMediaObject {
return ArchivedMediaObject(
mediaId = "media_id_$seed",
cdn = cdn
)
}
}

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,33 +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,
isMuted = 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)
@@ -84,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)
}
@@ -92,17 +113,16 @@ 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,
isMuted = true,
includedChats = listOf(aliceThread, charlieThread),
excludedChats = listOf(bobThread)
)
SignalDatabase.chatFolders.updateFolder(updatedFolder)
val actualFolder = SignalDatabase.chatFolders.getChatFolders().first()
val actualFolder = SignalDatabase.chatFolders.getCurrentChatFolders().first()
assertEquals(updatedFolder, actualFolder)
}
@@ -111,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,6 +26,11 @@ 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
@@ -92,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),
@@ -120,14 +126,23 @@ 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(
authWebSocket: SignalWebSocket.AuthenticatedWebSocket,
unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket,
protocolStore: SignalServiceDataStore,
pushServiceSocket: PushServiceSocket
pushServiceSocket: PushServiceSocket,
attachmentApi: AttachmentApi,
messageApi: MessageApi,
keysApi: KeysApi
): SignalServiceMessageSender {
if (signalServiceMessageSender == null) {
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(authWebSocket, unauthWebSocket, protocolStore, pushServiceSocket))
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, attachmentApi, messageApi, keysApi))
}
return signalServiceMessageSender!!
}

View File

@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.jobs
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEmpty
import okhttp3.mockwebserver.MockResponse
import org.junit.Before
@@ -69,30 +68,6 @@ class InAppPaymentAuthCheckJobTest {
assertThat(receipts).isEmpty()
}
@Test
fun givenSuccessfulOneTimeAuthRequiredPayment_whenICheck_thenIExpectAReceipt() {
initializeMockGetPaymentIntent(status = StripeIntentStatus.SUCCEEDED)
SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.ONE_TIME_DONATION,
state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION,
subscriberId = null,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData(
amount = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")).toFiatValue(),
waitForAuth = InAppPaymentData.WaitingForAuthorizationState(
stripeIntentId = TEST_INTENT_ID,
stripeClientSecret = TEST_CLIENT_SECRET
)
)
)
InAppPaymentAuthCheckJob().run()
val receipts = SignalDatabase.donationReceipts.getReceipts(InAppPaymentReceiptRecord.Type.ONE_TIME_DONATION)
assertThat(receipts).hasSize(1)
}
private fun initializeMockGetPaymentIntent(status: StripeIntentStatus) {
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get(TestStripePaths.getPaymentIntentPath(TEST_INTENT_ID, TEST_CLIENT_SECRET)) {

View File

@@ -0,0 +1,288 @@
package org.thoughtcrime.securesms.jobs
import android.net.Uri
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import org.junit.Rule
import org.junit.Test
import org.signal.donations.InAppPaymentType
import org.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSetupJobData
import org.thoughtcrime.securesms.jobs.protos.InAppPaymentSourceData
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
/**
* Core test logic for [InAppPaymentSetupJob]
*/
class InAppPaymentSetupJobTest {
@get:Rule
val signalDatabaseRule = SignalDatabaseRule()
@Test
fun givenAnInAppPaymentThatDoesntExist_whenIRun_thenIExpectFailure() {
val testData = InAppPaymentSetupJobData(
inAppPaymentId = 1L,
inAppPaymentSource = InAppPaymentSourceData.Builder()
.code(InAppPaymentSourceData.Code.CREDIT_CARD)
.tokenData(InAppPaymentSourceData.TokenData())
.build()
)
val testJob = TestInAppPaymentSetupJob(testData)
val result = testJob.run()
assertThat(result.isFailure).isEqualTo(true)
}
@Test
fun givenAnInAppPaymentInEndState_whenIRun_thenIExpectFailure() {
val id = insertInAppPayment(state = InAppPaymentTable.State.END)
val testData = InAppPaymentSetupJobData(
inAppPaymentId = id.rowId,
inAppPaymentSource = InAppPaymentSourceData.Builder()
.code(InAppPaymentSourceData.Code.CREDIT_CARD)
.tokenData(InAppPaymentSourceData.TokenData())
.build()
)
val testJob = TestInAppPaymentSetupJob(testData)
val result = testJob.run()
assertThat(result.isFailure).isEqualTo(true)
}
@Test
fun givenAnInAppPaymentInRequiredActionCompletedWithoutCompletedState_whenIRun_thenIExpectFailure() {
val id = insertInAppPayment(
state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED
)
val testData = InAppPaymentSetupJobData(
inAppPaymentId = id.rowId,
inAppPaymentSource = InAppPaymentSourceData.Builder()
.code(InAppPaymentSourceData.Code.CREDIT_CARD)
.tokenData(InAppPaymentSourceData.TokenData())
.build()
)
val testJob = TestInAppPaymentSetupJob(testData)
val result = testJob.run()
assertThat(result.isFailure).isEqualTo(true)
assertThat(SignalDatabase.inAppPayments.getById(id)?.state).isEqualTo(InAppPaymentTable.State.END)
}
@Test
fun givenAStripeInAppPaymentInRequiredActionCompletedWithCompletedState_whenIRun_thenIExpectSuccess() {
val id = insertInAppPayment(
state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED,
data = InAppPaymentData.Builder()
.paymentMethodType(InAppPaymentData.PaymentMethodType.CARD)
.stripeActionComplete(InAppPaymentData.StripeActionCompleteState())
.build()
)
val testData = InAppPaymentSetupJobData(
inAppPaymentId = id.rowId,
inAppPaymentSource = InAppPaymentSourceData.Builder()
.code(InAppPaymentSourceData.Code.CREDIT_CARD)
.build()
)
val testJob = TestInAppPaymentSetupJob(testData)
val result = testJob.run()
assertThat(result.isSuccess).isEqualTo(true)
}
@Test
fun givenAPayPalInAppPaymentInRequiredActionCompletedWithCompletedState_whenIRun_thenIExpectSuccess() {
val id = insertInAppPayment(
state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED,
data = InAppPaymentData.Builder()
.paymentMethodType(InAppPaymentData.PaymentMethodType.PAYPAL)
.payPalActionComplete(InAppPaymentData.PayPalActionCompleteState())
.build()
)
val testData = InAppPaymentSetupJobData(
inAppPaymentId = id.rowId,
inAppPaymentSource = InAppPaymentSourceData.Builder()
.code(InAppPaymentSourceData.Code.PAY_PAL)
.build()
)
val testJob = TestInAppPaymentSetupJob(testData)
val result = testJob.run()
assertThat(result.isSuccess).isEqualTo(true)
}
@Test
fun givenRequiredActionComplete_whenIRun_thenIBypassPerformPreUserAction() {
val id = insertInAppPayment(
state = InAppPaymentTable.State.REQUIRED_ACTION_COMPLETED,
data = InAppPaymentData.Builder()
.paymentMethodType(InAppPaymentData.PaymentMethodType.PAYPAL)
.payPalActionComplete(InAppPaymentData.PayPalActionCompleteState())
.build()
)
val testData = InAppPaymentSetupJobData(
inAppPaymentId = id.rowId,
inAppPaymentSource = InAppPaymentSourceData.Builder()
.code(InAppPaymentSourceData.Code.PAY_PAL)
.build()
)
val testJob = TestInAppPaymentSetupJob(
data = testData,
requiredUserAction = { error("Unexpected call to requiredUserAction") },
postUserActionResult = {
assertThat(SignalDatabase.inAppPayments.getById(id)?.state).isEqualTo(InAppPaymentTable.State.TRANSACTING)
Job.Result.success()
}
)
val result = testJob.run()
assertThat(result.isSuccess).isEqualTo(true)
}
@Test
fun givenPayPalUserActionRequired_whenIRun_thenIDoNotPerformPostUserActionResult() {
val id = insertInAppPayment(
state = InAppPaymentTable.State.CREATED,
data = InAppPaymentData.Builder()
.paymentMethodType(InAppPaymentData.PaymentMethodType.PAYPAL)
.build()
)
val testData = InAppPaymentSetupJobData(
inAppPaymentId = id.rowId,
inAppPaymentSource = InAppPaymentSourceData.Builder()
.code(InAppPaymentSourceData.Code.PAY_PAL)
.build()
)
val testJob = TestInAppPaymentSetupJob(
data = testData,
requiredUserAction = {
InAppPaymentSetupJob.RequiredUserAction.PayPalActionRequired("", "")
},
postUserActionResult = {
error("Unexpected call to postUserActionResult")
}
)
val result = testJob.run()
assertThat(result.isFailure).isEqualTo(true)
val fresh = SignalDatabase.inAppPayments.getById(id)!!
assertThat(fresh.state).isEqualTo(InAppPaymentTable.State.REQUIRES_ACTION)
assertThat(fresh.data.payPalRequiresAction).isNotNull()
}
@Test
fun givenStripeUserActionRequired_whenIRun_thenIDoNotPerformPostUserActionResult() {
val id = insertInAppPayment(
state = InAppPaymentTable.State.CREATED,
data = InAppPaymentData.Builder()
.paymentMethodType(InAppPaymentData.PaymentMethodType.CARD)
.build()
)
val testData = InAppPaymentSetupJobData(
inAppPaymentId = id.rowId,
inAppPaymentSource = InAppPaymentSourceData.Builder()
.code(InAppPaymentSourceData.Code.CREDIT_CARD)
.build()
)
val testJob = TestInAppPaymentSetupJob(
data = testData,
requiredUserAction = {
InAppPaymentSetupJob.RequiredUserAction.StripeActionRequired(
StripeApi.Secure3DSAction.ConfirmRequired(
uri = Uri.EMPTY,
returnUri = Uri.EMPTY,
stripeIntentAccessor = StripeIntentAccessor(
objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT,
intentId = "",
intentClientSecret = ""
),
paymentMethodId = null
)
)
},
postUserActionResult = {
error("Unexpected call to postUserActionResult")
}
)
val result = testJob.run()
assertThat(result.isFailure).isEqualTo(true)
val fresh = SignalDatabase.inAppPayments.getById(id)!!
assertThat(fresh.state).isEqualTo(InAppPaymentTable.State.REQUIRES_ACTION)
assertThat(fresh.data.stripeRequiresAction).isNotNull()
}
private fun insertInAppPayment(
state: InAppPaymentTable.State = InAppPaymentTable.State.CREATED,
data: InAppPaymentData = InAppPaymentData()
): InAppPaymentTable.InAppPaymentId {
return SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.ONE_TIME_DONATION,
state = state,
subscriberId = null,
endOfPeriod = null,
inAppPaymentData = data
)
}
private class TestInAppPaymentSetupJob(
data: InAppPaymentSetupJobData,
val requiredUserAction: () -> RequiredUserAction = {
RequiredUserAction.StripeActionNotRequired(
StripeApi.Secure3DSAction.NotNeeded(
paymentMethodId = "",
stripeIntentAccessor = StripeIntentAccessor(
objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT,
intentId = "",
intentClientSecret = ""
)
)
)
},
val postUserActionResult: () -> Result = { Result.success() }
) : InAppPaymentSetupJob(data, Parameters.Builder().build()) {
override fun performPreUserAction(inAppPayment: InAppPaymentTable.InAppPayment): RequiredUserAction {
return requiredUserAction()
}
override fun performPostUserAction(inAppPayment: InAppPaymentTable.InAppPayment): Result {
return postUserActionResult()
}
override fun getFactoryKey(): String = error("Not used.")
override fun run(): Result {
return performTransaction()
}
}
}

View File

@@ -677,7 +677,6 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
mmsId = this.mmsId,
hasData = this.hasData,
hasThumbnail = false,
hasArchiveThumbnail = false,
contentType = this.contentType,
transferProgress = this.transferState,
size = this.size,
@@ -705,8 +704,6 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
uploadTimestamp = this.uploadTimestamp,
dataHash = this.dataHash,
archiveCdn = this.archiveCdn,
archiveMediaName = this.archiveMediaName,
archiveMediaId = this.archiveMediaId,
thumbnailRestoreState = this.thumbnailRestoreState,
archiveTransferState = this.archiveTransferState,
uuid = uuid

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.testing
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.test.TestDispatcher
import org.junit.rules.ExternalResource
import org.signal.core.util.concurrent.SignalDispatchers
/**
* Rule that allows for injection of test dispatchers when operating with ViewModels.
*/
class CoroutineDispatcherRule(
defaultDispatcher: TestDispatcher,
mainDispatcher: TestDispatcher = defaultDispatcher,
ioDispatcher: TestDispatcher = defaultDispatcher,
unconfinedDispatcher: TestDispatcher = defaultDispatcher
) : ExternalResource() {
private val testDispatcherProvider = TestDispatcherProvider(
main = mainDispatcher,
io = ioDispatcher,
default = defaultDispatcher,
unconfined = unconfinedDispatcher
)
override fun before() {
SignalDispatchers.setDispatcherProvider(testDispatcherProvider)
}
override fun after() {
SignalDispatchers.setDispatcherProvider()
}
private class TestDispatcherProvider(
override val main: CoroutineDispatcher,
override val io: CoroutineDispatcher,
override val default: CoroutineDispatcher,
override val unconfined: CoroutineDispatcher
) : SignalDispatchers.DispatcherProvider
}

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

@@ -18,9 +18,9 @@ import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.fragment.findNavController
import androidx.navigation.navGraphViewModels
import org.signal.core.ui.Rows
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
@@ -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"
@@ -602,30 +603,44 @@
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="signal.tube" />
<data android:scheme="sgnl"
android:host="signal.tube" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="signal.me" />
<data android:scheme="sgnl"
android:host="signal.me" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="sgnl" />
<data android:scheme="https" />
<data android:host="signal.tube" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="sgnl" />
<data android:host="signal.tube" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="signal.me" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="sgnl" />
<data android:host="signal.me" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="signal.link" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="sgnl" />
<data android:host="signal.link" />
</intent-filter>
</activity>
@@ -880,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"
@@ -1055,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"/>
@@ -1134,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"

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,8 @@ object AppCapabilities {
storage = storageCapable,
deleteSync = true,
versionedExpirationTimer = true,
storageServiceEncryptionV2 = true
storageServiceEncryptionV2 = true,
attachmentBackfill = true
)
}
}

View File

@@ -32,7 +32,7 @@ public final class AppInitialization {
TextSecurePreferences.setAppMigrationVersion(context, ApplicationMigrations.CURRENT_VERSION);
TextSecurePreferences.setJobManagerVersion(context, JobManager.CURRENT_VERSION);
TextSecurePreferences.setLastVersionCode(context, Util.getCanonicalVersionCode());
TextSecurePreferences.setLastVersionCode(context, BuildConfig.VERSION_CODE);
TextSecurePreferences.setHasSeenStickerIntroTooltip(context, true);
SignalStore.settings().setPassphraseDisabled(true);
TextSecurePreferences.setReadReceiptsEnabled(context, true);
@@ -72,7 +72,7 @@ public final class AppInitialization {
TextSecurePreferences.setAppMigrationVersion(context, ApplicationMigrations.CURRENT_VERSION);
TextSecurePreferences.setJobManagerVersion(context, JobManager.CURRENT_VERSION);
TextSecurePreferences.setLastVersionCode(context, Util.getCanonicalVersionCode());
TextSecurePreferences.setLastVersionCode(context, BuildConfig.VERSION_CODE);
TextSecurePreferences.setHasSeenStickerIntroTooltip(context, true);
SignalStore.settings().setPassphraseDisabled(true);
AppDependencies.getMegaphoneRepository().onFirstEverAppLaunch();

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

@@ -170,7 +170,6 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
ContactSelectionActivity activity = this.activity.get();
if (activity != null && !activity.isFinishing()) {
activity.contactFilterView.clear();
activity.contactsFragment.resetQueryFilter();
}
}

View File

@@ -1,305 +0,0 @@
package org.thoughtcrime.securesms;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.view.ViewTreeObserver;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.ViewModelProvider;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.BundleExtensions;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.donations.StripeApi;
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar;
import org.thoughtcrime.securesms.components.ConnectivityWarningBottomSheet;
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment;
import org.thoughtcrime.securesms.components.DeviceSpecificNotificationBottomSheet;
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment;
import org.thoughtcrime.securesms.conversationlist.RestoreCompleteBottomSheetDialog;
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor;
import org.thoughtcrime.securesms.notifications.VitalsViewModel;
import org.thoughtcrime.securesms.stories.Stories;
import org.thoughtcrime.securesms.stories.tabs.ConversationListTab;
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository;
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
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;
public class MainActivity extends PassphraseRequiredActivity implements VoiceNoteMediaControllerOwner {
private static final String KEY_STARTING_TAB = "STARTING_TAB";
public static final int RESULT_CONFIG_CHANGED = Activity.RESULT_FIRST_USER + 901;
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
private final MainNavigator navigator = new MainNavigator(this);
private VoiceNoteMediaController mediaController;
private ConversationListTabsViewModel conversationListTabsViewModel;
private VitalsViewModel vitalsViewModel;
private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable();
private boolean onFirstRender = false;
public static @NonNull Intent clearTop(@NonNull Context context) {
Intent intent = new Intent(context, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
Intent.FLAG_ACTIVITY_NEW_TASK |
Intent.FLAG_ACTIVITY_SINGLE_TOP);
return intent;
}
public static @NonNull Intent clearTopAndOpenTab(@NonNull Context context, @NonNull ConversationListTab startingTab) {
Intent intent = clearTop(context);
intent.putExtra(KEY_STARTING_TAB, startingTab);
return intent;
}
@Override
protected void onCreate(Bundle savedInstanceState, boolean ready) {
AppStartup.getInstance().onCriticalRenderEventStart();
super.onCreate(savedInstanceState, ready);
setContentView(R.layout.main_activity);
final View content = findViewById(android.R.id.content);
content.getViewTreeObserver().addOnPreDrawListener(
new ViewTreeObserver.OnPreDrawListener() {
@Override
public boolean onPreDraw() {
// Use pre draw listener to delay drawing frames till conversation list is ready
if (onFirstRender) {
content.getViewTreeObserver().removeOnPreDrawListener(this);
return true;
} else {
return false;
}
}
});
lifecycleDisposable.bindTo(this);
mediaController = new VoiceNoteMediaController(this, true);
ConversationListTab startingTab = null;
if (getIntent().getExtras() != null) {
startingTab = BundleExtensions.getSerializableCompat(getIntent().getExtras(), KEY_STARTING_TAB, ConversationListTab.class);
}
ConversationListTabRepository repository = new ConversationListTabRepository();
ConversationListTabsViewModel.Factory factory = new ConversationListTabsViewModel.Factory(startingTab, repository);
handleDeeplinkIntent(getIntent());
CachedInflater.from(this).clear();
conversationListTabsViewModel = new ViewModelProvider(this, factory).get(ConversationListTabsViewModel.class);
updateTabVisibility();
vitalsViewModel = new ViewModelProvider(this).get(VitalsViewModel.class);
lifecycleDisposable.add(
vitalsViewModel
.getVitalsState()
.subscribe(this::presentVitalsState)
);
}
@SuppressLint("NewApi")
private void presentVitalsState(VitalsViewModel.State state) {
switch (state) {
case NONE:
break;
case PROMPT_SPECIFIC_BATTERY_SAVER_DIALOG:
DeviceSpecificNotificationBottomSheet.show(getSupportFragmentManager());
break;
case PROMPT_GENERAL_BATTERY_SAVER_DIALOG:
PromptBatterySaverDialogFragment.show(getSupportFragmentManager());
break;
case PROMPT_CONNECTIVITY_WARNING:
ConnectivityWarningBottomSheet.show(getSupportFragmentManager());
break;
case PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS:
DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.NOTIFICATIONS);
break;
case PROMPT_DEBUGLOGS_FOR_CRASH:
DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.CRASH);
break;
case PROMPT_DEBUGLOGS_FOR_CONNECTIVITY_WARNING:
DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.CONNECTIVITY_WARNING);
break;
}
}
@Override
public Intent getIntent() {
return super.getIntent().setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
Intent.FLAG_ACTIVITY_NEW_TASK |
Intent.FLAG_ACTIVITY_SINGLE_TOP);
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
handleDeeplinkIntent(intent);
if (intent.getExtras() != null) {
ConversationListTab startingTab = BundleExtensions.getSerializableCompat(intent.getExtras(), KEY_STARTING_TAB, ConversationListTab.class);
if (startingTab != null) {
switch (startingTab) {
case CHATS -> conversationListTabsViewModel.onChatsSelected();
case CALLS -> conversationListTabsViewModel.onCallsSelected();
case STORIES -> {
if (Stories.isFeatureEnabled()) {
conversationListTabsViewModel.onStoriesSelected();
}
}
}
}
}
}
@Override
protected void onPreCreate() {
super.onPreCreate();
dynamicTheme.onCreate(this);
}
@Override
protected void onResume() {
super.onResume();
dynamicTheme.onResume(this);
if (SignalStore.misc().getShouldShowLinkedDevicesReminder()) {
SignalStore.misc().setShouldShowLinkedDevicesReminder(false);
RelinkDevicesReminderBottomSheetFragment.show(getSupportFragmentManager());
}
if (SignalStore.registration().isRestoringOnNewDevice()) {
SignalStore.registration().setRestoringOnNewDevice(false);
RestoreCompleteBottomSheetDialog.show(getSupportFragmentManager());
} else if (SignalStore.misc().isOldDeviceTransferLocked()) {
new MaterialAlertDialogBuilder(this)
.setTitle(R.string.OldDeviceTransferLockedDialog__complete_registration_on_your_new_device)
.setMessage(R.string.OldDeviceTransferLockedDialog__your_signal_account_has_been_transferred_to_your_new_device)
.setPositiveButton(R.string.OldDeviceTransferLockedDialog__done, (d, w) -> OldDeviceExitActivity.exit(this))
.setNegativeButton(R.string.OldDeviceTransferLockedDialog__cancel_and_activate_this_device, (d, w) -> {
SignalStore.misc().setOldDeviceTransferLocked(false);
DeviceTransferBlockingInterceptor.getInstance().unblockNetwork();
})
.setCancelable(false)
.show();
}
updateTabVisibility();
vitalsViewModel.checkSlowNotificationHeuristics();
}
@Override
protected void onStop() {
super.onStop();
SplashScreenUtil.setSplashScreenThemeIfNecessary(this, SignalStore.settings().getTheme());
}
@Override
public void onBackPressed() {
if (!navigator.onBackPressed()) {
super.onBackPressed();
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == MainNavigator.REQUEST_CONFIG_CHANGES && resultCode == RESULT_CONFIG_CHANGED) {
recreate();
}
}
private void updateTabVisibility() {
findViewById(R.id.conversation_list_tabs).setVisibility(View.VISIBLE);
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorSurface2));
}
public @NonNull MainNavigator getNavigator() {
return navigator;
}
private void handleDeeplinkIntent(Intent intent) {
handleGroupLinkInIntent(intent);
handleProxyInIntent(intent);
handleSignalMeIntent(intent);
handleCallLinkInIntent(intent);
handleDonateReturnIntent(intent);
}
private void handleGroupLinkInIntent(Intent intent) {
Uri data = intent.getData();
if (data != null) {
CommunicationActions.handlePotentialGroupLinkUrl(this, data.toString());
}
}
private void handleProxyInIntent(Intent intent) {
Uri data = intent.getData();
if (data != null) {
CommunicationActions.handlePotentialProxyLinkUrl(this, data.toString());
}
}
private void handleSignalMeIntent(Intent intent) {
Uri data = intent.getData();
if (data != null) {
CommunicationActions.handlePotentialSignalMeUrl(this, data.toString());
}
}
private void handleCallLinkInIntent(Intent intent) {
Uri data = intent.getData();
if (data != null) {
CommunicationActions.handlePotentialCallLinkUrl(this, data.toString(), () -> {
YouAreAlreadyInACallSnackbar.show(findViewById(android.R.id.content));
});
}
}
private void handleDonateReturnIntent(Intent intent) {
Uri data = intent.getData();
if (data != null && data.toString().startsWith(StripeApi.RETURN_URL_IDEAL)) {
startActivity(AppSettingsActivity.manageSubscriptions(this));
}
}
public void onFirstRender() {
onFirstRender = true;
}
@Override
public @NonNull VoiceNoteMediaController getVoiceNoteMediaController() {
return mediaController;
}
}

View File

@@ -0,0 +1,735 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
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.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
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity.Companion.manageSubscriptions
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.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.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.viewModel
import org.thoughtcrime.securesms.window.AppScaffold
import org.thoughtcrime.securesms.window.WindowSizeClass
class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, MainNavigator.NavigatorProvider {
companion object {
private const val KEY_STARTING_TAB = "STARTING_TAB"
const val RESULT_CONFIG_CHANGED = Activity.RESULT_FIRST_USER + 901
@JvmStatic
fun clearTop(context: Context): Intent {
return Intent(context, MainActivity::class.java)
.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
@JvmStatic
fun clearTopAndOpenTab(context: Context, startingTab: MainNavigationListLocation): Intent {
return clearTop(context).putExtra(KEY_STARTING_TAB, startingTab)
}
}
private val dynamicTheme = DynamicNoActionBarTheme()
private val lifecycleDisposable = LifecycleDisposable()
private lateinit var mediaController: VoiceNoteMediaController
private lateinit var navigator: MainNavigator
override val voiceNoteMediaController: VoiceNoteMediaController
get() = mediaController
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 {
VitalsViewModel(application)
}
private val openSettings: ActivityResultLauncher<Intent> = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_CONFIG_CHANGED) {
recreate()
}
}
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) {
AppStartup.getInstance().onCriticalRenderEventStart()
enableEdgeToEdge(
navigationBarStyle = if (DynamicTheme.isDarkTheme(this)) {
SystemBarStyle.dark(0)
} else {
SystemBarStyle.light(0, 0)
}
)
super.onCreate(savedInstanceState, ready)
navigator = MainNavigator(this, mainNavigationViewModel)
AppForegroundObserver.addListener(object : AppForegroundObserver.Listener {
override fun onForeground() {
mainNavigationViewModel.getNextMegaphone()
}
})
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
mainNavigationViewModel.navigationEvents.collectLatest {
when (it) {
MainNavigationViewModel.NavigationEvent.STORY_CAMERA_FIRST -> {
mainBottomChromeCallback.onCameraClick(MainNavigationListLocation.STORIES)
}
}
}
}
}
shareDataTimestampViewModel.setTimestampFromActivityCreation(savedInstanceState, intent)
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)
}
}
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
)
}
}
val content: View = findViewById(android.R.id.content)
content.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
// Use pre draw listener to delay drawing frames till conversation list is ready
return if (onFirstRender) {
content.viewTreeObserver.removeOnPreDrawListener(this)
true
} else {
false
}
}
})
lifecycleDisposable.bindTo(this)
mediaController = VoiceNoteMediaController(this, true)
handleDeepLinkIntent(intent)
CachedInflater.from(this).clear()
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)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleDeepLinkIntent(intent)
val extras = intent.extras ?: return
val startingTab = extras.getSerializableCompat(KEY_STARTING_TAB, MainNavigationListLocation::class.java)
when (startingTab) {
MainNavigationListLocation.CHATS -> mainNavigationViewModel.onChatsSelected()
MainNavigationListLocation.CALLS -> mainNavigationViewModel.onCallsSelected()
MainNavigationListLocation.STORIES -> {
if (Stories.isFeatureEnabled()) {
mainNavigationViewModel.onStoriesSelected()
}
}
null -> Unit
}
}
override fun onPreCreate() {
super.onPreCreate()
dynamicTheme.onCreate(this)
}
override fun onResume() {
super.onResume()
dynamicTheme.onResume(this)
if (SignalStore.misc.shouldShowLinkedDevicesReminder) {
SignalStore.misc.shouldShowLinkedDevicesReminder = false
RelinkDevicesReminderBottomSheetFragment.show(supportFragmentManager)
}
if (SignalStore.registration.restoringOnNewDevice) {
SignalStore.registration.restoringOnNewDevice = false
RestoreCompleteBottomSheetDialog.show(supportFragmentManager)
} else if (SignalStore.misc.isOldDeviceTransferLocked) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.OldDeviceTransferLockedDialog__complete_registration_on_your_new_device)
.setMessage(R.string.OldDeviceTransferLockedDialog__your_signal_account_has_been_transferred_to_your_new_device)
.setPositiveButton(R.string.OldDeviceTransferLockedDialog__done) { _, _ -> OldDeviceExitActivity.exit(this) }
.setNegativeButton(R.string.OldDeviceTransferLockedDialog__cancel_and_activate_this_device) { _, _ ->
SignalStore.misc.isOldDeviceTransferLocked = false
DeviceTransferBlockingInterceptor.getInstance().unblockNetwork()
}
.setCancelable(false)
.show()
}
vitalsViewModel.checkSlowNotificationHeuristics()
mainNavigationViewModel.refreshNavigationBarState()
}
override fun onStop() {
super.onStop()
SplashScreenUtil.setSplashScreenThemeIfNecessary(this, SignalStore.settings.theme)
}
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?) {
super.onActivityResult(requestCode, resultCode, data)
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() {
onFirstRender = true
}
override fun getNavigator(): MainNavigator {
return navigator
}
private fun handleDeepLinkIntent(intent: Intent) {
handleConversationIntent(intent)
handleGroupLinkInIntent(intent)
handleProxyInIntent(intent)
handleSignalMeIntent(intent)
handleCallLinkInIntent(intent)
handleDonateReturnIntent(intent)
}
@SuppressLint("NewApi")
private fun presentVitalsState(state: VitalsViewModel.State) {
when (state) {
VitalsViewModel.State.NONE -> Unit
VitalsViewModel.State.PROMPT_SPECIFIC_BATTERY_SAVER_DIALOG -> DeviceSpecificNotificationBottomSheet.show(supportFragmentManager)
VitalsViewModel.State.PROMPT_GENERAL_BATTERY_SAVER_DIALOG -> PromptBatterySaverDialogFragment.show(supportFragmentManager)
VitalsViewModel.State.PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS -> DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.NOTIFICATIONS)
VitalsViewModel.State.PROMPT_DEBUGLOGS_FOR_CRASH -> DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.CRASH)
VitalsViewModel.State.PROMPT_CONNECTIVITY_WARNING -> ConnectivityWarningBottomSheet.show(supportFragmentManager)
VitalsViewModel.State.PROMPT_DEBUGLOGS_FOR_CONNECTIVITY_WARNING -> DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.CONNECTIVITY_WARNING)
}
}
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())
}
}
private fun handleProxyInIntent(intent: Intent) {
intent.data?.let { data ->
CommunicationActions.handlePotentialProxyLinkUrl(this, data.toString())
}
}
private fun handleSignalMeIntent(intent: Intent) {
intent.data?.let { data ->
CommunicationActions.handlePotentialSignalMeUrl(this, data.toString())
}
}
private fun handleCallLinkInIntent(intent: Intent) {
intent.data?.let { data ->
CommunicationActions.handlePotentialCallLinkUrl(this, data.toString()) {
show(findViewById(android.R.id.content))
}
}
}
private fun handleDonateReturnIntent(intent: Intent) {
intent.data?.let { data ->
if (data.toString().startsWith(StripeApi.RETURN_URL_IDEAL)) {
startActivity(manageSubscriptions(this))
}
}
}
inner class ToolbarCallback : MainToolbarCallback {
override fun onNewGroupClick() {
startActivity(CreateGroupActivity.newIntent(this@MainActivity))
}
override fun onClearPassphraseClick() {
val intent = Intent(this@MainActivity, KeyCachingService::class.java)
intent.setAction(KeyCachingService.CLEAR_KEY_ACTION)
startService(intent)
}
override fun onMarkReadClick() {
toolbarViewModel.markAllMessagesRead()
}
override fun onInviteFriendsClick() {
val intent = Intent(this@MainActivity, InviteActivity::class.java)
startActivity(intent)
}
override fun onFilterUnreadChatsClick() {
toolbarViewModel.setChatFilter(ConversationFilter.UNREAD)
}
override fun onClearUnreadChatsFilterClick() {
toolbarViewModel.setChatFilter(ConversationFilter.OFF)
}
override fun onSettingsClick() {
openSettings.launch(AppSettingsActivity.home(this@MainActivity))
}
override fun onNotificationProfileClick() {
NotificationProfileSelectionFragment.show(supportFragmentManager)
}
override fun onProxyClick() {
startActivity(AppSettingsActivity.proxy(this@MainActivity))
}
override fun onSearchClick() {
toolbarViewModel.setToolbarMode(MainToolbarMode.SEARCH)
}
override fun onClearCallHistoryClick() {
toolbarViewModel.clearCallHistory()
}
override fun onFilterMissedCallsClick() {
toolbarViewModel.setCallLogFilter(CallLogFilter.MISSED)
}
override fun onClearCallFilterClick() {
toolbarViewModel.setCallLogFilter(CallLogFilter.ALL)
}
override fun onStoryPrivacyClick() {
startActivity(StorySettingsActivity.getIntent(this@MainActivity))
}
override fun onCloseSearchClick() {
toolbarViewModel.setToolbarMode(MainToolbarMode.FULL)
}
override fun onCloseArchiveClick() {
toolbarViewModel.emitEvent(MainToolbarViewModel.Event.Chats.CloseArchive)
}
override fun onSearchQueryUpdated(query: String) {
toolbarViewModel.setSearchQuery(query)
}
override fun onNotificationProfileTooltipDismissed() {
SignalStore.notificationProfile.hasSeenTooltip = true
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

@@ -4,13 +4,15 @@ import android.app.Activity;
import android.content.Intent;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.FragmentManager;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation;
import org.thoughtcrime.securesms.main.MainNavigationViewModel;
import org.thoughtcrime.securesms.recipients.RecipientId;
import io.reactivex.rxjava3.disposables.Disposable;
@@ -19,12 +21,14 @@ public class MainNavigator {
public static final int REQUEST_CONFIG_CHANGES = 901;
private final MainActivity activity;
private final LifecycleDisposable lifecycleDisposable;
private final AppCompatActivity activity;
private final LifecycleDisposable lifecycleDisposable;
private final MainNavigationViewModel viewModel;
public MainNavigator(@NonNull MainActivity activity) {
public MainNavigator(@NonNull AppCompatActivity activity, @NonNull MainNavigationViewModel viewModel) {
this.activity = activity;
this.lifecycleDisposable = new LifecycleDisposable();
this.viewModel = viewModel;
lifecycleDisposable.bindTo(activity);
}
@@ -34,21 +38,7 @@ public class MainNavigator {
throw new IllegalArgumentException("Activity must be an instance of MainActivity!");
}
return ((MainActivity) 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;
return ((NavigatorProvider) activity).getNavigator();
}
public void goToConversation(@NonNull RecipientId recipientId, long threadId, int distributionType, int startingPosition) {
@@ -56,10 +46,7 @@ public class MainNavigator {
.map(builder -> builder.withDistributionType(distributionType)
.withStartingPosition(startingPosition)
.build())
.subscribe(intent -> {
activity.startActivity(intent);
activity.overridePendingTransition(R.anim.slide_from_end, R.anim.fade_scale_out);
});
.subscribe(intent -> viewModel.goTo(new MainNavigationDetailLocation.Conversation(intent)));
lifecycleDisposable.add(disposable);
}
@@ -88,4 +75,9 @@ public class MainNavigator {
*/
boolean onBackPressed();
}
public interface NavigatorProvider {
@NonNull MainNavigator getNavigator();
void onFirstRender();
}
}

View File

@@ -0,0 +1,197 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.attachments
import android.Manifest
import android.widget.CheckBox
import android.widget.Toast
import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.core.Completable
import kotlinx.coroutines.rx3.rxCompletable
import kotlinx.coroutines.withContext
import org.signal.core.ui.view.AlertDialogResult
import org.signal.core.ui.view.awaitResult
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
import org.thoughtcrime.securesms.components.ProgressCardDialogFragmentArgs
import org.thoughtcrime.securesms.database.MediaTable
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.util.SaveAttachmentUtil
import org.thoughtcrime.securesms.util.SaveAttachmentUtil.SaveAttachment
import org.thoughtcrime.securesms.util.SaveAttachmentUtil.SaveAttachmentsResult
import org.thoughtcrime.securesms.util.StorageUtil
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* Executes all of the steps needed to save message attachments to the device storage, including:
* - Showing the save to storage warning/confirmation dialog.
* - Requesting WRITE_EXTERNAL_STORAGE permission.
* - Showing/dismissing media save progress.
*/
class AttachmentSaver(private val host: Host) {
constructor(fragment: Fragment) : this(FragmentHost(fragment))
companion object {
private val TAG = Log.tag(AttachmentSaver::class)
private const val PROGRESS_DIALOG_TAG = "AttachmentSaver_progress_dialog"
}
suspend fun saveAttachments(record: MmsMessageRecord) {
val attachments = record.slideDeck.slides
.filter { it.uri != null && (it.hasImage() || it.hasVideo() || it.hasAudio() || it.hasDocument()) }
.map { SaveAttachment(it.uri!!, it.contentType, record.dateSent, it.fileName.orNull()) }
.toSet()
saveAttachments(attachments)
}
fun saveAttachmentsRx(attachments: Set<SaveAttachment>): Completable = rxCompletable { saveAttachments(attachments) }
suspend fun saveAttachments(records: Collection<MediaTable.MediaRecord>) {
val attachments = records.mapNotNull { record ->
val uri = record.attachment?.uri
val contentType = record.contentType
if (uri != null && contentType != null) {
SaveAttachment(uri, contentType, record.date, record.attachment.fileName)
} else {
null
}
}.toSet()
saveAttachments(attachments)
}
fun saveAttachmentsRx(records: Collection<MediaTable.MediaRecord>): Completable = rxCompletable { saveAttachments(records) }
suspend fun saveAttachments(attachments: Set<SaveAttachment>) {
if (checkIsSaveWarningAccepted(attachmentCount = attachments.size) == SaveToStorageWarningResult.ACCEPTED) {
if (checkCanWriteToMediaStore() == RequestPermissionResult.GRANTED) {
Log.d(TAG, "Saving ${attachments.size} attachments to device storage.")
saveToStorage(attachments)
} else {
Log.d(TAG, "Cancel saving ${attachments.size} attachments: media store permission denied.")
host.showSaveResult(SaveAttachmentsResult.WriteStoragePermissionDenied)
}
} else {
Log.d(TAG, "Cancel saving ${attachments.size} attachments: save to storage warning denied.")
}
}
private suspend fun checkIsSaveWarningAccepted(attachmentCount: Int): SaveToStorageWarningResult {
if (SignalStore.uiHints.hasDismissedSaveStorageWarning()) {
return SaveToStorageWarningResult.ACCEPTED
}
return host.showSaveToStorageWarning(attachmentCount)
}
private suspend fun checkCanWriteToMediaStore(): RequestPermissionResult {
if (StorageUtil.canWriteToMediaStore()) {
return RequestPermissionResult.GRANTED
}
return host.requestWriteExternalStoragePermission()
}
private suspend fun saveToStorage(attachments: Set<SaveAttachment>): SaveAttachmentsResult {
host.showSaveProgress(attachmentCount = attachments.size)
return try {
val result = SaveAttachmentUtil.saveAttachments(attachments)
withContext(SignalDispatchers.Main) {
host.showSaveResult(result)
}
result
} finally {
withContext(SignalDispatchers.Main) {
host.dismissSaveProgress()
}
}
}
interface Host {
suspend fun showSaveToStorageWarning(attachmentCount: Int): SaveToStorageWarningResult
suspend fun requestWriteExternalStoragePermission(): RequestPermissionResult
fun showSaveProgress(attachmentCount: Int)
fun showSaveResult(result: SaveAttachmentsResult)
fun dismissSaveProgress()
}
data class FragmentHost(private val fragment: Fragment) : Host {
override fun showSaveResult(result: SaveAttachmentsResult) {
Toast.makeText(fragment.requireContext(), result.getMessage(fragment.requireContext()), Toast.LENGTH_LONG).show()
}
override suspend fun showSaveToStorageWarning(attachmentCount: Int): SaveToStorageWarningResult = withContext(SignalDispatchers.Main) {
val dialog = MaterialAlertDialogBuilder(fragment.requireContext())
.setView(R.layout.dialog_save_attachment)
.setTitle(R.string.AttachmentSaver__save_to_phone)
.setCancelable(true)
.setMessage(fragment.resources.getQuantityString(R.plurals.AttachmentSaver__this_media_will_be_saved, attachmentCount, attachmentCount))
.create()
val result = dialog.awaitResult(
positiveButtonTextId = R.string.save,
negativeButtonTextId = android.R.string.cancel
)
if (result == AlertDialogResult.POSITIVE) {
val dontShowAgainCheckbox = dialog.findViewById<CheckBox>(R.id.checkbox)!!
if (dontShowAgainCheckbox.isChecked) {
SignalStore.uiHints.markDismissedSaveStorageWarning()
}
return@withContext SaveToStorageWarningResult.ACCEPTED
}
return@withContext SaveToStorageWarningResult.DENIED
}
override suspend fun requestWriteExternalStoragePermission(): RequestPermissionResult = withContext(SignalDispatchers.Main) {
suspendCoroutine { continuation ->
Permissions.with(fragment)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.ifNecessary()
.withPermanentDenialDialog(fragment.getString(R.string.AttachmentSaver__signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
.onAnyDenied {
Log.d(TAG, "WRITE_EXTERNAL_STORAGE permission request denied.")
continuation.resume(RequestPermissionResult.DENIED)
}
.onAllGranted {
Log.d(TAG, "WRITE_EXTERNAL_STORAGE permission request granted.")
continuation.resume(RequestPermissionResult.GRANTED)
}
.execute()
}
}
override fun showSaveProgress(attachmentCount: Int) {
val progressMessage = fragment.resources.getQuantityString(R.plurals.ConversationFragment_saving_n_attachments_to_sd_card, attachmentCount, attachmentCount)
val dialog = ProgressCardDialogFragment.create().apply {
arguments = ProgressCardDialogFragmentArgs.Builder(progressMessage).build().toBundle()
}
dialog.show(fragment.parentFragmentManager, PROGRESS_DIALOG_TAG)
}
override fun dismissSaveProgress() {
val dialog = fragment.parentFragmentManager.findFragmentByTag(PROGRESS_DIALOG_TAG)
(dialog as ProgressCardDialogFragment).dismissAllowingStateLoss()
}
}
enum class SaveToStorageWarningResult {
ACCEPTED,
DENIED
}
enum class RequestPermissionResult {
GRANTED,
DENIED
}
}

View File

@@ -29,19 +29,12 @@ class DatabaseAttachment : Attachment {
@JvmField
val archiveCdn: Int
@JvmField
val archiveMediaName: String?
@JvmField
val archiveMediaId: String?
@JvmField
val thumbnailRestoreState: AttachmentTable.ThumbnailRestoreState
@JvmField
val archiveTransferState: AttachmentTable.ArchiveTransferState
private val hasArchiveThumbnail: Boolean
private val hasThumbnail: Boolean
val displayOrder: Int
@@ -50,7 +43,6 @@ class DatabaseAttachment : Attachment {
mmsId: Long,
hasData: Boolean,
hasThumbnail: Boolean,
hasArchiveThumbnail: Boolean,
contentType: String?,
transferProgress: Int,
size: Long,
@@ -78,8 +70,6 @@ class DatabaseAttachment : Attachment {
uploadTimestamp: Long,
dataHash: String?,
archiveCdn: Int,
archiveMediaName: String?,
archiveMediaId: String?,
thumbnailRestoreState: AttachmentTable.ThumbnailRestoreState,
archiveTransferState: AttachmentTable.ArchiveTransferState,
uuid: UUID?
@@ -114,11 +104,8 @@ class DatabaseAttachment : Attachment {
this.hasData = hasData
this.dataHash = dataHash
this.hasThumbnail = hasThumbnail
this.hasArchiveThumbnail = hasArchiveThumbnail
this.displayOrder = displayOrder
this.archiveCdn = archiveCdn
this.archiveMediaName = archiveMediaName
this.archiveMediaId = archiveMediaId
this.thumbnailRestoreState = thumbnailRestoreState
this.archiveTransferState = archiveTransferState
}
@@ -131,9 +118,6 @@ class DatabaseAttachment : Attachment {
mmsId = parcel.readLong()
displayOrder = parcel.readInt()
archiveCdn = parcel.readInt()
archiveMediaName = parcel.readString()
archiveMediaId = parcel.readString()
hasArchiveThumbnail = ParcelUtil.readBoolean(parcel)
thumbnailRestoreState = AttachmentTable.ThumbnailRestoreState.deserialize(parcel.readInt())
archiveTransferState = AttachmentTable.ArchiveTransferState.deserialize(parcel.readInt())
}
@@ -147,9 +131,6 @@ class DatabaseAttachment : Attachment {
dest.writeLong(mmsId)
dest.writeInt(displayOrder)
dest.writeInt(archiveCdn)
dest.writeString(archiveMediaName)
dest.writeString(archiveMediaId)
ParcelUtil.writeBoolean(dest, hasArchiveThumbnail)
dest.writeInt(thumbnailRestoreState.value)
dest.writeInt(archiveTransferState.value)
}
@@ -169,7 +150,7 @@ class DatabaseAttachment : Attachment {
}
override val thumbnailUri: Uri?
get() = if (hasArchiveThumbnail) {
get() = if (thumbnailRestoreState == AttachmentTable.ThumbnailRestoreState.FINISHED) {
PartAuthority.getAttachmentThumbnailUri(attachmentId)
} else {
null

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

@@ -27,8 +27,8 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.avatar.AvatarRenderer
import org.thoughtcrime.securesms.avatar.Avatars

View File

@@ -5,21 +5,20 @@
package org.thoughtcrime.securesms.backup
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import org.signal.core.util.logging.Log
import org.signal.core.util.throttleLatest
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.max
import kotlin.time.Duration.Companion.milliseconds
@@ -28,6 +27,8 @@ import kotlin.time.Duration.Companion.milliseconds
*/
object ArchiveUploadProgress {
private val TAG = Log.tag(ArchiveUploadProgress::class)
private val PROGRESS_NONE = ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.None
)
@@ -36,18 +37,30 @@ object ArchiveUploadProgress {
private var uploadProgress: ArchiveUploadProgressState = SignalStore.backup.archiveUploadState ?: PROGRESS_NONE
private val partialMediaProgress: MutableMap<AttachmentId, Long> = ConcurrentHashMap()
/**
* Observe this to get updates on the current upload progress.
*/
val progress: Flow<ArchiveUploadProgressState> = _progress
.throttleLatest(500.milliseconds)
.throttleLatest(500.milliseconds) {
uploadProgress.state == ArchiveUploadProgressState.State.None ||
(uploadProgress.state == ArchiveUploadProgressState.State.UploadBackupFile && uploadProgress.backupFileUploadedBytes == 0L) ||
(uploadProgress.state == ArchiveUploadProgressState.State.UploadMedia && uploadProgress.mediaUploadedBytes == 0L)
}
.map {
if (uploadProgress.state != ArchiveUploadProgressState.State.UploadingAttachments) {
if (uploadProgress.state != ArchiveUploadProgressState.State.UploadMedia) {
return@map uploadProgress
}
val pendingCount = SignalDatabase.attachments.getPendingArchiveUploadCount()
if (pendingCount == uploadProgress.totalAttachments) {
if (!SignalStore.backup.backsUpMedia) {
Log.i(TAG, "Doesn't upload media. Done!")
return@map PROGRESS_NONE
}
val pendingMediaUploadBytes = SignalDatabase.attachments.getPendingArchiveUploadBytes() - partialMediaProgress.values.sum()
if (pendingMediaUploadBytes <= 0) {
Log.i(TAG, "No more pending bytes. Done!")
return@map PROGRESS_NONE
}
@@ -55,90 +68,96 @@ object ArchiveUploadProgress {
// If we wanted the most accurate progress possible, we could maintain a new database flag that indicates whether an attachment has been flagged as part
// of the current upload batch. However, this gets us pretty close while keeping things simple and not having to juggle extra flags, with the caveat that
// the progress bar may occasionally be including media that is not actually referenced in the active backup file.
val totalCount = max(uploadProgress.totalAttachments, pendingCount)
val totalMediaUploadBytes = max(uploadProgress.mediaTotalBytes, pendingMediaUploadBytes)
ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.UploadingAttachments,
completedAttachments = totalCount - pendingCount,
totalAttachments = totalCount
state = ArchiveUploadProgressState.State.UploadMedia,
mediaUploadedBytes = totalMediaUploadBytes - pendingMediaUploadBytes,
mediaTotalBytes = totalMediaUploadBytes
)
}
.onEach {
updateState(it, notify = false)
.onEach { updated ->
updateState(notify = false) { updated }
}
.flowOn(Dispatchers.IO)
.shareIn(scope = CoroutineScope(Dispatchers.IO), started = SharingStarted.WhileSubscribed(), replay = 1)
val inProgress
get() = uploadProgress.state != ArchiveUploadProgressState.State.None
fun begin() {
updateState(
updateState {
ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.BackingUpMessages
state = ArchiveUploadProgressState.State.Export
)
)
}
}
fun onMessageBackupCreated() {
updateState(
ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.UploadingMessages
fun onMessageBackupCreated(backupFileSize: Long) {
updateState {
it.copy(
state = ArchiveUploadProgressState.State.UploadBackupFile,
backupFileTotalBytes = backupFileSize,
backupFileUploadedBytes = 0
)
)
}
}
fun onAttachmentsStarted(attachmentCount: Long) {
updateState(
ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.UploadingAttachments,
completedAttachments = 0,
totalAttachments = attachmentCount
fun onMessageBackupUploadProgress(totalBytes: Long, bytesUploaded: Long) {
updateState {
it.copy(
state = ArchiveUploadProgressState.State.UploadBackupFile,
backupFileUploadedBytes = bytesUploaded,
backupFileTotalBytes = totalBytes
)
)
}
}
fun onAttachmentFinished() {
fun onAttachmentsStarted(totalAttachmentBytes: Long) {
updateState {
it.copy(
state = ArchiveUploadProgressState.State.UploadMedia,
mediaUploadedBytes = 0,
mediaTotalBytes = totalAttachmentBytes
)
}
}
fun onAttachmentProgress(attachmentId: AttachmentId, bytesUploaded: Long) {
partialMediaProgress[attachmentId] = bytesUploaded
_progress.tryEmit(Unit)
}
fun onAttachmentFinished(attachmentId: AttachmentId) {
partialMediaProgress.remove(attachmentId)
_progress.tryEmit(Unit)
}
fun onMessageBackupFinishedEarly() {
updateState(PROGRESS_NONE)
updateState { PROGRESS_NONE }
}
fun onValidationFailure() {
updateState(PROGRESS_NONE)
updateState { PROGRESS_NONE }
}
fun onMainBackupFileUploadFailure() {
updateState(PROGRESS_NONE)
updateState { PROGRESS_NONE }
}
private fun updateState(state: ArchiveUploadProgressState, notify: Boolean = true) {
uploadProgress = state
SignalStore.backup.archiveUploadState = state
private fun updateState(notify: Boolean = true, transform: (ArchiveUploadProgressState) -> ArchiveUploadProgressState) {
val newState = transform(uploadProgress)
if (uploadProgress == newState) {
return
}
uploadProgress = newState
SignalStore.backup.archiveUploadState = newState
if (notify) {
_progress.tryEmit(Unit)
}
}
class ArchiveUploadProgressListener(
private val shouldCancel: () -> Boolean = { false }
) : SignalServiceAttachment.ProgressListener {
override fun onAttachmentProgress(total: Long, progress: Long) {
updateState(
state = ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.UploadingMessages,
totalAttachments = total,
completedAttachments = progress
)
)
}
override fun shouldCancel(): Boolean = shouldCancel()
}
object ArchiveBackupProgressListener : BackupRepository.ExportProgressListener {
override fun onAccount() {
updatePhase(ArchiveUploadProgressState.BackupPhase.Account)
@@ -178,17 +197,17 @@ object ArchiveUploadProgress {
private fun updatePhase(
phase: ArchiveUploadProgressState.BackupPhase,
completedObjects: Long = 0L,
totalObjects: Long = 0L
exportedFrames: Long = 0L,
totalFrames: Long = 0L
) {
updateState(
state = ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.BackingUpMessages,
updateState {
ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.Export,
backupPhase = phase,
completedAttachments = completedObjects,
totalAttachments = totalObjects
frameExportCount = exportedFrames,
frameTotalCount = totalFrames
)
)
}
}
}
}

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

@@ -10,8 +10,6 @@ import android.os.Environment
import android.os.StatFs
import androidx.annotation.Discouraged
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.greenrobot.eventbus.EventBus
@@ -30,7 +28,7 @@ import org.signal.core.util.getAllTriggerDefinitions
import org.signal.core.util.getForeignKeyViolations
import org.signal.core.util.logging.Log
import org.signal.core.util.requireInt
import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireNonNullBlob
import org.signal.core.util.stream.NonClosingOutputStream
import org.signal.core.util.urlEncode
import org.signal.core.util.withinTransaction
@@ -61,6 +59,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.Recurring
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.BackupMediaSnapshotTable.ArchiveMediaItem
import org.thoughtcrime.securesms.database.KeyValueDatabase
import org.thoughtcrime.securesms.database.SearchTable
import org.thoughtcrime.securesms.database.SignalDatabase
@@ -134,10 +133,14 @@ object BackupRepository {
}
403 -> {
Log.w(TAG, "Received status 403. The user is not in the media tier. Updating local state.", error.exception)
SignalStore.backup.backupTier = MessageBackupTier.FREE
SignalStore.uiHints.markHasEverEnabledRemoteBackups()
// TODO [backup] If the user thought they were in media tier but aren't, feels like we should have a special UX flow for this?
if (SignalStore.backup.backupTierInternalOverride != null) {
Log.w(TAG, "Received status 403, but the internal override is set, so not doing anything.", error.exception)
} else {
Log.w(TAG, "Received status 403. The user is not in the media tier. Updating local state.", error.exception)
SignalStore.backup.backupTier = MessageBackupTier.FREE
SignalStore.uiHints.markHasEverEnabledRemoteBackups()
// TODO [backup] If the user thought they were in media tier but aren't, feels like we should have a special UX flow for this?
}
}
}
}
@@ -305,9 +308,7 @@ object BackupRepository {
}
val paidType = try {
withContext(Dispatchers.IO) {
getPaidType()
}
getPaidType()
} catch (e: IOException) {
Log.w(TAG, "Failed to retrieve paid type.", e)
return false
@@ -357,6 +358,7 @@ object BackupRepository {
Log.d(TAG, "Disabling backups.")
SignalStore.backup.disableBackups()
SignalDatabase.attachments.clearAllArchiveData()
true
} catch (e: Exception) {
Log.w(TAG, "Failed to turn off backups.", e)
@@ -1100,7 +1102,7 @@ object BackupRepository {
fun copyThumbnailToArchive(thumbnailAttachment: Attachment, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
return initBackupAndFetchAuth()
.then { credential ->
val request = thumbnailAttachment.toArchiveMediaRequest(parentAttachment.getThumbnailMediaName(), credential.mediaBackupAccess.backupKey)
val request = thumbnailAttachment.toArchiveMediaRequest(parentAttachment.requireThumbnailMediaName(), credential.mediaBackupAccess.backupKey)
SignalNetwork.archive.copyAttachmentToArchive(
aci = SignalStore.account.requireAci(),
@@ -1116,7 +1118,7 @@ object BackupRepository {
fun copyAttachmentToArchive(attachment: DatabaseAttachment): NetworkResult<Unit> {
return initBackupAndFetchAuth()
.then { credential ->
val mediaName = attachment.getMediaName()
val mediaName = attachment.requireMediaName()
val request = attachment.toArchiveMediaRequest(mediaName, credential.mediaBackupAccess.backupKey)
SignalNetwork.archive
.copyAttachmentToArchive(
@@ -1124,12 +1126,9 @@ object BackupRepository {
archiveServiceAccess = credential.mediaBackupAccess,
item = request
)
.map { credential to Triple(mediaName, request.mediaId, it) }
}
.map { (credential, triple) ->
val (mediaName, mediaId, response) = triple
val thumbnailId = credential.mediaBackupAccess.backupKey.deriveMediaId(attachment.getThumbnailMediaName()).encode()
SignalDatabase.attachments.setArchiveData(attachmentId = attachment.attachmentId, archiveCdn = response.cdn, archiveMediaName = mediaName.name, archiveMediaId = mediaId, archiveThumbnailMediaId = thumbnailId)
.map { response ->
SignalDatabase.attachments.setArchiveCdn(attachmentId = attachment.attachmentId, archiveCdn = response.cdn)
}
.also { Log.i(TAG, "archiveMediaResult: $it") }
}
@@ -1142,7 +1141,7 @@ object BackupRepository {
val attachmentIdToMediaName = mutableMapOf<AttachmentId, String>()
databaseAttachments.forEach {
val mediaName = it.getMediaName()
val mediaName = it.requireMediaName()
val request = it.toArchiveMediaRequest(mediaName, credential.mediaBackupAccess.backupKey)
requests += request
mediaIdToAttachmentId[request.mediaId] = it.attachmentId
@@ -1164,7 +1163,7 @@ object BackupRepository {
val attachmentId = result.mediaIdToAttachmentId(it.mediaId)
val mediaName = result.attachmentIdToMediaName(attachmentId)
val thumbnailId = credential.mediaBackupAccess.backupKey.deriveMediaId(MediaName.forThumbnailFromMediaName(mediaName = mediaName)).encode()
SignalDatabase.attachments.setArchiveData(attachmentId = attachmentId, archiveCdn = it.cdn!!, archiveMediaName = mediaName, archiveMediaId = it.mediaId, thumbnailId)
SignalDatabase.attachments.setArchiveCdn(attachmentId = attachmentId, archiveCdn = it.cdn!!)
}
result
}
@@ -1172,12 +1171,14 @@ object BackupRepository {
}
fun deleteArchivedMedia(attachments: List<DatabaseAttachment>): NetworkResult<Unit> {
val mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
val mediaToDelete = attachments
.filter { it.archiveMediaId != null }
.filter { it.archiveTransferState == AttachmentTable.ArchiveTransferState.FINISHED }
.map {
DeleteArchivedMediaRequest.ArchivedMediaObject(
cdn = it.archiveCdn,
mediaId = it.archiveMediaId!!
mediaId = it.requireMediaName().toMediaId(mediaRootBackupKey).encode()
)
}
@@ -1415,7 +1416,8 @@ object BackupRepository {
}
}
private suspend fun getFreeType(): MessageBackupsType.Free {
@WorkerThread
private fun getFreeType(): MessageBackupsType.Free {
val config = getSubscriptionsConfiguration()
return MessageBackupsType.Free(
@@ -1426,6 +1428,7 @@ object BackupRepository {
private suspend fun getPaidType(): MessageBackupsType.Paid? {
val config = getSubscriptionsConfiguration()
val product = AppDependencies.billingApi.queryProduct() ?: return null
val backupLevelConfiguration = config.backupConfiguration.backupLevelConfigurationMap[SubscriptionsConfiguration.BACKUPS_LEVEL] ?: return null
return MessageBackupsType.Paid(
@@ -1435,12 +1438,11 @@ object BackupRepository {
)
}
private suspend fun getSubscriptionsConfiguration(): SubscriptionsConfiguration {
val serviceResponse = withContext(Dispatchers.IO) {
AppDependencies
.donationsService
.getDonationsConfiguration(Locale.getDefault())
}
@WorkerThread
private fun getSubscriptionsConfiguration(): SubscriptionsConfiguration {
val serviceResponse = AppDependencies
.donationsService
.getDonationsConfiguration(Locale.getDefault())
if (serviceResponse.result.isEmpty) {
if (serviceResponse.applicationError.isPresent) {
@@ -1537,14 +1539,6 @@ object BackupRepository {
val profileKey: ProfileKey
)
fun DatabaseAttachment.getMediaName(): MediaName {
return MediaName.fromDigest(remoteDigest!!)
}
fun DatabaseAttachment.getThumbnailMediaName(): MediaName {
return MediaName.fromDigestForThumbnail(remoteDigest!!)
}
private fun Attachment.toArchiveMediaRequest(mediaName: MediaName, mediaRootBackupKey: MediaRootBackupKey): ArchiveMediaRequest {
val mediaSecrets = mediaRootBackupKey.deriveMediaSecrets(mediaName)
@@ -1645,7 +1639,7 @@ sealed class ImportResult {
* // Cursor is closed after use block.
* ```
*/
class ArchivedMediaObjectIterator(private val cursor: Cursor) : Iterator<ArchivedMediaObject> {
class ArchiveMediaItemIterator(private val cursor: Cursor) : Iterator<ArchiveMediaItem> {
init {
cursor.moveToFirst()
@@ -1653,10 +1647,14 @@ class ArchivedMediaObjectIterator(private val cursor: Cursor) : Iterator<Archive
override fun hasNext(): Boolean = !cursor.isAfterLast
override fun next(): ArchivedMediaObject {
val mediaId = cursor.requireNonNullString(AttachmentTable.ARCHIVE_MEDIA_ID)
override fun next(): ArchiveMediaItem {
val digest = cursor.requireNonNullBlob(AttachmentTable.REMOTE_DIGEST)
val cdn = cursor.requireInt(AttachmentTable.ARCHIVE_CDN)
val mediaId = MediaName.fromDigest(digest).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
val thumbnailMediaId = MediaName.fromDigestForThumbnail(digest).toMediaId(SignalStore.backup.mediaRootBackupKey).encode()
cursor.moveToNext()
return ArchivedMediaObject(mediaId, cdn)
return ArchiveMediaItem(mediaId, thumbnailMediaId, cdn, digest)
}
}

View File

@@ -1,16 +1,14 @@
/*
* Copyright 2024 Signal Messenger, LLC
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.database
package org.thoughtcrime.securesms.backup.v2
import android.text.TextUtils
import org.signal.core.util.Base64
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.attachments.InvalidAttachmentException
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getThumbnailMediaName
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.backup.MediaName
@@ -19,6 +17,43 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemo
import java.io.IOException
import java.util.Optional
object DatabaseAttachmentArchiveUtil {
@JvmStatic
fun requireMediaName(attachment: DatabaseAttachment): MediaName {
return MediaName.fromDigest(attachment.remoteDigest!!)
}
/**
* For java, since it struggles with value classes.
*/
@JvmStatic
fun requireMediaNameAsString(attachment: DatabaseAttachment): String {
return MediaName.fromDigest(attachment.remoteDigest!!).name
}
@JvmStatic
fun getMediaName(attachment: DatabaseAttachment): MediaName? {
return attachment.remoteDigest?.let { MediaName.fromDigest(it) }
}
@JvmStatic
fun requireThumbnailMediaName(attachment: DatabaseAttachment): MediaName {
return MediaName.fromDigestForThumbnail(attachment.remoteDigest!!)
}
}
fun DatabaseAttachment.requireMediaName(): MediaName {
return DatabaseAttachmentArchiveUtil.requireMediaName(this)
}
fun DatabaseAttachment.getMediaName(): MediaName? {
return DatabaseAttachmentArchiveUtil.getMediaName(this)
}
fun DatabaseAttachment.requireThumbnailMediaName(): MediaName {
return DatabaseAttachmentArchiveUtil.requireThumbnailMediaName(this)
}
/**
* Creates a [SignalServiceAttachmentPointer] for the archived attachment of the given [DatabaseAttachment].
*/
@@ -39,7 +74,7 @@ fun DatabaseAttachment.createArchiveAttachmentPointer(useArchiveCdn: Boolean): S
val id = SignalServiceAttachmentRemoteId.Backup(
mediaCdnPath = mediaCdnPath,
mediaId = mediaRootBackupKey.deriveMediaId(MediaName(archiveMediaName!!)).encode()
mediaId = this.requireMediaName().toMediaId(mediaRootBackupKey).encode()
)
id to archiveCdn
@@ -93,8 +128,8 @@ fun DatabaseAttachment.createArchiveThumbnailPointer(): SignalServiceAttachmentP
val mediaRootBackupKey = SignalStore.backup.mediaRootBackupKey
val mediaCdnPath = BackupRepository.getArchivedMediaCdnPath().successOrThrow()
return try {
val key = mediaRootBackupKey.deriveThumbnailTransitKey(getThumbnailMediaName())
val mediaId = mediaRootBackupKey.deriveMediaId(getThumbnailMediaName()).encode()
val key = mediaRootBackupKey.deriveThumbnailTransitKey(requireThumbnailMediaName())
val mediaId = mediaRootBackupKey.deriveMediaId(requireThumbnailMediaName()).encode()
SignalServiceAttachmentPointer(
cdnNumber = archiveCdn,
remoteId = SignalServiceAttachmentRemoteId.Backup(

View File

@@ -13,8 +13,13 @@ import org.thoughtcrime.securesms.backup.v2.exporters.ChatItemArchiveExporter
import org.thoughtcrime.securesms.backup.v2.importer.ChatItemArchiveImporter
import org.thoughtcrime.securesms.database.GroupTable
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.MessageTable.Companion.DATE_RECEIVED
import org.thoughtcrime.securesms.database.MessageTable.Companion.EXPIRES_IN
import org.thoughtcrime.securesms.database.MessageTable.Companion.PARENT_STORY_ID
import org.thoughtcrime.securesms.database.MessageTable.Companion.STORY_TYPE
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.recipients.RecipientId
import kotlin.time.Duration.Companion.days
private val TAG = "MessageTableArchiveExtensions"
@@ -25,9 +30,10 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, medi
val dateReceivedIndex = "message_date_received"
writableDatabase.execSQL(
"""CREATE INDEX $dateReceivedIndex ON ${MessageTable.TABLE_NAME} (
${MessageTable.DATE_RECEIVED} ASC,
${MessageTable.STORY_TYPE},
${MessageTable.PARENT_STORY_ID},
$DATE_RECEIVED ASC,
$STORY_TYPE,
$PARENT_STORY_ID,
$EXPIRES_IN,
${MessageTable.DATE_SENT},
${MessageTable.DATE_SERVER},
${MessageTable.TYPE},
@@ -36,7 +42,6 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, medi
${MessageTable.MESSAGE_RANGES},
${MessageTable.FROM_RECIPIENT_ID},
${MessageTable.TO_RECIPIENT_ID},
${MessageTable.EXPIRES_IN},
${MessageTable.EXPIRE_STARTED},
${MessageTable.REMOTE_DELETED},
${MessageTable.UNIDENTIFIED},
@@ -61,7 +66,7 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, medi
${MessageTable.MESSAGE_EXTRAS},
${MessageTable.VIEW_ONCE}
)
WHERE ${MessageTable.STORY_TYPE} = 0 AND ${MessageTable.PARENT_STORY_ID} <= 0
WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0
""".trimMargin()
)
Log.d(TAG, "Creating index took ${System.currentTimeMillis() - startTime} ms")
@@ -94,7 +99,7 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, medi
.select(
MessageTable.ID,
MessageTable.DATE_SENT,
MessageTable.DATE_RECEIVED,
DATE_RECEIVED,
MessageTable.DATE_SERVER,
MessageTable.TYPE,
MessageTable.THREAD_ID,
@@ -102,7 +107,7 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, medi
MessageTable.MESSAGE_RANGES,
MessageTable.FROM_RECIPIENT_ID,
MessageTable.TO_RECIPIENT_ID,
MessageTable.EXPIRES_IN,
EXPIRES_IN,
MessageTable.EXPIRE_STARTED,
MessageTable.REMOTE_DELETED,
MessageTable.UNIDENTIFIED,
@@ -126,12 +131,12 @@ fun MessageTable.getMessagesForBackup(db: SignalDatabase, backupTime: Long, medi
MessageTable.TYPE,
MessageTable.MESSAGE_EXTRAS,
MessageTable.VIEW_ONCE,
MessageTable.PARENT_STORY_ID
PARENT_STORY_ID
)
.from("${MessageTable.TABLE_NAME} INDEXED BY $dateReceivedIndex")
.where("${MessageTable.STORY_TYPE} = 0 AND ${MessageTable.PARENT_STORY_ID} <= 0 AND ${MessageTable.DATE_RECEIVED} >= $lastSeenReceivedTime")
.where("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND ($EXPIRES_IN == 0 OR $EXPIRES_IN > ${1.days.inWholeMilliseconds}) AND $DATE_RECEIVED >= $lastSeenReceivedTime")
.limit(count)
.orderBy("${MessageTable.DATE_RECEIVED} ASC")
.orderBy("$DATE_RECEIVED ASC")
.run()
}
)

View File

@@ -291,7 +291,7 @@ class ChatItemArchiveExporter(
}
MessageTypes.isThreadMergeType(record.type) -> {
builder.updateMessage = record.toRemoteThreadMergeUpdate() ?: continue
builder.updateMessage = record.toRemoteThreadMergeUpdate()?.takeIf { exportState.recipientIdToAci.contains(builder.authorId) } ?: continue
transformTimer.emit("thread-merge")
}
@@ -563,10 +563,11 @@ private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: Recipien
}
if (!MessageTypes.isExpirationTimerUpdate(record.type) && builder.expiresInMs != null && builder.expireStartDate != null) {
val cutoffDuration = 1.days.inWholeMilliseconds
val expiresAt = builder.expireStartDate!! + builder.expiresInMs!!
val threshold = if (exportState.forTransfer) backupStartTime else backupStartTime + 1.days.inWholeMilliseconds
val threshold = if (exportState.forTransfer) backupStartTime else backupStartTime + cutoffDuration
if (expiresAt < threshold) {
if (expiresAt < threshold || builder.expiresInMs!! <= cutoffDuration) {
Log.w(TAG, ExportSkips.messageExpiresTooSoon(record.dateSent))
return null
}
@@ -612,7 +613,7 @@ private fun BackupMessageRecord.toRemoteSessionSwitchoverUpdate(): ChatUpdateMes
return ChatUpdateMessage(
sessionSwitchover = try {
val event = SessionSwitchoverEvent.ADAPTER.decode(Base64.decodeOrThrow(this.body))
SessionSwitchoverChatUpdate(event.e164.e164ToLong()!!)
SessionSwitchoverChatUpdate(event.e164.e164ToLong() ?: 0)
} catch (e: IOException) {
SessionSwitchoverChatUpdate()
}
@@ -858,7 +859,7 @@ private fun LinkPreview.toRemoteLinkPreview(mediaArchiveEnabled: Boolean): org.t
}
private fun BackupMessageRecord.toRemoteViewOnceMessage(mediaArchiveEnabled: Boolean, reactionRecords: List<ReactionRecord>?, attachments: List<DatabaseAttachment>?): ViewOnceMessage {
val attachment: DatabaseAttachment? = attachments?.firstOrNull()?.takeUnless { !it.hasData && it.size == 0L && it.archiveMediaId == null && it.width == 0 && it.height == 0 && it.blurHash == null }
val attachment: DatabaseAttachment? = attachments?.firstOrNull()?.takeUnless { !it.hasData && it.size == 0L && it.remoteDigest == null && it.width == 0 && it.height == 0 && it.blurHash == null }
return ViewOnceMessage(
attachment = attachment?.toRemoteMessageAttachment(mediaArchiveEnabled),
@@ -874,21 +875,21 @@ private fun BackupMessageRecord.toRemoteContactMessage(mediaArchiveEnabled: Bool
name = sharedContact.name.toRemote(),
avatar = (sharedContact.avatar?.attachment as? DatabaseAttachment)?.toRemoteMessageAttachment(mediaArchiveEnabled)?.pointer,
organization = sharedContact.organization ?: "",
number = sharedContact.phoneNumbers.map { phone ->
number = sharedContact.phoneNumbers.mapNotNull { phone ->
ContactAttachment.Phone(
value_ = phone.number,
type = phone.type.toRemote(),
label = phone.label ?: ""
)
).takeUnless { it.value_.isBlank() }
},
email = sharedContact.emails.map { email ->
email = sharedContact.emails.mapNotNull { email ->
ContactAttachment.Email(
value_ = email.email,
label = email.label ?: "",
type = email.type.toRemote()
)
).takeUnless { it.value_.isBlank() }
},
address = sharedContact.postalAddresses.map { address ->
address = sharedContact.postalAddresses.mapNotNull { address ->
ContactAttachment.PostalAddress(
type = address.type.toRemote(),
label = address.label ?: "",
@@ -899,7 +900,7 @@ private fun BackupMessageRecord.toRemoteContactMessage(mediaArchiveEnabled: Bool
region = address.region ?: "",
postcode = address.postalCode ?: "",
country = address.country ?: ""
)
).takeUnless { it.street.isBlank() && it.pobox.isBlank() && it.neighborhood.isBlank() && it.city.isBlank() && it.region.isBlank() && it.postcode.isBlank() && it.country.isBlank() }
}
),
reactions = reactionRecords.toRemote()
@@ -1001,7 +1002,7 @@ private fun BackupMessageRecord.toRemoteStandardMessage(exportState: ExportState
?: emptyList()
val hasVoiceNote = messageAttachments.any { it.voiceNote }
return StandardMessage(
quote = this.toRemoteQuote(mediaArchiveEnabled, quotedAttachments),
quote = this.toRemoteQuote(exportState, mediaArchiveEnabled, quotedAttachments),
text = text.takeUnless { hasVoiceNote },
attachments = messageAttachments.toRemoteAttachments(mediaArchiveEnabled).withFixedVoiceNotes(textPresent = text != null || longTextAttachment != null),
linkPreview = linkPreviews.map { it.toRemoteLinkPreview(mediaArchiveEnabled) },
@@ -1010,8 +1011,8 @@ private fun BackupMessageRecord.toRemoteStandardMessage(exportState: ExportState
)
}
private fun BackupMessageRecord.toRemoteQuote(mediaArchiveEnabled: Boolean, attachments: List<DatabaseAttachment>? = null): Quote? {
if (this.quoteTargetSentTimestamp == MessageTable.QUOTE_NOT_PRESENT_ID || this.quoteAuthor <= 0) {
private fun BackupMessageRecord.toRemoteQuote(exportState: ExportState, mediaArchiveEnabled: Boolean, attachments: List<DatabaseAttachment>? = null): Quote? {
if (this.quoteTargetSentTimestamp == MessageTable.QUOTE_NOT_PRESENT_ID || this.quoteAuthor <= 0 || exportState.groupRecipientIds.contains(this.quoteAuthor)) {
return null
}
@@ -1475,10 +1476,14 @@ fun ChatItem.validateChatItem(): ChatItem? {
fun List<ChatItem>.repairRevisions(current: ChatItem.Builder): List<ChatItem> {
return if (current.standardMessage != null) {
val filtered = this.filter { it.standardMessage != null }
val filtered = this
.filter { it.standardMessage != null }
.map { it.withDowngradeVoiceNotes() }
if (this.size != filtered.size) {
Log.w(TAG, ExportOddities.mismatchedRevisionHistory(current.dateSent))
}
filtered
} else if (current.directStoryReplyMessage != null) {
val filtered = this.filter { it.directStoryReplyMessage != null }
@@ -1506,6 +1511,28 @@ private fun List<MessageAttachment>.withFixedVoiceNotes(textPresent: Boolean): L
}
}
private fun ChatItem.withDowngradeVoiceNotes(): ChatItem {
if (this.standardMessage == null) {
return this
}
if (this.standardMessage.attachments.none { it.flag == MessageAttachment.Flag.VOICE_MESSAGE }) {
return this
}
return this.copy(
standardMessage = this.standardMessage.copy(
attachments = this.standardMessage.attachments.map {
if (it.flag == MessageAttachment.Flag.VOICE_MESSAGE) {
it.copy(flag = MessageAttachment.Flag.NONE)
} else {
it
}
}
)
)
}
private fun Cursor.toBackupMessageRecord(pastIds: Set<Long>, backupStartTime: Long): BackupMessageRecord? {
val id = this.requireLong(MessageTable.ID)
if (pastIds.contains(id)) {

View File

@@ -14,6 +14,8 @@ import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireString
import org.signal.libsignal.usernames.BaseUsernameException
import org.signal.libsignal.usernames.Username
import org.thoughtcrime.securesms.backup.v2.ArchiveRecipient
import org.thoughtcrime.securesms.backup.v2.proto.Contact
import org.thoughtcrime.securesms.backup.v2.proto.Self
@@ -69,7 +71,7 @@ class ContactArchiveExporter(private val cursor: Cursor, private val selfId: Lon
val contactBuilder = Contact.Builder()
.aci(aci?.rawUuid?.toByteArray()?.toByteString())
.pni(pni?.rawUuid?.toByteArray()?.toByteString())
.username(cursor.requireString(RecipientTable.USERNAME))
.username(cursor.requireString(RecipientTable.USERNAME).takeIf { isValidUsername(it) })
.e164(cursor.requireString(RecipientTable.E164)?.e164ToLong())
.blocked(cursor.requireBoolean(RecipientTable.BLOCKED))
.visibility(Recipient.HiddenState.deserialize(cursor.requireInt(RecipientTable.HIDDEN)).toRemote())
@@ -144,3 +146,16 @@ private fun String.e164ToLong(): Long? {
return fixed.toLongOrNull()?.takeUnless { it == 0L }
}
private fun isValidUsername(username: String?): Boolean {
if (username.isNullOrBlank()) {
return false
}
return try {
Username(username)
true
} catch (e: BaseUsernameException) {
false
}
}

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,34 +42,30 @@ 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.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.theme.SignalTheme
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.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

@@ -9,7 +9,9 @@ import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -25,7 +27,7 @@ object BackupAlertDelegate {
BackupAlertBottomSheet.create(BackupAlert.BackupFailed).show(fragmentManager, null)
} else if (BackupRepository.shouldDisplayCouldNotCompleteBackupSheet()) {
BackupAlertBottomSheet.create(BackupAlert.CouldNotCompleteBackup(daysSinceLastBackup = SignalStore.backup.daysSinceLastBackup)).show(fragmentManager, null)
} else if (BackupRepository.shouldDisplayYourMediaWillBeDeletedTodaySheet()) {
} else if (withContext(Dispatchers.IO) { BackupRepository.shouldDisplayYourMediaWillBeDeletedTodaySheet() }) {
BackupAlertBottomSheet.create(BackupAlert.MediaWillBeDeletedToday).show(fragmentManager, null)
}
}

View File

@@ -25,10 +25,10 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
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.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.jobs.BackupMessagesJob

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

@@ -35,9 +35,9 @@ import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
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.ByteSize
import org.signal.core.util.bytes
import org.signal.core.util.kibiBytes
@@ -282,7 +282,7 @@ sealed interface BackupStatusData {
val bytesTotal: ByteSize = 0.bytes,
val restoreStatus: RestoreStatus = RestoreStatus.NORMAL
) : BackupStatusData {
override val iconRes: Int = R.drawable.symbol_backup_light
override val iconRes: Int = if (restoreStatus == RestoreStatus.FINISHED) R.drawable.symbol_check_circle_24 else R.drawable.symbol_backup_light
override val iconColors: BackupsIconColors = if (restoreStatus == RestoreStatus.FINISHED) BackupsIconColors.Success else BackupsIconColors.Normal
override val showDismissAction: Boolean = restoreStatus == RestoreStatus.FINISHED
@@ -311,7 +311,7 @@ sealed interface BackupStatusData {
RestoreStatus.FINISHED -> bytesTotal.toUnitString()
}
override val progress: Float = if (bytesTotal.bytes > 0) {
override val progress: Float = if (bytesTotal.bytes > 0 && restoreStatus != RestoreStatus.FINISHED) {
min(1f, max(0f, bytesDownloaded.bytes.toFloat() / bytesTotal.bytes.toFloat()))
} else {
NONE.toFloat()

View File

@@ -35,9 +35,9 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.Previews
import org.signal.core.ui.Rows
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.util.ByteSize
import org.thoughtcrime.securesms.R
import kotlin.math.roundToInt

View File

@@ -28,9 +28,9 @@ 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 org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.thoughtcrime.securesms.R
import org.signal.core.ui.R as CoreUiR

View File

@@ -21,7 +21,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.rx3.asFlowable
import org.signal.core.ui.Dialogs
import org.signal.core.ui.compose.Dialogs
import org.signal.core.util.getSerializableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
@@ -111,7 +111,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
val context = LocalContext.current
MessageBackupsKeyRecordScreen(
backupKey = state.accountEntropyPool.value,
backupKey = state.accountEntropyPool.displayValue,
onNavigationClick = viewModel::goToPreviousStage,
onNextClick = viewModel::goToNextStage,
onCopyToClipboardClick = {
@@ -120,6 +120,14 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
)
}
composable(route = MessageBackupsStage.Route.BACKUP_KEY_VERIFY.name) {
MessageBackupsKeyVerifyScreen(
backupKey = state.accountEntropyPool.displayValue,
onNavigationClick = viewModel::goToPreviousStage,
onNextClick = viewModel::goToNextStage
)
}
composable(route = MessageBackupsStage.Route.TYPE_SELECTION.name) {
MessageBackupsTypeSelectionScreen(
stage = state.stage,
@@ -140,19 +148,20 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
val currentRoute = navController.currentDestination?.route
if (currentRoute != newRoute) {
if (currentRoute != null && MessageBackupsStage.Route.valueOf(currentRoute).isAfter(state.stage.route)) {
navController.popBackStack()
navController.popBackStack(newRoute, inclusive = false)
} else {
navController.navigate(newRoute)
}
}
if (state.stage == MessageBackupsStage.CHECKOUT_SHEET) {
AppDependencies.billingApi.launchBillingFlow(requireActivity())
}
if (state.stage == MessageBackupsStage.COMPLETED) {
requireActivity().setResult(Activity.RESULT_OK, MessageBackupsCheckoutActivity.createResultData())
requireActivity().finishAfterTransition()
when (state.stage) {
MessageBackupsStage.CANCEL -> requireActivity().finishAfterTransition()
MessageBackupsStage.CHECKOUT_SHEET -> AppDependencies.billingApi.launchBillingFlow(requireActivity())
MessageBackupsStage.COMPLETED -> {
requireActivity().setResult(Activity.RESULT_OK, MessageBackupsCheckoutActivity.createResultData())
requireActivity().finishAfterTransition()
}
else -> Unit
}
}

View File

@@ -5,10 +5,8 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.annotation.WorkerThread
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.MutableStateFlow
@@ -23,9 +21,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.reactive.asFlow
import kotlinx.coroutines.withContext
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
@@ -70,7 +68,7 @@ class MessageBackupsFlowViewModel(
check(SignalStore.backup.backupTier != MessageBackupTier.PAID) { "This screen does not support cancellation or downgrades." }
viewModelScope.launch {
val result = withContext(Dispatchers.IO) {
val result = withContext(SignalDispatchers.IO) {
BackupRepository.triggerBackupIdReservation()
}
@@ -79,19 +77,26 @@ class MessageBackupsFlowViewModel(
internalStateFlow.update { it.copy(paymentReadyState = MessageBackupsFlowState.PaymentReadyState.READY) }
}
result.runOnStatusCodeError {
Log.d(TAG, "Failed to trigger backup id reservation. ($it)")
result.runOnStatusCodeError { code ->
Log.d(TAG, "Failed to trigger backup id reservation. ($code)")
internalStateFlow.update { it.copy(paymentReadyState = MessageBackupsFlowState.PaymentReadyState.FAILED) }
}
}
viewModelScope.launch {
internalStateFlow.update {
it.copy(
availableBackupTypes = BackupRepository.getAvailableBackupsTypes(
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 {
it.copy(availableBackupTypes = availableBackupTypes)
}
}
@@ -132,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(
@@ -160,9 +165,11 @@ class MessageBackupsFlowViewModel(
fun goToNextStage() {
internalStateFlow.update {
when (it.stage) {
MessageBackupsStage.CANCEL -> error("Unsupported state transition from terminal state CANCEL")
MessageBackupsStage.EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_EDUCATION)
MessageBackupsStage.BACKUP_KEY_EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_RECORD)
MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION)
MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_VERIFY)
MessageBackupsStage.BACKUP_KEY_VERIFY -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION)
MessageBackupsStage.TYPE_SELECTION -> validateTypeAndUpdateState(it)
MessageBackupsStage.CHECKOUT_SHEET -> it.copy(stage = MessageBackupsStage.PROCESS_PAYMENT)
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> error("This is driven by an async coroutine.")
@@ -177,12 +184,14 @@ class MessageBackupsFlowViewModel(
fun goToPreviousStage() {
internalStateFlow.update {
if (it.stage == it.startScreen) {
it.copy(stage = MessageBackupsStage.COMPLETED)
it.copy(stage = MessageBackupsStage.CANCEL)
} else {
val previousScreen = when (it.stage) {
MessageBackupsStage.EDUCATION -> MessageBackupsStage.COMPLETED
MessageBackupsStage.CANCEL -> error("Unsupported state transition from terminal state CANCEL")
MessageBackupsStage.EDUCATION -> MessageBackupsStage.CANCEL
MessageBackupsStage.BACKUP_KEY_EDUCATION -> MessageBackupsStage.EDUCATION
MessageBackupsStage.BACKUP_KEY_RECORD -> MessageBackupsStage.BACKUP_KEY_EDUCATION
MessageBackupsStage.BACKUP_KEY_VERIFY -> MessageBackupsStage.BACKUP_KEY_RECORD
MessageBackupsStage.TYPE_SELECTION -> MessageBackupsStage.BACKUP_KEY_RECORD
MessageBackupsStage.CHECKOUT_SHEET -> MessageBackupsStage.TYPE_SELECTION
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> MessageBackupsStage.CREATING_IN_APP_PAYMENT
@@ -218,7 +227,7 @@ class MessageBackupsFlowViewModel(
check(state.selectedMessageBackupTier == MessageBackupTier.PAID)
check(state.availableBackupTypes.any { it.tier == state.selectedMessageBackupTier })
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(SignalDispatchers.IO) {
internalStateFlow.update { it.copy(inAppPayment = null) }
val paidFiat = AppDependencies.billingApi.queryProduct()!!.price
@@ -249,25 +258,19 @@ class MessageBackupsFlowViewModel(
}
}
/**
* Ensures we have a SubscriberId created and available for use. This is considered safe because
* the screen this is called in is assumed to only be accessible if the user does not currently have
* a subscription.
*/
@WorkerThread
private fun ensureSubscriberIdForBackups(purchaseToken: IAPSubscriptionId.GooglePlayBillingPurchaseToken) {
RecurringInAppPaymentRepository.ensureSubscriberId(InAppPaymentSubscriberRecord.Type.BACKUP, iapSubscriptionId = purchaseToken).blockingAwait()
}
/**
* Handles a successful BillingPurchaseResult. Updates the in app payment, enqueues the appropriate job chain,
* and handles any resulting error. Like donations, we will wait up to 10s for the completion of the job chain.
*
* This will always rotate the subscriber-id.
*/
@OptIn(FlowPreview::class)
private suspend fun handleSuccess(result: BillingPurchaseResult.Success, inAppPaymentId: InAppPaymentTable.InAppPaymentId) {
withContext(Dispatchers.IO) {
withContext(SignalDispatchers.IO) {
Log.d(TAG, "Setting purchase token data on InAppPayment and InAppPaymentSubscriber.")
ensureSubscriberIdForBackups(IAPSubscriptionId.GooglePlayBillingPurchaseToken(result.purchaseToken))
val iapSubscriptionId = IAPSubscriptionId.GooglePlayBillingPurchaseToken(result.purchaseToken)
RecurringInAppPaymentRepository.ensureSubscriberIdSync(InAppPaymentSubscriberRecord.Type.BACKUP, iapSubscriptionId = iapSubscriptionId, isRotation = true)
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
SignalDatabase.inAppPayments.update(
@@ -287,7 +290,7 @@ class MessageBackupsFlowViewModel(
InAppPaymentPurchaseTokenJob.createJobChain(inAppPayment).enqueue()
}
val terminalInAppPayment = withContext(Dispatchers.IO) {
val terminalInAppPayment = withContext(SignalDispatchers.IO) {
Log.d(TAG, "Awaiting completion of job chain for up to 10 seconds.")
InAppPaymentsRepository.observeUpdates(inAppPaymentId).asFlow()
.filter { it.state == InAppPaymentTable.State.END }

View File

@@ -22,10 +22,10 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.signal.core.ui.R as CoreUiR
@@ -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

@@ -7,59 +7,41 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.ui.compose.Buttons
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.theme.SignalTheme
import org.thoughtcrime.securesms.R
import kotlin.random.Random
import kotlin.random.nextInt
import org.thoughtcrime.securesms.fonts.MonoTypeface
import org.signal.core.ui.R as CoreUiR
/**
* Screen displaying the backup key allowing the user to write it down
* or copy it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageBackupsKeyRecordScreen(
backupKey: String,
@@ -67,18 +49,13 @@ fun MessageBackupsKeyRecordScreen(
onCopyToClipboardClick: (String) -> Unit = {},
onNextClick: () -> Unit = {}
) {
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
val backupKeyString = remember(backupKey) {
backupKey.chunked(4).joinToString(" ")
}
Scaffolds.Settings(
title = "",
navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
navigationIconPainter = painterResource(R.drawable.symbol_arrow_start_24),
onNavigationClick = onNavigationClick
) { paddingValues ->
Column(
@@ -141,7 +118,7 @@ fun MessageBackupsKeyRecordScreen(
letterSpacing = 1.44.sp,
lineHeight = 36.sp,
textAlign = TextAlign.Center,
fontFamily = FontFamily.Monospace
fontFamily = MonoTypeface.fontFamily()
)
)
}
@@ -164,11 +141,7 @@ fun MessageBackupsKeyRecordScreen(
.padding(bottom = 24.dp)
) {
Buttons.LargeTonal(
onClick = {
coroutineScope.launch {
sheetState.show()
}
},
onClick = onNextClick,
modifier = Modifier.align(Alignment.BottomEnd)
) {
Text(
@@ -177,111 +150,6 @@ fun MessageBackupsKeyRecordScreen(
}
}
}
if (sheetState.isVisible) {
ModalBottomSheet(
dragHandle = null,
onDismissRequest = {
coroutineScope.launch {
sheetState.hide()
}
}
) {
BottomSheetContent(
onContinueClick = onNextClick,
onSeeKeyAgainClick = {
coroutineScope.launch {
sheetState.hide()
}
}
)
}
}
}
}
@Composable
private fun BottomSheetContent(
onContinueClick: () -> Unit,
onSeeKeyAgainClick: () -> Unit
) {
var checked by remember { mutableStateOf(false) }
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
.testTag("message-backups-key-record-screen-sheet-content")
) {
item {
BottomSheets.Handle()
}
item {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__keep_your_key_safe),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 30.dp)
)
}
item {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__signal_will_not),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp)
)
}
item {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = 24.dp)
.defaultMinSize(minWidth = 220.dp)
.clip(shape = RoundedCornerShape(percent = 50))
.clickable(onClick = { checked = !checked })
) {
Checkbox(
checked = checked,
onCheckedChange = { checked = it }
)
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__ive_recorded_my_key),
style = MaterialTheme.typography.bodyLarge
)
}
}
item {
Buttons.LargeTonal(
enabled = checked,
onClick = onContinueClick,
modifier = Modifier
.padding(bottom = 16.dp)
.defaultMinSize(minWidth = 220.dp)
) {
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__continue))
}
}
item {
TextButton(
onClick = onSeeKeyAgainClick,
modifier = Modifier
.padding(bottom = 24.dp)
.defaultMinSize(minWidth = 220.dp)
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_key_again)
)
}
}
}
}
@@ -290,15 +158,7 @@ private fun BottomSheetContent(
private fun MessageBackupsKeyRecordScreenPreview() {
Previews.Preview {
MessageBackupsKeyRecordScreen(
backupKey = (0 until 64).map { Random.nextInt(97..122).toChar() }.joinToString("")
backupKey = (0 until 63).map { (('A'..'Z') + ('0'..'9')).random() }.joinToString("") + "0"
)
}
}
@SignalPreview
@Composable
private fun BottomSheetContentPreview() {
Previews.BottomSheetPreview {
BottomSheetContent({}, {})
}
}

View File

@@ -0,0 +1,328 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
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.Scaffolds
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.fonts.MonoTypeface
import org.thoughtcrime.securesms.registrationv3.ui.restore.BackupKeyVisualTransformation
import org.thoughtcrime.securesms.registrationv3.ui.restore.attachBackupKeyAutoFillHelper
import org.thoughtcrime.securesms.registrationv3.ui.restore.backupKeyAutoFillHelper
import org.whispersystems.signalservice.api.AccountEntropyPool
import kotlin.random.Random
import kotlin.random.nextInt
import org.signal.core.ui.R as CoreUiR
/**
* Prompt user to re-enter backup key (AEP) to confirm they have it still.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun MessageBackupsKeyVerifyScreen(
backupKey: String,
onNavigationClick: () -> Unit = {},
onNextClick: () -> Unit = {}
) {
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
Scaffolds.Settings(
title = stringResource(R.string.MessageBackupsKeyVerifyScreen__confirm_your_backup_key),
navigationIconPainter = painterResource(R.drawable.symbol_arrow_start_24),
onNavigationClick = onNavigationClick
) { paddingValues ->
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
val scrollState = rememberScrollState()
val focusRequester = remember { FocusRequester() }
val visualTransform = remember { BackupKeyVisualTransformation(chunkSize = 4) }
val keyboardController = LocalSoftwareKeyboardController.current
var enteredBackupKey by remember { mutableStateOf("") }
var isBackupKeyValid by remember { mutableStateOf(false) }
var showError by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.verticalScroll(scrollState)
.weight(weight = 1f, fill = false)
.horizontalGutters(),
horizontalAlignment = Alignment.Start
) {
Text(
text = stringResource(R.string.MessageBackupsKeyVerifyScreen__enter_the_backup_key_that_you_just_recorded),
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
)
Spacer(modifier = Modifier.height(48.dp))
val updateEnteredBackupKey = { input: String ->
enteredBackupKey = AccountEntropyPool.removeIllegalCharacters(input).uppercase()
isBackupKeyValid = enteredBackupKey == backupKey
showError = !isBackupKeyValid && enteredBackupKey.length >= backupKey.length
}
var requestFocus: Boolean by remember { mutableStateOf(true) }
val autoFillHelper = backupKeyAutoFillHelper { updateEnteredBackupKey(it) }
TextField(
value = enteredBackupKey,
onValueChange = {
updateEnteredBackupKey(it)
autoFillHelper.onValueChanged(it)
},
label = {
Text(text = stringResource(id = R.string.MessageBackupsKeyVerifyScreen__backup_key))
},
textStyle = LocalTextStyle.current.copy(
fontFamily = MonoTypeface.fontFamily(),
lineHeight = 36.sp
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Next,
autoCorrectEnabled = false
),
keyboardActions = KeyboardActions(
onNext = {
if (isBackupKeyValid) {
keyboardController?.hide()
coroutineScope.launch { sheetState.show() }
}
}
),
supportingText = { if (showError) Text(text = stringResource(R.string.MessageBackupsKeyVerifyScreen__incorrect_backup_key)) },
isError = showError,
minLines = 4,
visualTransformation = visualTransform,
modifier = Modifier
.testTag("message-backups-key-verify-screen-backup-key-input-field")
.fillMaxWidth()
.focusRequester(focusRequester)
.attachBackupKeyAutoFillHelper(autoFillHelper)
.onGloballyPositioned {
if (requestFocus) {
focusRequester.requestFocus()
requestFocus = false
}
}
)
}
Surface(
shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.padding(top = 8.dp, bottom = 24.dp)
.horizontalGutters()
.fillMaxWidth()
) {
TextButton(
onClick = onNavigationClick
) {
Text(
text = stringResource(id = R.string.MessageBackupsKeyVerifyScreen__see_key_again)
)
}
Buttons.LargeTonal(
enabled = isBackupKeyValid,
onClick = {
coroutineScope.launch { sheetState.show() }
}
) {
Text(
text = stringResource(id = R.string.RegistrationActivity_next)
)
}
}
}
}
if (sheetState.isVisible) {
ModalBottomSheet(
sheetState = sheetState,
dragHandle = null,
onDismissRequest = {
coroutineScope.launch {
sheetState.hide()
}
}
) {
BottomSheetContent(
onContinueClick = {
coroutineScope.launch {
sheetState.hide()
}
onNextClick()
},
onSeeKeyAgainClick = {
coroutineScope.launch {
sheetState.hide()
}
onNavigationClick()
}
)
}
}
}
}
@Composable
private fun BottomSheetContent(
onContinueClick: () -> Unit,
onSeeKeyAgainClick: () -> Unit
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
.testTag("message-backups-key-record-screen-sheet-content")
) {
item {
BottomSheets.Handle()
}
item {
Image(
painter = painterResource(R.drawable.image_signal_backups_key),
contentDescription = null,
modifier = Modifier
.padding(top = 26.dp)
.size(80.dp)
)
}
item {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__keep_your_key_safe),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp)
)
}
item {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__signal_will_not),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp)
)
}
item {
Spacer(modifier = Modifier.height(54.dp))
Buttons.LargeTonal(
onClick = onContinueClick,
modifier = Modifier
.padding(bottom = 16.dp)
.defaultMinSize(minWidth = 220.dp)
) {
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__continue))
}
}
item {
TextButton(
onClick = onSeeKeyAgainClick,
modifier = Modifier
.padding(bottom = 24.dp)
.defaultMinSize(minWidth = 220.dp)
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_key_again)
)
}
}
}
}
@SignalPreview
@Composable
private fun MessageBackupsKeyRecordScreenPreview() {
Previews.Preview {
MessageBackupsKeyVerifyScreen(
backupKey = (0 until 64).map { Random.nextInt(65..90).toChar() }.joinToString("").uppercase()
)
}
}
@SignalPreview
@Composable
private fun BottomSheetContentPreview() {
Previews.BottomSheetPreview {
BottomSheetContent({}, {})
}
}

View File

@@ -11,9 +11,11 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription
enum class MessageBackupsStage(
val route: Route
) {
CANCEL(route = Route.CANCEL),
EDUCATION(route = Route.EDUCATION),
BACKUP_KEY_EDUCATION(route = Route.BACKUP_KEY_EDUCATION),
BACKUP_KEY_RECORD(route = Route.BACKUP_KEY_RECORD),
BACKUP_KEY_VERIFY(route = Route.BACKUP_KEY_VERIFY),
TYPE_SELECTION(route = Route.TYPE_SELECTION),
CREATING_IN_APP_PAYMENT(route = Route.TYPE_SELECTION),
CHECKOUT_SHEET(route = Route.TYPE_SELECTION),
@@ -26,9 +28,11 @@ enum class MessageBackupsStage(
* Compose navigation route to display while in a given stage.
*/
enum class Route {
CANCEL,
EDUCATION,
BACKUP_KEY_EDUCATION,
BACKUP_KEY_RECORD,
BACKUP_KEY_VERIFY,
TYPE_SELECTION;
fun isAfter(other: Route): Boolean = ordinal > other.ordinal

View File

@@ -18,8 +18,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
/**

View File

@@ -44,18 +44,18 @@ import androidx.compose.ui.text.withAnnotation
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.persistentListOf
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
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.theme.SignalTheme
import org.signal.core.util.bytes
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

@@ -8,6 +8,8 @@ package org.thoughtcrime.securesms.backup.v2.util
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64
import org.signal.core.util.emptyIfNull
import org.signal.core.util.nullIfBlank
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.attachments.ArchivedAttachment
import org.thoughtcrime.securesms.attachments.Attachment
@@ -15,8 +17,8 @@ import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.attachments.TombstoneAttachment
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getMediaName
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.getMediaName
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.AttachmentTable
@@ -96,7 +98,7 @@ fun FilePointer?.toLocalAttachment(
cdn = this.backupLocator.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
key = this.backupLocator.key.toByteArray(),
iv = null,
cdnKey = this.backupLocator.transitCdnKey,
cdnKey = this.backupLocator.transitCdnKey?.nullIfBlank(),
archiveCdn = this.backupLocator.cdnNumber,
archiveMediaName = this.backupLocator.mediaName,
archiveMediaId = importState.mediaRootBackupKey.deriveMediaId(MediaName(this.backupLocator.mediaName)).encode(),
@@ -147,14 +149,18 @@ fun DatabaseAttachment.toRemoteFilePointer(mediaArchiveEnabled: Boolean, content
val pending = this.archiveTransferState != AttachmentTable.ArchiveTransferState.FINISHED && (this.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && this.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED)
if (mediaArchiveEnabled && !pending) {
val transitCdnKey = this.remoteLocation?.nullIfBlank()
val transitCdnNumber = this.cdn.cdnNumber.takeIf { transitCdnKey != null }
val archiveMediaName = this.getMediaName()?.toString()
builder.backupLocator = FilePointer.BackupLocator(
mediaName = this.archiveMediaName ?: this.getMediaName().toString(),
cdnNumber = if (this.archiveMediaName != null) this.archiveCdn else Cdn.CDN_3.cdnNumber, // TODO [backup]: Update when new proto with optional cdn is landed
mediaName = archiveMediaName.emptyIfNull(),
cdnNumber = this.archiveCdn.takeIf { archiveMediaName != null },
key = Base64.decode(remoteKey).toByteString(),
size = this.size.toInt(),
digest = this.remoteDigest.toByteString(),
transitCdnNumber = this.cdn.cdnNumber.takeIf { this.remoteLocation != null },
transitCdnKey = this.remoteLocation
transitCdnNumber = transitCdnNumber,
transitCdnKey = transitCdnKey
)
return builder.build()
}

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

@@ -31,10 +31,10 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Texts
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Texts
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge

View File

@@ -47,7 +47,11 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
}
override fun getMaterial3OnScrollHelper(toolbar: Toolbar?): Material3OnScrollHelper? {
return Material3OnScrollHelper(requireActivity(), scrollShadow, viewLifecycleOwner)
return Material3OnScrollHelper(
activity = requireActivity(),
views = listOf(scrollShadow),
lifecycleOwner = viewLifecycleOwner
)
}
override fun bindAdapter(adapter: MappingAdapter) {

View File

@@ -8,15 +8,14 @@ package org.thoughtcrime.securesms.banner
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.key
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.Flow
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.logging.Log
/**
@@ -37,28 +36,27 @@ class BannerManager @JvmOverloads constructor(
* Re-evaluates the [Banner]s, choosing one to render (if any) and updating the view.
*/
fun updateContent(composeView: ComposeView) {
val banner: Banner<Any>? = banners.firstOrNull { it.enabled } as Banner<Any>?
composeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val banner: Banner<Any>? = banners.firstOrNull { it.enabled } as Banner<Any>?
if (banner == null) {
onNoBannerShownListener()
return@setContent
}
val state: State<Any?> = banner.dataFlow.collectAsStateWithLifecycle(initialValue = null)
val bannerState by state
key(banner) {
val bannerState by banner.dataFlow.collectAsStateWithLifecycle(initialValue = null)
bannerState?.let { model ->
SignalTheme {
Box {
banner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
bannerState?.let { model ->
SignalTheme {
Box {
banner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
}
}
}
onNewBannerShownListener()
} ?: onNoBannerShownListener()
onNewBannerShownListener()
} ?: onNoBannerShownListener()
}
}
}
}
@@ -68,12 +66,16 @@ class BannerManager @JvmOverloads constructor(
*/
@Composable
fun Banner() {
val banner by rememberUpdatedState(banners.firstOrNull { it.enabled } as Banner<Any>?)
val banner: Banner<Any>? = banners.firstOrNull { it.enabled } as Banner<Any>?
if (banner == null) {
return
}
banner?.let { nonNullBanner ->
val state by nonNullBanner.dataFlow.collectAsStateWithLifecycle(initialValue = null)
state?.let { model ->
nonNullBanner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
key(banner) {
val bannerState by banner.dataFlow.collectAsStateWithLifecycle(initialValue = null)
bannerState?.let { model ->
banner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp))
}
}
}

View File

@@ -9,12 +9,11 @@ import android.os.Build
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action

View File

@@ -11,8 +11,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action

View File

@@ -11,8 +11,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action

View File

@@ -12,8 +12,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action

View File

@@ -13,8 +13,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action

View File

@@ -12,8 +12,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action

View File

@@ -11,8 +11,8 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action

View File

@@ -13,8 +13,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action

View File

@@ -15,8 +15,8 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action

View File

@@ -12,8 +12,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner

View File

@@ -13,8 +13,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action

View File

@@ -11,8 +11,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.ui.compose.Action

View File

@@ -34,8 +34,8 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.util.isNotNullOrBlank
import org.thoughtcrime.securesms.R

View File

@@ -24,10 +24,10 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
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.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeBlock

View File

@@ -16,7 +16,7 @@ import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.rx3.asFlowable
import org.signal.core.ui.Dialogs
import org.signal.core.ui.compose.Dialogs
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsFlowViewModel
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsStage

View File

@@ -25,10 +25,10 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
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.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeBlock

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

@@ -13,7 +13,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.google.android.material.snackbar.Snackbar
import org.signal.core.ui.Snackbars
import org.signal.core.ui.compose.Snackbars
import org.thoughtcrime.securesms.R
/**

View File

@@ -35,8 +35,8 @@ import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.navArgs
import org.signal.core.ui.Buttons
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.util.BreakIteratorCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeDialogFragment

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