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()
+ }
}