Fixed a number of issues found during internal testing

Added copy for an unrecoverable startup case
Added some additional logs to better debug ValueObservation query errors
Increased the pageSize to 20 on iPad devices (to prevent it immediately loading a second page)
Cleaned up a bunch of threading logic (try to avoid overriding subscribe/receive threads specified at subscription)
Consolidated the 'sendMessage' and 'sendAttachments' functions
Updated the various frameworks to use 'DAWRF with DSYM' to allow for better debugging during debug mode (at the cost of a longer build time)
Updated the logic to optimistically insert messages when sending to avoid any database write delays
Updated the logic to avoid sending notifications for messages which are already marked as read by the config
Fixed an issue where multiple paths could incorrectly get built at the same time in some cases
Fixed an issue where other job queues could be started before the blockingQueue finishes
Fixed a potential bug with the snode version comparison (was just a string comparison which would fail when getting to double-digit values)
Fixed a bug where you couldn't remove the last reaction on a message
Fixed the broken media message zoom animations
Fixed a bug where the last message read in a conversation wouldn't be correctly detected as already read
Fixed a bug where the QuoteView had no line limits (resulting in the '@You' mention background highlight being incorrectly positioned in the quote preview)
Fixed a bug where a large number of configSyncJobs could be scheduled (only one would run at a time but this could result in performance impacts)
This commit is contained in:
Morgan Pretty 2023-06-23 17:54:29 +10:00
parent 5db254303a
commit 53a5db0ea5
119 changed files with 2290 additions and 1376 deletions

@ -1 +1 @@
Subproject commit 49c78682a6f4546c8773113f3e201244f0b1e65a
Subproject commit e0b994201a016cc5bf9065526a0ceb4291f60d5a

View File

@ -578,6 +578,10 @@
FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5B12554AF9800555489 /* VisibleMessage+Profile.swift */; };
FD245C6C2850669200B966DD /* MessageReceiveJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A31225574F5200338F3E /* MessageReceiveJob.swift */; };
FD245C6D285066A400B966DD /* NotifyPushServerJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */; };
FD29598B2A43BB8100888A17 /* GetStatsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD29598A2A43BB8100888A17 /* GetStatsResponse.swift */; };
FD29598D2A43BC0B00888A17 /* Version.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD29598C2A43BC0B00888A17 /* Version.swift */; };
FD2959902A43BE5F00888A17 /* VersionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD29598F2A43BE5F00888A17 /* VersionSpec.swift */; };
FD2959922A4417A900888A17 /* PreparedSendData.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2959912A4417A900888A17 /* PreparedSendData.swift */; };
FD2AAAED28ED3E1000A49611 /* MockGeneralCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */; };
FD2AAAEE28ED3E1100A49611 /* MockGeneralCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */; };
FD2AAAF028ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; };
@ -1281,7 +1285,6 @@
7BC707F127290ACB002817AD /* SessionCallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCallManager.swift; sourceTree = "<group>"; };
7BCD116B27016062006330F1 /* WebRTCSession+DataChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebRTCSession+DataChannel.swift"; sourceTree = "<group>"; };
7BD477A727EC39F5004E2822 /* Atomic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = "<group>"; };
7BD477A927F15F24004E2822 /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = "<group>"; };
7BDCFC0424206E7300641C39 /* SessionNotificationServiceExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SessionNotificationServiceExtension.entitlements; sourceTree = "<group>"; };
7BDCFC07242186E700641C39 /* NotificationServiceExtensionContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtensionContext.swift; sourceTree = "<group>"; };
7BFA8AE22831D0D4001876F3 /* ContextMenuVC+EmojiReactsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContextMenuVC+EmojiReactsView.swift"; sourceTree = "<group>"; };
@ -1725,6 +1728,10 @@
FD23EA6028ED0B260058676E /* CombineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineExtensions.swift; sourceTree = "<group>"; };
FD245C612850664300B966DD /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = "<group>"; };
FD28A4F527EAD44C00FF65E7 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = "<group>"; };
FD29598A2A43BB8100888A17 /* GetStatsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStatsResponse.swift; sourceTree = "<group>"; };
FD29598C2A43BC0B00888A17 /* Version.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Version.swift; sourceTree = "<group>"; };
FD29598F2A43BE5F00888A17 /* VersionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionSpec.swift; sourceTree = "<group>"; };
FD2959912A4417A900888A17 /* PreparedSendData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreparedSendData.swift; sourceTree = "<group>"; };
FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronousStorage.swift; sourceTree = "<group>"; };
FD2B4AFA29429D1000AB4848 /* ConfigContactsSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigContactsSpec.swift; sourceTree = "<group>"; };
FD2B4AFC294688D000AB4848 /* SessionUtil+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionUtil+Contacts.swift"; sourceTree = "<group>"; };
@ -3217,7 +3224,6 @@
FDC4381827B34EAD00C60D73 /* Models */,
FDC4380727B31D3A00C60D73 /* Types */,
FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */,
7BD477A927F15F24004E2822 /* OpenGroupServerIdLookup.swift */,
B88FA7B726045D100049422F /* OpenGroupAPI.swift */,
C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */,
);
@ -3609,6 +3615,7 @@
FD8ECF93293856AF00C0D1BB /* Randomness.swift */,
C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */,
C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */,
FD29598C2A43BC0B00888A17 /* Version.swift */,
);
path = Utilities;
sourceTree = "<group>";
@ -3784,6 +3791,14 @@
path = Utilities;
sourceTree = "<group>";
};
FD29598E2A43BE5400888A17 /* Utilities */ = {
isa = PBXGroup;
children = (
FD29598F2A43BE5F00888A17 /* VersionSpec.swift */,
);
path = Utilities;
sourceTree = "<group>";
};
FD2B4B022949886900AB4848 /* Database */ = {
isa = PBXGroup;
children = (
@ -4033,6 +4048,7 @@
FD37EA1228AB3F60003AE748 /* Database */,
FD83B9B927CF20A5005E1583 /* General */,
FD9B30F1293EA0AF008DEE3E /* Networking */,
FD29598E2A43BE5400888A17 /* Utilities */,
);
path = SessionUtilitiesKitTests;
sourceTree = "<group>";
@ -4167,6 +4183,7 @@
FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */,
FDC4380827B31D4E00C60D73 /* OpenGroupAPIError.swift */,
FDC4381627B32EC700C60D73 /* Personalization.swift */,
FD2959912A4417A900888A17 /* PreparedSendData.swift */,
FDC4381427B329CE00C60D73 /* NonceGenerator.swift */,
FDC438C227BB512200C60D73 /* SodiumProtocols.swift */,
);
@ -4395,6 +4412,7 @@
FDF848A629405C5A007DCAE5 /* ONSResolveRequest.swift */,
FDF8489E29405C5A007DCAE5 /* ONSResolveResponse.swift */,
FDF8489C29405C5A007DCAE5 /* GetServiceNodesRequest.swift */,
FD29598A2A43BB8100888A17 /* GetStatsResponse.swift */,
);
path = Models;
sourceTree = "<group>";
@ -5580,6 +5598,7 @@
FDF848CC29405C5B007DCAE5 /* SnodeReceivedMessage.swift in Sources */,
FDF848C129405C5A007DCAE5 /* UpdateExpiryRequest.swift in Sources */,
FDF848C729405C5B007DCAE5 /* SendMessageResponse.swift in Sources */,
FD29598B2A43BB8100888A17 /* GetStatsResponse.swift in Sources */,
FDF848CA29405C5B007DCAE5 /* DeleteAllBeforeRequest.swift in Sources */,
FDF848D229405C5B007DCAE5 /* LegacyGetMessagesRequest.swift in Sources */,
FDF848CB29405C5B007DCAE5 /* SnodePoolResponse.swift in Sources */,
@ -5696,6 +5715,7 @@
C3D9E4F4256778AF0040E4F3 /* NSData+Image.m in Sources */,
FDF222092818D2B0000A4995 /* NSAttributedString+Utilities.swift in Sources */,
C32C5E0C256DDAFA003C73A2 /* NSRegularExpression+SSK.swift in Sources */,
FD29598D2A43BC0B00888A17 /* Version.swift in Sources */,
FDF8487C29405906007DCAE5 /* HTTPMethod.swift in Sources */,
FDF8488429405A2B007DCAE5 /* RequestInfo.swift in Sources */,
C3BBE0A92554D4DE0050F1E3 /* HTTP.swift in Sources */,
@ -5892,6 +5912,7 @@
FD245C632850664600B966DD /* Configuration.swift in Sources */,
C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */,
C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */,
FD2959922A4417A900888A17 /* PreparedSendData.swift in Sources */,
FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */,
FD432437299DEA38008A0213 /* TypeConversion+Utilities.swift in Sources */,
FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */,
@ -6156,6 +6177,7 @@
FD0B77B229B82B7A009169BA /* ArrayUtilitiesSpec.swift in Sources */,
FD1A94FE2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift in Sources */,
FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */,
FD2959902A43BE5F00888A17 /* VersionSpec.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -6395,7 +6417,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 408;
CURRENT_PROJECT_VERSION = 409;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -6467,7 +6489,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 408;
CURRENT_PROJECT_VERSION = 409;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO;
@ -6532,7 +6554,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 408;
CURRENT_PROJECT_VERSION = 409;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -6606,7 +6628,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 408;
CURRENT_PROJECT_VERSION = 409;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO;
@ -6671,7 +6693,7 @@
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
DYLIB_COMPATIBILITY_VERSION = 1;
@ -6808,7 +6830,7 @@
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
DYLIB_COMPATIBILITY_VERSION = 1;
@ -6959,7 +6981,7 @@
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
DYLIB_COMPATIBILITY_VERSION = 1;
@ -7096,7 +7118,7 @@
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
DYLIB_COMPATIBILITY_VERSION = 1;
@ -7235,7 +7257,7 @@
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
DYLIB_COMPATIBILITY_VERSION = 1;
@ -7514,7 +7536,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 408;
CURRENT_PROJECT_VERSION = 409;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
@ -7585,7 +7607,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 408;
CURRENT_PROJECT_VERSION = 409;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",

View File

@ -214,7 +214,10 @@ extension ContextMenuVC {
let shouldShowEmojiActions: Bool = {
if cellViewModel.threadVariant == .community {
return OpenGroupManager.isOpenGroupSupport(.reactions, on: cellViewModel.threadOpenGroupServer)
return OpenGroupManager.doesOpenGroupSupport(
capability: .reactions,
on: cellViewModel.threadOpenGroupServer
)
}
return !currentThreadIsMessageRequest
}()

View File

@ -150,10 +150,17 @@ extension ConversationVC:
}
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) {
sendAttachments(attachments, with: messageText ?? "")
self.snInputView.text = ""
sendMessage(text: (messageText ?? ""), attachments: attachments)
resetMentions()
dismiss(animated: true) { }
dismiss(animated: true) { [weak self] in
if self?.isFirstResponder == false {
self?.becomeFirstResponder()
}
else {
self?.reloadInputViews()
}
}
}
func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? {
@ -167,13 +174,17 @@ extension ConversationVC:
// MARK: - AttachmentApprovalViewControllerDelegate
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) {
sendAttachments(attachments, with: messageText ?? "") { [weak self] in
self?.dismiss(animated: true, completion: nil)
}
scrollToBottom(isAnimated: false)
self.snInputView.text = ""
sendMessage(text: (messageText ?? ""), attachments: attachments)
resetMentions()
dismiss(animated: true) { [weak self] in
if self?.isFirstResponder == false {
self?.becomeFirstResponder()
}
else {
self?.reloadInputViews()
}
}
}
func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) {
@ -181,7 +192,7 @@ extension ConversationVC:
}
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageText newMessageText: String?) {
snInputView.text = newMessageText ?? ""
snInputView.text = (newMessageText ?? "")
}
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) {
@ -348,6 +359,7 @@ extension ConversationVC:
attachments: attachments,
approvalDelegate: self
)
navController.modalPresentationStyle = .fullScreen
present(navController, animated: true, completion: nil)
}
@ -369,7 +381,7 @@ extension ConversationVC:
modalActivityIndicator.dismiss {
guard !attachment.hasError else {
self?.showErrorAlert(for: attachment, onDismiss: nil)
self?.showErrorAlert(for: attachment)
return
}
@ -385,149 +397,33 @@ extension ConversationVC:
// MARK: --Message Sending
func handleSendButtonTapped() {
sendMessage()
}
func sendMessage(hasPermissionToSendSeed: Bool = false) {
guard !showBlockedModalIfNeeded() else { return }
let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines))
guard !text.isEmpty else { return }
if text.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed {
// Warn the user if they're about to send their seed to someone
let modal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: "modal_send_seed_title".localized(),
body: .text("modal_send_seed_explanation".localized()),
confirmTitle: "modal_send_seed_send_button_title".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text,
onConfirm: { [weak self] _ in self?.sendMessage(hasPermissionToSendSeed: true) }
)
)
return present(modal, animated: true, completion: nil)
}
// Clearing this out immediately to make this appear more snappy
DispatchQueue.main.async { [weak self] in
self?.snInputView.text = ""
self?.snInputView.quoteDraftInfo = nil
self?.resetMentions()
}
// Note: 'shouldBeVisible' is set to true the first time a thread is saved so we can
// use it to determine if the user is creating a new thread and update the 'isApproved'
// flags appropriately
let threadId: String = self.viewModel.threadData.threadId
let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant
let oldThreadShouldBeVisible: Bool = (self.viewModel.threadData.threadShouldBeVisible == true)
let sentTimestampMs: Int64 = SnodeAPI.currentOffsetTimestampMs()
let linkPreviewDraft: LinkPreviewDraft? = snInputView.linkPreviewInfo?.draft
let quoteModel: QuotedReplyModel? = snInputView.quoteDraftInfo?.model
// If this was a message request then approve it
approveMessageRequestIfNeeded(
for: threadId,
threadVariant: self.viewModel.threadData.threadVariant,
isNewThread: !oldThreadShouldBeVisible,
timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting
sendMessage(
text: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines),
linkPreviewDraft: snInputView.linkPreviewInfo?.draft,
quoteModel: snInputView.quoteDraftInfo?.model
)
// Send the message
Storage.shared
.writePublisher { [weak self] db in
// Let the viewModel know we are about to send a message
self?.viewModel.sentMessageBeforeUpdate = true
// Update the thread to be visible (if it isn't already)
if self?.viewModel.threadData.threadShouldBeVisible == false {
_ = try SessionThread
.filter(id: threadId)
.updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true))
}
let authorId: String = {
if let blindedId = self?.viewModel.threadData.currentUserBlindedPublicKey {
return blindedId
}
return self?.viewModel.threadData.currentUserPublicKey ?? getUserHexEncodedPublicKey(db)
}()
// Create the interaction
let interaction: Interaction = try Interaction(
threadId: threadId,
authorId: authorId,
variant: .standardOutgoing,
body: text,
timestampMs: sentTimestampMs,
hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: text),
expiresInSeconds: try? DisappearingMessagesConfiguration
.select(.durationSeconds)
.filter(id: threadId)
.filter(DisappearingMessagesConfiguration.Columns.isEnabled == true)
.asRequest(of: TimeInterval.self)
.fetchOne(db),
linkPreviewUrl: linkPreviewDraft?.urlString
).inserted(db)
// If there is a LinkPreview and it doesn't match an existing one then add it now
if
let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft,
(try? interaction.linkPreview.isEmpty(db)) == true
{
try LinkPreview(
url: linkPreviewDraft.urlString,
title: linkPreviewDraft.title,
attachmentId: LinkPreview.saveAttachmentIfPossible(
db,
imageData: linkPreviewDraft.jpegImageData,
mimeType: OWSMimeTypeImageJpeg
)
).insert(db)
}
// If there is a Quote the insert it now
if let interactionId: Int64 = interaction.id, let quoteModel: QuotedReplyModel = quoteModel {
try Quote(
interactionId: interactionId,
authorId: quoteModel.authorId,
timestampMs: quoteModel.timestampMs,
body: quoteModel.body,
attachmentId: quoteModel.generateAttachmentThumbnailIfNeeded(db)
).insert(db)
}
try MessageSender.send(
db,
interaction: interaction,
threadId: threadId,
threadVariant: threadVariant
)
}
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.sinkUntilComplete(
receiveCompletion: { [weak self] _ in
self?.handleMessageSent()
}
)
}
func sendAttachments(_ attachments: [SignalAttachment], with text: String, hasPermissionToSendSeed: Bool = false, onComplete: (() -> ())? = nil) {
func sendMessage(
text: String,
attachments: [SignalAttachment] = [],
linkPreviewDraft: LinkPreviewDraft? = nil,
quoteModel: QuotedReplyModel? = nil,
hasPermissionToSendSeed: Bool = false
) {
guard !showBlockedModalIfNeeded() else { return }
for attachment in attachments {
if attachment.hasError {
return showErrorAlert(for: attachment, onDismiss: onComplete)
}
// Handle attachment errors if applicable
if let failedAttachment: SignalAttachment = attachments.first(where: { $0.hasError }) {
return showErrorAlert(for: failedAttachment)
}
let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines))
let processedText: String = replaceMentions(in: text.trimmingCharacters(in: .whitespacesAndNewlines))
if text.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed {
// If we have no content then do nothing
guard !processedText.isEmpty || !attachments.isEmpty else { return }
if processedText.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed {
// Warn the user if they're about to send their seed to someone
let modal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
@ -537,7 +433,13 @@ extension ConversationVC:
confirmStyle: .danger,
cancelStyle: .alert_text,
onConfirm: { [weak self] _ in
self?.sendAttachments(attachments, with: text, hasPermissionToSendSeed: true, onComplete: onComplete)
self?.sendMessage(
text: text,
attachments: attachments,
linkPreviewDraft: linkPreviewDraft,
quoteModel: quoteModel,
hasPermissionToSendSeed: true
)
}
)
)
@ -564,17 +466,24 @@ extension ConversationVC:
// If this was a message request then approve it
approveMessageRequestIfNeeded(
for: threadId,
threadVariant: self.viewModel.threadData.threadVariant,
threadVariant: threadVariant,
isNewThread: !oldThreadShouldBeVisible,
timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting
)
// Send the message
// Optimistically insert the outgoing message (this will trigger a UI update)
self.viewModel.sentMessageBeforeUpdate = true
let optimisticData: ConversationViewModel.OptimisticMessageData = self.viewModel.optimisticallyAppendOutgoingMessage(
text: processedText,
sentTimestampMs: sentTimestampMs,
attachments: attachments,
linkPreviewDraft: linkPreviewDraft,
quoteModel: quoteModel
)
// Actually send the message
Storage.shared
.writePublisher { [weak self] db in
// Let the viewModel know we are about to send a message
self?.viewModel.sentMessageBeforeUpdate = true
// Update the thread to be visible (if it isn't already)
if self?.viewModel.threadData.threadShouldBeVisible == false {
_ = try SessionThread
@ -582,35 +491,44 @@ extension ConversationVC:
.updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true))
}
// Create the interaction
let interaction: Interaction = try Interaction(
threadId: threadId,
authorId: getUserHexEncodedPublicKey(db),
variant: .standardOutgoing,
body: text,
timestampMs: sentTimestampMs,
hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: text),
expiresInSeconds: try? DisappearingMessagesConfiguration
.select(.durationSeconds)
.filter(id: threadId)
.filter(DisappearingMessagesConfiguration.Columns.isEnabled == true)
.asRequest(of: TimeInterval.self)
.fetchOne(db)
).inserted(db)
// Insert the interaction and associated it with the optimistically inserted message so
// we can remove it once the database triggers a UI update
let insertedInteraction: Interaction = try optimisticData.interaction.inserted(db)
self?.viewModel.associate(optimisticMessageId: optimisticData.id, to: insertedInteraction.id)
guard let interactionId: Int64 = interaction.id else { return }
// If there is a LinkPreview and it doesn't match an existing one then add it now
if
let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft,
(try? insertedInteraction.linkPreview.isEmpty(db)) == true
{
try LinkPreview(
url: linkPreviewDraft.urlString,
title: linkPreviewDraft.title,
attachmentId: try optimisticData.linkPreviewAttachment?.inserted(db).id
).insert(db)
}
// Prepare any attachments
try Attachment.prepare(
// If there is a Quote the insert it now
if let interactionId: Int64 = insertedInteraction.id, let quoteModel: QuotedReplyModel = quoteModel {
try Quote(
interactionId: interactionId,
authorId: quoteModel.authorId,
timestampMs: quoteModel.timestampMs,
body: quoteModel.body,
attachmentId: quoteModel.generateAttachmentThumbnailIfNeeded(db)
).insert(db)
}
// Process any attachments
try Attachment.process(
db,
attachments: attachments,
for: interactionId
data: optimisticData.attachmentData,
for: insertedInteraction.id
)
// Send the message
try MessageSender.send(
db,
interaction: interaction,
interaction: insertedInteraction,
threadId: threadId,
threadVariant: threadVariant
)
@ -619,11 +537,6 @@ extension ConversationVC:
.sinkUntilComplete(
receiveCompletion: { [weak self] _ in
self?.handleMessageSent()
// Attachment successfully sent - dismiss the screen
DispatchQueue.main.async {
onComplete?()
}
}
)
}
@ -1212,7 +1125,7 @@ extension ConversationVC:
guard cellViewModel.threadVariant == .community else { return }
Storage.shared
.readPublisherFlatMap { db -> AnyPublisher<(OpenGroupAPI.ReactionRemoveAllResponse, OpenGroupAPI.PendingChange), Error> in
.readPublisher { db -> (OpenGroupAPI.PreparedSendData<OpenGroupAPI.ReactionRemoveAllResponse>, OpenGroupAPI.PendingChange) in
guard
let openGroup: OpenGroup = try? OpenGroup
.fetchOne(db, id: cellViewModel.threadId),
@ -1223,6 +1136,14 @@ extension ConversationVC:
.fetchOne(db)
else { throw StorageError.objectNotFound }
let sendData: OpenGroupAPI.PreparedSendData<OpenGroupAPI.ReactionRemoveAllResponse> = try OpenGroupAPI
.preparedReactionDeleteAll(
db,
emoji: emoji,
id: openGroupServerMessageId,
in: openGroup.roomToken,
on: openGroup.server
)
let pendingChange: OpenGroupAPI.PendingChange = OpenGroupManager
.addPendingReaction(
emoji: emoji,
@ -1232,27 +1153,22 @@ extension ConversationVC:
type: .removeAll
)
return OpenGroupAPI
.reactionDeleteAll(
db,
emoji: emoji,
id: openGroupServerMessageId,
in: openGroup.roomToken,
on: openGroup.server
)
.map { _, response in (response, pendingChange) }
.eraseToAnyPublisher()
return (sendData, pendingChange)
}
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.handleEvents(
receiveOutput: { response, pendingChange in
OpenGroupManager
.updatePendingChange(
pendingChange,
seqNo: response.seqNo
)
}
)
.flatMap { sendData, pendingChange in
OpenGroupAPI.send(data: sendData)
.handleEvents(
receiveOutput: { _, response in
OpenGroupManager
.updatePendingChange(
pendingChange,
seqNo: response.seqNo
)
}
)
.eraseToAnyPublisher()
}
.sinkUntilComplete(
receiveCompletion: { _ in
Storage.shared.writeAsync { db in
@ -1266,14 +1182,16 @@ extension ConversationVC:
}
func react(_ cellViewModel: MessageViewModel, with emoji: String, remove: Bool) {
guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else {
return
}
let threadIsMessageRequest: Bool = (self.viewModel.threadData.threadIsMessageRequest == true)
guard !threadIsMessageRequest else { return }
guard
self.viewModel.threadData.threadIsMessageRequest != true && (
cellViewModel.variant == .standardIncoming ||
cellViewModel.variant == .standardOutgoing
)
else { return }
// Perform local rate limiting (don't allow more than 20 reactions within 60 seconds)
let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant
let openGroupRoom: String? = self.viewModel.threadData.openGroupRoomToken
let sentTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs()
let recentReactionTimestamps: [Int64] = General.cache.wrappedValue.recentReactionTimestamps
@ -1299,9 +1217,38 @@ extension ConversationVC:
.appending(sentTimestamp)
}
// Perform the sending logic
Storage.shared
.writePublisherFlatMap { [weak self] db -> AnyPublisher<MessageSender.PreparedSendData?, Error> in
typealias OpenGroupInfo = (
pendingReaction: Reaction?,
pendingChange: OpenGroupAPI.PendingChange,
sendData: OpenGroupAPI.PreparedSendData<Int64?>
)
/// Perform the sending logic, we generate the pending reaction first in a deferred future closure to prevent the OpenGroup
/// cache from blocking either the main thread or the database write thread
Deferred {
Future<OpenGroupAPI.PendingChange?, Error> { resolver in
guard
threadVariant == .community,
let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId,
let openGroupServer: String = cellViewModel.threadOpenGroupServer,
let openGroupPublicKey: String = cellViewModel.threadOpenGroupPublicKey
else { return resolver(Result.success(nil)) }
// Create the pending change if we have open group info
return resolver(Result.success(
OpenGroupManager.addPendingReaction(
emoji: emoji,
id: serverMessageId,
in: openGroupServer,
on: openGroupPublicKey,
type: (remove ? .remove : .add)
)
))
}
}
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.flatMap { pendingChange -> AnyPublisher<(MessageSender.PreparedSendData?, OpenGroupInfo?), Error> in
Storage.shared.writePublisher { [weak self] db -> (MessageSender.PreparedSendData?, OpenGroupInfo?) in
// Update the thread to be visible (if it isn't already)
if self?.viewModel.threadData.threadShouldBeVisible == false {
_ = try SessionThread
@ -1310,29 +1257,29 @@ extension ConversationVC:
}
let pendingReaction: Reaction? = {
if remove {
guard !remove else {
return try? Reaction
.filter(Reaction.Columns.interactionId == cellViewModel.id)
.filter(Reaction.Columns.authorId == cellViewModel.currentUserPublicKey)
.filter(Reaction.Columns.emoji == emoji)
.fetchOne(db)
} else {
let sortId = Reaction.getSortId(
db,
interactionId: cellViewModel.id,
emoji: emoji
)
return Reaction(
interactionId: cellViewModel.id,
serverHash: nil,
timestampMs: sentTimestamp,
authorId: cellViewModel.currentUserPublicKey,
emoji: emoji,
count: 1,
sortId: sortId
)
}
let sortId: Int64 = Reaction.getSortId(
db,
interactionId: cellViewModel.id,
emoji: emoji
)
return Reaction(
interactionId: cellViewModel.id,
serverHash: nil,
timestampMs: sentTimestamp,
authorId: cellViewModel.currentUserPublicKey,
emoji: emoji,
count: 1,
sortId: sortId
)
}()
// Update the database
@ -1350,125 +1297,108 @@ extension ConversationVC:
Emoji.addRecent(db, emoji: emoji)
}
// If it's not an OpenGroup then send the message directly to the thread
guard
let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: cellViewModel.threadId),
OpenGroupManager.isOpenGroupSupport(.reactions, on: openGroup.server)
else {
let sendData: MessageSender.PreparedSendData = try MessageSender.preparedSendData(
db,
message: VisibleMessage(
sentTimestamp: UInt64(sentTimestamp),
text: nil,
reaction: VisibleMessage.VMReaction(
timestamp: UInt64(cellViewModel.timestampMs),
publicKey: {
guard cellViewModel.variant == .standardIncoming else {
return cellViewModel.currentUserPublicKey
}
return cellViewModel.authorId
}(),
emoji: emoji,
kind: (remove ? .remove : .react)
)
),
to: try Message.Destination
.from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant),
namespace: try Message.Destination
.from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant)
.defaultNamespace,
interactionId: cellViewModel.id
)
return Just(sendData)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
// Otherwise we need to make an API call to the OpenGroup
// Send reaction to open groups
guard
let openGroupServerMessageId: Int64 = try? Interaction
.select(.openGroupServerMessageId)
.filter(id: cellViewModel.id)
.asRequest(of: Int64.self)
.fetchOne(db)
else { throw MessageSenderError.invalidMessage }
let pendingChange: OpenGroupAPI.PendingChange = OpenGroupManager
.addPendingReaction(
emoji: emoji,
id: openGroupServerMessageId,
in: openGroup.roomToken,
on: openGroup.server,
type: (remove ? .remove : .add)
)
let request: AnyPublisher<Int64?, Error> = {
switch remove {
case true:
return OpenGroupAPI
.reactionDelete(
db,
emoji: emoji,
id: openGroupServerMessageId,
in: openGroup.roomToken,
on: openGroup.server
)
.map { _, response in response.seqNo }
.eraseToAnyPublisher()
case false:
return OpenGroupAPI
.reactionAdd(
db,
emoji: emoji,
id: openGroupServerMessageId,
in: openGroup.roomToken,
on: openGroup.server
)
.map { _, response in response.seqNo }
.eraseToAnyPublisher()
}
}()
return request
.handleEvents(
receiveOutput: { seqNo in
OpenGroupManager
.updatePendingChange(
pendingChange,
seqNo: seqNo
)
},
receiveCompletion: { [weak self] result in
switch result {
case .finished: break
case .failure:
OpenGroupManager.removePendingChange(pendingChange)
self?.handleReactionSentFailure(
pendingReaction,
remove: remove
switch threadVariant {
case .community:
guard
let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId,
let openGroupServer: String = cellViewModel.threadOpenGroupServer,
let openGroupRoom: String = openGroupRoom,
let pendingChange: OpenGroupAPI.PendingChange = pendingChange,
OpenGroupManager.doesOpenGroupSupport(db, capability: .reactions, on: openGroupServer)
else { throw MessageSenderError.invalidMessage }
let sendData: OpenGroupAPI.PreparedSendData<Int64?> = try {
guard !remove else {
return try OpenGroupAPI
.preparedReactionDelete(
db,
emoji: emoji,
id: serverMessageId,
in: openGroupRoom,
on: openGroupServer
)
.map { _, response in response.seqNo }
}
}
)
.map { _ in nil }
.eraseToAnyPublisher()
}
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.flatMap { maybeSendData -> AnyPublisher<Void, Error> in
guard let sendData: MessageSender.PreparedSendData = maybeSendData else {
return Just(())
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
return try OpenGroupAPI
.preparedReactionAdd(
db,
emoji: emoji,
id: serverMessageId,
in: openGroupRoom,
on: openGroupServer
)
.map { _, response in response.seqNo }
}()
return (nil, (pendingReaction, pendingChange, sendData))
default:
let sendData: MessageSender.PreparedSendData = try MessageSender.preparedSendData(
db,
message: VisibleMessage(
sentTimestamp: UInt64(sentTimestamp),
text: nil,
reaction: VisibleMessage.VMReaction(
timestamp: UInt64(cellViewModel.timestampMs),
publicKey: {
guard cellViewModel.variant == .standardIncoming else {
return cellViewModel.currentUserPublicKey
}
return cellViewModel.authorId
}(),
emoji: emoji,
kind: (remove ? .remove : .react)
)
),
to: try Message.Destination
.from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant),
namespace: try Message.Destination
.from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant)
.defaultNamespace,
interactionId: cellViewModel.id
)
return (sendData, nil)
}
return MessageSender.sendImmediate(preparedSendData: sendData)
}
.sinkUntilComplete()
}
.tryFlatMap { messageSendData, openGroupInfo -> AnyPublisher<Void, Error> in
switch (messageSendData, openGroupInfo) {
case (.some(let sendData), _):
return MessageSender.sendImmediate(preparedSendData: sendData)
case (_, .some(let info)):
return OpenGroupAPI.send(data: info.sendData)
.handleEvents(
receiveOutput: { _, seqNo in
OpenGroupManager
.updatePendingChange(
info.pendingChange,
seqNo: seqNo
)
},
receiveCompletion: { [weak self] result in
switch result {
case .finished: break
case .failure:
OpenGroupManager.removePendingChange(info.pendingChange)
self?.handleReactionSentFailure(
info.pendingReaction,
remove: remove
)
}
}
)
.map { _ in () }
.eraseToAnyPublisher()
default: throw MessageSenderError.invalidMessage
}
}
.sinkUntilComplete()
}
func handleReactionSentFailure(_ pendingReaction: Reaction?, remove: Bool) {
@ -1891,16 +1821,18 @@ extension ConversationVC:
// Delete the message from the open group
deleteRemotely(
from: self,
request: Storage.shared.readPublisherFlatMap { db in
OpenGroupAPI.messageDelete(
db,
id: openGroupServerMessageId,
in: openGroup.roomToken,
on: openGroup.server
)
request: Storage.shared
.readPublisher { db in
try OpenGroupAPI.preparedMessageDelete(
db,
id: openGroupServerMessageId,
in: openGroup.roomToken,
on: openGroup.server
)
}
.flatMap { OpenGroupAPI.send(data: $0) }
.map { _ in () }
.eraseToAnyPublisher()
}
) { [weak self] in
self?.showInputAccessoryView()
}
@ -2100,21 +2032,20 @@ extension ConversationVC:
cancelStyle: .alert_text,
onConfirm: { [weak self] _ in
Storage.shared
.readPublisherFlatMap { db -> AnyPublisher<Void, Error> in
.readPublisher { db -> OpenGroupAPI.PreparedSendData<NoResponse> in
guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else {
throw StorageError.objectNotFound
}
return OpenGroupAPI
.userBan(
return try OpenGroupAPI
.preparedUserBan(
db,
sessionId: cellViewModel.authorId,
from: [openGroup.roomToken],
on: openGroup.server
)
.map { _ in () }
.eraseToAnyPublisher()
}
.flatMap { OpenGroupAPI.send(data: $0) }
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sinkUntilComplete(
@ -2316,11 +2247,11 @@ extension ConversationVC:
let attachment = SignalAttachment.voiceMessageAttachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4Audio as String)
guard !attachment.hasError else {
return showErrorAlert(for: attachment, onDismiss: nil)
return showErrorAlert(for: attachment)
}
// Send attachment
sendAttachments([ attachment ], with: "")
sendMessage(text: "", attachments: [attachment])
}
func cancelVoiceMessageRecording() {
@ -2360,15 +2291,14 @@ extension ConversationVC:
// MARK: - Convenience
func showErrorAlert(for attachment: SignalAttachment, onDismiss: (() -> ())?) {
func showErrorAlert(for attachment: SignalAttachment) {
let modal: ConfirmationModal = ConfirmationModal(
targetView: self.view,
info: ConfirmationModal.Info(
title: "ATTACHMENT_ERROR_ALERT_TITLE".localized(),
body: .text(attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage),
cancelTitle: "BUTTON_OK".localized(),
cancelStyle: .alert_text,
afterClosed: onDismiss
cancelStyle: .alert_text
)
)
self.present(modal, animated: true)

View File

@ -890,6 +890,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
guard !didSendMessageBeforeUpdate && !wasOnlyUpdates else {
self.viewModel.updateInteractionData(updatedData)
self.tableView.reloadData()
self.tableView.layoutIfNeeded()
// If we just sent a message then we want to jump to the bottom of the conversation instantly
if didSendMessageBeforeUpdate {

View File

@ -176,9 +176,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
public lazy var observableThreadData: ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> = setupObservableThreadData(for: self.threadId)
public typealias ThreadObservation = ValueObservation<ValueReducers.Trace<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>>>
public lazy var observableThreadData: ThreadObservation = setupObservableThreadData(for: self.threadId)
private func setupObservableThreadData(for threadId: String) -> ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> {
private func setupObservableThreadData(for threadId: String) -> ThreadObservation {
return ValueObservation
.trackingConstantRegion { [weak self] db -> SessionThreadViewModel? in
let userPublicKey: String = getUserHexEncodedPublicKey(db)
@ -197,6 +198,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
}
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[ConversationViewModel] Observation failed with error: \($0)") })
}
public func updateThreadData(_ updatedData: SessionThreadViewModel) {
@ -314,8 +316,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
)
],
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
self?.resolveOptimisticUpdates(with: updatedData)
PagedData.processAndTriggerUpdates(
updatedData: self?.process(data: updatedData, for: updatedPageInfo),
updatedData: self?.process(
data: updatedData,
for: updatedPageInfo,
optimisticMessages: (self?.optimisticallyInsertedMessages.wrappedValue.values)
.map { Array($0) },
initialUnreadInteractionId: self?.initialUnreadInteractionId
),
currentDataRetriever: { self?.interactionData },
onDataChange: self?.onInteractionChange,
onUnobservedDataChange: { updatedData, changeset in
@ -329,11 +339,16 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
)
}
private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
let initialUnreadInteractionId: Int64? = self.initialUnreadInteractionId
private func process(
data: [MessageViewModel],
for pageInfo: PagedData.PageInfo,
optimisticMessages: [MessageViewModel]?,
initialUnreadInteractionId: Int64?
) -> [SectionModel] {
let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true })
let sortedData: [MessageViewModel] = data
.filter { $0.isTypingIndicator != true }
.appending(contentsOf: (optimisticMessages ?? []))
.filter { !$0.cellType.isPostProcessed }
.sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs }
// We load messages from newest to oldest so having a pageOffset larger than zero means
@ -408,12 +423,151 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
self.interactionData = updatedData
}
public func expandReactions(for interactionId: Int64) {
reactionExpandedInteractionIds.insert(interactionId)
// MARK: - Optimistic Message Handling
public typealias OptimisticMessageData = (
id: UUID,
interaction: Interaction,
attachmentData: Attachment.PreparedData?,
linkPreviewAttachment: Attachment?
)
private var optimisticallyInsertedMessages: Atomic<[UUID: MessageViewModel]> = Atomic([:])
private var optimisticMessageAssociatedInteractionIds: Atomic<[Int64: UUID]> = Atomic([:])
public func optimisticallyAppendOutgoingMessage(
text: String?,
sentTimestampMs: Int64,
attachments: [SignalAttachment]?,
linkPreviewDraft: LinkPreviewDraft?,
quoteModel: QuotedReplyModel?
) -> OptimisticMessageData {
// Generate the optimistic data
let optimisticMessageId: UUID = UUID()
let currentUserProfile: Profile = Profile.fetchOrCreateCurrentUser()
let interaction: Interaction = Interaction(
threadId: threadData.threadId,
authorId: (threadData.currentUserBlindedPublicKey ?? threadData.currentUserPublicKey),
variant: .standardOutgoing,
body: text,
timestampMs: sentTimestampMs,
hasMention: Interaction.isUserMentioned(
publicKeysToCheck: [
threadData.currentUserPublicKey,
threadData.currentUserBlindedPublicKey
].compactMap { $0 },
body: text
),
expiresInSeconds: threadData.disappearingMessagesConfiguration
.map { disappearingConfig in
guard disappearingConfig.isEnabled else { return nil }
return disappearingConfig.durationSeconds
},
linkPreviewUrl: linkPreviewDraft?.urlString
)
let optimisticAttachments: Attachment.PreparedData? = attachments
.map { Attachment.prepare(attachments: $0) }
let linkPreviewAttachment: Attachment? = linkPreviewDraft.map { draft in
try? LinkPreview.generateAttachmentIfPossible(
imageData: draft.jpegImageData,
mimeType: OWSMimeTypeImageJpeg
)
}
let optimisticData: OptimisticMessageData = (
optimisticMessageId,
interaction,
optimisticAttachments,
linkPreviewAttachment
)
// Generate the actual 'MessageViewModel'
let messageViewModel: MessageViewModel = MessageViewModel(
threadId: threadData.threadId,
threadVariant: threadData.threadVariant,
threadHasDisappearingMessagesEnabled: (threadData.disappearingMessagesConfiguration?.isEnabled ?? false),
threadOpenGroupServer: threadData.openGroupServer,
threadOpenGroupPublicKey: threadData.openGroupPublicKey,
threadContactNameInternal: threadData.threadContactName(),
timestampMs: interaction.timestampMs,
receivedAtTimestampMs: interaction.receivedAtTimestampMs,
authorId: interaction.authorId,
authorNameInternal: currentUserProfile.displayName(),
body: interaction.body,
expiresStartedAtMs: interaction.expiresStartedAtMs,
expiresInSeconds: interaction.expiresInSeconds,
isSenderOpenGroupModerator: OpenGroupManager.isUserModeratorOrAdmin(
threadData.currentUserPublicKey,
for: threadData.openGroupRoomToken,
on: threadData.openGroupServer
),
currentUserProfile: currentUserProfile,
quote: quoteModel.map { model in
// Don't care about this optimistic quote (the proper one will be generated in the database)
Quote(
interactionId: -1, // Can't save to db optimistically
authorId: model.authorId,
timestampMs: model.timestampMs,
body: model.body,
attachmentId: model.attachment?.id
)
},
quoteAttachment: quoteModel?.attachment,
linkPreview: linkPreviewDraft.map { draft in
LinkPreview(
url: draft.urlString,
title: draft.title,
attachmentId: nil // Can't save to db optimistically
)
},
linkPreviewAttachment: linkPreviewAttachment,
attachments: optimisticAttachments?.attachments
)
optimisticallyInsertedMessages.mutate { $0[optimisticMessageId] = messageViewModel }
// If we can't get the current page data then don't bother trying to update (it's not going to work)
guard let currentPageInfo: PagedData.PageInfo = self.pagedDataObserver?.pageInfo.wrappedValue else {
return optimisticData
}
/// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above
let currentData: [SectionModel] = (unobservedInteractionDataChanges?.0 ?? interactionData)
PagedData.processAndTriggerUpdates(
updatedData: process(
data: (currentData.first(where: { $0.model == .messages })?.elements ?? []),
for: currentPageInfo,
optimisticMessages: Array(optimisticallyInsertedMessages.wrappedValue.values),
initialUnreadInteractionId: initialUnreadInteractionId
),
currentDataRetriever: { [weak self] in self?.interactionData },
onDataChange: self.onInteractionChange,
onUnobservedDataChange: { [weak self] updatedData, changeset in
self?.unobservedInteractionDataChanges = (changeset.isEmpty ?
nil :
(updatedData, changeset)
)
}
)
return optimisticData
}
public func collapseReactions(for interactionId: Int64) {
reactionExpandedInteractionIds.remove(interactionId)
/// Record an association between an `optimisticMessageId` and a specific `interactionId`
public func associate(optimisticMessageId: UUID, to interactionId: Int64?) {
guard let interactionId: Int64 = interactionId else { return }
optimisticMessageAssociatedInteractionIds.mutate { $0[interactionId] = optimisticMessageId }
}
/// Remove any optimisticUpdate entries which have an associated interactionId in the provided data
private func resolveOptimisticUpdates(with data: [MessageViewModel]) {
let interactionIds: [Int64] = data.map { $0.id }
let idsToRemove: [UUID] = optimisticMessageAssociatedInteractionIds
.mutate { associatedIds in interactionIds.compactMap { associatedIds.removeValue(forKey: $0) } }
optimisticallyInsertedMessages.mutate { messages in idsToRemove.forEach { messages.removeValue(forKey: $0) } }
}
// MARK: - Mentions
@ -575,6 +729,14 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
}
}
public func expandReactions(for interactionId: Int64) {
reactionExpandedInteractionIds.insert(interactionId)
}
public func collapseReactions(for interactionId: Int64) {
reactionExpandedInteractionIds.remove(interactionId)
}
// MARK: - Audio Playback
public struct PlaybackInfo {

View File

@ -332,6 +332,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
// Build the link preview
LinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] result in

View File

@ -119,17 +119,6 @@ final class QuoteView: UIView {
contentView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: self)
contentView.rightAnchor.constraint(lessThanOrEqualTo: self.rightAnchor).isActive = true
// Line view
let lineColor: ThemeValue = {
switch mode {
case .regular: return (direction == .outgoing ? .messageBubble_outgoingText : .primary)
case .draft: return .primary
}
}()
let lineView = UIView()
lineView.themeBackgroundColor = lineColor
lineView.set(.width, to: Values.accentLineThickness)
if let attachment: Attachment = attachment {
let isAudio: Bool = MIMETypeUtil.isAudio(attachment.contentType)
let fallbackImageName: String = (isAudio ? "attachment_audio" : "actionsheet_document_black")
@ -181,13 +170,26 @@ final class QuoteView: UIView {
}
}
else {
// Line view
let lineColor: ThemeValue = {
switch mode {
case .regular: return (direction == .outgoing ? .messageBubble_outgoingText : .primary)
case .draft: return .primary
}
}()
let lineView = UIView()
lineView.themeBackgroundColor = lineColor
mainStackView.addArrangedSubview(lineView)
lineView.pin(.top, to: .top, of: mainStackView)
lineView.pin(.bottom, to: .bottom, of: mainStackView)
lineView.set(.width, to: Values.accentLineThickness)
}
// Body label
let bodyLabel = TappableLabel()
bodyLabel.numberOfLines = 0
bodyLabel.lineBreakMode = .byTruncatingTail
bodyLabel.numberOfLines = 2
let targetThemeColor: ThemeValue = {
switch mode {
@ -229,7 +231,6 @@ final class QuoteView: UIView {
// Label stack view
let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace)
var authorLabelHeight: CGFloat?
let isCurrentUser: Bool = [
currentUserPublicKey,
@ -259,16 +260,12 @@ final class QuoteView: UIView {
authorLabel.themeTextColor = targetThemeColor
authorLabel.lineBreakMode = .byTruncatingTail
authorLabel.isHidden = (authorLabel.text == nil)
let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace)
authorLabel.set(.height, to: authorLabelSize.height)
authorLabelHeight = authorLabelSize.height
authorLabel.numberOfLines = 1
let labelStackView = UIStackView(arrangedSubviews: [ authorLabel, bodyLabel ])
labelStackView.axis = .vertical
labelStackView.spacing = labelStackViewSpacing
labelStackView.distribution = .equalCentering
labelStackView.set(.width, to: max(bodyLabelSize.width, authorLabelSize.width))
labelStackView.isLayoutMarginsRelativeArrangement = true
labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0)
mainStackView.addArrangedSubview(labelStackView)
@ -277,29 +274,6 @@ final class QuoteView: UIView {
contentView.addSubview(mainStackView)
mainStackView.pin(to: contentView)
if threadVariant == .contact {
bodyLabel.set(.width, to: bodyLabelSize.width)
}
let bodyLabelHeight = bodyLabelSize.height.clamp(0, (mode == .regular ? 60 : 40))
let contentViewHeight: CGFloat
if attachment != nil {
contentViewHeight = thumbnailSize + 8 // Add a small amount of spacing above and below the thumbnail
bodyLabel.set(.height, to: 18) // Experimentally determined
}
else {
if let authorLabelHeight = authorLabelHeight { // Group thread
contentViewHeight = bodyLabelHeight + (authorLabelHeight + labelStackViewSpacing) + 2 * labelStackViewVMargin
}
else {
contentViewHeight = bodyLabelHeight + 2 * smallSpacing
}
}
contentView.set(.height, to: contentViewHeight)
lineView.set(.height, to: contentViewHeight - 8) // Add a small amount of spacing above and below the line
if mode == .draft {
// Cancel button
let cancelButton = UIButton(type: .custom)

View File

@ -149,6 +149,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadD
]
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[ThreadDisappearingMessageSettingsViewModel] Observation failed with error: \($0)") })
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
.mapToSessionTableViewData(for: self)

View File

@ -709,6 +709,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
]
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[ThreadSettingsViewModel] Observation failed with error: \($0)") })
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
.mapToSessionTableViewData(for: self)

View File

@ -20,7 +20,7 @@ public class HomeViewModel {
// MARK: - Variables
public static let pageSize: Int = 15
public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15)
public struct State: Equatable {
let showViewedSeedBanner: Bool
@ -231,6 +231,7 @@ public class HomeViewModel {
public lazy var observableState = ValueObservation
.trackingConstantRegion { db -> State in try HomeViewModel.retrieveState(db) }
.removeDuplicates()
.handleEvents(didFail: { SNLog("[HomeViewModel] Observation failed with error: \($0)") })
private static func retrieveState(_ db: Database) throws -> State {
let hasViewedSeed: Bool = db[.hasViewedSeed]

View File

@ -17,7 +17,7 @@ public class MessageRequestsViewModel {
// MARK: - Variables
public static let pageSize: Int = 15
public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15)
// MARK: - Initialization

View File

@ -210,6 +210,7 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle
.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in
SnodeAPI
.getSessionID(for: onsNameOrPublicKey)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sinkUntilComplete(
receiveCompletion: { result in

View File

@ -360,6 +360,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
cell
.requestRenditionForSending()
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] result in
@ -490,6 +491,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
assert(searchBar.text == nil || searchBar.text?.count == 0)
GiphyAPI.trending()
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { result in
@ -527,6 +529,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
GiphyAPI
.search(query: query)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] result in

View File

@ -291,7 +291,6 @@ enum GiphyAPI {
return urlSession
.dataTaskPublisher(for: url)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.mapError { urlError in
Logger.error("search request failed: \(urlError)")
@ -340,7 +339,6 @@ enum GiphyAPI {
return urlSession
.dataTaskPublisher(for: request)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.mapError { urlError in
Logger.error("search request failed: \(urlError)")

View File

@ -360,7 +360,7 @@ public class MediaGalleryViewModel {
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
public typealias AlbumObservation = ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<[Item]>>>
public typealias AlbumObservation = ValueObservation<ValueReducers.Trace<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<[Item]>>>>
public lazy var observableAlbumData: AlbumObservation = buildAlbumObservation(for: nil)
private func buildAlbumObservation(for interactionId: Int64?) -> AlbumObservation {
@ -383,6 +383,7 @@ public class MediaGalleryViewModel {
.fetchAll(db)
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[MediaGalleryViewModel] Observation failed with error: \($0)") })
}
@discardableResult public func loadAndCacheAlbumData(for interactionId: Int64, in threadId: String) -> [Item] {

View File

@ -84,7 +84,7 @@ class PhotoCapture: NSObject {
func startCapture() -> AnyPublisher<Void, Error> {
return Just(())
.subscribe(on: sessionQueue)
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
.setFailureType(to: Error.self)
.tryMap { [weak self] _ -> Void in
self?.session.beginConfiguration()
@ -136,7 +136,7 @@ class PhotoCapture: NSObject {
func stopCapture() -> AnyPublisher<Void, Never> {
return Just(())
.subscribe(on: sessionQueue)
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
.handleEvents(
receiveOutput: { [weak self] in self?.session.stopRunning() }
)
@ -160,7 +160,7 @@ class PhotoCapture: NSObject {
return Just(())
.setFailureType(to: Error.self)
.subscribe(on: sessionQueue)
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
.tryMap { [weak self, newPosition = self.desiredPosition] _ -> Void in
self?.session.beginConfiguration()
defer { self?.session.commitConfiguration() }
@ -196,7 +196,7 @@ class PhotoCapture: NSObject {
func switchFlashMode() -> AnyPublisher<Void, Never> {
return Just(())
.subscribe(on: sessionQueue)
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
.handleEvents(
receiveOutput: { [weak self] _ in
switch self?.captureOutput.flashMode {
@ -351,7 +351,7 @@ extension PhotoCapture: CaptureButtonDelegate {
Logger.verbose("")
Just(())
.subscribe(on: sessionQueue)
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
.sinkUntilComplete(
receiveCompletion: { [weak self] _ in
guard let strongSelf = self else { return }

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
class MediaDismissAnimationController: NSObject {
private let mediaItem: Media
@ -46,6 +47,18 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning
switch fromVC {
case let contextProvider as MediaPresentationContextProvider:
fromContextProvider = contextProvider
case let topBannerController as TopBannerController:
guard
let firstChild: UIViewController = topBannerController.children.first,
let navController: UINavigationController = firstChild as? UINavigationController,
let contextProvider = navController.topViewController as? MediaPresentationContextProvider
else {
transitionContext.completeTransition(false)
return
}
fromContextProvider = contextProvider
case let navController as UINavigationController:
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
@ -64,6 +77,19 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning
case let contextProvider as MediaPresentationContextProvider:
toVC.view.layoutIfNeeded()
toContextProvider = contextProvider
case let topBannerController as TopBannerController:
guard
let firstChild: UIViewController = topBannerController.children.first,
let navController: UINavigationController = firstChild as? UINavigationController,
let contextProvider = navController.topViewController as? MediaPresentationContextProvider
else {
transitionContext.completeTransition(false)
return
}
toVC.view.layoutIfNeeded()
toContextProvider = contextProvider
case let navController as UINavigationController:
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
class MediaZoomAnimationController: NSObject {
private let mediaItem: Media
@ -34,6 +35,18 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning {
switch fromVC {
case let contextProvider as MediaPresentationContextProvider:
fromContextProvider = contextProvider
case let topBannerController as TopBannerController:
guard
let firstChild: UIViewController = topBannerController.children.first,
let navController: UINavigationController = firstChild as? UINavigationController,
let contextProvider = navController.topViewController as? MediaPresentationContextProvider
else {
transitionContext.completeTransition(false)
return
}
fromContextProvider = contextProvider
case let navController as UINavigationController:
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
@ -51,6 +64,18 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning {
switch toVC {
case let contextProvider as MediaPresentationContextProvider:
toContextProvider = contextProvider
case let topBannerController as TopBannerController:
guard
let firstChild: UIViewController = topBannerController.children.first,
let navController: UINavigationController = firstChild as? UINavigationController,
let contextProvider = navController.topViewController as? MediaPresentationContextProvider
else {
transitionContext.completeTransition(false)
return
}
toContextProvider = contextProvider
case let navController as UINavigationController:
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {

View File

@ -332,12 +332,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
}
}
private func showFailedMigrationAlert(calledFrom lifecycleMethod: LifecycleMethod, error: Error?) {
private func showFailedMigrationAlert(
calledFrom lifecycleMethod: LifecycleMethod,
error: Error?,
isRestoreError: Bool = false
) {
let alert = UIAlertController(
title: "Session",
message: {
switch (error ?? StorageError.generic) {
case StorageError.startupFailed: return "DATABASE_STARTUP_FAILED".localized()
switch (isRestoreError, (error ?? StorageError.generic)) {
case (true, _): return "DATABASE_RESTORE_FAILED".localized()
case (_, StorageError.startupFailed): return "DATABASE_STARTUP_FAILED".localized()
default: return "DATABASE_MIGRATION_FAILED".localized()
}
}(),
@ -348,32 +353,45 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
self?.showFailedMigrationAlert(calledFrom: lifecycleMethod, error: error)
}
})
alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in
// Remove the legacy database and any message hashes that have been migrated to the new DB
try? SUKLegacy.deleteLegacyDatabaseFilesAndKey()
Storage.shared.write { db in
try SnodeReceivedMessageInfo.deleteAll(db)
}
// The re-run the migration (should succeed since there is no data)
AppSetup.runPostSetupMigrations(
migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in
self?.loadingViewController?.updateProgress(
progress: progress,
minEstimatedTotalTime: minEstimatedTotalTime
)
},
migrationsCompletion: { [weak self] result, needsConfigSync in
if case .failure(let error) = result {
self?.showFailedMigrationAlert(calledFrom: lifecycleMethod, error: error)
return
}
// Only offer the 'Restore' option if the user hasn't already tried to restore
if !isRestoreError {
alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in
if SUKLegacy.hasLegacyDatabaseFile {
// Remove the legacy database and any message hashes that have been migrated to the new DB
try? SUKLegacy.deleteLegacyDatabaseFilesAndKey()
self?.completePostMigrationSetup(calledFrom: lifecycleMethod, needsConfigSync: needsConfigSync)
Storage.shared.write { db in
try SnodeReceivedMessageInfo.deleteAll(db)
}
}
)
})
else {
// If we don't have a legacy database then reset the current database for a clean migration
Storage.resetForCleanMigration()
}
// Hide the top banner if there was one
TopBannerController.hide()
// The re-run the migration (should succeed since there is no data)
AppSetup.runPostSetupMigrations(
migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in
self?.loadingViewController?.updateProgress(
progress: progress,
minEstimatedTotalTime: minEstimatedTotalTime
)
},
migrationsCompletion: { [weak self] result, needsConfigSync in
if case .failure(let error) = result {
self?.showFailedMigrationAlert(calledFrom: lifecycleMethod, error: error, isRestoreError: true)
return
}
self?.completePostMigrationSetup(calledFrom: lifecycleMethod, needsConfigSync: needsConfigSync)
}
)
})
}
alert.addAction(UIAlertAction(title: "Close", style: .default) { _ in
DDLog.flushLog()
@ -612,12 +630,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
public func startPollersIfNeeded(shouldStartGroupPollers: Bool = true) {
guard Identity.userExists() else { return }
poller.start()
guard shouldStartGroupPollers else { return }
ClosedGroupPoller.shared.start()
OpenGroupManager.shared.startPolling()
/// There is a fun issue where if you launch without any valid paths then the pollers are guaranteed to fail their first poll due to
/// trying and failing to build paths without having the `SnodeAPI.snodePool` populated, by waiting for the
/// `JobRunner.blockingQueue` to complete we can have more confidence that paths won't fail to build incorrectly
JobRunner.afterBlockingQueue { [weak self] in
self?.poller.start()
guard shouldStartGroupPollers else { return }
ClosedGroupPoller.shared.start()
OpenGroupManager.shared.startPolling()
}
}
public func stopPollers(shouldStopUserPoller: Bool = true) {

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "لطفا بعدا دوباره تلاش کنید";
"LOADING_CONVERSATIONS" = "درحال بارگزاری پیام ها...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "هنگام بهینه‌سازی پایگاه داده خطایی روی داد\n\nشما می‌توانید گزارش‌های برنامه خود را صادر کنید تا بتوانید برای عیب‌یابی به اشتراک بگذارید یا می‌توانید دستگاه خود را بازیابی کنید\n\nهشدار: بازیابی دستگاه شما منجر به از دست رفتن داده‌های قدیمی‌تر از دو هفته می‌شود.";
"RECOVERY_PHASE_ERROR_GENERIC" = "مشکلی پیش آمد. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید.";
"RECOVERY_PHASE_ERROR_LENGTH" = "به نظر می رسد کلمات کافی وارد نکرده اید. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Veuillez réessayer plus tard";
"LOADING_CONVERSATIONS" = "Chargement des conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "Une erreur est survenue pendant l'optimisation de la base de données\n\nVous pouvez exporter votre journal d'application pour le partager et aider à régler le problème ou vous pouvez restaurer votre appareil\n\nAttention : restaurer votre appareil résultera en une perte des données des deux dernières semaines";
"RECOVERY_PHASE_ERROR_GENERIC" = "Quelque chose s'est mal passé. Vérifiez votre phrase de récupération et réessayez s'il vous plaît.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Il semble que vous n'avez pas saisi tous les mots. Vérifiez votre phrase de récupération et réessayez s'il vous plaît.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -415,6 +415,7 @@
"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later";
"LOADING_CONVERSATIONS" = "Loading Conversations...";
"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to be able to share for troubleshooting but to continue to use Session you may need to reinstall it";
"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks";
"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again.";
"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again.";

View File

@ -575,9 +575,7 @@ class NotificationActionHandler {
threadVariant: thread.variant
)
}
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.flatMap { MessageSender.sendImmediate(preparedSendData: $0) }
.receive(on: DispatchQueue.main)
.handleEvents(
receiveCompletion: { result in
switch result {

View File

@ -52,8 +52,9 @@ public enum PushRegistrationError: Error {
Logger.info("")
return registerUserNotificationSettings()
.setFailureType(to: Error.self)
.subscribe(on: DispatchQueue.global(qos: .default))
.receive(on: DispatchQueue.main) // MUST be on main thread
.setFailureType(to: Error.self)
.tryFlatMap { _ -> AnyPublisher<(pushToken: String, voipToken: String), Error> in
#if targetEnvironment(simulator)
throw PushRegistrationError.pushNotSupported(description: "Push not supported on simulators")

View File

@ -251,6 +251,8 @@ public class UserNotificationActionHandler: NSObject {
func handleNotificationResponse( _ response: UNNotificationResponse, completionHandler: @escaping () -> Void) {
AssertIsOnMainThread()
handleNotificationResponse(response)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sinkUntilComplete(
receiveCompletion: { result in
switch result {

View File

@ -189,7 +189,7 @@ final class DisplayNameVC: BaseVC {
// Try to save the user name but ignore the result
ProfileManager.updateLocal(
queue: DispatchQueue.global(qos: .default),
queue: .global(qos: .default),
profileName: displayName
)

View File

@ -36,14 +36,12 @@ enum Onboarding {
let userPublicKey: String = getUserHexEncodedPublicKey()
return SnodeAPI.getSwarm(for: userPublicKey)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.tryFlatMapWithRandomSnode { snode -> AnyPublisher<Void, Error> in
CurrentUserPoller
.poll(
namespaces: [.configUserProfile],
from: snode,
for: userPublicKey,
on: DispatchQueue.global(qos: .userInitiated),
// Note: These values mean the received messages will be
// processed immediately rather than async as part of a Job
calledFromBackgroundPoller: true,
@ -67,7 +65,6 @@ enum Onboarding {
namespaces: [.default],
from: snode,
for: userPublicKey,
on: DispatchQueue.global(qos: .userInitiated),
// Note: These values mean the received messages will be
// processed immediately rather than async as part of a Job
calledFromBackgroundPoller: true,
@ -215,7 +212,9 @@ enum Onboarding {
guard self != .register else { return }
// Fetch the
Onboarding.profileNamePublisher.sinkUntilComplete()
Onboarding.profileNamePublisher
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.sinkUntilComplete()
}
func completeRegistration() {

View File

@ -176,6 +176,7 @@ final class PNModeVC: BaseVC, OptionViewDelegate {
// If we don't have one then show a loading indicator and try to retrieve the existing name
ModalActivityIndicatorViewController.present(fromViewController: self) { [weak self, flow = self.flow] viewController in
Onboarding.profileNamePublisher
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.timeout(.seconds(15), scheduler: DispatchQueue.main, customError: { HTTPError.timeout })
.catch { _ -> AnyPublisher<String?, Error> in
SNLog("Onboarding failed to retrieve existing profile information")

View File

@ -143,7 +143,8 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle
widthAnchor.constraint(greaterThanOrEqualToConstant: OpenGroupSuggestionGrid.cellHeight).isActive = true
OpenGroupManager.getDefaultRoomsIfNeeded()
.receive(on: DispatchQueue.main)
.subscribe(on: DispatchQueue.global(qos: .default))
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
.sinkUntilComplete(
receiveCompletion: { [weak self] _ in self?.update() },
receiveValue: { [weak self] rooms in self?.rooms = rooms }
@ -336,7 +337,7 @@ extension OpenGroupSuggestionGrid {
.eraseToAnyPublisher()
)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receiveOnMain(immediately: true)
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
.sinkUntilComplete(
receiveValue: { [weak self] imageData, hasData in
guard hasData else {

View File

@ -104,6 +104,7 @@ class ConversationSettingsViewModel: SessionTableViewModel<NoNav, ConversationSe
]
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[ConversationSettingsViewModel] Observation failed with error: \($0)") })
.publisher(in: Storage.shared)
.mapToSessionTableViewData(for: self)
}

View File

@ -168,6 +168,7 @@ class HelpViewModel: SessionTableViewModel<NoNav, HelpViewModel.Section, HelpVie
#endif
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[HelpViewModel] Observation failed with error: \($0)") })
.publisher(in: Storage.shared)
.mapToSessionTableViewData(for: self)

View File

@ -69,6 +69,7 @@ class NotificationContentViewModel: SessionTableViewModel<NoNav, NotificationSet
]
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[NotificationContentViewModel] Observation failed with error: \($0)") })
.publisher(in: storage, scheduling: scheduler)
.mapToSessionTableViewData(for: self)
}

View File

@ -147,6 +147,7 @@ class NotificationSettingsViewModel: SessionTableViewModel<NoNav, NotificationSe
]
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[NotificationSettingsViewModel] Observation failed with error: \($0)") })
.publisher(in: Storage.shared)
.mapToSessionTableViewData(for: self)
}

View File

@ -146,6 +146,7 @@ class NotificationSoundViewModel: SessionTableViewModel<NotificationSoundViewMod
]
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[NotificationSoundViewModel] Observation failed with error: \($0)") })
.publisher(in: Storage.shared)
.mapToSessionTableViewData(for: self)

View File

@ -150,6 +150,7 @@ final class NukeDataModal: Modal {
private func clearDeviceOnly() {
ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { [weak self] _ in
ConfigurationSyncJob.run()
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sinkUntilComplete(
receiveCompletion: { _ in
@ -164,7 +165,7 @@ final class NukeDataModal: Modal {
ModalActivityIndicatorViewController
.present(fromViewController: presentedViewController, canCancel: false) { [weak self] _ in
SnodeAPI.deleteAllMessages(namespace: .all)
.subscribe(on: DispatchQueue.global(qos: .default))
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sinkUntilComplete(
receiveCompletion: { result in

View File

@ -232,6 +232,7 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N
]
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[PrivacySettingsViewModel] Observation failed with error: \($0)") })
.publisher(in: Storage.shared)
.mapToSessionTableViewData(for: self)
}

View File

@ -466,6 +466,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
]
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[SettingsViewModel] Observation failed with error: \($0)") })
.publisher(in: Storage.shared)
.mapToSessionTableViewData(for: self)
@ -572,7 +573,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
) {
let viewController = ModalActivityIndicatorViewController(canCancel: false) { [weak self] modalActivityIndicator in
ProfileManager.updateLocal(
queue: DispatchQueue.global(qos: .default),
queue: .global(qos: .default),
profileName: name,
avatarUpdate: avatarUpdate,
success: { db in

View File

@ -187,11 +187,12 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
private func startObservingChanges() {
// Start observing for data changes
dataChangeCancellable = viewModel.observableTableData
.receiveOnMain(
.receive(
on: DispatchQueue.main,
// If we haven't done the initial load the trigger it immediately (blocking the main
// thread so we remain on the launch screen until it completes to be consistent with
// the old behaviour)
immediately: !hasLoadedInitialTableData
immediatelyIfMain: !hasLoadedInitialTableData
)
.sink(
receiveCompletion: { [weak self] result in
@ -333,7 +334,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
.store(in: &disposables)
viewModel.leftNavItems
.receiveOnMain(immediately: true)
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
.sink { [weak self] maybeItems in
self?.navigationItem.setLeftBarButtonItems(
maybeItems.map { items in
@ -355,7 +356,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
.store(in: &disposables)
viewModel.rightNavItems
.receiveOnMain(immediately: true)
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
.sink { [weak self] maybeItems in
self?.navigationItem.setRightBarButtonItems(
maybeItems.map { items in
@ -377,21 +378,21 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect
.store(in: &disposables)
viewModel.emptyStateTextPublisher
.receiveOnMain(immediately: true)
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
.sink { [weak self] text in
self?.emptyStateLabel.text = text
}
.store(in: &disposables)
viewModel.footerView
.receiveOnMain(immediately: true)
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
.sink { [weak self] footerView in
self?.tableView.tableFooterView = footerView
}
.store(in: &disposables)
viewModel.footerButtonInfo
.receiveOnMain(immediately: true)
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
.sink { [weak self] buttonInfo in
if let buttonInfo: SessionButton.Info = buttonInfo {
self?.footerButton.setTitle(buttonInfo.title, for: .normal)

View File

@ -11,21 +11,32 @@ public final class BackgroundPoller {
private static var publishers: [AnyPublisher<Void, Error>] = []
public static var isValid: Bool = false
public static func poll(completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
public static func poll(
completionHandler: @escaping (UIBackgroundFetchResult) -> Void,
dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies(
subscribeQueue: .global(qos: .background),
receiveQueue: .main
)
) {
Publishers
.MergeMany(
[pollForMessages()]
.appending(contentsOf: pollForClosedGroupMessages())
[pollForMessages(using: dependencies)]
.appending(contentsOf: pollForClosedGroupMessages(using: dependencies))
.appending(
contentsOf: Storage.shared
.read { db in
// The default room promise creates an OpenGroup with an empty
// `roomToken` value, we don't want to start a poller for this
// as the user hasn't actually joined a room
/// The default room promise creates an OpenGroup with an empty `roomToken` value, we
/// don't want to start a poller for this as the user hasn't actually joined a room
///
/// We also want to exclude any rooms which have failed to poll too many times in a row from
/// the background poll as they are likely to fail again
try OpenGroup
.select(.server)
.filter(OpenGroup.Columns.roomToken != "")
.filter(OpenGroup.Columns.isActive)
.filter(
OpenGroup.Columns.roomToken != "" &&
OpenGroup.Columns.isActive &&
OpenGroup.Columns.pollFailureCount < OpenGroupAPI.Poller.maxRoomFailureCountForBackgroundPoll
)
.distinct()
.asRequest(of: String.self)
.fetchSet(db)
@ -38,13 +49,14 @@ public final class BackgroundPoller {
return poller.poll(
calledFromBackgroundPoller: true,
isBackgroundPollerValid: { BackgroundPoller.isValid },
isPostCapabilitiesRetry: false
isPostCapabilitiesRetry: false,
using: dependencies
)
}
)
)
.subscribeOnMain(immediately: true)
.receiveOnMain(immediately: true)
.subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true)
.receive(on: dependencies.receiveQueue, immediatelyIfMain: true)
.collect()
.sinkUntilComplete(
receiveCompletion: { result in
@ -61,27 +73,29 @@ public final class BackgroundPoller {
)
}
private static func pollForMessages() -> AnyPublisher<Void, Error> {
private static func pollForMessages(
using dependencies: OpenGroupManager.OGMDependencies
) -> AnyPublisher<Void, Error> {
let userPublicKey: String = getUserHexEncodedPublicKey()
return SnodeAPI.getSwarm(for: userPublicKey)
.subscribeOnMain(immediately: true)
.receiveOnMain(immediately: true)
.tryFlatMapWithRandomSnode { snode -> AnyPublisher<[Message], Error> in
CurrentUserPoller.poll(
namespaces: CurrentUserPoller.namespaces,
from: snode,
for: userPublicKey,
on: DispatchQueue.main,
calledFromBackgroundPoller: true,
isBackgroundPollValid: { BackgroundPoller.isValid }
isBackgroundPollValid: { BackgroundPoller.isValid },
using: dependencies
)
}
.map { _ in () }
.eraseToAnyPublisher()
}
private static func pollForClosedGroupMessages() -> [AnyPublisher<Void, Error>] {
private static func pollForClosedGroupMessages(
using dependencies: OpenGroupManager.OGMDependencies
) -> [AnyPublisher<Void, Error>] {
// Fetch all closed groups (excluding any don't contain the current user as a
// GroupMemeber as the user is no longer a member of those)
return Storage.shared
@ -98,8 +112,6 @@ public final class BackgroundPoller {
.defaulting(to: [])
.map { groupPublicKey in
SnodeAPI.getSwarm(for: groupPublicKey)
.subscribeOnMain(immediately: true)
.receiveOnMain(immediately: true)
.tryFlatMap { swarm -> AnyPublisher<[Message], Error> in
guard let snode: Snode = swarm.randomElement() else {
throw OnionRequestAPIError.insufficientSnodes
@ -109,9 +121,9 @@ public final class BackgroundPoller {
namespaces: ClosedGroupPoller.namespaces,
from: snode,
for: groupPublicKey,
on: DispatchQueue.main,
calledFromBackgroundPoller: true,
isBackgroundPollValid: { BackgroundPoller.isValid }
isBackgroundPollValid: { BackgroundPoller.isValid },
using: dependencies
)
}
.map { _ in () }

View File

@ -198,6 +198,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate {
)
}
.flatMap { MessageSender.sendImmediate(preparedSendData: $0) }
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.sinkUntilComplete(
receiveCompletion: { result in
switch result {
@ -263,6 +264,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate {
)
}
.flatMap { MessageSender.sendImmediate(preparedSendData: $0) }
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.sinkUntilComplete(
receiveCompletion: { result in
switch result {

View File

@ -994,11 +994,14 @@ extension Attachment {
}
}
public static func prepare(_ db: Database, attachments: [SignalAttachment], for interactionId: Int64) throws {
// Prepare any attachments
try attachments.enumerated()
.forEach { index, signalAttachment in
let maybeAttachment: Attachment? = Attachment(
public struct PreparedData {
public let attachments: [Attachment]
}
public static func prepare(attachments: [SignalAttachment]) -> PreparedData {
return PreparedData(
attachments: attachments.compactMap { signalAttachment in
Attachment(
variant: (signalAttachment.isVoiceMessage ?
.voiceMessage :
.standard
@ -1008,9 +1011,23 @@ extension Attachment {
sourceFilename: signalAttachment.sourceFilename,
caption: signalAttachment.captionText
)
}
)
}
public static func process(
_ db: Database,
data: PreparedData?,
for interactionId: Int64?
) throws {
guard
let data: PreparedData = data,
let interactionId: Int64 = interactionId
else { return }
guard let attachment: Attachment = maybeAttachment else { return }
try data.attachments
.enumerated()
.forEach { index, attachment in
let interactionAttachment: InteractionAttachment = InteractionAttachment(
albumIndex: index,
interactionId: interactionId,
@ -1042,7 +1059,7 @@ extension Attachment {
let attachmentId: String = self.id
return Storage.shared
.writePublisherFlatMap { db -> AnyPublisher<(String?, Data?, Data?), Error> in
.writePublisher { db -> (OpenGroupAPI.PreparedSendData<FileUploadResponse>?, String?, Data?, Data?) in
// If the attachment is a downloaded attachment, check if it came from
// the server and if so just succeed immediately (no use re-uploading
// an attachment that is already present on the server) - or if we want
@ -1062,9 +1079,7 @@ extension Attachment {
.filter(id: attachmentId)
.updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploaded))
return Just((Attachment.fileId(for: self.downloadUrl), nil, nil))
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
return (nil, Attachment.fileId(for: self.downloadUrl), nil, nil)
}
var encryptionKey: NSData = NSData()
@ -1089,42 +1104,41 @@ extension Attachment {
.filter(id: attachmentId)
.updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading))
switch destination {
case .openGroup(let openGroup):
return OpenGroupAPI
.uploadFile(
db,
bytes: data.bytes,
to: openGroup.roomToken,
on: openGroup.server
)
.map { _, response -> (String, Data?, Data?) in
(
response.id,
(destination.shouldEncrypt ? encryptionKey as Data : nil),
(destination.shouldEncrypt ? digest as Data : nil)
// We need database access for OpenGroup uploads so generate prepared data
let preparedSendData: OpenGroupAPI.PreparedSendData<FileUploadResponse>? = try {
switch destination {
case .openGroup(let openGroup):
return try OpenGroupAPI
.preparedUploadFile(
db,
bytes: data.bytes,
to: openGroup.roomToken,
on: openGroup.server
)
}
.eraseToAnyPublisher()
case .fileServer:
/// **Note:** FileServer uploads don't need database access so
return Just((
nil,
(destination.shouldEncrypt ? encryptionKey as Data : nil),
(destination.shouldEncrypt ? digest as Data : nil)
))
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
default: return nil
}
}()
return (
preparedSendData,
nil,
(destination.shouldEncrypt ? encryptionKey as Data : nil),
(destination.shouldEncrypt ? digest as Data : nil)
)
}
.flatMap { maybeFileId, encryptionKey, digest -> AnyPublisher<(String?, Data?, Data?), Error> in
.flatMap { preparedSendData, existingFileId, encryptionKey, digest -> AnyPublisher<(String?, Data?, Data?), Error> in
// No need to upload if the file was already uploaded
if let fileId: String = existingFileId {
return Just((fileId, encryptionKey, digest))
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
switch destination {
case .openGroup:
/// **Note:** OpenGroup uploads need database access so this should
/// have already been uploaded
return Just((maybeFileId, encryptionKey, digest))
.setFailureType(to: Error.self)
return OpenGroupAPI.send(data: preparedSendData)
.map { _, response -> (String, Data?, Data?) in (response.id, encryptionKey, digest) }
.eraseToAnyPublisher()
case .fileServer:

View File

@ -5,7 +5,7 @@ import GRDB
import SessionUtilitiesKit
import SessionSnodeKit
public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "disappearingMessagesConfiguration" }
internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id])
private static let thread = belongsTo(SessionThread.self, using: threadForeignKey)

View File

@ -319,7 +319,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
openGroupServerMessageId: Int64? = nil,
openGroupWhisperMods: Bool = false,
openGroupWhisperTo: String? = nil
) throws {
) {
self.serverHash = serverHash
self.messageUuid = messageUuid
self.threadId = threadId
@ -821,6 +821,18 @@ public extension Interaction {
}
}
return isUserMentioned(
publicKeysToCheck: publicKeysToCheck,
body: body,
quoteAuthorId: quoteAuthorId
)
}
static func isUserMentioned(
publicKeysToCheck: [String],
body: String?,
quoteAuthorId: String? = nil
) -> Bool {
// A user is mentioned if their public key is in the body of a message or one of their messages
// was quoted
return publicKeysToCheck.contains { publicKey in

View File

@ -130,7 +130,7 @@ public extension LinkPreview {
return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution)
}
static func saveAttachmentIfPossible(_ db: Database, imageData: Data?, mimeType: String) throws -> String? {
static func generateAttachmentIfPossible(imageData: Data?, mimeType: String) throws -> Attachment? {
guard let imageData: Data = imageData, !imageData.isEmpty else { return nil }
guard let fileExtension: String = MIMETypeUtil.fileExtension(forMIMEType: mimeType) else { return nil }
@ -141,9 +141,7 @@ public extension LinkPreview {
return nil
}
return try Attachment(contentType: mimeType, dataSource: dataSource)?
.inserted(db)
.id
return Attachment(contentType: mimeType, dataSource: dataSource)
}
static func isValidLinkUrl(_ urlString: String) -> Bool {
@ -355,7 +353,6 @@ public extension LinkPreview {
return session
.dataTaskPublisher(for: request)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.mapError { _ -> Error in HTTPError.generic } // URLError codes are negative values
.tryMap { data, response -> (Data, URLResponse) in
guard let urlResponse: HTTPURLResponse = response as? HTTPURLResponse else {

View File

@ -89,7 +89,6 @@ public enum FileServerAPI {
with: serverPublicKey,
timeout: timeout
)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.tryMap { _, response -> Data in
guard let response: Data = response else { throw HTTPError.parsingFailed }

View File

@ -94,9 +94,20 @@ public enum AttachmentDownloadJob: JobExecutor {
else { throw AttachmentDownloadError.invalidUrl }
return Storage.shared
.readPublisher { db in try OpenGroup.fetchOne(db, id: threadId) }
.flatMap { maybeOpenGroup -> AnyPublisher<Data, Error> in
guard let openGroup: OpenGroup = maybeOpenGroup else {
.readPublisher { db -> OpenGroupAPI.PreparedSendData<Data>? in
try OpenGroup.fetchOne(db, id: threadId)
.map { openGroup in
try OpenGroupAPI
.preparedDownloadFile(
db,
fileId: fileId,
from: openGroup.roomToken,
on: openGroup.server
)
}
}
.flatMap { maybePreparedSendData -> AnyPublisher<Data, Error> in
guard let preparedSendData: OpenGroupAPI.PreparedSendData<Data> = maybePreparedSendData else {
return FileServerAPI
.download(
fileId,
@ -105,16 +116,8 @@ public enum AttachmentDownloadJob: JobExecutor {
.eraseToAnyPublisher()
}
return Storage.shared
.readPublisherFlatMap { db in
OpenGroupAPI
.downloadFile(
db,
fileId: fileId,
from: openGroup.roomToken,
on: openGroup.server
)
}
return OpenGroupAPI
.send(data: preparedSendData)
.map { _, data in data }
.eraseToAnyPublisher()
}

View File

@ -221,25 +221,29 @@ public extension ConfigurationSyncJob {
return
}
// Upsert a config sync job (if there is already an pending one then no need
// to add another one)
// Upsert a config sync job if needed
JobRunner.upsert(
db,
job: ConfigurationSyncJob.createOrUpdateIfNeeded(db, publicKey: publicKey)
job: ConfigurationSyncJob.createIfNeeded(db, publicKey: publicKey)
)
}
@discardableResult static func createOrUpdateIfNeeded(_ db: Database, publicKey: String) -> Job {
// Try to get an existing job (if there is one that's not running)
if
let existingJobs: [Job] = try? Job
@discardableResult static func createIfNeeded(_ db: Database, publicKey: String) -> Job? {
/// The ConfigurationSyncJob will automatically reschedule itself to run again after 3 seconds so if there is an existing
/// job then there is no need to create another instance
///
/// **Note:** Jobs with different `threadId` values can run concurrently
guard
JobRunner
.infoForCurrentlyRunningJobs(of: .configurationSync)
.filter({ _, info in info.threadId == publicKey })
.isEmpty,
(try? Job
.filter(Job.Columns.variant == Job.Variant.configurationSync)
.filter(Job.Columns.threadId == publicKey)
.fetchAll(db),
let existingJob: Job = existingJobs.first(where: { !JobRunner.isCurrentlyRunning($0) })
{
return existingJob
}
.isEmpty(db))
.defaulting(to: false)
else { return nil }
// Otherwise create a new job
return Job(
@ -278,7 +282,7 @@ public extension ConfigurationSyncJob {
Future { resolver in
ConfigurationSyncJob.run(
Job(variant: .configurationSync),
queue: DispatchQueue.global(qos: .userInitiated),
queue: .global(qos: .userInitiated),
success: { _, _ in resolver(Result.success(())) },
failure: { _, error, _ in resolver(Result.failure(error ?? HTTPError.generic)) },
deferred: { _ in }

View File

@ -55,6 +55,7 @@ public enum GroupLeavingJob: JobExecutor {
)
}
.flatMap { MessageSender.sendImmediate(preparedSendData: $0) }
.subscribe(on: queue)
.receive(on: queue)
.sinkUntilComplete(
receiveCompletion: { result in

View File

@ -189,6 +189,7 @@ public enum MessageSendJob: JobExecutor {
}
.map { sendData in sendData.with(fileIds: messageFileIds) }
.flatMap { MessageSender.sendImmediate(preparedSendData: $0) }
.subscribe(on: queue)
.receive(on: queue)
.sinkUntilComplete(
receiveCompletion: { result in

View File

@ -49,6 +49,7 @@ public enum SendReadReceiptsJob: JobExecutor {
)
}
.flatMap { MessageSender.sendImmediate(preparedSendData: $0) }
.subscribe(on: queue)
.receive(on: queue)
.sinkUntilComplete(
receiveCompletion: { result in

File diff suppressed because it is too large Load Diff

View File

@ -296,8 +296,6 @@ public final class OpenGroupManager {
)
}
}
.subscribe(on: OpenGroupAPI.workQueue)
.receive(on: OpenGroupAPI.workQueue)
.flatMap { response -> Future<Void, Error> in
Future<Void, Error> { resolver in
dependencies.storage.write { db in
@ -347,7 +345,7 @@ public final class OpenGroupManager {
_ db: Database,
openGroupId: String,
calledFromConfigHandling: Bool,
dependencies: OGMDependencies = OGMDependencies()
using dependencies: OGMDependencies = OGMDependencies()
) {
let server: String? = try? OpenGroup
.select(.server)
@ -907,26 +905,28 @@ public final class OpenGroupManager {
}
/// This method specifies if the given capability is supported on a specified Open Group
public static func isOpenGroupSupport(
_ capability: Capability.Variant,
public static func doesOpenGroupSupport(
_ db: Database? = nil,
capability: Capability.Variant,
on server: String?,
using dependencies: OGMDependencies = OGMDependencies()
) -> Bool {
guard let server: String = server else { return false }
guard let db: Database = db else {
return dependencies.storage
.read { db in doesOpenGroupSupport(db, capability: capability, on: server, using: dependencies) }
.defaulting(to: false)
}
return dependencies.storage
.read { db in
let capabilities: [Capability.Variant] = (try? Capability
.select(.variant)
.filter(Capability.Columns.openGroupServer == server)
.filter(Capability.Columns.isMissing == false)
.asRequest(of: Capability.Variant.self)
.fetchAll(db))
.defaulting(to: [])
let capabilities: [Capability.Variant] = (try? Capability
.select(.variant)
.filter(Capability.Columns.openGroupServer == server)
.filter(Capability.Columns.isMissing == false)
.asRequest(of: Capability.Variant.self)
.fetchAll(db))
.defaulting(to: [])
return capabilities.contains(capability)
}
.defaulting(to: false)
return capabilities.contains(capability)
}
/// This method specifies if the given publicKey is a moderator or an admin within a specified Open Group
@ -1012,7 +1012,10 @@ public final class OpenGroupManager {
}
@discardableResult public static func getDefaultRoomsIfNeeded(
using dependencies: OGMDependencies = OGMDependencies()
using dependencies: OGMDependencies = OGMDependencies(
subscribeQueue: OpenGroupAPI.workQueue,
receiveQueue: OpenGroupAPI.workQueue
)
) -> AnyPublisher<[OpenGroupAPI.Room], Error> {
// Note: If we already have a 'defaultRoomsPromise' then there is no need to get it again
if let existingPublisher: AnyPublisher<[OpenGroupAPI.Room], Error> = dependencies.cache.defaultRoomsPublisher {
@ -1028,8 +1031,8 @@ public final class OpenGroupManager {
using: dependencies
)
}
.subscribe(on: OpenGroupAPI.workQueue)
.receive(on: OpenGroupAPI.workQueue)
.subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true)
.receive(on: dependencies.receiveQueue, immediatelyIfMain: true)
.retry(8)
.map { response in
dependencies.storage.writeAsync { db in
@ -1077,7 +1080,6 @@ public final class OpenGroupManager {
on: OpenGroupAPI.defaultServer,
using: dependencies
)
.sinkUntilComplete()
}
}
@ -1107,13 +1109,13 @@ public final class OpenGroupManager {
return publisher
}
public static func roomImage(
@discardableResult public static func roomImage(
_ db: Database,
fileId: String,
for roomToken: String,
on server: String,
using dependencies: OGMDependencies = OGMDependencies(
queue: DispatchQueue.global(qos: .background)
subscribeQueue: .global(qos: .background)
)
) -> AnyPublisher<Data, Error> {
// Normally the image for a given group is stored with the group thread, so it's only
@ -1149,37 +1151,63 @@ public final class OpenGroupManager {
return publisher
}
// Trigger the download on a background queue
let publisher: AnyPublisher<Data, Error> = OpenGroupAPI
.downloadFile(
db,
fileId: fileId,
from: roomToken,
on: server,
using: dependencies
)
.map { _, imageData in
if server.lowercased() == OpenGroupAPI.defaultServer {
dependencies.storage.write { db in
_ = try OpenGroup
.filter(id: threadId)
.updateAll(db, OpenGroup.Columns.imageData.set(to: imageData))
}
dependencies.standardUserDefaults[.lastOpenGroupImageUpdate] = now
let sendData: OpenGroupAPI.PreparedSendData<Data>
do {
sendData = try OpenGroupAPI
.preparedDownloadFile(
db,
fileId: fileId,
from: roomToken,
on: server,
using: dependencies
)
}
catch {
return Fail(error: error)
.eraseToAnyPublisher()
}
// Defer the actual download and run it on a separate thread to avoid blocking the calling thread
let publisher: AnyPublisher<Data, Error> = Deferred {
Future { resolver in
dependencies.subscribeQueue.async {
// Hold on to the publisher until it has completed at least once
OpenGroupAPI
.send(
data: sendData,
using: dependencies
)
.sinkUntilComplete(
receiveCompletion: { result in
switch result {
case .finished: break
case .failure(let error): resolver(Result.failure(error))
}
},
receiveValue: { _, imageData in
if server.lowercased() == OpenGroupAPI.defaultServer {
dependencies.storage.write { db in
_ = try OpenGroup
.filter(id: threadId)
.updateAll(db, OpenGroup.Columns.imageData.set(to: imageData))
}
dependencies.standardUserDefaults[.lastOpenGroupImageUpdate] = now
}
resolver(Result.success(imageData))
}
)
}
return imageData
}
.shareReplay(1)
.eraseToAnyPublisher()
}
.shareReplay(1)
.eraseToAnyPublisher()
dependencies.mutableCache.mutate { cache in
cache.groupImagePublishers[threadId] = publisher
}
// Hold on to the publisher until it has completed at least once
publisher.sinkUntilComplete()
return publisher
}
}
@ -1198,7 +1226,8 @@ extension OpenGroupManager {
public var cache: OGMCacheType { return mutableCache.wrappedValue }
public init(
queue: DispatchQueue? = nil,
subscribeQueue: DispatchQueue? = nil,
receiveQueue: DispatchQueue? = nil,
cache: Atomic<OGMCacheType>? = nil,
onionApi: OnionRequestAPIType.Type? = nil,
generalCache: Atomic<GeneralCacheType>? = nil,
@ -1218,7 +1247,8 @@ extension OpenGroupManager {
_mutableCache = Atomic(cache)
super.init(
queue: queue,
subscribeQueue: subscribeQueue,
receiveQueue: receiveQueue,
onionApi: onionApi,
generalCache: generalCache,
storage: storage,

View File

@ -7,6 +7,7 @@ public enum OpenGroupAPIError: LocalizedError {
case signingFailed
case noPublicKey
case invalidEmoji
case invalidPreparedData
public var errorDescription: String? {
switch self {
@ -14,6 +15,7 @@ public enum OpenGroupAPIError: LocalizedError {
case .signingFailed: return "Couldn't sign message."
case .noPublicKey: return "Couldn't find server public key."
case .invalidEmoji: return "The emoji is invalid."
case .invalidPreparedData: return "Invalid PreparedSendData provided."
}
}
}

View File

@ -0,0 +1,125 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import SessionUtilitiesKit
public extension OpenGroupAPI {
struct PreparedSendData<R> {
internal let request: URLRequest
internal let endpoint: Endpoint
internal let server: String
internal let publicKey: String
internal let originalType: Decodable.Type
internal let responseType: R.Type
internal let timeout: TimeInterval
internal let responseConverter: ((ResponseInfoType, Any) throws -> R)
internal init(
request: URLRequest,
endpoint: Endpoint,
server: String,
publicKey: String,
responseType: R.Type,
timeout: TimeInterval
) where R: Decodable {
self.request = request
self.endpoint = endpoint
self.server = server
self.publicKey = publicKey
self.originalType = responseType
self.responseType = responseType
self.timeout = timeout
self.responseConverter = { _, response in
guard let validResponse: R = response as? R else { throw HTTPError.invalidResponse }
return validResponse
}
}
private init<U: Decodable>(
request: URLRequest,
endpoint: Endpoint,
server: String,
publicKey: String,
originalType: U.Type,
responseType: R.Type,
timeout: TimeInterval,
responseConverter: @escaping (ResponseInfoType, Any) throws -> R
) {
self.request = request
self.endpoint = endpoint
self.server = server
self.publicKey = publicKey
self.originalType = originalType
self.responseType = responseType
self.timeout = timeout
self.responseConverter = responseConverter
}
}
}
public extension OpenGroupAPI.PreparedSendData {
func map<O>(transform: @escaping (ResponseInfoType, R) throws -> O) -> OpenGroupAPI.PreparedSendData<O> {
return OpenGroupAPI.PreparedSendData(
request: request,
endpoint: endpoint,
server: server,
publicKey: publicKey,
originalType: originalType,
responseType: O.self,
timeout: timeout,
responseConverter: { info, response in
let validResponse: R = try responseConverter(info, response)
return try transform(info, validResponse)
}
)
}
}
// MARK: - Convenience
public extension Publisher where Output == (ResponseInfoType, Data?), Failure == Error {
func decoded<R>(
with preparedData: OpenGroupAPI.PreparedSendData<R>,
using dependencies: Dependencies = Dependencies()
) -> AnyPublisher<(ResponseInfoType, R), Error> {
self
.tryMap { responseInfo, maybeData -> (ResponseInfoType, R) in
// Depending on the 'originalType' we need to process the response differently
let targetData: Any = try {
switch preparedData.originalType {
case is NoResponse.Type: return NoResponse()
case is Optional<Data>.Type: return maybeData as Any
case is Data.Type: return try maybeData ?? { throw HTTPError.parsingFailed }()
case is _OptionalProtocol.Type:
guard let data: Data = maybeData else { return maybeData as Any }
return try preparedData.originalType.decoded(from: data, using: dependencies)
default:
guard let data: Data = maybeData else { throw HTTPError.parsingFailed }
return try preparedData.originalType.decoded(from: data, using: dependencies)
}
}()
// Generate and return the converted data
let convertedData: R = try preparedData.responseConverter(responseInfo, targetData)
return (responseInfo, convertedData)
}
.eraseToAnyPublisher()
}
}
// MARK: - _OptionalProtocol
/// This protocol should only be used within this file and is used to distinguish between `Any.Type` and `Optional<Any>.Type` as
/// it seems that `is Optional<Any>.Type` doesn't work nicely but this protocol works nicely as long as the case is under any explicit
/// `Optional<T>` handling that we need
private protocol _OptionalProtocol {}
extension Optional: _OptionalProtocol {}

View File

@ -1,4 +1,5 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import Sodium
@ -57,7 +58,8 @@ public class SMKDependencies: SSKDependencies {
// MARK: - Initialization
public init(
queue: DispatchQueue? = nil,
subscribeQueue: DispatchQueue? = nil,
receiveQueue: DispatchQueue? = nil,
onionApi: OnionRequestAPIType.Type? = nil,
generalCache: Atomic<GeneralCacheType>? = nil,
storage: Storage? = nil,
@ -83,7 +85,8 @@ public class SMKDependencies: SSKDependencies {
_nonceGenerator24 = Atomic(nonceGenerator24)
super.init(
queue: queue,
subscribeQueue: subscribeQueue,
receiveQueue: receiveQueue,
onionApi: onionApi,
generalCache: generalCache,
storage: storage,

View File

@ -64,12 +64,14 @@ extension MessageReceiver {
let thread: SessionThread = try SessionThread
.fetchOrCreate(db, id: sender, variant: .contact, shouldBeVisible: nil)
Environment.shared?.notificationsManager.wrappedValue?
.notifyUser(
db,
forIncomingCall: interaction,
in: thread
)
if !interaction.wasRead {
Environment.shared?.notificationsManager.wrappedValue?
.notifyUser(
db,
forIncomingCall: interaction,
in: thread
)
}
}
return
}
@ -79,12 +81,14 @@ extension MessageReceiver {
let thread: SessionThread = try SessionThread
.fetchOrCreate(db, id: sender, variant: .contact, shouldBeVisible: nil)
Environment.shared?.notificationsManager.wrappedValue?
.notifyUser(
db,
forIncomingCall: interaction,
in: thread
)
if !interaction.wasRead {
Environment.shared?.notificationsManager.wrappedValue?
.notifyUser(
db,
forIncomingCall: interaction,
in: thread
)
}
// Trigger the missed call UI if needed
NotificationCenter.default.post(
@ -196,6 +200,10 @@ extension MessageReceiver {
SNLog("[Calls] Sending end call message because there is an ongoing call.")
let messageSentTimestamp: Int64 = (
message.sentTimestamp.map { Int64($0) } ??
SnodeAPI.currentOffsetTimestampMs()
)
_ = try Interaction(
serverHash: message.serverHash,
messageUuid: message.uuid,
@ -203,9 +211,13 @@ extension MessageReceiver {
authorId: caller,
variant: .infoCall,
body: String(data: messageInfoData, encoding: .utf8),
timestampMs: (
message.sentTimestamp.map { Int64($0) } ??
SnodeAPI.currentOffsetTimestampMs()
timestampMs: messageSentTimestamp,
wasRead: SessionUtil.timestampAlreadyRead(
threadId: thread.id,
threadVariant: thread.variant,
timestampMs: (messageSentTimestamp * 1000),
userPublicKey: getUserHexEncodedPublicKey(db),
openGroup: nil
)
)
.inserted(db)
@ -227,6 +239,7 @@ extension MessageReceiver {
interactionId: nil // Explicitly nil as it's a separate message from above
)
)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.sinkUntilComplete()
}
@ -246,9 +259,10 @@ extension MessageReceiver {
!thread.isMessageRequest(db)
else { return nil }
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo(
state: state.defaulting(
to: (sender == getUserHexEncodedPublicKey(db) ?
to: (sender == currentUserPublicKey ?
.outgoing :
.incoming
)
@ -268,7 +282,14 @@ extension MessageReceiver {
authorId: sender,
variant: .infoCall,
body: String(data: messageInfoData, encoding: .utf8),
timestampMs: timestampMs
timestampMs: timestampMs,
wasRead: SessionUtil.timestampAlreadyRead(
threadId: thread.id,
threadVariant: thread.variant,
timestampMs: (timestampMs * 1000),
userPublicKey: currentUserPublicKey,
openGroup: nil
)
).inserted(db)
}
}

View File

@ -3,6 +3,7 @@
import Foundation
import GRDB
import SessionSnodeKit
import SessionUtilitiesKit
extension MessageReceiver {
internal static func handleDataExtractionNotification(
@ -17,6 +18,10 @@ extension MessageReceiver {
let messageKind: DataExtractionNotification.Kind = message.kind
else { throw MessageReceiverError.invalidMessage }
let timestampMs: Int64 = (
message.sentTimestamp.map { Int64($0) } ??
SnodeAPI.currentOffsetTimestampMs()
)
_ = try Interaction(
serverHash: message.serverHash,
threadId: threadId,
@ -27,9 +32,13 @@ extension MessageReceiver {
case .mediaSaved: return .infoMediaSavedNotification
}
}(),
timestampMs: (
message.sentTimestamp.map { Int64($0) } ??
SnodeAPI.currentOffsetTimestampMs()
timestampMs: timestampMs,
wasRead: SessionUtil.timestampAlreadyRead(
threadId: threadId,
threadVariant: threadVariant,
timestampMs: (timestampMs * 1000),
userPublicKey: getUserHexEncodedPublicKey(db),
openGroup: nil
)
).inserted(db)
}

View File

@ -71,18 +71,26 @@ extension MessageReceiver {
}
// Add an info message for the user
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
_ = try Interaction(
serverHash: nil, // Intentionally null so sync messages are seen as duplicates
threadId: threadId,
authorId: sender,
variant: .infoDisappearingMessagesUpdate,
body: config.messageInfoString(
with: (sender != getUserHexEncodedPublicKey(db) ?
with: (sender != currentUserPublicKey ?
Profile.displayName(db, id: sender) :
nil
)
),
timestampMs: timestampMs
timestampMs: timestampMs,
wasRead: SessionUtil.timestampAlreadyRead(
threadId: threadId,
threadVariant: threadVariant,
timestampMs: (timestampMs * 1000),
userPublicKey: currentUserPublicKey,
openGroup: nil
)
).inserted(db)
// Only save the updated config if we can perform the change

View File

@ -48,6 +48,7 @@ extension MessageReceiver {
publicKey: author,
serverHashes: [serverHash]
)
.subscribe(on: DispatchQueue.global(qos: .background))
.sinkUntilComplete()
}

View File

@ -117,7 +117,8 @@ extension MessageReceiver {
message: message,
associatedWithProto: proto,
sender: sender,
messageSentTimestamp: messageSentTimestamp
messageSentTimestamp: messageSentTimestamp,
openGroup: maybeOpenGroup
) {
return interactionId
}
@ -323,7 +324,7 @@ extension MessageReceiver {
}
// Notify the user if needed
guard variant == .standardIncoming else { return interactionId }
guard variant == .standardIncoming && !interaction.wasRead else { return interactionId }
// Use the same identifier for notifications when in backgroud polling to prevent spam
Environment.shared?.notificationsManager.wrappedValue?
@ -342,7 +343,8 @@ extension MessageReceiver {
message: VisibleMessage,
associatedWithProto proto: SNProtoContent,
sender: String,
messageSentTimestamp: TimeInterval
messageSentTimestamp: TimeInterval,
openGroup: OpenGroup?
) throws -> Int64? {
guard
let reaction: VisibleMessage.VMReaction = message.reaction,
@ -370,17 +372,28 @@ extension MessageReceiver {
switch reaction.kind {
case .react:
let timestampMs: Int64 = Int64(messageSentTimestamp * 1000)
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
let reaction: Reaction = try Reaction(
interactionId: interactionId,
serverHash: message.serverHash,
timestampMs: Int64(messageSentTimestamp * 1000),
timestampMs: timestampMs,
authorId: sender,
emoji: reaction.emoji,
count: 1,
sortId: sortId
).inserted(db)
let timestampAlreadyRead: Bool = SessionUtil.timestampAlreadyRead(
threadId: thread.id,
threadVariant: thread.variant,
timestampMs: timestampMs,
userPublicKey: currentUserPublicKey,
openGroup: openGroup
)
if sender != getUserHexEncodedPublicKey(db) {
// Don't notify if the reaction was added before the lastest read timestamp for
// the conversation
if sender != currentUserPublicKey && !timestampAlreadyRead {
Environment.shared?.notificationsManager.wrappedValue?
.notifyUser(
db,

View File

@ -108,10 +108,7 @@ extension MessageSender {
)
}
public static func performUploadsIfNeeded(
queue: DispatchQueue,
preparedSendData: PreparedSendData
) -> AnyPublisher<PreparedSendData, Error> {
public static func performUploadsIfNeeded(preparedSendData: PreparedSendData) -> AnyPublisher<PreparedSendData, Error> {
// We need an interactionId in order for a message to have uploads
guard let interactionId: Int64 = preparedSendData.interactionId else {
return Just(preparedSendData)

View File

@ -643,7 +643,6 @@ public final class MessageSender {
snodeMessage,
in: namespace
)
.subscribe(on: DispatchQueue.global(qos: .default))
.flatMap { response -> AnyPublisher<Void, Error> in
let updatedMessage: Message = message
updatedMessage.serverHash = response.1.hash
@ -703,7 +702,7 @@ public final class MessageSender {
Future<Void, Error> { resolver in
NotifyPushServerJob.run(
job,
queue: DispatchQueue.global(qos: .default),
queue: .global(qos: .default),
success: { _, _ in resolver(Result.success(())) },
failure: { _, _, _ in
// Always fulfill because the notify PN server job isn't critical.
@ -760,9 +759,9 @@ public final class MessageSender {
// Send the result
return dependencies.storage
.readPublisherFlatMap { db in
OpenGroupAPI
.send(
.readPublisher { db in
try OpenGroupAPI
.preparedSend(
db,
plaintext: plaintext,
to: roomToken,
@ -773,7 +772,7 @@ public final class MessageSender {
using: dependencies
)
}
.subscribe(on: DispatchQueue.global(qos: .default))
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.flatMap { (responseInfo, responseData) -> AnyPublisher<Void, Error> in
let serverTimestampMs: UInt64? = responseData.posted.map { UInt64(floor($0 * 1000)) }
let updatedMessage: Message = message
@ -828,9 +827,9 @@ public final class MessageSender {
// Send the result
return dependencies.storage
.readPublisherFlatMap { db in
return OpenGroupAPI
.send(
.readPublisher { db in
try OpenGroupAPI
.preparedSend(
db,
ciphertext: ciphertext,
toInboxFor: recipientBlindedPublicKey,
@ -838,7 +837,7 @@ public final class MessageSender {
using: dependencies
)
}
.subscribe(on: DispatchQueue.global(qos: .default))
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.flatMap { (responseInfo, responseData) -> AnyPublisher<Void, Error> in
let updatedMessage: Message = message
updatedMessage.openGroupServerMessageId = UInt64(responseData.id)

View File

@ -92,12 +92,16 @@ public final class ClosedGroupPoller: Poller {
.eraseToAnyPublisher()
}
override func handlePollError(_ error: Error, for publicKey: String) {
override func handlePollError(
_ error: Error,
for publicKey: String,
using dependencies: SMKDependencies = SMKDependencies()
) {
SNLog("Polling failed for closed group with public key: \(publicKey) due to error: \(error).")
// Try to restart the poller from scratch
Threading.pollerQueue.async { [weak self] in
self?.setUpPolling(for: publicKey)
self?.setUpPolling(for: publicKey, using: dependencies)
}
}
}

View File

@ -100,7 +100,11 @@ public final class CurrentUserPoller: Poller {
.eraseToAnyPublisher()
}
override func handlePollError(_ error: Error, for publicKey: String) {
override func handlePollError(
_ error: Error,
for publicKey: String,
using dependencies: SMKDependencies = SMKDependencies()
) {
if UserDefaults.sharedLokiProject?[.isMainAppActive] != true {
// Do nothing when an error gets throws right after returning from the background (happens frequently)
}
@ -115,7 +119,7 @@ public final class CurrentUserPoller: Poller {
// Try to restart the poller from scratch
Threading.pollerQueue.async { [weak self] in
self?.setUpPolling(for: publicKey)
self?.setUpPolling(for: publicKey, using: dependencies)
}
}
}

View File

@ -18,8 +18,16 @@ extension OpenGroupAPI {
// MARK: - Settings
private static let minPollInterval: TimeInterval = 3
private static let maxPollInterval: Double = (60 * 60)
internal static let maxInactivityPeriod: Double = (14 * 24 * 60 * 60)
private static let maxPollInterval: TimeInterval = (60 * 60)
internal static let maxInactivityPeriod: TimeInterval = (14 * 24 * 60 * 60)
/// If there are hidden rooms that we poll and they fail too many times we want to prune them (as it likely means they no longer
/// exist, and since they are already hidden it's unlikely that the user will notice that we stopped polling for them)
internal static let maxHiddenRoomFailureCount: Int64 = 10
/// When doing a background poll we want to only fetch from rooms which are unlikely to timeout, in order to do this we exclude
/// any rooms which have failed more than this threashold
public static let maxRoomFailureCountForBackgroundPoll: Int64 = 15
// MARK: - Lifecycle
@ -41,7 +49,12 @@ extension OpenGroupAPI {
// MARK: - Polling
private func pollRecursively(using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) {
private func pollRecursively(
using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies(
subscribeQueue: Threading.pollerQueue,
receiveQueue: OpenGroupAPI.workQueue
)
) {
guard hasStarted else { return }
let minPollFailureCount: TimeInterval = Storage.shared
@ -59,6 +72,8 @@ extension OpenGroupAPI {
// Wait until the last poll completes before polling again ensuring we don't poll any faster than
// the 'nextPollInterval' value
poll(using: dependencies)
.subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true)
.receive(on: dependencies.receiveQueue, immediatelyIfMain: true)
.sinkUntilComplete(
receiveCompletion: { [weak self] _ in
let currentTime: TimeInterval = Date().timeIntervalSince1970
@ -129,8 +144,6 @@ extension OpenGroupAPI {
.map { response in (failureCount, response) }
.eraseToAnyPublisher()
}
.subscribe(on: Threading.pollerQueue)
.receive(on: Threading.pollerQueue)
.handleEvents(
receiveOutput: { [weak self] failureCount, response in
guard !calledFromBackgroundPoller || isBackgroundPollerValid() else {
@ -179,7 +192,8 @@ extension OpenGroupAPI {
calledFromBackgroundPoller: calledFromBackgroundPoller,
isBackgroundPollerValid: isBackgroundPollerValid,
isPostCapabilitiesRetry: isPostCapabilitiesRetry,
error: error
error: error,
using: dependencies
)
.handleEvents(
receiveOutput: { [weak self] didHandleError in
@ -232,7 +246,7 @@ extension OpenGroupAPI {
/// If the polling has failed 10+ times then try to prune any invalid rooms that
/// aren't visible (they would have been added via config messages and will
/// likely always fail but the user has no way to delete them)
guard pollFailureCount > 10 else { return }
guard pollFailureCount > Poller.maxHiddenRoomFailureCount else { return }
prunedIds = roomsAreVisible
.filter { !$0.shouldBeVisible }
@ -247,19 +261,20 @@ extension OpenGroupAPI {
/// not be in an invalid state on other devices - one of the other devices
/// will eventually trigger a new config update which will re-add this room
/// and hopefully at that time it'll work again
calledFromConfigHandling: true
calledFromConfigHandling: true,
using: dependencies
)
}
}
SNLog("Open group polling failed due to error: \(error). Setting failure count to \(pollFailureCount).")
SNLog("Open group polling to \(server) failed due to error: \(error). Setting failure count to \(pollFailureCount).")
// Add a note to the logs that this happened
if !prunedIds.isEmpty {
let rooms: String = prunedIds
.compactMap { $0.components(separatedBy: server).last }
.joined(separator: ", ")
SNLog("Hidden open group failure count surpassed 10, removed hidden rooms \(rooms).")
SNLog("Hidden open group failure count surpassed \(Poller.maxHiddenRoomFailureCount), removed hidden rooms \(rooms).")
}
}
@ -299,16 +314,15 @@ extension OpenGroupAPI {
}
return dependencies.storage
.readPublisherFlatMap { db in
OpenGroupAPI.capabilities(
.readPublisher { db in
try OpenGroupAPI.preparedCapabilities(
db,
server: server,
forceBlinded: true,
using: dependencies
)
}
.subscribe(on: OpenGroupAPI.workQueue)
.receive(on: OpenGroupAPI.workQueue)
.flatMap { OpenGroupAPI.send(data: $0, using: dependencies) }
.flatMap { [weak self] _, responseBody -> AnyPublisher<Void, Error> in
guard let strongSelf = self, isBackgroundPollerValid() else {
return Just(())

View File

@ -59,7 +59,7 @@ public class Poller {
preconditionFailure("abstract class - override in subclass")
}
internal func handlePollError(_ error: Error, for publicKey: String) {
internal func handlePollError(_ error: Error, for publicKey: String, using dependencies: SMKDependencies) {
preconditionFailure("abstract class - override in subclass")
}
@ -81,37 +81,46 @@ public class Poller {
/// We want to initially trigger a poll against the target service node and then run the recursive polling,
/// if an error is thrown during the poll then this should automatically restart the polling
internal func setUpPolling(for publicKey: String) {
internal func setUpPolling(
for publicKey: String,
using dependencies: SMKDependencies = SMKDependencies(
subscribeQueue: Threading.pollerQueue,
receiveQueue: Threading.pollerQueue
)
) {
guard isPolling.wrappedValue[publicKey] == true else { return }
let namespaces: [SnodeAPI.Namespace] = self.namespaces
getSnodeForPolling(for: publicKey)
.subscribe(on: Threading.pollerQueue)
.subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true)
.flatMap { snode -> AnyPublisher<[Message], Error> in
Poller.poll(
namespaces: namespaces,
from: snode,
for: publicKey,
on: Threading.pollerQueue,
poller: self
poller: self,
using: dependencies
)
}
.receive(on: Threading.pollerQueue)
.receive(on: dependencies.receiveQueue, immediatelyIfMain: true)
.sinkUntilComplete(
receiveCompletion: { [weak self] result in
switch result {
case .finished: self?.pollRecursively(for: publicKey)
case .finished: self?.pollRecursively(for: publicKey, using: dependencies)
case .failure(let error):
guard self?.isPolling.wrappedValue[publicKey] == true else { return }
self?.handlePollError(error, for: publicKey)
self?.handlePollError(error, for: publicKey, using: dependencies)
}
}
)
}
private func pollRecursively(for publicKey: String) {
private func pollRecursively(
for publicKey: String,
using dependencies: SMKDependencies = SMKDependencies()
) {
guard isPolling.wrappedValue[publicKey] == true else { return }
let namespaces: [SnodeAPI.Namespace] = self.namespaces
@ -125,21 +134,21 @@ public class Poller {
timer.invalidate()
self?.getSnodeForPolling(for: publicKey)
.subscribe(on: Threading.pollerQueue)
.subscribe(on: dependencies.subscribeQueue, immediatelyIfMain: true)
.flatMap { snode -> AnyPublisher<[Message], Error> in
Poller.poll(
namespaces: namespaces,
from: snode,
for: publicKey,
on: Threading.pollerQueue,
poller: self
poller: self,
using: dependencies
)
}
.receive(on: Threading.pollerQueue)
.receive(on: dependencies.receiveQueue, immediatelyIfMain: true)
.sinkUntilComplete(
receiveCompletion: { result in
switch result {
case .failure(let error): self?.handlePollError(error, for: publicKey)
case .failure(let error): self?.handlePollError(error, for: publicKey, using: dependencies)
case .finished:
let maxNodePollCount: UInt = (self?.maxNodePollCount ?? 0)
@ -161,7 +170,7 @@ public class Poller {
timer.invalidate()
self?.pollCount.mutate { $0[publicKey] = 0 }
self?.setUpPolling(for: publicKey)
self?.setUpPolling(for: publicKey, using: dependencies)
}
}
return
@ -169,7 +178,7 @@ public class Poller {
}
// Otherwise just loop
self?.pollRecursively(for: publicKey)
self?.pollRecursively(for: publicKey, using: dependencies)
}
}
)
@ -186,10 +195,12 @@ public class Poller {
namespaces: [SnodeAPI.Namespace],
from snode: Snode,
for publicKey: String,
on queue: DispatchQueue,
calledFromBackgroundPoller: Bool = false,
isBackgroundPollValid: @escaping (() -> Bool) = { true },
poller: Poller? = nil
poller: Poller? = nil,
using dependencies: SMKDependencies = SMKDependencies(
receiveQueue: Threading.pollerQueue
)
) -> AnyPublisher<[Message], Error> {
// If the polling has been cancelled then don't continue
guard
@ -213,7 +224,8 @@ public class Poller {
namespaces: namespaces,
refreshingConfigHashes: configHashes,
from: snode,
associatedWith: publicKey
associatedWith: publicKey,
using: dependencies
)
.flatMap { namespacedResults -> AnyPublisher<[Message], Error> in
guard
@ -391,7 +403,7 @@ public class Poller {
// Note: In the background we just want jobs to fail silently
ConfigMessageReceiveJob.run(
job,
queue: queue,
queue: dependencies.receiveQueue,
success: { _, _ in resolver(Result.success(())) },
failure: { _, _, _ in resolver(Result.success(())) },
deferred: { _ in resolver(Result.success(())) }
@ -411,7 +423,7 @@ public class Poller {
// Note: In the background we just want jobs to fail silently
MessageReceiveJob.run(
job,
queue: queue,
queue: dependencies.receiveQueue,
success: { _, _ in resolver(Result.success(())) },
failure: { _, _, _ in resolver(Result.success(())) },
deferred: { _ in resolver(Result.success(())) }

View File

@ -328,7 +328,7 @@ public extension SessionUtil {
return false
}
return (oneToOne.last_read > timestampMs)
return (oneToOne.last_read >= timestampMs)
case .legacyGroup:
var cThreadId: [CChar] = threadId.cArray.nullTerminated()
@ -338,7 +338,7 @@ public extension SessionUtil {
return false
}
return (legacyGroup.last_read > timestampMs)
return (legacyGroup.last_read >= timestampMs)
case .community:
guard let openGroup: OpenGroup = openGroup else { return false }
@ -351,7 +351,7 @@ public extension SessionUtil {
return false
}
return (convoCommunity.last_read > timestampMs)
return (convoCommunity.last_read >= timestampMs)
case .group: return false
}

View File

@ -355,7 +355,6 @@ public enum SessionUtil {
)
let seqNo: Int64 = cPushData.pointee.seqno
cPushData.deallocate()
SNLog("[libSession - DEBUG] Push data for \(variant) config data, size: \(configCountInfo), bytes: \(pushData.count)")
return OutgoingConfResult(
message: SharedConfigMessage(

View File

@ -56,6 +56,15 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
case typingIndicator
case dateHeader
case unreadMarker
/// A number of the `CellType` entries are dynamically added to the dataset after processing, this flag indicates
/// whether the given type is one of them
public var isPostProcessed: Bool {
switch self {
case .typingIndicator, .dateHeader, .unreadMarker: return true
default: return false
}
}
}
public var differenceIdentifier: Int64 { id }
@ -74,6 +83,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
public let rowId: Int64
public let id: Int64
public let openGroupServerMessageId: Int64?
public let variant: Interaction.Variant
public let timestampMs: Int64
public let receivedAtTimestampMs: Int64
@ -171,6 +181,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
threadContactNameInternal: self.threadContactNameInternal,
rowId: self.rowId,
id: self.id,
openGroupServerMessageId: self.openGroupServerMessageId,
variant: self.variant,
timestampMs: self.timestampMs,
receivedAtTimestampMs: self.receivedAtTimestampMs,
@ -335,6 +346,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable,
threadContactNameInternal: self.threadContactNameInternal,
rowId: self.rowId,
id: self.id,
openGroupServerMessageId: self.openGroupServerMessageId,
variant: self.variant,
timestampMs: self.timestampMs,
receivedAtTimestampMs: self.receivedAtTimestampMs,
@ -516,7 +528,7 @@ public extension MessageViewModel {
static let genericId: Int64 = -1
static let typingIndicatorId: Int64 = -2
// Note: This init method is only used system-created cells or empty states
/// This init method is only used for system-created cells or empty states
init(
variant: Interaction.Variant = .standardOutgoing,
timestampMs: Int64 = Int64.max,
@ -546,6 +558,7 @@ public extension MessageViewModel {
}()
self.rowId = targetId
self.id = targetId
self.openGroupServerMessageId = nil
self.variant = variant
self.timestampMs = timestampMs
self.receivedAtTimestampMs = receivedAtTimestampMs
@ -567,11 +580,11 @@ public extension MessageViewModel {
self.linkPreview = nil
self.linkPreviewAttachment = nil
self.currentUserPublicKey = ""
self.attachments = nil
self.reactionInfo = nil
// Post-Query Processing Data
self.attachments = nil
self.reactionInfo = nil
self.cellType = cellType
self.authorName = ""
self.senderName = nil
@ -587,6 +600,84 @@ public extension MessageViewModel {
self.isLastOutgoing = isLastOutgoing
self.currentUserBlindedPublicKey = nil
}
/// This init method is only used for optimistic outgoing messages
init(
threadId: String,
threadVariant: SessionThread.Variant,
threadHasDisappearingMessagesEnabled: Bool,
threadOpenGroupServer: String?,
threadOpenGroupPublicKey: String?,
threadContactNameInternal: String,
timestampMs: Int64,
receivedAtTimestampMs: Int64,
authorId: String,
authorNameInternal: String,
body: String?,
expiresStartedAtMs: Double?,
expiresInSeconds: TimeInterval?,
isSenderOpenGroupModerator: Bool,
currentUserProfile: Profile,
quote: Quote?,
quoteAttachment: Attachment?,
linkPreview: LinkPreview?,
linkPreviewAttachment: Attachment?,
attachments: [Attachment]?
) {
self.threadId = threadId
self.threadVariant = threadVariant
self.threadIsTrusted = false
self.threadHasDisappearingMessagesEnabled = threadHasDisappearingMessagesEnabled
self.threadOpenGroupServer = threadOpenGroupServer
self.threadOpenGroupPublicKey = threadOpenGroupPublicKey
self.threadContactNameInternal = threadContactNameInternal
// Interaction Info
self.rowId = -1
self.id = -1
self.openGroupServerMessageId = nil
self.variant = .standardOutgoing
self.timestampMs = timestampMs
self.receivedAtTimestampMs = receivedAtTimestampMs
self.authorId = authorId
self.authorNameInternal = authorNameInternal
self.body = body
self.rawBody = body
self.expiresStartedAtMs = expiresStartedAtMs
self.expiresInSeconds = expiresInSeconds
self.state = .sending
self.hasAtLeastOneReadReceipt = false
self.mostRecentFailureText = nil
self.isSenderOpenGroupModerator = isSenderOpenGroupModerator
self.isTypingIndicator = false
self.profile = currentUserProfile
self.quote = quote
self.quoteAttachment = quoteAttachment
self.linkPreview = linkPreview
self.linkPreviewAttachment = linkPreviewAttachment
self.currentUserPublicKey = currentUserProfile.id
self.attachments = attachments
self.reactionInfo = nil
// Post-Query Processing Data
self.cellType = .textOnlyMessage
self.authorName = ""
self.senderName = nil
self.canHaveProfile = false
self.shouldShowProfile = false
self.shouldShowDateHeader = false
self.containsOnlyEmoji = nil
self.glyphCount = nil
self.previousVariant = nil
self.positionInCluster = .middle
self.isOnlyMessageInCluster = true
self.isLast = false
self.isLastOutgoing = false
self.currentUserBlindedPublicKey = nil
}
}
// MARK: - Convenience
@ -688,7 +779,7 @@ public extension MessageViewModel {
let interactionAttachmentAttachmentIdColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name)
let interactionAttachmentAlbumIndexColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name)
let numColumnsBeforeLinkedRecords: Int = 21
let numColumnsBeforeLinkedRecords: Int = 22
let finalGroupSQL: SQL = (groupSQL ?? "")
let request: SQLRequest<ViewModel> = """
SELECT
@ -704,6 +795,7 @@ public extension MessageViewModel {
\(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey),
\(interaction[.id]),
\(interaction[.openGroupServerMessageId]),
\(interaction[.variant]),
\(interaction[.timestampMs]),
\(interaction[.receivedAtTimestampMs]),
@ -977,7 +1069,7 @@ public extension MessageViewModel.ReactionInfo {
items: pagedRowIdsWithNoReactions
.compactMap { rowId -> ViewModel? in updatedPagedDataCache.data[rowId] }
.filter { viewModel -> Bool in (viewModel.reactionInfo?.isEmpty == false) }
.map { viewModel -> ViewModel in viewModel.with(reactionInfo: nil) }
.map { viewModel -> ViewModel in viewModel.with(reactionInfo: []) }
)
return updatedPagedDataCache

View File

@ -33,6 +33,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
public static let threadWasMarkedUnreadKey: SQL = SQL(stringLiteral: CodingKeys.threadWasMarkedUnread.stringValue)
public static let threadUnreadCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadCount.stringValue)
public static let threadUnreadMentionCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadMentionCount.stringValue)
public static let disappearingMessagesConfigurationKey: SQL = SQL(stringLiteral: CodingKeys.disappearingMessagesConfiguration.stringValue)
public static let contactProfileKey: SQL = SQL(stringLiteral: CodingKeys.contactProfile.stringValue)
public static let closedGroupNameKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupName.stringValue)
public static let closedGroupUserCountKey: SQL = SQL(stringLiteral: CodingKeys.closedGroupUserCount.stringValue)
@ -66,6 +67,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
public static let threadUnreadMentionCountString: String = CodingKeys.threadUnreadMentionCount.stringValue
public static let closedGroupUserCountString: String = CodingKeys.closedGroupUserCount.stringValue
public static let openGroupUserCountString: String = CodingKeys.openGroupUserCount.stringValue
public static let disappearingMessagesConfigurationString: String = CodingKeys.disappearingMessagesConfiguration.stringValue
public static let contactProfileString: String = CodingKeys.contactProfile.stringValue
public static let closedGroupProfileFrontString: String = CodingKeys.closedGroupProfileFront.stringValue
public static let closedGroupProfileBackString: String = CodingKeys.closedGroupProfileBack.stringValue
@ -116,6 +118,8 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat
// Thread display info
public let disappearingMessagesConfiguration: DisappearingMessagesConfiguration?
private let contactProfile: Profile?
private let closedGroupProfileFront: Profile?
private let closedGroupProfileBack: Profile?
@ -343,7 +347,8 @@ public extension SessionThreadViewModel {
contactProfile: Profile? = nil,
currentUserIsClosedGroupMember: Bool? = nil,
openGroupPermissions: OpenGroup.Permissions? = nil,
unreadCount: UInt = 0
unreadCount: UInt = 0,
disappearingMessagesConfiguration: DisappearingMessagesConfiguration? = nil
) {
self.rowId = -1
self.threadId = threadId
@ -368,6 +373,8 @@ public extension SessionThreadViewModel {
// Thread display info
self.disappearingMessagesConfiguration = disappearingMessagesConfiguration
self.contactProfile = contactProfile
self.closedGroupProfileFront = nil
self.closedGroupProfileBack = nil
@ -430,6 +437,7 @@ public extension SessionThreadViewModel {
threadWasMarkedUnread: self.threadWasMarkedUnread,
threadUnreadCount: self.threadUnreadCount,
threadUnreadMentionCount: self.threadUnreadMentionCount,
disappearingMessagesConfiguration: self.disappearingMessagesConfiguration,
contactProfile: self.contactProfile,
closedGroupProfileFront: self.closedGroupProfileFront,
closedGroupProfileBack: self.closedGroupProfileBack,
@ -486,6 +494,7 @@ public extension SessionThreadViewModel {
threadWasMarkedUnread: self.threadWasMarkedUnread,
threadUnreadCount: self.threadUnreadCount,
threadUnreadMentionCount: self.threadUnreadMentionCount,
disappearingMessagesConfiguration: self.disappearingMessagesConfiguration,
contactProfile: self.contactProfile,
closedGroupProfileFront: self.closedGroupProfileFront,
closedGroupProfileBack: self.closedGroupProfileBack,
@ -839,6 +848,7 @@ public extension SessionThreadViewModel {
/// but including this warning just in case there is a discrepancy)
static func conversationQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest<SQLRequest<SessionThreadViewModel>> {
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let disappearingMessagesConfiguration: TypedTableAlias<DisappearingMessagesConfiguration> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
@ -883,6 +893,8 @@ public extension SessionThreadViewModel {
\(thread[.markedAsUnread]) AS \(ViewModel.threadWasMarkedUnreadKey),
\(aggregateInteractionLiteral).\(ViewModel.threadUnreadCountKey),
\(ViewModel.disappearingMessagesConfigurationKey).*,
\(ViewModel.contactProfileKey).*,
\(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey),
@ -911,6 +923,7 @@ public extension SessionThreadViewModel {
\(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey)
FROM \(SessionThread.self)
LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfiguration[.threadId]) = \(thread[.id])
LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])
LEFT JOIN (
SELECT
@ -945,11 +958,13 @@ public extension SessionThreadViewModel {
return request.adapted { db in
let adapters = try splittingRowAdapters(columnCounts: [
numColumnsBeforeProfiles,
DisappearingMessagesConfiguration.numberOfSelectedColumns(db),
Profile.numberOfSelectedColumns(db)
])
return ScopeAdapter([
ViewModel.contactProfileString: adapters[1]
ViewModel.disappearingMessagesConfigurationString: adapters[1],
ViewModel.contactProfileString: adapters[2]
])
}
}

View File

@ -51,7 +51,10 @@ public struct ProfileManager {
}
if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty {
downloadAvatar(for: profile)
// FIXME: Refactor avatar downloading to be a proper Job so we can avoid this
JobRunner.afterBlockingQueue {
ProfileManager.downloadAvatar(for: profile)
}
}
return nil
@ -78,7 +81,10 @@ public struct ProfileManager {
completion: { _, _ in
// Try to re-download the avatar if it has a URL
if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty {
downloadAvatar(for: profile)
// FIXME: Refactor avatar downloading to be a proper Job so we can avoid this
JobRunner.afterBlockingQueue {
ProfileManager.downloadAvatar(for: profile)
}
}
}
)
@ -214,7 +220,8 @@ public struct ProfileManager {
FileServerAPI
.download(fileId, useOldServer: useOldServer)
.receive(on: DispatchQueue.global(qos: .default))
.subscribe(on: DispatchQueue.global(qos: .background))
.receive(on: DispatchQueue.global(qos: .background))
.sinkUntilComplete(
receiveCompletion: { _ in
currentAvatarDownloads.mutate { $0.remove(profile.id) }
@ -451,6 +458,7 @@ public struct ProfileManager {
// Upload the avatar to the FileServer
FileServerAPI
.upload(encryptedAvatarData)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: queue)
.sinkUntilComplete(
receiveCompletion: { result in
@ -590,7 +598,12 @@ public struct ProfileManager {
db.afterNextTransactionNestedOnce(dedupeId: dedupeIdentifier) { db in
// Need to refetch to ensure the db changes have occurred
ProfileManager.downloadAvatar(for: Profile.fetchOrCreate(db, id: publicKey))
let targetProfile: Profile = Profile.fetchOrCreate(db, id: publicKey)
// FIXME: Refactor avatar downloading to be a proper Job so we can avoid this
JobRunner.afterBlockingQueue {
ProfileManager.downloadAvatar(for: targetProfile)
}
}
}
}

View File

@ -1,6 +1,5 @@
import Foundation
internal enum Threading {
internal static let pollerQueue = DispatchQueue(label: "SessionMessagingKit.pollerQueue")
public enum Threading {
public static let pollerQueue = DispatchQueue(label: "SessionMessagingKit.pollerQueue")
}

File diff suppressed because it is too large Load Diff

View File

@ -116,7 +116,8 @@ class OpenGroupManagerSpec: QuickSpec {
mockNonce24Generator = MockNonce24Generator()
mockUserDefaults = MockUserDefaults()
dependencies = OpenGroupManager.OGMDependencies(
queue: DispatchQueue.main,
subscribeQueue: DispatchQueue.main,
receiveQueue: DispatchQueue.main,
cache: Atomic(mockOGMCache),
onionApi: TestCapabilitiesAndRoomApi.self,
generalCache: Atomic(mockGeneralCache),
@ -991,7 +992,7 @@ class OpenGroupManagerSpec: QuickSpec {
db,
openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"),
calledFromConfigHandling: true, // Don't trigger SessionUtil logic
dependencies: dependencies
using: dependencies
)
}
@ -1006,7 +1007,7 @@ class OpenGroupManagerSpec: QuickSpec {
db,
openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"),
calledFromConfigHandling: true, // Don't trigger SessionUtil logic
dependencies: dependencies
using: dependencies
)
}
@ -1024,7 +1025,7 @@ class OpenGroupManagerSpec: QuickSpec {
db,
openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"),
calledFromConfigHandling: true, // Don't trigger SessionUtil logic
dependencies: dependencies
using: dependencies
)
}
@ -1038,7 +1039,7 @@ class OpenGroupManagerSpec: QuickSpec {
db,
openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"),
calledFromConfigHandling: true, // Don't trigger SessionUtil logic
dependencies: dependencies
using: dependencies
)
}
@ -1077,7 +1078,7 @@ class OpenGroupManagerSpec: QuickSpec {
db,
openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"),
calledFromConfigHandling: true, // Don't trigger SessionUtil logic
dependencies: dependencies
using: dependencies
)
}
@ -1130,7 +1131,7 @@ class OpenGroupManagerSpec: QuickSpec {
db,
openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer),
calledFromConfigHandling: true, // Don't trigger SessionUtil logic
dependencies: dependencies
using: dependencies
)
}
@ -1145,7 +1146,7 @@ class OpenGroupManagerSpec: QuickSpec {
db,
openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer),
calledFromConfigHandling: true, // Don't trigger SessionUtil logic
dependencies: dependencies
using: dependencies
)
}

View File

@ -136,7 +136,14 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension
guard case .preOffer = callMessage.kind else { return self.completeSilenty() }
if !db[.areCallsEnabled] {
if let sender: String = callMessage.sender, let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: callMessage, state: .permissionDenied) {
if
let sender: String = callMessage.sender,
let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(
db,
for: callMessage,
state: .permissionDenied
)
{
let thread: SessionThread = try SessionThread
.fetchOrCreate(
db,
@ -145,12 +152,15 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension
shouldBeVisible: nil
)
Environment.shared?.notificationsManager.wrappedValue?
.notifyUser(
db,
forIncomingCall: interaction,
in: thread
)
// Notify the user if the call message wasn't already read
if !interaction.wasRead {
Environment.shared?.notificationsManager.wrappedValue?
.notifyUser(
db,
forIncomingCall: interaction,
in: thread
)
}
}
break
}

View File

@ -153,7 +153,8 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
tableView.deselectRow(at: indexPath, animated: true)
ShareNavController.attachmentPrepPublisher?
.receiveOnMain(immediately: true)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main, immediatelyIfMain: true)
.sinkUntilComplete(
receiveValue: { [weak self] attachments in
guard let strongSelf = self else { return }
@ -232,18 +233,20 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
try LinkPreview(
url: linkPreviewDraft.urlString,
title: linkPreviewDraft.title,
attachmentId: LinkPreview.saveAttachmentIfPossible(
db,
imageData: linkPreviewDraft.jpegImageData,
mimeType: OWSMimeTypeImageJpeg
)
attachmentId: LinkPreview
.generateAttachmentIfPossible(
imageData: linkPreviewDraft.jpegImageData,
mimeType: OWSMimeTypeImageJpeg
)?
.inserted(db)
.id
).insert(db)
}
// Prepare any attachments
try Attachment.prepare(
try Attachment.process(
db,
attachments: finalAttachments,
data: Attachment.prepare(attachments: finalAttachments),
for: interactionId
)
@ -257,13 +260,9 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView
)
}
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.flatMap {
MessageSender.performUploadsIfNeeded(
queue: DispatchQueue.global(qos: .userInitiated),
preparedSendData: $0
)
}
.flatMap { MessageSender.performUploadsIfNeeded(preparedSendData: $0) }
.flatMap { MessageSender.sendImmediate(preparedSendData: $0) }
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sinkUntilComplete(
receiveCompletion: { [weak self] result in

View File

@ -29,6 +29,7 @@ public class ThreadPickerViewModel {
.fetchAll(db)
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[ThreadPickerViewModel] Observation failed with error: \($0)") })
// MARK: - Functions

View File

@ -56,7 +56,7 @@ public enum GetSnodePoolJob: JobExecutor {
public static func run() {
GetSnodePoolJob.run(
Job(variant: .getSnodePool),
queue: DispatchQueue.global(qos: .background),
queue: .global(qos: .background),
success: { _, _ in },
failure: { _, _, _ in },
deferred: { _ in }

View File

@ -0,0 +1,16 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import SessionUtilitiesKit
extension SnodeAPI {
public struct GetStatsResponse: Codable {
private enum CodingKeys: String, CodingKey {
case versionString = "version"
}
let versionString: String?
var version: Version? { versionString.map { Version.from($0) } }
}
}

View File

@ -71,18 +71,12 @@ public enum OnionRequestAPI: OnionRequestAPIType {
let timeout: TimeInterval = 3 // Use a shorter timeout for testing
return HTTP.execute(.get, url, timeout: timeout)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.tryMap { responseData -> Void in
// TODO: Remove JSON usage
guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else {
throw HTTPError.invalidJSON
}
guard let version = responseJson["version"] as? String else {
throw OnionRequestAPIError.missingSnodeVersion
}
guard version >= "2.0.7" else {
SNLog("Unsupported snode version: \(version).")
throw OnionRequestAPIError.unsupportedSnodeVersion(version)
.decoded(as: SnodeAPI.GetStatsResponse.self)
.tryMap { response -> Void in
guard let version: Version = response.version else { throw OnionRequestAPIError.missingSnodeVersion }
guard version >= Version(major: 2, minor: 0, patch: 7) else {
SNLog("Unsupported snode version: \(version.stringValue).")
throw OnionRequestAPIError.unsupportedSnodeVersion(version.stringValue)
}
return ()
@ -154,63 +148,82 @@ public enum OnionRequestAPI: OnionRequestAPIType {
return existingBuildPathsPublisher
}
SNLog("Building onion request paths.")
DispatchQueue.main.async {
NotificationCenter.default.post(name: .buildingPaths, object: nil)
}
let reusableGuardSnodes = reusablePaths.map { $0[0] }
let publisher: AnyPublisher<[[Snode]], Error> = getGuardSnodes(reusing: reusableGuardSnodes)
.flatMap { guardSnodes -> AnyPublisher<[[Snode]], Error> in
var unusedSnodes = SnodeAPI.snodePool.wrappedValue
.subtracting(guardSnodes)
.subtracting(reusablePaths.flatMap { $0 })
let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count)
let pathSnodeCount = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount)
guard unusedSnodes.count >= pathSnodeCount else {
return Fail<[[Snode]], Error>(error: OnionRequestAPIError.insufficientSnodes)
.eraseToAnyPublisher()
}
// Don't test path snodes as this would reveal the user's IP to them
return Just(
guardSnodes
return buildPathsPublisher.mutate { result in
/// It was possible for multiple threads to call this at the same time resulting in duplicate promises getting created, while
/// this should no longer be possible (as the `wrappedValue` should now properly be blocked) this is a sanity check
/// to make sure we don't create an additional promise when one already exists
if let previouslyBlockedPublisher: AnyPublisher<[[Snode]], Error> = result {
return previouslyBlockedPublisher
}
SNLog("Building onion request paths.")
DispatchQueue.main.async {
NotificationCenter.default.post(name: .buildingPaths, object: nil)
}
/// Need to include the post-request code and a `shareReplay` within the publisher otherwise it can still be executed
/// multiple times as a result of multiple subscribers
let reusableGuardSnodes = reusablePaths.map { $0[0] }
let publisher: AnyPublisher<[[Snode]], Error> = getGuardSnodes(reusing: reusableGuardSnodes)
.flatMap { (guardSnodes: Set<Snode>) -> AnyPublisher<[[Snode]], Error> in
var unusedSnodes: Set<Snode> = SnodeAPI.snodePool.wrappedValue
.subtracting(guardSnodes)
.subtracting(reusablePaths.flatMap { $0 })
let reusableGuardSnodeCount: UInt = UInt(reusableGuardSnodes.count)
let pathSnodeCount: UInt = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount)
guard unusedSnodes.count >= pathSnodeCount else {
return Fail<[[Snode]], Error>(error: OnionRequestAPIError.insufficientSnodes)
.eraseToAnyPublisher()
}
// Don't test path snodes as this would reveal the user's IP to them
let paths: [[Snode]] = guardSnodes
.subtracting(reusableGuardSnodes)
.map { guardSnode in
let result = [ guardSnode ] + (0..<(pathSize - 1)).map { _ in
// randomElement() uses the system's default random generator, which is cryptographically secure
let pathSnode = unusedSnodes.randomElement()! // Safe because of the pathSnodeCount check above
unusedSnodes.remove(pathSnode) // All used snodes should be unique
return pathSnode
}
.map { (guardSnode: Snode) in
let result: [Snode] = [guardSnode]
.appending(
contentsOf: (0..<(pathSize - 1))
.map { _ in
// randomElement() uses the system's default random generator,
// which is cryptographically secure
let pathSnode: Snode = unusedSnodes.randomElement()! // Safe because of the pathSnodeCount check above
unusedSnodes.remove(pathSnode) // All used snodes should be unique
return pathSnode
}
)
SNLog("Built new onion request path: \(result.prettifiedDescription).")
return result
}
return Just(paths)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
.handleEvents(
receiveOutput: { output in
OnionRequestAPI.paths = (output + reusablePaths)
Storage.shared.write { db in
SNLog("Persisting onion request paths to database.")
try? output.save(db)
}
DispatchQueue.main.async {
NotificationCenter.default.post(name: .pathsBuilt, object: nil)
}
},
receiveCompletion: { _ in buildPathsPublisher.mutate { $0 = nil } }
)
.setFailureType(to: Error.self)
.shareReplay(1)
.eraseToAnyPublisher()
}
.handleEvents(
receiveOutput: { output in
OnionRequestAPI.paths = (output + reusablePaths)
Storage.shared.write { db in
SNLog("Persisting onion request paths to database.")
try? output.save(db)
}
DispatchQueue.main.async {
NotificationCenter.default.post(name: .pathsBuilt, object: nil)
}
},
receiveCompletion: { _ in buildPathsPublisher.mutate { $0 = nil } }
)
.eraseToAnyPublisher()
buildPathsPublisher.mutate { $0 = publisher }
return publisher
/// Actually assign the atomic value
result = publisher
return publisher
}
}
/// Returns a `Path` to be used for building an onion request. Builds new paths as needed.
@ -245,6 +258,7 @@ public enum OnionRequestAPI: OnionRequestAPIType {
if let snode = snode {
if let path = paths.first(where: { !$0.contains(snode) }) {
buildPaths(reusing: paths) // Re-build paths in the background
.subscribe(on: DispatchQueue.global(qos: .background))
.sink(receiveCompletion: { _ in cancellable = [] }, receiveValue: { _ in })
.store(in: &cancellable)
@ -269,6 +283,7 @@ public enum OnionRequestAPI: OnionRequestAPIType {
}
else {
buildPaths(reusing: paths) // Re-build paths in the background
.subscribe(on: DispatchQueue.global(qos: .background))
.sink(receiveCompletion: { _ in cancellable = [] }, receiveValue: { _ in })
.store(in: &cancellable)
@ -480,7 +495,6 @@ public enum OnionRequestAPI: OnionRequestAPIType {
var guardSnode: Snode?
return buildOnion(around: payload, targetedAt: destination)
.subscribe(on: Threading.workQueue)
.flatMap { intermediate -> AnyPublisher<(ResponseInfoType, Data?), Error> in
guardSnode = intermediate.guardSnode
let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2"

Some files were not shown because too many files have changed in this diff Show More