diff --git a/Podfile.lock b/Podfile.lock index 3088b746b..01df7da9c 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -27,7 +27,7 @@ PODS: - DifferenceKit/Core (1.3.0) - DifferenceKit/UIKitExtension (1.3.0): - DifferenceKit/Core - - GRDB.swift/SQLCipher (6.10.1): + - GRDB.swift/SQLCipher (6.13.0): - SQLCipher (>= 3.4.2) - libwebp (1.2.1): - libwebp/demux (= 1.2.1) @@ -222,7 +222,7 @@ SPEC CHECKSUMS: CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17 Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6 DifferenceKit: ab185c4d7f9cef8af3fcf593e5b387fb81e999ca - GRDB.swift: 1cc67278f1a9878d6eb1b849485518112b79cab7 + GRDB.swift: fe420b1af49ec519c7e96e07887ee44f5dfa2b78 libwebp: 98a37e597e40bfdb4c911fc98f2c53d0b12d05fc Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84 NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667 @@ -242,6 +242,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: e9443a8235dbff1fc342aa9bf08bbc66923adf68 +PODFILE CHECKSUM: f2f07345491c3a64dd6a526e87381a0e46a231d2 COCOAPODS: 1.11.3 diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 10c6922b6..d5ec2f3cc 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -169,7 +169,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl }() lazy var snInputView: InputView = InputView( - threadVariant: self.viewModel.threadData.threadVariant, + threadVariant: self.viewModel.initialThreadVariant, delegate: self ) @@ -180,6 +180,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl result.layer.cornerRadius = (ConversationVC.unreadCountViewSize / 2) result.set(.width, greaterThanOrEqualTo: ConversationVC.unreadCountViewSize) result.set(.height, to: ConversationVC.unreadCountViewSize) + result.isHidden = true return result }() @@ -361,12 +362,12 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl scrollButton.pin(.right, to: .right, of: view, withInset: -20) messageRequestView.pin(.left, to: .left, of: view) messageRequestView.pin(.right, to: .right, of: view) - self.messageRequestsViewBotomConstraint = messageRequestView.pin(.bottom, to: .bottom, of: view, withInset: -16) - self.scrollButtonBottomConstraint = scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -16) - self.scrollButtonBottomConstraint?.isActive = false // Note: Need to disable this to avoid a conflict with the other bottom constraint - self.scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestView, withInset: -16) - self.scrollButtonPendingMessageRequestInfoBottomConstraint = scrollButton.pin(.bottom, to: .top, of: pendingMessageRequestExplanationLabel, withInset: -16) - + messageRequestsViewBotomConstraint = messageRequestView.pin(.bottom, to: .bottom, of: view, withInset: -16) + scrollButtonBottomConstraint = scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -16) + scrollButtonBottomConstraint?.isActive = false // Note: Need to disable this to avoid a conflict with the other bottom constraint + scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestView, withInset: -16) + scrollButtonPendingMessageRequestInfoBottomConstraint = scrollButton.pin(.bottom, to: .top, of: pendingMessageRequestExplanationLabel, withInset: -16) + messageRequestBlockButton.pin(.top, to: .top, of: messageRequestView, withInset: 10) messageRequestBlockButton.center(.horizontal, in: messageRequestView) @@ -483,7 +484,11 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges(didReturnFromBackground: true) + /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query + DispatchQueue.main.async { [weak self] in + self?.startObservingChanges(didReturnFromBackground: true) + } + recoverInputView() if !isShowingSearchUI { diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index a5c30bed7..65f4ecec4 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -53,27 +53,65 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Initialization init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64?) { - // If we have a specified 'focusedInteractionId' then use that, otherwise retrieve the oldest - // unread interaction and start focused around that one - let targetInteractionId: Int64? = { - if let focusedInteractionId: Int64 = focusedInteractionId { return focusedInteractionId } + typealias InitialData = ( + targetInteractionId: Int64?, + currentUserIsClosedGroupMember: Bool?, + openGroupPermissions: OpenGroup.Permissions?, + blindedKey: String? + ) + + let initialData: InitialData? = Storage.shared.read { db -> InitialData in + let interaction: TypedTableAlias = TypedTableAlias() + let groupMember: TypedTableAlias = TypedTableAlias() - return Storage.shared.read { db in - let interaction: TypedTableAlias = TypedTableAlias() - - return try Interaction + // If we have a specified 'focusedInteractionId' then use that, otherwise retrieve the oldest + // unread interaction and start focused around that one + let targetInteractionId: Int64? = (focusedInteractionId != nil ? focusedInteractionId : + try Interaction .select(.id) .filter(interaction[.wasRead] == false) .filter(interaction[.threadId] == threadId) .order(interaction[.timestampMs].asc) .asRequest(of: Int64.self) .fetchOne(db) - } - }() + ) + let currentUserIsClosedGroupMember: Bool? = (threadVariant != .closedGroup ? nil : + try GroupMember + .filter(groupMember[.groupId] == threadId) + .filter(groupMember[.profileId] == getUserHexEncodedPublicKey(db)) + .filter(groupMember[.role] == GroupMember.Role.standard) + .isNotEmpty(db) + ) + let openGroupPermissions: OpenGroup.Permissions? = (threadVariant != .openGroup ? nil : + try OpenGroup + .filter(id: threadId) + .select(.permissions) + .asRequest(of: OpenGroup.Permissions.self) + .fetchOne(db) + ) + let blindedKey: String? = SessionThread.getUserHexEncodedBlindedKey( + db, + threadId: threadId, + threadVariant: threadVariant + ) + + return ( + targetInteractionId, + currentUserIsClosedGroupMember, + openGroupPermissions, + blindedKey + ) + } self.threadId = threadId self.initialThreadVariant = threadVariant - self.focusedInteractionId = targetInteractionId + self.focusedInteractionId = initialData?.targetInteractionId + self.threadData = SessionThreadViewModel( + threadId: threadId, + threadVariant: threadVariant, + currentUserIsClosedGroupMember: initialData?.currentUserIsClosedGroupMember, + openGroupPermissions: initialData?.openGroupPermissions + ).populatingCurrentUserBlindedKey(currentUserBlindedPublicKeyForThisThread: initialData?.blindedKey) self.pagedDataObserver = nil // Note: Since this references self we need to finish initializing before setting it, we @@ -93,7 +131,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { DispatchQueue.global(qos: .userInitiated).async { [weak self] in // If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query // from a `0` offset) - guard let initialFocusedId: Int64 = targetInteractionId else { + guard let initialFocusedId: Int64 = initialData?.targetInteractionId else { self?.pagedDataObserver?.load(.pageBefore) return } @@ -105,21 +143,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Thread Data /// This value is the current state of the view - public private(set) lazy var threadData: SessionThreadViewModel = SessionThreadViewModel( - threadId: self.threadId, - threadVariant: self.initialThreadVariant, - currentUserIsClosedGroupMember: (self.initialThreadVariant != .closedGroup ? - nil : - Storage.shared.read { db in - try GroupMember - .filter(GroupMember.Columns.groupId == self.threadId) - .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db)) - .filter(GroupMember.Columns.role == GroupMember.Role.standard) - .isNotEmpty(db) - } - ) - ) - .populatingCurrentUserBlindedKey() + public private(set) var threadData: SessionThreadViewModel /// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise /// performance https://github.com/groue/GRDB.swift#valueobservation-performance diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 8101060d1..4727b53fa 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -37,8 +37,6 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M set { inputTextView.selectedRange = newValue } } - var inputTextViewIsFirstResponder: Bool { inputTextView.isFirstResponder } - var enabledMessageTypes: MessageInputTypes = .all { didSet { setEnabledMessageTypes(enabledMessageTypes, message: nil) @@ -440,10 +438,6 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M override func resignFirstResponder() -> Bool { inputTextView.resignFirstResponder() } - - func inputTextViewBecomeFirstResponder() { - inputTextView.becomeFirstResponder() - } func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer) { // Not relevant in this case diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index ebd726a1c..69e90a357 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -3,6 +3,7 @@ import UIKit import SessionUIKit import SessionMessagingKit +import SessionUtilitiesKit final class QuoteView: UIView { static let thumbnailSize: CGFloat = 48 @@ -237,17 +238,27 @@ final class QuoteView: UIView { .compactMap { $0 } .asSet() .contains(authorId) + let authorLabel = UILabel() authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) - authorLabel.text = (isCurrentUser ? - "MEDIA_GALLERY_SENDER_NAME_YOU".localized() : - Profile.displayName( + authorLabel.text = { + guard !isCurrentUser else { return "MEDIA_GALLERY_SENDER_NAME_YOU".localized() } + guard body != nil else { + // When we can't find the quoted message we want to hide the author label + return Profile.displayNameNoFallback( + id: authorId, + threadVariant: threadVariant + ) + } + + return Profile.displayName( id: authorId, threadVariant: threadVariant ) - ) + }() authorLabel.themeTextColor = targetThemeColor authorLabel.lineBreakMode = .byTruncatingTail + authorLabel.isHidden = (authorLabel.text == nil) let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace) authorLabel.set(.height, to: authorLabelSize.height) diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index 139b5c383..d6e07e269 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -91,14 +91,17 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo setupNavigationBar() } - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) searchBar.becomeFirstResponder() } public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - searchBar.resignFirstResponder() + + UIView.performWithoutAnimation { + searchBar.resignFirstResponder() + } } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -138,10 +141,6 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo } } - private func reloadTableData() { - tableView.reloadData() - } - // MARK: - Update Search Results private func refreshSearchResults() { @@ -155,9 +154,11 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo let searchText = rawSearchText.stripped guard searchText.count > 0 else { + guard searchText != (lastSearchText ?? "") else { return } + searchResultSet = defaultSearchResults lastSearchText = nil - reloadTableData() + tableView.reloadData() return } guard lastSearchText != searchText else { return } @@ -212,7 +213,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo .compactMap { $0 } .flatMap { $0 } self?.isLoading = false - self?.reloadTableData() + self?.tableView.reloadData() self?.refreshTimer = nil default: break @@ -283,18 +284,12 @@ extension GlobalSearchViewController { return } - if let presentedVC = self.presentedViewController { - presentedVC.dismiss(animated: false, completion: nil) - } - - let viewControllers: [UIViewController] = (self.navigationController? - .viewControllers) - .defaulting(to: []) - .appending( - ConversationVC(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId) - ) - - self.navigationController?.setViewControllers(viewControllers, animated: true) + let viewController: ConversationVC = ConversationVC( + threadId: threadId, + threadVariant: threadVariant, + focusedInteractionId: focusedInteractionId + ) + self.navigationController?.pushViewController(viewController, animated: true) } // MARK: - UITableViewDataSource diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 1b15da4bb..5c89d8166 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -308,7 +308,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges(didReturnFromBackground: true) + /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query + DispatchQueue.main.async { [weak self] in + self?.startObservingChanges(didReturnFromBackground: true) + } } @objc func applicationDidResignActive(_ notification: Notification) { @@ -393,8 +396,18 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi // in from a frame of CGRect.zero) guard hasLoadedInitialThreadData else { hasLoadedInitialThreadData = true - UIView.performWithoutAnimation { - handleThreadUpdates(updatedData, changeset: changeset, initialLoad: true) + + UIView.performWithoutAnimation { [weak self] in + // Hide the 'loading conversations' label (now that we have received conversation data) + self?.loadingConversationsLabel.isHidden = true + + // Show the empty state if there is no data + self?.emptyStateView.isHidden = ( + !updatedData.isEmpty && + updatedData.contains(where: { !$0.elements.isEmpty }) + ) + + self?.viewModel.updateThreadData(updatedData) } return } diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index 2dfbbf89f..180b47761 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -162,7 +162,10 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges(didReturnFromBackground: true) + /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query + DispatchQueue.main.async { [weak self] in + self?.startObservingChanges(didReturnFromBackground: true) + } } @objc func applicationDidResignActive(_ notification: Notification) { diff --git a/Session/Media Viewing & Editing/DocumentTitleViewController.swift b/Session/Media Viewing & Editing/DocumentTitleViewController.swift index 7a1e34733..bff7c7597 100644 --- a/Session/Media Viewing & Editing/DocumentTitleViewController.swift +++ b/Session/Media Viewing & Editing/DocumentTitleViewController.swift @@ -119,7 +119,10 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate, } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges() + /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query + DispatchQueue.main.async { [weak self] in + self?.startObservingChanges() + } } @objc func applicationDidResignActive(_ notification: Notification) { diff --git a/Session/Media Viewing & Editing/MediaInfoVC+MediaInfoView.swift b/Session/Media Viewing & Editing/MediaInfoVC+MediaInfoView.swift index 25c0f5774..b1c5483e1 100644 --- a/Session/Media Viewing & Editing/MediaInfoVC+MediaInfoView.swift +++ b/Session/Media Viewing & Editing/MediaInfoVC+MediaInfoView.swift @@ -3,6 +3,8 @@ import UIKit import SessionUIKit import SessionUtilitiesKit +import SessionMessagingKit +import SignalUtilitiesKit extension MediaInfoVC { final class MediaInfoView: UIView { diff --git a/Session/Media Viewing & Editing/MediaInfoVC+MediaPreviewView.swift b/Session/Media Viewing & Editing/MediaInfoVC+MediaPreviewView.swift index 2ca703e6e..462e143ff 100644 --- a/Session/Media Viewing & Editing/MediaInfoVC+MediaPreviewView.swift +++ b/Session/Media Viewing & Editing/MediaInfoVC+MediaPreviewView.swift @@ -3,6 +3,7 @@ import UIKit import SessionUIKit import SessionUtilitiesKit +import SessionMessagingKit extension MediaInfoVC { final class MediaPreviewView: UIView { diff --git a/Session/Media Viewing & Editing/MediaInfoVC.swift b/Session/Media Viewing & Editing/MediaInfoVC.swift index 26711c83b..1d8991d4c 100644 --- a/Session/Media Viewing & Editing/MediaInfoVC.swift +++ b/Session/Media Viewing & Editing/MediaInfoVC.swift @@ -2,7 +2,9 @@ import UIKit import SessionUIKit +import SessionMessagingKit import SessionUtilitiesKit +import SignalUtilitiesKit final class MediaInfoVC: BaseVC, SessionCarouselViewDelegate { internal static let mediaSize: CGFloat = UIScreen.main.bounds.width - 2 * Values.veryLargeSpacing diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 0ffc3cd42..7da69bfb9 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -245,7 +245,10 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges() + /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query + DispatchQueue.main.async { [weak self] in + self?.startObservingChanges() + } } @objc func applicationDidResignActive(_ notification: Notification) { diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index bbd5506c4..db7915b95 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -175,7 +175,10 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges(didReturnFromBackground: true) + /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query + DispatchQueue.main.async { [weak self] in + self?.startObservingChanges(didReturnFromBackground: true) + } } @objc func applicationDidResignActive(_ notification: Notification) { diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index d91851597..13046ce60 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -446,19 +446,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD guard CurrentAppContext().isMainApp else { return } - CurrentAppContext().setMainAppBadgeNumber( - Storage.shared + /// On application startup the `Storage.read` can be slightly slow while GRDB spins up it's database + /// read pools (up to a few seconds), since this read is blocking we want to dispatch it to run async to ensure + /// we don't block user interaction while it's running + DispatchQueue.global(qos: .default).async { + let unreadCount: Int = Storage.shared .read { db in let userPublicKey: String = getUserHexEncodedPublicKey(db) let thread: TypedTableAlias = TypedTableAlias() return try Interaction .filter(Interaction.Columns.wasRead == false) - .filter( - // Exclude outgoing and deleted messages from the count - Interaction.Columns.variant != Interaction.Variant.standardOutgoing && - Interaction.Columns.variant != Interaction.Variant.standardIncomingDeleted - ) + .filter(Interaction.Variant.variantsToIncrementUnreadCount.contains(Interaction.Columns.variant)) .filter( // Only count mentions if 'onlyNotifyForMentions' is set thread[.onlyNotifyForMentions] == false || @@ -482,7 +481,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD .fetchCount(db) } .defaulting(to: 0) - ) + + DispatchQueue.main.async { + CurrentAppContext().setMainAppBadgeNumber(unreadCount) + } + } } } diff --git a/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@1x.png b/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@1x.png index 53c019a76..03d20502b 100644 Binary files a/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@1x.png and b/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@1x.png differ diff --git a/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@2x.png b/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@2x.png index 4625ebffa..5633c5ced 100644 Binary files a/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@2x.png and b/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@2x.png differ diff --git a/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@3x.png b/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@3x.png index 4adf408bd..db52359aa 100644 Binary files a/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@3x.png and b/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@3x.png differ diff --git a/Session/Path/PathVC.swift b/Session/Path/PathVC.swift index 1b641f43f..6004e6ed9 100644 --- a/Session/Path/PathVC.swift +++ b/Session/Path/PathVC.swift @@ -218,9 +218,21 @@ final class PathVC: BaseVC { } private func getPathRow(snode: Snode, location: LineView.Location, dotAnimationStartDelay: Double, dotAnimationRepeatInterval: Double, isGuardSnode: Bool) -> UIStackView { - let country = IP2Country.isInitialized ? (IP2Country.shared.countryNamesCache[snode.ip] ?? "Resolving...") : "Resolving..." - let title = isGuardSnode ? NSLocalizedString("vc_path_guard_node_row_title", comment: "") : NSLocalizedString("vc_path_service_node_row_title", comment: "") - return getPathRow(title: title, subtitle: country, location: location, dotAnimationStartDelay: dotAnimationStartDelay, dotAnimationRepeatInterval: dotAnimationRepeatInterval) + let country: String = (IP2Country.isInitialized ? + IP2Country.shared.countryNamesCache.wrappedValue[snode.ip].defaulting(to: "Resolving...") : + "Resolving..." + ) + + return getPathRow( + title: (isGuardSnode ? + "vc_path_guard_node_row_title".localized() : + "vc_path_service_node_row_title".localized() + ), + subtitle: country, + location: location, + dotAnimationStartDelay: dotAnimationStartDelay, + dotAnimationRepeatInterval: dotAnimationRepeatInterval + ) } // MARK: - Interaction diff --git a/Session/Settings/BlockedContactsViewController.swift b/Session/Settings/BlockedContactsViewController.swift index 7fbdb00af..cc6cfb85d 100644 --- a/Session/Settings/BlockedContactsViewController.swift +++ b/Session/Settings/BlockedContactsViewController.swift @@ -145,7 +145,10 @@ class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDat } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges(didReturnFromBackground: true) + /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query + DispatchQueue.main.async { [weak self] in + self?.startObservingChanges(didReturnFromBackground: true) + } } @objc func applicationDidResignActive(_ notification: Notification) { diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index 24b6cd82d..3d7def772 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -132,7 +132,10 @@ class SessionTableViewController = Atomic([:]) + private static let workQueue = DispatchQueue(label: "IP2Country.workQueue", qos: .utility) // It's important that this is a serial queue static var isInitialized = false // MARK: Tables - /// This table has two columns: the "network" column and the "registered_country_geoname_id" column. The network column contains the **lower** bound of an IP - /// range and the "registered_country_geoname_id" column contains the ID of the country corresponding to that range. We look up an IP by finding the first index in the - /// network column where the value is greater than the IP we're looking up (converted to an integer). The IP we're looking up must then be in the range **before** that - /// range. + /// This table has two columns: the "network" column and the "registered_country_geoname_id" column. The network column contains + /// the **lower** bound of an IP range and the "registered_country_geoname_id" column contains the ID of the country corresponding + /// to that range. We look up an IP by finding the first index in the network column where the value is greater than the IP we're looking + /// up (converted to an integer). The IP we're looking up must then be in the range **before** that range. private lazy var ipv4Table: [String:[Int]] = { let url = Bundle.main.url(forResource: "GeoLite2-Country-Blocks-IPv4", withExtension: nil)! let data = try! Data(contentsOf: url) @@ -36,15 +37,23 @@ final class IP2Country { NotificationCenter.default.removeObserver(self) } - // MARK: Implementation - private func cacheCountry(for ip: String) -> String { - if let result = countryNamesCache[ip] { return result } - let ipAsInt = IPv4.toInt(ip) - guard let ipv4TableIndex = ipv4Table["network"]!.firstIndex(where: { $0 > ipAsInt }).map({ $0 - 1 }) else { return "Unknown Country" } // Relies on the array being sorted - let countryID = ipv4Table["registered_country_geoname_id"]![ipv4TableIndex] - guard let countryNamesTableIndex = countryNamesTable["geoname_id"]!.firstIndex(of: String(countryID)) else { return "Unknown Country" } - let result = countryNamesTable["country_name"]![countryNamesTableIndex] - countryNamesCache[ip] = result + // MARK: - Implementation + + @discardableResult private func cacheCountry(for ip: String, inCache cache: inout [String: String]) -> String { + if let result: String = cache[ip] { return result } + + let ipAsInt: Int = IPv4.toInt(ip) + + guard + let ipv4TableIndex = ipv4Table["network"]?.firstIndex(where: { $0 > ipAsInt }).map({ $0 - 1 }), + let countryID: Int = ipv4Table["registered_country_geoname_id"]?[ipv4TableIndex], + let countryNamesTableIndex = countryNamesTable["geoname_id"]?.firstIndex(of: String(countryID)), + let result: String = countryNamesTable["country_name"]?[countryNamesTableIndex] + else { + return "Unknown Country" // Relies on the array being sorted + } + + cache[ip] = result return result } @@ -58,9 +67,12 @@ final class IP2Country { func populateCacheIfNeeded() -> Bool { guard let pathToDisplay: [Snode] = OnionRequestAPI.paths.first else { return false } - pathToDisplay.forEach { snode in - let _ = self.cacheCountry(for: snode.ip) // Preload if needed + countryNamesCache.mutate { [weak self] cache in + pathToDisplay.forEach { snode in + self?.cacheCountry(for: snode.ip, inCache: &cache) // Preload if needed + } } + DispatchQueue.main.async { IP2Country.isInitialized = true NotificationCenter.default.post(name: .onionRequestPathCountriesLoaded, object: nil) diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index a26b052ac..eb5b20be1 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -87,6 +87,10 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu // MARK: - Convenience + public static let variantsToIncrementUnreadCount: [Variant] = [ + .standardIncoming, .infoCall + ] + public var isInfoMessage: Bool { switch self { case .infoClosedGroupCreated, .infoClosedGroupUpdated, diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index b02681de6..793c07aaf 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -100,8 +100,14 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public var canWrite: Bool { switch threadVariant { case .contact: return true - case .closedGroup: return (currentUserIsClosedGroupMember == true) && (interactionVariant?.isGroupLeavingStatus != true) - case .openGroup: return openGroupPermissions?.contains(.write) ?? false + case .closedGroup: + return ( + currentUserIsClosedGroupMember == true && + interactionVariant?.isGroupLeavingStatus != true + ) + + case .openGroup: + return (openGroupPermissions?.contains(.write) ?? false) } } @@ -241,6 +247,7 @@ public extension SessionThreadViewModel { threadIsNoteToSelf: Bool = false, contactProfile: Profile? = nil, currentUserIsClosedGroupMember: Bool? = nil, + openGroupPermissions: OpenGroup.Permissions? = nil, unreadCount: UInt = 0 ) { self.rowId = -1 @@ -279,7 +286,7 @@ public extension SessionThreadViewModel { self.openGroupPublicKey = nil self.openGroupProfilePictureData = nil self.openGroupUserCount = nil - self.openGroupPermissions = nil + self.openGroupPermissions = openGroupPermissions // Interaction display info diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 5fb7aaf2a..77fa71796 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -85,7 +85,10 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges() + /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query + DispatchQueue.main.async { [weak self] in + self?.startObservingChanges() + } } @objc func applicationDidResignActive(_ notification: Notification) { diff --git a/SessionUtilitiesKit/General/Atomic.swift b/SessionUtilitiesKit/General/Atomic.swift index 865745e14..d16ac102c 100644 --- a/SessionUtilitiesKit/General/Atomic.swift +++ b/SessionUtilitiesKit/General/Atomic.swift @@ -1,4 +1,4 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import Foundation @@ -6,23 +6,28 @@ import Foundation /// The `Atomic` wrapper is a generic wrapper providing a thread-safe way to get and set a value /// -/// A write-up on the need for this class and it's approach can be found here: +/// A write-up on the need for this class and it's approaches can be found at these links: +/// https://www.vadimbulavin.com/atomic-properties/ /// https://www.vadimbulavin.com/swift-atomic-properties-with-property-wrappers/ /// there is also another approach which can be taken but it requires separate types for collections and results in /// a somewhat inconsistent interface between different `Atomic` wrappers +/// +/// We use a Read-write lock approach because the `DispatchQueue` approach means mutating the property +/// occurs on a different thread, and GRDB requires it's changes to be executed on specific threads so using a lock +/// is more compatible (and Read-write locks allow for concurrent reads which shouldn't be a huge issue but could +/// help reduce cases of blocking) @propertyWrapper public class Atomic { - // Note: Using 'userInteractive' to ensure this can't be blockedby higher priority queues - // which could result in the main thread getting blocked - private let queue: DispatchQueue = DispatchQueue( - label: "io.oxen.\(UUID().uuidString)", - qos: .userInteractive - ) private var value: Value + private let lock: ReadWriteLock = ReadWriteLock() /// In order to change the value you **must** use the `mutate` function public var wrappedValue: Value { - return queue.sync { return value } + lock.readLock() + let result: Value = value + lock.unlock() + + return result } /// For more information see https://github.com/apple/swift-evolution/blob/master/proposals/0258-property-wrappers.md#projections @@ -36,12 +41,34 @@ public class Atomic { self.value = initialValue } + public init(wrappedValue: Value) { + self.value = wrappedValue + } + // MARK: - Functions @discardableResult public func mutate(_ mutation: (inout Value) -> T) -> T { - return queue.sync { - return mutation(&value) + lock.writeLock() + let result: T = mutation(&value) + lock.unlock() + + return result + } + + @discardableResult public func mutate(_ mutation: (inout Value) throws -> T) throws -> T { + let result: T + + do { + lock.writeLock() + result = try mutation(&value) + lock.unlock() } + catch { + lock.unlock() + throw error + } + + return result } } @@ -50,3 +77,25 @@ extension Atomic where Value: CustomDebugStringConvertible { return value.debugDescription } } + +// MARK: - ReadWriteLock + +private class ReadWriteLock { + private var rwlock: pthread_rwlock_t = { + var rwlock = pthread_rwlock_t() + pthread_rwlock_init(&rwlock, nil) + return rwlock + }() + + func writeLock() { + pthread_rwlock_wrlock(&rwlock) + } + + func readLock() { + pthread_rwlock_rdlock(&rwlock) + } + + func unlock() { + pthread_rwlock_unlock(&rwlock) + } +}