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(