Compare commits

...

277 Commits

Author SHA1 Message Date
Kate
972078533b Update translations
Some checks failed
CI / Build and Test (Xcode_26.5) (push) Has been cancelled
CI / Check if strings file is outdated (push) Has been cancelled
protobuf-check / check protobufs (push) Has been cancelled
2026-06-10 15:47:47 -04:00
Kate
1eb8b48bb4 Update release notes 2026-06-10 15:47:10 -04:00
Max Radermacher
221043a998
Compute mediaName dynamically 2026-06-10 13:51:02 -05:00
Max Radermacher
3914c811be
Debug when we request reviews 2026-06-10 13:26:53 -05:00
Elaine
c4b6b61da7
Disable notification actions with screen lock enabled 2026-06-10 14:08:15 -04:00
Sasha Weiss
aa353b3f59
Remove remote-config gate for Optimize Storage 2026-06-10 10:56:11 -07:00
Sasha Weiss
ee158d4c4c
Remove redundant startCron argument to AppDelegate/refreshConnection 2026-06-10 10:35:24 -07:00
Sasha Weiss
459643fd14
Default Optimize Storage to off for new Backup signups 2026-06-10 10:09:02 -07:00
Elaine
3b1636e179
Fix stories links 2026-06-09 19:30:28 -04:00
Sasha Weiss
cad4022e68
Backfill missing errorType property for OWSRecoverableDecryptionPlaceholder(s) 2026-06-09 16:26:43 -07:00
Sasha Weiss
d12641a3a2
Inline some OWSRecoverablePlaceholder logic 2026-06-09 16:10:25 -07:00
Elaine
f995e51b28
Revert "Improved media viewer layout on iPad." 2026-06-09 18:03:11 -04:00
Pete Walters
213cbcd9ad
Remove option to enter recovery key from PIN entry screen. 2026-06-09 16:53:12 -05:00
Sasha Weiss
f5892e0aad
Warn when attempting to paste AEP into ConversationInputTextView 2026-06-09 12:56:31 -07:00
Max Radermacher
b139b7c7b9
Update to LibSignal v0.95.0 2026-06-09 14:29:20 -05:00
Pete Walters
3e6dc35321
Disable optimizeLocalStorage when restoring during provisioning 2026-06-09 08:21:44 -05:00
Sasha Weiss
30431a6354
Hide "sensitive" views from the App Switcher 2026-06-08 20:47:21 -07:00
Max Radermacher
6be8862bdb
Don’t start expiration timer unless specified 2026-06-08 21:01:03 -05:00
Max Radermacher
2182e5952a
Make some expiration timer fields nonnull 2026-06-08 20:59:03 -05:00
Elaine
800a5cc0bc
Improve handling of many large collapse sets 2026-06-08 20:17:12 -04:00
Pete Walters
82ce1ead86
Prioritize backup thumbnail downloads for the opened conversation 2026-06-08 17:45:33 -05:00
Pete Walters
d10259eae1
Fix AudioCell layout for undownloaded items 2026-06-08 17:45:07 -05:00
sashaweiss-signal
1a9a0dbdd9 Tweak strings for Recovery Key copying warning sheet 2026-06-08 15:33:32 -07:00
Sasha Weiss
1b8e0a0c93
Don't require SessionRecord to copy flawlessly in DatabaseRecovery 2026-06-08 12:53:13 -07:00
Max Radermacher
cef72e5a5a
Wait for sync message after storage service error 2026-06-08 14:17:53 -05:00
Sasha Weiss
eb533a72a2
Return PENDING, not TERMINAL, error when attempting to backfill offloaded media 2026-06-08 11:35:45 -07:00
kate-signal
08a3e32943
fix release notes wallpaper 2026-06-08 09:04:22 -04:00
Max Radermacher
b957357516
Clarify regeneration vs. duplicates comment 2026-06-05 12:12:24 -05:00
Max Radermacher
c38b1309dd
Remove unused provisioning/sync message fields 2026-06-05 12:10:55 -05:00
Max Radermacher
926432d03a Fix typo: seconday → secondary 2026-06-05 12:07:19 -05:00
Max Radermacher
6628b9f6fc
Remove unused MOB code 2026-06-05 11:52:31 -05:00
Pete Walters
1954342a36
De-singleton RemoteAttestation 2026-06-05 08:15:59 -05:00
Max Radermacher
f40bc944ae
Remove throws from method that doesn’t throw 2026-06-04 19:38:23 -05:00
Elaine
feeb1303e5 Bump version to 8.16 2026-06-04 15:48:48 -04:00
Elaine
a6e8eda73c Update translations
Some checks failed
CI / Build and Test (Xcode_26.5) (push) Has been cancelled
CI / Check if strings file is outdated (push) Has been cancelled
2026-06-04 15:48:45 -04:00
Elaine
a661667b24 Update release notes 2026-06-04 15:48:06 -04:00
Sasha Weiss
a978b4cc8b
Add warning sheet when copying Recovery Key 2026-06-04 11:29:34 -07:00
Sasha Weiss
15ada8dcf0
Expose BackupPlanOptionView for reuse 2026-06-03 21:28:07 -07:00
Pete Walters
15f9d3dc96
Fix a layout issue when reloading a gif in the media gallery 2026-06-03 16:31:30 -05:00
Pete Walters
fc5102cd54
Label video + shouldLoop as GIF in media gallery 2026-06-03 16:31:06 -05:00
Sasha Weiss
16c179115e
Convert Decryption Placeholder expiration to an ExpirationJob 2026-06-03 13:58:46 -07:00
Elaine
d28e29fa21
Reload story rows when nicknames change 2026-06-03 15:31:51 -04:00
andrew-signal
265757716a
Update to libsignal v0.94.4. 2026-06-03 13:23:57 -04:00
Max Radermacher
0f0c3e6fc6
Use failIfThrows in place of forced unwraps 2026-06-03 12:12:08 -05:00
Pete Walters
8a53464a41
Allow non-restore backup tier downloads over cellular 2026-06-02 21:38:48 -05:00
sashaweiss-signal
79bbd556a4 Tweak Optimize Storage warning strings 2026-06-02 16:51:31 -07:00
Max Radermacher
cfb22a38b3 Fix typo: attachmenr → attachment 2026-06-02 17:33:38 -05:00
Elaine
808f3218db
Only insert one group update per info message 2026-06-02 17:03:28 -04:00
Pete Walters
280fc1f244
Bump webP encoder quality up to default quality (4) 2026-06-02 15:24:53 -05:00
Pete Walters
78130adac7
Add internal setting to force regeneration of backup thumbnails 2026-06-02 14:00:50 -05:00
sashaweiss-signal
feba86dbfb String change for Optimize Storage explanation footer 2026-06-02 11:59:38 -07:00
Pete Walters
202d8a1f07
Add two separate modes to the Safety Tip screen
Co-authored-by: Max Radermacher <max@signal.org>
2026-06-02 13:00:37 -05:00
Ehren Kret
c6492caae7 Negate negated 2026-06-02 12:38:07 -05:00
Sasha Weiss
a82216e06c
Add warning sheets for undownloaded media with an expiring IAP subscription 2026-06-02 09:51:04 -07:00
sashaweiss-signal
a173d4599a Tweak .paidExpiringSoon preview in BackupSettingsVC 2026-06-02 08:25:46 -07:00
sashaweiss-signal
6f5bc03b96 Finalize new AEP after popping to BackupSettingsVC
In the "disabling Backups to rotate my AEP" case, we may end up
presenting action sheets. To that end, make sure we're at Backup
Settings before we get there.
2026-06-02 08:25:46 -07:00
Sasha Weiss
ba15734132
Add extensive warning before skipping downloads of offloaded media 2026-06-01 16:41:31 -07:00
Pete Walters
5a57831b26
Only offload attachments related to link previews and messages 2026-06-01 16:49:43 -05:00
Pete Walters
31867c8d06
When CVC isn't visible, don't complain about starting the read timer 2026-06-01 16:45:33 -05:00
Sasha Weiss
08ae6b3e07
Make Megaphone construction one step instead of two 2026-06-01 13:24:08 -07:00
Sasha Weiss
28e9247793
Split Megaphone, MegaphoneView 2026-06-01 12:56:00 -07:00
Sasha Weiss
8b1379149c
Don't show a megaphone for 1d after dismissing previous 2026-06-01 12:51:35 -07:00
Sasha Weiss
0cc18a5285
Consolidate MegaphoneView, ExperienceUpgradeManager code 2026-06-01 12:45:54 -07:00
Sasha Weiss
f64e718ba2
Never allow My Story to be deleted 2026-06-01 12:42:17 -07:00
Sasha Weiss
185035784c
Treat images with image/gif MIME types as "GIFs" in the Media Gallery 2026-06-01 12:09:38 -07:00
Sasha Weiss
dcf02125a0
Simplify PIN creation error handling 2026-06-01 11:04:13 -07:00
Pete Walters
44e6c6cb43
Show the download icon overlay if no thumbnail present 2026-06-01 12:57:01 -05:00
Pete Walters
dc3a819024
Use backup thumbnails for link previews 2026-06-01 12:56:46 -05:00
Pete Walters
c0cedd0026
Fall back to backup thumbnail for quoted message attachment 2026-06-01 12:56:28 -05:00
Pete Walters
27439824e7
Consolidate MediaGallery-related CVAttachmentProgressView creation 2026-06-01 12:55:56 -05:00
Max Radermacher
c7005df406
Don’t send reactive profile keys for groups 2026-06-01 12:50:30 -05:00
Max Radermacher
4caec2f2d3
Stop decoding/validating most recordTypes 2026-06-01 12:50:08 -05:00
Max Radermacher
7dded9229a
Prune old record types 2026-06-01 12:49:00 -05:00
Max Radermacher
aa7bced824
Don’t use SDSRecordType for TSThread 2026-06-01 12:47:16 -05:00
Elaine
8663b50018
Require screen unlock before starting calls 2026-06-01 11:44:33 -04:00
Igor Solomennikov
2259a151d9
Delete unused OWSButton methods / properties. 2026-05-31 10:12:11 -07:00
Igor Solomennikov
08bf2bb9e5
Remove OWSRoundedButton. 2026-05-31 10:11:44 -07:00
Sasha Weiss
0206e8c487
Add "Enable Optimize Storage" toggle to "Welcome to Backups" sheet 2026-05-29 15:49:10 -07:00
andrew-signal
39780d4bc7
Bump to libsignal v0.94.2 2026-05-29 15:27:16 -07:00
Pete Walters
49311ef328
Add ability to download attchment via the media gallery list 2026-05-29 17:21:27 -05:00
Pete Walters
08371f4c50
Tap to download an undownloaded gallery tile attachments 2026-05-29 17:21:05 -05:00
Pete Walters
0d76c69ec1
Fix settings -> media gallery transition when missing image 2026-05-29 17:20:30 -05:00
Pete Walters
e80b3d8bdb
Allow cancelling audio/file media gallery downloads 2026-05-29 17:19:51 -05:00
Pete Walters
79122a2301
Notify when an attachment stops downloading due to exception 2026-05-29 17:19:02 -05:00
Max Radermacher
8f60728454
Don’t wrap PreKeyManager mocks in Tasks 2026-05-29 13:29:34 -05:00
Sasha Weiss
65f577efed
Stop caching SVRB auth credentials 2026-05-29 11:18:26 -07:00
Max Radermacher
8f0c315ad7
Remove rotatePreKeysOnUpgradeIfNecessary 2026-05-29 12:52:14 -05:00
gram-signal
7fd03d6bd2
SPQR: add requirePqRatio to remote configs, enforce it in MessageSender. 2026-05-29 10:07:04 -07:00
Sasha Weiss
102b164f89
De-SDS-ify ExperienceUpgrade 2026-05-29 09:42:50 -07:00
Sasha Weiss
c57f731c67
Revert "Reapply "Display AEPs with 0/O, accept 0/O/=/#"" 2026-05-29 09:42:15 -07:00
Igor Solomennikov
8b613f8bc1
Refactoring of PhotoCaptureVC and SendMediaNavController.
Restructure the code to move protocol conformance declarations into the class deceleration and stop doing class extensions.
2026-05-29 06:24:19 -07:00
Igor Solomennikov
a178545e1e
Update MessageReactionPicker.
• do not use OWSFlatButton.
• document layout constants.
• some renames for clarity.
2026-05-29 06:23:43 -07:00
Igor Solomennikov
7dde1505d7
Modernize appearance of "Choose Photo" button in QR code scanner. 2026-05-29 06:23:17 -07:00
Igor Solomennikov
6d78ec66e1
Updates to scan QR code button in Find by Username screen.
• use shared "smallSecondary" button configuration.
• use Symbols font to show qr code icon.
2026-05-29 06:22:42 -07:00
Igor Solomennikov
832f2b06eb
Modernize configuration of "Names not Verified" button.
• use UIButton.Configuration (shared with Safety Tips button now).
• do not use OWSRoundedButton anymore.
2026-05-29 06:22:09 -07:00
Igor Solomennikov
2260eb9b8f
Convert ContactCellConfiguration and ContactCellAccessoryView to structs. 2026-05-29 06:21:35 -07:00
Sasha Weiss
d535e8bfe2
Significantly slim SVRBError 2026-05-28 21:55:25 -07:00
Igor Solomennikov
5ba733f275
Updates to QR code button in top row of App Settings screen.
• use shared "roundGray" button configuration - gives us simpler code and standardized size and colors.
• do not downscale QR code icon - button grew from 36 dp to 40 dp.
2026-05-28 10:28:50 -07:00
Igor Solomennikov
3fafbe67bc
Put all buttons of different styles into one preview VC. 2026-05-28 10:28:07 -07:00
Igor Solomennikov
52f5b28db3
Remove PaypalButton - configure via UIButton.Configuration instead. 2026-05-28 10:27:27 -07:00
Igor Solomennikov
0bc63e4b57
Modernize Reset button in username qr code view.
Use UIButton.Configuration - shared "smallSecondary" style.
2026-05-28 10:26:41 -07:00
Max Radermacher
344081c1b6
Pare down InternalDiskUsageViewController 2026-05-28 12:06:44 -05:00
Max Radermacher
9e4e2976c6
Remove MasterKeySyncManager 2026-05-28 12:06:30 -05:00
Max Radermacher
892b51221a
Fix empty WAL file transfer during device transfer
Co-authored-by: Sasha Weiss <sasha@signal.org>
2026-05-28 11:29:51 -05:00
Max Radermacher
fa6876eefa
Consolidate various Signal Protocol-related files 2026-05-28 11:03:43 -05:00
Max Radermacher
79fc5037a3
Rename PreKey → PreKeyRecord 2026-05-28 11:02:25 -05:00
Max Radermacher
0088304b35
Remove BuildFlags.decodeDeprecatedPreKeys 2026-05-28 11:01:45 -05:00
Pete Walters
e4b9550f31
Support downloading of file/audio attachments within the media gallery 2026-05-28 09:25:31 -05:00
Pete Walters
7856a0f0e1
Disable the 'dont show undownloaded' flag if optimize media available 2026-05-27 22:12:33 -05:00
Pete Walters
e0b88ecf86
Remove unnecessary dispatch during progress view layout 2026-05-27 22:12:06 -05:00
Pete Walters
507959b305
Only cancel non-cancelled, non-nil download tasks 2026-05-27 22:11:43 -05:00
Igor Solomennikov
aa059ff975 Revert "Modernize appearance of "Choose Photo" button in QR code scanner."
This reverts commit 3ac71942a7.
2026-05-27 19:47:14 -07:00
Igor Solomennikov
3ac71942a7 Modernize appearance of "Choose Photo" button in QR code scanner. 2026-05-27 19:46:56 -07:00
Igor Solomennikov
28b9200637
Use CGFloat.hairlineWidth for separator height constraint.
Instead of 0.3
2026-05-27 15:33:06 -07:00
Igor Solomennikov
1c5dfb2e71
Tiny updates to Group Story Settings screen.
• use UIColor.Signal colors.
• remove unnecessary updates to UIViewController.navigationItem.
• Remove > from Remove Group Story button.
2026-05-27 15:32:47 -07:00
sashaweiss-signal
2cd69719c7 Bump version to 8.15 2026-05-27 15:12:33 -07:00
sashaweiss-signal
cdb96e1029 Update translations
Some checks failed
CI / Build and Test (Xcode_26.5) (push) Has been cancelled
CI / Check if strings file is outdated (push) Has been cancelled
2026-05-27 15:09:56 -07:00
sashaweiss-signal
961936b0ca Update release notes 2026-05-27 15:09:11 -07:00
Igor Solomennikov
46445edfe7
Use modern UIButton configuration for Approve / Reject group join request buttons.
Made avatar and button a bit larger too.
2026-05-27 14:57:59 -07:00
Elaine
ab454da687
Add debug log preview 2026-05-27 16:22:31 -04:00
Igor Solomennikov
025a9ff9be
New design for wallpaper preview view. 2026-05-27 12:31:41 -07:00
Max Radermacher
c91c15ec7f
Fix estimated length when removing characters 2026-05-27 14:30:16 -05:00
Igor Solomennikov
975834e1f6
A couple improvements for the Contact Us screen.
• show "Reason" selection UI as a popup menu instead of an action sheet.
• fix "Include debug log" not wrapping to multiple lines.
2026-05-27 12:28:53 -07:00
Igor Solomennikov
77bc1008ad
Layout tweaks for username qr code view.
• use modern UIButton configuration for Copy Username button.
• better vertical alignment for the copy button.
2026-05-27 12:28:23 -07:00
Igor Solomennikov
ed9f3615ba
Remove obsolete UIButton extension methods. 2026-05-27 12:27:54 -07:00
Elaine
aad90b9f5b
Add chevron animation to collapse events 2026-05-27 14:46:08 -04:00
Elaine
dc3827ed5f
Stay scrolled at bottom when expanding collapse sets 2026-05-27 11:39:07 -07:00
Max Radermacher
f5806db594 Use Swift Testing for StringSanitizerTests 2026-05-27 13:16:03 -05:00
Max Radermacher
18871a45bd Use escape sequences for StringSanitizerTests 2026-05-27 13:16:03 -05:00
Igor Solomennikov
ec69b9425f
Present "New Story" creation options via a popup menu.
Instead of a customized action sheet.
2026-05-26 16:45:15 -07:00
Igor Solomennikov
732d375c82
Improve code for creating ••• bar button that shows a popup menu.
Old: create a ContextMenuButton (which is an UIButton) and put it into a UIBarButtonItem (as customView).

New: Create UIBarButtonItem with ••• icon and a set of UIActions to show in a popup menu.
2026-05-26 16:43:10 -07:00
Igor Solomennikov
ce442092f3
Do not tint Next button in Contact Us flow in blue.
To match all other Settings screens.
2026-05-26 16:42:42 -07:00
Igor Solomennikov
2faeff7589
Improved layout in SAEFailedViewController.
• fix issue where text was made white and wasn't visible unless in dark mode.
• use UIColor.Signal colors.
• better vertical alignment.
2026-05-26 16:42:01 -07:00
Igor Solomennikov
8384fab6a1
Update BlurredToolbarContainer.
• remove forceDarkMode parameter.
• simplify logic.
2026-05-26 16:40:03 -07:00
Max Radermacher
3a3ffde3dd
Remove indirection for some methods 2026-05-26 18:24:40 -05:00
Max Radermacher
6e45f851f2
Move some account/SVR files 2026-05-26 17:48:41 -05:00
Max Radermacher
a6476a9e79
Remove BuildFlags.AttachmentBackfill 2026-05-26 17:29:34 -05:00
Max Radermacher
276d778e22
Remove BuildFlags.MemberLabel 2026-05-26 17:29:24 -05:00
Pete Walters
92b54a1ceb
Fix transitions from a gallery item w/o image 2026-05-26 16:06:17 -05:00
kate-signal
ea190d9ae0
Pop to chat list root in showAppSettings 2026-05-26 14:16:16 -04:00
Pete Walters
507d23b760
Always listen for progress in CVAttachmentProgressView 2026-05-26 12:28:44 -05:00
Pete Walters
e39fb58e06
Fix display of download indicator when optimize media is disabled 2026-05-26 08:21:34 -05:00
Sasha Weiss
d655b7b7a4
Don't allow addresses with a label but nothing else in Backup exports.
Uses OWSContactAddress.isValid to decide if we should even try and archive it; notably, isValid did not return true if only the label was present.

Also uses strippedOrNil instead of just nilIfEmpty – I don't think we'll want to export a field if it's got just spaces in it.
2026-05-25 15:01:20 -07:00
Igor Solomennikov
29b863abf8
Improved media viewer layout on iPad.
Previous layout code was assuming that a view would have "regular" horizontal size class only when vertical size class is "compact". This is only correct on iPhones. On iPad views most often has both vertical and horizontal size classes as "regular".

This commit fixes an issue where on iPad media viewer layout would assume "compact" width and would not use screen real estate efficiently.
2026-05-25 14:17:37 -07:00
kate-signal
7beb7330a0
show verification code requested sheet in CVC 2026-05-25 08:44:48 -04:00
Max Radermacher
e14f223e79
Remove unused SpamChallengeResolvedError 2026-05-22 21:10:33 -05:00
Max Radermacher
334f6b9888
Remove obsolete Storage Service migrations 2026-05-22 20:43:35 -05:00
Max Radermacher
5f7cbb5f66
Don’t send stories multiple times 2026-05-22 20:33:19 -05:00
Sasha Weiss
0ca83a1b50
Clean up Internal Settings 2026-05-22 15:46:13 -07:00
Max Radermacher
665fda1f2a
Simplify & fix checkpointing 2026-05-22 17:36:39 -05:00
Pete Walters
ab097068a8
Add ios.optimizeStorageEnabled remote config 2026-05-22 16:08:09 -05:00
Pete Walters
ebd1292f61
Update prod SVR2 enclaves
Co-authored-by: Max Radermacher <max@signal.org>
2026-05-22 15:40:58 -05:00
Max Radermacher
118e6289ab
Fix isRetryable for network failure SignalErrors 2026-05-22 15:39:21 -05:00
Elaine
73e5108c1e
Add internal setting 2026-05-22 15:44:58 -04:00
Max Radermacher
fa65a9e9b5
Add support for simultaneous SVR2 enclaves 2026-05-22 14:17:06 -05:00
Max Radermacher
1ea6b1b1d9
Migrate current enclave to potential enclaves 2026-05-22 14:16:23 -05:00
Max Radermacher
b8a90daaf0
Stop retrying if expose fails 2026-05-22 14:13:37 -05:00
Max Radermacher
e7a0d760ca
Simplify SVR2 2026-05-22 14:12:57 -05:00
Pierre-Yves Lapersonne
e541922e02 Use dynamic type font for GroupTableViewCell subtitle
Addresses PR 6272.
2026-05-21 17:21:59 -07:00
Pierre-Yves Lapersonne
7166c115af Add .button accessibility trait to ProfileDetailsLabel, if necessary
Addresses PR 6270.
2026-05-21 17:21:59 -07:00
sashaweiss-signal
5897252015 Remove unnecessary ProfileDetailsLabel longPressAction 2026-05-21 17:21:59 -07:00
Pierre-Yves Lapersonne
7771cf159d Hide mutual-groups images from VoiceOver
Addresses PR 6268.
2026-05-21 17:21:59 -07:00
Pierre-Yves Lapersonne
7f55a610d8 Add .link accessibility trait to Donor FAQ button
Addresses PR 6266.
2026-05-21 17:21:59 -07:00
Pierre-Yves Lapersonne
c859d83b1d Add accessibility label to Share button in FingerprintViewController
Addresses PR 6264.
2026-05-21 17:21:59 -07:00
Pierre-Yves Lapersonne
c23f22445a Add accessibility labels to two MOB balance buttons
Addresses PR 6262.
2026-05-21 17:21:59 -07:00
Pierre-Yves Lapersonne
3cbd9ece13 Mark Payments helper cards as embedded hyperlinks
Addresses PR 6259.
2026-05-21 17:21:59 -07:00
Pierre-Yves Lapersonne
140a572d85 Hide Backups onboarding image from VoiceOver
Addresses PR 6257.
2026-05-21 17:21:59 -07:00
Sasha Weiss
a6387b9bfd
Use failIfThrows in Backup archiving enumerations 2026-05-21 16:16:33 -07:00
Pete Walters
d3b8a06e00
Display thumbnails when optimize media & auto-downloads enabled 2026-05-21 17:59:48 -05:00
Max Radermacher
48fb1065c6
Convert SVRUtil to enum 2026-05-21 17:59:07 -05:00
Pete Walters
87eb0382b3
Update WebP thumbnail quality settings 2026-05-21 17:58:13 -05:00
sashaweiss-signal
30c949e930 Snooze the Recovery Key reminder for 7d, not 2d 2026-05-21 15:13:07 -07:00
Sasha Weiss
60554470e6
Add debuglogs/ to the gitignore 2026-05-21 13:46:08 -07:00
Pete Walters
4ab7d1d12d
Fix layout of downloaded looping media 2026-05-21 13:20:56 -05:00
Igor Solomennikov
a7eb78f46c
Enable isPointerInteractionEnabled on some glass buttons.
Round buttons in chat input bar and media viewer.

With this property enabled iPadOS will highlight the button what trackpad cursor is over it.
2026-05-21 11:19:01 -07:00
Max Radermacher
153efb2d45
Defer pruning of old, unknown enclaves 2026-05-21 12:30:01 -05:00
kate-signal
ba2b662d37
Fetch remote announcements 2026-05-21 11:37:50 -04:00
Pete Walters
543085bd26
Migrate ChangeNumber prompt from HeroSheet -> ActionSheet 2026-05-21 10:07:16 -05:00
Pete Walters
a65ec79c04
Move the gallery item progress view to the root view vs media view 2026-05-20 20:45:09 -05:00
Pete Walters
2ea672abb3
Better handle deduped attachment download in media gallery 2026-05-20 20:44:47 -05:00
Pete Walters
e7d209ee66
Disable share/forward/save for undownloaded attachments 2026-05-20 20:39:58 -05:00
Pete Walters
df2e8557e9
Add MediaGallery support for downloading fullsize media 2026-05-20 20:31:18 -05:00
Max Radermacher
46b3f825a2
Back up again when MasterKey changes 2026-05-20 18:04:43 -05:00
Max Radermacher
7b7727287c
Update to Xcode 26.5 2026-05-20 17:38:30 -05:00
Max Radermacher
cf47211efe
Use Cron for SVR2 2026-05-20 17:32:18 -05:00
Max Radermacher
3cb8044133
Simplify InProgressBackup handling 2026-05-20 17:29:58 -05:00
Sasha Weiss
8ddef58df8
Stop logging Backup frames on error 2026-05-20 15:04:17 -07:00
Sasha Weiss
7f240db8a4
Use single-element TimeGatedBatch in BackupOversizeTextCache 2026-05-20 14:59:37 -07:00
Max Radermacher
d5747a432a Remove unnecessary do { … } block 2026-05-20 16:54:01 -05:00
Max Radermacher
c1c292b23a Clean up name and some formatting in registration 2026-05-20 16:53:46 -05:00
Max Radermacher
ce9b44ec82
Remove obsolete SVRLocalStorage methods 2026-05-20 16:51:17 -05:00
Sasha Weiss
608bea72f6
Update two KT sheet strings 2026-05-20 14:49:17 -07:00
Max Radermacher
1aadffb78a
Remove getPinType/setPinType 2026-05-20 15:19:09 -05:00
Max Radermacher
25f73ea745 Bump version to 8.14 2026-05-20 14:49:35 -05:00
Max Radermacher
3cac16ff20 Update translations 2026-05-20 14:49:25 -05:00
Max Radermacher
ab5c593d00 Update release notes 2026-05-20 14:48:26 -05:00
Max Radermacher
8b77f19f16
Clean up verifyPin/pinCode methods 2026-05-20 14:47:11 -05:00
Pete Walters
0f62aa13f2
Prefer the use backup thumbnails when optimize storage is enabled 2026-05-20 14:41:49 -05:00
Max Radermacher
6c2037b2f2
De-protocolize SVRLocalStorage 2026-05-20 14:29:04 -05:00
kate-signal
92efa6a1fb
update verification sheet design 2026-05-20 13:56:36 -04:00
Sasha Weiss
d427041444
Adopt new KT.reset(...) API 2026-05-20 10:50:04 -07:00
sashaweiss-signal
71444c4eca Reapply "Display AEPs with 0/O, accept 0/O/=/#" 2026-05-20 10:35:13 -07:00
Sasha Weiss
abc2e213c7
Revert "Display AEPs with 0/O, accept 0/O/=/#" 2026-05-20 10:32:56 -07:00
kate-signal
7635902bb8
verification notice sheet 2026-05-20 12:16:01 -04:00
Pete Walters
ddb0f79fc1
Add waiting period for change number requests
Co-authored-by: Max Radermacher <max@signal.org>
2026-05-20 08:49:24 -05:00
Max Radermacher
43889edbca
Remove unused writePing/receivedPong methods 2026-05-20 02:50:11 -05:00
Max Radermacher
85dd27caf8
Add logging for retryable periodic Cron errors 2026-05-20 02:31:26 -05:00
Max Radermacher
b5d530fb14
Remove unused waitForAllResponses & friends 2026-05-20 02:28:58 -05:00
Max Radermacher
743c59f545
Remove SVRError 2026-05-20 01:35:33 -05:00
Max Radermacher
dda98e5f6c
Inline startFreshBackupExpose/continueWithExpose 2026-05-20 01:33:55 -05:00
Max Radermacher
f5d8b785db
Remove SVR2 connection caching 2026-05-20 01:32:44 -05:00
Max Radermacher
7c3a73d1a7
Adjust SVR2PinHash protocol 2026-05-20 01:28:44 -05:00
Max Radermacher
7a30fc750e
Remove ChainedPromise 2026-05-20 01:25:57 -05:00
Max Radermacher
1105ac39a3
Asyncify SVR2 2026-05-20 01:25:06 -05:00
Igor Solomennikov
ee6cd21fb0 Rename StoryGroupRepliesAndViewsViewController to StoryGroupRepliesAndViewsSheet.
Since it is configured internally to be presented as a sheet.
2026-05-19 23:21:21 -07:00
Igor Solomennikov
ba6e02810d
Tweak layout in Story Viewer a little bit.
• larger avatar.
• tweaked UI element spacing per design specs.
2026-05-19 23:12:09 -07:00
Igor Solomennikov
6560f22eac
Update story reply UI for iOS 26.
• use UISheetPresentationController for group story views / replies screen.
• use UISheetPresentationController for private story views screen.
• liquid glass backgrounds for reply input field and reactions panel.
• move away from Theme and hardcoded colors to UIColor.Signal palette.
• use modern UIButton configuration APIs.
2026-05-19 23:10:25 -07:00
Igor Solomennikov
f1335b65d3
Update Story Info sheet.
• use UISheetPresentationController.
• use UIColor.Signal colors.
2026-05-19 23:09:28 -07:00
Sasha Weiss
43256239f2
Dedent error handling in BackupAttachmentUploadQueueRunner 2026-05-19 16:44:39 -07:00
Sasha Weiss
7c3430d9aa
Show dedicated sheet when Backup Export hits a Too Large error 2026-05-19 18:33:22 -05:00
Sasha Weiss
da245447f3
Clean up some ExperienceUpgrade code 2026-05-19 16:32:44 -07:00
Sasha Weiss
dfbae2e781
Add handling for SignalError.rateLimitedError to the upload queue runner 2026-05-19 18:31:47 -05:00
Sasha Weiss
6015f70ce0
Skip thread-merge events for the Note to Self 2026-05-19 14:45:42 -07:00
sashaweiss-signal
863d3e62f4 String change for KT failure sheet 2026-05-19 13:44:07 -07:00
Pete Walters
d0585fd780
Update some download manager methods 2026-05-19 15:30:18 -05:00
kate-signal
057dc81197
Updated signal symbol 2026-05-19 14:16:19 -04:00
sashaweiss-signal
5817347d32 Remove ^ operator for Bool 2026-05-19 10:26:50 -07:00
andrew-signal
3915313e5e
Remove orphaned stickers onboarding assets 2026-05-18 19:05:52 -06:00
Elaine
88ed60064b
Don't bump blocks to top of chat list 2026-05-18 19:17:04 -04:00
Sasha Weiss
e67abb1376
Use failIfThrows in BackupOversizeTextCache 2026-05-18 16:14:33 -07:00
Igor Solomennikov
77e65a98e3
Update story media viewer for iOS 26.
• glass round buttons.
• modern configuration for "Reply" button.
2026-05-18 15:37:55 -07:00
Sasha Weiss
a49428670a
Modernize CallRecordStore, CallLinkRecordStore 2026-05-18 15:29:07 -07:00
Sasha Weiss
a9e3580fb4
Restrict KT to beta 2026-05-18 15:23:23 -07:00
Sasha Weiss
e72061106f
Use failIfThrows in BackupArchiveExportProgress.prepare 2026-05-18 17:20:36 -05:00
Max Radermacher
979d64d10a
Update to LibSignal v0.94.1 2026-05-18 14:55:39 -05:00
Sasha Weiss
3260d67215
Use failIfThrows in EditStore 2026-05-18 13:14:52 -05:00
Max Radermacher
0cf7d2f1e0
update to ruby 3.4.9 2026-05-18 12:08:33 -05:00
Max Radermacher
3e9c06e504
Update fastlane 2026-05-18 12:07:08 -05:00
Pierre-Yves Lapersonne
a9c49fabcc Add unread badge count to tab accessibilityValue 2026-05-18 09:11:00 -07:00
Igor Solomennikov
32e804824e
Use larger (40 dp) corner radius for stories cards on iOS 26. 2026-05-15 16:30:15 -07:00
Igor Solomennikov
fa6f8e6489
Remove chevron from "Delete Custom Story" button in settings. 2026-05-15 16:29:48 -07:00
Max Radermacher
2af2d50a84
Add Marathi, Urdu, Gujarati, & Bangla to App Store 2026-05-15 18:19:40 -05:00
Igor Solomennikov
5aa3493580
Update story viewer's onboarding UI for iOS 26. 2026-05-15 14:36:21 -07:00
Igor Solomennikov
296aa8cc46
Improved message selection indicators in chat.
• use SelectionIndicatorView in chat.
• modify SelectionIndicatorView to allow to configure ring color.
• improve legibility by using custom shade of gray for selection indicator in "not selected" state in chat when in light mode and with a wallpaper set.
2026-05-15 14:35:43 -07:00
Igor Solomennikov
b5838a1afc
Use ListItemSelectionIndicatorView in All Media view.
Added functionality to show selection indicator with a white outline - to be displayed on top of media.

Rename ListItemSelectionIndicatorView to SelectionIndicatorView.
2026-05-15 14:34:18 -07:00
Elaine
868ed6bb4b
Anchor collapse set expansion to its button 2026-05-15 16:31:17 -04:00
Elaine
ff39563167
BadgeDetailsSheet -> HeroSheetViewController 2026-05-15 16:31:02 -04:00
adel-signal
ffc8a07b36
Update to RingRTC v2.69.1 2026-05-15 13:25:42 -07:00
Max Radermacher
99a5252b59 Increase SVR2 test delays 2026-05-15 12:38:29 -05:00
kate-signal
b789a19ba4
adjust message request buttons styling 2026-05-15 13:24:01 -04:00
Max Radermacher
7984addb13
Asyncify SVR2ConcurrencyTests 2026-05-14 18:07:04 -05:00
Igor Solomennikov
c2d2af31b8 Decrease spacing between timer icon and text.
In conversation picker when a conversation has disappearing messages timer set.
2026-05-14 15:30:14 -07:00
Pete Walters
957e1c1b1d
Update SVR2 staging enclaves 2026-05-14 16:45:02 -05:00
Igor Solomennikov
02ebaf8e14
Simplify two cases of UIButton configuration.
Use UIButton.Configuration.baseForegroundColor instead of UIButton.Configuration.imageColorTransformer.

Verified that button icon color works properly as before.
2026-05-14 11:03:38 -07:00
Igor Solomennikov
988dc123f1
A couple improvements for media / file / audio list view. (#13857)
No user visible changes.

• use ListItemSelectionIndicatorView in selection mode.
• use UIColor.Signal values for UILabel text color.
2026-05-14 11:03:03 -07:00
Igor Solomennikov
1f4b88f6d2
Use Theme.iconImage() where possible.
Instead of UIImage(named: Theme.iconName(xxx)).
2026-05-14 11:01:27 -07:00
Igor Solomennikov
5121d5a125
Do not show selection indicator in "Choose New Admin" view. 2026-05-14 11:01:01 -07:00
Igor Solomennikov
6e7e8154fb
Use ListItemSelectionIndicatorView in multi-select contact pickers. 2026-05-14 11:00:35 -07:00
Max Radermacher
2707897017
Hold strong references during SVR operations 2026-05-14 11:48:42 -05:00
Max Radermacher
8b0d0c27e2
Remove no-longer-necessary migration code 2026-05-14 11:47:28 -05:00
Max Radermacher
c836d27490
Run SVR operation after transaction commits 2026-05-14 11:47:05 -05:00
Max Radermacher
f1ec358d7f
Use LibSignal’s ProvisioningConnection 2026-05-14 11:46:46 -05:00
Max Radermacher
68d6736df4
Identify provisioning socket by their identity 2026-05-14 11:46:02 -05:00
Max Radermacher
b339e224d0
Remove ProvisioningUrlParams 2026-05-14 11:38:30 -05:00
Igor Solomennikov
27e471e195
Fix layout issue in ListItemSelectionIndicatorView. 2026-05-14 01:44:34 -05:00
Igor Solomennikov
23daba5750 Capitalize "Choose New Admin" when used as action sheet button title. 2026-05-13 21:18:56 -07:00
Igor Solomennikov
20002eec14
Actually update appearance of "Message Request" panel in chat. 2026-05-13 22:29:26 -05:00
Max Radermacher
0ff64b6073
Remove unused code 2026-05-13 21:39:07 -05:00
Igor Solomennikov
8ca0d7ada9
UI updates for "My Story Privacy" screen.
• use ListItemSelectionIndicatorView in UITableView rows.
• use UIColor.Signal colors instead of Theme colors.
• use modern UIButton configuration methods.
2026-05-13 18:52:35 -07:00
Max Radermacher
a9338e9da7
Simplify ProvisioningCipher 2026-05-13 19:01:10 -05:00
Igor Solomennikov
710c225f9f
Use ListItemSelectionIndicatorView in contact sharing UI.
For consistent looks across the app.
2026-05-13 16:52:00 -07:00
Igor Solomennikov
33ec84806a
Reference "check-circle-fill" asset via ThemeIcon where possible. 2026-05-13 16:51:37 -07:00
Igor Solomennikov
aed08142cd
New design for disappearing message indicator in chat picker.
Put timer duration next to the time icon instead of below.

Also make the updated view a public class in SignalUI and use it in badge gifting confirmation screen for consistency.
2026-05-13 16:51:02 -07:00
Sasha Weiss
a7fd1a2506
Remove BackupArchive.LoggableId 2026-05-13 16:16:08 -07:00
Sasha Weiss
714a789cc5
Make DonationSubscriptionManager a singleton instance, not a static class 2026-05-13 16:15:30 -07:00
Sasha Weiss
8a0e1a9a2c
Use Cron/scheduleFrequently for subscription redemption 2026-05-13 16:14:19 -07:00
sashaweiss-signal
4d8b53474f Bump version to 8.13 2026-05-13 14:02:34 -07:00
776 changed files with 22595 additions and 21717 deletions

View File

@ -18,7 +18,7 @@ concurrency:
env:
# Path format pulled from https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md#xcode
DEVELOPER_DIR: /Applications/Xcode_26.4.app
DEVELOPER_DIR: /Applications/Xcode_26.5.app
jobs:
build_and_test:
@ -31,7 +31,7 @@ jobs:
strategy:
matrix:
# Add additional Xcode versions here if necessary.
xcode: ["Xcode_26.4"]
xcode: ["Xcode_26.5"]
steps:
- name: Set Xcode version

View File

@ -34,7 +34,7 @@ concurrency:
env:
# Path format pulled from https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md#xcode
DEVELOPER_DIR: /Applications/Xcode_26.4.app
DEVELOPER_DIR: /Applications/Xcode_26.5.app
# v0.60.1
swiftformat-ref: c8e50ff2cfc2eab46246c072a9ae25ab656c6ec3

View File

@ -28,7 +28,7 @@ concurrency:
env:
# Path format pulled from https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md#xcode
DEVELOPER_DIR: /Applications/Xcode_26.4.app
DEVELOPER_DIR: /Applications/Xcode_26.5.app
# v1.36.1
swift-protobuf-ref: a008af1a102ff3dd6cc3764bb69bf63226d0f5f6

View File

@ -7,7 +7,7 @@ on:
env:
# Path format pulled from https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md#xcode
DEVELOPER_DIR: /Applications/Xcode_26.4.app
DEVELOPER_DIR: /Applications/Xcode_26.5.app
jobs:
check-strings:

View File

@ -17,7 +17,7 @@ concurrency:
env:
# Path format pulled from https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md#xcode
DEVELOPER_DIR: /Applications/Xcode_26.4.app
DEVELOPER_DIR: /Applications/Xcode_26.5.app
jobs:
build:

View File

@ -17,7 +17,7 @@ concurrency:
env:
# Path format pulled from https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md#xcode
DEVELOPER_DIR: /Applications/Xcode_26.4.app
DEVELOPER_DIR: /Applications/Xcode_26.5.app
jobs:
build:

3
.gitignore vendored
View File

@ -36,6 +36,9 @@ Index/
*.sdsjson
Scripts/sds_codegen/sds-includes/*
# Logs
debuglogs/
/.idea
/.vscode

View File

@ -1 +1 @@
3.2.2
3.4.9

View File

@ -1 +1 @@
Xcode 26.4.1
Xcode 26.5

View File

@ -1,10 +1,8 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.7)
base64
nkf
rexml
CFPropertyList (3.0.8)
abbrev (0.1.2)
activesupport (7.1.3.2)
base64
bigdecimal
@ -15,32 +13,36 @@ GEM
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
algoliasearch (1.27.5)
httpclient (~> 2.8, >= 2.8.3)
json (>= 1.5.1)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.1001.0)
aws-sdk-core (3.211.0)
aws-eventstream (1.4.0)
aws-partitions (1.1249.0)
aws-sdk-core (3.247.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.95.0)
aws-sdk-core (~> 3, >= 3.210.0)
logger
aws-sdk-kms (1.125.0)
aws-sdk-core (~> 3, >= 3.247.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.169.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-s3 (1.222.0)
aws-sdk-core (~> 3, >= 3.247.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.10.1)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
bigdecimal (3.1.6)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
claide (1.1.0)
cocoapods (1.15.2)
addressable (~> 2.8)
@ -85,8 +87,9 @@ GEM
highline (~> 2.0.0)
concurrent-ruby (1.2.3)
connection_pool (2.4.1)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.6.5)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
@ -97,7 +100,7 @@ GEM
ethon (0.16.0)
ffi (>= 1.15.0)
excon (0.112.0)
faraday (1.10.4)
faraday (1.10.5)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@ -109,32 +112,36 @@ GEM
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.3.1)
fastlane (2.225.0)
CFPropertyList (>= 2.3, < 4.0.0)
fastimage (2.4.1)
fastlane (2.234.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
@ -142,20 +149,24 @@ GEM
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-env (>= 1.6.0, <= 2.1.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
@ -166,97 +177,100 @@ GEM
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.3.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
fastlane-sirp (1.1.0)
ffi (1.17.3)
fourflusher (2.3.1)
fuzzy_match (2.0.4)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
google-apis-androidpublisher_v3 (0.100.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.7.1)
google-apis-iamcredentials_v1 (0.27.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.62.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.4.0)
google-cloud-storage (1.47.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.6.0)
google-cloud-storage (1.60.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
googleauth (~> 1.9)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.7)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.8.3)
httpclient (2.9.0)
mutex_m
i18n (1.14.1)
concurrent-ruby (~> 1.0)
jmespath (1.6.2)
json (2.8.1)
jwt (2.9.3)
json (2.19.5)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
minitest (5.22.2)
molinillo (0.8.0)
multi_json (1.15.0)
multi_json (1.21.1)
multipart-post (2.4.1)
mutex_m (0.2.0)
mutex_m (0.3.0)
nanaimo (0.4.0)
nap (1.1.0)
naturally (2.2.1)
naturally (2.3.0)
netrc (0.11.0)
nkf (0.2.0)
optparse (0.5.0)
optparse (0.8.1)
os (1.1.4)
plist (3.7.1)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (4.0.7)
rake (13.2.1)
rake (13.4.2)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.3.9)
rouge (2.0.7)
retriable (3.4.1)
rexml (3.4.4)
rouge (3.28.0)
ruby-macho (2.5.1)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
rubyzip (2.4.1)
security (0.1.5)
signet (0.19.0)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
@ -282,8 +296,8 @@ GEM
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
@ -296,4 +310,4 @@ DEPENDENCIES
xcode-install
BUNDLED WITH
2.5.6
2.6.9

View File

@ -11,13 +11,13 @@ source 'https://cdn.cocoapods.org/'
pod 'blurhash', podspec: './ThirdParty/blurhash.podspec'
pod 'SwiftProtobuf', "1.36.1"
ENV['LIBSIGNAL_FFI_PREBUILD_CHECKSUM'] = '2b781ed29e11848acf7127457e24dedd5f6dce2189ba3c8f6773ee85fc255b3b'
pod 'LibSignalClient', git: 'https://github.com/signalapp/libsignal.git', tag: 'v0.94.0', testspecs: ["Tests"]
ENV['LIBSIGNAL_FFI_PREBUILD_CHECKSUM'] = '79f53932ff82f792b70e30bad3b38801da0b882137adaf65ad54d907a94f3d29'
pod 'LibSignalClient', git: 'https://github.com/signalapp/libsignal.git', tag: 'v0.95.0', testspecs: ["Tests"]
# pod 'LibSignalClient', path: '../libsignal', testspecs: ["Tests"]
ENV['RINGRTC_PREBUILD_CHECKSUM'] = '64743212da1c13ab7092ac4ba905c4b629d2ab1935bf3b3b8db7341cc4b5864e'
ENV['RINGRTC_PREBUILD_CHECKSUM'] = 'c19c813ab5255aa3cd7c2af36374100f7cc69c2fd794cae23baebd6ec9dae90c'
# ENV['RINGRTC_USE_FILE_BASED_CAMERA'] = '1'
pod 'SignalRingRTC', git: 'https://github.com/signalapp/ringrtc', tag: 'v2.68.1', inhibit_warnings: true
pod 'SignalRingRTC', git: 'https://github.com/signalapp/ringrtc', tag: 'v2.69.1', inhibit_warnings: true
# pod 'SignalRingRTC', path: '../ringrtc', testspecs: ["Tests"]
pod 'GRDB.swift/SQLCipher'

View File

@ -9,8 +9,8 @@ PODS:
- LibMobileCoin/CoreHTTP (6.0.2):
- SwiftProtobuf (~> 1.5)
- libPhoneNumber-iOS (1.2.0)
- LibSignalClient (0.94.0)
- LibSignalClient/Tests (0.94.0)
- LibSignalClient (0.95.0)
- LibSignalClient/Tests (0.95.0)
- libwebp (1.5.0):
- libwebp/demux (= 1.5.0)
- libwebp/mux (= 1.5.0)
@ -35,9 +35,9 @@ PODS:
- SDWebImageWebPCoder (0.14.6):
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.17)
- SignalRingRTC (2.68.1):
- SignalRingRTC/WebRTC (= 2.68.1)
- SignalRingRTC/WebRTC (2.68.1)
- SignalRingRTC (2.69.1):
- SignalRingRTC/WebRTC (= 2.69.1)
- SignalRingRTC/WebRTC (2.69.1)
- SQLCipher (4.6.1):
- SQLCipher/standard (= 4.6.1)
- SQLCipher/common (4.6.1)
@ -52,15 +52,15 @@ DEPENDENCIES:
- GRDB.swift/SQLCipher
- LibMobileCoin/CoreHTTP (from `https://github.com/signalapp/libmobilecoin-ios-artifacts`, tag `signal/6.0.2`)
- libPhoneNumber-iOS (from `https://github.com/signalapp/libPhoneNumber-iOS`, branch `signal-master`)
- LibSignalClient (from `https://github.com/signalapp/libsignal.git`, tag `v0.94.0`)
- LibSignalClient/Tests (from `https://github.com/signalapp/libsignal.git`, tag `v0.94.0`)
- LibSignalClient (from `https://github.com/signalapp/libsignal.git`, tag `v0.95.0`)
- LibSignalClient/Tests (from `https://github.com/signalapp/libsignal.git`, tag `v0.95.0`)
- libwebp
- lottie-ios
- MobileCoin/CoreHTTP (from `https://github.com/mobilecoinofficial/MobileCoin-Swift`, tag `v6.0.3`)
- PureLayout
- SDWebImage
- SDWebImageWebPCoder
- SignalRingRTC (from `https://github.com/signalapp/ringrtc`, tag `v2.68.1`)
- SignalRingRTC (from `https://github.com/signalapp/ringrtc`, tag `v2.69.1`)
- SQLCipher (from `https://github.com/signalapp/sqlcipher.git`, tag `v4.6.1-f_barrierfsync`)
- SwiftProtobuf (= 1.36.1)
@ -89,13 +89,13 @@ EXTERNAL SOURCES:
:git: https://github.com/signalapp/libPhoneNumber-iOS
LibSignalClient:
:git: https://github.com/signalapp/libsignal.git
:tag: v0.94.0
:tag: v0.95.0
MobileCoin:
:git: https://github.com/mobilecoinofficial/MobileCoin-Swift
:tag: v6.0.3
SignalRingRTC:
:git: https://github.com/signalapp/ringrtc
:tag: v2.68.1
:tag: v2.69.1
SQLCipher:
:git: https://github.com/signalapp/sqlcipher.git
:tag: v4.6.1-f_barrierfsync
@ -113,13 +113,13 @@ CHECKOUT OPTIONS:
:git: https://github.com/signalapp/libPhoneNumber-iOS
LibSignalClient:
:git: https://github.com/signalapp/libsignal.git
:tag: v0.94.0
:tag: v0.95.0
MobileCoin:
:git: https://github.com/mobilecoinofficial/MobileCoin-Swift
:tag: v6.0.3
SignalRingRTC:
:git: https://github.com/signalapp/ringrtc
:tag: v2.68.1
:tag: v2.69.1
SQLCipher:
:git: https://github.com/signalapp/sqlcipher.git
:tag: v4.6.1-f_barrierfsync
@ -131,7 +131,7 @@ SPEC CHECKSUMS:
GRDB.swift: 1395cb3556df6b16ed69dfc74c3886abc75d2825
LibMobileCoin: 8503f567fa32184a5be7bc038fbd727747dd9991
libPhoneNumber-iOS: 1a34106b49dc6e12a7f37eb9aee7c64011509547
LibSignalClient: 8023facf81b9909ad817f75e8df296cf2d8846f1
LibSignalClient: a98db1d538243e43ecac040005204bd274cbd8c7
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
Logging: beeb016c9c80cf77042d62e83495816847ef108b
lottie-ios: fcb5e73e17ba4c983140b7d21095c834b3087418
@ -139,10 +139,10 @@ SPEC CHECKSUMS:
PureLayout: f08c01b8dec00bb14a1fefa3de4c7d9c265df85e
SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
SignalRingRTC: 0d98294e8b0c95ddb94ab294a59789e280dd72e0
SignalRingRTC: b907e1c8ef7743926c031810e9655366d7aa3eeb
SQLCipher: ff2f045b20d675a73a70f7329395ddd4a2580063
SwiftProtobuf: 9e106a71456f4d3f6a3b0c8fd87ef0be085efc38
PODFILE CHECKSUM: 7328d74a7af4adf8f9cbef39102cf1b41d5201a6
PODFILE CHECKSUM: ee98007764e1569e9dbe4f25053510725b19fc88
COCOAPODS: 1.15.2

2
Pods

@ -1 +1 @@
Subproject commit cf2c3580a41c5b30e319cf3c2c691e276b437d71
Subproject commit 5e81462d833ad24e8091d7b6ab675c2cdc94af54

View File

@ -1,76 +1,27 @@
{
"#comment": "NOTE: This file is generated by /Scripts/sds_codegen/sds_generate.py. Do not manually edit it, instead run `sds_codegen.sh`.",
"BaseModel": 56,
"ExperienceUpgrade": 55,
"IncomingGroupsV2MessageJob": 63,
"InstalledSticker": 24,
"OWS100RemoveTSRecipientsMigration": 40,
"OWS101ExistingUsersBlockOnIdentityChange": 43,
"OWS102MoveLoggingPreferenceToUserDefaults": 47,
"OWS103EnableVideoCalling": 42,
"OWS104CreateRecipientIdentities": 45,
"OWS105AttachmentFilePaths": 44,
"OWS107LegacySounds": 50,
"OWS108CallLoggingPreference": 48,
"OWS109OutgoingMessageState": 51,
"#max": 80,
"OWSAddToContactsOfferMessage": 25,
"OWSAddToProfileWhitelistOfferMessage": 7,
"OWSBackupFragment": 32,
"OWSContactOffersInteraction": 22,
"OWSContactQuery": 57,
"OWSDatabaseMigration": 46,
"OWSDevice": 33,
"OWSDisappearingConfigurationUpdateInfoMessage": 28,
"OWSDisappearingMessagesConfiguration": 39,
"OWSGroupCallMessage": 65,
"OWSIncomingArchivedPaymentMessage": 78,
"OWSIncomingContactSyncJobRecord": 61,
"OWSIncomingGroupSyncJobRecord": 60,
"OWSIncomingPaymentMessage": 75,
"OWSLinkedDeviceReadReceipt": 36,
"OWSLocalUserLeaveGroupJobRecord": 74,
"OWSMessageContentJob": 15,
"OWSOutgoingArchivedPaymentMessage": 79,
"OWSOutgoingPaymentMessage": 68,
"OWSPaymentActivationRequestFinishedMessage": 77,
"OWSPaymentActivationRequestMessage": 76,
"OWSReaction": 62,
"OWSReceiptCredentialRedemptionJobRecord": 71,
"OWSRecipientIdentity": 38,
"OWSRecoverableDecryptionPlaceholder": 70,
"OWSResaveCollectionDBMigration": 49,
"OWSSendGiftBadgeJobRecord": 73,
"OWSSessionResetJobRecord": 52,
"OWSUnknownContactBlockOfferMessage": 5,
"OWSUnknownDBObject": 37,
"OWSUnknownProtocolVersionMessage": 54,
"OWSUserProfile": 41,
"OWSVerificationStateChangeMessage": 13,
"SSKJobRecord": 34,
"SSKMessageDecryptJobRecord": 53,
"SSKMessageSenderJobRecord": 35,
"SignalAccount": 30,
"SignalRecipient": 31,
"StickerPack": 14,
"TSCall": 20,
"TSContactThread": 27,
"TSErrorMessage": 9,
"TSGroupMember": 69,
"TSGroupThread": 26,
"TSIncomingMessage": 19,
"TSInfoMessage": 10,
"TSInteraction": 16,
"TSInvalidIdentityKeyErrorMessage": 17,
"TSInvalidIdentityKeyReceivingErrorMessage": 1,
"TSInvalidIdentityKeySendingErrorMessage": 23,
"TSMention": 64,
"TSMessage": 11,
"TSOutgoingMessage": 21,
"TSPaymentModel": 67,
"TSPaymentRequestModel": 66,
"TSPrivateStoryThread": 72,
"TSRecipientReadReceipt": 12,
"TSThread": 2,
"TSUnreadIndicatorInteraction": 4,
"TestModel": 59
"TSUnreadIndicatorInteraction": 4
}

View File

@ -2440,31 +2440,23 @@ record_type_map = {}
# It's critical that our "record type" values are consistent, even if we add/remove/rename model classes.
# Therefore we persist the mapping of known classes in a JSON file that is under source control.
def update_record_type_map(record_type_swift_path, record_type_json_path):
record_type_map_filepath = record_type_json_path
old_record_types = {}
if os.path.exists(record_type_json_path):
with open(record_type_json_path, "r") as f:
old_record_types = json.load(f)
if os.path.exists(record_type_map_filepath):
with open(record_type_map_filepath, "rt") as f:
json_string = f.read()
json_data = json.loads(json_string)
record_type_map.update(json_data)
max_record_type = 0
for class_name in record_type_map:
if class_name.startswith("#"):
continue
record_type = record_type_map[class_name]
max_record_type = max(max_record_type, record_type)
max_record_type = old_record_types.get("#max", 0)
for clazz in global_class_map.values():
if clazz.name not in record_type_map:
if not clazz.should_generate_extensions():
continue
max_record_type = int(max_record_type) + 1
record_type = max_record_type
record_type_map[clazz.name] = record_type
if not clazz.should_generate_extensions():
continue
if clazz.name in old_record_types:
record_type_map[clazz.name] = old_record_types[clazz.name]
else:
max_record_type += 1
record_type_map[clazz.name] = max_record_type
record_type_map["#max"] = max_record_type
record_type_map["#comment"] = (
"NOTE: This file is generated by %s. Do not manually edit it, instead run `sds_codegen.sh`."
% (sds_common.pretty_module_path(__file__),)
@ -2472,7 +2464,7 @@ def update_record_type_map(record_type_swift_path, record_type_json_path):
json_string = json.dumps(record_type_map, sort_keys=True, indent=4)
sds_common.write_text_file_if_changed(record_type_map_filepath, json_string)
sds_common.write_text_file_if_changed(record_type_json_path, json_string)
# TODO: We'll need to import SignalServiceKit for non-SSK classes.

View File

@ -9,6 +9,7 @@ import Foundation
private let languageMap: [String: [String]] = [
// These languages are returned from Smartling and need to be moved to their correct final destination.
"ar": ["ar-SA"],
"bn-BD": ["bn-BD"],
"ca": ["ca"],
"cs": ["cs"],
"da": ["da"],
@ -17,6 +18,7 @@ private let languageMap: [String: [String]] = [
"es": ["es-ES", "es-MX"],
"fi": ["fi"],
"fr": ["fr-CA", "fr-FR"],
"gu-IN": ["gu-IN"],
"he": ["he"],
"hi-IN": ["hi"],
"hr-HR": ["hr"],
@ -25,6 +27,7 @@ private let languageMap: [String: [String]] = [
"it": ["it"],
"ja": ["ja"],
"ko": ["ko"],
"mr-IN": ["mr-IN"],
"ms": ["ms"],
"nb": ["no"],
"nl": ["nl-NL"],
@ -38,6 +41,7 @@ private let languageMap: [String: [String]] = [
"th": ["th"],
"tr": ["tr"],
"uk-UA": ["uk"],
"ur": ["ur-PK"],
"vi": ["vi"],
"zh-CN": ["zh-Hans"],
"zh-HK": ["zh-Hant"],
@ -45,15 +49,11 @@ private let languageMap: [String: [String]] = [
// These don't exist in App Store Connect, so there's no need to fetch them from Smartling.
// "be-BY": [],
// "bg-BG": [],
// "bn-BD": [],
// "fa-IR": [],
// "ga-IE": [],
// "gu-IN": [],
// "lt-LT": [],
// "mr-IN": [],
// "sr-YR": [],
// "ug": [],
// "ur": [],
// "yue": [],
// "zh-TW": [],
]

File diff suppressed because it is too large Load Diff

View File

@ -87,7 +87,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
}
appReadiness.runNowOrWhenAppDidBecomeReadySync {
self.refreshConnection(isAppActive: false, shouldRunCron: false)
self.refreshConnection(isAppActive: false)
}
clearAppropriateNotificationsAndRestoreBadgeCount()
@ -148,9 +148,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
debugLogger.enableFileLogging(appContext: mainAppContext, canLaunchInBackground: true)
DebugLogger.configureSwiftLogging()
if DebugFlags.audibleErrorLogging {
debugLogger.enableErrorReporting()
}
Logger.warn("Launching…")
defer { Logger.info("Launched.") }
@ -372,13 +369,14 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
}
}
private lazy var screenLockUI = ScreenLockUI(appReadiness: appReadiness)
private func configureGlobalUI(in window: UIWindow) {
let screenLockUI = AppEnvironment.shared.screenLockUI
let windowManager = AppEnvironment.shared.windowManagerRef
Theme.setupSignalAppearance()
screenLockUI.setupWithRootWindow(window)
AppEnvironment.shared.windowManagerRef.setupWithRootWindow(window, screenBlockingWindow: screenLockUI.screenBlockingWindow)
windowManager.setupWithRootWindow(window, screenBlockingWindow: screenLockUI.screenBlockingWindow)
screenLockUI.startObserving()
}
@ -400,7 +398,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
let dataMigrationContinuation = globalsContinuation.initGlobals(
appContext: launchContext.appContext,
appReadiness: appReadiness,
backupArchiveErrorPresenterFactory: BackupArchiveErrorPresenterFactoryInternal(),
deviceBatteryLevelManager: DeviceBatteryLevelManagerImpl(),
deviceSleepManager: launchContext.deviceSleepManager,
paymentsEvents: PaymentsEventsMainApp(),
@ -646,16 +643,16 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
}
}
let remoteMegaphoneFetcher = RemoteMegaphoneFetcher(
databaseStorage: SSKEnvironment.shared.databaseStorageRef,
signalService: SSKEnvironment.shared.signalServiceRef,
let remoteReleaseNotesFetchingManager = RemoteReleaseNotesFetchingManager(
db: DependenciesBridge.shared.db,
remoteReleaseNotesService: DependenciesBridge.shared.remoteReleaseNotesService,
)
cron.schedulePeriodically(
uniqueKey: .fetchMegaphones,
approximateInterval: 3 * .day,
mustBeRegistered: false,
mustBeConnected: true,
operation: { try await remoteMegaphoneFetcher.syncRemoteMegaphones() },
operation: { try await remoteReleaseNotesFetchingManager.syncRemoteReleaseNotes() },
)
appReadiness.runNowOrWhenAppDidBecomeReadyAsync {
@ -720,6 +717,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
// element" should call .restart() on the appropriate job.
dependenciesBridge.deletedCallRecordExpirationJob.start()
dependenciesBridge.disappearingMessagesExpirationJob.start()
dependenciesBridge.decryptionPlaceholderExpirationJob.start()
dependenciesBridge.storyMessageExpirationJob.start()
dependenciesBridge.pinnedMessageExpirationJob.start()
@ -781,6 +779,27 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
operation: { try await blockingManager.syncBlockListIfNecessary(force: false) },
)
let svr = DependenciesBridge.shared.svr
// We must refresh our SVR2 credentials periodically. We typically do this
// when updating to a new version, but we want to refresh it after 14 days
// if we haven't upgraded.
cron.schedulePeriodically(
uniqueKey: .refreshSVRCredentials,
approximateInterval: 14 * .day,
mustBeRegistered: true,
mustBeDeviceType: .primary,
mustBeConnected: true,
operation: { try await svr.refreshCredentialsIfNecessary() },
)
cron.scheduleFrequently(
mustBeRegistered: true,
mustBeDeviceType: .primary,
mustBeConnected: true,
operation: { try await svr.refreshBackupIfNecessary() },
)
// Warm the "available emoji" cache, intentionally off the main thread.
Task.detached {
Emoji.warmAvailableCache()
@ -790,7 +809,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
// launching from the background, without this, we end up waiting some extra
// seconds before receiving an actionable push notification.
if !appContext.isMainAppAndActive {
self.refreshConnection(isAppActive: false, shouldRunCron: false)
self.refreshConnection(isAppActive: false)
}
if registeredState != nil {
@ -1233,14 +1252,20 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
switch action {
case .submitDebugLogsAndCrash:
addSubmitDebugLogsAction {
DebugLogs.submitLogs(supportTag: supportTag, dumper: logDumper) {
DebugLogs(dumper: logDumper).promptToSubmitLogs(
from: viewController,
supportTag: supportTag,
) {
owsFail("Exiting after submitting debug logs")
}
}
case .submitDebugLogsAndLaunchApp(let window, let launchContext):
addSubmitDebugLogsAction { [unowned window] in
DebugLogs.submitLogs(supportTag: supportTag, dumper: logDumper) {
DebugLogs(dumper: logDumper).promptToSubmitLogs(
from: viewController,
supportTag: supportTag,
) {
ignoreErrorAndLaunchApp(in: window, launchContext: launchContext)
}
}
@ -1367,7 +1392,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
}
}
refreshConnection(isAppActive: true, shouldRunCron: true)
refreshConnection(isAppActive: true)
// Every time we become active...
if registeredState != nil {
@ -1435,7 +1460,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
/// is in the background.
private var backgroundFetchHandle: BackgroundTaskHandle?
private func refreshConnection(isAppActive: Bool, shouldRunCron: Bool) {
private func refreshConnection(isAppActive: Bool) {
let chatConnectionManager = DependenciesBridge.shared.chatConnectionManager
let oldActiveConnectionTokens = self.activeConnectionTokens
@ -1443,9 +1468,10 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
// If we're active, open a connection.
self.activeConnectionTokens = chatConnectionManager.requestConnections()
oldActiveConnectionTokens.forEach { $0.releaseConnection() }
if shouldRunCron {
self.startCronTask()
}
// Start a new Cron task on activate.
self.startCronTask()
// We're back in the foreground. We've passed off connection management to
// the foreground logic, so just tear it down without waiting for anything.
self.backgroundFetchHandle?.interrupt()
@ -1462,17 +1488,14 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
do {
await backgroundFetcher.start()
oldActiveConnectionTokens.forEach { $0.releaseConnection() }
// If there's a Cron task running that was started in the foreground, wait
// for it to finish.
await withTaskCancellationHandler(
operation: { await cronTask?.value },
onCancel: { cronTask?.cancel() },
)
// If there's a fresh request to run Cron when entering the background,
// start a new Cron instance.
if shouldRunCron {
await self.runCron()
}
// This will usually be limited to 30 seconds rather than 3 minutes.
let waitDeadline = startDate.adding(180)
if isPastRegistration {
@ -1745,8 +1768,24 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
return false
}
let isVideo = isVideoCall(intent)
appReadiness.runNowOrWhenAppDidBecomeReadySync {
Task { @MainActor [appReadiness] in
do {
try await appReadiness.waitForAppReady()
} catch {
return
}
let callService = AppEnvironment.shared.callService!
let screenLockUI = AppEnvironment.shared.screenLockUI
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
do {
try await screenLockUI.waitForScreenUnlockThrowingPrevious()
} catch {
return
}
guard tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else {
Logger.warn("Ignoring user activity; not registered.")
return
@ -1764,7 +1803,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
// * It can be received if the user taps the "video" button for a contact
// in the contacts app. If so, the correct response is to try to initiate a
// new call to that user - unless there is another call in progress.
let callService = AppEnvironment.shared.callService!
if let currentCall = callService.callServiceState.currentCall {
if isVideo, case .individual = currentCall.mode, currentCall.mode.matches(callTarget) {
Logger.info("Upgrading existing call to video")
@ -1776,6 +1814,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
}
callService.initiateCall(to: callTarget, isVideo: isVideo)
}
return true
}
@ -1790,17 +1829,12 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
scheduleBgAppRefresh()
let attachmentDownloadmanager = DependenciesBridge.shared.attachmentDownloadManager
let db = DependenciesBridge.shared.db
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
let registeredState = try? tsAccountManager.registeredStateWithMaybeSneakyTransaction()
if let registeredState {
Logger.info("localAci: \(registeredState.localIdentifiers.aci)")
db.write { transaction in
ExperienceUpgradeFinder.markAllCompleteForNewUser(transaction: transaction)
}
attachmentDownloadmanager.beginDownloadingIfNecessary()
// Schedule a Cron run if we're in the foreground.
@ -1908,9 +1942,15 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
Task { @MainActor [appReadiness] () -> Void in
defer { completionHandler() }
try await self.appReadiness.waitForAppReady()
do {
try await self.appReadiness.waitForAppReady()
} catch {
return
}
let screenLockUI = AppEnvironment.shared.screenLockUI
let backgroundMessageFetcherFactory = DependenciesBridge.shared.backgroundMessageFetcherFactory
let backgroundMessageFetcher = backgroundMessageFetcherFactory.buildFetcher()
// So that we open up a connection for replies.
await backgroundMessageFetcher.start()
@ -1919,7 +1959,11 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
let elapsedDuration = (MonotonicDate() - startDate).seconds
try await withCooperativeTimeout(seconds: 27 - elapsedDuration) {
// Do the actual thing we care about.
try await NotificationActionHandler.handleNotificationResponse(response, appReadiness: appReadiness)
try await NotificationActionHandler.handleNotificationResponse(
response,
appReadiness: appReadiness,
screenLockUI: screenLockUI,
)
// Then wait for any enqueued messages (e.g., read receipts) to be sent.
try await backgroundMessageFetcher.waitForFetchingProcessingAndSideEffects()

View File

@ -22,12 +22,12 @@ public class AppEnvironment: NSObject {
@MainActor
var ownedObjects = [AnyObject]()
let cvAudioPlayerRef: CVAudioPlayer
let deviceTransferServiceRef: DeviceTransferService
let pushRegistrationManagerRef: PushRegistrationManager
let cvAudioPlayerRef = CVAudioPlayer()
let speechManagerRef = SpeechManager()
let windowManagerRef = WindowManager()
let screenLockUI: ScreenLockUI
let speechManagerRef: SpeechManager
let windowManagerRef: WindowManager
private(set) var appIconBadgeUpdater: AppIconBadgeUpdater!
private(set) var avatarHistoryManager: AvatarHistoryManager!
@ -44,8 +44,12 @@ public class AppEnvironment: NSObject {
private var registrationIdMismatchManager: RegistrationIdMismatchManager!
init(appReadiness: AppReadiness, deviceTransferService: DeviceTransferService) {
self.cvAudioPlayerRef = CVAudioPlayer()
self.deviceTransferServiceRef = deviceTransferService
self.screenLockUI = ScreenLockUI(appReadiness: appReadiness)
self.pushRegistrationManagerRef = PushRegistrationManager(appReadiness: appReadiness)
self.speechManagerRef = SpeechManager()
self.windowManagerRef = WindowManager()
super.init()
@ -193,6 +197,51 @@ public class AppEnvironment: NSObject {
operation: { try await identityKeyMismatchManager.validateLocalPniIdentityKeyIfNecessary() },
)
let backupSubscriptionManager = DependenciesBridge.shared.backupSubscriptionManager
cron.scheduleFrequently(
mustBeRegistered: true,
mustBeConnected: true,
operation: { try await backupSubscriptionManager.redeemSubscriptionIfNecessary() },
handleResult: {
switch $0 {
case .success, .failure(is CancellationError):
break
case .failure(let error):
Logger.warn("Terminally failed to redeem Backups subscription! \(error)")
}
},
)
let backupTestFlightEntitlementManager = DependenciesBridge.shared.backupTestFlightEntitlementManager
cron.scheduleFrequently(
mustBeRegistered: true,
mustBeConnected: true,
operation: { try await backupTestFlightEntitlementManager.renewEntitlementIfNecessary() },
handleResult: {
switch $0 {
case .success, .failure(is CancellationError):
break
case .failure(let error):
Logger.warn("Terminally failed to redeem Backups TestFlight subscription! \(error)")
}
},
)
let donationSubscriptionManager = DependenciesBridge.shared.donationSubscriptionManager
cron.scheduleFrequently(
mustBeRegistered: true,
mustBeConnected: true,
operation: { try await donationSubscriptionManager.redeemSubscriptionIfNecessary() },
handleResult: {
switch $0 {
case .success, .failure(is CancellationError):
break
case .failure(let error):
Logger.warn("Terminally failed to redeem Donations subscription! \(error)")
}
},
)
appReadiness.runNowOrWhenAppWillBecomeReady {
self.badgeManager.startObservingChanges(in: DependenciesBridge.shared.databaseChangeObserver)
self.appIconBadgeUpdater.startObserving()
@ -203,14 +252,11 @@ public class AppEnvironment: NSObject {
let attachmentBackfillManager = DependenciesBridge.shared.attachmentBackfillManager
let backupExportJobRunner = DependenciesBridge.shared.backupExportJobRunner
let backupIdService = DependenciesBridge.shared.backupIdService
let backupSubscriptionManager = DependenciesBridge.shared.backupSubscriptionManager
let backupTestFlightEntitlementManager = DependenciesBridge.shared.backupTestFlightEntitlementManager
let callRecordStore = DependenciesBridge.shared.callRecordStore
let callRecordQuerier = DependenciesBridge.shared.callRecordQuerier
let db = DependenciesBridge.shared.db
let groupCallPeekClient = SSKEnvironment.shared.groupCallManagerRef.groupCallPeekClient
let interactionStore = DependenciesBridge.shared.interactionStore
let masterKeySyncManager = DependenciesBridge.shared.masterKeySyncManager
let notificationPresenter = SSKEnvironment.shared.notificationPresenterRef
let recipientDatabaseTable = DependenciesBridge.shared.recipientDatabaseTable
let storageServiceManager = SSKEnvironment.shared.storageServiceManagerRef
@ -241,11 +287,7 @@ public class AppEnvironment: NSObject {
// Things that should run on either the primary or linked devices.
if let registeredState, registeredState.isPrimary {
Task {
do {
try await avatarDefaultColorStorageServiceMigrator.performMigrationIfNecessary()
} catch {
Logger.warn("Couldn't perform avatar default color migration: \(error)")
}
await avatarDefaultColorStorageServiceMigrator.performMigrationIfNecessary()
}
Task {
@ -286,12 +328,6 @@ public class AppEnvironment: NSObject {
} else {
}
Task {
await db.awaitableWrite { tx in
masterKeySyncManager.runStartupJobs(tx: tx)
}
}
Task {
await db.awaitableWrite { tx in
groupCallRecordRingingCleanupManager.cleanupRingingCalls(tx: tx)
@ -305,31 +341,6 @@ public class AppEnvironment: NSObject {
Task {
await self.avatarHistoryManager.cleanupOrphanedImages()
}
Task {
do {
try await backupSubscriptionManager.redeemSubscriptionIfNecessary()
} catch {
owsFailDebug("Failed to redeem Backup subscription in launch job: \(error)")
}
}
Task {
do {
try await backupTestFlightEntitlementManager.renewEntitlementIfNecessary()
} catch {
owsFailDebug("Failed to renew Backup entitlement for TestFlight in launch job: \(error)")
}
}
Task {
await DonationSubscriptionManager.performMigrationToStorageServiceIfNecessary()
do {
try await DonationSubscriptionManager.redeemSubscriptionIfNecessary()
} catch {
owsFailDebug("Failed to redeem subscription in launch job: \(error)")
}
}
}
}
}

View File

@ -128,6 +128,7 @@ public class SignalApp {
owsFailDebug("Missing conversationSplitViewController.")
return
}
conversationSplitViewController.showAppSettingsWithMode(mode, completion: completion)
}
@ -223,11 +224,6 @@ public class SignalApp {
Logger.info("")
// If there's a presented blocking splash, but the user is trying to open a
// thread, dismiss it. We'll try again next time they open the app. We
// don't want to block them from accessing their conversations.
ExperienceUpgradeManager.dismissSplashWithoutCompletingIfNecessary()
if let visibleThread = conversationSplitViewController.visibleThread, visibleThread.uniqueId == threadUniqueId {
AppEnvironment.shared.windowManagerRef.minimizeCallIfNeeded()
conversationSplitViewController.selectedConversationViewController?.scrollToInitialPosition(animated: animated)

View File

@ -32,8 +32,8 @@ struct AvatarDefaultColorStorageServiceMigrator {
self.threadStore = threadStore
}
func performMigrationIfNecessary() async throws {
try await db.awaitableWrite { tx in
func performMigrationIfNecessary() async {
await db.awaitableWrite { tx in
if kvStore.hasValue(StoreKeys.hasEnqueuedMigrationKey, transaction: tx) {
return
}
@ -46,15 +46,14 @@ struct AvatarDefaultColorStorageServiceMigrator {
}
var groupV2MasterKeys = [GroupMasterKey]()
try threadStore.enumerateGroupThreads(tx: tx) { groupThread in
guard
threadStore.enumerateGroupThreads(tx: tx) { groupThread in
if
let groupModelV2 = groupThread.groupModel as? TSGroupModelV2,
let groupMasterKey = try? groupModelV2.masterKey()
else {
return true
{
groupV2MasterKeys.append(groupMasterKey)
}
groupV2MasterKeys.append(groupMasterKey)
return true
}

View File

@ -225,7 +225,6 @@ final class BackupDisablingManager {
accountEntropyPoolManager.setAccountEntropyPool(
newAccountEntropyPool: try! AccountEntropyPool(key: aepBeingRotatedString),
disablePIN: false,
tx: tx,
)
}

View File

@ -263,7 +263,10 @@ final class BackupEnablingManager {
private func enablePaidPlanWithoutStoreKit() async throws(SheetDisplayableError) {
do {
try await backupTestFlightEntitlementManager.acquireEntitlement()
await db.awaitableWrite { tx in
backupTestFlightEntitlementManager.setRenewEntitlementIsNecessary(tx: tx)
}
try await backupTestFlightEntitlementManager.renewEntitlementIfNecessary()
} catch where error.isNetworkFailureOrTimeout {
throw .networkError
} catch {

View File

@ -0,0 +1,45 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
final class BackupNeverShareRecoveryKeySheet: HeroSheetViewController {
init(
primaryButton: HeroSheetViewController.Button,
secondaryButton: HeroSheetViewController.Button?,
) {
let bodyText: NSAttributedString = NSAttributedString.composed(of: [
OWSLocalizedString(
"BACKUP_NEVER_SHARE_RECOVERY_KEY_SHEET_BODY",
comment: "Body for a warning sheet shown to discourage the user from sharing their 'Recovery Key', warning them not to share it with anyone.",
).styled(
with: .xmlRules([.style("bold", StringStyle(.font(.dynamicTypeSubheadline.bold())))]),
),
" ",
CommonStrings.learnMore.styled(
with: .link(.Support.phishingPrevention),
),
])
super.init(
hero: .circleIcon(
icon: .errorTriangle,
iconSize: 40,
tintColor: .Signal.red,
backgroundColor: UIColor(rgbHex: 0xF8E0D9),
),
title: OWSLocalizedString(
"BACKUP_NEVER_SHARE_RECOVERY_KEY_SHEET_TITLE",
comment: "Title for a warning sheet shown to discourage the user from sharing their 'Recovery Key'.",
),
body: HeroSheetViewController.Body(
textContent: .attributed(bodyText),
),
primary: .button(primaryButton),
secondary: secondaryButton.map { .button($0) },
)
}
}

View File

@ -0,0 +1,105 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SwiftUI
struct BackupPlanOptionView: View {
struct BulletPoint {
let icon: UIImage
let text: String
}
let title: String
let subtitle: String
let bullets: [BulletPoint]
let isCurrentPlan: Bool
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(alignment: .top) {
VStack(alignment: .leading) {
if isCurrentPlan {
Label(
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_CURRENT_PLAN_LABEL",
comment: "A label indicating that a given Backup plan option is what the user has already enabled.",
),
systemImage: "checkmark",
)
.font(.footnote)
.foregroundStyle(Color.Signal.secondaryLabel)
.padding(.vertical, 4)
.padding(.horizontal, 12)
.background {
Capsule().fill(Color.Signal.secondaryFill)
}
}
Text(title)
.font(.headline)
.multilineTextAlignment(.leading)
Text(subtitle).foregroundStyle(Color.Signal.secondaryLabel)
ForEach(bullets, id: \.text) { bullet in
Label {
Text(bullet.text).font(.subheadline)
} icon: {
Image(uiImage: bullet.icon)
.foregroundStyle(
isSelected
? Color.Signal.ultramarine
: Color.Signal.label,
)
}
.padding(.leading, 20)
.padding(.vertical, 2)
}
}
Spacer()
Group {
if isSelected {
Circle()
.fill(Color.Signal.ultramarine)
.overlay {
Image(systemName: "checkmark")
.resizable()
.foregroundColor(.white)
.padding(6)
}
} else {
Circle()
.stroke(Color.Signal.secondaryLabel, lineWidth: 2)
.opacity(0.3)
}
}
.frame(width: 24, height: 24)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 20)
.padding(.leading, 20)
.padding(.trailing, 16)
.background(Color.Signal.secondaryGroupedBackground)
.cornerRadius(16)
.overlay {
RoundedRectangle(cornerRadius: 16)
.stroke(
Color.Signal.ultramarine,
lineWidth: isSelected ? 3 : 0,
)
}
.shadow(
color: isSelected ? .black.opacity(0.12) : .clear,
radius: 8,
y: 2,
)
}
.buttonStyle(.plain)
}
}

View File

@ -77,6 +77,9 @@ class BackupRecordKeyViewController: OWSViewController, OWSNavigationChildContro
override func viewDidLoad() {
super.viewDidLoad()
let screenLockUI = AppEnvironment.shared.screenLockUI
screenLockUI.sensitiveContentDidLoad(inViewController: self)
view.backgroundColor = .Signal.groupedBackground
if let onBackPressedBlock {
@ -115,7 +118,7 @@ class BackupRecordKeyViewController: OWSViewController, OWSNavigationChildContro
comment: "Title for a button allowing users to copy their 'Recovery Key' to the clipboard.",
)),
primaryAction: UIAction { [weak self] _ in
self?.copyToClipboard()
self?.copyToClipboardWithConfirmation()
},
),
]
@ -175,6 +178,26 @@ class BackupRecordKeyViewController: OWSViewController, OWSNavigationChildContro
stackView.setCustomSpacing(32, after: aepTextView)
}
private func copyToClipboardWithConfirmation() {
let warningSheet = BackupNeverShareRecoveryKeySheet(
primaryButton: HeroSheetViewController.Button(
title: OWSLocalizedString(
"BACKUP_RECORD_KEY_COPY_WARNING_SHEET_PRIMARY_BUTTON_TITLE",
comment: "Title for the primary button in a warning sheet shown before copying the user's 'Recovery Key' to the clipboard, which acknowledges the warning and proceeds with the copy.",
),
action: { sheet in
sheet.dismiss(animated: true) { [weak self] in
guard let self else { return }
copyToClipboard()
}
},
),
secondaryButton: nil,
)
present(warningSheet, animated: true)
}
private func copyToClipboard() {
UIPasteboard.general.setItems(
[[UIPasteboard.typeAutomatic: displayableAEP.displayString]],

View File

@ -16,6 +16,7 @@ class BackupSettingsViewController:
enum OnAppearAction {
case presentWelcomeToBackupsSheet
case automaticallyStartBackup(completion: ((UIViewController) -> Void)?)
case disableOptimizeLocalStorage
}
private let accountEntropyPoolManager: AccountEntropyPoolManager
@ -120,7 +121,7 @@ class BackupSettingsViewController:
self.onAppearAction = onAppearAction
switch onAppearAction {
case .presentWelcomeToBackupsSheet, nil:
case nil, .presentWelcomeToBackupsSheet, .disableOptimizeLocalStorage:
break
case .automaticallyStartBackup(let completion):
self.onBackupComplete = completion
@ -179,6 +180,8 @@ class BackupSettingsViewController:
presentWelcomeToBackupsSheet()
case .automaticallyStartBackup:
performManualBackup()
case .disableOptimizeLocalStorage:
setOptimizeLocalStorage(false)
}
}
@ -618,28 +621,87 @@ class BackupSettingsViewController:
final class WelcomeToBackupsSheet: HeroSheetViewController {
override var canBeDismissed: Bool { false }
init(onConfirm: @escaping () -> Void) {
init(
optimizeLocalStorage: (isOn: Bool, onValueChanged: (Bool) -> Void)?,
onConfirm: @escaping (HeroSheetViewController) -> Void,
) {
let toggle: HeroSheetViewController.Body.Toggle?
if let (isOn, onValueChanged) = optimizeLocalStorage {
toggle = HeroSheetViewController.Body.Toggle(
title: OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_OPTIMIZE_MEDIA_TOGGLE_TITLE",
comment: "Title for a toggle shown after the user enables backups, letting them enable the Optimize Storage feature.",
),
footer: OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_OPTIMIZE_MEDIA_TOGGLE_FOOTER",
comment: "Footer for a toggle shown after the user enables backups, letting them enable the Optimize Storage feature.",
),
isOn: isOn,
onValueChanged: onValueChanged,
)
} else {
toggle = nil
}
super.init(
hero: .image(.backupsSubscribed),
title: OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_TITLE",
comment: "Title for a sheet shown after the user enables backups.",
),
body: OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_MESSAGE",
comment: "Message for a sheet shown after the user enables backups.",
),
primaryButton: HeroSheetViewController.Button(
title: CommonStrings.okButton,
action: { _ in onConfirm() },
body: HeroSheetViewController.Body(
textContent: .plain(OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_MESSAGE",
comment: "Message for a sheet shown after the user enables backups.",
)),
toggle: toggle,
),
primary: .button(HeroSheetViewController.Button(
title: OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_BUTTON_TITLE",
comment: "Title for a button in a sheet shown after the user enables backups.",
),
action: { onConfirm($0) },
)),
secondary: nil,
)
}
}
let welcomeToBackupsSheet = WelcomeToBackupsSheet { [self] in
viewModel.performManualBackup()
dismiss(animated: true)
let backupPlan = db.read { tx in
backupPlanManager.backupPlan(tx: tx)
}
let welcomeToBackupsSheet: WelcomeToBackupsSheet
switch backupPlan {
case .disabled,
.disabling,
.free:
welcomeToBackupsSheet = WelcomeToBackupsSheet(
optimizeLocalStorage: nil,
onConfirm: { sheet in
sheet.dismiss(animated: true) { [self] in
viewModel.performManualBackup()
}
},
)
case .paid,
.paidAsTester,
.paidExpiringSoon:
var isOptimizeStorageEnabled = false
welcomeToBackupsSheet = WelcomeToBackupsSheet(
optimizeLocalStorage: (
isOn: isOptimizeStorageEnabled,
onValueChanged: { isOptimizeStorageEnabled = $0 },
),
onConfirm: { sheet in
sheet.dismiss(animated: true) { [self] in
setOptimizeLocalStorage(isOptimizeStorageEnabled)
viewModel.performManualBackup()
}
},
)
}
present(welcomeToBackupsSheet, animated: true)
@ -972,6 +1034,15 @@ class BackupSettingsViewController:
))
actionSheet.addAction(.cancel)
case BackupArchive.Response.BackupUploadFormError.tooLarge:
actionSheet = ActionSheetController(
message: OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_EXPORT_ERROR_SHEET_FILE_TOO_LARGE",
comment: "Message for an action sheet explaining that performing a backup failed because the backup file is too large to upload.",
),
)
actionSheet.addAction(.okay)
case _ where error.isNetworkFailureOrTimeout || error.is5xxServiceResponse:
actionSheet = ActionSheetController(
message: OWSLocalizedString(
@ -1009,35 +1080,38 @@ class BackupSettingsViewController:
// MARK: -
fileprivate func setOptimizeLocalStorage(_ newOptimizeLocalStorage: Bool) {
let isPaidPlanTester: Bool = db.write { tx in
let hasMadeAtLeastOneBackup: Bool? = db.write { tx in
let currentBackupPlan = backupPlanManager.backupPlan(tx: tx)
let newBackupPlan: BackupPlan
let isPaidPlanTester: Bool
let lastBackupDetails = backupSettingsStore.lastBackupDetails(tx: tx)
let newBackupPlan: BackupPlan
switch currentBackupPlan {
case .disabled, .disabling, .free:
owsFailDebug("Shouldn't be setting Optimize Local Storage: \(currentBackupPlan)")
return false
case .disabled,
.disabling,
.free,
.paid(optimizeLocalStorage: newOptimizeLocalStorage),
.paidExpiringSoon(optimizeLocalStorage: newOptimizeLocalStorage),
.paidAsTester(optimizeLocalStorage: newOptimizeLocalStorage):
return nil
case .paid:
newBackupPlan = .paid(optimizeLocalStorage: newOptimizeLocalStorage)
isPaidPlanTester = false
case .paidExpiringSoon:
newBackupPlan = .paidExpiringSoon(optimizeLocalStorage: newOptimizeLocalStorage)
isPaidPlanTester = false
case .paidAsTester:
newBackupPlan = .paidAsTester(optimizeLocalStorage: newOptimizeLocalStorage)
isPaidPlanTester = true
}
backupPlanManager.setBackupPlan(newBackupPlan, tx: tx)
return isPaidPlanTester
return lastBackupDetails != nil
}
// If disabling Optimize Local Storage, offer to start downloads now.
if !newOptimizeLocalStorage {
if
hasMadeAtLeastOneBackup == true,
!newOptimizeLocalStorage
{
// If disabling Optimize Local Storage with media potentially
// offloaded, offer to start downloads now.
showDownloadOffloadedMediaSheet()
} else if isPaidPlanTester {
showOffloadedMediaForTestersWarningSheet(onAcknowledge: {})
}
}
@ -1076,54 +1150,41 @@ class BackupSettingsViewController:
presentActionSheet(actionSheet)
}
private func showOffloadedMediaForTestersWarningSheet(
onAcknowledge: @escaping () -> Void,
) {
let actionSheet = ActionSheetController(
title: OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TESTER_WARNING_SHEET_TITLE",
comment: "Title for an action sheet warning users who are testers about the Optimize Local Storage feature.",
),
message: OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TESTER_WARNING_SHEET_MESSAGE",
comment: "Message for an action sheet warning users who are testers about the Optimize Local Storage feature.",
),
)
actionSheet.addAction(ActionSheetAction(
title: CommonStrings.okButton,
handler: { _ in
onAcknowledge()
},
))
presentActionSheet(actionSheet)
}
// MARK: -
fileprivate func setIsBackupDownloadQueueSuspended(_ isSuspended: Bool, backupPlan: BackupPlan) {
if isSuspended {
let warningTitle: String?
let warningMessage: String?
switch backupPlan {
case .disabled, .disabling, .free, .paid:
db.write { tx in
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
}
case .paidAsTester:
showOffloadedMediaForTestersWarningSheet(onAcknowledge: { [self] in
db.write { tx in
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
}
})
case .paidExpiringSoon:
case .disabled, .disabling:
warningTitle = OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_DISABLING_WARNING_SHEET_TITLE",
comment: "Title for a sheet warning the user about skipping downloads while disabling Backups.",
)
warningMessage = OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_DISABLING_WARNING_SHEET_MESSAGE",
comment: "Message for a sheet warning the user about skipping downloads while disabling Backups.",
)
case .free, .paidExpiringSoon:
warningTitle = OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_EXPIRING_WARNING_SHEET_TITLE",
comment: "Title for a sheet warning the user about skipping downloads that will expire.",
)
warningMessage = OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_EXPIRING_WARNING_SHEET_MESSAGE",
comment: "Message for a sheet warning the user about skipping downloads that will expire.",
)
case .paid, .paidAsTester:
warningTitle = nil
warningMessage = nil
}
if let warningTitle, let warningMessage {
let warningSheet = ActionSheetController(
title: OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_WARNING_SHEET_TITLE",
comment: "Title for a sheet warning the user about skipping downloads.",
),
message: OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_WARNING_SHEET_MESSAGE",
comment: "Message for a sheet warning the user about skipping downloads.",
),
title: warningTitle,
message: warningMessage,
)
warningSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
@ -1132,9 +1193,31 @@ class BackupSettingsViewController:
),
style: .destructive,
handler: { [self] _ in
db.write { tx in
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
}
let secondWarningSheet = ActionSheetController(
title: OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_SECOND_WARNING_SHEET_TITLE",
comment: "Title for a double-confirmation sheet warning the user about skipping downloads.",
),
message: OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_SECOND_WARNING_SHEET_MESSAGE",
comment: "Message for a double-confirmation sheet warning the user about skipping downloads.",
),
)
secondWarningSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_SECOND_WARNING_SHEET_ACTION_SKIP",
comment: "Title for an action in a double-confirmation sheet warning the user about skipping downloads.",
),
style: .destructive,
handler: { [self] _ in
db.write { tx in
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
}
},
))
secondWarningSheet.addAction(.cancel)
presentActionSheet(secondWarningSheet)
},
))
warningSheet.addAction(ActionSheetAction(
@ -1149,6 +1232,10 @@ class BackupSettingsViewController:
warningSheet.addAction(.cancel)
presentActionSheet(warningSheet)
} else {
db.write { tx in
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
}
}
} else {
db.write { tx in
@ -1352,10 +1439,10 @@ class BackupSettingsViewController:
onConfirmed: { [weak self] _ in
guard let self else { return }
self.finalizeNewRecoveryKey(newCandidateAEP: newCandidateAEP)
// Pop all the way back to Backup Settings.
navigationController?.popToViewController(self, animated: true) {
self.finalizeNewRecoveryKey(newCandidateAEP: newCandidateAEP)
self.presentToast(text: OWSLocalizedString(
"BACKUP_SETTINGS_CREATE_NEW_KEY_SUCCESS_TOAST",
comment: "Toast shown when a new Recovery Key has been created successfully.",
@ -1381,7 +1468,6 @@ class BackupSettingsViewController:
accountEntropyPoolManager.setAccountEntropyPool(
newAccountEntropyPool: newCandidateAEP,
disablePIN: false,
tx: tx,
)
case .disabling, .free, .paid, .paidExpiringSoon, .paidAsTester:
@ -1624,16 +1710,21 @@ private class BackupSettingsViewModel: ObservableObject {
// MARK: -
var optimizeLocalStorageAvailable: Bool {
/// Whether the "Optimze Storage" feature is available, per the current
/// `BackupPlan`.
var isOptimizeLocalStorageAvailable: Bool {
switch backupPlan {
case .disabled, .disabling, .free:
false
case .paid, .paidExpiringSoon, .paidAsTester:
case .paid, .paidAsTester:
true
case .paidExpiringSoon(let optimizeLocalStorage):
// Only allow disabling Optimize Storage if expiring soon, not enabling.
optimizeLocalStorage
}
}
var optimizeLocalStorage: Bool {
var isOptimizeLocalStorageEnabled: Bool {
switch backupPlan {
case .disabled, .disabling, .free:
false
@ -1906,44 +1997,32 @@ struct BackupSettingsView: View {
viewModel: viewModel,
)
if BuildFlags.Backups.showOptimizeMedia {
Toggle(
OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_TITLE",
comment: "Title for a toggle allowing users to change the Optimize Local Storage setting.",
),
isOn: Binding(
get: { viewModel.optimizeLocalStorage },
set: { viewModel.setOptimizeLocalStorage($0) },
),
).disabled(!viewModel.optimizeLocalStorageAvailable)
}
Toggle(
OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_TITLE",
comment: "Title for a toggle allowing users to change the Optimize Local Storage setting.",
),
isOn: Binding(
get: { viewModel.isOptimizeLocalStorageEnabled },
set: { viewModel.setOptimizeLocalStorage($0) },
),
).disabled(!viewModel.isOptimizeLocalStorageAvailable)
} footer: {
if BuildFlags.Backups.showOptimizeMedia {
let footerText: String = if
viewModel.optimizeLocalStorageAvailable,
viewModel.isPaidPlanTester
{
OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_AVAILABLE_FOR_TESTERS",
comment: "Footer for a toggle allowing users to change the Optimize Local Storage setting, if the toggle is available and they are a tester.",
)
} else if viewModel.optimizeLocalStorageAvailable {
OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_AVAILABLE",
comment: "Footer for a toggle allowing users to change the Optimize Local Storage setting, if the toggle is available.",
)
} else {
OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_UNAVAILABLE",
comment: "Footer for a toggle allowing users to change the Optimize Local Storage setting, if the toggle is unavailable.",
)
}
Text(footerText)
.foregroundStyle(Color.Signal.secondaryLabel)
.font(.caption)
let footerText: String = if viewModel.isOptimizeLocalStorageAvailable {
OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_AVAILABLE",
comment: "Footer for a toggle allowing users to change the Optimize Local Storage setting, if the toggle is available.",
)
} else {
OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_UNAVAILABLE",
comment: "Footer for a toggle allowing users to change the Optimize Local Storage setting, if the toggle is unavailable.",
)
}
Text(footerText)
.foregroundStyle(Color.Signal.secondaryLabel)
.font(.caption)
}
SignalSection {
@ -3200,7 +3279,8 @@ private extension BackupSettingsViewModel {
backupSubscriptionLoadingState: .loaded(.paidButExpiring(
expirationDate: Date().addingTimeInterval(.week),
)),
backupPlan: .paidExpiringSoon(optimizeLocalStorage: false),
backupPlan: .paidExpiringSoon(optimizeLocalStorage: true),
latestBackupAttachmentDownloadUpdateState: .suspended,
))
}

View File

@ -8,7 +8,10 @@ import SignalUI
import StoreKit
import SwiftUI
class ChooseBackupPlanViewController: HostingController<ChooseBackupPlanView> {
class ChooseBackupPlanViewController:
HostingController<ChooseBackupPlanView>,
ChooseBackupPlanViewModel.ActionsDelegate
{
typealias OnConfirmPlanSelectionBlock = (ChooseBackupPlanViewController, PlanSelection) -> Void
enum StoreKitAvailability {
@ -118,11 +121,9 @@ class ChooseBackupPlanViewController: HostingController<ChooseBackupPlanView> {
onConfirmPlanSelectionBlock: onConfirmPlanSelectionBlock,
)
}
}
// MARK: - ChooseBackupPlanViewModel.ActionsDelegate
// MARK: - ChooseBackupPlanViewModel.ActionsDelegate
extension ChooseBackupPlanViewController: ChooseBackupPlanViewModel.ActionsDelegate {
fileprivate func confirmSelection(_ planSelection: PlanSelection) {
switch (initialPlanSelection, planSelection) {
case (.free, .free), (.paid, .paid):
@ -233,7 +234,7 @@ struct ChooseBackupPlanView: View {
Spacer().frame(height: 20)
PlanOptionView(
BackupPlanOptionView(
title: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_FREE_PLAN_TITLE",
comment: "Title for the free plan option, when choosing a Backup plan.",
@ -247,11 +248,11 @@ struct ChooseBackupPlanView: View {
viewModel.freeMediaTierDays,
),
bullets: [
PlanOptionView.BulletPoint(iconKey: "thread", text: OWSLocalizedString(
BackupPlanOptionView.BulletPoint(icon: .thread, text: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_FULL_TEXT_BACKUP",
comment: "Text for a bullet point in a list of Backup features, describing that all text messages are included.",
)),
PlanOptionView.BulletPoint(iconKey: "album-tilt", text: String.localizedStringWithFormat(
BackupPlanOptionView.BulletPoint(icon: .albumTilt, text: String.localizedStringWithFormat(
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_RECENT_MEDIA_BACKUP_%d",
tableName: "PluralAware",
@ -269,7 +270,7 @@ struct ChooseBackupPlanView: View {
Spacer().frame(height: 16)
PlanOptionView(
BackupPlanOptionView(
title: {
switch viewModel.storeKitAvailability {
case .available(let paidPlanDisplayPrice):
@ -292,15 +293,15 @@ struct ChooseBackupPlanView: View {
comment: "Subtitle for the paid plan option, when choosing a Backup plan.",
),
bullets: [
PlanOptionView.BulletPoint(iconKey: "thread", text: OWSLocalizedString(
BackupPlanOptionView.BulletPoint(icon: .thread, text: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_FULL_TEXT_BACKUP",
comment: "Text for a bullet point in a list of Backup features, describing that all text messages are included.",
)),
PlanOptionView.BulletPoint(iconKey: "album-tilt", text: OWSLocalizedString(
BackupPlanOptionView.BulletPoint(icon: .albumTilt, text: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_FULL_MEDIA_BACKUP",
comment: "Text for a bullet point in a list of Backup features, describing that all media is included.",
)),
PlanOptionView.BulletPoint(iconKey: "data", text: String.nonPluralLocalizedStringWithFormat(
BackupPlanOptionView.BulletPoint(icon: .data, text: String.nonPluralLocalizedStringWithFormat(
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_STORAGE_AMOUNT",
comment: "Text for a bullet point in a list of Backup features, describing the amount of included storage. Embeds {{ the amount of storage preformatted as a localized byte count, e.g. '100 GB' }}.",
@ -383,106 +384,6 @@ struct ChooseBackupPlanView: View {
// MARK: -
private struct PlanOptionView: View {
struct BulletPoint {
let iconKey: String
let text: String
}
let title: String
let subtitle: String
let bullets: [BulletPoint]
let isCurrentPlan: Bool
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(alignment: .top) {
VStack(alignment: .leading) {
if isCurrentPlan {
Label(
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_CURRENT_PLAN_LABEL",
comment: "A label indicating that a given Backup plan option is what the user has already enabled.",
),
systemImage: "checkmark",
)
.font(.footnote)
.foregroundStyle(Color.Signal.secondaryLabel)
.padding(.vertical, 4)
.padding(.horizontal, 12)
.background {
Capsule().fill(Color.Signal.secondaryFill)
}
}
Text(title)
.font(.headline)
.multilineTextAlignment(.leading)
Text(subtitle).foregroundStyle(Color.Signal.secondaryLabel)
ForEach(bullets, id: \.iconKey) { bullet in
Label {
Text(bullet.text).font(.subheadline)
} icon: {
Image(bullet.iconKey)
.foregroundStyle(
isSelected
? Color.Signal.ultramarine
: Color.Signal.label,
)
}
.padding(.leading, 20)
.padding(.vertical, 2)
}
}
Spacer()
Group {
if isSelected {
Circle()
.fill(Color.Signal.ultramarine)
.overlay {
Image(systemName: "checkmark")
.resizable()
.foregroundColor(.white)
.padding(6)
}
} else {
Circle()
.stroke(Color.Signal.secondaryLabel, lineWidth: 2)
.opacity(0.3)
}
}
.frame(width: 24, height: 24)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 20)
.padding(.leading, 20)
.padding(.trailing, 16)
.background(Color.Signal.secondaryGroupedBackground)
.cornerRadius(16)
.overlay {
RoundedRectangle(cornerRadius: 16)
.stroke(
Color.Signal.ultramarine,
lineWidth: isSelected ? 3 : 0,
)
}
.shadow(
color: isSelected ? .black.opacity(0.12) : .clear,
radius: 8,
y: 2,
)
}
.buttonStyle(.plain)
}
}
// MARK: -
#if DEBUG
private extension ChooseBackupPlanViewModel {

View File

@ -31,9 +31,8 @@ struct DisplayableAccountEntropyPool {
.uppercased()
.map { char in
switch char {
// TODO: Reenable this once support is available for all platforms
// case "0": "="
// case "O", "o": "#"
case "0": "="
case "O", "o": "#"
default: char
}
},

View File

@ -51,6 +51,9 @@ class EnterAccountEntropyPoolViewController: OWSViewController {
override func viewDidLoad() {
super.viewDidLoad()
let screenLockUI = AppEnvironment.shared.screenLockUI
screenLockUI.sensitiveContentDidLoad(inViewController: self)
view.backgroundColor = colorConfig.background
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: CommonStrings.nextButton,

View File

@ -71,6 +71,7 @@ struct BackupOnboardingIntroView: View {
Image(.backupsLogo)
.frame(width: 80, height: 80)
.accessibilityHidden(true)
Spacer().frame(height: 16)
HStack {

View File

@ -9,7 +9,7 @@ import SignalServiceKit
final class AdHocCallStateObserver {
private let adHocCallRecordManager: any AdHocCallRecordManager
private let callLinkStore: any CallLinkRecordStore
private let callLinkStore: CallLinkRecordStore
private let db: any DB
private let messageSenderJobQueue: MessageSenderJobQueue
@ -32,7 +32,7 @@ final class AdHocCallStateObserver {
init(
callLinkCall: CallLinkCall,
adHocCallRecordManager: any AdHocCallRecordManager,
callLinkStore: any CallLinkRecordStore,
callLinkStore: CallLinkRecordStore,
messageSenderJobQueue: MessageSenderJobQueue,
db: any DB,
) {
@ -62,33 +62,29 @@ final class AdHocCallStateObserver {
}
self.furthestJoinLevel = joinLevel
db.write { tx in
do {
let rootKey = callLinkCall.callLink.rootKey
var (callLink, inserted) = try callLinkStore.fetchOrInsert(rootKey: rootKey, tx: tx)
if inserted {
callLink.updateState(callLinkCall.callLinkState)
try callLinkStore.update(callLink, tx: tx)
}
if callLink.adminPasskey == nil, !callLink.isDeleted {
let updateSender = CallLinkUpdateMessageSender(messageSenderJobQueue: messageSenderJobQueue)
updateSender.sendCallLinkUpdateMessage(rootKey: rootKey, adminPasskey: nil, tx: tx)
}
try adHocCallRecordManager.createOrUpdateRecord(
callId: callIdFromEra(eraId),
callLink: callLink,
status: { () -> CallRecord.CallStatus.CallLinkCallStatus in
switch joinLevel {
case .attempted: return .generic
case .joined: return .joined
}
}(),
timestamp: Date.ows_millisecondTimestamp(),
shouldSendSyncMessge: true,
tx: tx,
)
} catch {
owsFailDebug("Couldn't update CallRecord: \(error)")
let rootKey = callLinkCall.callLink.rootKey
var (callLink, inserted) = callLinkStore.fetchOrInsert(rootKey: rootKey, tx: tx)
if inserted {
callLink.updateState(callLinkCall.callLinkState)
callLinkStore.update(callLink, tx: tx)
}
if callLink.adminPasskey == nil, !callLink.isDeleted {
let updateSender = CallLinkUpdateMessageSender(messageSenderJobQueue: messageSenderJobQueue)
updateSender.sendCallLinkUpdateMessage(rootKey: rootKey, adminPasskey: nil, tx: tx)
}
adHocCallRecordManager.createOrUpdateRecord(
callId: callIdFromEra(eraId),
callLink: callLink,
status: { () -> CallRecord.CallStatus.CallLinkCallStatus in
switch joinLevel {
case .attempted: return .generic
case .joined: return .joined
}
}(),
timestamp: Date.ows_millisecondTimestamp(),
shouldSendSyncMessge: true,
tx: tx,
)
}
}
@ -105,15 +101,11 @@ final class AdHocCallStateObserver {
}
self.activeEraId = .some(peekInfo.eraId)
db.write { tx in
do {
try adHocCallRecordManager.handlePeekResult(
eraId: peekInfo.eraId,
rootKey: self.callLinkCall.callLink.rootKey,
tx: tx,
)
} catch {
owsFailDebug("\(error)")
}
adHocCallRecordManager.handlePeekResult(
eraId: peekInfo.eraId,
rootKey: self.callLinkCall.callLink.rootKey,
tx: tx,
)
}
}
}

View File

@ -8,12 +8,12 @@ import SignalServiceKit
/// Refreshes call links that need to be updated.
actor CallLinkFetchJobRunner: DatabaseChangeDelegate {
private let callLinkStore: any CallLinkRecordStore
private let callLinkStore: CallLinkRecordStore
private let callLinkStateUpdater: CallLinkStateUpdater
private let db: any DB
init(
callLinkStore: any CallLinkRecordStore,
callLinkStore: CallLinkRecordStore,
callLinkStateUpdater: CallLinkStateUpdater,
db: any DB,
) {
@ -52,13 +52,8 @@ actor CallLinkFetchJobRunner: DatabaseChangeDelegate {
var sequentialFailureCount = 0
while true {
let callLinkToFetch: CallLinkRecord?
do {
callLinkToFetch = try db.read(block: callLinkStore.fetchAnyPendingRecord(tx:))
} catch {
owsFailDebug("Can't fetch pending record: \(error)")
mightHavePendingFetch = false
return
let callLinkToFetch = db.read { tx in
callLinkStore.fetchAnyPendingRecord(tx: tx)
}
guard let callLinkToFetch else {
// Nothing to fetch.

View File

@ -18,7 +18,7 @@ actor CallLinkStateUpdater {
private let authCredentialManager: any AuthCredentialManager
private let callLinkFetcher: CallLinkFetcherImpl
private let callLinkManager: any CallLinkManager
private let callLinkStore: any CallLinkRecordStore
private let callLinkStore: CallLinkRecordStore
private let callRecordDeleteManager: any CallRecordDeleteManager
private let callRecordStore: any CallRecordStore
private let db: any DB
@ -30,7 +30,7 @@ actor CallLinkStateUpdater {
authCredentialManager: any AuthCredentialManager,
callLinkFetcher: CallLinkFetcherImpl,
callLinkManager: any CallLinkManager,
callLinkStore: any CallLinkRecordStore,
callLinkStore: CallLinkRecordStore,
callRecordDeleteManager: any CallRecordDeleteManager,
callRecordStore: any CallRecordStore,
db: any DB,
@ -90,8 +90,8 @@ actor CallLinkStateUpdater {
}
let registeredState = try tsAccountManager.registeredStateWithMaybeSneakyTransaction()
let oldRecord = try db.read { tx -> CallLinkRecord? in
return try callLinkStore.fetch(roomId: roomId, tx: tx)
let oldRecord = db.read { tx -> CallLinkRecord? in
return callLinkStore.fetch(roomId: roomId, tx: tx)
}
let authCredential = try await authCredentialManager.fetchCallLinkAuthCredential(localIdentifiers: registeredState.localIdentifiers)
let updateResult = await Result { try await updateAndFetch(authCredential) }
@ -113,8 +113,8 @@ actor CallLinkStateUpdater {
throw error
}
try await db.awaitableWrite { tx in
if var newRecord = try self.callLinkStore.fetch(roomId: roomId, tx: tx) {
await db.awaitableWrite { tx in
if var newRecord = self.callLinkStore.fetch(roomId: roomId, tx: tx) {
if !newRecord.isDeleted {
switch updateAction {
case .update(let newState):
@ -123,7 +123,7 @@ actor CallLinkStateUpdater {
break
case .delete:
newRecord.markDeleted(atTimestampMs: Date.ows_millisecondTimestamp())
try self.callRecordDeleteManager.deleteCallRecords(
self.callRecordDeleteManager.deleteCallRecords(
self.callRecordStore.fetchExisting(conversationId: .callLink(callLinkRowId: newRecord.id), limit: nil, tx: tx),
sendSyncMessageOnDelete: true,
tx: tx,
@ -133,7 +133,7 @@ actor CallLinkStateUpdater {
if newRecord.pendingFetchCounter == oldRecord?.pendingFetchCounter {
newRecord.clearNeedsFetch()
}
try self.callLinkStore.update(newRecord, tx: tx)
self.callLinkStore.update(newRecord, tx: tx)
}
}

View File

@ -192,13 +192,16 @@ class CallQualitySurveyManager {
return proto
}
func submit(rating: CallQualitySurvey.Rating, shouldSubmitDebugLogs: Bool) {
func submit(
rating: CallQualitySurvey.Rating,
logsToSubmit logs: DebugLogs?,
) {
var proto = buildProto(rating: rating)
Task {
if shouldSubmitDebugLogs {
if let logs {
do {
let debugLogURL = try await DebugLogs.uploadLogs(dumper: .fromGlobals())
let debugLogURL = try await logs.uploadLogs()
proto.debugLogURL = debugLogURL.absoluteString
} catch {
logger.error("Failed to submit debug logs: \(error)")

View File

@ -27,7 +27,7 @@ final class CallService: CallServiceStateObserver, CallServiceStateDelegate {
private var adHocCallRecordManager: any AdHocCallRecordManager { DependenciesBridge.shared.adHocCallRecordManager }
private let appReadiness: AppReadiness
private var audioSession: AudioSession { SUIEnvironment.shared.audioSessionRef }
private var callLinkStore: any CallLinkRecordStore { DependenciesBridge.shared.callLinkStore }
private var callLinkStore: CallLinkRecordStore { DependenciesBridge.shared.callLinkStore }
private var chatConnectionManager: any ChatConnectionManager { DependenciesBridge.shared.chatConnectionManager }
let authCredentialManager: any AuthCredentialManager
private var databaseStorage: SDSDatabaseStorage { SSKEnvironment.shared.databaseStorageRef }
@ -91,7 +91,7 @@ final class CallService: CallServiceStateObserver, CallServiceStateDelegate {
appReadiness: AppReadiness,
authCredentialManager: any AuthCredentialManager,
callLinkPublicParams: GenericServerPublicParams,
callLinkStore: any CallLinkRecordStore,
callLinkStore: CallLinkRecordStore,
callRecordDeleteManager: any CallRecordDeleteManager,
callRecordStore: any CallRecordStore,
callServiceSettingsStore: CallServiceSettingsStore,
@ -658,8 +658,8 @@ final class CallService: CallServiceStateObserver, CallServiceStateDelegate {
}
let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction!
let authCredential = try await authCredentialManager.fetchCallLinkAuthCredential(localIdentifiers: localIdentifiers)
let (adminPasskey, isDeleted) = try databaseStorage.read { tx -> (Data?, Bool) in
let callLinkRecord = try callLinkStore.fetch(roomId: callLink.rootKey.deriveRoomId(), tx: tx)
let (adminPasskey, isDeleted) = databaseStorage.read { tx -> (Data?, Bool) in
let callLinkRecord = callLinkStore.fetch(roomId: callLink.rootKey.deriveRoomId(), tx: tx)
return (callLinkRecord?.adminPasskey, callLinkRecord?.isDeleted == true)
}
let serverPublicParams = CallService.serverPublicParams()

View File

@ -282,20 +282,16 @@ private extension GroupCallRecordManager {
}
logger.info("Creating or updating record for group call join.")
do {
try createOrUpdateCallRecord(
callId: callId,
groupThread: groupThread,
groupThreadRowId: groupThreadRowId,
callDirection: callDirection,
groupCallStatus: groupCallStatus,
callEventTimestamp: joinTimestamp,
shouldSendSyncMessage: true,
tx: tx,
)
} catch let error {
owsFailBeta("Failed to insert call record: \(error)")
}
createOrUpdateCallRecord(
callId: callId,
groupThread: groupThread,
groupThreadRowId: groupThreadRowId,
callDirection: callDirection,
groupCallStatus: groupCallStatus,
callEventTimestamp: joinTimestamp,
shouldSendSyncMessage: true,
tx: tx,
)
}
/// Create or update a call record in response to the local declining a ring
@ -314,19 +310,15 @@ private extension GroupCallRecordManager {
}
logger.info("Creating or updating record for group ring decline.")
do {
try createOrUpdateCallRecord(
callId: callIdFromRingId(ringId),
groupThread: groupThread,
groupThreadRowId: groupThreadRowId,
callDirection: .incoming,
groupCallStatus: .ringingDeclined,
callEventTimestamp: Date().ows_millisecondsSince1970,
shouldSendSyncMessage: true,
tx: tx,
)
} catch let error {
owsFailBeta("Failed to insert call record: \(error)")
}
createOrUpdateCallRecord(
callId: callIdFromRingId(ringId),
groupThread: groupThread,
groupThreadRowId: groupThreadRowId,
callDirection: .incoming,
groupCallStatus: .ringingDeclined,
callEventTimestamp: Date().ows_millisecondsSince1970,
shouldSendSyncMessage: true,
tx: tx,
)
}
}

View File

@ -240,7 +240,7 @@ extension CallControlsOverflowView: MessageReactionPickerDelegate {
self.react(with: reaction)
}
func didSelectAnyEmoji() {
func didSelectShowFullEmojiPicker() {
let sheet = EmojiPickerSheet(
message: nil,
reactionPickerConfigurationListener: self,

View File

@ -290,7 +290,7 @@ class IndividualCallSheetDataSource: CallDrawerSheetDataSource {
isLocalUser: false,
isUnknown: false,
isAudioMuted: self.individualCall.isRemoteAudioMuted,
isVideoMuted: self.individualCall.isRemoteVideoEnabled.negated,
isVideoMuted: !self.individualCall.isRemoteVideoEnabled,
isPresenting: self.individualCall.isRemoteSharingScreen,
))
}

View File

@ -14,7 +14,7 @@ final class CallLinkViewController: OWSTableViewController2 {
override var navbarBackgroundColorOverride: UIColor? { tableBackgroundColor }
private var db: any DB { DependenciesBridge.shared.db }
private var callLinkStore: any CallLinkRecordStore { DependenciesBridge.shared.callLinkStore }
private var callLinkStore: CallLinkRecordStore { DependenciesBridge.shared.callLinkStore }
private let callLink: CallLink
@ -259,14 +259,10 @@ final class CallLinkViewController: OWSTableViewController2 {
private func createCallLinkRecord() -> Int64 {
let rowId = SSKEnvironment.shared.databaseStorageRef.write { tx in
var callLinkRecord: CallLinkRecord
do {
(callLinkRecord, _) = try callLinkStore.fetchOrInsert(rootKey: callLink.rootKey, tx: tx)
callLinkRecord.adminPasskey = adminPasskey!
callLinkRecord.updateState(callLinkState!)
try callLinkStore.update(callLinkRecord, tx: tx)
} catch {
owsFail("Couldn't create CallLinkRecord: \(error)")
}
(callLinkRecord, _) = callLinkStore.fetchOrInsert(rootKey: callLink.rootKey, tx: tx)
callLinkRecord.adminPasskey = adminPasskey!
callLinkRecord.updateState(callLinkState!)
callLinkStore.update(callLinkRecord, tx: tx)
CallLinkUpdateMessageSender(
messageSenderJobQueue: SSKEnvironment.shared.messageSenderJobQueueRef,
@ -333,19 +329,14 @@ final class CallLinkViewController: OWSTableViewController2 {
extension CallLinkViewController: DatabaseChangeDelegate {
private func loadStateAndReloadViewIfNeeded(callLinkRowId: Int64) {
let didChangeVisibleProperty: Bool
do {
let oldState = self.callLinkState
let newState = try self.db.read { tx in try callLinkStore.fetch(rowId: callLinkRowId, tx: tx)?.state }
didChangeVisibleProperty = (
(oldState == nil) != (newState == nil)
|| (oldState?.name != newState?.name)
|| (oldState?.requiresAdminApproval != newState?.requiresAdminApproval),
)
self.callLinkState = newState
} catch {
owsFailDebug("Couldn't fetch CallLink: \(error)")
return
}
let oldState = self.callLinkState
let newState = self.db.read { tx in callLinkStore.fetch(rowId: callLinkRowId, tx: tx)?.state }
didChangeVisibleProperty = (
(oldState == nil) != (newState == nil)
|| (oldState?.name != newState?.name)
|| (oldState?.requiresAdminApproval != newState?.requiresAdminApproval),
)
self.callLinkState = newState
if didChangeVisibleProperty, self.isViewLoaded {
updateContents(shouldReload: true)
}

View File

@ -49,7 +49,7 @@ extension CallsListViewController {
case newer
}
private let callLinkStore: any CallLinkRecordStore
private let callLinkStore: CallLinkRecordStore
private let callRecordLoader: CallRecordLoader
private let callViewModelForCallRecords: CallViewModelForCallRecords
private let callViewModelForUpcomingCallLink: CallViewModelForUpcomingCallLink
@ -59,7 +59,7 @@ extension CallsListViewController {
private let maxCoalescedCallsInOneViewModel: Int
init(
callLinkStore: any CallLinkRecordStore,
callLinkStore: CallLinkRecordStore,
callRecordLoader: CallRecordLoader,
callViewModelForCallRecords: @escaping CallViewModelForCallRecords,
callViewModelForUpcomingCallLink: @escaping CallViewModelForUpcomingCallLink,
@ -233,13 +233,7 @@ extension CallsListViewController {
guard shouldFetchUpcomingCallLinks else {
return
}
let upcomingCallLinks: [CallLinkRecord]
do {
upcomingCallLinks = try callLinkStore.fetchUpcoming(earlierThan: nil, limit: 2048, tx: tx)
} catch {
Logger.warn("Couldn't fetch call links to show on the calls tab: \(error)")
return
}
let upcomingCallLinks = callLinkStore.fetchUpcoming(earlierThan: nil, limit: 2048, tx: tx)
self.upcomingCallLinkReferences = upcomingCallLinks.map {
return UpcomingCallLinkReference(callLinkRowId: $0.id)
}

View File

@ -36,7 +36,7 @@ class CallsListViewController: OWSViewController, HomeTabViewController, CallSer
let adHocCallRecordManager: any AdHocCallRecordManager
let badgeManager: BadgeManager
let blockingManager: BlockingManager
let callLinkStore: any CallLinkRecordStore
let callLinkStore: CallLinkRecordStore
let callRecordDeleteAllJobQueue: CallRecordDeleteAllJobQueue
let callRecordDeleteManager: any CallRecordDeleteManager
let callRecordMissedCallManager: CallRecordMissedCallManager
@ -416,12 +416,12 @@ class CallsListViewController: OWSViewController, HomeTabViewController, CallSer
// because they must first be deleted on the server. (We delete them
// individually at the end of this method.)
let callLinksToDelete: [(rootKey: CallLinkRootKey, adminPasskey: Data)]
callLinksToDelete = (try? self.deps.callLinkStore.fetchAll(tx: tx).compactMap {
callLinksToDelete = self.deps.callLinkStore.fetchAll(tx: tx).compactMap {
guard let adminPasskey = $0.adminPasskey else {
return nil
}
return ($0.rootKey, adminPasskey)
}) ?? []
}
/// Delete-all should use the timestamp of the most-recent call, at
/// the time the action was initiated, as the timestamp we delete
/// before (and include in the outgoing sync message).
@ -720,7 +720,7 @@ class CallsListViewController: OWSViewController, HomeTabViewController, CallSer
// Query the database separately when starting & ending calls because the
// row will usually be inserted during the call (ie `rowId` may be nil when
// starting the call but nonnil when ending the very same call).
let rowId = deps.db.read { tx in try? deps.callLinkStore.fetch(roomId: call.callLink.rootKey.deriveRoomId(), tx: tx)?.id }
let rowId = deps.db.read { tx in deps.callLinkStore.fetch(roomId: call.callLink.rootKey.deriveRoomId(), tx: tx)?.id }
guard let rowId else {
// If you open the lobby for an ongoing call that you've never joined,
// we'll call this method after the peek succeeds. However, you haven't
@ -1015,13 +1015,8 @@ class CallsListViewController: OWSViewController, HomeTabViewController, CallSer
} else {
return nil
}
do {
return try deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx) ?? {
owsFail("Couldn't load CallLinkRecord that must exist!")
}()
} catch {
owsFail("Couldn't load CallLinkRecord that must exist: \(error)")
}
return deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx)
.owsFailUnwrap("FOREIGN KEYs mean this must exist.")
}()
if let callLinkRecord {
@ -1236,8 +1231,8 @@ class CallsListViewController: OWSViewController, HomeTabViewController, CallSer
} catch CallLinkManagerImpl.PeekError.expired, CallLinkManagerImpl.PeekError.invalid {
eraId = nil
}
try await deps.db.awaitableWrite { tx in
try deps.adHocCallRecordManager.handlePeekResult(eraId: eraId, rootKey: rootKey, tx: tx)
await deps.db.awaitableWrite { tx in
deps.adHocCallRecordManager.handlePeekResult(eraId: eraId, rootKey: rootKey, tx: tx)
}
}
@ -2051,15 +2046,9 @@ extension CallsListViewController: CallCellDelegate, NewCallViewControllerDelega
guard let callLinkRowId else {
return false
}
do {
let callLinkRecord = try self.deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx) ?? {
throw OWSAssertionError("Couldn't fetch CallLink that must exist.")
}()
return callLinkRecord.adminPasskey != nil
} catch {
owsFailDebug("\(error)")
return false
}
let callLinkRecord = self.deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx)
.owsFailUnwrap("FOREIGN KEYs mean this must exist.")
return callLinkRecord.adminPasskey != nil
}
}
@ -2069,18 +2058,17 @@ extension CallsListViewController: CallCellDelegate, NewCallViewControllerDelega
// First, delete everything that's local only. This includes thread-based
// calls & any call link calls for which we're not the admin. These
// deletions never fail (except for db corruption-level failures).
callLinksToDelete = try await deps.databaseStorage.awaitableWrite { tx in
callLinksToDelete = await deps.databaseStorage.awaitableWrite { tx in
var callLinksToDelete = [(rootKey: CallLinkRootKey, adminPasskey: Data)]()
var callRecordIdsWithInteractions = [CallRecord.ID]()
for modelReferences in modelReferenceses {
if let callLinkRowId = modelReferences.callLinkRowId {
let callLinkRecord = try self.deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx) ?? {
throw OWSAssertionError("Couldn't fetch CallLink that must exist.")
}()
let callLinkRecord = self.deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx)
.owsFailUnwrap("FOREIGN KEYs mean this must exist.")
if let adminPasskey = callLinkRecord.adminPasskey {
callLinksToDelete.append((callLinkRecord.rootKey, adminPasskey))
} else {
try self.deleteCallRecords(forCallLinkRowId: callLinkRecord.id, tx: tx)
self.deleteCallRecords(forCallLinkRowId: callLinkRecord.id, tx: tx)
}
} else {
callRecordIdsWithInteractions.append(contentsOf: modelReferences.callRecordRowIds)
@ -2107,8 +2095,8 @@ extension CallsListViewController: CallCellDelegate, NewCallViewControllerDelega
try await deleteCallLinks(callLinksToDelete: callLinksToDelete)
}
private nonisolated func deleteCallRecords(forCallLinkRowId callLinkRowId: Int64, tx: DBWriteTransaction) throws {
let callRecords = try deps.callRecordStore.fetchExisting(conversationId: .callLink(callLinkRowId: callLinkRowId), limit: nil, tx: tx)
private nonisolated func deleteCallRecords(forCallLinkRowId callLinkRowId: Int64, tx: DBWriteTransaction) {
let callRecords = deps.callRecordStore.fetchExisting(conversationId: .callLink(callLinkRowId: callLinkRowId), limit: nil, tx: tx)
deps.callRecordDeleteManager.deleteCallRecords(callRecords, sendSyncMessageOnDelete: true, tx: tx)
}
@ -2202,13 +2190,8 @@ extension CallsListViewController: CallCellDelegate, NewCallViewControllerDelega
private func showCallInfo(forRootKey rootKey: CallLinkRootKey, callRecords: [CallRecord]) {
let callLinkRecord = deps.db.read { tx -> CallLinkRecord in
do {
return try deps.callLinkStore.fetch(roomId: rootKey.deriveRoomId(), tx: tx) ?? {
owsFail("Can't fetch CallLinkRecord that must exist.")
}()
} catch {
owsFail("Can't fetch CallLinkRecord: \(error)")
}
return deps.callLinkStore.fetch(roomId: rootKey.deriveRoomId(), tx: tx)
.owsFailUnwrap("FOREIGN KEYs mean this must exist.")
}
showCallInfo(viewController: CallLinkViewController.forExisting(callLinkRecord: callLinkRecord, callRecords: callRecords))
}
@ -2319,23 +2302,14 @@ private extension CallsListViewController {
}()
private func makeStartCallButton(viewModel: CallViewModel) -> UIButton {
var config = UIButton.Configuration.gray()
config.cornerStyle = .capsule
config.background.backgroundInsets = .init(margin: 2)
config.baseBackgroundColor = UIColor.Signal.tertiaryFill
config.baseForegroundColor = UIColor.Signal.label
let icon: ThemeIcon = switch viewModel.medium {
case .audio:
.buttonVoiceCall
case .video, .link:
.buttonVideoCall
}
config.image = Theme.iconImage(icon)
let button = UIButton(
configuration: config,
configuration: .roundGray(image: Theme.iconImage(icon)),
primaryAction: UIAction { [weak self] _ in
self?.detailsTapped(viewModel: viewModel)
},

View File

@ -125,6 +125,7 @@ extension NewCallViewController: RecipientContextMenuHelperDelegate {
// MARK: - RecipientPickerDelegate
extension NewCallViewController: RecipientPickerDelegate, UsernameLinkScanDelegate {
func recipientPicker(
_ recipientPickerViewController: RecipientPickerViewController,
selectionStyleForRecipient recipient: PickedRecipient,
@ -133,7 +134,10 @@ extension NewCallViewController: RecipientPickerDelegate, UsernameLinkScanDelega
return .default
}
func recipientPicker(_ recipientPickerViewController: RecipientPickerViewController, didSelectRecipient recipient: PickedRecipient) {
func recipientPicker(
_ recipientPickerViewController: RecipientPickerViewController,
didSelectRecipient recipient: PickedRecipient,
) {
switch recipient.identifier {
case let .address(address):
let thread = TSContactThread.getOrCreateThread(contactAddress: address)
@ -143,7 +147,12 @@ extension NewCallViewController: RecipientPickerDelegate, UsernameLinkScanDelega
}
}
func recipientPicker(_ recipientPickerViewController: RecipientPickerViewController, accessoryViewForRecipient recipient: PickedRecipient, transaction: DBReadTransaction) -> ContactCellAccessoryView? {
func recipientPicker(
_ recipientPickerViewController: RecipientPickerViewController,
contactCellAccessoryForRecipient recipient: PickedRecipient,
transaction: DBReadTransaction,
) -> ContactCellView.Accessory? {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.spacing = 20

View File

@ -16,10 +16,12 @@ final class SurveyDebugLogViewController: CallQualitySurveySheetViewController {
private let tableViewController = OWSTableViewController2()
private var shouldSubmitDebugLogs = false
private var logs: DebugLogs
private let rating: CallQualitySurvey.Rating
init(rating: CallQualitySurvey.Rating) {
self.logs = DebugLogs(dumper: .fromGlobals())
self.rating = rating
super.init(nibName: nil, bundle: nil)
}
@ -129,7 +131,8 @@ final class SurveyDebugLogViewController: CallQualitySurveySheetViewController {
let container = UIView()
let textView = LinkingTextView { [weak self] in
self?.showDebugLogPreview()
guard let self else { return }
self.logs.showPreview(from: self)
}
textView.attributedText = .composed(of: [
OWSLocalizedString(
@ -193,12 +196,6 @@ final class SurveyDebugLogViewController: CallQualitySurveySheetViewController {
present(nav, animated: true)
}
private func showDebugLogPreview() {
let vc = DebugLogPreviewViewController()
let nav = OWSNavigationController(rootViewController: vc)
present(nav, animated: true)
}
override func customSheetHeight() -> CGFloat? {
let headerHeight = headerContainer.height
let collectionViewHeight = tableViewController.tableView.contentSize.height + tableViewController.tableView.contentInset.totalHeight
@ -209,7 +206,7 @@ final class SurveyDebugLogViewController: CallQualitySurveySheetViewController {
private func submit() {
sheetNav?.submit(
rating: self.rating,
shouldSubmitDebugLogs: self.shouldSubmitDebugLogs,
logsToSubmit: shouldSubmitDebugLogs ? logs : nil,
)
}
}

View File

@ -82,10 +82,13 @@ final class CallQualitySurveyNavigationController: UINavigationController {
pushViewController(vc, animated: false)
}
func submit(rating: CallQualitySurvey.Rating, shouldSubmitDebugLogs: Bool) {
func submit(
rating: CallQualitySurvey.Rating,
logsToSubmit: DebugLogs?,
) {
callQualitySurveyManager.submit(
rating: rating,
shouldSubmitDebugLogs: shouldSubmitDebugLogs,
logsToSubmit: logsToSubmit,
)
let host = presentingViewController
dismiss(animated: true) {

View File

@ -159,11 +159,10 @@ class MessageUserSubsetSheet: OWSTableSheetViewController {
cell.selectionStyle = .none
let configuration = ContactCellConfiguration(address: address, localUserDisplayMode: .asLocalUser)
var configuration = ContactCellView.Configuration(address: address, localUserDisplayMode: .asLocalUser)
configuration.forceDarkAppearance = self?.forceDarkMode ?? false
if
BuildFlags.MemberLabel.display,
let groupThread = self?.groupThread,
let senderAci = address.aci,
let memberLabelString = groupThread.groupModel.groupMembership.memberLabel(for: senderAci)?.labelForRendering(),

View File

@ -149,6 +149,8 @@ public class CVViewState: NSObject {
var unwrappedGiftMessageIds = Set<String>()
// MARK: - Collapse Sets
/// The set of collapse set IDs that have been expanded by the user.
/// Resets to empty when leaving the conversation.
var expandedCollapseSets = Set<String>()

View File

@ -114,9 +114,7 @@ class CVAttachmentProgressView: ManualLayoutView {
addLayoutBlock { view in
guard let view = view as? CVAttachmentProgressView else { return }
DispatchQueue.main.async {
view.loadInitialStateIfNeeded()
}
view.loadInitialStateIfNeeded()
}
}
@ -194,14 +192,19 @@ class CVAttachmentProgressView: ManualLayoutView {
applyState(.tapToDownload, animated: animateStateChange)
case .enqueuedOrDownloading:
applyState(.unknownProgress, animated: animateStateChange)
NotificationCenter.default.addObserver(
self,
selector: #selector(processDownloadNotification(notification:)),
name: AttachmentDownloads.attachmentDownloadProgressNotification,
object: nil,
)
}
NotificationCenter.default.addObserver(
self,
selector: #selector(processDownloadNotification(notification:)),
name: AttachmentDownloads.attachmentDownloadProgressNotification,
object: nil,
)
NotificationCenter.default.addObserver(
self,
selector: #selector(processDownloadStoppedNotification(notification:)),
name: AttachmentDownloads.attachmentDownloadStoppedNotification,
object: nil,
)
}
}
@ -360,6 +363,22 @@ class CVAttachmentProgressView: ManualLayoutView {
applyState(.progress(progress: progress), animated: window != nil)
}
@objc
private func processDownloadStoppedNotification(notification: Notification) {
AssertIsOnMainThread()
guard
let attachmentId = notification.userInfo?[AttachmentDownloads.attachmentDownloadAttachmentIDKey] as? Attachment.IDType
else {
owsFailDebug("Missing notificationAttachmentId.")
return
}
guard attachmentId == self.attachmentId else {
return
}
applyState(.tapToDownload, animated: window != nil)
}
@objc
private func processUploadNotification(notification: Notification) {
AssertIsOnMainThread()

View File

@ -743,8 +743,8 @@ public class CVPollView: ManualStackView {
switch type {
case .pendingVote, .pendingUnvote:
let spinningEllipse = UIImageView(image: UIImage(named: Theme.iconName(.ellipse)))
let checkMark = UIImageView(image: UIImage(named: Theme.iconName(.checkmark)))
let spinningEllipse = UIImageView(image: Theme.iconImage(.ellipse))
let checkMark = UIImageView(image: Theme.iconImage(.checkmark))
checkboxContainer.addSubview(spinningEllipse, withLayoutBlock: { [weak self] _ in
guard let self else { return }
spinView(view: spinningEllipse)
@ -769,7 +769,7 @@ public class CVPollView: ManualStackView {
pollIsEnded: Bool,
pendingVotesCount: Int,
) {
let circle = UIImageView(image: UIImage(named: Theme.iconName(.circle)))
let circle = UIImageView(image: Theme.iconImage(.circle))
let checkBoxSize = pollIsEnded ? configurator.checkBoxEndedSize : configurator.checkBoxSize
checkboxContainer.addSubview(circle, withLayoutBlock: { [weak self] _ in
@ -785,7 +785,7 @@ public class CVPollView: ManualStackView {
switch localUserVoteState {
case .vote:
let checkMarkCircle = UIImageView(image: UIImage(named: Theme.iconName(.checkCircleFill)))
let checkMarkCircle = UIImageView(image: Theme.iconImage(.checkCircleFill))
checkboxContainer.addSubview(checkMarkCircle, withLayoutBlock: { [weak self] _ in
guard let self else { return }
let subviewFrame = CGRect(

View File

@ -6,63 +6,41 @@
import SignalServiceKit
import SignalUI
/// ManualLayoutView wrapper around SelectionIndicatorView.
class MessageSelectionView: ManualLayoutView {
var isSelected: Bool = false {
didSet {
selectedView.isHidden = !isSelected
unselectedView.isHidden = isSelected
private let selectionIndicatorView = SelectionIndicatorView(style: .list)
var isSelected: Bool {
get {
selectionIndicatorView.isSelected
}
set {
selectionIndicatorView.isSelected = newValue
}
}
init() {
super.init(name: "MessageSelectionView")
addSubviewToCenterOnSuperview(selectedView, size: .square(Self.circleDiameter))
addSubviewToCenterOnSuperview(unselectedView, size: .square(Self.circleDiameter))
addLayoutBlock { view in
guard let selectionView = view as? MessageSelectionView else { return }
selectionView.checkmarkIcon.center = selectionView.selectedView.bounds.center
}
selectedView.isHidden = !isSelected
addSubviewToFillSuperviewEdges(selectionIndicatorView)
}
static var preferredSize: CGSize {
CGSize(square: ConversationStyle.selectionViewWidth)
CGSize(square: SelectionIndicatorView.preferredSize)
}
private static var circleDiameter: CGFloat {
// 22 dp as per spec
ConversationStyle.selectionViewWidth - 2
}
private static var emptyCheckmarkStrokeLineWidth: CGFloat { 2 }
private lazy var checkmarkIcon: UIImageView = {
let imageView = UIImageView(image: UIImage(named: "check-compact"))
imageView.contentMode = .scaleAspectFit
imageView.tintColor = .white
return imageView
}()
private lazy var selectedView: UIView = {
let circleView = CircleView(frame: .init(origin: .zero, size: .square(MessageSelectionView.circleDiameter)))
circleView.addSubview(checkmarkIcon)
return circleView
}()
private lazy var unselectedView: UIView = {
let circleView = RingView()
circleView.lineWidth = MessageSelectionView.emptyCheckmarkStrokeLineWidth
return circleView
}()
func updateStyle(conversationStyle: ConversationStyle) {
AssertIsOnMainThread()
selectedView.backgroundColor = conversationStyle.chatColorValue.asChatUIElementTintColor()
unselectedView.tintColor = UIColor.Signal.tertiaryLabel
selectionIndicatorView.fillColor = conversationStyle.chatColorValue.asChatUIElementTintColor()
// Less transparent empty circle when there's a wallpaper and we're in light theme
// to improve legibility over darker wallpapers.
if
conversationStyle.isDarkThemeEnabled == false,
conversationStyle.hasWallpaper
{
selectionIndicatorView.unselectedListIndicatorColor = UIColor(rgbHex: 0x808080, alpha: 0.5)
} else {
selectionIndicatorView.unselectedListIndicatorColor = nil // reset to default
}
}
}

View File

@ -65,7 +65,7 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
let hasWallpaper = conversationStyle.hasWallpaper
let wallpaperModeHasChanged = hasWallpaper != componentView.hasWallpaper
let isReusing = componentView.rootView.superview != nil
&& componentView.label.superview != nil
&& componentView.innerStack.superview != nil
&& !wallpaperModeHasChanged
if !isReusing {
@ -75,19 +75,32 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
componentView.hasWallpaper = hasWallpaper
labelConfig.applyForRendering(label: componentView.label)
chevronConfig.applyForRendering(label: componentView.chevronLabel)
if isReusing {
componentView.innerStack.configureForReuse(
config: innerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_innerStack,
)
componentView.outerStack.configureForReuse(
config: outerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_outerStack,
)
} else {
componentView.innerStack.configure(
config: innerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_innerStack,
subviews: [componentView.label, componentView.chevronContainer],
)
componentView.outerStack.configure(
config: outerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_outerStack,
subviews: [componentView.label],
subviews: [componentView.innerStack],
)
let bubbleView: UIView
@ -110,13 +123,20 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
}
componentView.outerStack.addSubview(bubbleView)
componentView.outerStack.sendSubviewToBack(bubbleView)
// This seemed easier than adding an entirely new ManualStackView
// just to constrain the label and background to
componentView.outerStack.addLayoutBlock { [label = componentView.label] _ in
bubbleView.frame = label.frame.inset(by: Self.backgroundLayoutInsets)
componentView.outerStack.addLayoutBlock { [innerStack = componentView.innerStack] _ in
bubbleView.frame = innerStack.frame.inset(by: Self.backgroundLayoutInsets)
}
componentView.innerStack.addLayoutBlock { [chevronContainer = componentView.chevronContainer, chevronLabel = componentView.chevronLabel] _ in
chevronLabel.bounds.size = chevronContainer.bounds.size
chevronLabel.center = CGPoint(x: chevronContainer.bounds.midX, y: chevronContainer.bounds.midY)
}
}
componentView.isShowingExpanded = collapseSet.isExpanded
componentView.chevronLabel.transform = collapseSet.isExpanded
? CGAffineTransform(rotationAngle: -.pi)
: .identity
if
hasWallpaper,
let wallpaperBlurView = componentView.wallpaperBlurView
@ -128,15 +148,7 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
componentView.outerStack.isAccessibilityElement = true
componentView.outerStack.accessibilityLabel = titleString
componentView.outerStack.accessibilityTraits = .button
componentView.outerStack.accessibilityHint = collapseSet.isExpanded
? OWSLocalizedString(
"COLLAPSE_SET_ACCESSIBILITY_HINT_COLLAPSE",
comment: "VoiceOver hint for an expanded collapse set button.",
)
: OWSLocalizedString(
"COLLAPSE_SET_ACCESSIBILITY_HINT_EXPAND",
comment: "VoiceOver hint for a collapsed collapse set button.",
)
componentView.outerStack.accessibilityHint = accessibilityHint(isExpanded: collapseSet.isExpanded)
}
// MARK: - Events
@ -147,6 +159,35 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
componentView: CVComponentView,
renderItem: CVRenderItem,
) -> Bool {
if let componentView = componentView as? CVComponentViewCollapseSet {
let wasExpanded = componentView.isShowingExpanded
let willBeExpanded = !wasExpanded
let expandedRotation: CGFloat = -.pi
let isRTL = componentView.chevronLabel.effectiveUserInterfaceLayoutDirection == .rightToLeft
let fromAngle: CGFloat
let toAngle: CGFloat
if willBeExpanded {
fromAngle = 0
toAngle = isRTL ? CGFloat.pi : -CGFloat.pi
} else {
fromAngle = expandedRotation
toAngle = isRTL ? -2 * CGFloat.pi : 0
}
componentView.isShowingExpanded = willBeExpanded
componentView.chevronLabel.transform = willBeExpanded
? CGAffineTransform(rotationAngle: expandedRotation)
: .identity
componentView.outerStack.accessibilityHint = accessibilityHint(isExpanded: willBeExpanded)
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
animation.fromValue = fromAngle
animation.toValue = toAngle
animation.duration = 0.2
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
componentView.chevronLabel.layer.add(animation, forKey: "chevronRotation")
}
componentDelegate.didTapCollapseSet(collapseSetId: interaction.uniqueId)
return true
}
@ -154,6 +195,7 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
// MARK: - Measurement
fileprivate static let measurementKey_outerStack = "CVComponentCollapseSet.outerStack"
fileprivate static let measurementKey_innerStack = "CVComponentCollapseSet.innerStack"
func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
owsAssertDebug(maxWidth > 0)
@ -161,18 +203,38 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
0,
maxWidth - outerStackConfig.layoutMargins.totalWidth,
)
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: availableWidth)
let chevronSize = CVText.measureLabel(config: chevronConfig, maxWidth: availableWidth)
let labelMaxWidth = max(0, availableWidth - chevronSize.width - innerStackConfig.spacing)
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: labelMaxWidth)
let innerMeasurement = ManualStackView.measure(
config: innerStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_innerStack,
subviewInfos: [
labelSize.asManualSubviewInfo(hasFixedWidth: true),
chevronSize.asManualSubviewInfo(hasFixedSize: true),
],
)
let outerMeasurement = ManualStackView.measure(
config: outerStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_outerStack,
subviewInfos: [labelSize.asManualSubviewInfo(hasFixedWidth: true)],
subviewInfos: [innerMeasurement.measuredSize.asManualSubviewInfo(hasFixedWidth: true)],
)
return outerMeasurement.measuredSize
}
// MARK: - Layout
private var innerStackConfig: CVStackViewConfig {
CVStackViewConfig(
axis: .horizontal,
alignment: .center,
spacing: 4,
layoutMargins: .zero,
)
}
private var outerStackConfig: CVStackViewConfig {
CVStackViewConfig(
axis: .vertical,
@ -233,7 +295,6 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
)
let nbsp = SignalSymbol.LeadingCharacter.nonBreakingSpace.rawValue
let chevron: SignalSymbol = collapseSet.isExpanded ? .chevronUp : .chevronDown
let result = NSMutableAttributedString()
result.append(leadingIcon.attributedString(
@ -242,18 +303,33 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
attributes: [.foregroundColor: UIColor.Signal.label],
))
result.append(NSAttributedString(
string: "\(nbsp)\(labelText)\(nbsp)",
string: "\(nbsp)\(labelText)",
attributes: [
.font: labelFont,
.foregroundColor: UIColor.Signal.label,
],
))
result.append(chevron.attributedString(
return result
}
private var chevronConfig: CVLabelConfig {
CVLabelConfig(
text: .attributedText(chevronAttributedString),
displayConfig: .forUnstyledText(font: labelFont, textColor: .Signal.label),
font: labelFont,
textColor: .Signal.label,
numberOfLines: 1,
lineBreakMode: .byClipping,
textAlignment: .center,
)
}
private var chevronAttributedString: NSAttributedString {
SignalSymbol.chevronDown.attributedString(
for: .footnote,
clamped: false,
attributes: [.foregroundColor: UIColor.Signal.label],
))
return result
)
}
private var titleString: String {
@ -264,6 +340,18 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
)
}
private func accessibilityHint(isExpanded: Bool) -> String {
isExpanded
? OWSLocalizedString(
"COLLAPSE_SET_ACCESSIBILITY_HINT_COLLAPSE",
comment: "VoiceOver hint for an expanded collapse set button.",
)
: OWSLocalizedString(
"COLLAPSE_SET_ACCESSIBILITY_HINT_EXPAND",
comment: "VoiceOver hint for a collapsed collapse set button.",
)
}
private func summaryLabel(
count: Int,
type: CollapseSetInteraction.MessagesType,
@ -322,9 +410,14 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
class CVComponentViewCollapseSet: NSObject, CVComponentView {
fileprivate let outerStack = ManualStackView(name: "collapseSet.outerStack")
fileprivate let innerStack = ManualStackView(name: "collapseSet.innerStack")
fileprivate let label = CVLabel()
fileprivate let chevronContainer = UIView()
fileprivate let chevronLabel = CVLabel()
fileprivate let solidBackgroundView = UIView()
fileprivate var isShowingExpanded = false
fileprivate var wallpaperBlurView: CVWallpaperBlurView?
fileprivate func ensureWallpaperBlurView() -> CVWallpaperBlurView {
if let wallpaperBlurView = self.wallpaperBlurView {
@ -341,14 +434,24 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
var rootView: UIView { outerStack }
override init() {
super.init()
chevronContainer.addSubview(chevronLabel)
}
func setIsCellVisible(_ isCellVisible: Bool) {}
func reset() {
label.reset()
chevronLabel.reset()
chevronLabel.transform = .identity
chevronLabel.layer.removeAnimation(forKey: "chevronRotation")
isShowingExpanded = false
solidBackgroundView.backgroundColor = nil
wallpaperBlurView?.removeFromSuperview()
wallpaperBlurView = nil
hasWallpaper = false
innerStack.reset()
outerStack.reset()
}
}

View File

@ -800,7 +800,7 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent {
reactionsFrame.y = contentFrame.maxY - reactionsVOverlap
let leftAlignX = contentFrame.minX + reactionsHInset
let rightAlignX = contentFrame.maxX - (reactionsSize.width + reactionsHInset)
if isIncoming ^ CurrentAppContext().isRTL {
if isIncoming != CurrentAppContext().isRTL {
reactionsFrame.x = max(leftAlignX, rightAlignX)
} else {
reactionsFrame.x = min(leftAlignX, rightAlignX)

View File

@ -150,6 +150,7 @@ private class CVQuotedMessageViewAdapter: CVQuotedMessageViewDelegate {
DependenciesBridge.shared.attachmentDownloadManager.enqueueDownloadOfAttachmentsForMessage(
message,
priority: .userInitiated,
useThumbnails: false,
tx: tx,
)
}

View File

@ -493,6 +493,9 @@ public struct CVComponentState: Equatable {
let detailsText: NSAttributedString?
/// For mutual groups, lack thereof and note-to-self description.
let mutualGroupsText: NSAttributedString?
/// Populated if `mutualGroupsText` is not suitable for a11y, for
/// example if it embeds an image.
let mutualGroupsAccessibilityText: String?
let threadType: SafetyTipsType
let shouldShowSafetyTipsButton: Bool
let isOfficialChat: Bool
@ -525,7 +528,6 @@ public struct CVComponentState: Equatable {
static func ==(lhs: CollapseSet, rhs: CollapseSet) -> Bool {
return lhs.collapsedInteractions.map(\.uniqueId) == rhs.collapsedInteractions.map(\.uniqueId)
&& lhs.collapseSetType == rhs.collapseSetType
&& lhs.isExpanded == rhs.isExpanded
&& lhs.finalTimerDescription == rhs.finalTimerDescription
}
}
@ -1194,7 +1196,7 @@ private extension CVComponentState.Builder {
self.collapseSet = CVComponentState.CollapseSet(
collapsedInteractions: collapseSetInteraction.collapsedInteractions,
collapseSetType: collapseSetInteraction.collapseSetType,
isExpanded: collapseSetInteraction.isExpanded,
isExpanded: viewStateSnapshot.expandedCollapseSetIds.contains(collapseSetInteraction.uniqueId),
finalTimerDescription: collapseSetInteraction.finalTimerDescription,
)
return build()
@ -1361,7 +1363,33 @@ private extension CVComponentState.Builder {
case .failed:
mediaAlbumHasFailedAttachment = true
case .none:
mediaAlbumHasSkippedAttachment = true
// If optimize local storage is enabled, and the user has auto-downloads
// disabled, show the 'skipped attachment' download indicator. Otherwise
// render the attachment as normal, using the backup thumbnail for display.
let backupPlan = DependenciesBridge.shared.backupPlanManager.backupPlan(tx: transaction)
switch backupPlan {
case
.paid(let optimizeLocalStorage),
.paidAsTester(let optimizeLocalStorage),
.paidExpiringSoon(let optimizeLocalStorage):
if
optimizeLocalStorage,
canAutoDownloadAttachment(referencedAttachment: attachment),
attachment.attachment.localRelativeFilePathThumbnail != nil
{
// If optimize storage is enabled, auto-downloads are enabled,
// and the backup thumbnail is present, show the backup thumbnail
// as a true attachment (don't show the download icon overlay).
mediaAlbumHasSkippedAttachment = false
} else {
mediaAlbumHasSkippedAttachment = true
}
case
.free,
.disabled,
.disabling:
mediaAlbumHasSkippedAttachment = true
}
}
}
@ -1397,6 +1425,28 @@ private extension CVComponentState.Builder {
return result
}
private func canAutoDownloadAttachment(referencedAttachment: ReferencedAttachment) -> Bool {
let mediaBandwidthPreferenceStore = DependenciesBridge.shared.mediaBandwidthPreferenceStore
let autoDownloadableMediaTypes = mediaBandwidthPreferenceStore.autoDownloadableMediaTypes(tx: transaction)
let mimeType = referencedAttachment.attachment.mimeType
if MimeTypeUtil.isSupportedImageMimeType(mimeType) {
return autoDownloadableMediaTypes.contains(.photo)
}
if MimeTypeUtil.isSupportedVideoMimeType(mimeType) {
return autoDownloadableMediaTypes.contains(.video)
}
if MimeTypeUtil.isSupportedAudioMimeType(mimeType) {
if
autoDownloadableMediaTypes.contains(.audio),
referencedAttachment.reference.renderingFlag != .voiceMessage
{
return true
}
return false
}
return autoDownloadableMediaTypes.contains(.document)
}
mutating func buildThreadDetails() -> ThreadDetails {
owsAssertDebug(interaction is ThreadDetailsInteraction)
@ -1631,7 +1681,6 @@ private extension CVComponentState.Builder {
} else if let quotedMessage = message.quotedMessage {
var memberLabel: String?
if
BuildFlags.MemberLabel.display,
let groupThread = thread as? TSGroupThread,
!threadViewModel.hasPendingMessageRequest,
let originalMessageAuthor = quotedMessage.authorAddress.aci
@ -1714,10 +1763,10 @@ private extension CVComponentState.Builder {
let caption = referencedAttachment.reference.legacyMessageCaption
let hasCaption = caption.map {
return CVComponentState.displayableCaption(
return !CVComponentState.displayableCaption(
text: $0,
transaction: transaction,
).fullTextValue.isEmpty.negated
).fullTextValue.isEmpty
} ?? false
switch cvAttachment {
@ -2006,7 +2055,7 @@ private extension CVComponentState.Builder {
self.giftBadge = GiftBadge(
messageUniqueId: messageUniqueId,
otherUserShortName: threadViewModel.shortName ?? threadViewModel.name,
cachedBadge: DonationSubscriptionManager.getCachedBadge(level: .giftBadge(level)),
cachedBadge: DependenciesBridge.shared.donationSubscriptionManager.getCachedBadge(level: .giftBadge(level)),
expirationDate: expirationDate,
redemptionState: giftBadge.redemptionState,
)

View File

@ -195,16 +195,22 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
if safetySection.shouldShowProfileNamesEducation {
innerViews.append(UIView.spacer(withHeight: vSpacingNotVerifiedLabel))
let nameNotVerifiedButton = componentView.profileNamesEducationButton
var buttonConfiguration = headerButtonConfigurationBase()
buttonConfiguration.baseBackgroundColor = .Signal.warningLabel.withAlphaComponent(0.2)
buttonConfiguration.contentInsets = notVerifierButtonContentInsets
let nameNotVerifiedButtonLabelConfig = nameNotVerifiedConfig()
nameNotVerifiedButtonLabelConfig.applyForRendering(button: nameNotVerifiedButton)
nameNotVerifiedButton.backgroundColor = UIColor.Signal.warningLabel.withAlphaComponent(0.2)
nameNotVerifiedButton.ows_contentEdgeInsets = .init(hMargin: hPaddingNotVerifiedButton, vMargin: vPaddingNotVerifiedButton)
nameNotVerifiedButton.dimsWhenHighlighted = true
nameNotVerifiedButton.block = {
componentDelegate.didTapNameEducation(type: safetySection.threadType)
}
nameNotVerifiedButtonLabelConfig.applyForRendering(buttonConfiguration: &buttonConfiguration)
let nameNotVerifiedButton = UIButton(
configuration: buttonConfiguration,
primaryAction: UIAction { _ in
componentDelegate.didTapNameEducation(type: safetySection.threadType)
},
)
innerViews.append(nameNotVerifiedButton)
componentView.profileNamesEducationButton = nameNotVerifiedButton
} else if safetySection.isOfficialChat {
innerViews.append(UIView.spacer(withHeight: vSpacingNotVerifiedLabel))
@ -249,23 +255,30 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
innerViews.append(UIView.spacer(withHeight: vSpacingSafetySectionDefault))
let mutualGroupsLabelConfig = mutualGroupsLabelConfig(attributedText: mutualGroupsText)
mutualGroupsLabelConfig.applyForRendering(label: mutualGroupsLabel)
mutualGroupsLabel.accessibilityLabel = safetySection.mutualGroupsAccessibilityText
innerViews.append(mutualGroupsLabel)
}
if safetySection.shouldShowSafetyTipsButton {
let showTipsButton = componentView.showTipsButton
safetyTipsButtonLabelConfig.applyForRendering(buttonConfiguration: &showTipsButton.configuration!)
showTipsButton.configuration?.baseBackgroundColor =
var buttonConfiguration = headerButtonConfigurationBase()
buttonConfiguration.contentInsets = safetyButtonContentInsets
buttonConfiguration.baseBackgroundColor =
conversationStyle.hasWallpaper ? .Signal.MaterialBase.button : .Signal.secondaryFill
showTipsButton.addAction(
UIAction { _ in
let safetyTipsButtonLabelConfig = safetyTipsButtonLabelConfig()
safetyTipsButtonLabelConfig.applyForRendering(buttonConfiguration: &buttonConfiguration)
let showTipsButton = UIButton(
configuration: buttonConfiguration,
primaryAction: UIAction { _ in
componentDelegate.didTapSafetyTips()
},
for: .primaryActionTriggered,
)
innerViews.append(UIView.spacer(withHeight: vSpacingSafetyButton))
innerViews.append(showTipsButton)
componentView.showTipsButton = showTipsButton
}
}
@ -426,7 +439,7 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
}
private func officialLabelConfig() -> CVLabelConfig {
let symbol = SignalSymbol.checkCircle.attributedString(dynamicTypeBaseSize: UIFont.dynamicTypeCalloutClamped.pointSize)
let symbol = SignalSymbol.officialBadge.attributedString(dynamicTypeBaseSize: UIFont.dynamicTypeCalloutClamped.pointSize)
let notVerifiedString = NSAttributedString.composed(
of: [
symbol,
@ -448,7 +461,7 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
)
}
private var safetyTipsButtonLabelConfig: CVLabelConfig {
private func safetyTipsButtonLabelConfig() -> CVLabelConfig {
CVLabelConfig.unstyledText(
OWSLocalizedString(
"SAFETY_TIPS_BUTTON_ACTION_TITLE",
@ -642,10 +655,14 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
private let vSpacingSafetySectionDefault: CGFloat = 8
private let safetyButtonContentInsets = NSDirectionalEdgeInsets(hMargin: 12, vMargin: 5)
private let hPaddingGroupDetails: CGFloat = 25
private let notVerifierButtonContentInsets = NSDirectionalEdgeInsets(hMargin: 12, vMargin: 2)
private func headerButtonConfigurationBase() -> UIButton.Configuration {
var configuration = UIButton.Configuration.filled()
configuration.cornerStyle = .capsule
return configuration
}
private let vPaddingNotVerifiedButton: CGFloat = 2
private let hPaddingNotVerifiedButton: CGFloat = 12
private let hPaddingGroupDetails: CGFloat = 25
private let vOffsetThreadDetailsOutline: CGFloat = 16
@ -688,20 +705,18 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
if let safetySection = threadDetails.safetySection {
if safetySection.shouldShowProfileNamesEducation {
innerSubviewInfos.append(CGSize(square: vSpacingNotVerifiedLabel).asManualSubviewInfo)
let notVerifiedSize = CVText.measureLabel(
let buttonSize = CVText.measureLabel(
config: nameNotVerifiedConfig(),
maxWidth: maxContentWidth,
)
let notVerifiedSizeWithPadding = CGSize(width: notVerifiedSize.width + hPaddingNotVerifiedButton * 2, height: notVerifiedSize.height + vPaddingNotVerifiedButton * 2)
innerSubviewInfos.append(notVerifiedSizeWithPadding.asManualSubviewInfo)
) + notVerifierButtonContentInsets.asSize
innerSubviewInfos.append(buttonSize.asManualSubviewInfo)
} else if safetySection.isOfficialChat {
innerSubviewInfos.append(CGSize(square: vSpacingNotVerifiedLabel).asManualSubviewInfo)
let officialLabelSize = CVText.measureLabel(
let buttonSize = CVText.measureLabel(
config: officialLabelConfig(),
maxWidth: maxContentWidth,
)
let officialLabelSizeWithPadding = CGSize(width: officialLabelSize.width + hPaddingNotVerifiedButton * 2, height: officialLabelSize.height + vPaddingNotVerifiedButton * 2)
innerSubviewInfos.append(officialLabelSizeWithPadding.asManualSubviewInfo)
) + notVerifierButtonContentInsets.asSize
innerSubviewInfos.append(buttonSize.asManualSubviewInfo)
}
}
@ -737,7 +752,7 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
if safetySection.shouldShowSafetyTipsButton {
innerSubviewInfos.append(CGSize(square: vSpacingSafetyButton).asManualSubviewInfo)
let buttonSize = CVText.measureLabel(
config: safetyTipsButtonLabelConfig,
config: safetyTipsButtonLabelConfig(),
maxWidth: maxContentWidth,
) + safetyButtonContentInsets.asSize
innerSubviewInfos.append(buttonSize.asManualSubviewInfo)
@ -786,7 +801,8 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
if let safetySection = threadDetails.safetySection {
if
safetySection.shouldShowSafetyTipsButton,
componentView.showTipsButton.bounds.contains(sender.location(in: componentView.showTipsButton))
let showTipsButton = componentView.showTipsButton,
showTipsButton.bounds.contains(sender.location(in: componentView.showTipsButton))
{
componentDelegate.didTapSafetyTips()
return true
@ -803,7 +819,8 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
if
safetySection.shouldShowProfileNamesEducation,
componentView.profileNamesEducationButton.bounds.contains(sender.location(in: componentView.profileNamesEducationButton))
let profileNamesEducationButton = componentView.profileNamesEducationButton,
profileNamesEducationButton.bounds.contains(sender.location(in: componentView.profileNamesEducationButton))
{
componentDelegate.didTapNameEducation(type: safetySection.threadType)
return true
@ -838,17 +855,13 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
fileprivate let titleButton = CVButton()
fileprivate let bioLabel = CVLabel()
fileprivate let profileNamesEducationButton = OWSRoundedButton()
fileprivate var profileNamesEducationButton: UIButton?
fileprivate let officialLabel = CVLabel()
fileprivate let reviewCarefullyLabel = CVLabel()
fileprivate let detailsButton = CVButton()
fileprivate let mutualGroupsLabel = CVLabel()
fileprivate let showTipsButton: UIButton = {
let button = UIButton(configuration: .gray())
button.configuration?.contentInsets = NSDirectionalEdgeInsets(hMargin: 10, vMargin: 5)
return button
}()
fileprivate var showTipsButton: UIButton?
fileprivate let groupDescriptionPreviewView = GroupDescriptionPreviewView(
shouldDeactivateConstraints: true,
@ -906,6 +919,7 @@ extension CVComponentThreadDetails {
shouldShowProfileNamesEducation: false,
detailsText: NSAttributedString(string: OWSLocalizedString("RELEASE_NOTES_DETAILS", comment: "Details text for the thread details view of the release notes channel")),
mutualGroupsText: nil,
mutualGroupsAccessibilityText: nil,
threadType: .contact,
shouldShowSafetyTipsButton: false,
isOfficialChat: true,
@ -1038,6 +1052,7 @@ extension CVComponentThreadDetails {
shouldShowProfileNamesEducation: shouldShowUnknownThreadWarning,
detailsText: membersAttributedText,
mutualGroupsText: nil,
mutualGroupsAccessibilityText: nil,
threadType: .group,
shouldShowSafetyTipsButton: shouldShowUnknownThreadWarning && groupThread.hasPendingMessageRequest(transaction: tx),
isOfficialChat: false,
@ -1070,6 +1085,7 @@ extension CVComponentThreadDetails {
with: .font(.dynamicTypeSubheadline),
.color(UIColor.Signal.label),
),
mutualGroupsAccessibilityText: nil,
threadType: .contact,
shouldShowSafetyTipsButton: false,
isOfficialChat: false,
@ -1174,6 +1190,7 @@ extension CVComponentThreadDetails {
" ",
formattedString,
]),
mutualGroupsAccessibilityText: formattedString,
threadType: .contact,
shouldShowSafetyTipsButton: isMessageRequest,
isOfficialChat: false,

View File

@ -59,7 +59,7 @@ extension TSInfoMessage.PersistableGroupUpdateItem {
)
{
owsAssertDebug(
isTail.negated,
!isTail,
"Collapsed item with a following request shouldn't be a tail!",
)
return nextItemAction

View File

@ -22,9 +22,12 @@ class ConversationBottomPanelView: UIView {
let contentLayoutGuide = UILayoutGuide()
private var backgroundViewEffect: UIVisualEffect {
guard #available(iOS 26, *), useGlassPanel else {
if UIAccessibility.isReduceTransparencyEnabled {
return UIBlurEffect(style: .systemThinMaterial)
}
guard #available(iOS 26, *), useGlassPanel else {
return Theme.barBlurEffect
}
// Same as in ConversationInputToolbar.
let glassEffect = UIGlassEffect(style: .regular)
glassEffect.tintColor = .Signal.glassBackgroundTint
@ -123,6 +126,19 @@ class ConversationBottomPanelView: UIView {
constant: UIDevice.current.hasIPhoneXNotch ? 0 : -12,
),
])
// Alter the visual effect view's tint to match our background color
// so the bottom panel, when over a solid color background matching UIColor.Signal.background,
// exactly matches the background color. This is brittle, but there is no way to get
// this behavior from UIVisualEffectView otherwise.
if
!UIAccessibility.isReduceTransparencyEnabled,
let tintingView = backgroundView.subviews.first(where: {
String(describing: type(of: $0)) == "_UIVisualEffectSubview"
})
{
tintingView.backgroundColor = UIColor.Signal.background.withAlphaComponent(OWSNavigationBar.backgroundBlurMutingFactor)
}
}
}

View File

@ -179,6 +179,23 @@ class ConversationHeaderView: UIView {
}
}
// MARK: Spinning Title
func updateTitleSpinning() {
let key = "spin"
if InMemorySettings.spinningConversationTitle {
guard layer.animation(forKey: key) == nil else { return }
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
animation.toValue = NSNumber(value: Double.pi * 2)
animation.duration = 1
animation.isCumulative = true
animation.repeatCount = .greatestFiniteMagnitude
layer.add(animation, forKey: key)
} else {
layer.removeAnimation(forKey: key)
}
}
// MARK: Delegate Methods
@objc

View File

@ -9,6 +9,7 @@ public import UIKit
public protocol ConversationInputTextViewDelegate: AnyObject {
func didAttemptAttachmentPaste()
func didAttemptAccountEntropyPoolPaste(completePaste: @escaping () -> Void)
func inputTextViewSendMessagePressed()
func textViewDidChange(_ textView: UITextView)
}
@ -199,9 +200,50 @@ class ConversationInputTextView: BodyRangesTextView {
return
}
if handleAttemptedAccountEntropyPoolPaste() {
return
}
super.paste(sender)
}
private func handleAttemptedAccountEntropyPoolPaste() -> Bool {
let accountKeyStore = DependenciesBridge.shared.accountKeyStore
let db = DependenciesBridge.shared.db
guard let pasteboardString = UIPasteboard.general.strings?.first else {
return false
}
let filteredPasteboardString = pasteboardString.filter { !$0.isWhitespace }
guard
let pastedAEP = try? DisplayableAccountEntropyPool(displayString: filteredPasteboardString),
let localAEP = db.read(block: { accountKeyStore.getAccountEntropyPool(tx: $0) }),
pastedAEP.rawValue == localAEP
else {
return false
}
inputTextViewDelegate?.didAttemptAccountEntropyPoolPaste(
completePaste: { [weak self] in
guard let self else { return }
let pasteRange: UITextRange
if let selectedTextRange {
pasteRange = selectedTextRange
} else if let endRange = textRange(from: endOfDocument, to: endOfDocument) {
pasteRange = endRange
} else {
return
}
replace(pasteRange, withText: filteredPasteboardString)
},
)
return true
}
// MARK: - UITextViewDelegate
override func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {

View File

@ -249,6 +249,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
button.configuration?.baseForegroundColor = Style.buttonTintColor
button.accessibilityLabel = accessibilityLabel
button.accessibilityIdentifier = accessibilityIdentifier
button.isPointerInteractionEnabled = true
button.translatesAutoresizingMaskIntoConstraints = false
button.addConstraints([
button.widthAnchor.constraint(equalToConstant: LayoutMetrics.initialTextBoxHeight),
@ -345,6 +346,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
button.configuration?.cornerStyle = .capsule
button.accessibilityLabel = MessageStrings.sendButton
button.accessibilityIdentifier = accessibilityIdentifier
button.isPointerInteractionEnabled = true
button.translatesAutoresizingMaskIntoConstraints = false
button.addConstraints([
button.widthAnchor.constraint(equalToConstant: buttonSize),
@ -378,6 +380,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
comment: "Accessibility hint describing what you can do with the attachment button",
)
button.accessibilityIdentifier = accessibilityIdentifier
button.isPointerInteractionEnabled = true
button.translatesAutoresizingMaskIntoConstraints = false
button.addConstraints([
button.widthAnchor.constraint(equalToConstant: buttonSize),
@ -403,6 +406,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
button.configuration?.baseForegroundColor = .white
button.configuration?.cornerStyle = .capsule
button.accessibilityIdentifier = accessibilityIdentifier
button.isPointerInteractionEnabled = true
button.translatesAutoresizingMaskIntoConstraints = false
button.addConstraints([
button.widthAnchor.constraint(equalToConstant: buttonSize),
@ -707,9 +711,10 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
// Rounded rect background for the text input field:
// Liquid Glass on iOS 26, gray-ish on earlier iOS versions.
let backgroundView: UIView
let cornerRadius = LayoutMetrics.initialTextBoxHeight / 2
if #available(iOS 26, *) {
let glassEffectView = UIVisualEffectView(effect: Style.glassEffect(isInteractive: true))
glassEffectView.cornerConfiguration = .uniformCorners(radius: 20)
glassEffectView.cornerConfiguration = .uniformCorners(radius: .fixed(cornerRadius))
glassEffectView.contentView.addSubview(messageComponentsView)
backgroundView = glassEffectView
@ -717,7 +722,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
} else {
backgroundView = UIView()
backgroundView.backgroundColor = UIColor.Signal.tertiaryFill
backgroundView.layer.cornerRadius = 20
backgroundView.layer.cornerRadius = cornerRadius
messageContentView.addSubview(backgroundView)
messageContentView.addSubview(messageComponentsView)
@ -1214,7 +1219,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
lazy var sendButton: UIButton = {
let button = UIButton(type: .system)
button.accessibilityLabel = MessageStrings.sendButton
button.ows_adjustsImageWhenDisabled = true
button.isPointerInteractionEnabled = true
button.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "sendButton")
button.setImage(UIImage(imageLiteralResourceName: "send-blue-28"), for: .normal)
button.bounds.size = CGSize(width: 48, height: LayoutMetrics.initialToolbarHeight)
@ -1224,6 +1229,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
lazy var cameraButton: UIButton = {
let button = UIButton(type: .system)
button.tintColor = Style.buttonTintColor
button.isPointerInteractionEnabled = true
button.accessibilityLabel = OWSLocalizedString(
"CAMERA_BUTTON_LABEL",
comment: "Accessibility label for camera button.",
@ -1241,6 +1247,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
lazy var voiceMemoButton: UIButton = {
let button = UIButton(type: .system)
button.tintColor = Style.buttonTintColor
button.isPointerInteractionEnabled = true
button.accessibilityLabel = OWSLocalizedString(
"INPUT_TOOLBAR_VOICE_MEMO_BUTTON_ACCESSIBILITY_LABEL",
comment: "accessibility label for the button which records voice memos",
@ -1366,6 +1373,8 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
override private init(frame: CGRect) {
super.init(frame: frame)
isPointerInteractionEnabled = true
addSubview(roundedCornersBackground)
roundedCornersBackground.autoCenterInSuperview()
roundedCornersBackground.autoSetDimensions(to: CGSize(square: 28))

View File

@ -155,7 +155,7 @@ extension ConversationViewController: CVLoadCoordinatorDelegate {
cvCustomAction.messageAction?.block(self)
}
func willUpdateWithNewRenderState(_ renderState: CVRenderState) -> CVUpdateToken {
func willUpdateWithNewRenderState(_ update: CVUpdate) -> CVUpdateToken {
AssertIsOnMainThread()
// HACK to work around radar #28167779
@ -171,7 +171,9 @@ extension ConversationViewController: CVLoadCoordinatorDelegate {
// Snapshot CVC layout state before we land the load;
// we use this to ensure scroll continuity when landing the load.
let scrollContinuityToken = layout.buildScrollContinuityToken()
let scrollContinuityToken = layout.buildScrollContinuityToken(
preferredAnchorInteractionId: update.loadRequest.preferredScrollContinuityAnchorInteractionId,
)
// CVC will often use this state to ensure scroll continuity
// when landing loads, so ensure the value is updated before
@ -543,6 +545,14 @@ extension ConversationViewController: CVLoadCoordinatorDelegate {
}
}
if
scrollAction.action == .none,
update.loadRequest.preferredScrollContinuityAnchorInteractionId != nil,
isScrolledToBottom
{
scrollAction = CVScrollAction(action: .bottomOfLoadWindow, isAnimated: false)
}
if .loadOlder == renderState.loadType {
scrollAction = .none
}

View File

@ -32,7 +32,11 @@ extension ConversationViewController: CVComponentDelegate {
} else {
viewState.expandedCollapseSets.insert(collapseSetId)
}
loadCoordinator.enqueueReload()
loadCoordinator.enqueueReload(
updatedInteractionIds: [collapseSetId],
deletedInteractionIds: [],
preferredScrollContinuityAnchorInteractionId: collapseSetId,
)
}
// MARK: - Double-Tap
@ -179,7 +183,7 @@ extension ConversationViewController: CVComponentDelegate {
// If any of the failed or pending downloads were enqueued by a Backup
// restore, immediately attempt to download those attachments.
Task {
Task.detached {
let attachmentDownloadManager = DependenciesBridge.shared.attachmentDownloadManager
let attachmentStore = DependenciesBridge.shared.attachmentStore
let backupAttachmentDownloadStore = DependenciesBridge.shared.backupAttachmentDownloadStore
@ -190,17 +194,22 @@ extension ConversationViewController: CVComponentDelegate {
return
}
let messageHasAnyEnqueuedBackupDownloads = db.read { tx in
enum DownloadTypeToEnqueue {
case thumbnail
case fullsize
}
let messageTypeToDownload: DownloadTypeToEnqueue? = db.read { tx in
let referencedAttachments = attachmentStore.fetchReferencedAttachmentsOwnedByMessage(
messageRowId: messageRowId,
tx: tx,
)
return referencedAttachments.contains { referencedAttachment in
let downloadTypes: [DownloadTypeToEnqueue] = referencedAttachments.compactMap { referencedAttachment in
// We only auto-download on appear if we've got a cdn number to try.
// The user can still manual download if there isn't one (using fallback cdn).
guard referencedAttachment.attachment.mediaTierInfo?.cdnNumber != nil else {
return false
return nil
}
// Otherwise use presence in the backup download queue to indicate
// downloadability; this just functionally bumps the priority so the
@ -211,22 +220,60 @@ extension ConversationViewController: CVComponentDelegate {
tx: tx,
)
switch enqueuedDownload?.state {
case .ineligible:
if referencedAttachment.attachment.localRelativeFilePathThumbnail != nil {
return nil
}
let enqueuedThumbnail = backupAttachmentDownloadStore.getEnqueuedDownload(
attachmentRowId: referencedAttachment.attachment.id,
thumbnail: true,
tx: tx,
)
switch enqueuedThumbnail?.state {
case .ready:
return .thumbnail
case .done, .ineligible, nil:
// There is already a thumbnail, or never will be a thumbnail to display here.
// Either way, no need to re-enqueue the thumbnail
return nil
}
case nil, .done:
return false
case .ineligible, .ready:
return true
return nil
case .ready:
return .fullsize
}
}
if downloadTypes.contains(.fullsize) {
return .fullsize
} else if downloadTypes.contains(.thumbnail) {
return .thumbnail
} else {
return nil
}
}
if messageHasAnyEnqueuedBackupDownloads {
switch messageTypeToDownload {
case .fullsize:
await db.awaitableWrite { tx in
attachmentDownloadManager.enqueueDownloadOfAttachmentsForMessage(
message,
priority: .default,
useThumbnails: false,
tx: tx,
)
}
case .thumbnail:
await db.awaitableWrite { tx in
attachmentDownloadManager.enqueueDownloadOfAttachmentsForMessage(
message,
priority: .default,
useThumbnails: true,
tx: tx,
)
}
case .none:
break
}
}
}
@ -240,6 +287,7 @@ extension ConversationViewController: CVComponentDelegate {
attachmentDownloadManager.enqueueDownloadOfAttachmentsForMessage(
message,
priority: .userInitiated,
useThumbnails: false,
tx: tx,
)
}
@ -1406,17 +1454,16 @@ extension ConversationViewController: CVComponentDelegate {
}
public func didTapSafetyTips() {
let viewController = SafetyTipsViewController()
viewController.delegate = self
present(viewController, animated: true)
}
}
// MARK: - SafetyTipsViewControllerDelegate
extension ConversationViewController: SafetyTipsViewControllerDelegate {
public func didTapViewMoreSafetyTips() {
let viewController = MoreSafetyTipsViewController()
let viewController = SafetyTipsViewController(
mode: .messageRequest,
primaryButton: SafetyTipsViewController.Button(
title: CommonStrings.viewMoreButton,
action: { [weak self] in
let viewController = MoreSafetyTipsViewController()
self?.present(viewController, animated: true)
},
),
)
present(viewController, animated: true)
}
}

View File

@ -295,6 +295,58 @@ extension ConversationViewController: ConversationInputTextViewDelegate {
}
}
public func didAttemptAccountEntropyPoolPaste(completePaste: @escaping () -> Void) {
let warningSheet = BackupNeverShareRecoveryKeySheet(
primaryButton: .dismissing(
title: OWSLocalizedString(
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_WARNING_SHEET_DO_NOT_SHARE_BUTTON_TITLE",
comment: "Title for the button in a warning sheet shown before the user pastes their 'Recovery Key' into the chat input text field that dismisses the sheet without pasting the key.",
),
),
secondaryButton: HeroSheetViewController.Button(
title: OWSLocalizedString(
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_WARNING_SHEET_SHARE_BUTTON_TITLE",
comment: "Title for the button in a warning sheet shown before the user pastes their 'Recovery Key' into the chat input text field that acknowledges the warning and proceeds with the paste.",
),
style: .secondaryDestructive,
action: .custom({ [weak self] sheet in
sheet.dismiss(animated: true) {
let doubleWarningSheet = ActionSheetController(
title: OWSLocalizedString(
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_CONFIRM_SHEET_TITLE",
comment: "Title for a second confirmation sheet shown after the user opts to paste their 'Recovery Key' into the chat input text field anyway.",
),
message: OWSLocalizedString(
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_CONFIRM_SHEET_MESSAGE",
comment: "Message body for a second confirmation sheet shown after the user opts to paste their 'Recovery Key' into the chat input text field anyway, warning them not to share it.",
),
)
doubleWarningSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_CONFIRM_SHEET_PASTE_BUTTON_TITLE",
comment: "Title for the destructive button in a second confirmation sheet shown after the user opts to paste their 'Recovery Key' into the chat input text field anyway, that proceeds with the paste.",
),
style: .destructive,
handler: { _ in
completePaste()
},
))
doubleWarningSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_CONFIRM_SHEET_DO_NOT_SHARE_BUTTON_TITLE",
comment: "Title for the button in a second confirmation sheet shown after the user opts to paste their 'Recovery Key' into the chat input text field anyway, that dismisses the sheet without pasting the key.",
),
))
self?.present(doubleWarningSheet, animated: true)
}
}),
),
)
present(warningSheet, animated: true)
}
public func inputTextViewSendMessagePressed() {
AssertIsOnMainThread()

View File

@ -83,7 +83,7 @@ extension ConversationViewController {
let mode: BadgeIssueSheetState.Mode
if isRedeemed {
let hasCurrentSubscription = SSKEnvironment.shared.databaseStorageRef.read { tx -> Bool in
return DonationSubscriptionManager.probablyHasCurrentSubscription(tx: tx)
return DependenciesBridge.shared.donationSubscriptionManager.probablyHasCurrentSubscription(tx: tx)
}
mode = .giftBadgeExpired(hasCurrentSubscription: hasCurrentSubscription)
} else {

View File

@ -372,8 +372,8 @@ extension ConversationViewController {
timer.invalidate()
return
}
// If the view isn't visible, return
guard self.view.window != nil else {
owsFailDebug("Read timer fired when ConversationViewController is not in a view hierarchy")
timer.invalidate()
return
}

View File

@ -78,6 +78,14 @@ extension ConversationViewController {
object: AVAudioSession.sharedInstance(),
)
NotificationCenter.default.addObserver(
self,
selector: #selector(smsVerificationCodeRequested),
name: .smsVerificationCodeRequested,
object: nil,
)
SafetyTipsManager.startObservingDarwinNotifications()
AppEnvironment.shared.callService.callServiceState.addObserver(self, syncStateImmediately: false)
}
@ -203,6 +211,28 @@ extension ConversationViewController {
AssertIsOnMainThread()
ensureBottomViewType()
}
@objc
private func smsVerificationCodeRequested(_ notification: NSNotification) {
AssertIsOnMainThread()
let db = DependenciesBridge.shared.db
let safetyTipsManager = SafetyTipsManager()
let timestamp: UInt64? = db.read { tx in
safetyTipsManager.lastVerificationCodeTimestampMsWithinExpiryTime(transaction: tx)
}
guard let timestamp else { return }
let actionSheetController = SafetyTipsSheet.makeSmsCodeRequestedSheet(
timestampMs: timestamp,
fromViewController: self,
)
present(actionSheetController, animated: true, completion: {
db.write { tx in
safetyTipsManager.removeVerificationCodeRequestedTimestampMs(transaction: tx)
}
})
}
}
// MARK: -

View File

@ -31,6 +31,8 @@ extension ConversationViewController {
} else {
headerView.titleLabel.text = title
}
headerView.updateTitleSpinning()
}
public func createHeaderViews() {

View File

@ -893,6 +893,21 @@ public class ConversationViewLayout: UICollectionViewLayout {
return contentOffsetAdjustment
}
if
let anchorInteractionId = scrollContinuityToken.anchorInteractionId,
let beforeItemLayout = beforeItemLayoutMap[anchorInteractionId],
let afterItemLayout = afterItemLayoutMap[anchorInteractionId]
{
if beforeItemLayout.canBeUsedForContinuity, afterItemLayout.canBeUsedForContinuity {
return calculateAdjustment(
beforeItemLayout: beforeItemLayout,
afterItemLayout: afterItemLayout,
)
} else {
owsFailDebug("Invalid scroll continuity anchor.")
}
}
// Prefer to maintain continuity with visible interactions.
//
// Honor the scroll continuity bias. If we prefer continuity with regard
@ -967,7 +982,7 @@ public class ConversationViewLayout: UICollectionViewLayout {
delegateScrollContinuityMode = .disabled
}
public func buildScrollContinuityToken() -> CVScrollContinuityToken {
public func buildScrollContinuityToken(preferredAnchorInteractionId: String? = nil) -> CVScrollContinuityToken {
AssertIsOnMainThread()
let layoutInfo = ensureCurrentLayoutInfo()
@ -992,6 +1007,7 @@ public class ConversationViewLayout: UICollectionViewLayout {
layoutInfo: layoutInfo,
contentOffset: contentOffset,
visibleUniqueIds: visibleUniqueIds,
anchorInteractionId: preferredAnchorInteractionId,
)
}
@ -1095,15 +1111,18 @@ public class CVScrollContinuityToken: NSObject {
fileprivate let layoutInfo: ConversationViewLayout.LayoutInfo
fileprivate let contentOffset: CGPoint
fileprivate let visibleUniqueIds: [String]
fileprivate let anchorInteractionId: String?
fileprivate init(
layoutInfo: ConversationViewLayout.LayoutInfo,
contentOffset: CGPoint,
visibleUniqueIds: [String],
anchorInteractionId: String? = nil,
) {
self.layoutInfo = layoutInfo
self.contentOffset = contentOffset
self.visibleUniqueIds = visibleUniqueIds
self.anchorInteractionId = anchorInteractionId
}
}

View File

@ -5,7 +5,7 @@
import SignalServiceKit
class CollapseSetInteraction: TSInteraction {
final class CollapseSetInteraction: TSInteraction {
enum MessagesType: Equatable {
case groupUpdates
@ -18,8 +18,6 @@ class CollapseSetInteraction: TSInteraction {
let collapseSetType: MessagesType
let isExpanded: Bool
let finalTimerDescription: String?
override var isDynamicInteraction: Bool { true }
@ -32,12 +30,10 @@ class CollapseSetInteraction: TSInteraction {
thread: TSThread,
collapsedInteractions: [TSInteraction],
collapseSetType: MessagesType,
isExpanded: Bool = false,
) {
owsPrecondition(!collapsedInteractions.isEmpty)
self.collapsedInteractions = collapsedInteractions
self.collapseSetType = collapseSetType
self.isExpanded = isExpanded
self.finalTimerDescription = Self.disappearingTimerDescription(
for: collapsedInteractions,
type: collapseSetType,
@ -45,13 +41,17 @@ class CollapseSetInteraction: TSInteraction {
let firstInteraction = collapsedInteractions[0]
super.init(
customUniqueId: "CollapseSet_\(firstInteraction.timestamp)",
customUniqueId: Self.id(firstInteraction: firstInteraction),
timestamp: firstInteraction.timestamp,
receivedAtTimestamp: firstInteraction.receivedAtTimestamp,
thread: thread,
)
}
static func id(firstInteraction: TSInteraction) -> String {
"CollapseSet_\(firstInteraction.timestamp)"
}
private static func disappearingTimerDescription(
for interactions: [TSInteraction],
type: MessagesType,

View File

@ -423,7 +423,6 @@ struct CVItemModelBuilder: CVItemBuilding {
var memberLabel: String?
if
BuildFlags.MemberLabel.display,
let groupThread = thread as? TSGroupThread,
!threadViewModel.hasPendingMessageRequest,
let senderAci = incomingSenderAddress.aci

View File

@ -11,7 +11,7 @@ import UIKit
protocol CVLoadCoordinatorDelegate: UIScrollViewDelegate {
var viewState: CVViewState { get }
func willUpdateWithNewRenderState(_ renderState: CVRenderState) -> CVUpdateToken
func willUpdateWithNewRenderState(_ update: CVUpdate) -> CVUpdateToken
func updateWithNewRenderState(
update: CVUpdate,
@ -385,6 +385,13 @@ public class CVLoadCoordinator: NSObject {
loadIfNecessary()
}
public func enqueueReload(preferredScrollContinuityAnchorInteractionId: String) {
loadRequestBuilder.reload(
preferredScrollContinuityAnchorInteractionId: preferredScrollContinuityAnchorInteractionId,
)
loadIfNecessary()
}
public func enqueueReload(scrollAction: CVScrollAction) {
loadRequestBuilder.reload(scrollAction: scrollAction)
loadIfNecessary()
@ -420,6 +427,23 @@ public class CVLoadCoordinator: NSObject {
loadIfNecessary()
}
public func enqueueReload(
updatedInteractionIds: Set<String>,
deletedInteractionIds: Set<String>,
preferredScrollContinuityAnchorInteractionId: String,
) {
AssertIsOnMainThread()
loadRequestBuilder.reload(
updatedInteractionIds: updatedInteractionIds,
deletedInteractionIds: deletedInteractionIds,
)
loadRequestBuilder.reload(
preferredScrollContinuityAnchorInteractionId: preferredScrollContinuityAnchorInteractionId,
)
loadIfNecessary()
}
public func enqueueReloadWithoutCaches() {
AssertIsOnMainThread()
@ -616,7 +640,7 @@ public class CVLoadCoordinator: NSObject {
}
let renderState = update.renderState
let updateToken = delegate.willUpdateWithNewRenderState(renderState)
let updateToken = delegate.willUpdateWithNewRenderState(update)
self.renderState = renderState

View File

@ -87,6 +87,7 @@ struct CVLoadRequest {
let canReuseInteractionModels: Bool
let canReuseComponentStates: Bool
let didReset: Bool
let preferredScrollContinuityAnchorInteractionId: String?
var isInitialLoad: Bool {
switch loadType {
@ -147,6 +148,7 @@ struct CVLoadRequest {
private var canReuseInteractionModels = true
private var canReuseComponentStates = true
private var didReset = false
private var preferredScrollContinuityAnchorInteractionId: String?
mutating func reload(
updatedInteractionIds: Set<String>,
@ -232,6 +234,13 @@ struct CVLoadRequest {
shouldLoad = true
}
mutating func reload(preferredScrollContinuityAnchorInteractionId: String) {
AssertIsOnMainThread()
self.preferredScrollContinuityAnchorInteractionId = preferredScrollContinuityAnchorInteractionId
reload()
}
mutating func reloadWithoutCaches() {
reload(canReuseInteractionModels: false, canReuseComponentStates: false, didReset: true)
}
@ -265,6 +274,7 @@ struct CVLoadRequest {
canReuseInteractionModels: canReuseInteractionModels,
canReuseComponentStates: canReuseComponentStates,
didReset: didReset,
preferredScrollContinuityAnchorInteractionId: preferredScrollContinuityAnchorInteractionId,
)
}
}

View File

@ -80,6 +80,10 @@ public class CVLoader: NSObject {
localAci: localAci,
transaction: transaction,
)
let preprocessingContext = MessageLoaderPreprocessingContext(
thread: loadContext.thread,
oldestUnreadSortId: viewStateSnapshot.oldestUnreadMessageSortId,
)
// Don't cache in the reset() case.
let canReuseInteractions = loadRequest.canReuseInteractionModels && !loadRequest.didReset
@ -132,30 +136,35 @@ public class CVLoader: NSObject {
focusMessageId: focusMessageIdOnOpen,
reusableInteractions: [:],
deletedInteractionIds: [],
preprocessingContext: preprocessingContext,
tx: transaction,
)
case .loadSameLocation:
try messageLoader.loadSameLocation(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: transaction,
)
case .loadOlder:
try messageLoader.loadOlderMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: transaction,
)
case .loadNewer:
try messageLoader.loadNewerMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: transaction,
)
case .loadNewest:
try messageLoader.loadNewestMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: transaction,
)
case .loadPageAroundInteraction(let interactionId, _):
@ -163,6 +172,7 @@ public class CVLoader: NSObject {
aroundInteractionId: interactionId,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: transaction,
)
}
@ -171,36 +181,18 @@ public class CVLoader: NSObject {
throw error
}
let initialLoadCount = messageLoader.loadedInteractions.count
var processedInteractions = Self.preprocessInteractions(
messageLoader.loadedInteractions,
loadContext: loadContext,
)
if case .loadInitialMapping = loadRequest.loadType {
let maxExtraLoads = 5
var extraLoads = 0
while
processedInteractions.count < initialLoadCount,
messageLoader.canLoadOlder,
extraLoads < maxExtraLoads
let expandedInteractions = messageLoader.loadedDisplayableInteractions.flatMap { interaction in
if
let collapseSet = interaction as? CollapseSetInteraction,
viewStateSnapshot.expandedCollapseSetIds.contains(collapseSet.uniqueId)
{
try messageLoader.loadOlderMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: transaction,
)
processedInteractions = Self.preprocessInteractions(
messageLoader.loadedInteractions,
loadContext: loadContext,
)
extraLoads += 1
return [collapseSet] + collapseSet.collapsedInteractions
}
return [interaction]
}
let itemModels = self.buildItemModels(
interactions: processedInteractions,
interactions: expandedInteractions,
loadContext: loadContext,
updatedInteractionIds: updatedInteractionIds,
localAci: localAci,
@ -272,214 +264,6 @@ public class CVLoader: NSObject {
return itemModelBuilder.buildItems(localAci: localAci, interactions: interactions)
}
// MARK: - Interaction Preprocessing
private static let maxCollapseSetSize = 50
/// Takes a list of interactions and applies preprocessing before the expensive task of creating `CVItemModel`s via `CVItemModelBuilder.buildItems`.
///
/// 1. Inserts date headers
/// 2. Inserts unread indicator
/// 3. Collapses chat events
private static func preprocessInteractions(
_ interactions: [TSInteraction],
loadContext: CVLoadContext,
) -> [TSInteraction] {
let thread = loadContext.thread
let isGroupThread = thread.isGroupThread
let expandedCollapseSets = loadContext.viewStateSnapshot.expandedCollapseSets
let oldestUnreadSortId = loadContext.viewStateSnapshot.oldestUnreadMessageSortId
let todayDate = Date()
var result = [TSInteraction]()
var currentRun = [TSInteraction]()
var currentRunType: CollapseSetInteraction.MessagesType?
var pastUnreadIndicator = false
var shouldShowDateOnNextViewItem = true
var previousDaysBeforeToday: Int?
func finalizeSet() {
defer {
currentRun.removeAll()
currentRunType = nil
}
guard currentRun.count >= 2, let runType = currentRunType else {
result.append(contentsOf: currentRun)
return
}
let collapseId = "CollapseSet_\(currentRun[0].timestamp)"
let isExpanded = expandedCollapseSets.contains(collapseId)
let collapseSetInteraction = CollapseSetInteraction(
thread: thread,
collapsedInteractions: currentRun,
collapseSetType: runType,
isExpanded: isExpanded,
)
result.append(collapseSetInteraction)
if isExpanded {
result.append(contentsOf: currentRun)
}
}
for interaction in interactions {
let timestamp = interaction.timestamp
let daysBeforeToday = DateUtil.daysFrom(
firstDate: Date(millisecondsSince1970: timestamp),
toSecondDate: todayDate,
)
if let previousDaysBeforeToday {
if daysBeforeToday != previousDaysBeforeToday {
shouldShowDateOnNextViewItem = true
}
} else {
// Only show for the first item if the date is not today
shouldShowDateOnNextViewItem = daysBeforeToday != 0
}
if
shouldShowDateOnNextViewItem,
canShowDateHeader(before: interaction)
{
// Collapse sets shouldn't cross date boundaries
finalizeSet()
result.append(DateHeaderInteraction(thread: thread, timestamp: timestamp))
shouldShowDateOnNextViewItem = false
}
previousDaysBeforeToday = daysBeforeToday
// Only insert one unread indicator and don't collapse unread events
if pastUnreadIndicator {
result.append(interaction)
continue
}
if let oldestUnreadSortId, oldestUnreadSortId <= interaction.sortId {
finalizeSet()
let unreadIndicatorInteraction = UnreadIndicatorInteraction(
thread: thread,
timestamp: timestamp,
receivedAtTimestamp: interaction.receivedAtTimestamp,
)
result.append(unreadIndicatorInteraction)
pastUnreadIndicator = true
result.append(interaction)
continue
}
guard BuildFlags.collapsingChatEvents else {
result.append(interaction)
continue
}
let collapseType = collapseSetType(for: interaction, isGroupThread: isGroupThread)
if let collapseType {
let isDifferentSetThanCurrentRun = currentRunType != nil && currentRunType != collapseType
let exceededCurrentRunLimit = currentRun.count >= maxCollapseSetSize
if isDifferentSetThanCurrentRun || exceededCurrentRunLimit {
finalizeSet()
}
currentRun.append(interaction)
currentRunType = collapseType
} else {
finalizeSet()
result.append(interaction)
}
}
finalizeSet()
return result
}
private static func canShowDateHeader(before interaction: TSInteraction) -> Bool {
switch interaction.interactionType {
case .unknown, .typingIndicator, .threadDetails, .dateHeader, .unknownThreadWarning, .defaultDisappearingMessageTimer, .collapseSet:
return false
case .info:
guard let infoMessage = interaction as? TSInfoMessage else {
owsFailDebug("Invalid interaction.")
return false
}
// Only show the date for non-synced thread messages;
return infoMessage.messageType != .syncedThread
case .unreadIndicator, .incomingMessage, .outgoingMessage, .error, .call:
return true
}
}
private static func collapseSetType(
for interaction: TSInteraction,
isGroupThread: Bool,
) -> CollapseSetInteraction.MessagesType? {
switch interaction.interactionType {
case .info:
guard let infoMessage = interaction as? TSInfoMessage else {
owsFailDebug("info interaction is not TSInfoMessage")
return nil
}
switch infoMessage.messageType {
case .typeDisappearingMessagesUpdate:
return .timerChanges
case .typeGroupUpdate:
if
let wrapper = infoMessage.infoMessageUserInfo?[.groupUpdateItems]
as? TSInfoMessage.PersistableGroupUpdateItemsWrapper
{
for event in wrapper.updateItems {
switch event {
case
.groupTerminatedByLocalUser,
.groupTerminatedByOtherUser,
.groupTerminatedByUnknownUser:
return nil
case
.disappearingMessagesEnabledByLocalUser,
.disappearingMessagesEnabledByOtherUser,
.disappearingMessagesEnabledByUnknownUser,
.disappearingMessagesDisabledByLocalUser,
.disappearingMessagesDisabledByOtherUser,
.disappearingMessagesDisabledByUnknownUser:
return .timerChanges
default:
break
}
}
}
return isGroupThread ? .groupUpdates : .chatUpdates
case .verificationStateChange,
.profileUpdate,
.phoneNumberChange,
.typeEndPoll,
.typePinnedMessage:
return isGroupThread ? .groupUpdates : .chatUpdates
default:
return nil
}
case .error:
guard let errorMessage = interaction as? TSErrorMessage else {
owsFailDebug("error interaction is not TSErrorMessage")
return nil
}
if errorMessage.errorType == .nonBlockingIdentityChange {
return isGroupThread ? .groupUpdates : .chatUpdates
}
return nil
case .call:
// Don't collapse an active group call.
if
let groupCallMessage = interaction as? OWSGroupCallMessage,
!groupCallMessage.hasEnded
{
return nil
}
return .callEvents
default:
return nil
}
}
// MARK: -
#if USE_DEBUG_UI
public static func debugui_buildStandaloneRenderItem(

View File

@ -42,7 +42,7 @@ struct CVViewStateSnapshot {
let hasActiveCall: Bool
let currentGroupThreadCallGroupId: GroupIdentifier?
let expandedCollapseSets: Set<String>
let expandedCollapseSetIds: Set<String>
private static var currentCallProvider: any CurrentCallProvider { DependenciesBridge.shared.currentCallProvider }
@ -64,7 +64,7 @@ struct CVViewStateSnapshot {
oldestUnreadMessageSortId: oldestUnreadMessageSortId,
hasActiveCall: currentCallProvider.hasCurrentCall,
currentGroupThreadCallGroupId: currentCallProvider.currentGroupThreadCallGroupId,
expandedCollapseSets: viewState.expandedCollapseSets,
expandedCollapseSetIds: viewState.expandedCollapseSets,
)
}
@ -84,7 +84,7 @@ struct CVViewStateSnapshot {
oldestUnreadMessageSortId: nil,
hasActiveCall: false,
currentGroupThreadCallGroupId: nil,
expandedCollapseSets: [],
expandedCollapseSetIds: [],
)
}
}

View File

@ -7,11 +7,13 @@ import Foundation
import SignalServiceKit
private enum Constants {
/// The maximum number of interactions to keep in memory. We start dropping
/// interactions (in an LRU fashion) once we've exceeded this value.
/// The maximum number of top-level interactions to keep in memory. We start
/// dropping interactions (in an LRU fashion) once we've exceeded this value.
///
/// TODO: Should we reduce this value?
static let maxInteractionCount = 500
static let maxDisplayableInteractionCount = 500
static let maxCollapseSetSize = 50
}
protocol MessageLoaderBatchFetcher {
@ -28,11 +30,19 @@ protocol MessageLoaderInteractionFetcher {
// MARK: -
struct MessageLoaderPreprocessingContext {
let thread: TSThread
let oldestUnreadSortId: UInt64?
}
// MARK: -
class MessageLoader {
private let batchFetcher: MessageLoaderBatchFetcher
private let interactionFetchers: [MessageLoaderInteractionFetcher]
private(set) var loadedInteractions: [TSInteraction] = []
private(set) var loadedDisplayableInteractions: [TSInteraction] = []
/// If true, there might be older messages that could be loaded. If false,
/// we believe we've reached the beginning of the chat.
@ -90,10 +100,61 @@ class MessageLoader {
case sameLocation
}
/// A single display unit: one standalone interaction or a collapse set.
private struct LoadedSegment {
/// Either a single item to be displayed or multiple updates to be
/// grouped in a collapse set.
var rawInteractions: [TSInteraction]
/// Zero or more generated elements (date header or unread indicator)
/// followed by the elements to be displayed. The single raw item
/// itself, or a collapse set which would be followed by
/// `rawInteractions` if expanded.
var displayableInteractions: [TSInteraction]
}
/// Groups raw interactions with the displayable interactions they produce
/// during preprocessing, so trimming can drop complete display units.
private struct LoadedPage {
let segments: [LoadedSegment]
var rawInteractions: [TSInteraction] {
segments.flatMap(\.rawInteractions)
}
var displayableInteractions: [TSInteraction] {
segments.flatMap(\.displayableInteractions)
}
var rawInteractionCount: Int {
segments.lazy.map(\.rawInteractions.count).reduce(0, +)
}
func trimmingDisplayableInteractions(
trimOlder: Bool,
) -> LoadedPage {
let segments = trimOlder ? self.segments.reversed() : self.segments
var trimmedSegments: [LoadedSegment] = []
var displayableCount = 0
for segment in segments {
let segmentDisplayableCount = segment.displayableInteractions.count
displayableCount += segmentDisplayableCount
guard displayableCount <= Constants.maxDisplayableInteractionCount else {
break
}
trimmedSegments.append(segment)
}
if trimOlder {
trimmedSegments.reverse()
}
return LoadedPage(segments: trimmedSegments)
}
}
func loadMessagePage(
aroundInteractionId interactionUniqueId: String,
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
tx: DBReadTransaction,
) throws {
try ensureLoaded(
@ -101,6 +162,7 @@ class MessageLoader {
count: initialLoadCount,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
}
@ -108,6 +170,7 @@ class MessageLoader {
func loadNewerMessagePage(
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
tx: DBReadTransaction,
) throws {
try ensureLoaded(
@ -115,6 +178,7 @@ class MessageLoader {
count: initialLoadCount * 2,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
}
@ -122,6 +186,7 @@ class MessageLoader {
func loadOlderMessagePage(
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
tx: DBReadTransaction,
) throws {
try ensureLoaded(
@ -129,6 +194,7 @@ class MessageLoader {
count: initialLoadCount * 2,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
}
@ -136,6 +202,7 @@ class MessageLoader {
func loadNewestMessagePage(
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
tx: DBReadTransaction,
) throws {
try ensureLoaded(
@ -143,6 +210,7 @@ class MessageLoader {
count: initialLoadCount,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
}
@ -151,6 +219,7 @@ class MessageLoader {
focusMessageId: String?,
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
tx: DBReadTransaction,
) throws {
if let focusMessageId {
@ -159,12 +228,14 @@ class MessageLoader {
count: initialLoadCount,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
} else {
try loadNewestMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
}
@ -173,13 +244,15 @@ class MessageLoader {
func loadSameLocation(
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
tx: DBReadTransaction,
) throws {
try ensureLoaded(
.sameLocation,
count: max(initialLoadCount, loadedInteractions.count),
count: max(initialLoadCount, loadedDisplayableInteractions.count),
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
}
@ -195,21 +268,122 @@ class MessageLoader {
count: Int,
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext?,
tx: DBReadTransaction,
) throws {
owsAssertDebug(count > 0)
let count = count.clamp(1, Constants.maxInteractionCount)
let loadBatch = try buildLoadBatch(
let maxRawInteractionFetchCount = Constants.maxDisplayableInteractionCount * Constants.maxCollapseSetSize
let count = count.clamp(1, maxRawInteractionFetchCount)
let loadedDisplayableCount = loadedDisplayableInteractions.count
let desiredDisplayableInteractionCount: Int = switch direction {
case .older, .newer:
loadedDisplayableCount + count
case .sameLocation:
max(initialLoadCount, loadedDisplayableCount)
case .around, .newest:
count
}
var loadBatch = try buildLoadBatch(
direction,
count: count,
deletedInteractionIds: deletedInteractionIds,
tx: tx,
)
loadedInteractions = fetchInteractions(
uniqueIds: loadBatch.uniqueIds,
var loadedPage = buildLoadedPage(
for: loadBatch,
reusableInteractions: reusableInteractions,
preprocessingContext: preprocessingContext,
tx: tx,
)
func loadMoreIfNeeded(context: MessageLoaderPreprocessingContext) throws -> Bool {
let loadedDisplayableInteractionCount = loadedPage.displayableInteractions.count
guard loadedDisplayableInteractionCount < desiredDisplayableInteractionCount else {
return false
}
// Heuristically adjust fetch size based on the proportion of
// messages so far that are collapsed.
let remainingCount = desiredDisplayableInteractionCount - loadedDisplayableInteractionCount
let estimatedRawInteractionsPerDisplayableInteraction = min(
Constants.maxCollapseSetSize,
max(
1,
Int(ceil(Double(loadedPage.rawInteractionCount) / Double(max(loadedDisplayableInteractionCount, 1)))),
),
)
let fetchCount = min(
maxRawInteractionFetchCount,
max(count, remainingCount * estimatedRawInteractionsPerDisplayableInteraction),
)
guard fetchCount > 0 else {
return false
}
func fetchOlder() throws -> Bool {
guard
loadBatch.canLoadOlder,
let firstInteraction = loadedPage.segments.first?.rawInteractions.first,
let rowId = firstInteraction.sqliteRowId
else {
return false
}
return try self.fetchOlder(before: rowId, count: fetchCount, batch: &loadBatch, tx: tx) > 0
}
func fetchNewer() throws -> Bool {
guard
loadBatch.canLoadNewer,
let lastInteraction = loadedPage.segments.last?.rawInteractions.last,
let rowId = lastInteraction.sqliteRowId
else {
return false
}
return try self.fetchNewer(after: rowId, count: fetchCount, batch: &loadBatch, tx: tx) > 0
}
let didLoadMore: Bool
switch direction {
case .older, .newest:
didLoadMore = try fetchOlder()
case .newer:
didLoadMore = try fetchNewer()
case .sameLocation, .around:
if try fetchOlder() {
didLoadMore = true
} else {
didLoadMore = try fetchNewer()
}
}
guard didLoadMore else {
return false
}
loadedPage = buildLoadedPage(
for: loadBatch,
reusableInteractions: reusableInteractions,
preprocessingContext: context,
tx: tx,
)
return true
}
if let preprocessingContext {
while try loadMoreIfNeeded(context: preprocessingContext) {
// Loading more messages...
}
}
trimLoadedPageIfNeeded(
&loadBatch,
loadedPage: &loadedPage,
loadDirection: direction,
)
loadedInteractions = loadedPage.rawInteractions
loadedDisplayableInteractions = loadedPage.displayableInteractions
canLoadNewer = loadBatch.canLoadNewer
canLoadOlder = loadBatch.canLoadOlder
}
@ -228,24 +402,6 @@ class MessageLoader {
)
}
/// Expands `batch` with `count` messages preceding `rowId`.
@discardableResult
func fetchOlder(before rowId: Int64, count: Int, batch: inout MessageLoaderBatch) throws -> Int {
let uniqueIds: [String] = try fetch(filter: .before(rowId), limit: count)
batch.insertOlder(uniqueIds: uniqueIds, didReachOldest: uniqueIds.count < count)
batch.trimNewer()
return uniqueIds.count
}
/// Expands `batch` with `count` messages succeeding `rowId`.
@discardableResult
func fetchNewer(after rowId: Int64, count: Int, batch: inout MessageLoaderBatch) throws -> Int {
let uniqueIds: [String] = try fetch(filter: .after(rowId), limit: count)
batch.insertNewer(uniqueIds: uniqueIds, didReachNewest: uniqueIds.count < count)
batch.trimOlder()
return uniqueIds.count
}
/// Fetches uniqueIds in the range of provided rowIds.
func fetchRange(_ rowIds: ClosedRange<Int64>) throws -> [String] {
return try fetch(filter: .range(rowIds), limit: rowIds.count)
@ -265,8 +421,8 @@ class MessageLoader {
return try loadNewest()
}
var batch = MessageLoaderBatch(canLoadNewer: true, canLoadOlder: true, uniqueIds: [uniqueId])
let olderCount = try fetchOlder(before: rowId, count: count / 2, batch: &batch)
try fetchNewer(after: rowId, count: count - olderCount, batch: &batch)
let olderCount = try fetchOlder(before: rowId, count: count / 2, batch: &batch, tx: tx)
try fetchNewer(after: rowId, count: count - olderCount, batch: &batch, tx: tx)
return batch
}
@ -311,7 +467,7 @@ class MessageLoader {
return batch
case .older:
var batch = priorLoad.batch
try fetchOlder(before: priorLoad.range.lowerBound, count: count, batch: &batch)
try fetchOlder(before: priorLoad.range.lowerBound, count: count, batch: &batch, tx: tx)
return batch
case .sameLocation where !priorLoad.batch.canLoadNewer:
// If we're loading at the same location and are already at the end of the
@ -319,13 +475,13 @@ class MessageLoader {
fallthrough
case .newer:
var batch = priorLoad.batch
try fetchNewer(after: priorLoad.range.upperBound, count: count, batch: &batch)
try fetchNewer(after: priorLoad.range.upperBound, count: count, batch: &batch, tx: tx)
return batch
case .sameLocation:
var batch = priorLoad.batch
if batch.uniqueIds.count < initialLoadCount {
try fetchOlder(before: priorLoad.range.lowerBound, count: initialLoadCount, batch: &batch)
try fetchNewer(after: priorLoad.range.upperBound, count: initialLoadCount, batch: &batch)
try fetchOlder(before: priorLoad.range.lowerBound, count: initialLoadCount, batch: &batch, tx: tx)
try fetchNewer(after: priorLoad.range.upperBound, count: initialLoadCount, batch: &batch, tx: tx)
}
return batch
case .around(interactionUniqueId: let uniqueId):
@ -343,6 +499,32 @@ class MessageLoader {
}
}
/// Expands `batch` with `count` messages preceding `rowId`.
@discardableResult
private func fetchOlder(
before rowId: Int64,
count: Int,
batch: inout MessageLoaderBatch,
tx: DBReadTransaction,
) throws -> Int {
let uniqueIds = try batchFetcher.fetchUniqueIds(filter: .before(rowId), limit: count, tx: tx)
batch.insertOlder(uniqueIds: uniqueIds, didReachOldest: uniqueIds.count < count)
return uniqueIds.count
}
/// Expands `batch` with `count` messages succeeding `rowId`.
@discardableResult
private func fetchNewer(
after rowId: Int64,
count: Int,
batch: inout MessageLoaderBatch,
tx: DBReadTransaction,
) throws -> Int {
let uniqueIds = try batchFetcher.fetchUniqueIds(filter: .after(rowId), limit: count, tx: tx)
batch.insertNewer(uniqueIds: uniqueIds, didReachNewest: uniqueIds.count < count)
return uniqueIds.count
}
private func fetchInteractions(
uniqueIds interactionIds: [String],
reusableInteractions: [String: TSInteraction] = [:],
@ -360,6 +542,268 @@ class MessageLoader {
}
return refinery.values.compacted()
}
private func buildLoadedPage(
for batch: MessageLoaderBatch,
reusableInteractions: [String: TSInteraction],
preprocessingContext: MessageLoaderPreprocessingContext?,
tx: DBReadTransaction,
) -> LoadedPage {
let rawInteractions = fetchInteractions(
uniqueIds: batch.uniqueIds,
reusableInteractions: reusableInteractions,
tx: tx,
)
return LoadedPage(
segments: Self.preprocessInteractions(
rawInteractions,
preprocessingContext: preprocessingContext,
),
)
}
private func trimLoadedPageIfNeeded(
_ loadBatch: inout MessageLoaderBatch,
loadedPage: inout LoadedPage,
loadDirection: LoadWindowDirection,
) {
guard loadedPage.displayableInteractions.count > Constants.maxDisplayableInteractionCount else {
return
}
let trimOlder: Bool = switch loadDirection {
case .newer, .around, .newest, .sameLocation:
true
case .older:
false
}
loadedPage = loadedPage.trimmingDisplayableInteractions(trimOlder: trimOlder)
loadBatch.uniqueIds = loadedPage.rawInteractions.map(\.uniqueId)
if trimOlder {
loadBatch.canLoadOlder = true
} else {
loadBatch.canLoadNewer = true
}
}
/// Converts interactions into page segments. When a preprocessing context
/// is provided, this also inserts dynamic items (date headers and unread
/// indicators) and collapse sets.
private static func preprocessInteractions(
_ interactions: [TSInteraction],
preprocessingContext: MessageLoaderPreprocessingContext?,
) -> [LoadedSegment] {
guard let preprocessingContext else {
return interactions.map { interaction in
LoadedSegment(rawInteractions: [interaction], displayableInteractions: [interaction])
}
}
let thread = preprocessingContext.thread
let isGroupThread = thread.isGroupThread
let oldestUnreadSortId = preprocessingContext.oldestUnreadSortId
let todayDate = Date()
var result = [LoadedSegment]()
var pendingDisplayableInteractions = [TSInteraction]()
var currentRun = [TSInteraction]()
var currentRunType: CollapseSetInteraction.MessagesType?
var pastUnreadIndicator = false
var shouldShowDateOnNextViewItem = true
var previousDaysBeforeToday: Int?
func appendItem(_ interaction: TSInteraction) {
result.append(LoadedSegment(
rawInteractions: [interaction],
displayableInteractions: pendingDisplayableInteractions + [interaction],
))
pendingDisplayableInteractions.removeAll()
}
func finalizeSet() {
defer {
currentRun.removeAll()
currentRunType = nil
}
guard !currentRun.isEmpty else {
return
}
guard currentRun.count >= 2, let runType = currentRunType else {
for interaction in currentRun {
appendItem(interaction)
}
return
}
let collapseSetInteraction = CollapseSetInteraction(
thread: thread,
collapsedInteractions: currentRun,
collapseSetType: runType,
)
result.append(LoadedSegment(
rawInteractions: currentRun,
displayableInteractions: pendingDisplayableInteractions + [collapseSetInteraction],
))
pendingDisplayableInteractions.removeAll()
}
for interaction in interactions {
let timestamp = interaction.timestamp
let daysBeforeToday = DateUtil.daysFrom(
firstDate: Date(millisecondsSince1970: timestamp),
toSecondDate: todayDate,
)
if let previousDaysBeforeToday {
if daysBeforeToday != previousDaysBeforeToday {
shouldShowDateOnNextViewItem = true
}
} else {
// Only show for the first item if the date is not today
shouldShowDateOnNextViewItem = daysBeforeToday != 0
}
if
shouldShowDateOnNextViewItem,
canShowDateHeader(before: interaction)
{
// Collapse sets shouldn't cross date boundaries
finalizeSet()
pendingDisplayableInteractions.append(DateHeaderInteraction(thread: thread, timestamp: timestamp))
shouldShowDateOnNextViewItem = false
}
previousDaysBeforeToday = daysBeforeToday
// Only insert one unread indicator and don't collapse unread events
if pastUnreadIndicator {
appendItem(interaction)
continue
}
if let oldestUnreadSortId, oldestUnreadSortId <= interaction.sortId {
finalizeSet()
let unreadIndicatorInteraction = UnreadIndicatorInteraction(
thread: thread,
timestamp: timestamp,
receivedAtTimestamp: interaction.receivedAtTimestamp,
)
pendingDisplayableInteractions.append(unreadIndicatorInteraction)
pastUnreadIndicator = true
appendItem(interaction)
continue
}
guard BuildFlags.collapsingChatEvents else {
appendItem(interaction)
continue
}
let collapseType = collapseSetType(for: interaction, isGroupThread: isGroupThread)
if let collapseType {
let isDifferentSetThanCurrentRun = currentRunType != nil && currentRunType != collapseType
let exceededCurrentRunLimit = currentRun.count >= Constants.maxCollapseSetSize
if isDifferentSetThanCurrentRun || exceededCurrentRunLimit {
finalizeSet()
}
currentRun.append(interaction)
currentRunType = collapseType
} else {
finalizeSet()
appendItem(interaction)
}
}
finalizeSet()
return result
}
private static func canShowDateHeader(before interaction: TSInteraction) -> Bool {
switch interaction.interactionType {
case .unknown, .typingIndicator, .threadDetails, .dateHeader, .unknownThreadWarning, .defaultDisappearingMessageTimer, .collapseSet:
return false
case .info:
guard let infoMessage = interaction as? TSInfoMessage else {
owsFailDebug("Invalid interaction.")
return false
}
// Only show the date for non-synced thread messages;
return infoMessage.messageType != .syncedThread
case .unreadIndicator, .incomingMessage, .outgoingMessage, .error, .call:
return true
}
}
private static func collapseSetType(
for interaction: TSInteraction,
isGroupThread: Bool,
) -> CollapseSetInteraction.MessagesType? {
switch interaction.interactionType {
case .info:
guard let infoMessage = interaction as? TSInfoMessage else {
owsFailDebug("info interaction is not TSInfoMessage")
return nil
}
switch infoMessage.messageType {
case .typeDisappearingMessagesUpdate:
return .timerChanges
case .typeGroupUpdate:
if
let wrapper = infoMessage.infoMessageUserInfo?[.groupUpdateItems]
as? TSInfoMessage.PersistableGroupUpdateItemsWrapper
{
for event in wrapper.updateItems {
switch event {
case
.groupTerminatedByLocalUser,
.groupTerminatedByOtherUser,
.groupTerminatedByUnknownUser:
return nil
case
.disappearingMessagesEnabledByLocalUser,
.disappearingMessagesEnabledByOtherUser,
.disappearingMessagesEnabledByUnknownUser,
.disappearingMessagesDisabledByLocalUser,
.disappearingMessagesDisabledByOtherUser,
.disappearingMessagesDisabledByUnknownUser:
return .timerChanges
default:
break
}
}
}
return isGroupThread ? .groupUpdates : .chatUpdates
case .verificationStateChange,
.profileUpdate,
.phoneNumberChange,
.typeEndPoll,
.typePinnedMessage:
return isGroupThread ? .groupUpdates : .chatUpdates
default:
return nil
}
case .error:
guard let errorMessage = interaction as? TSErrorMessage else {
owsFailDebug("error interaction is not TSErrorMessage")
return nil
}
if errorMessage.errorType == .nonBlockingIdentityChange {
return isGroupThread ? .groupUpdates : .chatUpdates
}
return nil
case .call:
// Don't collapse an active group call.
if
let groupCallMessage = interaction as? OWSGroupCallMessage,
!groupCallMessage.hasEnded
{
return nil
}
return .callEvents
default:
return nil
}
}
}
// MARK: -
@ -447,8 +891,6 @@ struct MessageLoaderBatch {
}
uniqueIds = otherUniqueIds.dropLast(overlappingCount) + uniqueIds
mergeCanLoad(otherLoadBatch)
// Make sure we keep all of `self`, so trim entries we just added if needed.
trimOlder()
case (let firstIndex?, nil):
let overlappingCount = uniqueIds.endIndex - firstIndex
guard uniqueIds.suffix(overlappingCount) == otherUniqueIds.prefix(overlappingCount) else {
@ -458,8 +900,6 @@ struct MessageLoaderBatch {
}
uniqueIds += otherUniqueIds.dropFirst(overlappingCount)
mergeCanLoad(otherLoadBatch)
// Make sure we keep all of `self`, so trim entries we just added if needed.
trimNewer()
case (let firstIndex?, let lastIndex?):
guard uniqueIds[firstIndex...lastIndex] == otherUniqueIds[...] else {
// If this breaks, it probably means `deletedInteractionIds` is broken (or
@ -494,24 +934,4 @@ struct MessageLoaderBatch {
canLoadNewer = false
}
}
mutating func trimOlder() {
guard uniqueIds.count > Constants.maxInteractionCount else {
return
}
uniqueIds = Array(uniqueIds.suffix(Constants.maxInteractionCount))
// We trimmed from the beginning. If the oldest had been marked as loaded,
// it's no longer loaded.
canLoadOlder = true
}
mutating func trimNewer() {
guard uniqueIds.count > Constants.maxInteractionCount else {
return
}
uniqueIds = Array(uniqueIds.prefix(Constants.maxInteractionCount))
// We trimmed from the end. If the newest had already been marked as
// loaded, it's no longer loaded.
canLoadNewer = true
}
}

View File

@ -101,6 +101,12 @@ class MessageRequestView: ConversationBottomPanelView {
weak var delegate: MessageRequestDelegate?
// MARK: - ConversationBottomPanelView
override var useGlassPanel: Bool {
false
}
init(threadViewModel: ThreadViewModel) {
let thread = threadViewModel.threadRecord
self.thread = thread
@ -467,13 +473,33 @@ class MessageRequestView: ConversationBottomPanelView {
// MARK: -
private func buttonConfiguration(title: String) -> UIButton.Configuration {
var configuration: UIButton.Configuration
if #available(iOS 26, *) {
configuration = .prominentGlass()
configuration.baseForegroundColor = .Signal.label
} else {
configuration = .plain()
configuration.baseForegroundColor = .Signal.accent
}
configuration.titleAlignment = .center
configuration.titleTextAttributesTransformer = .defaultFont(.dynamicTypeHeadlineClamped)
configuration.baseBackgroundColor = .clear
if #available(iOS 26, *) {
configuration.cornerStyle = .capsule
}
configuration.title = title
configuration.contentInsets = NSDirectionalEdgeInsets(hMargin: 12, vMargin: 8)
return configuration
}
private func prepareButton(
title: String,
destructive: Bool = false,
actionBlock: @escaping () -> Void,
) -> UIButton {
let button = UIButton(
configuration: .mediumSecondary(title: title),
configuration: buttonConfiguration(title: title),
primaryAction: UIAction { _ in
actionBlock()
},

View File

@ -47,11 +47,12 @@ enum ContactSupportActionSheet {
let submitWithLogAction = ActionSheetAction(title: submitWithLogTitle, style: .default) { [weak fromViewController] _ in
guard let fromViewController else { return }
let logs = DebugLogs(dumper: logDumper)
let emailRequest = SupportEmailModel(
userDescription: nil,
emojiMood: nil,
supportFilter: emailFilter.asString,
debugLogPolicy: .requireUpload(logDumper),
debugLogPolicy: .requireUpload(logs),
hasRecentChallenge: logDumper.challengeReceivedRecently(),
)

View File

@ -8,7 +8,7 @@ import SignalServiceKit
import SignalUI
import zlib
public struct DebugLogDumper {
struct DebugLogDumper {
fileprivate var accountManager: (any TSAccountManager)?
fileprivate var appVersion: any AppVersion
fileprivate var db: (any DB)?
@ -25,7 +25,7 @@ public struct DebugLogDumper {
)
}
public func challengeReceivedRecently() -> Bool {
func challengeReceivedRecently() -> Bool {
guard let db else {
return false
}
@ -57,34 +57,134 @@ public struct DebugLogDumper {
}
}
enum DebugLogs {
final class DebugLogs {
private let dumper: DebugLogDumper
private var logsDirPath: String?
init(dumper: DebugLogDumper) {
self.dumper = dumper
self.logsDirPath = DebugLogs.collectAndFlushLogs(dumper: dumper)
}
deinit {
if let logsDirPath {
OWSFileSystem.deleteFile(logsDirPath)
}
}
func showPreview(
from viewController: UIViewController,
onSubmit: (() -> Void)? = nil,
onCancel: (() -> Void)? = nil,
) {
guard let logsDirPath else {
Logger.error("No logs path found for preview")
handleError(error: .noLogs, viewController: viewController)
onCancel?()
return
}
let logFilePaths = ((try? FileManager.default.contentsOfDirectory(atPath: logsDirPath)) ?? []).map {
URL(fileURLWithPath: logsDirPath).appendingPathComponent($0).path
}
let previewVC = DebugLogPreviewViewController(logFilePaths: logFilePaths, onSubmit: onSubmit, onCancel: onCancel)
let nav = OWSNavigationController(rootViewController: previewVC)
viewController.present(nav, animated: true)
}
/// Presents a log preview with an option to submit. Completion is only
/// called if the user submits, after the submission is completed.
@MainActor
static func submitLogs(supportTag: String? = nil, dumper: DebugLogDumper, completion: (() -> Void)? = nil) {
let submitLogsCompletion = {
if let completion {
// Wait a moment. If the user opens a URL, it needs a moment to complete.
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
func promptToSubmitLogs(
from viewController: UIViewController,
supportTag: String? = nil,
completion: (() -> Void)? = nil,
) {
showPreview(from: viewController, onSubmit: {
Task {
await viewController.awaitableDismiss(animated: true)
await self.submitLogs(supportTag: supportTag)
if let completion {
try? await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
completion()
}
}
}
})
}
@MainActor
func promptToSubmitLogs(
from viewController: UIViewController,
supportTag: String? = nil,
) async {
let didSubmit = await withCheckedContinuation { continuation in
showPreview(
from: viewController,
onSubmit: {
continuation.resume(returning: true)
},
onCancel: {
continuation.resume(returning: false)
},
)
}
if didSubmit {
await viewController.awaitableDismiss(animated: true)
await submitLogs(supportTag: supportTag)
}
}
enum DebugLogsError: LocalizedError {
case noLogs
case couldNotPackageLogs
case uploadError(zipFilePath: String)
var errorDescription: String? { localizedErrorMessage }
var localizedErrorMessage: String {
switch self {
case .noLogs:
OWSLocalizedString(
"DEBUG_LOG_ALERT_NO_LOGS",
comment: "Error indicating that no debug logs could be found.",
)
case .couldNotPackageLogs:
OWSLocalizedString(
"DEBUG_LOG_ALERT_COULD_NOT_PACKAGE_LOGS",
comment: "Error indicating that the debug logs could not be packaged.",
)
case .uploadError:
OWSLocalizedString(
"DEBUG_LOG_ALERT_ERROR_UPLOADING_LOG",
comment: "Error indicating that a debug log could not be uploaded.",
)
}
}
}
@MainActor
private func submitLogs(supportTag: String?) async {
var supportFilter = "Signal - iOS Debug Log"
if let supportTag {
supportFilter += " - \(supportTag)"
}
guard let frontmostViewController = UIApplication.shared.frontmostViewControllerIgnoringAlerts else {
submitLogsCompletion()
return
}
uploadLogsUsingViewController(frontmostViewController, dumper: dumper) { url in
guard let presentingViewController = UIApplication.shared.frontmostViewControllerIgnoringAlerts else {
submitLogsCompletion()
return
}
let url: URL?
do {
url = try await uploadLogsWithUI(from: frontmostViewController)
} catch {
self.handleError(error: error, viewController: frontmostViewController)
return
}
guard let url else { return }
guard let presentingViewController = UIApplication.shared.frontmostViewControllerIgnoringAlerts else {
return
}
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
let alert = ActionSheetController(
title: NSLocalizedString("DEBUG_LOG_ALERT_TITLE", comment: "Title of the debug log alert."),
message: NSLocalizedString("DEBUG_LOG_ALERT_MESSAGE", comment: "Message of the debug log alert."),
@ -102,10 +202,10 @@ enum DebugLogs {
await ComposeSupportEmailOperation.sendEmailWithDefaultErrorHandling(
supportFilter: supportFilter,
logUrl: url,
hasRecentChallenge: dumper.challengeReceivedRecently(),
hasRecentChallenge: self.dumper.challengeReceivedRecently(),
)
}
submitLogsCompletion()
continuation.resume()
},
))
}
@ -118,7 +218,7 @@ enum DebugLogs {
handler: { _ in
UIPasteboard.general.string = url.absoluteString
presentingViewController.presentToast(text: CommonStrings.copiedToClipboardToast, image: .copy)
submitLogsCompletion()
continuation.resume()
},
))
alert.addAction(ActionSheetAction(
@ -131,67 +231,39 @@ enum DebugLogs {
AttachmentSharing.showShareUI(
for: url.absoluteString,
sender: nil,
completion: submitLogsCompletion,
completion: { continuation.resume() },
)
},
))
alert.addAction(ActionSheetAction(
title: CommonStrings.cancelButton,
style: .cancel,
handler: { _ in submitLogsCompletion() },
handler: { _ in continuation.resume() },
))
presentingViewController.presentActionSheet(alert)
}
}
@MainActor
private static func uploadLogsUsingViewController(_ viewController: UIViewController, dumper: DebugLogDumper, completion: @escaping (URL) -> Void) {
AssertIsOnMainThread()
ModalActivityIndicatorViewController.present(
fromViewController: viewController,
private func uploadLogsWithUI(from viewController: UIViewController) async throws(DebugLogsError) -> URL? {
return try await ModalActivityIndicatorViewController.presentAndPropagateResult(
from: viewController,
canCancel: true,
asyncBlock: { await _uploadLogs(dumper: dumper, modalActivityIndicator: $0, completion: completion) },
)
}
@MainActor
private static func _uploadLogs(dumper: DebugLogDumper, modalActivityIndicator: ModalActivityIndicatorViewController, completion: @escaping (URL) -> Void) async {
do {
let url = try await uploadLogs(dumper: dumper)
guard !modalActivityIndicator.wasCancelled else { return }
modalActivityIndicator.dismiss {
completion(url)
}
} catch {
guard !modalActivityIndicator.wasCancelled else {
if let logArchiveOrDirectoryPath = error.logArchiveOrDirectoryPath {
OWSFileSystem.deleteFile(logArchiveOrDirectoryPath)
) { () throws(DebugLogsError) -> URL? in
do throws(DebugLogsError) {
return try await self.uploadLogs()
} catch {
if Task.isCancelled {
return nil
}
return
}
modalActivityIndicator.dismiss {
DebugLogs.showFailureAlert(
with: error.localizedErrorMessage,
logArchiveOrDirectoryPath: error.logArchiveOrDirectoryPath,
)
throw error
}
}
}
// MARK: - Collecting & uploading
private struct NoLogsError: Error {
var errorString: String {
OWSLocalizedString(
"DEBUG_LOG_ALERT_NO_LOGS",
comment: "Error indicating that no debug logs could be found.",
)
}
}
private static func collectLogs() -> Result<String, NoLogsError> {
private static func collectLogs() -> String? {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy.MM.dd hh.mm.ss"
let dateString = dateFormatter.string(from: Date())
@ -203,7 +275,7 @@ enum DebugLogs {
let logFilePaths = DebugLogger.shared.allLogFilePaths
if logFilePaths.isEmpty {
return .failure(NoLogsError())
return nil
}
for logFilePath in logFilePaths {
@ -219,50 +291,44 @@ enum DebugLogs {
OWSFileSystem.protectFileOrFolder(atPath: copyFilePath)
}
return .success(zipDirPath)
return zipDirPath
}
static func exportLogs() {
func exportLogs(viewController: UIViewController) {
AssertIsOnMainThread()
switch collectLogs() {
case let .success(logsDirPath):
AttachmentSharing.showShareUI(for: URL(fileURLWithPath: logsDirPath), sender: nil) {
OWSFileSystem.deleteFile(logsDirPath)
}
case let .failure(error):
Self.showFailureAlert(with: error.errorString, logArchiveOrDirectoryPath: nil)
return
guard let logsDirPath else {
return handleError(
error: .noLogs,
viewController: viewController,
)
}
AttachmentSharing.showShareUI(for: URL(fileURLWithPath: logsDirPath), sender: nil) {
OWSFileSystem.deleteFile(logsDirPath)
}
}
struct UploadDebugLogError: Error {
var localizedErrorMessage: String
var logArchiveOrDirectoryPath: String?
}
/// - Note: Various dependencies might not be initialized yet when this
/// method is called from the database recovery flow. Notably, the database
/// isn't available in that flow.
static func uploadLogs(dumper: DebugLogDumper) async throws(UploadDebugLogError) -> URL {
// Phase 1: Dump any additional details that are relevant.
private static func collectAndFlushLogs(
dumper: DebugLogDumper,
) -> String? {
// Dump any additional details that are relevant.
dumper.dump()
Logger.info("About to zip debug logs")
// Phase 2: Flush pending logs to disk.
// Flush pending logs to disk.
Logger.flush()
// Phase 3: Make a local copy of all of the log files.
let zipDirPath: String
switch collectLogs() {
case let .success(logsDirPath):
zipDirPath = logsDirPath
case let .failure(error):
throw UploadDebugLogError(localizedErrorMessage: error.errorString)
// Make a local copy of all of the log files.
return collectLogs()
}
func uploadLogs() async throws(DebugLogsError) -> URL {
guard let logsDirPath else {
throw DebugLogsError.noLogs
}
// Phase 4: Zip up the log files.
let zipDirUrl = URL(fileURLWithPath: zipDirPath)
let zipFileUrl = URL(fileURLWithPath: (zipDirPath as NSString).appendingPathExtension("zip")!)
// Zip up the log files.
let zipDirUrl = URL(fileURLWithPath: logsDirPath)
let zipFileUrl = URL(fileURLWithPath: (logsDirPath as NSString).appendingPathExtension("zip")!)
let fileCoordinator = NSFileCoordinator()
var zipError: NSError?
fileCoordinator.coordinate(readingItemAt: zipDirUrl, options: [.forUploading], error: &zipError) { temporaryFileUrl in
@ -273,38 +339,44 @@ enum DebugLogs {
}
}
if zipError != nil || !OWSFileSystem.fileOrFolderExists(url: zipFileUrl) {
let errorMessage = OWSLocalizedString(
"DEBUG_LOG_ALERT_COULD_NOT_PACKAGE_LOGS",
comment: "Error indicating that the debug logs could not be packaged.",
)
throw UploadDebugLogError(localizedErrorMessage: errorMessage, logArchiveOrDirectoryPath: zipDirPath)
throw DebugLogsError.couldNotPackageLogs
}
OWSFileSystem.protectFileOrFolder(atPath: zipFileUrl.path)
OWSFileSystem.deleteFile(zipDirPath)
// Phase 5: Upload the log files.
// Upload the log files.
do {
let url = try await DebugLogUploader.uploadFile(fileUrl: zipFileUrl, mimeType: MimeType.applicationZip.rawValue)
try OWSFileSystem.deleteFile(url: zipFileUrl)
return url
} catch {
let errorMessage = OWSLocalizedString(
"DEBUG_LOG_ALERT_ERROR_UPLOADING_LOG",
comment: "Error indicating that a debug log could not be uploaded.",
)
throw UploadDebugLogError(localizedErrorMessage: errorMessage, logArchiveOrDirectoryPath: zipFileUrl.path)
throw DebugLogsError.uploadError(zipFilePath: zipFileUrl.path)
}
}
private static func showFailureAlert(with message: String, logArchiveOrDirectoryPath: String?) {
let deleteArchive: (String) -> Void = { filePath in
OWSFileSystem.deleteFile(filePath)
private func handleError(
error: DebugLogsError,
viewController: UIViewController,
) {
let logsPath: String?
let completion: (() -> Void)?
switch error {
case .noLogs:
logsPath = nil
completion = nil
case .couldNotPackageLogs:
logsPath = self.logsDirPath
completion = nil
case .uploadError(let zipFilePath):
logsPath = zipFilePath
completion = {
OWSFileSystem.deleteFile(zipFilePath)
}
}
let alert = ActionSheetController(title: nil, message: message)
let alert = ActionSheetController(message: error.localizedErrorMessage)
if let logArchiveOrDirectoryPath {
if let logsPath {
alert.addAction(.init(
title: OWSLocalizedString(
"DEBUG_LOG_ALERT_OPTION_EXPORT_LOG_ARCHIVE",
@ -312,23 +384,18 @@ enum DebugLogs {
),
) { _ in
AttachmentSharing.showShareUI(
for: URL(fileURLWithPath: logArchiveOrDirectoryPath),
for: URL(fileURLWithPath: logsPath),
sender: nil,
completion: {
deleteArchive(logArchiveOrDirectoryPath)
},
completion: completion,
)
})
}
alert.addAction(.init(title: CommonStrings.okButton) { _ in
if let logArchiveOrDirectoryPath {
deleteArchive(logArchiveOrDirectoryPath)
}
completion?()
})
let presentingViewController = UIApplication.shared.frontmostViewControllerIgnoringAlerts
presentingViewController?.presentActionSheet(alert)
viewController.presentActionSheet(alert)
}
}

View File

@ -32,9 +32,6 @@ extension DeviceTransferService {
let wal: DeviceTransferProtoFile = try {
let file = SSKEnvironment.shared.databaseStorageRef.grdbStorage.databaseWALFilePath
let size = try OWSFileSystem.fileSize(ofPath: file)
guard size > 0 else {
throw OWSAssertionError("database wal is empty")
}
estimatedTotalSize += size
let fileBuilder = DeviceTransferProtoFile.builder(
identifier: DeviceTransferService.databaseWALIdentifier,

View File

@ -5,6 +5,7 @@
import CryptoKit
import Foundation
import GRDB
import MultipeerConnectivity
import SignalServiceKit
@ -366,7 +367,15 @@ class DeviceTransferService: NSObject, DeviceTransferServiceProtocol {
taskGroup.addTask {
// Make a copy of the database files within a write transaction so we can be confident
// they aren't mutated during the copy. We then transfer these copies.
let dbCopy = try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { _ in
let dbCopy = try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
// The MultipeerConnectivity framework stalls if we try to send an empty
// file. The receiver requires a non-empty file. We can't send garbage
// (because that would corrupt the database), so mutate the database, force
// it to be written to the WAL file, and then send that result to our peer.
let store = NewKeyValueStore(collection: "DeviceTransferWAL")
store.writeValue(Randomness.generateRandomBytes(32), forKey: "MustBeNonEmpty", tx: tx)
store.removeValue(forKey: "MustBeNonEmpty", tx: tx)
sqlite3_db_cacheflush(tx.database.sqliteConnection!)
do {
let dbCopy = try Self.makeLocalCopy(databaseFile: database.database)
let walCopy = try Self.makeLocalCopy(databaseFile: database.wal)

View File

@ -113,7 +113,7 @@ extension EmojiReactionPickerConfigViewController: MessageReactionPickerDelegate
present(picker, animated: true)
}
func didSelectAnyEmoji() {
func didSelectShowFullEmojiPicker() {
// No-op for configuration
}
}

View File

@ -1,176 +0,0 @@
%PDF-1.7
1 0 obj
<< >>
endobj
2 0 obj
<< /Length 3 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
q
1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
0.231373 0.231373 0.231373 scn
0.000000 72.000000 m
0.000000 76.418274 3.581722 80.000000 8.000000 80.000000 c
32.000000 80.000000 l
36.418278 80.000000 40.000000 76.418274 40.000000 72.000000 c
40.000000 8.000000 l
40.000000 3.581726 36.418278 0.000000 32.000000 0.000000 c
8.000000 0.000000 l
3.581722 0.000000 0.000000 3.581718 0.000000 8.000000 c
0.000000 72.000000 l
h
f
n
Q
0.000000 72.000000 m
0.000000 76.418274 3.581722 80.000000 8.000000 80.000000 c
32.000000 80.000000 l
36.418278 80.000000 40.000000 76.418274 40.000000 72.000000 c
40.000000 8.000000 l
40.000000 3.581726 36.418278 0.000000 32.000000 0.000000 c
8.000000 0.000000 l
3.581722 0.000000 0.000000 3.581718 0.000000 8.000000 c
0.000000 72.000000 l
h
W*
n
q
1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
0.368627 0.368627 0.368627 scn
8.000000 77.000000 m
32.000000 77.000000 l
32.000000 83.000000 l
8.000000 83.000000 l
8.000000 77.000000 l
h
37.000000 72.000000 m
37.000000 8.000000 l
43.000000 8.000000 l
43.000000 72.000000 l
37.000000 72.000000 l
h
32.000000 3.000000 m
8.000000 3.000000 l
8.000000 -3.000000 l
32.000000 -3.000000 l
32.000000 3.000000 l
h
3.000000 8.000000 m
3.000000 72.000000 l
-3.000000 72.000000 l
-3.000000 8.000000 l
3.000000 8.000000 l
h
8.000000 3.000000 m
5.238576 3.000000 3.000000 5.238579 3.000000 8.000000 c
-3.000000 8.000000 l
-3.000000 1.924866 1.924867 -3.000000 8.000000 -3.000000 c
8.000000 3.000000 l
h
37.000000 8.000000 m
37.000000 5.238579 34.761421 3.000000 32.000000 3.000000 c
32.000000 -3.000000 l
38.075134 -3.000000 43.000000 1.924873 43.000000 8.000000 c
37.000000 8.000000 l
h
32.000000 77.000000 m
34.761425 77.000000 37.000000 74.761421 37.000000 72.000000 c
43.000000 72.000000 l
43.000000 78.075134 38.075134 83.000000 32.000000 83.000000 c
32.000000 77.000000 l
h
8.000000 83.000000 m
1.924867 83.000000 -3.000000 78.075127 -3.000000 72.000000 c
3.000000 72.000000 l
3.000000 74.761421 5.238577 77.000000 8.000000 77.000000 c
8.000000 83.000000 l
h
f
n
Q
Q
q
1.000000 0.000000 -0.000000 1.000000 9.502441 28.866180 cm
0.380392 0.568627 0.952941 scn
0.807579 19.713818 m
0.217579 18.883818 -0.502421 17.333817 0.497579 14.303818 c
1.647666 11.308455 3.414371 8.588205 5.683169 6.319407 c
7.951967 4.050610 10.672216 2.283905 13.667579 1.133818 c
16.667580 0.063818 18.247580 0.833818 19.077579 1.423819 c
19.806948 1.924915 20.384636 2.616756 20.747580 3.423819 c
20.993914 3.896008 21.059738 4.441780 20.932735 4.958998 c
20.805733 5.476215 20.494604 5.929427 20.057579 6.233817 c
16.627579 8.633817 l
16.198772 8.939078 15.673998 9.078884 15.150155 9.027419 c
14.626312 8.975954 14.138780 8.736692 13.777579 8.353816 c
13.387579 7.943816 13.137579 7.663816 12.777579 7.353816 c
12.599248 7.137367 12.346200 6.995701 12.068459 6.956817 c
11.790717 6.917933 11.508494 6.984663 11.277578 7.143817 c
10.357920 7.826672 9.488976 8.575300 8.677579 9.383817 c
7.890476 10.190874 7.161960 11.053063 6.497579 11.963817 c
6.338425 12.194733 6.271694 12.476955 6.310578 12.754697 c
6.349462 13.032438 6.491130 13.285485 6.707579 13.463817 c
7.057579 13.773817 7.337579 14.023817 7.707579 14.413816 c
8.090455 14.775017 8.329715 15.262550 8.381180 15.786394 c
8.432645 16.310236 8.292840 16.835011 7.987579 17.263817 c
5.617579 20.693817 l
5.313189 21.130842 4.859977 21.441969 4.342760 21.568972 c
3.825542 21.695976 3.279768 21.630152 2.807579 21.383818 c
2.000517 21.020874 1.308676 20.443186 0.807579 19.713818 c
0.807579 19.713818 l
h
f
n
Q
endstream
endobj
3 0 obj
3567
endobj
4 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 40.000000 80.000000 ]
/Resources 1 0 R
/Contents 2 0 R
/Parent 5 0 R
>>
endobj
5 0 obj
<< /Kids [ 4 0 R ]
/Count 1
/Type /Pages
>>
endobj
6 0 obj
<< /Type /Catalog
/Pages 5 0 R
>>
endobj
xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000034 00000 n
0000003657 00000 n
0000003680 00000 n
0000003853 00000 n
0000003927 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 6 0 R
/Size 7
>>
startxref
3986
%%EOF

View File

@ -1,176 +0,0 @@
%PDF-1.7
1 0 obj
<< >>
endobj
2 0 obj
<< /Length 3 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
q
1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
1.000000 1.000000 1.000000 scn
0.000000 72.000000 m
0.000000 76.418274 3.581722 80.000000 8.000000 80.000000 c
32.000000 80.000000 l
36.418278 80.000000 40.000000 76.418274 40.000000 72.000000 c
40.000000 8.000000 l
40.000000 3.581726 36.418278 0.000000 32.000000 0.000000 c
8.000000 0.000000 l
3.581722 0.000000 0.000000 3.581718 0.000000 8.000000 c
0.000000 72.000000 l
h
f
n
Q
0.000000 72.000000 m
0.000000 76.418274 3.581722 80.000000 8.000000 80.000000 c
32.000000 80.000000 l
36.418278 80.000000 40.000000 76.418274 40.000000 72.000000 c
40.000000 8.000000 l
40.000000 3.581726 36.418278 0.000000 32.000000 0.000000 c
8.000000 0.000000 l
3.581722 0.000000 0.000000 3.581718 0.000000 8.000000 c
0.000000 72.000000 l
h
W*
n
q
1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
0.725490 0.725490 0.725490 scn
8.000000 77.000000 m
32.000000 77.000000 l
32.000000 83.000000 l
8.000000 83.000000 l
8.000000 77.000000 l
h
37.000000 72.000000 m
37.000000 8.000000 l
43.000000 8.000000 l
43.000000 72.000000 l
37.000000 72.000000 l
h
32.000000 3.000000 m
8.000000 3.000000 l
8.000000 -3.000000 l
32.000000 -3.000000 l
32.000000 3.000000 l
h
3.000000 8.000000 m
3.000000 72.000000 l
-3.000000 72.000000 l
-3.000000 8.000000 l
3.000000 8.000000 l
h
8.000000 3.000000 m
5.238576 3.000000 3.000000 5.238579 3.000000 8.000000 c
-3.000000 8.000000 l
-3.000000 1.924866 1.924867 -3.000000 8.000000 -3.000000 c
8.000000 3.000000 l
h
37.000000 8.000000 m
37.000000 5.238579 34.761421 3.000000 32.000000 3.000000 c
32.000000 -3.000000 l
38.075134 -3.000000 43.000000 1.924873 43.000000 8.000000 c
37.000000 8.000000 l
h
32.000000 77.000000 m
34.761425 77.000000 37.000000 74.761421 37.000000 72.000000 c
43.000000 72.000000 l
43.000000 78.075134 38.075134 83.000000 32.000000 83.000000 c
32.000000 77.000000 l
h
8.000000 83.000000 m
1.924867 83.000000 -3.000000 78.075127 -3.000000 72.000000 c
3.000000 72.000000 l
3.000000 74.761421 5.238577 77.000000 8.000000 77.000000 c
8.000000 83.000000 l
h
f
n
Q
Q
q
1.000000 0.000000 -0.000000 1.000000 9.502441 28.866180 cm
0.172549 0.419608 0.929412 scn
0.807579 19.713818 m
0.217579 18.883818 -0.502421 17.333817 0.497579 14.303818 c
1.647666 11.308455 3.414371 8.588205 5.683169 6.319407 c
7.951967 4.050610 10.672216 2.283905 13.667579 1.133818 c
16.667580 0.063818 18.247580 0.833818 19.077579 1.423819 c
19.806948 1.924915 20.384636 2.616756 20.747580 3.423819 c
20.993914 3.896008 21.059738 4.441780 20.932735 4.958998 c
20.805733 5.476215 20.494604 5.929427 20.057579 6.233817 c
16.627579 8.633817 l
16.198772 8.939078 15.673998 9.078884 15.150155 9.027419 c
14.626312 8.975954 14.138780 8.736692 13.777579 8.353816 c
13.387579 7.943816 13.137579 7.663816 12.777579 7.353816 c
12.599248 7.137367 12.346200 6.995701 12.068459 6.956817 c
11.790717 6.917933 11.508494 6.984663 11.277578 7.143817 c
10.357920 7.826672 9.488976 8.575300 8.677579 9.383817 c
7.890476 10.190874 7.161960 11.053063 6.497579 11.963817 c
6.338425 12.194733 6.271694 12.476955 6.310578 12.754697 c
6.349462 13.032438 6.491130 13.285485 6.707579 13.463817 c
7.057579 13.773817 7.337579 14.023817 7.707579 14.413816 c
8.090455 14.775017 8.329715 15.262550 8.381180 15.786394 c
8.432645 16.310236 8.292840 16.835011 7.987579 17.263817 c
5.617579 20.693817 l
5.313189 21.130842 4.859977 21.441969 4.342760 21.568972 c
3.825542 21.695976 3.279768 21.630152 2.807579 21.383818 c
2.000517 21.020874 1.308676 20.443186 0.807579 19.713818 c
0.807579 19.713818 l
h
f
n
Q
endstream
endobj
3 0 obj
3567
endobj
4 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 40.000000 80.000000 ]
/Resources 1 0 R
/Contents 2 0 R
/Parent 5 0 R
>>
endobj
5 0 obj
<< /Kids [ 4 0 R ]
/Count 1
/Type /Pages
>>
endobj
6 0 obj
<< /Type /Catalog
/Pages 5 0 R
>>
endobj
xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000034 00000 n
0000003657 00000 n
0000003680 00000 n
0000003853 00000 n
0000003927 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 6 0 R
/Size 7
>>
startxref
3986
%%EOF

View File

@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "change-number-dark-40.pdf",
"filename" : "change-number.pdf",
"idiom" : "universal"
}
],

Binary file not shown.

View File

@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "change-number-light-40.pdf",
"filename" : "change-number-error.pdf",
"idiom" : "universal"
}
],

View File

@ -1,23 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "ios-rick-roll-dark@1x.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "ios-rick-roll-dark@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "ios-rick-roll-dark@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 596 KiB

View File

@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "official_wallpaper_reduced.pdf",
"filename" : "official-wallpaper.pdf",
"idiom" : "universal"
}
],

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "safetytip_48_pin.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "safetytip_48_lock.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "verificationcode_alert_96.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

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