diff --git a/.drone.jsonnet b/.drone.jsonnet index 99cfbe705..99934153f 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -227,7 +227,7 @@ local sim_delete_cmd = 'if [ -f build/artifacts/sim_uuid ]; then rm -f /Users/$U name: 'Build', commands: [ 'mkdir build', - 'NSUnbufferedIO=YES set -o pipefail && xcodebuild archive -workspace Session.xcworkspace -scheme Session -derivedDataPath ./build/derivedData -parallelizeTargets -configuration "App Store Release" -sdk iphonesimulator -archivePath ./build/Session_sim.xcarchive -destination "generic/platform=iOS Simulator" | xcbeautify --is-ci', + 'NSUnbufferedIO=YES set -o pipefail && xcodebuild archive -workspace Session.xcworkspace -scheme Session -derivedDataPath ./build/derivedData -parallelizeTargets -configuration "App_Store_Release" -sdk iphonesimulator -archivePath ./build/Session_sim.xcarchive -destination "generic/platform=iOS Simulator" | xcbeautify --is-ci', ], depends_on: [ 'Install CocoaPods', diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 8536b6560..75cc072d5 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -8034,7 +8034,7 @@ CLANG_WARN__ARC_BRIDGE_CAST_NONARC = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 449; + CURRENT_PROJECT_VERSION = 450; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8071,7 +8071,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; HEADER_SEARCH_PATHS = ""; IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 2.6.1; + MARKETING_VERSION = 2.6.2; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "-fobjc-arc-exceptions", @@ -8112,7 +8112,7 @@ CLANG_WARN__ARC_BRIDGE_CAST_NONARC = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 449; + CURRENT_PROJECT_VERSION = 450; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; @@ -8144,7 +8144,7 @@ GCC_WARN_UNUSED_VARIABLE = YES; HEADER_SEARCH_PATHS = ""; IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 2.6.1; + MARKETING_VERSION = 2.6.2; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 443568155..70fcf5bbb 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -298,7 +298,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD guard BackgroundPoller.isValid else { return } - Log.info("Background poll failed due to manual timeout") + Log.info("Background poll failed due to manual timeout.") BackgroundPoller.isValid = false if Singleton.hasAppContext && Singleton.appContext.isInBackground { diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index 782650752..9f32adc3c 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -15,46 +15,50 @@ public final class BackgroundPoller { completionHandler: @escaping (UIBackgroundFetchResult) -> Void, using dependencies: Dependencies = Dependencies() ) { + let (groupIds, servers): (Set, Set) = Storage.shared.read { db in + ( + try ClosedGroup + .select(.threadId) + .joining( + required: ClosedGroup.members + .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db)) + ) + .asRequest(of: String.self) + .fetchSet(db), + /// The default room promise creates an OpenGroup with an empty `roomToken` value, we + /// don't want to start a poller for this as the user hasn't actually joined a room + /// + /// We also want to exclude any rooms which have failed to poll too many times in a row from + /// the background poll as they are likely to fail again + try OpenGroup + .select(.server) + .filter( + OpenGroup.Columns.roomToken != "" && + OpenGroup.Columns.isActive && + OpenGroup.Columns.pollFailureCount < OpenGroupAPI.Poller.maxRoomFailureCountForBackgroundPoll + ) + .distinct() + .asRequest(of: String.self) + .fetchSet(db) + ) + } + .defaulting(to: ([], [])) + + Log.info("[BackgroundPoller] Fetching 1 User, \(groupIds.count) \("group", number: groupIds.count), \(servers.count) \("communit", number: servers.count, singular: "y", plural: "ies").") Publishers .MergeMany( [pollForMessages(using: dependencies)] - .appending(contentsOf: pollForClosedGroupMessages(using: dependencies)) - .appending( - contentsOf: Storage.shared - .read { db in - /// The default room promise creates an OpenGroup with an empty `roomToken` value, we - /// don't want to start a poller for this as the user hasn't actually joined a room - /// - /// We also want to exclude any rooms which have failed to poll too many times in a row from - /// the background poll as they are likely to fail again - try OpenGroup - .select(.server) - .filter( - OpenGroup.Columns.roomToken != "" && - OpenGroup.Columns.isActive && - OpenGroup.Columns.pollFailureCount < OpenGroupAPI.Poller.maxRoomFailureCountForBackgroundPoll - ) - .distinct() - .asRequest(of: String.self) - .fetchSet(db) - } - .defaulting(to: []) - .map { server -> AnyPublisher in - let poller: OpenGroupAPI.Poller = OpenGroupAPI.Poller(for: server) - poller.stop() - - return poller.poll( - calledFromBackgroundPoller: true, - isBackgroundPollerValid: { BackgroundPoller.isValid }, - isPostCapabilitiesRetry: false, - using: dependencies - ) - } - ) + .appending(contentsOf: pollForClosedGroupMessages(groupIds: groupIds, using: dependencies)) + .appending(contentsOf: pollForCommunityMessages(servers: servers, using: dependencies)) ) .subscribe(on: DispatchQueue.global(qos: .background), using: dependencies) .receive(on: DispatchQueue.main, using: dependencies) .collect() + .handleEvents( + receiveOutput: { _ in + Log.info("[BackgroundPoller] Finished polling.") + } + ) .sinkUntilComplete( receiveCompletion: { result in // If we have already invalidated the timer then do nothing (we essentially timed out) @@ -63,7 +67,7 @@ public final class BackgroundPoller { switch result { case .finished: completionHandler(.newData) case .failure(let error): - SNLog("Background poll failed due to error: \(error)") + Log.error("[BackgroundPoller] Failed due to error: \(error).") completionHandler(.failed) } } @@ -83,39 +87,55 @@ public final class BackgroundPoller { drainBehaviour: .alwaysRandom, using: dependencies ) + .handleEvents( + receiveOutput: { _, _, validMessageCount, _ in + Log.info("[BackgroundPoller] Received \(validMessageCount) valid \("message", number: validMessageCount).") + } + ) .map { _ in () } .eraseToAnyPublisher() } private static func pollForClosedGroupMessages( + groupIds: Set, using dependencies: Dependencies ) -> [AnyPublisher] { // Fetch all closed groups (excluding any don't contain the current user as a // GroupMemeber as the user is no longer a member of those) - return Storage.shared - .read { db in - try ClosedGroup - .select(.threadId) - .joining( - required: ClosedGroup.members - .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db)) - ) - .asRequest(of: String.self) - .fetchAll(db) - } - .defaulting(to: []) - .map { groupPublicKey in - return ClosedGroupPoller() - .poll( - namespaces: ClosedGroupPoller.namespaces, - for: groupPublicKey, - calledFromBackgroundPoller: true, - isBackgroundPollValid: { BackgroundPoller.isValid }, - drainBehaviour: .alwaysRandom, - using: dependencies - ) - .map { _ in () } - .eraseToAnyPublisher() - } + return groupIds.map { groupPublicKey in + return ClosedGroupPoller() + .poll( + namespaces: ClosedGroupPoller.namespaces, + for: groupPublicKey, + calledFromBackgroundPoller: true, + isBackgroundPollValid: { BackgroundPoller.isValid }, + drainBehaviour: .alwaysRandom, + using: dependencies + ) + .handleEvents( + receiveOutput: { _, _, validMessageCount, _ in + Log.info("[BackgroundPoller] Received \(validMessageCount) valid \("message", number: validMessageCount) for group: \(groupPublicKey).") + } + ) + .map { _ in () } + .eraseToAnyPublisher() + } + } + + private static func pollForCommunityMessages( + servers: Set, + using dependencies: Dependencies + ) -> [AnyPublisher] { + return servers.map { server -> AnyPublisher in + let poller: OpenGroupAPI.Poller = OpenGroupAPI.Poller(for: server) + poller.stop() + + return poller.poll( + calledFromBackgroundPoller: true, + isBackgroundPollerValid: { BackgroundPoller.isValid }, + isPostCapabilitiesRetry: false, + using: dependencies + ) + } } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index d3269bf7d..db480d14b 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -49,7 +49,7 @@ public final class ClosedGroupPoller: Poller { // MARK: - Abstract Methods override func pollerName(for publicKey: String) -> String { - return "closed group with public key: \(publicKey)" + return "Closed group poller with public key: \(publicKey)" } override func nextPollDelay(for publicKey: String, using dependencies: Dependencies) -> TimeInterval { @@ -80,8 +80,7 @@ public final class ClosedGroupPoller: Poller { return nextPollInterval } - override func handlePollError(_ error: Error, for publicKey: String, using dependencies: Dependencies) -> Bool { - SNLog("Polling failed for closed group with public key: \(publicKey) due to error: \(error).") - return true + override func handlePollError(_ error: Error, for publicKey: String, using dependencies: Dependencies) -> PollerErrorResponse { + return .continuePolling } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift index 827d29d2f..2e19c3076 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift @@ -63,7 +63,7 @@ public final class CurrentUserPoller: Poller { return min(maxRetryInterval, nextDelay) } - override func handlePollError(_ error: Error, for publicKey: String, using dependencies: Dependencies) -> Bool { + override func handlePollError(_ error: Error, for publicKey: String, using dependencies: Dependencies) -> PollerErrorResponse { if UserDefaults.sharedLokiProject?[.isMainAppActive] != true { // Do nothing when an error gets throws right after returning from the background (happens frequently) } @@ -71,13 +71,13 @@ public final class CurrentUserPoller: Poller { let drainBehaviour: Atomic = drainBehaviour.wrappedValue[publicKey], case .limitedReuse(_, .some(let targetSnode), _, _, _) = drainBehaviour.wrappedValue { - SNLog("Main Poller polling \(targetSnode) failed with error: \(period: "\(error)"); switching to next snode.") drainBehaviour.mutate { $0 = $0.clearTargetSnode() } + return .continuePollingInfo("Switching from \(targetSnode) to next snode.") } else { - SNLog("Polling failed due to having no target service node.") + return .continuePollingInfo("Had no target snode.") } - return true + return .continuePolling } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index ce25ea0ce..92495786a 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -17,6 +17,12 @@ public class Poller { hadValidHashUpdate: Bool ) + internal enum PollerErrorResponse { + case stopPolling + case continuePolling + case continuePollingInfo(String) + } + private var cancellables: Atomic<[String: AnyCancellable]> = Atomic([:]) internal var isPolling: Atomic<[String: Bool]> = Atomic([:]) internal var pollCount: Atomic<[String: Int]> = Atomic([:]) @@ -72,7 +78,7 @@ public class Poller { } /// Perform and logic which should occur when the poll errors, will stop polling if `false` is returned - internal func handlePollError(_ error: Error, for publicKey: String, using dependencies: Dependencies) -> Bool { + internal func handlePollError(_ error: Error, for publicKey: String, using dependencies: Dependencies) -> PollerErrorResponse { preconditionFailure("abstract class - override in subclass") } @@ -125,18 +131,24 @@ public class Poller { .sink( receiveCompletion: { _ in }, // Never called receiveValue: { result in + // If the polling has been cancelled then don't continue + guard self?.isPolling.wrappedValue[swarmPublicKey] == true else { return } + let endTime: TimeInterval = dependencies.dateNow.timeIntervalSince1970 // Log information about the poll switch result { case .failure(let error): // Determine if the error should stop us from polling anymore - guard self?.handlePollError(error, for: swarmPublicKey, using: dependencies) == true else { - return + switch self?.handlePollError(error, for: swarmPublicKey, using: dependencies) { + case .stopPolling: return + case .continuePollingInfo(let info): + Log.error("\(pollerName) failed to process any messages due to error: \(error). \(info)") + + case .continuePolling, .none: + Log.error("\(pollerName) failed to process any messages due to error: \(error).") } - Log.error("\(pollerName) failed to process any messages due to error: \(error)") - case .success(let response): let duration: TimeUnit = .seconds(endTime - lastPollStart) diff --git a/SessionNotificationServiceExtension/NotificationError.swift b/SessionNotificationServiceExtension/NotificationError.swift index abf243ecf..355c5563c 100644 --- a/SessionNotificationServiceExtension/NotificationError.swift +++ b/SessionNotificationServiceExtension/NotificationError.swift @@ -8,6 +8,7 @@ import SessionMessagingKit enum NotificationError: Error, CustomStringConvertible { case processing(PushNotificationAPI.ProcessResult) case messageProcessing + case ignorableMessage case messageHandling(MessageReceiverError) case other(Error) @@ -15,6 +16,7 @@ enum NotificationError: Error, CustomStringConvertible { switch self { case .processing(let result): return "Failed to process notification (\(result)) (NotificationError.processing)." case .messageProcessing: return "Failed to process message (NotificationError.messageProcessing)." + case .ignorableMessage: return "Ignorable message (NotificationError.ignorableMessage)." case .messageHandling(let error): return "Failed to handle message (\(error)) (NotificationError.messageHandling)." case .other(let error): return "Unknown error occurred: \(error) (NotificationError.other)." } diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 5b321b48b..55fa3cf8b 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -15,7 +15,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension private var didPerformSetup = false private var contentHandler: ((UNNotificationContent) -> Void)? private var request: UNNotificationRequest? - private var openGroupPollCancellable: AnyCancellable? private var hasCompleted: Atomic = Atomic(false) public static let isFromRemoteKey = "remote" // stringlint:disable @@ -27,19 +26,22 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // MARK: Did receive a remote push notification request override public func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - Log.info("didReceive called.") self.contentHandler = contentHandler self.request = request guard let notificationContent = request.content.mutableCopy() as? UNMutableNotificationContent else { + Log.info("didReceive called with no content.") return self.completeSilenty() } // Abort if the main app is running guard !(UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { + Log.info("didReceive called while main app running.") return self.completeSilenty() } + Log.info("didReceive called.") + /// Create the context if we don't have it (needed before _any_ interaction with the database) if !Singleton.hasAppContext { Singleton.setup(appContext: NotificationServiceExtensionContext()) @@ -50,23 +52,10 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // Perform main setup Storage.resumeDatabaseAccess() - LibSession.resumeNetworkAccess() DispatchQueue.main.sync { self.setUpIfNecessary() { } } // Handle the push notification Singleton.appReadiness.runNowOrWhenAppDidBecomeReady { - let openGroupPollingPublishers: [AnyPublisher] = self.pollForOpenGroups() - defer { - self.openGroupPollCancellable = Publishers - .MergeMany(openGroupPollingPublishers) - .subscribe(on: DispatchQueue.global(qos: .background)) - .subscribe(on: DispatchQueue.main) - .sink( - receiveCompletion: { [weak self] _ in self?.completeSilenty() }, - receiveValue: { _ in } - ) - } - let (maybeData, metadata, result) = PushNotificationAPI.processNotification( notificationContent: notificationContent ) @@ -84,16 +73,17 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // Just log if the notification was too long (a ~2k message should be able to fit so // these will most commonly be call or config messages) case .successTooLong: - return Log.info("Received too long notification for namespace: \(metadata.namespace).") + Log.info("Received too long notification for namespace: \(metadata.namespace).") + return self.completeSilenty() - case .legacyForceSilent, .failureNoContent: return + case .legacyForceSilent, .failureNoContent: return self.completeSilenty() } } // HACK: It is important to use write synchronously here to avoid a race condition // where the completeSilenty() is called before the local notification request // is added to notification center - Storage.shared.write { db in + Storage.shared.write { [weak self] db in do { guard let processedMessage: ProcessedMessage = try Message.processRawReceivedMessageAsNotification(db, data: data, metadata: metadata) else { throw NotificationError.messageProcessing @@ -119,7 +109,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension /// extension, for all other message types we want to just use the standard `MessageReceiver.handle` call case .standard(let threadId, let threadVariant, _, let messageInfo) where messageInfo.message is CallMessage: guard let callMessage = messageInfo.message as? CallMessage else { - return self.completeSilenty() + throw NotificationError.ignorableMessage } // Throw if the message is outdated and shouldn't be processed @@ -138,7 +128,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension ) guard case .preOffer = callMessage.kind else { - return self.completeSilenty() + throw NotificationError.ignorableMessage } switch (db[.areCallsEnabled], isCallOngoing) { @@ -179,7 +169,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension case (true, false): try MessageReceiver.insertCallInfoMessage(db, for: callMessage) - self.handleSuccessForIncomingCall(db, for: callMessage) + self?.handleSuccessForIncomingCall(db, for: callMessage) } // Perform any required post-handling logic @@ -200,6 +190,11 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension associatedWithProto: proto ) } + + db.afterNextTransaction( + onCommit: { _ in self?.completeSilenty() }, + onRollback: { _ in self?.completeSilenty() } + ) } catch { // If an error occurred we want to rollback the transaction (by throwing) and then handle @@ -207,16 +202,16 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension let handleError = { switch error { case MessageReceiverError.invalidGroupPublicKey, MessageReceiverError.noGroupKeyPair, - MessageReceiverError.outdatedMessage: - self.completeSilenty() + MessageReceiverError.outdatedMessage, NotificationError.ignorableMessage: + self?.completeSilenty() case NotificationError.messageProcessing: - self.handleFailure(for: notificationContent, error: .messageProcessing) + self?.handleFailure(for: notificationContent, error: .messageProcessing) case let msgError as MessageReceiverError: - self.handleFailure(for: notificationContent, error: .messageHandling(msgError)) + self?.handleFailure(for: notificationContent, error: .messageHandling(msgError)) - default: self.handleFailure(for: notificationContent, error: .other(error)) + default: self?.handleFailure(for: notificationContent, error: .other(error)) } } @@ -330,12 +325,11 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // 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. Log.warn("Execution time expired.") - openGroupPollCancellable?.cancel() completeSilenty() } private func completeSilenty() { - // Ensure we on'y run this once + // Ensure we only run this once guard hasCompleted.mutate({ hasCompleted in let wasCompleted: Bool = hasCompleted @@ -350,7 +344,6 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension .map { NSNumber(value: $0) } .defaulting(to: NSNumber(value: 0)) Log.info("Complete silently.") - LibSession.suspendNetworkAccess() Storage.suspendDatabaseAccess() Log.flush() @@ -412,12 +405,11 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension } semaphore.wait() Log.info("Add remote notification request.") - Log.flush() + completeSilenty() } private func handleFailure(for content: UNMutableNotificationContent, error: NotificationError) { Log.error("Show generic failure message due to error: \(error).") - LibSession.suspendNetworkAccess() Storage.suspendDatabaseAccess() Log.flush() @@ -427,36 +419,4 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension content.userInfo = userInfo contentHandler!(content) } - - // MARK: - Poll for open groups - - private func pollForOpenGroups() -> [AnyPublisher] { - return Storage.shared - .read { db in - // The default room promise creates an OpenGroup with an empty `roomToken` value, - // we don't want to start a poller for this as the user hasn't actually joined a room - try OpenGroup - .select(.server) - .filter(OpenGroup.Columns.roomToken != "") - .filter(OpenGroup.Columns.isActive) - .distinct() - .asRequest(of: String.self) - .fetchSet(db) - } - .defaulting(to: []) - .map { server -> AnyPublisher in - OpenGroupAPI.Poller(for: server) - .poll(calledFromBackgroundPoller: true, isPostCapabilitiesRetry: false) - .timeout( - .seconds(20), - scheduler: DispatchQueue.global(qos: .default), - customError: { NotificationServiceError.timeout } - ) - .eraseToAnyPublisher() - } - } - - private enum NotificationServiceError: Error { - case timeout - } } diff --git a/SessionSnodeKit/LibSession/LibSession+Networking.swift b/SessionSnodeKit/LibSession/LibSession+Networking.swift index d755536c5..472c93870 100644 --- a/SessionSnodeKit/LibSession/LibSession+Networking.swift +++ b/SessionSnodeKit/LibSession/LibSession+Networking.swift @@ -550,6 +550,7 @@ public extension LibSession { throw SnodeAPIError.nodeNotFound(String(responseString.suffix(64))) + case (504, _): throw NetworkError.gatewayTimeout case (_, .none): throw NetworkError.unknown case (_, .some(let responseString)): throw NetworkError.requestFailed(error: responseString, rawData: data) } diff --git a/SessionUtilitiesKit/General/String+Utilities.swift b/SessionUtilitiesKit/General/String+Utilities.swift index 92c7dc74f..fe7d66cc2 100644 --- a/SessionUtilitiesKit/General/String+Utilities.swift +++ b/SessionUtilitiesKit/General/String+Utilities.swift @@ -82,8 +82,8 @@ public extension String.StringInterpolation { appendInterpolation(value == 1 ? "" : "s") // stringlint:disable } - public mutating func appendInterpolation(period value: String) { - appendInterpolation(value.hasSuffix(".") ? "" : ".") // stringlint:disable + mutating func appendInterpolation(_ value: String, number: Int, singular: String = "", plural: String = "s") { + appendInterpolation("\(value)\(number == 1 ? singular : plural)") // stringlint:disable } mutating func appendInterpolation(_ value: TimeUnit, unit: TimeUnit.Unit, resolution: Int = 2) { diff --git a/SessionUtilitiesKit/Networking/NetworkError.swift b/SessionUtilitiesKit/Networking/NetworkError.swift index 761ff3ec6..05ed29319 100644 --- a/SessionUtilitiesKit/Networking/NetworkError.swift +++ b/SessionUtilitiesKit/Networking/NetworkError.swift @@ -15,6 +15,7 @@ public enum NetworkError: Error, Equatable, CustomStringConvertible { case internalServerError case badGateway case serviceUnavailable + case gatewayTimeout case badRequest(error: String, rawData: Data?) case requestFailed(error: String, rawData: Data?) case timeout @@ -33,6 +34,7 @@ public enum NetworkError: Error, Equatable, CustomStringConvertible { case .internalServerError: return "Internal server error (NetworkError.internalServerError)." case .badGateway: return "Bad gateway (NetworkError.badGateway)." case .serviceUnavailable: return "Service unavailable (NetworkError.serviceUnavailable)." + case .gatewayTimeout: return "Gateway timeout (NetworkError.gatewayTimeout)." case .badRequest(let error, _), .requestFailed(let error, _): return error case .timeout: return "The request timed out (NetworkError.timeout)." case .suspended: return "Network requests are suspended (NetworkError.suspended)."