diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 95da2432b..e66c454b1 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -593,7 +593,6 @@ FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E728264937000CE219 /* MediaDetailViewController.swift */; }; FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */; }; FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5EB282B8F17000CE219 /* AttachmentError.swift */; }; - FD0BA51B27CD88EC00CC6805 /* BlindedIdMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */; }; FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */; }; FD17D79C27F40B2E00122BE0 /* SMKLegacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacy.swift */; }; FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; }; @@ -653,7 +652,7 @@ FD3C906027E410F700CD579F /* FileUploadResponseSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */; }; FD3C906227E411AF00CD579F /* HeaderSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906127E411AF00CD579F /* HeaderSpec.swift */; }; FD3C906427E4122F00CD579F /* RequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906327E4122F00CD579F /* RequestSpec.swift */; }; - FD3C906727E416AF00CD579F /* BlindedIdMappingSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906627E416AF00CD579F /* BlindedIdMappingSpec.swift */; }; + FD3C906727E416AF00CD579F /* BlindedIdLookupSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906627E416AF00CD579F /* BlindedIdLookupSpec.swift */; }; FD3C906A27E417CE00CD579F /* SodiumUtilitiesSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906927E417CE00CD579F /* SodiumUtilitiesSpec.swift */; }; FD3C906D27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906C27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift */; }; FD3C906F27E43E8700CD579F /* MockBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906E27E43E8700CD579F /* MockBox.swift */; }; @@ -1658,7 +1657,6 @@ FD09C5E728264937000CE219 /* MediaDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDetailViewController.swift; sourceTree = ""; }; FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTypingIndicator.swift; sourceTree = ""; }; FD09C5EB282B8F17000CE219 /* AttachmentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentError.swift; sourceTree = ""; }; - FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdMapping.swift; sourceTree = ""; }; FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D79B27F40B2E00122BE0 /* SMKLegacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacy.swift; sourceTree = ""; }; @@ -1694,7 +1692,7 @@ FD3C905F27E410F700CD579F /* FileUploadResponseSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUploadResponseSpec.swift; sourceTree = ""; }; FD3C906127E411AF00CD579F /* HeaderSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderSpec.swift; sourceTree = ""; }; FD3C906327E4122F00CD579F /* RequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestSpec.swift; sourceTree = ""; }; - FD3C906627E416AF00CD579F /* BlindedIdMappingSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdMappingSpec.swift; sourceTree = ""; }; + FD3C906627E416AF00CD579F /* BlindedIdLookupSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlindedIdLookupSpec.swift; sourceTree = ""; }; FD3C906927E417CE00CD579F /* SodiumUtilitiesSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SodiumUtilitiesSpec.swift; sourceTree = ""; }; FD3C906C27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSenderEncryptionSpec.swift; sourceTree = ""; }; FD3C906E27E43E8700CD579F /* MockBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBox.swift; sourceTree = ""; }; @@ -2424,14 +2422,6 @@ path = General; sourceTree = ""; }; - B8B3201F258B1A540020074B /* Contacts */ = { - isa = PBXGroup; - children = ( - FD0BA51A27CD88EC00CC6805 /* BlindedIdMapping.swift */, - ); - path = Contacts; - sourceTree = ""; - }; B8B558ED26C4B55F00693325 /* Calls */ = { isa = PBXGroup; children = ( @@ -3153,7 +3143,6 @@ C3C2A70A25539DF900C340D1 /* Meta */, FDC4384D27B47FD600C60D73 /* Common Networking */, B8DE1FB226C22F1F0079C9CE /* Calls */, - B8B3201F258B1A540020074B /* Contacts */, C32C5BCB256DC818003C73A2 /* Database */, C300A5BB2554AFFB00555489 /* Messages */, C300A5F02554B08500555489 /* Sending & Receiving */, @@ -3615,7 +3604,7 @@ FD3C906527E416A200CD579F /* Contacts */ = { isa = PBXGroup; children = ( - FD3C906627E416AF00CD579F /* BlindedIdMappingSpec.swift */, + FD3C906627E416AF00CD579F /* BlindedIdLookupSpec.swift */, ); path = Contacts; sourceTree = ""; @@ -5113,7 +5102,6 @@ FD09797527FAB64300936362 /* ProfileManager.swift in Sources */, FD245C57285065F100B966DD /* Poller.swift in Sources */, FDA8EAFE280E8B78002B68E5 /* FailedMessageSendsJob.swift in Sources */, - FD0BA51B27CD88EC00CC6805 /* BlindedIdMapping.swift in Sources */, FD245C6A2850666F00B966DD /* FileServerAPI.swift in Sources */, FDC4386927B4E6B800C60D73 /* String+Utlities.swift in Sources */, FD716E6628502EE200C96BF4 /* CurrentCallProtocol.swift in Sources */, @@ -5487,7 +5475,7 @@ FD3C906D27E43C4B00CD579F /* MessageSenderEncryptionSpec.swift in Sources */, FDC2908D27D70905005DAE71 /* UpdateMessageRequestSpec.swift in Sources */, FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */, - FD3C906727E416AF00CD579F /* BlindedIdMappingSpec.swift in Sources */, + FD3C906727E416AF00CD579F /* BlindedIdLookupSpec.swift in Sources */, FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */, FD078E5C27E29F78000769AF /* MockNonce24Generator.swift in Sources */, ); @@ -6818,7 +6806,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 354; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -6890,7 +6878,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 350; + CURRENT_PROJECT_VERSION = 354; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 2aea6b825..2324dd93c 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -751,7 +751,7 @@ extension ConversationVC: guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return } switch mediaView.attachment.state { - case .pendingDownload, .downloading, .uploading: break + case .pendingDownload, .downloading, .uploading, .invalid: break // Failed uploads should be handled via the "resend" process instead case .failedUpload: break diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 4d44fa286..5f631d953 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -36,6 +36,12 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { public var lastSearchedText: String? public let focusedInteractionId: Int64? // Note: This is used for global search + /// We maintain a local set of ids for attachments which we have automatically created attachmentDownload jobs for + /// in order to avoid creating excessive jobs while the user is actively chatting in a conversation (the attachmentDownload + /// jobs run serially and will only actually perform the download if the attachment hasn't already been downloaded so + /// we don't need to worry about duplicate jobs but it's better to avoid creating duplicate jobs when possible) + private var autoStartedDownloadJobAttachmentIds: Set = [] + public lazy var blockedBannerMessage: String = { switch self.threadData.threadVariant { case .contact: @@ -240,6 +246,44 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { .filter { $0.isTypingIndicator != true } .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs } + // Add download jobs for any attachments which need to be downloaded + let pendingAttachmentsToDownload: [(attachment: Attachment, interactionId: Int64)] = sortedData + .flatMap { viewModel -> [(attachment: Attachment, interactionId: Int64)] in + // Do nothing if this is an incoming message on an untrusted contact thread + guard + viewModel.variant != .standardIncoming || + viewModel.threadIsTrusted || + viewModel.threadVariant != .contact + else { return [] } + + return (viewModel.attachments ?? []) + .appending(viewModel.quoteAttachment) + .appending(viewModel.linkPreviewAttachment) + .filter { $0.state == .pendingDownload } + .filter { !self.autoStartedDownloadJobAttachmentIds.contains($0.id) } + .map { ($0, viewModel.id) } + } + + if !pendingAttachmentsToDownload.isEmpty { + GRDBStorage.shared.writeAsync { db in + pendingAttachmentsToDownload.forEach { attachment, interactionId in + JobRunner.add( + db, + job: Job( + variant: .attachmentDownload, + threadId: self.threadId, + interactionId: interactionId, + details: AttachmentDownloadJob.Details( + attachmentId: attachment.id + ) + ) + ) + + self.autoStartedDownloadJobAttachmentIds.insert(attachment.id) + } + } + } + // We load messages from newest to oldest so having a pageOffset larger than zero means // there are newer pages to load return [ diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift index 22fc0ea74..054d19271 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift @@ -96,7 +96,7 @@ public extension LinkPreview { return .loaded case .pendingDownload, .downloading, .uploading: return .loading - case .failedDownload, .failedUpload: return .invalid + case .failedDownload, .failedUpload, .invalid: return .invalid } } diff --git a/Session/Conversations/Views & Modals/BlockedModal.swift b/Session/Conversations/Views & Modals/BlockedModal.swift index 854e2fc36..9bcc28c02 100644 --- a/Session/Conversations/Views & Modals/BlockedModal.swift +++ b/Session/Conversations/Views & Modals/BlockedModal.swift @@ -73,21 +73,20 @@ final class BlockedModal: Modal { contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing) } - // MARK: Interaction + // MARK: - Interaction + @objc private func unblock() { let publicKey: String = self.publicKey - GRDBStorage.shared.writeAsync( - updates: { db in - try? Contact - .fetchOne(db, id: publicKey)? - .with(isBlocked: true) - .update(db) - }, - completion: { db, _ in - try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() - } - ) + GRDBStorage.shared.writeAsync { db in + try Contact + .filter(id: publicKey) + .updateAll(db, Contact.Columns.isBlocked.set(to: true)) + + try MessageSender + .syncConfiguration(db, forceSyncNow: true) + .retainUntilComplete() + } presentingViewController?.dismiss(animated: true, completion: nil) } diff --git a/Session/Media Viewing & Editing/MediaDetailViewController.swift b/Session/Media Viewing & Editing/MediaDetailViewController.swift index b83035af2..0ae1ff5a3 100644 --- a/Session/Media Viewing & Editing/MediaDetailViewController.swift +++ b/Session/Media Viewing & Editing/MediaDetailViewController.swift @@ -155,9 +155,7 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate, OWSVid self.mediaView.removeFromSuperview() self.playVideoButton.removeFromSuperview() self.videoProgressBar.removeFromSuperview() - - // TODO: COnfirm this - scrollView.zoomScale = 1 + self.scrollView.zoomScale = 1 if self.galleryItem.attachment.isAnimated { if self.galleryItem.attachment.isValid, let originalFilePath: String = self.galleryItem.attachment.originalFilePath { diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index 3ebda7272..12d7713ec 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Loslösen"; "modal_call_missed_tips_title" = "Verpasster Anruf"; "modal_call_missed_tips_explanation" = "Verpasster Anruf von '%@', da du die Berechtigung 'Anrufe und Videoanrufe' in den Datenschutzeinstellungen aktivieren musst."; -"meida_saved" = "Medien gespeichert von %@."; +"media_saved" = "Medien gespeichert von %@."; "screenshot_taken" = "%@ hat ein Screenshot gemacht."; "SEARCH_SECTION_CONTACTS" = "Kontakte und Gruppen"; "SEARCH_SECTION_MESSAGES" = "Nachrichten"; diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index f46ff4cbf..57ef01ba2 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Unpin"; "modal_call_missed_tips_title" = "Call missed"; "modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings."; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ took a screenshot."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; @@ -660,3 +660,4 @@ "MESSAGE_TRIMMING_TITLE" = "Message Trimming"; "MESSAGE_TRIMMING_OPEN_GROUP_TITLE" = "Delete Old Open Group Messages"; "MESSAGE_TRIMMING_OPEN_GROUP_DESCRIPTION" = "Automatically delete open group messages which are older than 6 months when starting the app"; + diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index 6c0ce3a4f..c721a032e 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Dejar de fijar"; "modal_call_missed_tips_title" = "Llamada perdida"; "modal_call_missed_tips_explanation" = "Llamada perdida de '%@' porque necesitas habilitar el permiso de 'Llamadas de voz y video' en la configuración de privacidad."; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ tomó una captura de pantalla."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index cf91d20ac..b15fa022f 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Unpin"; "modal_call_missed_tips_title" = "Call missed"; "modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings."; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ took a screenshot."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index c30b60393..4c4ed311e 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Irrota"; "modal_call_missed_tips_title" = "Vastaamaton puhelu"; "modal_call_missed_tips_explanation" = "Vastaamaton puhelu käyttäjältä '%@', koska pahelut edellyttävät 'Ääni- ja videopuhelut' -käyttöoikeuden yksityisyysasetuksista."; -"meida_saved" = "%@ tallensi median."; +"media_saved" = "%@ tallensi median."; "screenshot_taken" = "%@ otti kuvankaappauksen."; "SEARCH_SECTION_CONTACTS" = "Henkilöt ja ryhmät"; "SEARCH_SECTION_MESSAGES" = "Viestit"; diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index 820120d7c..52ca9e487 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Désépingler"; "modal_call_missed_tips_title" = "Appel manqué"; "modal_call_missed_tips_explanation" = "Appel manqué de '%@' car vous devez activer la permission 'Appels vocaux et vidéo' dans les paramètres de confidentialité."; -"meida_saved" = "%@ a enregistré le média."; +"media_saved" = "%@ a enregistré le média."; "screenshot_taken" = "%@ a pris une capture d'écran."; "SEARCH_SECTION_CONTACTS" = "Contacts et Groupes"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index e862c25c0..5bdba14ac 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Unpin"; "modal_call_missed_tips_title" = "Call missed"; "modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings."; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ took a screenshot."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index e40b5492e..bacb148c8 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Otkvači"; "modal_call_missed_tips_title" = "Propušten poziv"; "modal_call_missed_tips_explanation" = "Propušten poziv od '%@' jer 'Audio i video pozivi' nemaju dopuštenje u Postavkama privatnosti."; -"meida_saved" = "%@ je spremio/la medij."; +"media_saved" = "%@ je spremio/la medij."; "screenshot_taken" = "%@ je napravio/la snimku zaslona."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index 3ffd04b51..b3ccf6522 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Unpin"; "modal_call_missed_tips_title" = "Call missed"; "modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings."; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ took a screenshot."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index bbd0183d8..b00a8fab3 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Non fissare in alto"; "modal_call_missed_tips_title" = "Chiamata persa"; "modal_call_missed_tips_explanation" = "Chiamata persa da '%@' perché era necessario abilitare l'autorizzazione 'Voce e video chiamate' nelle Impostazioni Privacy."; -"meida_saved" = "Media salvato da %@."; +"media_saved" = "Media salvato da %@."; "screenshot_taken" = "%@ ha acquisito uno screenshot."; "SEARCH_SECTION_CONTACTS" = "Contatti e Gruppi"; "SEARCH_SECTION_MESSAGES" = "Messaggi"; diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index 108805053..7be122fca 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "ピン留めを外す"; "modal_call_missed_tips_title" = "通話できません"; "modal_call_missed_tips_explanation" = "プライバシー設定で「音声通話とビデオ通話」を許可していないため、%@から着信できませんでした。"; -"meida_saved" = "%@ によって保存されたメディア"; +"media_saved" = "%@ によって保存されたメディア"; "screenshot_taken" = "%@はスクリーンショットを撮りました。"; "SEARCH_SECTION_CONTACTS" = "連絡先とグループ"; "SEARCH_SECTION_MESSAGES" = "メッセージ"; diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index 6aadc1caf..ac4a696b4 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Losmaken"; "modal_call_missed_tips_title" = "Oproep gemist"; "modal_call_missed_tips_explanation" = "Oproep gemist van '%@' omdat je de 'Spraak- en video-oproep' permissie nodig hebt in de privacy-instellingen."; -"meida_saved" = "Media opgeslagen door %@."; +"media_saved" = "Media opgeslagen door %@."; "screenshot_taken" = "%@ heeft een schermafbeelding genomen."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index 2e93d89d6..e873b2e2e 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Odepnij"; "modal_call_missed_tips_title" = "Połączenie nieodebrane"; "modal_call_missed_tips_explanation" = "Połączenie nieodebrane od '%@' ponieważ musisz włączyć uprawnienie 'Połączenia głosowe i wideo' w Ustawieniach Prywatności."; -"meida_saved" = "Media zapisane przez %@."; +"media_saved" = "Media zapisane przez %@."; "screenshot_taken" = "%@ wykonał zrzut ekranu."; "SEARCH_SECTION_CONTACTS" = "Kontakty i grupy"; "SEARCH_SECTION_MESSAGES" = "Wiadomości"; diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index 3a933083a..218d22cac 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Desfixar"; "modal_call_missed_tips_title" = "Chamada perdida"; "modal_call_missed_tips_explanation" = "Chamada perdida de '%@', você precisa habilitar a permissão de 'Voz e Video' nas configurações de Privacidade."; -"meida_saved" = "Mídia salva por %@."; +"media_saved" = "Mídia salva por %@."; "screenshot_taken" = "%@ fez uma captura de tela."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index e6f0421e3..2f501ee7d 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Открепить"; "modal_call_missed_tips_title" = "Пропущен вызов"; "modal_call_missed_tips_explanation" = "Вызов от '%@' пропущен, вам необходимо включить разрешение 'Голосовые и видео вызовы' в настройках Конфиденциальности."; -"meida_saved" = "%@ сохранил(а) медиафайл."; +"media_saved" = "%@ сохранил(а) медиафайл."; "screenshot_taken" = "%@ сделал(а) снимок экрана."; "SEARCH_SECTION_CONTACTS" = "Контакты и группы"; "SEARCH_SECTION_MESSAGES" = "Сообщения"; diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index 04d1ea3d2..14696b268 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Unpin"; "modal_call_missed_tips_title" = "Call missed"; "modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings."; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ took a screenshot."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index d2ed3836a..cdf5f5f7f 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Zrušiť pripnutie"; "modal_call_missed_tips_title" = "Zmeškaný hovor"; "modal_call_missed_tips_explanation" = "Zmeškaný hovor od %@ pretože ste potrebovali zapnúť povolenie pre 'Hlasové a video hovory' v Nastaveniach Súkromia."; -"meida_saved" = "Médiá uložené používateľom %@."; +"media_saved" = "Médiá uložené používateľom %@."; "screenshot_taken" = "%@ urobili snímku obrazovky."; "SEARCH_SECTION_CONTACTS" = "Kontakty a Skupiny"; "SEARCH_SECTION_MESSAGES" = "Správy"; diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index 617b930c5..ccc97107f 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Unpin"; "modal_call_missed_tips_title" = "Call missed"; "modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings."; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ took a screenshot."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index 5832a403b..656d65ef6 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Unpin"; "modal_call_missed_tips_title" = "Call missed"; "modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings."; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ took a screenshot."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index 5835cc170..2a0d7928c 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "Unpin"; "modal_call_missed_tips_title" = "Call missed"; "modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings."; -"meida_saved" = "Media saved by %@."; +"media_saved" = "Media saved by %@."; "screenshot_taken" = "%@ took a screenshot."; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index 333724265..87e8a6c3e 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "取消置頂"; "modal_call_missed_tips_title" = "未接來電"; "modal_call_missed_tips_explanation" = "Call missed from '%@' because you needed to enable the 'Voice and video calls' permission in the Privacy Settings."; -"meida_saved" = "%@ 儲存了媒體"; +"media_saved" = "%@ 儲存了媒體"; "screenshot_taken" = "%@ 擷取了螢幕畫面"; "SEARCH_SECTION_CONTACTS" = "Contacts and Groups"; "SEARCH_SECTION_MESSAGES" = "Messages"; diff --git a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings index 637d27c67..46cad399f 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -627,7 +627,7 @@ "UNPIN_BUTTON_TEXT" = "取消置顶"; "modal_call_missed_tips_title" = "未接来电"; "modal_call_missed_tips_explanation" = "未接听 '%@',因为您需要在隐私设置中启用“语音和视频通话”权限。"; -"meida_saved" = "%@ 保存了媒体内容。"; +"media_saved" = "%@ 保存了媒体内容。"; "screenshot_taken" = "%@ 进行了截图。"; "SEARCH_SECTION_CONTACTS" = "联系人和群组"; "SEARCH_SECTION_MESSAGES" = "消息"; diff --git a/SessionMessagingKit/Common Networking/Header.swift b/SessionMessagingKit/Common Networking/Header.swift index 97ea01ef7..6c33e41a3 100644 --- a/SessionMessagingKit/Common Networking/Header.swift +++ b/SessionMessagingKit/Common Networking/Header.swift @@ -7,8 +7,6 @@ enum Header: String { case contentType = "Content-Type" case contentDisposition = "Content-Disposition" - case room = "Room" // TODO: Confirm this is needed - case sogsPubKey = "X-SOGS-Pubkey" case sogsNonce = "X-SOGS-Nonce" case sogsTimestamp = "X-SOGS-Timestamp" diff --git a/SessionMessagingKit/Contacts/BlindedIdMapping.swift b/SessionMessagingKit/Contacts/BlindedIdMapping.swift deleted file mode 100644 index 5b289c96b..000000000 --- a/SessionMessagingKit/Contacts/BlindedIdMapping.swift +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -@objc(SNBlindedIdMapping) -public final class BlindedIdMapping: NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility - @objc public let blindedId: String - @objc public let sessionId: String - @objc public let serverPublicKey: String - - // MARK: - Initialization - - @objc public init(blindedId: String, sessionId: String, serverPublicKey: String) { - self.blindedId = blindedId - self.sessionId = sessionId - self.serverPublicKey = serverPublicKey - - super.init() - } - - private override init() { preconditionFailure("Use init(blindedId:sessionId:) instead.") } - - // MARK: - Coding - - public required init?(coder: NSCoder) { - guard let blindedId: String = coder.decodeObject(forKey: "blindedId") as! String? else { return nil } - guard let sessionId: String = coder.decodeObject(forKey: "sessionId") as! String? else { return nil } - guard let serverPublicKey: String = coder.decodeObject(forKey: "serverPublicKey") as! String? else { return nil } - - self.blindedId = blindedId - self.sessionId = sessionId - self.serverPublicKey = serverPublicKey - } - - public func encode(with coder: NSCoder) { - coder.encode(blindedId, forKey: "blindedId") - coder.encode(sessionId, forKey: "sessionId") - coder.encode(serverPublicKey, forKey: "serverPublicKey") - } -} diff --git a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift index ac82f040a..b488653b8 100644 --- a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -48,11 +48,8 @@ enum _002_SetupStandardJobs: Migration { _ = try Job( variant: .garbageCollection, - behaviour: .recurringOnActive, - details: GarbageCollectionJob.Details( - typesToCollect: GarbageCollectionJob.Types.allCases - ) - )?.inserted(db) + behaviour: .recurringOnActive + ).inserted(db) } GRDBStorage.update(progress: 1, for: self, in: target) // In case this is the last migration diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index 0ef03a903..ec5db6db8 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -841,7 +841,6 @@ enum _003_YDBToGRDBMigration: Migration { } default: - // TODO: What message types have no body? SNLog("[Migration Error] Unsupported interaction type") throw StorageError.migrationFailed } @@ -926,7 +925,6 @@ enum _003_YDBToGRDBMigration: Migration { receivedMessageTimestamps.remove(legacyInteraction.timestamp) guard let interactionId: Int64 = interaction.id else { - // TODO: Is it possible the old database has duplicates which could hit this case? SNLog("[Migration Error] Failed to insert interaction") throw StorageError.migrationFailed } @@ -1072,13 +1070,9 @@ enum _003_YDBToGRDBMigration: Migration { // Note: The `legacyInteraction.timestamp` value is in milliseconds let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: Double(legacyInteraction.timestamp)) - guard linkPreview.imageAttachmentId == nil || attachments[linkPreview.imageAttachmentId ?? ""] != nil else { - // TODO: Is it possible to hit this case if a quoted attachment hasn't been downloaded? - SNLog("[Migration Error] Missing link preview attachment") - throw StorageError.migrationFailed - } - - // Setup the attachment and add it to the lookup (if it exists) + // Setup the attachment and add it to the lookup (if it exists - we do actually + // support link previews with no image attachments so no need to throw migration + // errors in those cases) let attachmentId: String? = try attachmentId( db, for: linkPreview.imageAttachmentId, @@ -1100,15 +1094,28 @@ enum _003_YDBToGRDBMigration: Migration { // Handle any attachments try attachmentIds.enumerated().forEach { index, legacyAttachmentId in - guard let attachmentId: String = try attachmentId( + let maybeAttachmentId: String? = (try attachmentId( db, for: legacyAttachmentId, interactionVariant: variant, attachments: attachments, processedAttachmentIds: &processedAttachmentIds - ) else { - SNLog("[Migration Error] Missing interaction attachment") -// throw StorageError.migrationFailed + )) + .defaulting( + // It looks like somehow messages could exist in the old database which + // referenced attachments but had no attachments in the database; doing + // nothing here results in these messages appearing as empty message + // bubbles so instead we want to insert invalid attachments instead + to: try invalidAttachmentId( + db, + for: legacyAttachmentId, + attachments: attachments, + processedAttachmentIds: &processedAttachmentIds + ) + ) + + guard let attachmentId: String = maybeAttachmentId else { + SNLog("[Migration Warning] Failed to create invalid attachment for missing attachment") return } @@ -1457,7 +1464,7 @@ enum _003_YDBToGRDBMigration: Migration { } guard let legacyAttachment: SMKLegacy._Attachment = attachments[legacyAttachmentId] else { - SNLog("[Migration Warning] Missing attachment - interaction will appear as blank") + SNLog("[Migration Warning] Missing attachment - interaction will show a \"failed\" attachment") return nil } @@ -1589,6 +1596,45 @@ enum _003_YDBToGRDBMigration: Migration { return legacyAttachmentId } + private static func invalidAttachmentId( + _ db: Database, + for legacyAttachmentId: String, + interactionVariant: Interaction.Variant? = nil, + attachments: [String: SMKLegacy._Attachment], + processedAttachmentIds: inout Set + ) throws -> String { + guard !processedAttachmentIds.contains(legacyAttachmentId) else { + return legacyAttachmentId + } + + _ = try Attachment( + // Note: The legacy attachment object used a UUID string for it's id as well + // and saved files using these id's so just used the existing id so we don't + // need to bother renaming files as part of the migration + id: legacyAttachmentId, + serverId: nil, + variant: .standard, + state: .invalid, + contentType: "", + byteCount: 0, + creationTimestamp: Date().timeIntervalSince1970, + sourceFilename: nil, + downloadUrl: nil, + localRelativeFilePath: nil, + width: nil, + height: nil, + duration: nil, + isValid: false, + encryptionKey: nil, + digest: nil, + caption: nil + ).inserted(db) + + processedAttachmentIds.insert(legacyAttachmentId) + + return legacyAttachmentId + } + private static func mapLegacyTypesForNSKeyedUnarchiver() { NSKeyedUnarchiver.setClass( SMKLegacy._Thread.self, diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index c04ea6aef..d0201658a 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -56,6 +56,8 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR case failedUpload case uploading case uploaded + + case invalid = 100 } /// A unique identifier for the attachment @@ -939,6 +941,8 @@ extension Attachment { return } + let attachmentId: String = self.id + // 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 it to be encrypted and it's not @@ -956,16 +960,20 @@ extension Attachment { // Save the final upload info let uploadedAttachment: Attachment? = { guard let db: Database = db else { - return GRDBStorage.shared.write { db in - try? self - .with(state: .uploaded) - .saved(db) + GRDBStorage.shared.write { db in + try? Attachment + .filter(id: attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploaded)) } + + return self.with(state: .uploaded) } - return try? self - .with(state: .uploaded) - .saved(db) + _ = try? Attachment + .filter(id: attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploaded)) + + return self.with(state: .uploaded) }() guard uploadedAttachment != nil else { @@ -1008,16 +1016,20 @@ extension Attachment { // Update the attachment to the 'uploading' state let updatedAttachment: Attachment? = { guard let db: Database = db else { - return GRDBStorage.shared.write { db in - try? processedAttachment - .with(state: .uploading) - .saved(db) + GRDBStorage.shared.write { db in + try? Attachment + .filter(id: attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading)) } + + return processedAttachment.with(state: .uploading) } - return try? processedAttachment - .with(state: .uploading) - .saved(db) + _ = try? Attachment + .filter(id: attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading)) + + return processedAttachment.with(state: .uploading) }() guard updatedAttachment != nil else { @@ -1062,9 +1074,9 @@ extension Attachment { } .catch(on: queue) { error in GRDBStorage.shared.write { db in - try updatedAttachment? - .with(state: .failedUpload) - .saved(db) + try Attachment + .filter(id: attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) } failure?(error) diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index dcd4b9e81..48be3511a 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -78,18 +78,6 @@ public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRe } } -// MARK: - Mutation - -public extension ClosedGroup { - func with(name: String) -> ClosedGroup { - return ClosedGroup( - threadId: threadId, - name: name, - formationTimestamp: formationTimestamp - ) - } -} - // MARK: - GRDB Interactions public extension ClosedGroup { diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 03cb18f66..6aea5fa3b 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -79,13 +79,8 @@ public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, Persis public extension LinkPreview { init?(_ db: Database, proto: SNProtoDataMessage, body: String?, sentTimestampMs: TimeInterval) throws { guard let previewProto = proto.preview.first else { throw LinkPreviewError.noPreview } - guard proto.attachments.count < 1 else { throw LinkPreviewError.invalidInput } guard URL(string: previewProto.url) != nil else { throw LinkPreviewError.invalidInput } guard LinkPreview.isValidLinkUrl(previewProto.url) else { throw LinkPreviewError.invalidInput } - guard let body: String = body else { throw LinkPreviewError.invalidInput } - guard LinkPreview.allPreviewUrls(forMessageBodyText: body).contains(previewProto.url) else { - throw LinkPreviewError.invalidInput - } // Try to get an existing link preview first let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: sentTimestampMs) diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 0ab4a96ce..da3b236cc 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -151,7 +151,7 @@ public extension OpenGroup { roomToken: roomToken, publicKey: publicKey, isActive: false, - name: "", + name: roomToken, // Default the name to the `roomToken` until we get retrieve the actual name roomDescription: nil, imageId: nil, imageData: nil, diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 977eb4541..328de9fe6 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -200,7 +200,6 @@ public extension Profile { public extension Profile { func with( name: String? = nil, - nickname: Updatable = .existing, profilePictureUrl: Updatable = .existing, profilePictureFileName: Updatable = .existing, profileEncryptionKey: Updatable = .existing @@ -208,7 +207,7 @@ public extension Profile { return Profile( id: id, name: (name ?? self.name), - nickname: (nickname ?? self.nickname), + nickname: self.nickname, profilePictureUrl: (profilePictureUrl ?? self.profilePictureUrl), profilePictureFileName: (profilePictureFileName ?? self.profilePictureFileName), profileEncryptionKey: (profileEncryptionKey ?? self.profileEncryptionKey) @@ -406,9 +405,9 @@ public class SMKProfile: NSObject { let profile: Profile = Profile.fetchOrCreate(id: profileId) let targetNickname: String? = ((nickname ?? "").count > 0 ? nickname : nil) - _ = try profile - .with(nickname: .update(targetNickname)) - .saved(db) + try Profile + .filter(id: profile.id) + .updateAll(db, Profile.Columns.nickname.set(to: targetNickname)) return (targetNickname ?? profile.name) } diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index 2c9755347..9fa244677 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -7,7 +7,7 @@ import SessionSnodeKit import SignalCoreKit public enum AttachmentDownloadJob: JobExecutor { - public static var maxFailureCount: Int = 10 + public static var maxFailureCount: Int = 3 public static var requiresThreadId: Bool = true public static let requiresInteractionId: Bool = true @@ -30,13 +30,50 @@ public enum AttachmentDownloadJob: JobExecutor { } // Due to the complex nature of jobs and how attachments can be reused it's possible for - // and AttachmentDownloadJob to get created for an attachment which has already been + // an AttachmentDownloadJob to get created for an attachment which has already been // downloaded/uploaded so in those cases just succeed immediately guard attachment.state != .downloaded && attachment.state != .uploaded else { success(job, false) return } + // If we ever make attachment downloads concurrent this will prevent us from downloading + // the same attachment multiple times at the same time (it also adds a "clean up" mechanism + // if an attachment ends up stuck in a "downloading" state incorrectly + guard attachment.state != .downloading else { + let otherCurrentJobAttachmentIds: Set = JobRunner + .defailsForCurrentlyRunningJobs(of: .attachmentDownload) + .filter { key, _ in key != job.id } + .values + .compactMap { data -> String? in + guard let data: Data = data else { return nil } + + return (try? JSONDecoder().decode(Details.self, from: data))? + .attachmentId + } + .asSet() + + // If there isn't another currently running attachmentDownload job downloading this attachment + // then we should update the state of the attachment to be failed to avoid having attachments + // appear in an endlessly downloading state + if !otherCurrentJobAttachmentIds.contains(attachment.id) { + GRDBStorage.shared.write { db in + _ = try Attachment + .filter(id: attachment.id) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedDownload)) + } + } + + // Note: The only ways we should be able to get into this state are if we enable concurrent + // downloads or if the app was closed/crashed while an attachmentDownload job was in progress + // + // If there is another current job then just fail this one permanently, otherwise let it + // retry (if there are more retry attempts available) and in the next retry it's state should + // be 'failedDownload' so we won't get stuck in a loop + failure(job, nil, otherCurrentJobAttachmentIds.contains(attachment.id)) + return + } + // Update to the 'downloading' state (no need to update the 'attachment' instance) GRDBStorage.shared.write { db in try Attachment @@ -123,25 +160,43 @@ public enum AttachmentDownloadJob: JobExecutor { .catch(on: queue) { error in OWSFileSystem.deleteFile(temporaryFileUrl.path) + let targetState: Attachment.State + let permanentFailure: Bool + switch error { - case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 400: - /// Otherwise, the attachment will show a state of downloading forever, and the message - /// won't be able to be marked as read - /// - /// **Note:** We **MUST** use the `'with()` function here as it will update the - /// `isValid` and `duration` values based on the downloaded data and the state - GRDBStorage.shared.write { db in - _ = try attachment - .with(state: .failedDownload) - .saved(db) - } - - // This usually indicates a file that has expired on the server, so there's no need to retry - failure(job, error, true) + /// If we get a 404 then we got a successful response from the server but the attachment doesn't + /// exist, in this case update the attachment to an "invalid" state so the user doesn't get stuck in + /// a retry download loop + case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 404: + targetState = .invalid + permanentFailure = true + case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 400 || statusCode == 401: + /// If we got a 400 or a 401 then we want to fail the download in a way that has to be manually retried as it's + /// likely something else is going on that caused the failure + targetState = .failedDownload + permanentFailure = true + + /// For any other error it's likely either the server is down or something weird just happened with the request + /// so we want to automatically retry default: - failure(job, error, false) + targetState = .failedDownload + permanentFailure = false } + + /// To prevent the attachment from showing a state of downloading forever, we need to update the attachment + /// state here based on the type of error that occurred + /// + /// **Note:** We **MUST** use the `'with()` function here as it will update the + /// `isValid` and `duration` values based on the downloaded data and the state + GRDBStorage.shared.write { db in + _ = try Attachment + .filter(id: attachment.id) + .updateAll(db, Attachment.Columns.state.set(to: targetState)) + } + + /// Trigger the failure and provide the `permanentFailure` value defined above + failure(job, error, permanentFailure) } } } diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index 78225cf0c..dd89ee21b 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -7,6 +7,10 @@ import SignalCoreKit import SessionUtilitiesKit import SessionSnodeKit +/// This job deletes unused and orphaned data from the database as well as orphaned files from device storage +/// +/// **Note:** When sheduling this job if no `Details` are provided (with a list of `typesToCollect`) then this job will +/// assume that it should be collecting all `Types` public enum GarbageCollectionJob: JobExecutor { public static var maxFailureCount: Int = -1 public static var requiresThreadId: Bool = false @@ -20,39 +24,33 @@ public enum GarbageCollectionJob: JobExecutor { failure: @escaping (Job, Error?, Bool) -> (), deferred: @escaping (Job) -> () ) { - guard - let detailsData: Data = job.details, - let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) - else { - failure(job, JobRunnerError.missingRequiredDetails, false) - return - } - - // If there are no types to collect then complete the job (and never run again - it doesn't do anything) - guard !details.typesToCollect.isEmpty else { - success(job, true) - return - } - + /// Determine what types of data we want to collect (if we didn't provide any then assume we want to collect everything) + /// + /// **Note:** The reason we default to handle all cases (instead of just doing nothing in that case) is so the initial registration + /// of the garbageCollection job never needs to be updated as we continue to add more types going forward + let typesToCollect: [Types] = (job.details + .map { try? JSONDecoder().decode(Details.self, from: $0) }? + .typesToCollect) + .defaulting(to: Types.allCases) let timestampNow: TimeInterval = Date().timeIntervalSince1970 GRDBStorage.shared.writeAsync( updates: { db in /// Remove any expired controlMessageProcessRecords - if details.typesToCollect.contains(.expiredControlMessageProcessRecords) { + if typesToCollect.contains(.expiredControlMessageProcessRecords) { _ = try ControlMessageProcessRecord .filter(ControlMessageProcessRecord.Columns.serverExpirationTimestamp <= timestampNow) .deleteAll(db) } /// Remove any typing indicators - if details.typesToCollect.contains(.threadTypingIndicators) { + if typesToCollect.contains(.threadTypingIndicators) { _ = try ThreadTypingIndicator .deleteAll(db) } /// Remove any old open group messages - open group messages which are older than six months - if details.typesToCollect.contains(.oldOpenGroupMessages) && db[.trimOpenGroupMessagesOlderThanSixMonths] { + if typesToCollect.contains(.oldOpenGroupMessages) && db[.trimOpenGroupMessagesOlderThanSixMonths] { let interaction: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() @@ -71,7 +69,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned jobs - jobs which have had their threads or interactions removed - if details.typesToCollect.contains(.orphanedJobs) { + if typesToCollect.contains(.orphanedJobs) { let job: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() @@ -97,7 +95,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned link previews - link previews which have no interactions with matching url & rounded timestamps - if details.typesToCollect.contains(.orphanedLinkPreviews) { + if typesToCollect.contains(.orphanedLinkPreviews) { let linkPreview: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() @@ -117,7 +115,7 @@ public enum GarbageCollectionJob: JobExecutor { /// Orphaned open groups - open groups which are no longer associated to a thread (except for the session-run ones for which /// we want cached image data even if the user isn't in the group) - if details.typesToCollect.contains(.orphanedOpenGroups) { + if typesToCollect.contains(.orphanedOpenGroups) { let openGroup: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() @@ -136,7 +134,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned open group capabilities - capabilities which have no existing open groups with the same server - if details.typesToCollect.contains(.orphanedOpenGroupCapabilities) { + if typesToCollect.contains(.orphanedOpenGroupCapabilities) { let capability: TypedTableAlias = TypedTableAlias() let openGroup: TypedTableAlias = TypedTableAlias() @@ -152,7 +150,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned blinded id lookups - lookups which have no existing threads or approval/block settings for either blinded/un-blinded id - if details.typesToCollect.contains(.orphanedBlindedIdLookups) { + if typesToCollect.contains(.orphanedBlindedIdLookups) { let blindedIdLookup: TypedTableAlias = TypedTableAlias() let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() @@ -178,8 +176,28 @@ public enum GarbageCollectionJob: JobExecutor { """) } + /// Approved blinded contact records - once a blinded contact has been approved there is no need to keep the blinded + /// contact record around anymore + if typesToCollect.contains(.approvedBlindedContactRecords) { + let contact: TypedTableAlias = TypedTableAlias() + let blindedIdLookup: TypedTableAlias = TypedTableAlias() + + try db.execute(literal: """ + DELETE FROM \(Contact.self) + WHERE \(Column.rowID) IN ( + SELECT \(contact.alias[Column.rowID]) + FROM \(Contact.self) + LEFT JOIN \(BlindedIdLookup.self) ON ( + \(blindedIdLookup[.blindedId]) = \(contact[.id]) AND + \(blindedIdLookup[.sessionId]) IS NOT NULL + ) + WHERE \(blindedIdLookup[.sessionId]) IS NOT NULL + ) + """) + } + /// Orphaned attachments - attachments which have no related interactions, quotes or link previews - if details.typesToCollect.contains(.orphanedAttachments) { + if typesToCollect.contains(.orphanedAttachments) { let attachment: TypedTableAlias = TypedTableAlias() let quote: TypedTableAlias = TypedTableAlias() let linkPreview: TypedTableAlias = TypedTableAlias() @@ -216,7 +234,7 @@ public enum GarbageCollectionJob: JobExecutor { var profileAvatarFilenames: Set = [] /// Orphaned attachment files - attachment files which don't have an associated record in the database - if details.typesToCollect.contains(.orphanedAttachmentFiles) { + if typesToCollect.contains(.orphanedAttachmentFiles) { /// **Note:** Thumbnails are stored in the `NSCachesDirectory` directory which should be automatically manage /// it's own garbage collection so we can just ignore it according to the various comments in the following stack overflow /// post, the directory will be cleared during app updates as well as if the system is running low on memory (if the app isn't running) @@ -229,7 +247,7 @@ public enum GarbageCollectionJob: JobExecutor { } /// Orphaned profile avatar files - profile avatar files which don't have an associated record in the database - if details.typesToCollect.contains(.orphanedProfileAvatars) { + if typesToCollect.contains(.orphanedProfileAvatars) { profileAvatarFilenames = try Profile .select(.profilePictureFileName) .filter(Profile.Columns.profilePictureFileName != nil) @@ -252,7 +270,7 @@ public enum GarbageCollectionJob: JobExecutor { var deletionErrors: [Error] = [] // Orphaned attachment files (actual deletion) - if details.typesToCollect.contains(.orphanedAttachmentFiles) { + if typesToCollect.contains(.orphanedAttachmentFiles) { // Note: Looks like in order to recursively look through files we need to use the // enumerator method let fileEnumerator = FileManager.default.enumerator( @@ -294,7 +312,7 @@ public enum GarbageCollectionJob: JobExecutor { } // Orphaned profile avatar files (actual deletion) - if details.typesToCollect.contains(.orphanedProfileAvatars) { + if typesToCollect.contains(.orphanedProfileAvatars) { let allAvatarProfileFilenames: Set = (try? FileManager.default .contentsOfDirectory(atPath: ProfileManager.sharedDataProfileAvatarsDirPath)) .defaulting(to: []) @@ -339,6 +357,7 @@ extension GarbageCollectionJob { case orphanedOpenGroups case orphanedOpenGroupCapabilities case orphanedBlindedIdLookups + case approvedBlindedContactRecords case orphanedAttachments case orphanedAttachmentFiles case orphanedProfileAvatars diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift index d398e4b47..a9e24c8f7 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift @@ -212,9 +212,9 @@ extension MessageReceiver { guard case let .nameChange(name) = message.kind else { return } try performIfValid(db, message: message) { id, sender, thread, closedGroup in - try closedGroup - .with(name: name) - .save(db) + _ = try ClosedGroup + .filter(id: id) + .updateAll(db, ClosedGroup.Columns.name.set(to: name)) // Notify the user if needed guard name != closedGroup.name else { return } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 97806cff0..4692851d9 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -204,20 +204,20 @@ extension MessageReceiver { message.attachmentIds = attachments.map { $0.id } // Persist quote if needed - let quote: Quote? = try? Quote( + try? Quote( db, proto: dataMessage, interactionId: interactionId, thread: thread - )?.inserted(db) + )?.insert(db) // Parse link preview if needed - let linkPreview: LinkPreview? = try? LinkPreview( + try? LinkPreview( db, proto: dataMessage, body: message.text, sentTimestampMs: (messageSentTimestamp * 1000) - )?.saved(db) + )?.save(db) // Open group invitations are stored as LinkPreview values so create one if needed if @@ -232,30 +232,6 @@ extension MessageReceiver { ).save(db) } - // Start attachment downloads if needed (ie. trusted contact or group thread) - let isContactTrusted: Bool = ((try? Contact.fetchOne(db, id: sender))?.isTrusted ?? false) - - if isContactTrusted || thread.variant != .contact { - attachments - .map { $0.id } - .appending(quote?.attachmentId) - .appending(linkPreview?.attachmentId) - .forEach { attachmentId in - JobRunner.add( - db, - job: Job( - variant: .attachmentDownload, - threadId: thread.id, - interactionId: interactionId, - details: AttachmentDownloadJob.Details( - attachmentId: attachmentId - ) - ), - canStartJob: isMainAppActive - ) - } - } - // Cancel any typing indicators if needed if isMainAppActive { TypingIndicators.didStopTyping(db, threadId: thread.id, direction: .incoming) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift index d9332c814..8bf2d87dd 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift @@ -216,8 +216,9 @@ extension MessageSender { // Update name if needed if name != closedGroup.name { // Update the group - let updatedClosedGroup: ClosedGroup = closedGroup.with(name: name) - try updatedClosedGroup.save(db) + _ = try ClosedGroup + .filter(id: closedGroup.id) + .updateAll(db, ClosedGroup.Columns.name.set(to: name)) // Notify the user let interaction: Interaction = try Interaction( diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 0c5ceeb6b..bde98e927 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -165,6 +165,7 @@ extension MessageSender { } if let error: Error = errors.first { return Promise(error: error) } + return GRDBStorage.shared.writeAsync { db in try MessageSender.sendImmediate( db, diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index f470754c0..dde2ee13e 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -646,21 +646,15 @@ public final class MessageSender { with error: MessageSenderError, interactionId: Int64? ) { - guard let interaction: Interaction = try? interaction(db, for: message, interactionId: interactionId) else { - return - } - // Mark any "sending" recipients as "failed" - try? interaction.recipientStates - .fetchAll(db) - .forEach { oldState in - guard oldState.state == .sending else { return } - - try? oldState.with( - state: .failed, - mostRecentFailureText: error.localizedDescription - ).save(db) - } + _ = try? RecipientState + .filter(RecipientState.Columns.interactionId == interactionId) + .filter(RecipientState.Columns.state == RecipientState.State.sending) + .updateAll( + db, + RecipientState.Columns.state.set(to: RecipientState.State.failed), + RecipientState.Columns.mostRecentFailureText.set(to: error.localizedDescription) + ) } // MARK: - Convenience diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index 992fdaaa6..4604cf104 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -180,9 +180,9 @@ public struct ProfileManager { return } - try? latestProfile - .with(profilePictureFileName: .update(fileName)) - .update(db) + _ = try? Profile + .filter(id: profile.id) + .updateAll(db, Profile.Columns.profilePictureFileName.set(to: fileName)) profileAvatarCache.mutate { $0[fileName] = image } } diff --git a/SessionMessagingKitTests/Contacts/BlindedIdLookupSpec.swift b/SessionMessagingKitTests/Contacts/BlindedIdLookupSpec.swift new file mode 100644 index 000000000..4cb4b4cba --- /dev/null +++ b/SessionMessagingKitTests/Contacts/BlindedIdLookupSpec.swift @@ -0,0 +1,32 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class BlindedIdLookupSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a BlindedIdLookup") { + context("when initializing") { + it("sets the values correctly") { + let lookup: BlindedIdLookup = BlindedIdLookup( + blindedId: "testBlindedId", + sessionId: "testSessionId", + openGroupServer: "testServer", + openGroupPublicKey: "testPublicKey" + ) + + expect(lookup.blindedId).to(equal("testBlindedId")) + expect(lookup.sessionId).to(equal("testSessionId")) + expect(lookup.openGroupServer).to(equal("testServer")) + expect(lookup.openGroupPublicKey).to(equal("testPublicKey")) + } + } + } + } +} diff --git a/SessionMessagingKitTests/Contacts/BlindedIdMappingSpec.swift b/SessionMessagingKitTests/Contacts/BlindedIdMappingSpec.swift deleted file mode 100644 index 3c2c26d40..000000000 --- a/SessionMessagingKitTests/Contacts/BlindedIdMappingSpec.swift +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -import Quick -import Nimble - -@testable import SessionMessagingKit - -class BlindedIdMappingSpec: QuickSpec { - // MARK: - Spec - - override func spec() { - describe("a BlindedIdMapping") { - context("when initializing") { - it("sets the values correctly") { - let mapping: BlindedIdMapping = BlindedIdMapping( - blindedId: "testBlindedId", - sessionId: "testSessionId", - serverPublicKey: "testPublicKey" - ) - - expect(mapping.blindedId).to(equal("testBlindedId")) - expect(mapping.sessionId).to(equal("testSessionId")) - expect(mapping.serverPublicKey).to(equal("testPublicKey")) - } - } - - context("when NSCoding") { - // Note: Unit testing NSCoder is horrible so we won't do it properly - wait until we refactor it to Codable - it("successfully encodes and decodes") { - let mappingToEncode: BlindedIdMapping = BlindedIdMapping( - blindedId: "testBlindedId", - sessionId: "testSessionId", - serverPublicKey: "testPublicKey" - ) - let encodedData: Data = try! NSKeyedArchiver.archivedData(withRootObject: mappingToEncode, requiringSecureCoding: false) - let mapping: BlindedIdMapping? = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(encodedData) as? BlindedIdMapping - - expect(mapping).toNot(beNil()) - expect(mapping?.blindedId).to(equal("testBlindedId")) - expect(mapping?.sessionId).to(equal("testSessionId")) - expect(mapping?.serverPublicKey).to(equal("testPublicKey")) - } - } - } - } -} diff --git a/SessionUtilitiesKit/General/Dictionary+Utilities.swift b/SessionUtilitiesKit/General/Dictionary+Utilities.swift index 1b3c918f9..5ac19dcd2 100644 --- a/SessionUtilitiesKit/General/Dictionary+Utilities.swift +++ b/SessionUtilitiesKit/General/Dictionary+Utilities.swift @@ -28,7 +28,9 @@ public extension Dictionary.Values { // MARK: - Functional Convenience public extension Dictionary { - func setting(_ key: Key, _ value: Value?) -> [Key: Value] { + func setting(_ key: Key?, _ value: Value?) -> [Key: Value] { + guard let key: Key = key else { return self } + var updatedDictionary: [Key: Value] = self updatedDictionary[key] = value @@ -45,7 +47,9 @@ public extension Dictionary { return updatedDictionary } - func removingValue(forKey key: Key) -> [Key: Value] { + func removingValue(forKey key: Key?) -> [Key: Value] { + guard let key: Key = key else { return self } + var updatedDictionary: [Key: Value] = self updatedDictionary.removeValue(forKey: key) diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 2ab114973..2e6055d93 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -285,6 +285,11 @@ public final class JobRunner { return (queues.wrappedValue[job.variant]?.isCurrentlyRunning(jobId) == true) } + public static func defailsForCurrentlyRunningJobs(of variant: Job.Variant) -> [Int64: Data?] { + return (queues.wrappedValue[variant]?.detailsForAllCurrentlyRunningJobs()) + .defaulting(to: [:]) + } + public static func hasPendingOrRunningJob(with variant: Job.Variant, details: T) -> Bool { guard let targetQueue: JobQueue = queues.wrappedValue[variant] else { return false } guard let detailsData: Data = try? JSONEncoder().encode(details) else { return false } @@ -396,6 +401,7 @@ private final class JobQueue { fileprivate var isRunning: Atomic = Atomic(false) private var queue: Atomic<[Job]> = Atomic([]) private var jobsCurrentlyRunning: Atomic> = Atomic([]) + private var detailsForCurrentlyRunningJobs: Atomic<[Int64: Data?]> = Atomic([:]) fileprivate var hasPendingJobs: Bool { !queue.wrappedValue.isEmpty } @@ -505,6 +511,10 @@ private final class JobQueue { return jobsCurrentlyRunning.wrappedValue.contains(jobId) } + fileprivate func detailsForAllCurrentlyRunningJobs() -> [Int64: Data?] { + return detailsForCurrentlyRunningJobs.wrappedValue + } + fileprivate func hasPendingOrRunningJob(with detailsData: Data?) -> Bool { let pendingJobs: [Job] = queue.wrappedValue @@ -683,6 +693,7 @@ private final class JobQueue { jobsCurrentlyRunning = jobsCurrentlyRunning.inserting(nextJob.id) numJobsRunning = jobsCurrentlyRunning.count } + detailsForCurrentlyRunningJobs.mutate { $0 = $0.setting(nextJob.id, nextJob.details) } SNLog("[JobRunner] \(queueContext) started job (\(executionType == .concurrent ? "\(numJobsRunning) currently running, " : "")\(numJobsRemaining) remaining)") jobExecutor.run( @@ -817,6 +828,7 @@ private final class JobQueue { // The job is removed from the queue before it runs so all we need to to is remove it // from the 'currentlyRunning' set and start the next one jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } + detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: job.id) } internalQueue.async { [weak self] in self?.runNextJob() } @@ -828,6 +840,7 @@ private final class JobQueue { guard GRDBStorage.shared.read({ db in try Job.exists(db, id: job.id ?? -1) }) == true else { SNLog("[JobRunner] \(queueContext) \(job.variant) job canceled") jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } + detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: job.id) } internalQueue.async { [weak self] in self?.runNextJob() @@ -839,6 +852,7 @@ private final class JobQueue { if self.type == .blocking && job.shouldBlockFirstRunEachSession { SNLog("[JobRunner] \(queueContext) \(job.variant) job failed; retrying immediately") jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } + detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: job.id) } queue.mutate { $0.insert(job, at: 0) } internalQueue.async { [weak self] in @@ -915,6 +929,7 @@ private final class JobQueue { } jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } + detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: job.id) } internalQueue.async { [weak self] in self?.runNextJob() } @@ -924,6 +939,7 @@ private final class JobQueue { /// on other jobs, and it should automatically manage those dependencies) private func handleJobDeferred(_ job: Job) { jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } + detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: job.id) } internalQueue.async { [weak self] in self?.runNextJob() }