From 7635902bb82b0643af64fc895d05b4a62eca4544 Mon Sep 17 00:00:00 2001 From: kate-signal Date: Wed, 20 May 2026 12:16:01 -0400 Subject: [PATCH] verification notice sheet --- Signal.xcodeproj/project.pbxproj | 12 ++ ...onViewController+CVComponentDelegate.swift | 20 ++-- .../Contents.json | 12 ++ .../verificationcode_alert_96.pdf | Bin 0 -> 4934 bytes .../OutgoingDeviceRestorePresenter.swift | 4 +- .../DonationReadMoreSheetViewController.swift | 4 +- .../ChatListFYISheetCoordinator.swift | 111 +++++++++++++++++- ...ChatListViewController+Notifications.swift | 14 +++ .../Chat List/ChatListViewController.swift | 4 + .../SafetyTipsViewController.swift | 34 +++--- .../translations/en.lproj/Localizable.strings | 9 ++ SignalNSE/NotificationService.swift | 13 ++ .../SafetyTips/SafetyTipsManager.swift | 75 ++++++++++++ .../Util/DarwinNotificationName.swift | 1 + .../HeroSheetViewController.swift | 29 +++-- 15 files changed, 302 insertions(+), 40 deletions(-) create mode 100644 Signal/Images.xcassets/safety-tips/verificationcode_alert_96.imageset/Contents.json create mode 100644 Signal/Images.xcassets/safety-tips/verificationcode_alert_96.imageset/verificationcode_alert_96.pdf create mode 100644 SignalServiceKit/SafetyTips/SafetyTipsManager.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 344f803424..c5abe52989 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -63,6 +63,7 @@ 045B408E2EC6897B002D3F9A /* PinnedMessageManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045B408D2EC68974002D3F9A /* PinnedMessageManagerTest.swift */; }; 045B40922ECE406A002D3F9A /* ConversationViewController+PinnedMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045B40912ECE4060002D3F9A /* ConversationViewController+PinnedMessages.swift */; }; 045B40952ECF98C1002D3F9A /* PinnedMessagesDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045B40942ECF98BB002D3F9A /* PinnedMessagesDetailsViewController.swift */; }; + 046092262FBCD2DA00A8765F /* SafetyTipsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046092232FBCC7E700A8765F /* SafetyTipsManager.swift */; }; 046926092E8EBAAE00B1FC74 /* TSInfoMessage+Polls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 046926082E8EBAA800B1FC74 /* TSInfoMessage+Polls.swift */; }; 0477BE322FA4FC41002F9B47 /* TSReleaseNotesThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0477BE312FA4FC38002F9B47 /* TSReleaseNotesThread.swift */; }; 047A6DD02E00B5720048EDF4 /* BackupKeyReminderMegaphoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047A6DCF2E00B5640048EDF4 /* BackupKeyReminderMegaphoneTests.swift */; }; @@ -4208,6 +4209,7 @@ 045B408D2EC68974002D3F9A /* PinnedMessageManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedMessageManagerTest.swift; sourceTree = ""; }; 045B40912ECE4060002D3F9A /* ConversationViewController+PinnedMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConversationViewController+PinnedMessages.swift"; sourceTree = ""; }; 045B40942ECF98BB002D3F9A /* PinnedMessagesDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedMessagesDetailsViewController.swift; sourceTree = ""; }; + 046092232FBCC7E700A8765F /* SafetyTipsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafetyTipsManager.swift; sourceTree = ""; }; 046926082E8EBAA800B1FC74 /* TSInfoMessage+Polls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSInfoMessage+Polls.swift"; sourceTree = ""; }; 0477BE312FA4FC38002F9B47 /* TSReleaseNotesThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSReleaseNotesThread.swift; sourceTree = ""; }; 047A6DCF2E00B5640048EDF4 /* BackupKeyReminderMegaphoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupKeyReminderMegaphoneTests.swift; sourceTree = ""; }; @@ -8486,6 +8488,14 @@ path = PinnedMessages; sourceTree = ""; }; + 046092252FBCD28300A8765F /* SafetyTips */ = { + isa = PBXGroup; + children = ( + 046092232FBCC7E700A8765F /* SafetyTipsManager.swift */, + ); + path = SafetyTips; + sourceTree = ""; + }; 0484CECC2F44B7B4009AB2CB /* AdminDelete */ = { isa = PBXGroup; children = ( @@ -14640,6 +14650,7 @@ E19C3D922CF6B34200F2C496 /* QRCodes */, 6600F352298C8FBB00B1EDB7 /* Registration */, F9B0DC3B28948656004E07B7 /* Resources */, + 046092252FBCD28300A8765F /* SafetyTips */, 50B791552E8B39230063E71E /* Search */, 6673FF6A2978B5B900F96CFD /* SecureValueRecovery */, F9C5CB98289453B200548EEE /* Security */, @@ -19775,6 +19786,7 @@ F9C5CDF8289453B400548EEE /* ReverseDispatchQueue.swift in Sources */, F945FE4A2984796D00C835C7 /* RingrtcFieldTrials.swift in Sources */, 557238D32F2D53FD0033BC9A /* RingrtcVp9Config.swift in Sources */, + 046092262FBCD2DA00A8765F /* SafetyTipsManager.swift in Sources */, 668A01332C2B6088007B8808 /* Scheduler.swift in Sources */, 72C905912B9ACA3D00E586B8 /* ScreenLock.swift in Sources */, 7255A4D22B98E2B700E95368 /* ScrubbingLogFormatter.swift in Sources */, diff --git a/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift b/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift index 8f73298839..a302295cfd 100644 --- a/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift +++ b/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift @@ -1408,17 +1408,15 @@ 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( + primaryButton: SafetyTipsViewController.Button( + title: CommonStrings.viewMoreButton, + action: { [weak self] in + let viewController = MoreSafetyTipsViewController() + self?.present(viewController, animated: true) + }, + ), + ) present(viewController, animated: true) } } diff --git a/Signal/Images.xcassets/safety-tips/verificationcode_alert_96.imageset/Contents.json b/Signal/Images.xcassets/safety-tips/verificationcode_alert_96.imageset/Contents.json new file mode 100644 index 0000000000..e5f6db5956 --- /dev/null +++ b/Signal/Images.xcassets/safety-tips/verificationcode_alert_96.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "verificationcode_alert_96.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Signal/Images.xcassets/safety-tips/verificationcode_alert_96.imageset/verificationcode_alert_96.pdf b/Signal/Images.xcassets/safety-tips/verificationcode_alert_96.imageset/verificationcode_alert_96.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d85f7451f6966df65d100ba3a78391a880e9b683 GIT binary patch literal 4934 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 literal 0 HcmV?d00001 diff --git a/Signal/QuickRestore/OutgoingDeviceRestorePresenter.swift b/Signal/QuickRestore/OutgoingDeviceRestorePresenter.swift index 57a40b984f..7583ba72d8 100644 --- a/Signal/QuickRestore/OutgoingDeviceRestorePresenter.swift +++ b/Signal/QuickRestore/OutgoingDeviceRestorePresenter.swift @@ -73,10 +73,10 @@ class OutgoingDeviceRestorePresenter: OutgoingDeviceRestoreInitialPresenter { "OUTGOING_DEVICE_RESTORE_CONTINUE_ON_OTHER_DEVICE_TITLE", comment: "Title of prompt notifying that action is necessary on the other device.", ), - body: HeroSheetViewController.Body(text: OWSLocalizedString( + body: HeroSheetViewController.Body(textContent: .plain(OWSLocalizedString( "OUTGOING_DEVICE_RESTORE_CONTINUE_ON_OTHER_DEVICE_BODY", comment: "Body of prompt notifying that action is necessary on the other device.", - )), + ))), primary: .hero(.animation(named: "circular_indeterminate", height: 60)), secondary: nil, ) diff --git a/Signal/src/ViewControllers/Donations/DonationReadMoreSheetViewController.swift b/Signal/src/ViewControllers/Donations/DonationReadMoreSheetViewController.swift index 3e285647c2..ffc39aede2 100644 --- a/Signal/src/ViewControllers/Donations/DonationReadMoreSheetViewController.swift +++ b/Signal/src/ViewControllers/Donations/DonationReadMoreSheetViewController.swift @@ -12,10 +12,10 @@ final class DonationReadMoreSheetViewController: HeroSheetViewController { hero: .image(.sustainerHeart), title: nil, body: HeroSheetViewController.Body( - text: OWSLocalizedString( + textContent: .plain(OWSLocalizedString( "DONATION_READ_MORE_SHEET_BODY", comment: "Body text for a sheet discussing donating to Signal.", - ), + )), textAlignment: .left, textColor: .Signal.label, bulletPoints: [ diff --git a/Signal/src/ViewControllers/HomeView/Chat List/ChatListFYISheetCoordinator.swift b/Signal/src/ViewControllers/HomeView/Chat List/ChatListFYISheetCoordinator.swift index 18dcb022ef..62ac6a099c 100644 --- a/Signal/src/ViewControllers/HomeView/Chat List/ChatListFYISheetCoordinator.swift +++ b/Signal/src/ViewControllers/HomeView/Chat List/ChatListFYISheetCoordinator.swift @@ -40,12 +40,17 @@ class ChatListFYISheetCoordinator { struct KeyTransparencySelfCheckFailed {} + struct SMSVerificationCodeSent { + let timestampMs: UInt64 + } + case badgeThanks(BadgeThanks) case badgeIssue(BadgeIssue) case badgeExpiration(BadgeExpiration) case backupSubscriptionExpired(BackupSubscriptionExpired) case backupSubscriptionFailedToRenew(BackupSubscriptionFailedToRenew) case keyTransparencySelfCheckFailed(KeyTransparencySelfCheckFailed) + case smsVerificationCodeSent(SMSVerificationCodeSent) } private let backupExportJobRunner: BackupExportJobRunner @@ -93,7 +98,9 @@ class ChatListFYISheetCoordinator { // MARK: - private func nextSheetToPresent(tx: DBReadTransaction) -> FYISheet? { - if let sheet = shouldShowBadgeThanksSheet(successMode: .oneTimeBoost, tx: tx) { + if let sheet = shouldShowSMSVerificationCodeSentSheet(tx: tx) { + return sheet + } else if let sheet = shouldShowBadgeThanksSheet(successMode: .oneTimeBoost, tx: tx) { return sheet } else if let sheet = shouldShowBadgeThanksSheet(successMode: .recurringSubscriptionInitiation, tx: tx) { return sheet @@ -126,6 +133,23 @@ class ChatListFYISheetCoordinator { } } + /// Checks for `.smsVerificationCodeSent` FYI sheets. + /// + /// When another device tries to register and receives an SMS code, notify + /// the primary device by showing an FYI sheet. + /// + private func shouldShowSMSVerificationCodeSentSheet( + tx: DBReadTransaction, + ) -> FYISheet? { + + let safetyTipsKVStore = SafetyTipsManager() + guard let timestamp = safetyTipsKVStore.lastVerificationCodeTimestampMsWithinExpiryTime(transaction: tx) else { + return nil + } + + return .smsVerificationCodeSent(FYISheet.SMSVerificationCodeSent(timestampMs: timestamp)) + } + /// Checks for `.badgeThanks` FYI sheets. /// /// When creating a new donation we show a `BadgeThankSheet` inline in the @@ -219,6 +243,8 @@ class ChatListFYISheetCoordinator { from chatListViewController: ChatListViewController, ) async { switch fyiSheet { + case .smsVerificationCodeSent(let smsVerificationCodeSent): + await _present(smsVerificationCodeSent: smsVerificationCodeSent, from: chatListViewController) case .badgeThanks(let badgeThanks): await _present(badgeThanks: badgeThanks, from: chatListViewController) case .badgeIssue(let badgeIssue): @@ -460,6 +486,22 @@ class ChatListFYISheetCoordinator { } } } + + private func _present( + smsVerificationCodeSent: FYISheet.SMSVerificationCodeSent, + from chatListViewController: ChatListViewController, + ) async { + let sheet = SMSVerificationCodeSentHeroSheet( + timestamp: smsVerificationCodeSent.timestampMs, + presentingFrom: chatListViewController, + ) + chatListViewController.present(sheet, animated: true, completion: { [self] in + let kvStore = SafetyTipsManager() + db.write { tx in + kvStore.removeVerificationCodeRequestedTimestampMs(transaction: tx) + } + }) + } } // MARK: - ChatListViewController: BadgeIssueSheetDelegate @@ -595,3 +637,70 @@ private final class KeyTransparencySelfCheckFailedHeroSheet: HeroSheetViewContro ) } } + +// MARK: - + +private final class SMSVerificationCodeSentHeroSheet: HeroSheetViewController { + init(timestamp: UInt64, presentingFrom fromViewController: UIViewController) { + let timestampString = DateUtil.formatMessageTimestampForCVC(timestamp, shouldUseLongFormat: true) + let bodyPartOne = OWSLocalizedString( + "VERIFICATION_CODE_REQUESTED_HERO_BODY_FIRST", + comment: "First part of body for a hero sheet informing the user a verification code was requested. {{ Embeds time the code was requested }}", + ) + + let bodyPartTwo = OWSLocalizedString( + "VERIFICATION_CODE_REQUESTED_HERO_BODY_SECOND", + comment: "Second part of body for a hero sheet informing the user a verification code was requested.", + ) + + let body: NSAttributedString = .composed(of: [ + bodyPartOne.styled( + with: .font(.dynamicTypeHeadline), + .paragraphSpacingAfter(4.0), + ), + "\n", + timestampString.styled(with: .font(.dynamicTypeBody)), + "\n", + bodyPartTwo.styled( + with: .font(.dynamicTypeBody), + .paragraphSpacingBefore(12.0), + ), + ]) + + super.init( + hero: .image(.verificationcodeAlert96), + title: nil, + body: HeroSheetViewController.Body( + textContent: .attributed(body), + textAlignment: .natural, + textColor: UIColor.Signal.label, + ), + primary: .button(HeroSheetViewController.Button( + title: OWSLocalizedString( + "SAFETY_TIPS_BUTTON_ACTION_TITLE", + comment: "Title for Safety Tips button in thread details.", + ), + style: .secondary, + action: .custom({ [fromViewController] sheet in + sheet.dismiss(animated: true) + let safetyTipsVC = SafetyTipsViewController( + primaryButton: SafetyTipsViewController.Button( + title: OWSLocalizedString( + "SETTINGS_ACCOUNT_BUTTON", + comment: "Label for button in Safety Tips to go to 'account' page in settings.", + ), + action: { + (fromViewController as? ChatListViewController)?.showAppSettings(mode: .accountSettings) + }, + ), + ) + fromViewController.present(safetyTipsVC, animated: true) + }), + )), + secondary: .button(.dismissing( + title: CommonStrings.okButton, + style: .secondary, + )), + ) + } +} diff --git a/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+Notifications.swift b/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+Notifications.swift index 470710a61b..2b4a3ab647 100644 --- a/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+Notifications.swift +++ b/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+Notifications.swift @@ -151,6 +151,13 @@ extension ChatListViewController { name: DonationReceiptCredentialRedemptionJob.didFailNotification, object: nil, ) + NotificationCenter.default.addObserver( + self, + selector: #selector(smsVerificationCodeRequested), + name: .smsVerificationCodeRequested, + object: nil, + ) + SafetyTipsManager.startObservingDarwinNotifications() SUIEnvironment.shared.contactsViewHelperRef.addObserver(self) @@ -221,6 +228,13 @@ extension ChatListViewController { showFYISheetIfNecessary() } + @objc + private func smsVerificationCodeRequested(_ notification: NSNotification) { + AssertIsOnMainThread() + + showFYISheetIfNecessary() + } + @objc private func appExpiryDidChange(_ notification: NSNotification) { AssertIsOnMainThread() diff --git a/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController.swift b/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController.swift index 2531d08812..95ffdc754a 100644 --- a/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController.swift +++ b/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController.swift @@ -1378,6 +1378,7 @@ extension ChatListViewController { case donate(donateMode: DonateViewController.DonateMode) case linkedDevices case proxy + case accountSettings } func showAppSettings(mode: ShowAppSettingsMode? = nil, completion: (() -> Void)? = nil) { @@ -1491,6 +1492,9 @@ extension ChatListViewController { case .proxy: viewControllers += [PrivacySettingsViewController(), AdvancedPrivacySettingsViewController(), ProxySettingsViewController()] + + case .accountSettings: + viewControllers += [AccountSettingsViewController(appReadiness: appReadiness)] } navigationController.setViewControllers(viewControllers, animated: false) diff --git a/Signal/src/ViewControllers/SafetyTipsViewController.swift b/Signal/src/ViewControllers/SafetyTipsViewController.swift index f26f7ffdf3..6d0bad5f00 100644 --- a/Signal/src/ViewControllers/SafetyTipsViewController.swift +++ b/Signal/src/ViewControllers/SafetyTipsViewController.swift @@ -12,12 +12,18 @@ public enum SafetyTipsType { case group } -public protocol SafetyTipsViewControllerDelegate: AnyObject { - func didTapViewMoreSafetyTips() -} - public class SafetyTipsViewController: InteractiveSheetViewController, UIScrollViewDelegate { + public struct Button { + let title: String + let action: () -> Void + } + override public var placeOnGlassIfAvailable: Bool { true } + let primaryButton: Button + + init(primaryButton: Button) { + self.primaryButton = primaryButton + } private enum SafetyTips: CaseIterable { case chatsFromSignal @@ -79,8 +85,6 @@ public class SafetyTipsViewController: InteractiveSheetViewController, UIScrollV let contentScrollView = UIScrollView() let stackView = UIStackView() - public weak var delegate: SafetyTipsViewControllerDelegate? - override public func viewDidLoad() { super.viewDidLoad() @@ -133,26 +137,26 @@ public class SafetyTipsViewController: InteractiveSheetViewController, UIScrollV var config = UIButton.Configuration.filled() config.baseBackgroundColor = UIColor.Signal.secondaryFill config.cornerStyle = .capsule - var attrString = AttributedString(CommonStrings.viewMoreButton) + var attrString = AttributedString(primaryButton.title) attrString.font = .dynamicTypeBodyClamped.medium() config.attributedTitle = attrString config.baseForegroundColor = UIColor.Signal.label config.contentInsets = .init(margin: 14) - let viewMoreButton = UIButton( + let button = UIButton( configuration: config, primaryAction: .init(handler: { [weak self] _ in self?.dismiss(animated: true) - self?.delegate?.didTapViewMoreSafetyTips() + self?.primaryButton.action() }), ) - viewMoreButton.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(viewMoreButton) + button.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(button) NSLayoutConstraint.activate([ - viewMoreButton.bottomAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.bottomAnchor), - viewMoreButton.leadingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.leadingAnchor, constant: 20), - viewMoreButton.trailingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.trailingAnchor, constant: -20), - viewMoreButton.heightAnchor.constraint(equalToConstant: 52), + button.bottomAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.bottomAnchor), + button.leadingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.leadingAnchor, constant: 20), + button.trailingAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.trailingAnchor, constant: -20), + button.heightAnchor.constraint(equalToConstant: 52), ]) } diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 72ba29ba0f..a29c5891bd 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -8257,6 +8257,9 @@ /* Title for the 'account' link in settings. */ "SETTINGS_ACCOUNT" = "Account"; +/* Label for button in Safety Tips to go to 'account' page in settings. */ +"SETTINGS_ACCOUNT_BUTTON" = "Open Account Settings"; + /* Label for button in settings to get your account data report */ "SETTINGS_ACCOUNT_DATA_REPORT_BUTTON" = "Your Account Data"; @@ -10144,6 +10147,12 @@ /* An error message indicating that a usernames-related requeset failed because of a network error. */ "USERNAMES_REMOTE_MUTATION_ERROR_DESCRIPTION" = "Usernames can only be updated when connected to the internet."; +/* First part of body for a hero sheet informing the user a verification code was requested. {{ Embeds time the code was requested }} */ +"VERIFICATION_CODE_REQUESTED_HERO_BODY_FIRST" = "A Verification Code Was Requested"; + +/* Second part of body for a hero sheet informing the user a verification code was requested. */ +"VERIFICATION_CODE_REQUESTED_HERO_BODY_SECOND" = "Do not give your verification code to anyone. Signal will never message you for it. If you received a message from someone pretending to be Signal, it is a scam.\nYou can safely ignore this message if you requested the code yourself."; + /* Format for info message indicating that the verification state was unverified on this device. Embeds {{user's name or phone number}}. */ "VERIFICATION_STATE_CHANGE_FORMAT_NOT_VERIFIED_LOCAL" = "You marked %@ as not verified."; diff --git a/SignalNSE/NotificationService.swift b/SignalNSE/NotificationService.swift index 2ebe4c7838..b94907b043 100644 --- a/SignalNSE/NotificationService.swift +++ b/SignalNSE/NotificationService.swift @@ -95,6 +95,10 @@ class NotificationService: UNNotificationServiceExtension { } } + private func verificationCodeRequestTimestampMs(userInfo: [AnyHashable: Any]) -> UInt64? { + return (userInfo["verificationCodeRequested"] as? [String: Any])?["timestamp"] as? UInt64 + } + @MainActor private func _didReceive(_ request: UNNotificationRequest, logger: NSELogger) async -> UNNotificationContent { globalEnvironment.setUp(logger: logger) @@ -140,6 +144,15 @@ class NotificationService: UNNotificationServiceExtension { globalEnvironment.setAppIsReady() } + if let timestamp = verificationCodeRequestTimestampMs(userInfo: request.content.userInfo) { + await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { transaction in + let kvStore = SafetyTipsManager() + kvStore.setLastVerificationCodeRequestedTimestampMs(value: timestamp, transaction: transaction) + } + Logger.info("Skipping fetch for verification code requested push") + return UNNotificationContent() + } + // Mark down that the APNS token is working since we got a push. // Do this as early as possible but after the app is ready and has run // GRDB migrations and such. diff --git a/SignalServiceKit/SafetyTips/SafetyTipsManager.swift b/SignalServiceKit/SafetyTips/SafetyTipsManager.swift new file mode 100644 index 0000000000..0db22f1a5f --- /dev/null +++ b/SignalServiceKit/SafetyTips/SafetyTipsManager.swift @@ -0,0 +1,75 @@ +// +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +extension NSNotification.Name { + public static let smsVerificationCodeRequested = Notification.Name("SafetyTipsKeyValueStore.smsVerificationCodeRequested") +} + +@MainActor +public class SafetyTipsManager { + private static var hasStartedObserving = false + static let expiryTimeSeconds = (TimeInterval.minute * 10) + + private enum StoreKeys { + static let smsCodeRequestedTimestampMs: String = "smsCodeRequestedTimestampMsKey" + } + + private let kvStore: NewKeyValueStore + + public init() { + self.kvStore = NewKeyValueStore(collection: "SafetyTips") + } + + public func setLastVerificationCodeRequestedTimestampMs( + value: UInt64, + transaction: DBWriteTransaction, + ) { + kvStore.writeValue( + value, + forKey: StoreKeys.smsCodeRequestedTimestampMs, + tx: transaction, + ) + + transaction.addSyncCompletion { + // Wake up observer in the main app to check KV store + DarwinNotificationCenter.postNotification(name: .smsVerificationCodeRequested) + } + } + + public func lastVerificationCodeTimestampMsWithinExpiryTime( + transaction: DBReadTransaction, + ) -> UInt64? { + guard + let timestamp = kvStore.fetchValue( + UInt64.self, + forKey: StoreKeys.smsCodeRequestedTimestampMs, + tx: transaction, + ) + else { + return nil + } + + let timestampDate = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0) + let seconds = Date().timeIntervalSince(timestampDate) + guard seconds <= Self.expiryTimeSeconds else { + return nil + } + return timestamp + } + + public func removeVerificationCodeRequestedTimestampMs( + transaction: DBWriteTransaction, + ) { + kvStore.removeValue(forKey: StoreKeys.smsCodeRequestedTimestampMs, tx: transaction) + } + + public static func startObservingDarwinNotifications() { + guard !hasStartedObserving else { return } + hasStartedObserving = true + _ = DarwinNotificationCenter.addObserver(name: .smsVerificationCodeRequested, queue: .main) { _ in + NotificationCenter.default.post(name: .smsVerificationCodeRequested, object: nil) + } + } +} diff --git a/SignalServiceKit/Util/DarwinNotificationName.swift b/SignalServiceKit/Util/DarwinNotificationName.swift index 4fca921a08..6b8231e909 100644 --- a/SignalServiceKit/Util/DarwinNotificationName.swift +++ b/SignalServiceKit/Util/DarwinNotificationName.swift @@ -7,6 +7,7 @@ import Foundation public struct DarwinNotificationName: ExpressibleByStringLiteral { static let primaryDBFolderNameDidChange: DarwinNotificationName = "org.signal.primaryDBFolderNameDidChange" + static let smsVerificationCodeRequested: DarwinNotificationName = "org.signal.safetyTips.smsVerificationCodeRequested" static func sdsCrossProcess(for type: AppContextType) -> DarwinNotificationName { DarwinNotificationName("org.signal.sdscrossprocess.\(type)") diff --git a/SignalUI/ActionSheets/HeroSheetViewController.swift b/SignalUI/ActionSheets/HeroSheetViewController.swift index b714c729b1..6acf008640 100644 --- a/SignalUI/ActionSheets/HeroSheetViewController.swift +++ b/SignalUI/ActionSheets/HeroSheetViewController.swift @@ -22,6 +22,11 @@ open class HeroSheetViewController: StackSheetViewController { } public struct Body { + public enum TextContent { + case plain(String) + case attributed(NSAttributedString) + } + public struct BulletPoint { public let icon: UIImage public let text: String @@ -48,20 +53,20 @@ open class HeroSheetViewController: StackSheetViewController { } } - public let text: String + public let textContent: TextContent public let textAlignment: NSTextAlignment public let textColor: UIColor public let bulletPoints: [BulletPoint] public let toggle: Toggle? public init( - text: String, + textContent: TextContent, textAlignment: NSTextAlignment = .center, textColor: UIColor = .Signal.secondaryLabel, bulletPoints: [BulletPoint] = [], toggle: Toggle? = nil, ) { - self.text = text + self.textContent = textContent self.textAlignment = textAlignment self.textColor = textColor self.bulletPoints = bulletPoints @@ -137,7 +142,7 @@ open class HeroSheetViewController: StackSheetViewController { ) { self.hero = hero self.titleText = title - self.body = Body(text: body) + self.body = Body(textContent: .plain(body)) self.primary = primaryButton.map { .button($0) } self.secondary = secondaryButton.map { .button($0) } super.init() @@ -193,10 +198,16 @@ open class HeroSheetViewController: StackSheetViewController { let bodyLabel = UILabel() self.stackView.addArrangedSubview(bodyLabel) self.stackView.setCustomSpacing(32, after: bodyLabel) - bodyLabel.text = body.text + switch body.textContent { + case .plain(let text): + bodyLabel.text = text + bodyLabel.font = .dynamicTypeSubheadline + case .attributed(let attributedText): + // attributed strings should set their own font. + bodyLabel.attributedText = attributedText + } bodyLabel.textColor = body.textColor bodyLabel.textAlignment = body.textAlignment - bodyLabel.font = .dynamicTypeSubheadline bodyLabel.numberOfLines = 0 for bodyBullet in body.bulletPoints { @@ -378,7 +389,7 @@ open class HeroSheetViewController: StackSheetViewController { hero: .image(UIImage(named: "sustainer-heart")!), title: nil, body: HeroSheetViewController.Body( - text: "As an independent nonprofit, Signal is committed to private messaging and calls. No ads, no trackers, no surveillance. Donate today to support Signal.", + textContent: .plain("As an independent nonprofit, Signal is committed to private messaging and calls. No ads, no trackers, no surveillance. Donate today to support Signal."), textAlignment: .left, textColor: .Signal.label, bulletPoints: [ @@ -407,7 +418,7 @@ open class HeroSheetViewController: StackSheetViewController { hero: .image(UIImage(named: "toggle-32")!), title: nil, body: HeroSheetViewController.Body( - text: #"Give Boots extra dinner? He'd like you to know he's "extra hungry" tonight."#, + textContent: .plain(#"Give Boots extra dinner? He'd like you to know he's "extra hungry" tonight."#), toggle: HeroSheetViewController.Body.Toggle( text: "Extra Food?", isOn: true, @@ -452,7 +463,7 @@ open class HeroSheetViewController: StackSheetViewController { SheetPreviewViewController(sheet: HeroSheetViewController( hero: .image(UIImage(named: "transfer_complete")!), title: LocalizationNotNeeded("Continue on your other device"), - body: HeroSheetViewController.Body(text: LocalizationNotNeeded("Continue transferring your account on your other device.")), + body: HeroSheetViewController.Body(textContent: .plain(LocalizationNotNeeded("Continue transferring your account on your other device."))), primary: .hero(.animation(named: "circular_indeterminate", height: 60)), secondary: nil, ))