diff --git a/LibSession-Util b/LibSession-Util index 9935bbe01..287f117fc 160000 --- a/LibSession-Util +++ b/LibSession-Util @@ -1 +1 @@ -Subproject commit 9935bbe0137423f39e3a2292268f180a043db94d +Subproject commit 287f117fc942cfcc820eaac87c523e0b364d0316 diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index b61697e0c..292d1f41e 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -7927,7 +7927,7 @@ CLANG_WARN__ARC_BRIDGE_CAST_NONARC = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 537; + CURRENT_PROJECT_VERSION = 538; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8003,7 +8003,7 @@ CLANG_WARN__ARC_BRIDGE_CAST_NONARC = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 537; + CURRENT_PROJECT_VERSION = 538; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 5064692ad..df1e4c851 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -714,24 +714,14 @@ extension ConversationVC: let newText: String = (inputTextView.text ?? "") if !newText.isEmpty { - let threadId: String = self.viewModel.threadData.threadId - let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant - let threadIsMessageRequest: Bool = (self.viewModel.threadData.threadIsMessageRequest == true) - let threadIsBlocked: Bool = (self.viewModel.threadData.threadIsBlocked == true) - let needsToStartTypingIndicator: Bool = viewModel.dependencies[singleton: .typingIndicators].didStartTypingNeedsToStart( - threadId: threadId, - threadVariant: threadVariant, - threadIsBlocked: threadIsBlocked, - threadIsMessageRequest: threadIsMessageRequest, + viewModel.dependencies[singleton: .typingIndicators].startIfNeeded( + threadId: viewModel.threadData.threadId, + threadVariant: viewModel.threadData.threadVariant, + threadIsBlocked: (viewModel.threadData.threadIsBlocked == true), + threadIsMessageRequest: (viewModel.threadData.threadIsMessageRequest == true), direction: .outgoing, timestampMs: viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() ) - - if needsToStartTypingIndicator { - viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in - dependencies[singleton: .typingIndicators].start(db, threadId: threadId, direction: .outgoing) - } - } } updateMentions(for: newText) @@ -2086,13 +2076,17 @@ extension ConversationVC: switch result { case .finished: modal.dismiss(animated: true) { - self?.viewModel.showToast( - text: "deleteMessageDeleted" - .putNumber(messagesToDelete.count) - .localized(), - backgroundColor: .backgroundSecondary, - inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing - ) + /// Dispatch after a delay because becoming the first responder can cause + /// an odd appearance animation + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(150)) { + self?.viewModel.showToast( + text: "deleteMessageDeleted" + .putNumber(messagesToDelete.count) + .localized(), + backgroundColor: .backgroundSecondary, + inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing + ) + } } case .failure: diff --git a/Session/Settings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettingsViewModel.swift index d31b5ab95..de84ef3d3 100644 --- a/Session/Settings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettingsViewModel.swift @@ -90,6 +90,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case updatedGroupsDeleteBeforeNow case updatedGroupsDeleteAttachmentsBeforeNow + case forceSlowDatabaseQueries case exportDatabase case importDatabase @@ -127,6 +128,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .updatedGroupsDeleteBeforeNow: return "updatedGroupsDeleteBeforeNow" case .updatedGroupsDeleteAttachmentsBeforeNow: return "updatedGroupsDeleteAttachmentsBeforeNow" + case .forceSlowDatabaseQueries: return "forceSlowDatabaseQueries" case .exportDatabase: return "exportDatabase" case .importDatabase: return "importDatabase" } @@ -168,6 +170,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .updatedGroupsDeleteBeforeNow: result.append(.updatedGroupsDeleteBeforeNow); fallthrough case .updatedGroupsDeleteAttachmentsBeforeNow: result.append(.updatedGroupsDeleteAttachmentsBeforeNow); fallthrough + case .forceSlowDatabaseQueries: result.append(.forceSlowDatabaseQueries); fallthrough case .exportDatabase: result.append(.exportDatabase); fallthrough case .importDatabase: result.append(.importDatabase) } @@ -206,6 +209,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, let updatedGroupsAllowInviteById: Bool let updatedGroupsDeleteBeforeNow: Bool let updatedGroupsDeleteAttachmentsBeforeNow: Bool + + let forceSlowDatabaseQueries: Bool } let title: String = "Developer Settings" @@ -238,7 +243,9 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, updatedGroupsAllowPromotions: dependencies[feature: .updatedGroupsAllowPromotions], updatedGroupsAllowInviteById: dependencies[feature: .updatedGroupsAllowInviteById], updatedGroupsDeleteBeforeNow: dependencies[feature: .updatedGroupsDeleteBeforeNow], - updatedGroupsDeleteAttachmentsBeforeNow: dependencies[feature: .updatedGroupsDeleteAttachmentsBeforeNow] + updatedGroupsDeleteAttachmentsBeforeNow: dependencies[feature: .updatedGroupsDeleteAttachmentsBeforeNow], + + forceSlowDatabaseQueries: dependencies[feature: .forceSlowDatabaseQueries] ) } .compactMapWithPrevious { [weak self] prev, current -> [SectionModel]? in self?.content(prev, current) } @@ -716,6 +723,25 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, let database: SectionModel = SectionModel( model: .database, elements: [ + SessionCell.Info( + id: .forceSlowDatabaseQueries, + title: "Force slow database queries", + subtitle: """ + Controls whether we artificially add an initial 1s delay to all database queries. + + Note: This is generally not desired (as it'll make things run slowly) but can be beneficial for testing to track down database queries which are running on the main thread when they shouldn't be. + """, + trailingAccessory: .toggle( + current.forceSlowDatabaseQueries, + oldValue: previous?.forceSlowDatabaseQueries + ), + onTap: { [weak self] in + self?.updateFlag( + for: .forceSlowDatabaseQueries, + to: !current.forceSlowDatabaseQueries + ) + } + ), SessionCell.Info( id: .exportDatabase, title: "Export App Data", @@ -796,6 +822,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .updatedGroupsAllowInviteById: updateFlag(for: .updatedGroupsAllowInviteById, to: nil) case .updatedGroupsDeleteBeforeNow: updateFlag(for: .updatedGroupsDeleteBeforeNow, to: nil) case .updatedGroupsDeleteAttachmentsBeforeNow: updateFlag(for: .updatedGroupsDeleteAttachmentsBeforeNow, to: nil) + + case .forceSlowDatabaseQueries: updateFlag(for: .forceSlowDatabaseQueries, to: nil) } } diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 924cdf50d..b6e2eb827 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -1435,32 +1435,42 @@ public extension Interaction { ) } - /// Mark the messages as deleted (ie. remove as much message data as we can) - try interactionInfo.grouped(by: { $0.variant }).forEach { variant, info in - let targetVariant: Interaction.Variant = { - switch (variant, localOnly) { - case (.standardOutgoing, true), (.standardOutgoingDeletedLocally, true): - return .standardOutgoingDeletedLocally - case (.standardOutgoing, false), (.standardOutgoingDeletedLocally, false), (.standardOutgoingDeleted, _): - return .standardOutgoingDeleted - case (.standardIncoming, true), (.standardIncomingDeletedLocally, true): - return .standardIncomingDeletedLocally - default: return .standardIncomingDeleted - } - }() - - try Interaction - .filter(ids: info.map { $0.id }) - .updateAll( - db, - Interaction.Columns.variant.set(to: targetVariant), - Interaction.Columns.body.set(to: nil), - Interaction.Columns.wasRead.set(to: true), - Interaction.Columns.hasMention.set(to: false), - Interaction.Columns.linkPreviewUrl.set(to: nil), - Interaction.Columns.state.set(to: Interaction.State.deleted) - ) - } + /// Delete info messages entirely (can't really mark them as deleted since they don't have a sender + let infoMessageIds: Set = interactionInfo + .filter { $0.variant.isInfoMessage } + .compactMap { $0.id } + .asSet() + _ = try Interaction.deleteAll(db, ids: infoMessageIds) + + /// Mark non-info messages as deleted (ie. remove as much message data as we can) + try interactionInfo + .filter { !$0.variant.isInfoMessage } + .grouped(by: { $0.variant }) + .forEach { variant, info in + let targetVariant: Interaction.Variant = { + switch (variant, localOnly) { + case (.standardOutgoing, true), (.standardOutgoingDeletedLocally, true): + return .standardOutgoingDeletedLocally + case (.standardOutgoing, false), (.standardOutgoingDeletedLocally, false), (.standardOutgoingDeleted, _): + return .standardOutgoingDeleted + case (.standardIncoming, true), (.standardIncomingDeletedLocally, true): + return .standardIncomingDeletedLocally + default: return .standardIncomingDeleted + } + }() + + try Interaction + .filter(ids: info.map { $0.id }) + .updateAll( + db, + Interaction.Columns.variant.set(to: targetVariant), + Interaction.Columns.body.set(to: nil), + Interaction.Columns.wasRead.set(to: true), + Interaction.Columns.hasMention.set(to: false), + Interaction.Columns.linkPreviewUrl.set(to: nil), + Interaction.Columns.state.set(to: Interaction.State.deleted) + ) + } /// If we had attachments then we want to try to delete their associated files immediately (in the next run loop) as that's the /// behaviour users would expect, if this fails for some reason then they will be cleaned up by the `GarbageCollectionJob` diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift index 7abf72fd2..c7e9ec035 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift @@ -406,6 +406,70 @@ public extension LibSession { } public extension LibSessionCacheType { + func conversationLastRead( + threadId: String, + threadVariant: SessionThread.Variant, + openGroup: OpenGroup? + ) -> Int64? { + // If we don't have a config then just assume it's unread + guard case .convoInfoVolatile(let conf) = config(for: .convoInfoVolatile, sessionId: userSessionId) else { + return nil + } + + switch threadVariant { + case .contact: + var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1() + guard + var cThreadId: [CChar] = threadId.cString(using: .utf8), + convo_info_volatile_get_1to1(conf, &oneToOne, &cThreadId) + else { + LibSessionError.clear(conf) + return nil + } + + return oneToOne.last_read + + case .legacyGroup: + var legacyGroup: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() + + guard + var cThreadId: [CChar] = threadId.cString(using: .utf8), + convo_info_volatile_get_legacy_group(conf, &legacyGroup, &cThreadId) + else { + LibSessionError.clear(conf) + return nil + } + + return legacyGroup.last_read + + case .community: + guard let openGroup: OpenGroup = openGroup else { return nil } + + var convoCommunity: convo_info_volatile_community = convo_info_volatile_community() + + guard + var cBaseUrl: [CChar] = openGroup.server.cString(using: .utf8), + var cRoomToken: [CChar] = openGroup.roomToken.cString(using: .utf8), + convo_info_volatile_get_community(conf, &convoCommunity, &cBaseUrl, &cRoomToken) + else { + LibSessionError.clear(conf) + return nil + } + + return convoCommunity.last_read + + case .group: + var group: convo_info_volatile_group = convo_info_volatile_group() + + guard + var cThreadId: [CChar] = threadId.cString(using: .utf8), + convo_info_volatile_get_group(conf, &group, &cThreadId) + else { return nil } + + return group.last_read + } + } + func timestampAlreadyRead( threadId: String, threadVariant: SessionThread.Variant, diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift index 59967a480..b8812c185 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift @@ -26,6 +26,7 @@ public enum MessageReceiverError: LocalizedError { case outdatedMessage case duplicatedCall case missingRequiredAdminPrivileges + case deprecatedMessage public var isRetryable: Bool { switch self { @@ -79,6 +80,7 @@ public enum MessageReceiverError: LocalizedError { case .outdatedMessage: return "Message was sent before a config change which would have removed the message." case .duplicatedCall: return "Duplicate call." case .missingRequiredAdminPrivileges: return "Handling this message requires admin privileges which the current user does not have." + case .deprecatedMessage: return "This message type has been deprecated." } } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LegacyClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LegacyClosedGroups.swift index 7be55612e..fce34fe77 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LegacyClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LegacyClosedGroups.swift @@ -14,6 +14,8 @@ extension MessageReceiver { message: ClosedGroupControlMessage, using dependencies: Dependencies ) throws { + guard !dependencies[feature: .legacyGroupsDeprecated] else { throw MessageReceiverError.deprecatedMessage } + switch message.kind { case .new: try handleNewLegacyClosedGroup(db, message: message, using: dependencies) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift index 952706a2c..b016bb4d5 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift @@ -34,7 +34,7 @@ extension MessageReceiver { )) .isEmpty(db)) .defaulting(to: false) - let needsToStartTypingIndicator: Bool = dependencies[singleton: .typingIndicators].didStartTypingNeedsToStart( + dependencies[singleton: .typingIndicators].startIfNeeded( threadId: threadId, threadVariant: threadVariant, threadIsBlocked: threadIsBlocked, @@ -43,10 +43,6 @@ extension MessageReceiver { timestampMs: message.sentTimestampMs.map { Int64($0) } ) - if needsToStartTypingIndicator { - dependencies[singleton: .typingIndicators].start(db, threadId: threadId, direction: .incoming) - } - case .stopped: dependencies[singleton: .typingIndicators].didStopTyping(db, threadId: threadId, direction: .incoming) diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift index 3b7fb637a..06bd6a0c6 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift @@ -42,6 +42,7 @@ public final class GroupPoller: SwarmPoller { override public func nextPollDelay() -> TimeInterval { // Get the received date of the last message in the thread. If we don't have // any messages yet, pick some reasonable fake time interval to use instead + // FIXME: Update this to be based on `active_at` once it gets added to libSession let lastMessageDate: Date = dependencies[singleton: .storage] .read { [pollerDestination] db in try Interaction @@ -57,8 +58,28 @@ public final class GroupPoller: SwarmPoller { return Date(timeIntervalSince1970: TimeInterval(Double(receivedAtTimestampMs) / 1000)) } .defaulting(to: dependencies.dateNow.addingTimeInterval(-5 * 60)) + let lastReadDate: Date = dependencies + .mutate(cache: .libSession) { cache in + cache.conversationLastRead( + threadId: pollerDestination.target, + // FIXME: Remove this check when legacy groups are deprecated (leaving the feature flag commented out to make it easier to find) + // dependencies[feature: .legacyGroupsDeprecated] + threadVariant: ((try? SessionId.Prefix(from: pollerDestination.target)) != .standard ? + .group : + .legacyGroup + ), + openGroup: nil + ) + } + .map { lastReadTimestampMs in + guard lastReadTimestampMs > 0 else { return nil } + + return Date(timeIntervalSince1970: TimeInterval(Double(lastReadTimestampMs) / 1000)) + } + .defaulting(to: dependencies.dateNow.addingTimeInterval(-5 * 60)) - let timeSinceLastMessage: TimeInterval = dependencies.dateNow.timeIntervalSince(lastMessageDate) + let timeSinceLastMessage: TimeInterval = dependencies.dateNow + .timeIntervalSince(max(lastMessageDate, lastReadDate)) let limit: Double = (12 * 60 * 60) let a: TimeInterval = ((maxPollInterval - minPollInterval) / limit) let nextPollInterval: TimeInterval = a * min(timeSinceLastMessage, limit) + minPollInterval diff --git a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift index 094c14d70..7026b9dc8 100644 --- a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift @@ -20,6 +20,10 @@ public class TypingIndicators { // MARK: - Variables private let dependencies: Dependencies + @ThreadSafeObject private var timerQueue: DispatchQueue = DispatchQueue( + label: "org.getsession.typingIndicatorQueue", + qos: .userInteractive + ) @ThreadSafeObject private var outgoing: [String: Indicator] = [:] @ThreadSafeObject private var incoming: [String: Indicator] = [:] @@ -31,65 +35,55 @@ public class TypingIndicators { // MARK: - Functions - public func didStartTypingNeedsToStart( + public func startIfNeeded( threadId: String, threadVariant: SessionThread.Variant, threadIsBlocked: Bool, threadIsMessageRequest: Bool, direction: Direction, timestampMs: Int64? - ) -> Bool { - switch direction { - case .outgoing: - // If we already have an existing typing indicator for this thread then just - // refresh it's timeout (no need to do anything else) - if let existingIndicator: Indicator = outgoing[threadId] { - existingIndicator.refreshTimeout(using: dependencies) - return false - } - - let newIndicator: Indicator? = Indicator( - threadId: threadId, - threadVariant: threadVariant, - threadIsBlocked: threadIsBlocked, - threadIsMessageRequest: threadIsMessageRequest, - direction: direction, - timestampMs: timestampMs, - using: dependencies - ) - newIndicator?.refreshTimeout(using: dependencies) - - _outgoing.performUpdate { $0.setting(threadId, newIndicator) } - return true - - case .incoming: - // If we already have an existing typing indicator for this thread then just - // refresh it's timeout (no need to do anything else) - if let existingIndicator: Indicator = incoming[threadId] { - existingIndicator.refreshTimeout(using: dependencies) - return false - } - - let newIndicator: Indicator? = Indicator( - threadId: threadId, - threadVariant: threadVariant, - threadIsBlocked: threadIsBlocked, - threadIsMessageRequest: threadIsMessageRequest, - direction: direction, - timestampMs: timestampMs, - using: dependencies - ) - newIndicator?.refreshTimeout(using: dependencies) - - _incoming.performUpdate { $0.setting(threadId, newIndicator) } - return true + ) { + let targetIndicators: [String: Indicator] = (direction == .outgoing ? outgoing : incoming) + + /// If we already have an existing typing indicator for this thread then just refresh it's timeout (no need to do anything else) + if let existingIndicator: Indicator = targetIndicators[threadId] { + existingIndicator.refreshTimeout(timerQueue: timerQueue, using: dependencies) + return } - } - - public func start(_ db: Database, threadId: String, direction: Direction) { - switch direction { - case .outgoing: outgoing[threadId]?.start(db, using: dependencies) - case .incoming: incoming[threadId]?.start(db, using: dependencies) + + /// Create the indicator on the `timerQueue` if needed + /// + /// Typing indicators should only show/send 1-to-1 conversations that aren't blocked or message requests + /// + /// The `typingIndicatorsEnabled` flag reflects the user-facing setting in the app preferences, if it's disabled we don't + /// want to emit "typing indicator" messages or show typing indicators for other users + /// + /// **Note:** We do this check on a background thread because, while it's just checking a setting, we are still accessing the + /// database to check `typingIndicatorsEnabled` so want to avoid doing it on the main thread + timerQueue.async { [weak self, dependencies] in + guard + threadVariant == .contact && + !threadIsBlocked && + !threadIsMessageRequest && + dependencies[singleton: .storage, key: .typingIndicatorsEnabled], + let timerQueue: DispatchQueue = self?.timerQueue + else { return } + + let newIndicator: Indicator = Indicator( + threadId: threadId, + threadVariant: threadVariant, + direction: direction, + timestampMs: (timestampMs ?? dependencies[cache: .snodeAPI].currentOffsetTimestampMs()) + ) + + switch direction { + case .outgoing: self?._outgoing.performUpdate { $0.setting(threadId, newIndicator) } + case .incoming: self?._incoming.performUpdate { $0.setting(threadId, newIndicator) } + } + + dependencies[singleton: .storage].writeAsync { db in + newIndicator.start(db, timerQueue: timerQueue, using: dependencies) + } } } @@ -125,49 +119,25 @@ public extension TypingIndicators { fileprivate let threadVariant: SessionThread.Variant fileprivate let direction: Direction fileprivate let timestampMs: Int64 + fileprivate var refreshTimer: DispatchSourceTimer? + fileprivate var stopTimer: DispatchSourceTimer? - fileprivate var refreshTimer: Timer? - fileprivate var stopTimer: Timer? - - init?( + init( threadId: String, threadVariant: SessionThread.Variant, - threadIsBlocked: Bool, - threadIsMessageRequest: Bool, direction: Direction, - timestampMs: Int64?, - using dependencies: Dependencies + timestampMs: Int64 ) { - // The `typingIndicatorsEnabled` flag reflects the user-facing setting in the app - // preferences, if it's disabled we don't want to emit "typing indicator" messages - // or show typing indicators for other users - // - // We also don't want to show/send typing indicators for message requests - guard - dependencies[singleton: .storage, key: .typingIndicatorsEnabled] && - !threadIsBlocked && - !threadIsMessageRequest - else { return nil } - - // Don't send typing indicators in group threads - guard - threadVariant != .legacyGroup && - threadVariant != .group && - threadVariant != .community - else { return nil } - self.threadId = threadId self.threadVariant = threadVariant self.direction = direction - self.timestampMs = (timestampMs ?? dependencies[cache: .snodeAPI].currentOffsetTimestampMs()) + self.timestampMs = timestampMs } - fileprivate func start(_ db: Database, using dependencies: Dependencies) { + fileprivate func start(_ db: Database, timerQueue: DispatchQueue, using dependencies: Dependencies) { // Start the typing indicator switch direction { - case .outgoing: - scheduleRefreshCallback(db, shouldSend: (refreshTimer == nil), using: dependencies) - + case .outgoing: scheduleRefreshCallback(timerQueue: timerQueue, using: dependencies) case .incoming: try? ThreadTypingIndicator( threadId: threadId, @@ -177,13 +147,13 @@ public extension TypingIndicators { } // Refresh the timeout since we just started - refreshTimeout(using: dependencies) + refreshTimeout(timerQueue: timerQueue, using: dependencies) } fileprivate func stop(_ db: Database, using dependencies: Dependencies) { - self.refreshTimer?.invalidate() + self.refreshTimer?.cancel() self.refreshTimer = nil - self.stopTimer?.invalidate() + self.stopTimer?.cancel() self.stopTimer = nil switch direction { @@ -204,49 +174,46 @@ public extension TypingIndicators { } } - fileprivate func refreshTimeout(using dependencies: Dependencies) { + fileprivate func refreshTimeout(timerQueue: DispatchQueue, using dependencies: Dependencies) { let threadId: String = self.threadId let direction: Direction = self.direction // Schedule the 'stopCallback' to cancel the typing indicator - stopTimer?.invalidate() - stopTimer = Timer.scheduledTimerOnMainThread( - withTimeInterval: (direction == .outgoing ? 3 : 5), - repeats: false, - using: dependencies - ) { _ in + stopTimer?.cancel() + stopTimer = DispatchSource.makeTimerSource(queue: timerQueue) + stopTimer?.schedule(deadline: .now() + .seconds(direction == .outgoing ? 3 : 15)) + stopTimer?.setEventHandler { dependencies[singleton: .storage].writeAsync { db in - dependencies[singleton: .typingIndicators].didStopTyping(db, threadId: threadId, direction: direction) + dependencies[singleton: .typingIndicators].didStopTyping( + db, + threadId: threadId, + direction: direction + ) } } + stopTimer?.resume() } private func scheduleRefreshCallback( - _ db: Database, - shouldSend: Bool = true, + timerQueue: DispatchQueue, using dependencies: Dependencies ) { - if shouldSend { - try? MessageSender.send( - db, - message: TypingIndicator(kind: .started), - interactionId: nil, - threadId: threadId, - threadVariant: threadVariant, - using: dependencies - ) - } - - refreshTimer?.invalidate() - refreshTimer = Timer.scheduledTimerOnMainThread( - withTimeInterval: 10, - repeats: false, - using: dependencies - ) { [weak self] _ in + refreshTimer?.cancel() + refreshTimer = DispatchSource.makeTimerSource(queue: timerQueue) + refreshTimer?.schedule(deadline: .now(), repeating: .seconds(10)) + refreshTimer?.setEventHandler { [threadId = self.threadId, threadVariant = self.threadVariant] in dependencies[singleton: .storage].writeAsync { db in - self?.scheduleRefreshCallback(db, using: dependencies) + try? MessageSender.send( + db, + message: TypingIndicator(kind: .started), + interactionId: nil, + threadId: threadId, + threadVariant: threadVariant, + using: dependencies + ) } } + refreshTimer?.resume() } } } diff --git a/SessionSnodeKit/LibSession/LibSession+Networking.swift b/SessionSnodeKit/LibSession/LibSession+Networking.swift index 35c60cf07..fed40b519 100644 --- a/SessionSnodeKit/LibSession/LibSession+Networking.swift +++ b/SessionSnodeKit/LibSession/LibSession+Networking.swift @@ -12,7 +12,14 @@ import SessionUtilitiesKit public extension Cache { static let libSessionNetwork: CacheConfig = Dependencies.create( identifier: "libSessionNetwork", - createInstance: { dependencies in LibSession.NetworkCache(using: dependencies) }, + createInstance: { dependencies in + /// The `libSessionNetwork` cache gets warmed during startup and creates a network instance, populates the snode + /// cache and builds onion requests when created - when running unit tests we don't want to do any of that unless explicitly + /// desired within the test itself so instead we default to a `NoopNetworkCache` when running unit tests + guard !SNUtilitiesKit.isRunningTests else { return LibSession.NoopNetworkCache() } + + return LibSession.NetworkCache(using: dependencies) + }, mutableInstance: { $0 }, immutableInstance: { $0 } ) @@ -897,4 +904,27 @@ public extension LibSession { func setPaths(paths: [[Snode]]) func clearSnodeCache() } + + class NoopNetworkCache: NetworkCacheType { + public var isSuspended: Bool { return false } + public var networkStatus: AnyPublisher { + Just(NetworkStatus.unknown).eraseToAnyPublisher() + } + + public var paths: AnyPublisher<[[Snode]], Never> { Just([]).eraseToAnyPublisher() } + public var hasPaths: Bool { return false } + public var currentPaths: [[LibSession.Snode]] { [] } + public var pathsDescription: String { "" } + + public func suspendNetworkAccess() {} + public func resumeNetworkAccess() {} + public func getOrCreateNetwork() -> AnyPublisher?, Error> { + return Fail(error: NetworkError.invalidState) + .eraseToAnyPublisher() + } + + public func setNetworkStatus(status: NetworkStatus) {} + public func setPaths(paths: [[LibSession.Snode]]) {} + public func clearSnodeCache() {} + } } diff --git a/SessionUIKit/Components/Modal.swift b/SessionUIKit/Components/Modal.swift index b81c50a97..3a7235154 100644 --- a/SessionUIKit/Components/Modal.swift +++ b/SessionUIKit/Components/Modal.swift @@ -130,6 +130,12 @@ open class Modal: UIViewController, UIGestureRecognizerDelegate { populateContentView() } + open override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + afterClosed?() + } + /// To be overridden by subclasses. open func populateContentView() { preconditionFailure("populateContentView() is abstract and must be overridden.") @@ -167,9 +173,7 @@ open class Modal: UIViewController, UIGestureRecognizerDelegate { } } - targetViewController?.presentingViewController?.dismiss(animated: true) { [weak self] in - self?.afterClosed?() - } + targetViewController?.presentingViewController?.dismiss(animated: true) } // MARK: - UIGestureRecognizerDelegate diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 5a6b3c112..066a71703 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -670,14 +670,16 @@ open class Storage { /// and just block the thread when we want to perform a synchronous operation @discardableResult private static func performOperation( _ info: CallInfo, + _ dependencies: Dependencies, _ operation: @escaping (Database) throws -> T, _ completion: ((Result) -> Void)? = nil ) -> Result { - let queryDbLock = NSLock() + // A serial queue for synchronizing completion updates. + let syncQueue = DispatchQueue(label: "com.session.performOperation.syncQueue") + var queryDb: Database? - let completionLock = NSLock() var didComplete: Bool = false - var result: Result = .failure(StorageError.invalidQueryResult) + var finalResult: Result = .failure(StorageError.invalidQueryResult) let semaphore: DispatchSemaphore? = (info.isAsync ? nil : DispatchSemaphore(value: 0)) let logErrorIfNeeded: (Result) -> () = { result in switch result { @@ -685,46 +687,37 @@ open class Storage { case .failure(let error): StorageState.logIfNeeded(error, isWrite: info.isWrite) } } - let completeOperation: (Result) -> Void = { operationResult in - completionLock.lock() - defer { completionLock.unlock() } - guard !didComplete else { return } - - /// If the query timed out then we should interrupt the query (don't want the query thread to remain blocked when we've - /// already handled it as a failure) - switch operationResult { - case .failure(let error) where error as? StorageError == StorageError.transactionDeadlockTimeout: - queryDbLock.lock() - defer { queryDbLock.unlock() } - queryDb?.interrupt() - - default: break - } - - didComplete = true - result = operationResult - semaphore?.signal() - - /// For async operations, log the error and call the completion closure - if info.isAsync { - logErrorIfNeeded(result) - completion?(result) + + func completeOperation(with result: Result) { + syncQueue.sync { + if didComplete { return } + didComplete = true + finalResult = result + semaphore?.signal() + + // For async operations, log and invoke the completion closure. + if info.isAsync { + logErrorIfNeeded(result) + completion?(result) + } } } /// Perform the actual operation switch (StorageState(info.storage), info.isWrite) { - case (.invalid(let error), _): completeOperation(.failure(error)) + case (.invalid(let error), _): completeOperation(with: .failure(error)) case (.valid(let dbWriter), true): dbWriter.asyncWrite( { db in - queryDbLock.lock() - defer { queryDbLock.unlock() } + syncQueue.sync { queryDb = db } + + if dependencies[feature: .forceSlowDatabaseQueries] { + Thread.sleep(forTimeInterval: 1) + } - queryDb = db return try Storage.track(db, info, operation) }, - completion: { _, dbResult in completeOperation(dbResult) } + completion: { _, dbResult in completeOperation(with: dbResult) } ) case (.valid(let dbWriter), false): @@ -733,14 +726,16 @@ open class Storage { switch dbResult { case .failure(let error): throw error case .success(let db): - queryDbLock.lock() - defer { queryDbLock.unlock() } + syncQueue.sync { queryDb = db } + + if dependencies[feature: .forceSlowDatabaseQueries] { + Thread.sleep(forTimeInterval: 1) + } - queryDb = db - completeOperation(.success(try Storage.track(db, info, operation))) + completeOperation(with: .success(try Storage.track(db, info, operation))) } } catch { - completeOperation(.failure(error)) + completeOperation(with: .failure(error)) } } } @@ -760,38 +755,50 @@ open class Storage { if !isDebuggerAttached() { semaphoreResult = semaphore?.wait(timeout: .now() + .seconds(Storage.transactionDeadlockTimeoutSeconds)) } - else if !info.isAsync { - let timerSemaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - let timerQueue = DispatchQueue(label: "org.session.debugSemaphoreTimer", qos: .userInteractive) - let timer = DispatchSource.makeTimerSource(queue: timerQueue) - var iterations: UInt64 = 0 + else if !info.isAsync, let semaphore: DispatchSemaphore = semaphore { + let pollQueue: DispatchQueue = DispatchQueue.global(qos: .userInitiated) + var iterations: Int = 0 + let maxIterations: Int = 50 + let pollCompletionSemaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - /// Every tick of the timer check if the semaphore has completed or we have timed out - timer.schedule(deadline: .now(), repeating: .milliseconds(100)) - timer.setEventHandler { + /// Stagger the size of the `pollIntervals` to avoid holding up the thread in case the query resolves very quickly + let pollIntervals: [DispatchTimeInterval] = [ + .milliseconds(5), .milliseconds(5), .milliseconds(10), .milliseconds(10), .milliseconds(10), + .milliseconds(100) + ] + + func pollSemaphore() { iterations += 1 - if iterations >= 50 || semaphore?.wait(timeout: .now()) == .success { - timer.cancel() - timerSemaphore.signal() + guard iterations < maxIterations && semaphore.wait(timeout: .now()) != .success else { + pollCompletionSemaphore.signal() + return + } + + let nextInterval: DispatchTimeInterval = pollIntervals[min(iterations, pollIntervals.count - 1)] + pollQueue.asyncAfter(deadline: .now() + nextInterval) { + pollSemaphore() } } - timer.resume() - timerSemaphore.wait() // Wait indefinitely for the timer semaphore + /// Poll the semaphore in a background queue + pollQueue.asyncAfter(deadline: .now() + pollIntervals[0]) { pollSemaphore() } + pollCompletionSemaphore.wait() // Wait indefinitely for the timer semaphore semaphoreResult = (iterations >= 50 ? .timedOut : .success) } #else semaphoreResult = semaphore?.wait(timeout: .now() + .seconds(Storage.transactionDeadlockTimeoutSeconds)) #endif - /// If the transaction timed out then log the error and report a failure, otherwise handle whatever the result was - completeOperation(semaphoreResult != .timedOut ? - result : - .failure(StorageError.transactionDeadlockTimeout) - ) + /// If the query timed out then we should interrupt the query (don't want the query thread to remain blocked when we've + /// already handled it as a failure) and need to call `completeOperation` as it wouldn't have been called within the + /// db transaction yet + if semaphoreResult == .timedOut { + syncQueue.sync { queryDb?.interrupt() } + completeOperation(with: .failure(StorageError.transactionDeadlockTimeout)) + } - return result + return finalResult } private func performPublisherOperation( @@ -812,9 +819,9 @@ open class Storage { /// Instead of this we are just using `Deferred { Future {} }` which is executed on the specified scheduled /// which behaves in a much more expected way than the GRDB `readPublisher`/`writePublisher` does let info: CallInfo = CallInfo(self, fileName, functionName, lineNumber, .syncWrite) - return Deferred { + return Deferred { [dependencies] in Future { resolver in - resolver(Storage.performOperation(info, operation)) + resolver(Storage.performOperation(info, dependencies, operation)) } }.eraseToAnyPublisher() } @@ -828,7 +835,7 @@ open class Storage { lineNumber line: Int = #line, updates: @escaping (Database) throws -> T? ) -> T? { - switch Storage.performOperation(CallInfo(self, file, funcN, line, .syncWrite), updates) { + switch Storage.performOperation(CallInfo(self, file, funcN, line, .syncWrite), dependencies, updates) { case .failure: return nil case .success(let result): return result } @@ -841,7 +848,7 @@ open class Storage { updates: @escaping (Database) throws -> T, completion: @escaping (Result) -> Void = { _ in } ) { - Storage.performOperation(CallInfo(self, file, funcN, line, .asyncWrite), updates, completion) + Storage.performOperation(CallInfo(self, file, funcN, line, .asyncWrite), dependencies, updates, completion) } open func writePublisher( @@ -859,7 +866,7 @@ open class Storage { lineNumber line: Int = #line, _ value: @escaping (Database) throws -> T? ) -> T? { - switch Storage.performOperation(CallInfo(self, file, funcN, line, .syncRead), value) { + switch Storage.performOperation(CallInfo(self, file, funcN, line, .syncRead), dependencies, value) { case .failure: return nil case .success(let result): return result } diff --git a/SessionUtilitiesKit/General/Feature.swift b/SessionUtilitiesKit/General/Feature.swift index 6c9bd5658..1d88e74d1 100644 --- a/SessionUtilitiesKit/General/Feature.swift +++ b/SessionUtilitiesKit/General/Feature.swift @@ -36,6 +36,10 @@ public extension FeatureStorage { ) ) + static let forceSlowDatabaseQueries: FeatureConfig = Dependencies.create( + identifier: "forceSlowDatabaseQueries" + ) + static let updatedGroups: FeatureConfig = Dependencies.create( identifier: "updatedGroups", defaultOption: true, diff --git a/_SharedTestUtilities/MockJobRunner.swift b/_SharedTestUtilities/MockJobRunner.swift index 92b8f41ef..728f68737 100644 --- a/_SharedTestUtilities/MockJobRunner.swift +++ b/_SharedTestUtilities/MockJobRunner.swift @@ -71,4 +71,13 @@ class MockJobRunner: Mock, JobRunnerType { func removePendingJob(_ job: Job?) { mockNoReturn(args: [job]) } + + + func registerRecurringJobs(scheduleInfo: [JobRunner.ScheduleInfo]) { + mockNoReturn(args: [scheduleInfo]) + } + + func scheduleRecurringJobsIfNeeded() { + mockNoReturn() + } }