diff --git a/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController.swift b/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController.swift index 4d0b8f602c..f4ec8fa542 100644 --- a/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController.swift +++ b/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController.swift @@ -188,6 +188,7 @@ public class ChatListViewController: OWSViewController, HomeTabViewController { searchResultsController.viewDidAppear(animated) showBadgeSheetIfNecessary() + Task { await self.checkForFailedServiceExtensionLaunches() } hasEverAppeared = true if viewState.multiSelectState.isActive { @@ -1178,6 +1179,133 @@ public class ChatListViewController: OWSViewController, HomeTabViewController { paymentsBannerView.addSubview(stack) stack.autoPinEdgesToSuperviewEdges() } + + // MARK: - Notifications + + func checkForFailedServiceExtensionLaunches() async { + guard #available(iOS 17.0, *) else { + return + } + + guard RemoteConfig.shouldCheckForServiceExtensionFailures else { + return + } + + await messageProcessor.waitForFetchingAndProcessing().awaitable() + + // Has the NSE ever launched with the current version? + let appVersion = AppVersionImpl.shared + guard + let mainAppVersion = appVersion.lastCompletedLaunchMainAppVersion, + let nseAppVersion = appVersion.lastCompletedLaunchNSEAppVersion, + let upgradeDate = appVersion.firstMainAppLaunchDateAfterUpdate + else { + return + } + guard nseAppVersion != mainAppVersion else { + return + } + + // Has it been at least an hour since we upgraded? + guard -upgradeDate.timeIntervalSinceNow > kHourInterval else { + return + } + + // Has the user restarted since the most recent update was installed? + let bootTime: Date? = { + var timeVal = timeval() + var timeValSize = MemoryLayout.size + let err = sysctlbyname("kern.boottime", &timeVal, &timeValSize, nil, 0) + guard err == 0, timeValSize == MemoryLayout.size else { + return nil + } + return Date(timeIntervalSince1970: TimeInterval(timeVal.tv_sec)) + }() + guard let bootTime else { + return + } + guard bootTime < upgradeDate else { + return + } + + let keyValueStore = SDSKeyValueStore(collection: "FailedNSELaunches") + let mostRecentDateKey = "mostRecentPromptDate" + let promptCountKey = "promptCount" + + let shouldShowPrompt = databaseStorage.read { tx in + // If we've shown the prompt recently, don't show it again. + let promptCount = keyValueStore.getInt(promptCountKey, defaultValue: 0, transaction: tx) + let promptBackoff: TimeInterval = { + switch promptCount { + case 0: + return 0 + case 1, 2: + return 24*kHourInterval + case 3: + return 48*kHourInterval + case 4: + return 72*kHourInterval + default: + return 96*kHourInterval + } + }() + let mostRecentDate = keyValueStore.getDate(mostRecentDateKey, transaction: tx) + if let mostRecentDate, -mostRecentDate.timeIntervalSinceNow < promptBackoff { + return false + } + + // If we haven't received a message since upgrading, don't show it. + guard + let mostRecentMessage = InteractionFinder.lastInsertedIncomingMessage(transaction: tx), + Date(millisecondsSince1970: mostRecentMessage.receivedAtTimestamp) > upgradeDate + else { + return false + } + return true + } + + guard shouldShowPrompt else { + return + } + + guard isChatListTopmostViewController() else { + return + } + + let actionSheet = ActionSheetController( + title: OWSLocalizedString( + "NOTIFICATIONS_ERROR_TITLE", + comment: "Shown as the title of an alert when notifications can't be shown due to an error." + ), + message: String( + format: OWSLocalizedString( + "NOTIFICATIONS_ERROR_MESSAGE", + comment: "Shown as the body of an alert when notifications can't be shown due to an error." + ), + UIDevice.current.localizedModel + ) + ) + actionSheet.addAction(ActionSheetAction( + title: CommonStrings.contactSupport, + handler: { [weak self] _ in + guard let self else { return } + ContactSupportAlert.presentStep2(emailSupportFilter: "NotLaunchingNSE", fromViewController: self) + } + )) + actionSheet.addAction(ActionSheetAction(title: CommonStrings.okButton)) + + let promptDate = Date() + self.present(actionSheet, animated: true) + + await databaseStorage.awaitableWrite { tx in + keyValueStore.setDate(promptDate, key: mostRecentDateKey, transaction: tx) + keyValueStore.setInt( + keyValueStore.getInt(promptCountKey, defaultValue: 0, transaction: tx) + 1, + key: promptCountKey, + transaction: tx + ) + } + } } // MARK: Settings Button diff --git a/Signal/src/util/ContactSupportAlert.swift b/Signal/src/util/ContactSupportAlert.swift index 6ea85cd595..c907a1fc4d 100644 --- a/Signal/src/util/ContactSupportAlert.swift +++ b/Signal/src/util/ContactSupportAlert.swift @@ -53,7 +53,9 @@ public class ContactSupportAlert: NSObject { modal.dismiss() }.catch { error in guard !modal.wasCancelled else { return } - showError(error, emailSupportFilter: emailSupportFilter, fromViewController: fromViewController) + modal.dismiss(completion: { + showError(error, emailSupportFilter: emailSupportFilter, fromViewController: fromViewController) + }) } } } diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 99f7825c4e..f70083d5ba 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -4366,6 +4366,12 @@ /* Title for notification permission reminder megaphone */ "NOTIFICATION_PERMISSION_REMINDER_MEGAPHONE_TITLE" = "Turn on Notifications?"; +/* Shown as the body of an alert when notifications can't be shown due to an error. */ +"NOTIFICATIONS_ERROR_MESSAGE" = "Restart your %1$@ if you’re not receiving Signal notifications."; + +/* Shown as the title of an alert when notifications can't be shown due to an error. */ +"NOTIFICATIONS_ERROR_TITLE" = "Couldn’t Show Notifications"; + /* No comment provided by engineer. */ "NOTIFICATIONS_FOOTER_WARNING" = "Actions include “Mark as Read,” “Reply,” and “Call Back.”"; diff --git a/SignalServiceKit/src/Util/AppVersion.swift b/SignalServiceKit/src/Util/AppVersion.swift index d660a93715..29924a4900 100644 --- a/SignalServiceKit/src/Util/AppVersion.swift +++ b/SignalServiceKit/src/Util/AppVersion.swift @@ -42,6 +42,7 @@ public protocol AppVersion { var lastCompletedLaunchMainAppVersion: String? { get } var lastCompletedLaunchSAEAppVersion: String? { get } var lastCompletedLaunchNSEAppVersion: String? { get } + var firstMainAppLaunchDateAfterUpdate: Date? { get } var buildDate: Date { get } @@ -62,6 +63,7 @@ public class AppVersionImpl: AppVersion { private let lastCompletedMainAppLaunchVersionKey = "kNSUserDefaults_LastCompletedLaunchAppVersion_MainApp" private let lastCompletedSAELaunchVersionKey = "kNSUserDefaults_LastCompletedLaunchAppVersion_SAE" private let lastCompletedNSELaunchVersionKey = "kNSUserDefaults_LastCompletedLaunchAppVersion_NSE" + private let firstMainAppLaunchDateAfterUpdateKey = "FirstMainAppLaunchDateAfterUpdate" public static let shared: AppVersion = { let result = AppVersionImpl( @@ -126,8 +128,10 @@ public class AppVersionImpl: AppVersion { public private(set) var lastCompletedLaunchMainAppVersion: String? { get { userDefaults.string(forKey: lastCompletedMainAppLaunchVersionKey) } set { + let didChange = lastCompletedLaunchMainAppVersion != newValue userDefaults.setOrRemove(newValue, forKey: lastCompletedLaunchVersionKey) userDefaults.setOrRemove(newValue, forKey: lastCompletedMainAppLaunchVersionKey) + if didChange { userDefaults.set(Date(), forKey: firstMainAppLaunchDateAfterUpdateKey) } } } public private(set) var lastCompletedLaunchSAEAppVersion: String? { @@ -144,6 +148,9 @@ public class AppVersionImpl: AppVersion { userDefaults.setOrRemove(newValue, forKey: lastCompletedNSELaunchVersionKey) } } + public var firstMainAppLaunchDateAfterUpdate: Date? { + return userDefaults.object(forKey: firstMainAppLaunchDateAfterUpdateKey) as? Date + } public let buildDate: Date @@ -341,6 +348,8 @@ public class MockAppVerion: AppVersion { public var lastCompletedLaunchNSEAppVersion: String? + public var firstMainAppLaunchDateAfterUpdate: Date? + public var buildDate: Date = Date() public func compare(_ lhs: String, with rhs: String) -> ComparisonResult { diff --git a/SignalServiceKit/src/Util/RemoteConfigManager.swift b/SignalServiceKit/src/Util/RemoteConfigManager.swift index 8184af5472..85c83627a5 100644 --- a/SignalServiceKit/src/Util/RemoteConfigManager.swift +++ b/SignalServiceKit/src/Util/RemoteConfigManager.swift @@ -256,6 +256,10 @@ public class RemoteConfig: BaseFlags { return isEnabled(.enableGifSearch, defaultValue: true) } + public static var shouldCheckForServiceExtensionFailures: Bool { + return !isEnabled(.serviceExtensionFailureKillSwitch) + } + // MARK: UInt values private static func getUIntValue( @@ -475,6 +479,7 @@ private enum IsEnabledFlag: String, FlagType { case cdsDisableCompatibilityMode = "cds.disableCompatibilityMode" case canDonateWithSepa = "ios.canDonateWithSepa" case enableGifSearch = "global.gifSearch" + case serviceExtensionFailureKillSwitch = "ios.serviceExtensionFailureKillSwitch" var isSticky: Bool { switch self { @@ -494,12 +499,15 @@ private enum IsEnabledFlag: String, FlagType { case .ringrtcNwPathMonitorTrialKillSwitch: fallthrough case .cdsDisableCompatibilityMode: fallthrough case .canDonateWithSepa: fallthrough - case .enableGifSearch: + case .enableGifSearch: fallthrough + case .serviceExtensionFailureKillSwitch: return false } } var isHotSwappable: Bool { switch self { + case .serviceExtensionFailureKillSwitch: + return true case .automaticSessionResetKillSwitch: fallthrough case .paymentsResetKillSwitch: fallthrough case .messageResendKillSwitch: fallthrough