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
This commit is contained in:
Morgan Pretty 2023-09-08 11:42:04 +10:00
parent ab610578e6
commit a2f1f36d2c
21 changed files with 606 additions and 358 deletions

View File

@ -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 = "<group>"; };
FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ARC4RandomNumberGenerator.swift; sourceTree = "<group>"; };
FD97B2412A3FEBF30027DD57 /* UnreadMarkerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnreadMarkerCell.swift; sourceTree = "<group>"; };
FD9AECA42AAA9609009B3406 /* NotificationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationError.swift; sourceTree = "<group>"; };
FD9B30F2293EA0BF008DEE3E /* BatchResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchResponseSpec.swift; sourceTree = "<group>"; };
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 = "<group>"; };
@ -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;

View File

@ -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()

View File

@ -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<SessionThread> = 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()

View File

@ -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
}

View File

@ -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()

View File

@ -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<SessionThread> = 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

View File

@ -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)
)
}
}

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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 = ""

View File

@ -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))"
}
}
}

View File

@ -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<Void, Error>] = 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)
}

View File

@ -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) { }
}

View File

@ -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("")
}

View File

@ -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() {

View File

@ -73,9 +73,6 @@ NSString *NSStringForUIApplicationState(UIApplicationState value);
// Should be a NOOP if isMainApp is NO.
- (void)ensureSleepBlocking:(BOOL)shouldBeBlocking blockingObjects:(NSArray<id> *)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;

View File

@ -42,6 +42,7 @@ public enum SNUserDefaults {
case lastOpen
case lastGarbageCollection
case lastPushNotificationSync
case lastCallPreOffer
}
public enum Double: Swift.String {

View File

@ -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<T: Codable> {
public let info: T
public let data: Data?
@ -58,23 +51,61 @@ public enum Bencode {
using dependencies: Dependencies = Dependencies()
) throws -> BencodeResponse<T> 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<T: BencodableType>(_ type: T.Type, from data: Data) throws -> T {
public static func decode<T: Decodable>(
_ 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<T: Decodable>(
_ 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<String>.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<T: BencodableType>(_ 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<String>.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 }
}

View File

@ -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<CodingKeys> = 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<TestType>? = 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<TestType>? = 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<TestType>? = 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<TestType> = 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<TestType> = 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<TestType3> = 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<TestType2> = 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<TestType>? = 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<TestType>? = 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<TestType>? = 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<TestType> = 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<TestType> = 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<String>? = 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<String>? = 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<String> = 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<Int>? = 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<Int>? = 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<Int> = try Bencode.decodeResponse(from: data)
_ = result
}.to(throwError(HTTPError.parsingFailed))
}
}
}
}

View File

@ -11,11 +11,34 @@ public enum AppSetup {
private static let hasRun: Atomic<Bool> = Atomic(false)
public static func setupEnvironment(
retrySetupIfDatabaseInvalid: Bool = false,
appSpecificBlock: @escaping () -> (),
migrationProgressChanged: ((CGFloat, TimeInterval) -> ())? = nil,
migrationsCompletion: @escaping (Result<Void, Error>, 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<Void, Error>, 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(