From a2f1f36d2cff1deadffd5d5d2c86f8540f981fbb Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 8 Sep 2023 11:42:04 +1000 Subject: [PATCH] Fixed a number of issues with the Notification Service Extension Fixed an issue where receiving a PN for outgoing messages could break the NotificationServiceExtension Fixed an issue where the NotificationServiceExtension could startup in an invalid way resulting in subsequent PNs failing to process Fixed an issue where you could incorrectly receive multiple generic notifications after receiving an incoming call notification Fixed an issue where the read state syncing might not clear notifications from the notification center Fixed an issue with parsing Bencoded data Updated the PN subscription to subscribe to CONVO_INFO_VOLATILE notifications (update read state) Updated the NotificationServiceExtension to use standard message processing where possible Updated the NotificationServiceExtension to update the app badge based on a database query --- Session.xcodeproj/project.pbxproj | 28 +- .../Call Management/SessionCallManager.swift | 10 +- Session/Meta/AppDelegate.swift | 45 +-- Session/Meta/MainAppContext.swift | 5 - Session/Settings/NukeDataModal.swift | 2 +- .../Database/Models/Interaction.swift | 202 +++++++----- .../Models/NotificationMetadata.swift | 2 +- .../Notifications/PushNotificationAPI.swift | 9 +- .../Notifications/Types/ProcessResult.swift | 2 + .../SessionUtil+ConvoInfoVolatile.swift | 24 +- .../NSENotificationPresenter.swift | 21 +- .../NotificationError.swift | 18 ++ .../NotificationServiceExtension.swift | 126 ++++---- .../NotificationServiceExtensionContext.swift | 1 - .../ShareAppExtensionContext.swift | 4 - SessionUtilitiesKit/Database/Storage.swift | 8 +- SessionUtilitiesKit/General/AppContext.h | 3 - .../General/SNUserDefaults.swift | 1 + SessionUtilitiesKit/Utilities/Bencode.swift | 131 +++----- .../Utilities/BencodeSpec.swift | 289 +++++++++++++++--- SignalUtilitiesKit/Utilities/AppSetup.swift | 33 +- 21 files changed, 606 insertions(+), 358 deletions(-) create mode 100644 SessionNotificationServiceExtension/NotificationError.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index d720d5f32..e7c24428b 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -740,6 +740,7 @@ FD96F3A729DBD43D00401309 /* MockJobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD96F3A629DBD43D00401309 /* MockJobRunner.swift */; }; FD97B2402A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */; }; FD97B2422A3FEBF30027DD57 /* UnreadMarkerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD97B2412A3FEBF30027DD57 /* UnreadMarkerCell.swift */; }; + FD9AECA52AAA9609009B3406 /* NotificationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9AECA42AAA9609009B3406 /* NotificationError.swift */; }; FD9B30F3293EA0BF008DEE3E /* BatchResponseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9B30F2293EA0BF008DEE3E /* BatchResponseSpec.swift */; }; FD9BDE002A5D22B7005F1EBC /* libSessionUtil.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FD9BDDF82A5D2294005F1EBC /* libSessionUtil.a */; }; FD9BDE012A5D24EA005F1EBC /* SessionUIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C331FF1B2558F9D300070591 /* SessionUIKit.framework */; }; @@ -1856,6 +1857,7 @@ FD96F3A629DBD43D00401309 /* MockJobRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockJobRunner.swift; sourceTree = ""; }; FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ARC4RandomNumberGenerator.swift; sourceTree = ""; }; FD97B2412A3FEBF30027DD57 /* UnreadMarkerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnreadMarkerCell.swift; sourceTree = ""; }; + FD9AECA42AAA9609009B3406 /* NotificationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationError.swift; sourceTree = ""; }; FD9B30F2293EA0BF008DEE3E /* BatchResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchResponseSpec.swift; sourceTree = ""; }; FD9BDDF82A5D2294005F1EBC /* libSessionUtil.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libSessionUtil.a; sourceTree = BUILT_PRODUCTS_DIR; }; FD9DD2702A72516D00ECB68E /* TestExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestExtensions.swift; sourceTree = ""; }; @@ -2408,6 +2410,7 @@ isa = PBXGroup; children = ( C31C219B255BC92200EC2D66 /* Meta */, + FD9AECA42AAA9609009B3406 /* NotificationError.swift */, 7BDCFC07242186E700641C39 /* NotificationServiceExtensionContext.swift */, 7BC01A3D241F40AB00BC7C55 /* NotificationServiceExtension.swift */, 7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */, @@ -5454,6 +5457,7 @@ 7BDCFC08242186E700641C39 /* NotificationServiceExtensionContext.swift in Sources */, 7BC01A3E241F40AB00BC7C55 /* NotificationServiceExtension.swift in Sources */, 7B1D74AA27BCC16E0030B423 /* NSENotificationPresenter.swift in Sources */, + FD9AECA52AAA9609009B3406 /* NotificationError.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6445,7 +6449,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 422; + CURRENT_PROJECT_VERSION = 423; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -6469,7 +6473,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.4.0; + MARKETING_VERSION = 2.4.1; MTL_ENABLE_DEBUG_INFO = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6517,7 +6521,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 422; + CURRENT_PROJECT_VERSION = 423; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -6546,7 +6550,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.4.0; + MARKETING_VERSION = 2.4.1; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6582,7 +6586,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 422; + CURRENT_PROJECT_VERSION = 423; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -6605,7 +6609,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.4.0; + MARKETING_VERSION = 2.4.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension"; @@ -6656,7 +6660,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 422; + CURRENT_PROJECT_VERSION = 423; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -6684,7 +6688,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.4.0; + MARKETING_VERSION = 2.4.1; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension"; @@ -7616,7 +7620,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 422; + CURRENT_PROJECT_VERSION = 423; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -7654,7 +7658,7 @@ "$(SRCROOT)", ); LLVM_LTO = NO; - MARKETING_VERSION = 2.4.0; + MARKETING_VERSION = 2.4.1; OTHER_LDFLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; @@ -7687,7 +7691,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 422; + CURRENT_PROJECT_VERSION = 423; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -7725,7 +7729,7 @@ "$(SRCROOT)", ); LLVM_LTO = NO; - MARKETING_VERSION = 2.4.0; + MARKETING_VERSION = 2.4.1; OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; PRODUCT_NAME = Session; diff --git a/Session/Calls/Call Management/SessionCallManager.swift b/Session/Calls/Call Management/SessionCallManager.swift index 81b3879a8..09b9d2305 100644 --- a/Session/Calls/Call Management/SessionCallManager.swift +++ b/Session/Calls/Call Management/SessionCallManager.swift @@ -93,7 +93,8 @@ public final class SessionCallManager: NSObject, CallManagerProtocol { public func reportOutgoingCall(_ call: SessionCall) { AssertIsOnMainThread() - UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing") + UserDefaults.sharedLokiProject?[.isCallOngoing] = true + UserDefaults.sharedLokiProject?[.lastCallPreOffer] = Date() call.stateDidChange = { if call.hasStartedConnecting { @@ -123,7 +124,8 @@ public final class SessionCallManager: NSObject, CallManagerProtocol { completion(error) return } - UserDefaults.sharedLokiProject?.set(true, forKey: "isCallOngoing") + UserDefaults.sharedLokiProject?[.isCallOngoing] = true + UserDefaults.sharedLokiProject?[.lastCallPreOffer] = Date() completion(nil) } } @@ -138,7 +140,9 @@ public final class SessionCallManager: NSObject, CallManagerProtocol { func handleCallEnded() { WebRTCSession.current = nil - UserDefaults.sharedLokiProject?.set(false, forKey: "isCallOngoing") + UserDefaults.sharedLokiProject?[.isCallOngoing] = false + UserDefaults.sharedLokiProject?[.lastCallPreOffer] = nil + if CurrentAppContext().isInBackground() { (UIApplication.shared.delegate as? AppDelegate)?.stopPollers() DDLog.flushLog() diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 283baeaf5..03103b7fe 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -89,7 +89,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD ) if Environment.shared?.callManager.wrappedValue?.currentCall == nil { - UserDefaults.sharedLokiProject?.set(false, forKey: "isCallOngoing") + UserDefaults.sharedLokiProject?[.isCallOngoing] = false + UserDefaults.sharedLokiProject?[.lastCallPreOffer] = nil } // No point continuing if we are running tests @@ -416,7 +417,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // Don't offer the 'Restore' option if it was a 'startupFailed' error as a restore is unlikely to // resolve it (most likely the database is locked or the key was somehow lost - safer to get them // to restart and manually reinstall/restore) - case .databaseError(StorageError.startupFailed): break + case .databaseError(StorageError.startupFailed), .databaseError(DatabaseError.SQLITE_LOCKED): break // Offer the 'Restore' option if it was a migration error case .databaseError: @@ -663,39 +664,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD /// we don't block user interaction while it's running DispatchQueue.global(qos: .default).async { let unreadCount: Int = Storage.shared - .read { db in - let userPublicKey: String = getUserHexEncodedPublicKey(db) - let thread: TypedTableAlias = TypedTableAlias() - - return try Interaction - .filter(Interaction.Columns.wasRead == false) - .filter(Interaction.Variant.variantsToIncrementUnreadCount.contains(Interaction.Columns.variant)) - .filter( - // Only count mentions if 'onlyNotifyForMentions' is set - thread[.onlyNotifyForMentions] == false || - Interaction.Columns.hasMention == true - ) - .joining( - required: Interaction.thread - .aliased(thread) - .joining(optional: SessionThread.contact) - .filter( - // Ignore muted threads - SessionThread.Columns.mutedUntilTimestamp == nil || - SessionThread.Columns.mutedUntilTimestamp < Date().timeIntervalSince1970 - ) - .filter( - // Ignore message request threads - SessionThread.Columns.variant != SessionThread.Variant.contact || - !SessionThread.isMessageRequest(userPublicKey: userPublicKey) - ) - ) - .fetchCount(db) - } + .read { db in try Interaction.fetchUnreadCount(db) } .defaulting(to: 0) DispatchQueue.main.async { - CurrentAppContext().setMainAppBadgeNumber(unreadCount) + UIApplication.shared.applicationIconBadgeNumber = unreadCount } } } @@ -904,7 +877,9 @@ private enum StartupError: Error { var name: String { switch self { - case .databaseError(StorageError.startupFailed): return "Database startup failed" + case .databaseError(StorageError.startupFailed), .databaseError(DatabaseError.SQLITE_LOCKED): + return "Database startup failed" + case .failedToRestore: return "Failed to restore" case .databaseError: return "Database error" case .startupTimeout: return "Startup timeout" @@ -913,7 +888,9 @@ private enum StartupError: Error { var message: String { switch self { - case .databaseError(StorageError.startupFailed): return "DATABASE_STARTUP_FAILED".localized() + case .databaseError(StorageError.startupFailed), .databaseError(DatabaseError.SQLITE_LOCKED): + return "DATABASE_STARTUP_FAILED".localized() + case .failedToRestore: return "DATABASE_RESTORE_FAILED".localized() case .databaseError: return "DATABASE_MIGRATION_FAILED".localized() case .startupTimeout: return "APP_STARTUP_TIMEOUT".localized() diff --git a/Session/Meta/MainAppContext.swift b/Session/Meta/MainAppContext.swift index 949176192..65dee3c66 100644 --- a/Session/Meta/MainAppContext.swift +++ b/Session/Meta/MainAppContext.swift @@ -179,11 +179,6 @@ final class MainAppContext: NSObject, AppContext { UIApplication.shared.isIdleTimerDisabled = shouldBeBlocking } - func setMainAppBadgeNumber(_ value: Int) { - UIApplication.shared.applicationIconBadgeNumber = value - UserDefaults.sharedLokiProject?.setValue(value, forKey: "currentBadgeNumber") - } - func frontmostViewController() -> UIViewController? { UIApplication.shared.frontmostViewControllerIgnoringAlerts } diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index 371aba884..90fb13620 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -240,7 +240,7 @@ final class NukeDataModal: Modal { // Clear the app badge and notifications AppEnvironment.shared.notificationPresenter.clearAllNotifications() - CurrentAppContext().setMainAppBadgeNumber(0) + UIApplication.shared.applicationIconBadgeNumber = 0 // Clear out the user defaults UserDefaults.removeAll() diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 9d93212cd..0ee0e9dd1 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -465,6 +465,46 @@ public extension Interaction { // MARK: - GRDB Interactions public extension Interaction { + struct ReadInfo: Decodable, FetchableRecord { + let id: Int64 + let variant: Interaction.Variant + let timestampMs: Int64 + let wasRead: Bool + } + + static func fetchUnreadCount( + _ db: Database, + using dependencies: Dependencies = Dependencies() + ) throws -> Int { + let userPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) + let thread: TypedTableAlias = TypedTableAlias() + + return try Interaction + .filter(Interaction.Columns.wasRead == false) + .filter(Interaction.Variant.variantsToIncrementUnreadCount.contains(Interaction.Columns.variant)) + .filter( + // Only count mentions if 'onlyNotifyForMentions' is set + thread[.onlyNotifyForMentions] == false || + Interaction.Columns.hasMention == true + ) + .joining( + required: Interaction.thread + .aliased(thread) + .joining(optional: SessionThread.contact) + .filter( + // Ignore muted threads + SessionThread.Columns.mutedUntilTimestamp == nil || + SessionThread.Columns.mutedUntilTimestamp < dependencies.dateNow.timeIntervalSince1970 + ) + .filter( + // Ignore message request threads + SessionThread.Columns.variant != SessionThread.Variant.contact || + !SessionThread.isMessageRequest(userPublicKey: userPublicKey) + ) + ) + .fetchCount(db) + } + /// This will update the `wasRead` state the the interaction /// /// - Parameters @@ -482,83 +522,16 @@ public extension Interaction { ) throws { guard let interactionId: Int64 = interactionId else { return } - struct InteractionReadInfo: Decodable, FetchableRecord { - let id: Int64 - let variant: Interaction.Variant - let timestampMs: Int64 - let wasRead: Bool - } - - // Once all of the below is done schedule the jobs - func scheduleJobs( - _ db: Database, - threadId: String, - threadVariant: SessionThread.Variant, - interactionInfo: [InteractionReadInfo], - lastReadTimestampMs: Int64 - ) throws { - // Update the last read timestamp if needed - try SessionUtil.syncThreadLastReadIfNeeded( - db, - threadId: threadId, - threadVariant: threadVariant, - lastReadTimestampMs: lastReadTimestampMs - ) - - // Add the 'DisappearingMessagesJob' if needed - this will update any expiring - // messages `expiresStartedAtMs` values - JobRunner.upsert( - db, - job: DisappearingMessagesJob.updateNextRunIfNeeded( - db, - interactionIds: interactionInfo.map { $0.id }, - startedAtMs: TimeInterval(SnodeAPI.currentOffsetTimestampMs()) - ) - ) - - // Clear out any notifications for the interactions we mark as read - Environment.shared?.notificationsManager.wrappedValue?.cancelNotifications( - identifiers: interactionInfo - .map { interactionInfo in - Interaction.notificationIdentifier( - for: interactionInfo.id, - threadId: threadId, - shouldGroupMessagesForThread: false - ) - } - .appending(Interaction.notificationIdentifier( - for: 0, - threadId: threadId, - shouldGroupMessagesForThread: true - )) - ) - - // If we want to send read receipts and it's a contact thread then try to add the - // 'SendReadReceiptsJob' for and unread messages that weren't outgoing - if trySendReadReceipt && threadVariant == .contact { - JobRunner.upsert( - db, - job: SendReadReceiptsJob.createOrUpdateIfNeeded( - db, - threadId: threadId, - interactionIds: interactionInfo - .filter { !$0.wasRead && $0.variant != .standardOutgoing } - .map { $0.id } - ) - ) - } - } - // Since there is no guarantee on the order messages are inserted into the database // fetch the timestamp for the interaction and set everything before that as read - let maybeInteractionInfo: InteractionReadInfo? = try Interaction + let maybeInteractionInfo: Interaction.ReadInfo? = try Interaction .select(.id, .variant, .timestampMs, .wasRead) .filter(id: interactionId) - .asRequest(of: InteractionReadInfo.self) + .asRequest(of: Interaction.ReadInfo.self) .fetchOne(db) // If we aren't including older interactions then update and save the current one - guard includingOlder, let interactionInfo: InteractionReadInfo = maybeInteractionInfo else { + guard includingOlder, let interactionInfo: Interaction.ReadInfo = maybeInteractionInfo else { // Only mark as read and trigger the subsequent jobs if the interaction is // actually not read (no point updating and triggering db changes otherwise) guard @@ -575,19 +548,21 @@ public extension Interaction { .filter(id: interactionId) .updateAll(db, Columns.wasRead.set(to: true)) - try scheduleJobs( + try Interaction.scheduleReadJobs( db, threadId: threadId, threadVariant: threadVariant, interactionInfo: [ - InteractionReadInfo( + Interaction.ReadInfo( id: interactionId, variant: variant, timestampMs: 0, wasRead: false ) ], - lastReadTimestampMs: timestampMs + lastReadTimestampMs: timestampMs, + trySendReadReceipt: trySendReadReceipt, + calledFromConfigHandling: false ) return } @@ -596,21 +571,23 @@ public extension Interaction { .filter(Interaction.Columns.threadId == threadId) .filter(Interaction.Columns.timestampMs <= interactionInfo.timestampMs) .filter(Interaction.Columns.wasRead == false) - let interactionInfoToMarkAsRead: [InteractionReadInfo] = try interactionQuery + let interactionInfoToMarkAsRead: [Interaction.ReadInfo] = try interactionQuery .select(.id, .variant, .timestampMs, .wasRead) - .asRequest(of: InteractionReadInfo.self) + .asRequest(of: Interaction.ReadInfo.self) .fetchAll(db) // If there are no other interactions to mark as read then just schedule the jobs // for this interaction (need to ensure the disapeparing messages run for sync'ed // outgoing messages which will always have 'wasRead' as false) guard !interactionInfoToMarkAsRead.isEmpty else { - try scheduleJobs( + try Interaction.scheduleReadJobs( db, threadId: threadId, threadVariant: threadVariant, interactionInfo: [interactionInfo], - lastReadTimestampMs: interactionInfo.timestampMs + lastReadTimestampMs: interactionInfo.timestampMs, + trySendReadReceipt: trySendReadReceipt, + calledFromConfigHandling: false ) return } @@ -619,12 +596,14 @@ public extension Interaction { try interactionQuery.updateAll(db, Columns.wasRead.set(to: true)) // Retrieve the interaction ids we want to update - try scheduleJobs( + try Interaction.scheduleReadJobs( db, threadId: threadId, threadVariant: threadVariant, interactionInfo: interactionInfoToMarkAsRead, - lastReadTimestampMs: interactionInfo.timestampMs + lastReadTimestampMs: interactionInfo.timestampMs, + trySendReadReceipt: trySendReadReceipt, + calledFromConfigHandling: false ) } @@ -691,6 +670,71 @@ public extension Interaction { .asSet() .subtracting(timestampsUpdated) } + + static func scheduleReadJobs( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + interactionInfo: [Interaction.ReadInfo], + lastReadTimestampMs: Int64, + trySendReadReceipt: Bool, + calledFromConfigHandling: Bool + ) throws { + guard !interactionInfo.isEmpty else { return } + + // Update the last read timestamp if needed + if !calledFromConfigHandling { + try SessionUtil.syncThreadLastReadIfNeeded( + db, + threadId: threadId, + threadVariant: threadVariant, + lastReadTimestampMs: lastReadTimestampMs + ) + } + + // Add the 'DisappearingMessagesJob' if needed - this will update any expiring + // messages `expiresStartedAtMs` values + JobRunner.upsert( + db, + job: DisappearingMessagesJob.updateNextRunIfNeeded( + db, + interactionIds: interactionInfo.map { $0.id }, + startedAtMs: TimeInterval(SnodeAPI.currentOffsetTimestampMs()) + ) + ) + + // Clear out any notifications for the interactions we mark as read + Environment.shared?.notificationsManager.wrappedValue?.cancelNotifications( + identifiers: interactionInfo + .map { interactionInfo in + Interaction.notificationIdentifier( + for: interactionInfo.id, + threadId: threadId, + shouldGroupMessagesForThread: false + ) + } + .appending(Interaction.notificationIdentifier( + for: 0, + threadId: threadId, + shouldGroupMessagesForThread: true + )) + ) + + /// If we want to send read receipts and it's a contact thread then try to add the `SendReadReceiptsJob` for and unread + /// messages that weren't outgoing + if trySendReadReceipt && threadVariant == .contact { + JobRunner.upsert( + db, + job: SendReadReceiptsJob.createOrUpdateIfNeeded( + db, + threadId: threadId, + interactionIds: interactionInfo + .filter { !$0.wasRead && $0.variant != .standardOutgoing } + .map { $0.id } + ) + ) + } + } } // MARK: - Search Queries diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift index 9a3633d85..fc9e93f9e 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift @@ -41,7 +41,7 @@ extension PushNotificationAPI.NotificationMetadata { hash: try container.decode(String.self, forKey: .hash), namespace: try container.decode(Int.self, forKey: .namespace), dataLength: try container.decode(Int.self, forKey: .dataLength), - dataTooLong: ((try? container.decode(Bool.self, forKey: .dataTooLong)) ?? false) + dataTooLong: ((try? container.decode(Int.self, forKey: .dataTooLong) != 0) ?? false) ) } } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 6e32886ac..bd937f072 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -56,7 +56,7 @@ public enum PushNotificationAPI { let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, using: dependencies) let request: SubscribeRequest = SubscribeRequest( pubkey: currentUserPublicKey, - namespaces: [.default], + namespaces: [.default, .configConvoInfoVolatile], // Note: Unfortunately we always need the message content because without the content // control messages can't be distinguished from visible messages which results in the // 'generic' notification being shown when receiving things like typing indicator updates @@ -372,8 +372,11 @@ public enum PushNotificationAPI { return (envelope, .legacySuccess) } + guard let base64EncodedEncString: String = notificationContent.userInfo["enc_payload"] as? String else { + return (nil, .failureNoContent) + } + guard - let base64EncodedEncString: String = notificationContent.userInfo["enc_payload"] as? String, let encData: Data = Data(base64Encoded: base64EncodedEncString), let notificationsEncryptionKey: Data = try? getOrGenerateEncryptionKey(using: dependencies), encData.count > dependencies.crypto.size(.aeadXChaCha20NonceBytes) @@ -401,7 +404,7 @@ public enum PushNotificationAPI { // If the metadata says that the message was too large then we should show the generic // notification (this is a valid case) - guard !notification.info.dataTooLong else { return (nil, .success) } + guard !notification.info.dataTooLong else { return (nil, .successTooLong) } // Check that the body we were given is valid guard diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift index 1c72b1629..f059dc2ee 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift @@ -5,7 +5,9 @@ import Foundation public extension PushNotificationAPI { enum ProcessResult { case success + case successTooLong case failure + case failureNoContent case legacySuccess case legacyFailure case legacyForceSilent diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+ConvoInfoVolatile.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+ConvoInfoVolatile.swift index 7e83611db..645881fba 100644 --- a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+ConvoInfoVolatile.swift +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+ConvoInfoVolatile.swift @@ -77,16 +77,28 @@ internal extension SessionUtil { } // Mark all older interactions as read - try Interaction - .filter( - Interaction.Columns.threadId == threadId && - Interaction.Columns.timestampMs <= lastReadTimestampMs && - Interaction.Columns.wasRead == false - ) + let interactionQuery = Interaction + .filter(Interaction.Columns.threadId == threadId) + .filter(Interaction.Columns.timestampMs <= lastReadTimestampMs) + .filter(Interaction.Columns.wasRead == false) + let interactionInfoToMarkAsRead: [Interaction.ReadInfo] = try interactionQuery + .select(.id, .variant, .timestampMs, .wasRead) + .asRequest(of: Interaction.ReadInfo.self) + .fetchAll(db) + try interactionQuery .updateAll( // Handling a config update so don't use `updateAllAndConfig` db, Interaction.Columns.wasRead.set(to: true) ) + try Interaction.scheduleReadJobs( + db, + threadId: threadId, + threadVariant: threadInfo.variant, + interactionInfo: interactionInfoToMarkAsRead, + lastReadTimestampMs: lastReadTimestampMs, + trySendReadReceipt: false, // Interactions already read, no need to send + calledFromConfigHandling: true + ) return nil } diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index 2a0d12b80..9dd5d330f 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -56,11 +56,9 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { notificationContent.sound = thread.notificationSound .defaulting(to: db[.defaultNotificationSound] ?? Preferences.Sound.defaultNotificationSound) .notificationSound(isQuiet: false) - - // Badge Number - let newBadgeNumber = CurrentAppContext().appUserDefaults().integer(forKey: "currentBadgeNumber") + 1 - notificationContent.badge = NSNumber(value: newBadgeNumber) - CurrentAppContext().appUserDefaults().set(newBadgeNumber, forKey: "currentBadgeNumber") + notificationContent.badge = (try? Interaction.fetchUnreadCount(db)) + .map { NSNumber(value: $0) } + .defaulting(to: NSNumber(value: 0)) // Title & body let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] @@ -157,16 +155,11 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { let notificationContent = UNMutableNotificationContent() notificationContent.userInfo = userInfo notificationContent.sound = thread.notificationSound - .defaulting( - to: db[.defaultNotificationSound] - .defaulting(to: Preferences.Sound.defaultNotificationSound) - ) + .defaulting(to: db[.defaultNotificationSound] ?? Preferences.Sound.defaultNotificationSound) .notificationSound(isQuiet: false) - - // Badge Number - let newBadgeNumber = CurrentAppContext().appUserDefaults().integer(forKey: "currentBadgeNumber") + 1 - notificationContent.badge = NSNumber(value: newBadgeNumber) - CurrentAppContext().appUserDefaults().set(newBadgeNumber, forKey: "currentBadgeNumber") + notificationContent.badge = (try? Interaction.fetchUnreadCount(db)) + .map { NSNumber(value: $0) } + .defaulting(to: NSNumber(value: 0)) notificationContent.title = "Session" notificationContent.body = "" diff --git a/SessionNotificationServiceExtension/NotificationError.swift b/SessionNotificationServiceExtension/NotificationError.swift new file mode 100644 index 000000000..5d2884509 --- /dev/null +++ b/SessionNotificationServiceExtension/NotificationError.swift @@ -0,0 +1,18 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionMessagingKit + +enum NotificationError: LocalizedError { + case processing(PushNotificationAPI.ProcessResult) + case messageProcessing + case messageHandling(MessageReceiverError) + + public var errorDescription: String? { + switch self { + case .processing(let result): return "Failed to process notification (\(result))" + case .messageProcessing: return "Failed to process message" + case .messageHandling(let error): return "Failed to handle message (\(error))" + } + } +} diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index a80b424f5..084ef835d 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -15,11 +15,13 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension private var didPerformSetup = false private var contentHandler: ((UNNotificationContent) -> Void)? private var request: UNNotificationRequest? + private var openGroupPollCancellable: AnyCancellable? public static let isFromRemoteKey = "remote" public static let threadIdKey = "Signal.AppNotificationsUserInfoKey.threadId" public static let threadVariantRaw = "Signal.AppNotificationsUserInfoKey.threadVariantRaw" public static let threadNotificationCounter = "Session.AppNotificationsUserInfoKey.threadNotificationCounter" + private static let callPreOfferLargeNotificationSupressionDuration: TimeInterval = 30 // MARK: Did receive a remote push notification request @@ -43,6 +45,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension let isCallOngoing: Bool = (UserDefaults.sharedLokiProject?[.isCallOngoing]) .defaulting(to: false) + let lastCallPreOffer: Date? = UserDefaults.sharedLokiProject?[.lastCallPreOffer] // Perform main setup Storage.resumeDatabaseAccess() @@ -52,14 +55,13 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension AppReadiness.runNowOrWhenAppDidBecomeReady { let openGroupPollingPublishers: [AnyPublisher] = self.pollForOpenGroups() defer { - Publishers + self.openGroupPollCancellable = Publishers .MergeMany(openGroupPollingPublishers) .subscribe(on: DispatchQueue.global(qos: .background)) .subscribe(on: DispatchQueue.main) - .sinkUntilComplete( - receiveCompletion: { _ in - self.completeSilenty() - } + .sink( + receiveCompletion: { [weak self] _ in self?.completeSilenty() }, + receiveValue: { _ in } ) } @@ -75,9 +77,21 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // If we got an explicit failure, or we got a success but no content then show // the fallback notification case .success, .legacySuccess, .failure, .legacyFailure: - return self.handleFailure(for: notificationContent) - - case .legacyForceSilent: return + return self.handleFailure(for: notificationContent, error: .processing(result)) + + case .successTooLong: + /// If the notification is too long and there is an ongoing call or a recent call pre-offer then we assume the notification + /// is a call `ICE_CANDIDATES` message and just complete silently (because the fallback would be annoying), if not + /// then we do want to show the fallback notification + guard + isCallOngoing || + (lastCallPreOffer ?? Date.distantPast).timeIntervalSinceNow < NotificationServiceExtension.callPreOfferLargeNotificationSupressionDuration + else { return self.handleFailure(for: notificationContent, error: .processing(result)) } + + NSLog("[NotificationServiceExtension] Suppressing large notification too close to a call.") + return + + case .legacyForceSilent, .failureNoContent: return } } @@ -87,10 +101,26 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension Storage.shared.write { db in do { guard let processedMessage: ProcessedMessage = try Message.processRawReceivedMessageAsNotification(db, envelope: envelope) else { - self.handleFailure(for: notificationContent) + self.handleFailure(for: notificationContent, error: .messageProcessing) return } + /// Due to the way the `CallMessage` and `SharedConfigMessage` work we need to custom + /// handle their behaviours, for all other message types we want to just use standard messages + switch processedMessage.messageInfo.message { + case is CallMessage, is SharedConfigMessage: break + default: + try MessageReceiver.handle( + db, + threadId: processedMessage.threadId, + threadVariant: processedMessage.threadVariant, + message: processedMessage.messageInfo.message, + serverExpirationTimestamp: processedMessage.messageInfo.serverExpirationTimestamp, + associatedWithProto: processedMessage.proto + ) + return + } + // Throw if the message is outdated and shouldn't be processed try MessageReceiver.throwIfMessageOutdated( db, @@ -100,47 +130,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension ) switch processedMessage.messageInfo.message { - case let visibleMessage as VisibleMessage: - let interactionId: Int64 = try MessageReceiver.handleVisibleMessage( - db, - threadId: processedMessage.threadId, - threadVariant: processedMessage.threadVariant, - message: visibleMessage, - associatedWithProto: processedMessage.proto - ) - - // Remove the notifications if there is an outgoing messages from a linked device - if - let interaction: Interaction = try? Interaction.fetchOne(db, id: interactionId), - interaction.variant == .standardOutgoing - { - let semaphore = DispatchSemaphore(value: 0) - let center = UNUserNotificationCenter.current() - center.getDeliveredNotifications { notifications in - let matchingNotifications = notifications.filter({ $0.request.content.userInfo[NotificationServiceExtension.threadIdKey] as? String == interaction.threadId }) - center.removeDeliveredNotifications(withIdentifiers: matchingNotifications.map({ $0.request.identifier })) - // Hack: removeDeliveredNotifications seems to be async,need to wait for some time before the delivered notifications can be removed. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { semaphore.signal() } - } - semaphore.wait() - } - - case let unsendRequest as UnsendRequest: - try MessageReceiver.handleUnsendRequest( - db, - threadId: processedMessage.threadId, - threadVariant: processedMessage.threadVariant, - message: unsendRequest - ) - - case let closedGroupControlMessage as ClosedGroupControlMessage: - try MessageReceiver.handleClosedGroupControlMessage( - db, - threadId: processedMessage.threadId, - threadVariant: processedMessage.threadVariant, - message: closedGroupControlMessage - ) - case let callMessage as CallMessage: try MessageReceiver.handleCallMessage( db, @@ -187,6 +176,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension break } + try MessageReceiver.insertCallInfoMessage(db, for: callMessage) self.handleSuccessForIncomingCall(db, for: callMessage) case let sharedConfigMessage as SharedConfigMessage: @@ -210,7 +200,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension if let error = error as? MessageReceiverError, error.isRetryable { switch error { case .invalidGroupPublicKey, .noGroupKeyPair, .outdatedMessage: self.completeSilenty() - default: self.handleFailure(for: notificationContent) + default: self.handleFailure(for: notificationContent, error: .messageHandling(error)) } } } @@ -236,6 +226,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension Cryptography.seedRandom() AppSetup.setupEnvironment( + retrySetupIfDatabaseInvalid: true, appSpecificBlock: { Environment.shared?.notificationsManager.mutate { $0 = NSENotificationPresenter() @@ -307,14 +298,21 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension override public func serviceExtensionTimeWillExpire() { // Called just before the extension will be terminated by the system. // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. + NSLog("[NotificationServiceExtension] Execution time expired") + openGroupPollCancellable?.cancel() completeSilenty() } private func completeSilenty() { NSLog("[NotificationServiceExtension] Complete silently") + let silentContent: UNMutableNotificationContent = UNMutableNotificationContent() + silentContent.badge = Storage.shared + .read { db in try Interaction.fetchUnreadCount(db) } + .map { NSNumber(value: $0) } + .defaulting(to: NSNumber(value: 0)) Storage.suspendDatabaseAccess() - self.contentHandler!(.init()) + self.contentHandler!(silentContent) } private func handleSuccessForIncomingCall(_ db: Database, for callMessage: CallMessage) { @@ -330,11 +328,12 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension CXProvider.reportNewIncomingVoIPPushPayload(payload) { error in if let error = error { self.handleFailureForVoIP(db, for: callMessage) - SNLog("Failed to notify main app of call message: \(error)") + NSLog("[NotificationServiceExtension] Failed to notify main app of call message: \(error)") } else { + NSLog("[NotificationServiceExtension] Successfully notified main app of call message.") + UserDefaults.sharedLokiProject?[.lastCallPreOffer] = Date() self.completeSilenty() - SNLog("Successfully notified main app of call message.") } } } @@ -347,11 +346,9 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension let notificationContent = UNMutableNotificationContent() notificationContent.userInfo = [ NotificationServiceExtension.isFromRemoteKey : true ] notificationContent.title = "Session" - - // Badge Number - let newBadgeNumber = CurrentAppContext().appUserDefaults().integer(forKey: "currentBadgeNumber") + 1 - notificationContent.badge = NSNumber(value: newBadgeNumber) - CurrentAppContext().appUserDefaults().set(newBadgeNumber, forKey: "currentBadgeNumber") + notificationContent.badge = (try? Interaction.fetchUnreadCount(db)) + .map { NSNumber(value: $0) } + .defaulting(to: NSNumber(value: 0)) if let sender: String = callMessage.sender { let senderDisplayName: String = Profile.displayName(db, id: sender, threadVariant: .contact) @@ -367,20 +364,21 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension UNUserNotificationCenter.current().add(request) { error in if let error = error { - SNLog("Failed to add notification request due to error:\(error)") + NSLog("[NotificationServiceExtension] Failed to add notification request due to error: \(error)") } semaphore.signal() } semaphore.wait() - SNLog("Add remote notification request") + NSLog("[NotificationServiceExtension] Add remote notification request") } - private func handleFailure(for content: UNMutableNotificationContent) { + private func handleFailure(for content: UNMutableNotificationContent, error: NotificationError) { + NSLog("[NotificationServiceExtension] Show generic failure message due to error: \(error)") Storage.suspendDatabaseAccess() - content.body = "You've got a new message" content.title = "Session" - let userInfo: [String:Any] = [ NotificationServiceExtension.isFromRemoteKey : true ] + content.body = "APN_Message".localized() + let userInfo: [String: Any] = [ NotificationServiceExtension.isFromRemoteKey: true ] content.userInfo = userInfo contentHandler!(content) } diff --git a/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift b/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift index d642a984c..4e643e0d5 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtensionContext.swift @@ -71,7 +71,6 @@ final class NotificationServiceExtensionContext : NSObject, AppContext { func ensureSleepBlocking(_ shouldBeBlocking: Bool, blockingObjectsDescription: String) { } func frontmostViewController() -> UIViewController? { nil } func runNowOr(whenMainAppIsActive block: @escaping AppActiveBlock) { } - func setMainAppBadgeNumber(_ value: Int) { } func setNetworkActivityIndicatorVisible(_ value: Bool) { } func setStatusBarHidden(_ isHidden: Bool, animated isAnimated: Bool) { } } diff --git a/SessionShareExtension/ShareAppExtensionContext.swift b/SessionShareExtension/ShareAppExtensionContext.swift index 4c647fdc7..2a654169c 100644 --- a/SessionShareExtension/ShareAppExtensionContext.swift +++ b/SessionShareExtension/ShareAppExtensionContext.swift @@ -186,10 +186,6 @@ final class ShareAppExtensionContext: NSObject, AppContext { OWSLogger.debug("Ignoring request to block sleep.") } - func setMainAppBadgeNumber(_ value: Int) { - owsFailDebug("") - } - func setNetworkActivityIndicatorVisible(_ value: Bool) { owsFailDebug("") } diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 2278e4670..f42118e30 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -157,7 +157,7 @@ open class Storage { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { let error: Error = (startupError ?? StorageError.startupFailed) SNLog("[Database Error] Statup failed with error: \(error)") - onComplete(.failure(StorageError.startupFailed), false) + onComplete(.failure(error), false) return } @@ -430,12 +430,16 @@ open class Storage { try? deleteDbKeys() } + public static func reconfigureDatabase() { + Storage.shared.configureDatabase() + } + public static func resetForCleanMigration() { // Clear existing content resetAllStorage() // Reconfigure - Storage.shared.configureDatabase() + reconfigureDatabase() } private static func deleteDatabaseFiles() { diff --git a/SessionUtilitiesKit/General/AppContext.h b/SessionUtilitiesKit/General/AppContext.h index dff051bd0..331a78380 100755 --- a/SessionUtilitiesKit/General/AppContext.h +++ b/SessionUtilitiesKit/General/AppContext.h @@ -73,9 +73,6 @@ NSString *NSStringForUIApplicationState(UIApplicationState value); // Should be a NOOP if isMainApp is NO. - (void)ensureSleepBlocking:(BOOL)shouldBeBlocking blockingObjects:(NSArray *)blockingObjects; -// Should only be called if isMainApp is YES. -- (void)setMainAppBadgeNumber:(NSInteger)value; - - (void)setStatusBarHidden:(BOOL)isHidden animated:(BOOL)isAnimated; @property (nonatomic, readonly) CGFloat statusBarHeight; diff --git a/SessionUtilitiesKit/General/SNUserDefaults.swift b/SessionUtilitiesKit/General/SNUserDefaults.swift index a1829aab0..a280078c2 100644 --- a/SessionUtilitiesKit/General/SNUserDefaults.swift +++ b/SessionUtilitiesKit/General/SNUserDefaults.swift @@ -42,6 +42,7 @@ public enum SNUserDefaults { case lastOpen case lastGarbageCollection case lastPushNotificationSync + case lastCallPreOffer } public enum Double: Swift.String { diff --git a/SessionUtilitiesKit/Utilities/Bencode.swift b/SessionUtilitiesKit/Utilities/Bencode.swift index 1138208cc..cf9375b50 100644 --- a/SessionUtilitiesKit/Utilities/Bencode.swift +++ b/SessionUtilitiesKit/Utilities/Bencode.swift @@ -2,13 +2,6 @@ import Foundation -public protocol BencodableType { - associatedtype ValueType: BencodableType - - static var isCollection: Bool { get } - static var isDictionary: Bool { get } -} - public struct BencodeResponse { public let info: T public let data: Data? @@ -58,23 +51,61 @@ public enum Bencode { using dependencies: Dependencies = Dependencies() ) throws -> BencodeResponse where T: Decodable { guard - let result: [Data] = try? decode([Data].self, from: data), - let responseData: Data = result.first + let decodedData: (value: Any, remainingData: Data) = decodeData(data), + decodedData.remainingData.isEmpty == true, // Ensure there is no left over data + let resultArray: [Any] = decodedData.value as? [Any], + resultArray.count > 0 else { throw HTTPError.parsingFailed } return BencodeResponse( - info: try responseData.decoded(as: T.self, using: dependencies), - data: (result.count > 1 ? result.last : nil) + info: try Bencode.decode(T.self, decodedValue: resultArray[0], using: dependencies), + data: { + guard resultArray.count > 1 else { return nil } + + switch resultArray.last { + case let bencodeString as BencodeString: return bencodeString.rawValue + default: return resultArray.last as? Data + } + }() ) } - public static func decode(_ type: T.Type, from data: Data) throws -> T { + public static func decode( + _ type: T.Type, + from data: Data, + using dependencies: Dependencies = Dependencies() + ) throws -> T { guard let decodedData: (value: Any, remainingData: Data) = decodeData(data), decodedData.remainingData.isEmpty == true // Ensure there is no left over data else { throw HTTPError.parsingFailed } - return try recursiveCast(type, from: decodedData.value) + return try Bencode.decode(T.self, decodedValue: decodedData.value, using: dependencies) + } + + private static func decode( + _ type: T.Type, + decodedValue: Any, + using dependencies: Dependencies = Dependencies() + ) throws -> T { + switch (decodedValue, T.self) { + case (let directResult as T, _): return directResult + case + (let bencodeString as BencodeString, is String.Type), + (let bencodeString as BencodeString, is Optional.Type): + return try (bencodeString.value as? T ?? { throw HTTPError.parsingFailed }()) + + case (let bencodeString as BencodeString, _): + return try bencodeString.rawValue.decoded(as: T.self, using: dependencies) + + default: + guard + let jsonifiedInfo: Any = try? jsonify(decodedValue), + let infoData: Data = try? JSONSerialization.data(withJSONObject: jsonifiedInfo) + else { throw HTTPError.parsingFailed } + + return try infoData.decoded(as: T.self, using: dependencies) + } } // MARK: - Logic @@ -190,74 +221,12 @@ public enum Bencode { // MARK: - Internal Functions - private static func recursiveCast(_ type: T.Type, from value: Any) throws -> T { - switch (type.isCollection, type.isDictionary) { - case (_, true): - guard let dictValue: [String: Any] = value as? [String: Any] else { throw HTTPError.parsingFailed } - - return try ( - dictValue.mapValues { try recursiveCast(type.ValueType.self, from: $0) } as? T ?? - { throw HTTPError.parsingFailed }() - ) - - case (true, _): - guard let arrayValue: [Any] = value as? [Any] else { throw HTTPError.parsingFailed } - - return try ( - arrayValue.map { try recursiveCast(type.ValueType.self, from: $0) } as? T ?? - { throw HTTPError.parsingFailed }() - ) - - default: - switch (value, type) { - case (let bencodeString as BencodeString, is String.Type): - return try (bencodeString.value as? T ?? { throw HTTPError.parsingFailed }()) - - case (let bencodeString as BencodeString, is Optional.Type): - return try (bencodeString.value as? T ?? { throw HTTPError.parsingFailed }()) - - case (let bencodeString as BencodeString, _): - return try (bencodeString.rawValue as? T ?? { throw HTTPError.parsingFailed }()) - - default: return try (value as? T ?? { throw HTTPError.parsingFailed }()) - } + private static func jsonify(_ value: Any) throws -> Any { + switch value { + case let arrayValue as [Any]: return try arrayValue.map { try jsonify($0) } as Any + case let dictValue as [String: Any]: return try dictValue.mapValues { try jsonify($0) } as Any + case let bencodeString as BencodeString: return bencodeString.value as Any + default: return value } } } - -// MARK: - BencodableType Extensions - -extension Data: BencodableType { - public typealias ValueType = Data - - public static var isCollection: Bool { false } - public static var isDictionary: Bool { false } -} - -extension Int: BencodableType { - public typealias ValueType = Int - - public static var isCollection: Bool { false } - public static var isDictionary: Bool { false } -} - -extension String: BencodableType { - public typealias ValueType = String - - public static var isCollection: Bool { false } - public static var isDictionary: Bool { false } -} - -extension Array: BencodableType where Element: BencodableType { - public typealias ValueType = Element - - public static var isCollection: Bool { true } - public static var isDictionary: Bool { false } -} - -extension Dictionary: BencodableType where Key == String, Value: BencodableType { - public typealias ValueType = Value - - public static var isCollection: Bool { false } - public static var isDictionary: Bool { true } -} diff --git a/SessionUtilitiesKitTests/Utilities/BencodeSpec.swift b/SessionUtilitiesKitTests/Utilities/BencodeSpec.swift index 08f00df10..3ddbdc2a3 100644 --- a/SessionUtilitiesKitTests/Utilities/BencodeSpec.swift +++ b/SessionUtilitiesKitTests/Utilities/BencodeSpec.swift @@ -13,11 +13,37 @@ class BencodeSpec: QuickSpec { let stringValue: String } + struct TestType2: Codable, Equatable { + let stringValue: String + let boolValue: Bool + } + + struct TestType3: Codable, Equatable { + let stringValue: String + let boolValue: Bool + + init(_ stringValue: String, _ boolValue: Bool) { + self.stringValue = stringValue + self.boolValue = boolValue + } + + init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + self = TestType3( + try container.decode(String.self, forKey: .stringValue), + ((try? container.decode(Bool.self, forKey: .boolValue)) ?? false) + ) + } + } + // MARK: - Spec override func spec() { describe("Bencode") { + // MARK: - when decoding context("when decoding") { + // MARK: -- should decode a basic string it("should decode a basic string") { let basicStringData: Data = "5:howdy".data(using: .utf8)! let result = try? Bencode.decode(String.self, from: basicStringData) @@ -25,6 +51,7 @@ class BencodeSpec: QuickSpec { expect(result).to(equal("howdy")) } + // MARK: -- should decode a basic integer it("should decode a basic integer") { let basicIntegerData: Data = "i3e".data(using: .utf8)! let result = try? Bencode.decode(Int.self, from: basicIntegerData) @@ -32,6 +59,7 @@ class BencodeSpec: QuickSpec { expect(result).to(equal(3)) } + // MARK: -- should decode a list of integers it("should decode a list of integers") { let basicIntListData: Data = "li1ei2ee".data(using: .utf8)! let result = try? Bencode.decode([Int].self, from: basicIntListData) @@ -39,57 +67,246 @@ class BencodeSpec: QuickSpec { expect(result).to(equal([1, 2])) } + // MARK: -- should decode a basic dict it("should decode a basic dict") { let basicDictData: Data = "d4:spaml1:a1:bee".data(using: .utf8)! let result = try? Bencode.decode([String: [String]].self, from: basicDictData) expect(result).to(equal(["spam": ["a", "b"]])) } + + // MARK: -- decodes a decodable type + it("decodes a decodable type") { + let data: Data = "d8:intValuei100e11:stringValue4:Test".data(using: .utf8)! + let result: TestType? = try? Bencode.decode(TestType.self, from: data) + + expect(result).to(equal(TestType(intValue: 100, stringValue: "Test"))) + } + + // MARK: -- decodes a stringified decodable type + it("decodes a stringified decodable type") { + let data: Data = "37:{\"intValue\":100,\"stringValue\":\"Test\"}".data(using: .utf8)! + let result: TestType? = try? Bencode.decode(TestType.self, from: data) + + expect(result).to(equal(TestType(intValue: 100, stringValue: "Test"))) + } } + // MARK: - when decoding a response context("when decoding a response") { - it("decodes successfully") { - let data: Data = "l37:{\"intValue\":100,\"stringValue\":\"Test\"}5:\u{01}\u{02}\u{03}\u{04}\u{05}e" - .data(using: .utf8)! - let result: BencodeResponse? = try? Bencode.decodeResponse(from: data) + // MARK: -- with a decodable type + context("with a decodable type") { + // MARK: ---- decodes successfully + it("decodes successfully") { + let data: Data = "ld8:intValuei100e11:stringValue4:Teste5:\u{01}\u{02}\u{03}\u{04}\u{05}e" + .data(using: .utf8)! + let result: BencodeResponse? = try? Bencode.decodeResponse(from: data) + + expect(result) + .to(equal( + BencodeResponse( + info: TestType( + intValue: 100, + stringValue: "Test" + ), + data: Data([1, 2, 3, 4, 5]) + ) + )) + } - expect(result) - .to(equal( - BencodeResponse( - info: TestType( - intValue: 100, - stringValue: "Test" - ), - data: Data([1, 2, 3, 4, 5]) - ) - )) + // MARK: -- decodes successfully with no body + it("decodes successfully with no body") { + let data: Data = "ld8:intValuei100e11:stringValue4:Teste" + .data(using: .utf8)! + let result: BencodeResponse? = try? Bencode.decodeResponse(from: data) + + expect(result) + .to(equal( + BencodeResponse( + info: TestType( + intValue: 100, + stringValue: "Test" + ), + data: nil + ) + )) + } + + // MARK: ---- throws a parsing error when given an invalid length + it("throws a parsing error when given an invalid length") { + let data: Data = "ld12:intValuei100e11:stringValue4:Teste5:\u{01}\u{02}\u{03}\u{04}\u{05}e" + .data(using: .utf8)! + + expect { + let result: BencodeResponse = try Bencode.decodeResponse(from: data) + _ = result + }.to(throwError(HTTPError.parsingFailed)) + } + + // MARK: ---- throws a parsing error when given an invalid key + it("throws a parsing error when given an invalid key") { + let data: Data = "ld7:INVALIDi100e11:stringValue4:Teste5:\u{01}\u{02}\u{03}\u{04}\u{05}e" + .data(using: .utf8)! + + expect { + let result: BencodeResponse = try Bencode.decodeResponse(from: data) + _ = result + }.to(throwError(HTTPError.parsingFailed)) + } + + // MARK: ---- decodes correctly when trying to decode an int to a bool with custom handling + it("decodes correctly when trying to decode an int to a bool with custom handling") { + let data: Data = "ld9:boolValuei1e11:stringValue4:testee" + .data(using: .utf8)! + + expect { + let result: BencodeResponse = try Bencode.decodeResponse(from: data) + _ = result + }.toNot(throwError(HTTPError.parsingFailed)) + } + + // MARK: ---- throws a parsing error when trying to decode an int to a bool + it("throws a parsing error when trying to decode an int to a bool") { + let data: Data = "ld9:boolValuei1e11:stringValue4:testee" + .data(using: .utf8)! + + expect { + let result: BencodeResponse = try Bencode.decodeResponse(from: data) + _ = result + }.to(throwError(HTTPError.parsingFailed)) + } } - it("decodes successfully with no body") { - let data: Data = "l37:{\"intValue\":100,\"stringValue\":\"Test\"}e" - .data(using: .utf8)! - let result: BencodeResponse? = try? Bencode.decodeResponse(from: data) + // MARK: -- with stringified json info + context("with stringified json info") { + // MARK: -- decodes successfully + it("decodes successfully") { + let data: Data = "l37:{\"intValue\":100,\"stringValue\":\"Test\"}5:\u{01}\u{02}\u{03}\u{04}\u{05}e" + .data(using: .utf8)! + let result: BencodeResponse? = try? Bencode.decodeResponse(from: data) + + expect(result) + .to(equal( + BencodeResponse( + info: TestType( + intValue: 100, + stringValue: "Test" + ), + data: Data([1, 2, 3, 4, 5]) + ) + )) + } - expect(result) - .to(equal( - BencodeResponse( - info: TestType( - intValue: 100, - stringValue: "Test" - ), - data: nil - ) - )) + // MARK: -- decodes successfully with no body + it("decodes successfully with no body") { + let data: Data = "l37:{\"intValue\":100,\"stringValue\":\"Test\"}e" + .data(using: .utf8)! + let result: BencodeResponse? = try? Bencode.decodeResponse(from: data) + + expect(result) + .to(equal( + BencodeResponse( + info: TestType( + intValue: 100, + stringValue: "Test" + ), + data: nil + ) + )) + } + + // MARK: -- throws a parsing error when invalid + it("throws a parsing error when invalid") { + let data: Data = "l36:{\"INVALID\":100,\"stringValue\":\"Test\"}5:\u{01}\u{02}\u{03}\u{04}\u{05}e" + .data(using: .utf8)! + + expect { + let result: BencodeResponse = try Bencode.decodeResponse(from: data) + _ = result + }.to(throwError(HTTPError.parsingFailed)) + } } - it("throws a parsing error when invalid") { - let data: Data = "l36:{\"INVALID\":100,\"stringValue\":\"Test\"}5:\u{01}\u{02}\u{03}\u{04}\u{05}e" - .data(using: .utf8)! - - expect { - let result: BencodeResponse = try Bencode.decodeResponse(from: data) - _ = result - }.to(throwError(HTTPError.parsingFailed)) + // MARK: -- with a string value + context("with a string value") { + // MARK: ---- decodes successfully + it("decodes successfully") { + let data: Data = "l4:Test5:\u{01}\u{02}\u{03}\u{04}\u{05}e".data(using: .utf8)! + let result: BencodeResponse? = try? Bencode.decodeResponse(from: data) + + expect(result) + .to(equal( + BencodeResponse( + info: "Test", + data: Data([1, 2, 3, 4, 5]) + ) + )) + } + + // MARK: ---- decodes successfully with no body + it("decodes successfully with no body") { + let data: Data = "l4:Teste".data(using: .utf8)! + let result: BencodeResponse? = try? Bencode.decodeResponse(from: data) + + expect(result) + .to(equal( + BencodeResponse( + info: "Test", + data: nil + ) + )) + } + + // MARK: ---- throws a parsing error when invalid + it("throws a parsing error when invalid") { + let data: Data = "l10:Teste".data(using: .utf8)! + + expect { + let result: BencodeResponse = try Bencode.decodeResponse(from: data) + _ = result + }.to(throwError(HTTPError.parsingFailed)) + } + } + + // MARK: -- with an int value + context("with an int value") { + // MARK: ---- decodes successfully + it("decodes successfully") { + let data: Data = "li100e5:\u{01}\u{02}\u{03}\u{04}\u{05}e".data(using: .utf8)! + let result: BencodeResponse? = try? Bencode.decodeResponse(from: data) + + expect(result) + .to(equal( + BencodeResponse( + info: 100, + data: Data([1, 2, 3, 4, 5]) + ) + )) + } + + // MARK: ---- decodes successfully with no body + it("decodes successfully with no body") { + let data: Data = "li100ee".data(using: .utf8)! + let result: BencodeResponse? = try? Bencode.decodeResponse(from: data) + + expect(result) + .to(equal( + BencodeResponse( + info: 100, + data: nil + ) + )) + } + + // MARK: ---- throws a parsing error when invalid + it("throws a parsing error when invalid") { + let data: Data = "l4:Teste".data(using: .utf8)! + + expect { + let result: BencodeResponse = try Bencode.decodeResponse(from: data) + _ = result + }.to(throwError(HTTPError.parsingFailed)) + } } } } diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index 75b708cba..63d27a6f2 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -11,11 +11,34 @@ public enum AppSetup { private static let hasRun: Atomic = Atomic(false) public static func setupEnvironment( + retrySetupIfDatabaseInvalid: Bool = false, appSpecificBlock: @escaping () -> (), migrationProgressChanged: ((CGFloat, TimeInterval) -> ())? = nil, migrationsCompletion: @escaping (Result, Bool) -> () ) { - guard !AppSetup.hasRun.wrappedValue else { return } + // If we've already run the app setup then only continue under certain circumstances + guard !AppSetup.hasRun.wrappedValue else { + let storageIsValid: Bool = Storage.shared.isValid + + switch (retrySetupIfDatabaseInvalid, storageIsValid) { + case (true, false): + Storage.reconfigureDatabase() + AppSetup.hasRun.mutate { $0 = false } + AppSetup.setupEnvironment( + retrySetupIfDatabaseInvalid: false, // Don't want to get stuck in a loop + appSpecificBlock: appSpecificBlock, + migrationProgressChanged: migrationProgressChanged, + migrationsCompletion: migrationsCompletion + ) + + default: + migrationsCompletion( + (storageIsValid ? .success(()) : .failure(StorageError.startupFailed)), + false + ) + } + return + } AppSetup.hasRun.mutate { $0 = true } @@ -64,14 +87,6 @@ public enum AppSetup { migrationProgressChanged: ((CGFloat, TimeInterval) -> ())? = nil, migrationsCompletion: @escaping (Result, Bool) -> () ) { - // If the database can't be initialised into a valid state then error - guard Storage.shared.isValid else { - DispatchQueue.main.async { - migrationsCompletion(Result.failure(StorageError.databaseInvalid), false) - } - return - } - var backgroundTask: OWSBackgroundTask? = (backgroundTask ?? OWSBackgroundTask(labelStr: #function)) Storage.shared.perform(