From 9b6324b29532bb0d4d4b7bca4fb78b5eda63490a Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:18:09 +0000 Subject: [PATCH] Add boot detection in the NSE and use this to inform the user that there may be more notifications. (#5054) * Add an initial implementation of the receivedWhileOfflineNotification. * Only deliver a single receivedWhileOffline notification per boot and clarify the API. * Add a 15-minute threshold for the receiveWhileOfflineNotification. --- ElementX.xcodeproj/project.pbxproj | 12 +-- .../en-US.lproj/Localizable.strings | 1 + .../en.lproj/Localizable.strings | 1 + .../Application/Settings/AppSettings.swift | 9 +- ElementX/Sources/Generated/Strings.swift | 2 + .../Extensions/UNNotificationContent.swift | 10 +- .../Manager/NotificationManager.swift | 12 ++- .../SupportingFiles/PrivacyInfo.xcprivacy | 8 ++ ...nager.swift => BootDetectionManager.swift} | 13 ++- .../NotificationServiceExtension.swift | 102 ++++++++++++++++-- NSE/SupportingFiles/PrivacyInfo.xcprivacy | 8 ++ 11 files changed, 157 insertions(+), 21 deletions(-) rename NSE/Sources/{DataProtectionManager.swift => BootDetectionManager.swift} (72%) diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 5319bcf5d..453e6b98a 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -184,6 +184,7 @@ 1C4CB9009E50E6535883D5B2 /* RestorationToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3558A15CFB934F9229301527 /* RestorationToken.swift */; }; 1C598D3B785645AAC7B35760 /* ReportRoomScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 292EEE1F71DCC205C45728F7 /* ReportRoomScreenCoordinator.swift */; }; 1C6B06DB15EC194AF35C05DB /* RoomPowerLevelsProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFFA5E881D281810AB428EA3 /* RoomPowerLevelsProxy.swift */; }; + 1C6B2A1A5A9699DD4C9755B3 /* BootDetectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F054DE7D47849687662C9D9 /* BootDetectionManager.swift */; }; 1C8BC70A18060677E295A846 /* ShareToMapsAppActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */; }; 1C9BB74711E5F24C77B7FED0 /* RoomMembersListScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA0B743847CFA5B3C38EE4 /* RoomMembersListScreenCoordinator.swift */; }; 1D5DC685CED904386C89B7DA /* NSRegularExpresion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95BAC0F6C9644336E9567EE6 /* NSRegularExpresion.swift */; }; @@ -716,13 +717,11 @@ 7BDC3976D88D40D2A45BEB8C /* NSRegularExpresion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95BAC0F6C9644336E9567EE6 /* NSRegularExpresion.swift */; }; 7BF368A78E6D9AFD222F25AF /* SecureBackupScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AE094FCB6387D268C436161 /* SecureBackupScreenViewModel.swift */; }; 7C0E29E0279866C62EC67A28 /* JoinRoomScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE5127D6EA05B2E45D0A7D59 /* JoinRoomScreenViewModelTests.swift */; }; - 7C101936C8F3DAF8D8166124 /* DataProtectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75821CD31A4BD02B99C327A4 /* DataProtectionManager.swift */; }; 7C164A642E8932B5F9004550 /* test_voice_message.m4a in Resources */ = {isa = PBXBuildFile; fileRef = DCA2D836BD10303F37FAAEED /* test_voice_message.m4a */; }; 7C1A7B594B2F8143F0DD0005 /* ElementXAttributeScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = C024C151639C4E1B91FCC68B /* ElementXAttributeScope.swift */; }; 7C545FFEC9930F7247352593 /* SecurityAndPrivacyScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978092B01BEAB39F2C4389AE /* SecurityAndPrivacyScreenViewModel.swift */; }; 7C6376192F578E0BA801BFEC /* AnalyticsSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C64A14EE89928207E3B42B /* AnalyticsSettingsScreenModels.swift */; }; 7C9BDF1FC7BD46C4676536AB /* AuthenticationStartScreenBackgroundImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 682BC7BAF0EFEF512A8C5140 /* AuthenticationStartScreenBackgroundImage.swift */; }; - 7CD05B18A432060E4770FBD8 /* DataProtectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75821CD31A4BD02B99C327A4 /* DataProtectionManager.swift */; }; 7CD16990BA843BE9ED639129 /* ImageRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DFE4453AB0B34C203447162 /* ImageRoomTimelineItem.swift */; }; 7D249465ED00988EEEC14E05 /* JoinedRoomProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 867DC9530C42F7B5176BE465 /* JoinedRoomProxyMock.swift */; }; 7D261B5119E78CC8E771CA15 /* GlobalSearchScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74653BE903970C0E36867D46 /* GlobalSearchScreenCoordinator.swift */; }; @@ -988,6 +987,7 @@ A851635B3255C6DC07034A12 /* RoomScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */; }; A87DC550659C5176AC1829DE /* ElementTextFieldStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7673F2B0B038FAB2A8D16AD /* ElementTextFieldStyle.swift */; }; A88328D7E17F73AB64501B51 /* DSWaveformImageViews in Frameworks */ = {isa = PBXBuildFile; productRef = 2A4106A0A96DC4C273128AA5 /* DSWaveformImageViews */; }; + A8E324E700E596E36B0A311B /* BootDetectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F054DE7D47849687662C9D9 /* BootDetectionManager.swift */; }; A8FA7671948E3DF27F320026 /* BugReportFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7367B3B9A8CAF902220F31D1 /* BugReportFlowCoordinator.swift */; }; A91D125414C3D9ABBABCF2F1 /* KZFileWatchers in Frameworks */ = {isa = PBXBuildFile; productRef = 6690850AA47ECED7E1CAB345 /* KZFileWatchers */; }; A93661C962B12942C08864B6 /* WysiwygComposer in Frameworks */ = {isa = PBXBuildFile; productRef = CA07D57389DACE18AEB6A5E2 /* WysiwygComposer */; }; @@ -2146,6 +2146,7 @@ 6E5E9C044BEB7C70B1378E91 /* UserSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSession.swift; sourceTree = ""; }; 6E964AF2DFEB31E2B799999F /* RoomPollsHistoryScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomPollsHistoryScreenModels.swift; sourceTree = ""; }; 6EA1D2CBAEA5D0BD00B90D1B /* BindableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BindableState.swift; sourceTree = ""; }; + 6F054DE7D47849687662C9D9 /* BootDetectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BootDetectionManager.swift; sourceTree = ""; }; 6F1C3CBBC62C566DDF5E84C1 /* TimelineItemMenuAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemMenuAction.swift; sourceTree = ""; }; 6F3DFE5B444F131648066F05 /* StateStoreViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateStoreViewModel.swift; sourceTree = ""; }; 6F65E4BB9E82EB8373207CF8 /* MediaProviderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProviderMock.swift; sourceTree = ""; }; @@ -2189,7 +2190,6 @@ 7509AB72755DCC4B4E721B36 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/SAS.strings; sourceTree = ""; }; 752A0EB49BF5BCEA37EDF7A3 /* Signposter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Signposter.swift; sourceTree = ""; }; 753B4C6C0EDDCBF0708DC384 /* TimelineItemSendInfoLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemSendInfoLabel.swift; sourceTree = ""; }; - 75821CD31A4BD02B99C327A4 /* DataProtectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataProtectionManager.swift; sourceTree = ""; }; 75B3CE05643C7791D46AC54B /* LeaveSpaceHandleProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaveSpaceHandleProxy.swift; sourceTree = ""; }; 76310030C831D4610A705603 /* URLComponentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLComponentsTests.swift; sourceTree = ""; }; 76A46ABD27628CB5FC402541 /* Backports.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backports.swift; sourceTree = ""; }; @@ -5207,7 +5207,7 @@ 864330656491EBAADA4901D3 /* Sources */ = { isa = PBXGroup; children = ( - 75821CD31A4BD02B99C327A4 /* DataProtectionManager.swift */, + 6F054DE7D47849687662C9D9 /* BootDetectionManager.swift */, D7BB243B26D54EF1A0C422C0 /* NotificationContentBuilder.swift */, BB6ED50FE104992419310EEB /* NotificationHandler.swift */, 27A1AD6389A4659AF0CEAE62 /* NotificationServiceExtension.swift */, @@ -7532,12 +7532,12 @@ CDCA8A559E098503DDE29477 /* AttributedStringBuilder.swift in Sources */, BA43D782BE85C7F5F20C624A /* AttributedStringBuilderProtocol.swift in Sources */, F255083E18CDBFDF7E640FB1 /* Avatars.swift in Sources */, + A8E324E700E596E36B0A311B /* BootDetectionManager.swift in Sources */, 9A3B0CDF097E3838FB1B9595 /* Bundle.swift in Sources */, 9295F1F5E04484E10780BCE8 /* CharacterSet.swift in Sources */, 238D561CA231339C6D4D06F3 /* ClientBuilder.swift in Sources */, 0BAF83521871E69D222EE8E4 /* ClientBuilderHook.swift in Sources */, 211B5F524E851178EE549417 /* CurrentValuePublisher.swift in Sources */, - 7CD05B18A432060E4770FBD8 /* DataProtectionManager.swift in Sources */, 24A75F72EEB7561B82D726FD /* Date.swift in Sources */, FE9A5D4715C7AB68682C030C /* Dictionary.swift in Sources */, 9F11B9F347F9E2D236799FB3 /* ElementCallServiceConstants.swift in Sources */, @@ -7945,6 +7945,7 @@ 54FDA3625AACBD9E438D084D /* BlurEffectView.swift in Sources */, B6DF6B6FA8734B70F9BF261E /* BlurHashDecode.swift in Sources */, E794AB6ABE1FF5AF0573FEA1 /* BlurHashEncode.swift in Sources */, + 1C6B2A1A5A9699DD4C9755B3 /* BootDetectionManager.swift in Sources */, A8FA7671948E3DF27F320026 /* BugReportFlowCoordinator.swift in Sources */, F4C005F006FC3657B9F0A31D /* BugReportHook.swift in Sources */, 6AD722DD92E465E56D2885AB /* BugReportScreen.swift in Sources */, @@ -8017,7 +8018,6 @@ A722F426FD81FC67706BB1E0 /* CustomLayoutLabelStyle.swift in Sources */, 12C867E85E6D12EEDFD0B127 /* CustomStringConvertible.swift in Sources */, 9905C1B1C6EFE38F3A6533F3 /* Data.swift in Sources */, - 7C101936C8F3DAF8D8166124 /* DataProtectionManager.swift in Sources */, C6C06DDA8881260303FBA3A0 /* Date.swift in Sources */, 99C463F12F89C99C77D02077 /* DeactivateAccountScreen.swift in Sources */, 3E3CC3D17908A3BB9F224CC5 /* DeactivateAccountScreenCoordinator.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings index 73a82e586..35aa508bb 100644 --- a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings @@ -454,6 +454,7 @@ "notification_mentioned_you_body" = "Mentioned you: %1$@"; "notification_new_messages" = "New Messages"; "notification_reaction_body" = "Reacted with %1$@"; +"notification_received_while_offline_ios" = "You received one or more notifications while offline."; "notification_room_invite_body" = "Invited you to join the room"; "notification_room_invite_body_with_sender" = "%1$@ invited you to join the room"; "notification_sender_me" = "Me"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 47a41c0a7..2f01b019b 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -454,6 +454,7 @@ "notification_mentioned_you_body" = "Mentioned you: %1$@"; "notification_new_messages" = "New Messages"; "notification_reaction_body" = "Reacted with %1$@"; +"notification_received_while_offline_ios" = "You received one or more notifications while offline."; "notification_room_invite_body" = "Invited you to join the room"; "notification_room_invite_body_with_sender" = "%1$@ invited you to join the room"; "notification_sender_me" = "Me"; diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index 14ef90261..77b0001cd 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -14,7 +14,9 @@ import Foundation import SwiftUI /// Common settings between app and NSE -protocol CommonSettingsProtocol { +protocol CommonSettingsProtocol: AnyObject { + var lastNotificationBootTime: TimeInterval? { get set } + var logLevel: LogLevel { get } var traceLogPacks: Set { get } var bugReportRageshakeURL: RemotePreference { get } @@ -51,6 +53,7 @@ final class AppSettings { case enableNotifications case enableInAppNotifications case pusherProfileTag + case lastNotificationBootTime case logLevel case traceLogPacks case viewSourceEnabled @@ -291,6 +294,10 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.pusherProfileTag, storageType: .userDefaults(store)) var pusherProfileTag: String? + /// The device's last boot time as recorded by the NSE. + @UserPreference(key: UserDefaultsKeys.lastNotificationBootTime, storageType: .userDefaults(store)) + var lastNotificationBootTime: TimeInterval? + // MARK: - Logging @UserPreference(key: UserDefaultsKeys.logLevel, defaultValue: LogLevel.info, storageType: .userDefaults(store)) diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 493873ef1..6a74f385c 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -1060,6 +1060,8 @@ internal enum L10n { internal static func notificationReactionBody(_ p1: Any) -> String { return L10n.tr("Localizable", "notification_reaction_body", String(describing: p1)) } + /// You received one or more notifications while offline. + internal static var notificationReceivedWhileOfflineIos: String { return L10n.tr("Localizable", "notification_received_while_offline_ios") } /// Mark as read internal static var notificationRoomActionMarkAsRead: String { return L10n.tr("Localizable", "notification_room_action_mark_as_read") } /// Quick reply diff --git a/ElementX/Sources/Other/Extensions/UNNotificationContent.swift b/ElementX/Sources/Other/Extensions/UNNotificationContent.swift index 081ca39eb..33ae8edc5 100644 --- a/ElementX/Sources/Other/Extensions/UNNotificationContent.swift +++ b/ElementX/Sources/Other/Extensions/UNNotificationContent.swift @@ -29,6 +29,10 @@ extension UNNotificationContent { @objc var threadRootEventID: String? { userInfo[NotificationConstants.UserInfoKey.threadRootEventIdentifier] as? String } + + var unreadCount: Int? { + userInfo[NotificationConstants.UserInfoKey.unreadCount] as? Int + } } extension UNMutableNotificationContent { @@ -40,7 +44,7 @@ extension UNMutableNotificationContent { userInfo[NotificationConstants.UserInfoKey.receiverIdentifier] = newValue } } - + override var roomID: String? { get { userInfo[NotificationConstants.UserInfoKey.roomIdentifier] as? String @@ -67,8 +71,4 @@ extension UNMutableNotificationContent { userInfo[NotificationConstants.UserInfoKey.threadRootEventIdentifier] = newValue } } - - var unreadCount: Int? { - userInfo[NotificationConstants.UserInfoKey.unreadCount] as? Int - } } diff --git a/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift b/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift index 34238ba60..03e2dc883 100644 --- a/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift +++ b/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift @@ -56,8 +56,14 @@ final class NotificationManager: NSObject, NotificationManagerProtocol { self?.enableNotifications(newValue) } .store(in: &cancellables) - } + NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) + .sink { [weak self] _ in + self?.removeReceivedWhileOfflineNotification() + } + .store(in: &cancellables) + } + func requestAuthorization() { guard appSettings.enableNotifications, !userSession.isNil else { return } Task { @@ -151,6 +157,10 @@ final class NotificationManager: NSObject, NotificationManagerProtocol { notificationCenter.removeDeliveredNotifications(withIdentifiers: notificationsIdentifiers) } + + private func removeReceivedWhileOfflineNotification() { + notificationCenter.removeDeliveredNotifications(withIdentifiers: [NotificationServiceExtension.receivedWhileOfflineNotificationID]) + } private func setPusher(with deviceToken: Data, clientProxy: ClientProxyProtocol) async -> Bool { do { diff --git a/ElementX/SupportingFiles/PrivacyInfo.xcprivacy b/ElementX/SupportingFiles/PrivacyInfo.xcprivacy index fddc95865..cab7206e8 100644 --- a/ElementX/SupportingFiles/PrivacyInfo.xcprivacy +++ b/ElementX/SupportingFiles/PrivacyInfo.xcprivacy @@ -166,6 +166,14 @@ 7D9E.1 + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 8FFB.1 + + diff --git a/NSE/Sources/DataProtectionManager.swift b/NSE/Sources/BootDetectionManager.swift similarity index 72% rename from NSE/Sources/DataProtectionManager.swift rename to NSE/Sources/BootDetectionManager.swift index 09b5834d6..7bb91a6db 100644 --- a/NSE/Sources/DataProtectionManager.swift +++ b/NSE/Sources/BootDetectionManager.swift @@ -8,7 +8,7 @@ import Foundation -enum DataProtectionManager { +enum BootDetectionManager { /// Detects after reboot, before unlocked state. Does this by trying to write a file to the filesystem (to the Caches directory) and read it back. /// - Parameter containerURL: Container url to write the file. /// - Returns: true if the state detected @@ -34,4 +34,15 @@ enum DataProtectionManager { } return false } + + /// The time that the system was booted, as a Unix timestamp. + static func systemBootTime() -> TimeInterval? { + var bootTime = timeval() + var size = MemoryLayout.size + var managementInformationBase: [Int32] = [CTL_KERN, KERN_BOOTTIME] + + guard sysctl(&managementInformationBase, 2, &bootTime, &size, nil, 0) == 0 else { return nil } + + return TimeInterval(bootTime.tv_sec) + TimeInterval(bootTime.tv_usec) / 1_000_000 + } } diff --git a/NSE/Sources/NotificationServiceExtension.swift b/NSE/Sources/NotificationServiceExtension.swift index b225c65be..5ab7f6ea4 100644 --- a/NSE/Sources/NotificationServiceExtension.swift +++ b/NSE/Sources/NotificationServiceExtension.swift @@ -30,7 +30,13 @@ import UserNotifications // notification. class NotificationServiceExtension: UNNotificationServiceExtension { + static let receivedWhileOfflineNotificationID = "io.element.elementx.receivedWhileOfflineNotification" + private static var targetConfiguration: Target.ConfigurationResult? + + private static var hasHandledFirstNotificationSinceBoot = false + private static let firstNotificationThreshold: TimeInterval = 15 * 60 + private let settings: CommonSettingsProtocol = AppSettings() private let appHooks: AppHooks @@ -55,7 +61,7 @@ class NotificationServiceExtension: UNNotificationServiceExtension { // the target configuration will fail. We could call exit(0) here, however with the // notification filtering entitlement that results in the notification being discarded // so we need to wait for the delegate method to be called and bail out there instead. - if !DataProtectionManager.isDeviceLockedAfterReboot(containerURL: URL.appGroupContainerDirectory), + if !BootDetectionManager.isDeviceLockedAfterReboot(containerURL: URL.appGroupContainerDirectory), Self.targetConfiguration == nil { Self.targetConfiguration = Target.nse.configure(logLevel: settings.logLevel, traceLogPacks: settings.traceLogPacks, @@ -72,14 +78,30 @@ class NotificationServiceExtension: UNNotificationServiceExtension { } private func handle(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) async { - // If we skipped configuring the target it means we can't write to the app group, so we're unlikely - // to be able to create a session (and even if we could, we would be missing the lightweightTokioRuntime), - // so instead lets deliver the default generic notification and avoid attempting to process the notification. + // If we skipped configuring the target it means we can't write to the app group, so we're unlikely to + // be able to create a session (and even if we could, we would be missing the lightweightTokioRuntime). + // Additionally, APNs servers only store the most recent notification when the device is powered off. + // So lets a) skip processing the notification and b) deliver a special "offline" notification as a workaround. guard Self.targetConfiguration != nil else { // MXLog isn't configured: // swiftlint:disable:next print_deprecation - print("Device is locked after reboot, delivering the unmodified notification.") - return contentHandler(request.content) + print("Device is locked after reboot.") + + if Self.hasHandledFirstNotificationSinceBoot { + return contentHandler(request.content) + } else { + Self.hasHandledFirstNotificationSinceBoot = true + deliverReceivedWhileOfflineNotification(for: request) + return contentHandler(.init()) + } + } + + guard !shouldDeliverReceivedWhileOfflineNotification() else { + // Don't log until the app hooks have been run: + // swiftlint:disable:next print_deprecation + print("Device is unlocked but may have missed notifications while offline.") + deliverReceivedWhileOfflineNotification(for: request) + return contentHandler(.init()) } guard let roomID = request.content.roomID else { @@ -149,7 +171,73 @@ class NotificationServiceExtension: UNNotificationServiceExtension { notificationHandler?.handleTimeExpiration() } - // MARK: - Private + // MARK: - Boot handling + + /// The APNs servers only store the most recent notification when delivery fails. So when the user first boots + /// their phone we need to use some approximations to decide whether or not the first notification may potentially + /// represent more than one message. When that appears possible we replace the notification's content with the special + /// "received while offline" notification as a more prominent prompt for to the user to open the app and check all their chats. + /// + /// Note that this only handles the first-boot case. When the SDK is able to compute the unread count, we should start to use the NSE, + /// remote-notifications (content-available) and background app refreshes to fetch and deliver our notifications as a more robust solution. + private func shouldDeliverReceivedWhileOfflineNotification() -> Bool { + if Self.hasHandledFirstNotificationSinceBoot { + // If we've already handled the first notification in this process there's no need to continue. + return false + } + + Self.hasHandledFirstNotificationSinceBoot = true + + guard let currentBootTime = BootDetectionManager.systemBootTime() else { + // There's not much we can do if the boot time is unknown, so don't show the offline notification. + return false + } + + guard let lastKnownBootTime = settings.lastNotificationBootTime else { + // Assume a missing boot time indicates a fresh installation… + // So store the current boot time but let the notification through. + settings.lastNotificationBootTime = currentBootTime + return false + } + + if abs(lastKnownBootTime - currentBootTime) < 1 { + return false + } + + // This is the first notification since boot, store the boot time. + settings.lastNotificationBootTime = currentBootTime + + // At this point it becomes a trade-off. Once the device has been powered on for a long enough amount + // of time it is a reasonable assumption that the device has now connected to a network and that any + // notification is actually new rather than having been sent whilst the device was powered off. + // + // Note: We could actually solve this by having Sygnal add a timestamp to the notification payload 🤔 + if Date.now.timeIntervalSince(Date(timeIntervalSince1970: currentBootTime)) > Self.firstNotificationThreshold { + return false + } else { + return true + } + } + + /// Delivers a generic notification informing the user that they have one or more new messages. + /// + /// Note: it is safe to call this method multiple times as it simply replaces any existing instance of the notification + /// with a fresh copy, meaning it won't queue multiple copies but will still re-play the notification sound. + private func deliverReceivedWhileOfflineNotification(for originalRequest: UNNotificationRequest) { + // This is intended to be called before the app hooks have been run, so don't log: + // swiftlint:disable:next print_deprecation + print("Delivering the 'received while offline' notification.") + + let content = UNMutableNotificationContent() + content.body = L10n.notificationReceivedWhileOfflineIos + content.badge = originalRequest.content.unreadCount as NSNumber? + content.sound = .init(named: .init("message.caf")) + + let request = UNNotificationRequest(identifier: Self.receivedWhileOfflineNotificationID, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + } + + // MARK: - Logging private var tag: String { "[NSE][\(Unmanaged.passUnretained(self).toOpaque())][\(Unmanaged.passUnretained(Thread.current).toOpaque())][\(ProcessInfo.processInfo.processIdentifier)]" diff --git a/NSE/SupportingFiles/PrivacyInfo.xcprivacy b/NSE/SupportingFiles/PrivacyInfo.xcprivacy index 04e918224..7ecb60d34 100644 --- a/NSE/SupportingFiles/PrivacyInfo.xcprivacy +++ b/NSE/SupportingFiles/PrivacyInfo.xcprivacy @@ -28,6 +28,14 @@ 7D9E.1 + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 8FFB.1 + +