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 0000000000..d85f7451f6 Binary files /dev/null and b/Signal/Images.xcassets/safety-tips/verificationcode_alert_96.imageset/verificationcode_alert_96.pdf differ 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, ))