Fixed a number of issues found during general testing

• Added a 'forceSlowDatabaseQueries' dev setting to help track down main thread database queries
• Updated the unit tests to use a NoopNetworkCache by default when running unit tests
• Updated the custom debug sync query timer to poll more frequently for the first few iterations
• Fixed the broken unit tests
• Fixed the bad memory issue again (wasn't properly fixed...)
• Fixed an issue where the keyboard wouldn't reappear after deleting a message
• Fixed an issue where the toast shown after deleting a message could do a weird appearance animation
• Fixed an issue where legacy group invites were incorrectly still being handled once legacy groups were deprecated
• Fixed an issue where control messages could incorrectly leave a deletion artifact when triggering "delete before now"
• Fixed an issue where the typing indicator logic could run on the main thread (was also overly complex)
• Fixed an issue where we were clearing the incoming typing indicator too quickly (5s vs 15s like other platforms)
• Fixed an issue where the GroupPoller could incorrectly wait for longer than it should between polls if a message was deleted (there will be a better approach we should use in the future when we have an `active_at` flag for the conversation)
pull/894/head
Morgan Pretty 10 months ago
parent 8bb51968f0
commit 3f3d4dde26

@ -1 +1 @@
Subproject commit 9935bbe0137423f39e3a2292268f180a043db94d
Subproject commit 287f117fc942cfcc820eaac87c523e0b364d0316

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

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

@ -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.
<b>Note:</b> 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)
}
}

@ -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<Int64> = 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`

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

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

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

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

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

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

@ -12,7 +12,14 @@ import SessionUtilitiesKit
public extension Cache {
static let libSessionNetwork: CacheConfig<LibSession.NetworkCacheType, LibSession.NetworkImmutableCacheType> = 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<NetworkStatus, Never> {
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<UnsafeMutablePointer<network_object>?, Error> {
return Fail(error: NetworkError.invalidState)
.eraseToAnyPublisher()
}
public func setNetworkStatus(status: NetworkStatus) {}
public func setPaths(paths: [[LibSession.Snode]]) {}
public func clearSnodeCache() {}
}
}

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

@ -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<T>(
_ info: CallInfo,
_ dependencies: Dependencies,
_ operation: @escaping (Database) throws -> T,
_ completion: ((Result<T, Error>) -> Void)? = nil
) -> Result<T, Error> {
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<T, Error> = .failure(StorageError.invalidQueryResult)
var finalResult: Result<T, Error> = .failure(StorageError.invalidQueryResult)
let semaphore: DispatchSemaphore? = (info.isAsync ? nil : DispatchSemaphore(value: 0))
let logErrorIfNeeded: (Result<T, Error>) -> () = { result in
switch result {
@ -685,46 +687,37 @@ open class Storage {
case .failure(let error): StorageState.logIfNeeded(error, isWrite: info.isWrite)
}
}
let completeOperation: (Result<T, Error>) -> 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<T, Error>) {
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<T>(
@ -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<T, Error>) -> 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<T>(
@ -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
}

@ -36,6 +36,10 @@ public extension FeatureStorage {
)
)
static let forceSlowDatabaseQueries: FeatureConfig<Bool> = Dependencies.create(
identifier: "forceSlowDatabaseQueries"
)
static let updatedGroups: FeatureConfig<Bool> = Dependencies.create(
identifier: "updatedGroups",
defaultOption: true,

@ -71,4 +71,13 @@ class MockJobRunner: Mock<JobRunnerType>, JobRunnerType {
func removePendingJob(_ job: Job?) {
mockNoReturn(args: [job])
}
func registerRecurringJobs(scheduleInfo: [JobRunner.ScheduleInfo]) {
mockNoReturn(args: [scheduleInfo])
}
func scheduleRecurringJobsIfNeeded() {
mockNoReturn()
}
}

Loading…
Cancel
Save