From bc6b084ba044a3dccf9ba6ffc33dbcb9247d1c15 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 14 Aug 2025 09:24:14 +1000 Subject: [PATCH 001/162] wip: dev settings to treat all users a session pro --- .../Settings/DeveloperSettingsViewModel.swift | 27 ++++++++++--------- .../Config Handling/LibSession+Pro.swift | 4 +-- SessionUtilitiesKit/General/Feature.swift | 4 +-- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/Session/Settings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettingsViewModel.swift index dfcb1d65c1..bad924ad24 100644 --- a/Session/Settings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettingsViewModel.swift @@ -105,7 +105,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case enableSessionPro case proStatus - case proIncomingMessages + case allUsersSessionPro case createMockContacts case forceSlowDatabaseQueries @@ -153,7 +153,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .enableSessionPro: return "enableSessionPro" case .proStatus: return "proStatus" - case .proIncomingMessages: return "proIncomingMessages" + case .allUsersSessionPro: return "allUsersSessionPro" case .createMockContacts: return "createMockContacts" case .forceSlowDatabaseQueries: return "forceSlowDatabaseQueries" @@ -205,7 +205,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .enableSessionPro: result.append(.enableSessionPro); fallthrough case .proStatus: result.append(.proStatus); fallthrough - case .proIncomingMessages: result.append(.proIncomingMessages); fallthrough + case .allUsersSessionPro: result.append(.allUsersSessionPro); fallthrough case .createMockContacts: result.append(.createMockContacts); fallthrough case .forceSlowDatabaseQueries: result.append(.forceSlowDatabaseQueries); fallthrough @@ -251,7 +251,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, let sessionProEnabled: Bool let mockCurrentUserSessionPro: Bool - let treatAllIncomingMessagesAsProMessages: Bool + let allUsersSessionPro: Bool let forceSlowDatabaseQueries: Bool } @@ -306,7 +306,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, sessionProEnabled: dependencies[feature: .sessionProEnabled], mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionPro], - treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages], + allUsersSessionPro: dependencies[feature: .allUsersSessionPro], forceSlowDatabaseQueries: dependencies[feature: .forceSlowDatabaseQueries] ) @@ -893,19 +893,20 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, } ), SessionCell.Info( - id: .proIncomingMessages, - title: "All Pro Incoming Messages", + id: .allUsersSessionPro, + title: "Everyone is a Pro", subtitle: """ Treat all incoming messages as Pro messages. + Treat all contacts, groups as Session Pro. """, trailingAccessory: .toggle( - current.treatAllIncomingMessagesAsProMessages, - oldValue: previous?.treatAllIncomingMessagesAsProMessages + current.allUsersSessionPro, + oldValue: previous?.allUsersSessionPro ), onTap: { [weak self] in self?.updateFlag( - for: .treatAllIncomingMessagesAsProMessages, - to: !current.treatAllIncomingMessagesAsProMessages + for: .allUsersSessionPro, + to: !current.allUsersSessionPro ) } ) @@ -1303,8 +1304,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, if dependencies.hasSet(feature: .mockCurrentUserSessionPro) { updateFlag(for: .mockCurrentUserSessionPro, to: nil) } - if dependencies.hasSet(feature: .treatAllIncomingMessagesAsProMessages) { - updateFlag(for: .treatAllIncomingMessagesAsProMessages, to: nil) + if dependencies.hasSet(feature: .allUsersSessionPro) { + updateFlag(for: .allUsersSessionPro, to: nil) } } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift index 31ba81eb19..3b7542ae3a 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift @@ -28,12 +28,12 @@ public extension LibSessionCacheType { func validateProProof(for message: Message?) -> Bool { guard let message = message, dependencies[feature: .sessionProEnabled] else { return false } - return dependencies[feature: .treatAllIncomingMessagesAsProMessages] + return dependencies[feature: .allUsersSessionPro] } func validateProProof(for profile: Profile?) -> Bool { guard let profile = profile, dependencies[feature: .sessionProEnabled] else { return false } - return dependencies[feature: .treatAllIncomingMessagesAsProMessages] + return dependencies[feature: .allUsersSessionPro] } func getProProof() -> String? { diff --git a/SessionUtilitiesKit/General/Feature.swift b/SessionUtilitiesKit/General/Feature.swift index 68c7c137ce..ebc7dad6ef 100644 --- a/SessionUtilitiesKit/General/Feature.swift +++ b/SessionUtilitiesKit/General/Feature.swift @@ -85,8 +85,8 @@ public extension FeatureStorage { identifier: "mockCurrentUserSessionPro" ) - static let treatAllIncomingMessagesAsProMessages: FeatureConfig = Dependencies.create( - identifier: "treatAllIncomingMessagesAsProMessages" + static let allUsersSessionPro: FeatureConfig = Dependencies.create( + identifier: "allUsersSessionPro" ) static let shortenFileTTL: FeatureConfig = Dependencies.create( From 32507384eff17bea1f72796efefa0fdd12df193a Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 14 Aug 2025 15:38:39 +1000 Subject: [PATCH 002/162] pro badge in conversation screen --- .../ConversationVC+Interaction.swift | 12 ++--- Session/Conversations/ConversationVC.swift | 4 +- .../Conversations/ConversationViewModel.swift | 2 +- .../Content Views/QuoteView.swift | 20 +++++--- .../Message Cells/VisibleMessageCell.swift | 49 +++++++++++++------ .../ConversationTitleView.swift | 34 +++++++++---- .../Settings/DeveloperSettingsViewModel.swift | 6 +-- .../Views/ThemeMessagePreviewView.swift | 4 +- .../Config Handling/LibSession+Pro.swift | 7 ++- .../LibSession+UserProfile.swift | 2 +- .../MessageSender+Convenience.swift | 2 +- .../SessionThreadViewModel.swift | 4 ++ .../Utilities/Profile+CurrentUser.swift | 2 +- SessionUIKit/Components/SessionProBadge.swift | 15 ++++-- .../SwiftUI/SessionProBadge+SwiftUI.swift | 6 ++- 15 files changed, 114 insertions(+), 55 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index f28d83e6ad..e27a1348af 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -236,7 +236,7 @@ extension ConversationVC: @discardableResult func showSessionProCTAIfNeeded(_ variant: ProCTAModal.Variant) -> Bool { let dependencies: Dependencies = viewModel.dependencies - guard dependencies[feature: .sessionProEnabled] && (!viewModel.isSessionPro) else { + guard dependencies[feature: .sessionProEnabled] && (!viewModel.isCurrentUserSessionPro) else { return false } self.hideInputAccessoryView() @@ -526,9 +526,9 @@ extension ConversationVC: self.hideInputAccessoryView() let numberOfCharactersLeft: Int = LibSession.numberOfCharactersLeft( for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), - isSessionPro: viewModel.isSessionPro + isSessionPro: viewModel.isCurrentUserSessionPro ) - let limit: Int = (viewModel.isSessionPro ? LibSession.ProCharacterLimit : LibSession.CharacterLimit) + let limit: Int = (viewModel.isCurrentUserSessionPro ? LibSession.ProCharacterLimit : LibSession.CharacterLimit) let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( @@ -599,9 +599,9 @@ extension ConversationVC: func handleSendButtonTapped() { guard LibSession.numberOfCharactersLeft( for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), - isSessionPro: viewModel.isSessionPro + isSessionPro: viewModel.isCurrentUserSessionPro ) >= 0 else { - showModalForMessagesExceedingCharacterLimit(isSessionPro: viewModel.isSessionPro) + showModalForMessagesExceedingCharacterLimit(viewModel.isCurrentUserSessionPro) return } @@ -612,7 +612,7 @@ extension ConversationVC: ) } - func showModalForMessagesExceedingCharacterLimit(isSessionPro: Bool) { + func showModalForMessagesExceedingCharacterLimit(_ isSessionPro: Bool) { guard !showSessionProCTAIfNeeded(.longerMessages) else { return } self.hideInputAccessoryView() diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index e3a7228511..ea888d1c55 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -447,7 +447,8 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa titleView.initialSetup( with: self.viewModel.initialThreadVariant, isNoteToSelf: self.viewModel.threadData.threadIsNoteToSelf, - isMessageRequest: (self.viewModel.threadData.threadIsMessageRequest == true) + isMessageRequest: (self.viewModel.threadData.threadIsMessageRequest == true), + isSessionPro: self.viewModel.threadData.isSessionPro(using: self.viewModel.dependencies) ) // Constraints @@ -770,6 +771,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa with: updatedThreadData.displayName, isNoteToSelf: updatedThreadData.threadIsNoteToSelf, isMessageRequest: (updatedThreadData.threadIsMessageRequest == true), + isSessionPro: updatedThreadData.isSessionPro(using: viewModel.dependencies), threadVariant: updatedThreadData.threadVariant, mutedUntilTimestamp: updatedThreadData.threadMutedUntilTimestamp, onlyNotifyForMentions: (updatedThreadData.threadOnlyNotifyForMentions == true), diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 3c8ede79ed..353fa71a6e 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -72,7 +72,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold private var markAsReadPublisher: AnyPublisher? public let dependencies: Dependencies - public var isSessionPro: Bool { dependencies[cache: .libSession].isSessionPro } + public var isCurrentUserSessionPro: Bool { dependencies[cache: .libSession].isSessionPro } public let legacyGroupsBannerFont: UIFont = .systemFont(ofSize: Values.miniFontSize) public lazy var legacyGroupsBannerMessage: ThemedAttributedString = { diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index 590395a6c8..0257ec422d 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -159,13 +159,13 @@ final class QuoteView: UIView { bodyLabel.lineBreakMode = .byTruncatingTail bodyLabel.numberOfLines = 2 - let targetThemeColor: ThemeValue = { + let (targetThemeColor, proBadgeThemeColor): (ThemeValue, ThemeValue) = { switch mode { case .regular: return (direction == .outgoing ? - .messageBubble_outgoingText : - .messageBubble_incomingText + (.messageBubble_outgoingText, .white) : + (.messageBubble_incomingText, .primary) ) - case .draft: return .textPrimary + case .draft: return (.textPrimary, .primary) } }() bodyLabel.font = .systemFont(ofSize: Values.smallFontSize) @@ -218,10 +218,18 @@ final class QuoteView: UIView { }() authorLabel.themeTextColor = targetThemeColor authorLabel.lineBreakMode = .byTruncatingTail - authorLabel.isHidden = (authorLabel.text == nil) authorLabel.numberOfLines = 1 - let labelStackView = UIStackView(arrangedSubviews: [ authorLabel, bodyLabel ]) + let sessionProBadge: SessionProBadge = SessionProBadge(size: .mini, themeBackgroundColor: proBadgeThemeColor) + sessionProBadge.isHidden = !dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: authorId) } + + let authorStackView: UIStackView = UIStackView(arrangedSubviews: [ authorLabel, sessionProBadge, UIView.hStretchingSpacer() ]) + authorStackView.axis = .horizontal + authorStackView.spacing = 3 + authorStackView.alignment = .center + authorStackView.isHidden = (authorLabel.text == nil) + + let labelStackView = UIStackView(arrangedSubviews: [ authorStackView, bodyLabel ]) labelStackView.axis = .vertical labelStackView.spacing = labelStackViewSpacing labelStackView.distribution = .equalCentering diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 182121d33f..ebf89e01f8 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -26,12 +26,12 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { override var contextSnapshotView: UIView? { return snContentView } // Constraints - internal lazy var authorLabelTopConstraint = authorLabel.pin(.top, to: .top, of: self) - private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0) + internal lazy var authorStackViewTopConstraint = authorStackView.pin(.top, to: .top, of: self) + private lazy var authorStackViewHeightConstraint = authorStackView.set(.height, to: 0) private lazy var profilePictureViewLeadingConstraint = profilePictureView.pin(.leading, to: .leading, of: self, withInset: VisibleMessageCell.groupThreadHSpacing) internal lazy var contentViewLeadingConstraint1 = snContentView.pin(.leading, to: .trailing, of: profilePictureView, withInset: VisibleMessageCell.groupThreadHSpacing) private lazy var contentViewLeadingConstraint2 = snContentView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: VisibleMessageCell.gutterSize) - private lazy var contentViewTopConstraint = snContentView.pin(.top, to: .bottom, of: authorLabel, withInset: VisibleMessageCell.authorLabelBottomSpacing) + private lazy var contentViewTopConstraint = snContentView.pin(.top, to: .bottom, of: authorStackView, withInset: VisibleMessageCell.authorStackViewBottomSpacing) internal lazy var contentViewTrailingConstraint1 = snContentView.pin(.trailing, to: .trailing, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing) private lazy var contentViewTrailingConstraint2 = snContentView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -VisibleMessageCell.gutterSize) private lazy var contentBottomConstraint = snContentView.bottomAnchor @@ -89,6 +89,20 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { result.font = .boldSystemFont(ofSize: Values.smallFontSize) return result }() + + private lazy var sessionProBadge: SessionProBadge = { + let result = SessionProBadge(size: .mini) + result.isHidden = true + return result + }() + + private lazy var authorStackView: UIStackView = { + let result = UIStackView(arrangedSubviews: [ authorLabel, sessionProBadge, UIView.hStretchingSpacer() ]) + result.axis = .horizontal + result.spacing = 3 + result.alignment = .center + return result + }() lazy var snContentView: UIStackView = { let result = UIStackView(arrangedSubviews: []) @@ -179,9 +193,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { // MARK: - Settings private static let messageStatusImageViewSize: CGFloat = 12 - private static let authorLabelBottomSpacing: CGFloat = 4 + private static let authorStackViewBottomSpacing: CGFloat = 4 private static let groupThreadHSpacing: CGFloat = 12 - private static let authorLabelInset: CGFloat = 12 + private static let authorStackViewInset: CGFloat = 12 private static let replyButtonSize: CGFloat = 24 private static let maxBubbleTranslationX: CGFloat = 40 private static let swipeToReplyThreshold: CGFloat = 110 @@ -211,9 +225,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { super.setUpViewHierarchy() // Author label - addSubview(authorLabel) - authorLabelTopConstraint.isActive = true - authorLabelHeightConstraint.isActive = true + addSubview(authorStackView) + authorStackViewTopConstraint.isActive = true + authorStackViewHeightConstraint.isActive = true // Profile picture view addSubview(profilePictureView) @@ -238,8 +252,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { replyButton.center(.vertical, in: snContentView) // Remaining constraints - authorLabel.pin(.leading, to: .leading, of: snContentView, withInset: VisibleMessageCell.authorLabelInset) - authorLabel.pin(.trailing, to: .trailing, of: self, withInset: -Values.mediumSpacing) + authorStackView.pin(.leading, to: .leading, of: snContentView, withInset: VisibleMessageCell.authorStackViewInset) + authorStackView.pin(.trailing, to: .trailing, of: self, withInset: -Values.mediumSpacing) // Under bubble content addSubview(underBubbleStackView) @@ -336,7 +350,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { contentViewLeadingConstraint1.isActive = cellViewModel.variant.isIncoming contentViewLeadingConstraint1.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : VisibleMessageCell.contactThreadHSpacing) contentViewLeadingConstraint2.isActive = cellViewModel.variant.isOutgoing - contentViewTopConstraint.constant = (cellViewModel.senderName == nil ? 0 : VisibleMessageCell.authorLabelBottomSpacing) + contentViewTopConstraint.constant = (cellViewModel.senderName == nil ? 0 : VisibleMessageCell.authorStackViewBottomSpacing) contentViewTrailingConstraint1.isActive = cellViewModel.variant.isOutgoing contentViewTrailingConstraint2.isActive = cellViewModel.variant.isIncoming @@ -359,15 +373,17 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { bubbleView.isAccessibilityElement = true // Author label - authorLabelTopConstraint.constant = (shouldAddTopInset ? Values.mediumSpacing : 0) - authorLabel.isHidden = (cellViewModel.senderName == nil) + authorStackViewTopConstraint.constant = (shouldAddTopInset ? Values.mediumSpacing : 0) + authorStackView.isHidden = (cellViewModel.senderName == nil) authorLabel.text = cellViewModel.senderName authorLabel.themeTextColor = .textPrimary - let authorLabelAvailableWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * VisibleMessageCell.authorLabelInset) + sessionProBadge.isHidden = !dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: cellViewModel.authorId)} + + let authorLabelAvailableWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * VisibleMessageCell.authorStackViewInset) let authorLabelAvailableSpace = CGSize(width: authorLabelAvailableWidth, height: .greatestFiniteMagnitude) let authorLabelSize = authorLabel.sizeThatFits(authorLabelAvailableSpace) - authorLabelHeightConstraint.constant = (cellViewModel.senderName != nil ? authorLabelSize.height : 0) + authorStackViewHeightConstraint.constant = (cellViewModel.senderName != nil ? authorLabelSize.height : 0) // Flip horizontally for RTL languages replyIconImageView.transform = CGAffineTransform.identity @@ -999,7 +1015,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let location = gestureRecognizer.location(in: self) - if profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)), cellViewModel.shouldShowProfile { + if profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)) || authorStackView.bounds.contains(authorStackView.convert(location, from: self)) + { delegate?.showUserProfileModal(for: cellViewModel) } else if replyButton.alpha > 0 && replyButton.bounds.contains(replyButton.convert(location, from: self)) { diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index 81c07987e0..c34e6362cc 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -32,22 +32,39 @@ final class ConversationTitleView: UIView { result.accessibilityIdentifier = "Conversation header name" result.accessibilityLabel = "Conversation header name" result.isAccessibilityElement = true - result.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.font = Fonts.Headings.H5 result.themeTextColor = .textPrimary result.lineBreakMode = .byTruncatingTail return result }() + private lazy var sessionProBadge: SessionProBadge = { + let result: SessionProBadge = SessionProBadge(size: .small) + result.isHidden = true + + return result + }() + + private lazy var titleStackView: UIStackView = { + let result: UIStackView = UIStackView(arrangedSubviews: [ titleLabel, sessionProBadge ]) + result.axis = .horizontal + result.alignment = .center + result.spacing = 4 + + return result + }() + private lazy var labelCarouselView: SessionLabelCarouselView = { let result = SessionLabelCarouselView(using: dependencies) return result }() private lazy var stackView: UIStackView = { - let result = UIStackView(arrangedSubviews: [ titleLabel, labelCarouselView ]) + let result = UIStackView(arrangedSubviews: [ titleStackView, labelCarouselView ]) result.axis = .vertical result.alignment = .center + result.spacing = 2 return result }() @@ -80,12 +97,14 @@ final class ConversationTitleView: UIView { public func initialSetup( with threadVariant: SessionThread.Variant, isNoteToSelf: Bool, - isMessageRequest: Bool + isMessageRequest: Bool, + isSessionPro: Bool ) { self.update( with: " ", isNoteToSelf: isNoteToSelf, isMessageRequest: isMessageRequest, + isSessionPro: isSessionPro, threadVariant: threadVariant, mutedUntilTimestamp: nil, onlyNotifyForMentions: false, @@ -113,6 +132,7 @@ final class ConversationTitleView: UIView { with name: String, isNoteToSelf: Bool, isMessageRequest: Bool, + isSessionPro: Bool, threadVariant: SessionThread.Variant, mutedUntilTimestamp: TimeInterval?, onlyNotifyForMentions: Bool, @@ -130,12 +150,8 @@ final class ConversationTitleView: UIView { self.titleLabel.text = name self.titleLabel.accessibilityLabel = name - self.titleLabel.font = .boldSystemFont( - ofSize: (shouldHaveSubtitle ? - Values.largeFontSize : - Values.veryLargeFontSize - ) - ) + self.titleLabel.font = (shouldHaveSubtitle ? Fonts.Headings.H6 : Fonts.Headings.H5) + self.sessionProBadge.isHidden = !isSessionPro self.labelCarouselView.isHidden = !shouldHaveSubtitle // Contact threads also have the call button to compensate for diff --git a/Session/Settings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettingsViewModel.swift index bad924ad24..c5774843e7 100644 --- a/Session/Settings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettingsViewModel.swift @@ -1084,12 +1084,12 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, updateFlag(for: .mockCurrentUserSessionPro, to: nil) - case .proIncomingMessages: - guard dependencies.hasSet(feature: .treatAllIncomingMessagesAsProMessages) else { + case .allUsersSessionPro: + guard dependencies.hasSet(feature: .allUsersSessionPro) else { return } - updateFlag(for: .treatAllIncomingMessagesAsProMessages, to: nil) + updateFlag(for: .allUsersSessionPro, to: nil) case .forceSlowDatabaseQueries: guard dependencies.hasSet(feature: .forceSlowDatabaseQueries) else { return } diff --git a/Session/Settings/Views/ThemeMessagePreviewView.swift b/Session/Settings/Views/ThemeMessagePreviewView.swift index 166bbab9f7..2e365f5f7a 100644 --- a/Session/Settings/Views/ThemeMessagePreviewView.swift +++ b/Session/Settings/Views/ThemeMessagePreviewView.swift @@ -35,7 +35,7 @@ final class ThemeMessagePreviewView: UIView { ) // Remove built-in padding - result.authorLabelTopConstraint.constant = 0 + result.authorStackViewTopConstraint.constant = 0 result.contentViewLeadingConstraint1.constant = 0 return result @@ -59,7 +59,7 @@ final class ThemeMessagePreviewView: UIView { ) // Remove built-in padding - result.authorLabelTopConstraint.constant = 0 + result.authorStackViewTopConstraint.constant = 0 result.contentViewTrailingConstraint1.constant = 0 return result diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift index 3b7542ae3a..7b85f051f7 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift @@ -36,7 +36,12 @@ public extension LibSessionCacheType { return dependencies[feature: .allUsersSessionPro] } - func getProProof() -> String? { + func validateSessionProState(for sessionId: String?) -> Bool { + guard let sessionId = sessionId, dependencies[feature: .sessionProEnabled] else { return false } + return dependencies[feature: .allUsersSessionPro] + } + + func getCurrentUserProProof() -> String? { guard isSessionPro else { return nil } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index 4f4bd20321..6115d191c9 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -71,7 +71,7 @@ internal extension LibSessionCacheType { url: displayPictureUrl, key: displayPic.get(\.key), filePath: filePath, - sessionProProof: getProProof() // TODO: double check if this is needed after Pro Proof is implemented + sessionProProof: getCurrentUserProProof() // TODO: double check if this is needed after Pro Proof is implemented ) }(), profileUpdateTimestamp: TimeInterval(Double(serverTimestampMs) / 1000), diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index bb681dd1b5..2660842d5b 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -26,7 +26,7 @@ extension MessageSender { message: VisibleMessage.from( db, interaction: interaction, - proProof: dependencies.mutate(cache: .libSession, { $0.getProProof() }) + proProof: dependencies.mutate(cache: .libSession, { $0.getCurrentUserProProof() }) ), threadId: threadId, interactionId: interactionId, diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index a85fc4b1d2..fb488faf30 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -334,6 +334,10 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D ) } + public func isSessionPro(using dependencies: Dependencies) -> Bool { + return dependencies.mutate(cache: .libSession) { [profile] in $0.validateProProof(for: profile)} + } + // MARK: - Marking as Read public enum ReadTarget { diff --git a/SessionMessagingKit/Utilities/Profile+CurrentUser.swift b/SessionMessagingKit/Utilities/Profile+CurrentUser.swift index 92ab6b1b80..eb7159e924 100644 --- a/SessionMessagingKit/Utilities/Profile+CurrentUser.swift +++ b/SessionMessagingKit/Utilities/Profile+CurrentUser.swift @@ -106,7 +106,7 @@ public extension Profile { url: result.downloadUrl, key: result.encryptionKey, filePath: result.filePath, - sessionProProof: dependencies.mutate(cache: .libSession) { $0.getProProof() } + sessionProProof: dependencies.mutate(cache: .libSession) { $0.getCurrentUserProProof() } ), profileUpdateTimestamp: TimeInterval(profileUpdateTimestampMs / 1000), using: dependencies diff --git a/SessionUIKit/Components/SessionProBadge.swift b/SessionUIKit/Components/SessionProBadge.swift index 35e0b5837e..4439409dd6 100644 --- a/SessionUIKit/Components/SessionProBadge.swift +++ b/SessionUIKit/Components/SessionProBadge.swift @@ -4,34 +4,39 @@ import UIKit public class SessionProBadge: UIView { public enum Size { - case small, large + case mini, small, large var width: CGFloat { switch self { + case .mini: return 24 case .small: return 40 case .large: return 52 } } var height: CGFloat { switch self { + case .mini: return 11 case .small: return 18 case .large: return 26 } } var cornerRadius: CGFloat { switch self { + case .mini: return 3 case .small: return 4 case .large: return 6 } } var proFontHeight: CGFloat { switch self { + case .mini: return 5 case .small: return 7 case .large: return 11 } } var proFontWidth: CGFloat { switch self { + case .mini: return 18 case .small: return 28 case .large: return 40 } @@ -42,10 +47,10 @@ public class SessionProBadge: UIView { // MARK: - Initialization - public init(size: Size) { + public init(size: Size, themeBackgroundColor: ThemeValue = .primary) { self.size = size super.init(frame: .zero) - self.setupView() + self.setupView(themeBackgroundColor) } public override init(frame: CGRect) { @@ -64,13 +69,13 @@ public class SessionProBadge: UIView { return result }() - private func setupView() { + private func setupView(_ themeBackgroundColor: ThemeValue) { self.addSubview(proImageView) proImageView.set(.height, to: self.size.proFontHeight) proImageView.set(.width, to: self.size.proFontWidth) proImageView.center(in: self) - self.themeBackgroundColor = .primary + self.themeBackgroundColor = themeBackgroundColor self.clipsToBounds = true self.layer.cornerRadius = self.size.cornerRadius self.set(.width, to: self.size.width) diff --git a/SessionUIKit/Components/SwiftUI/SessionProBadge+SwiftUI.swift b/SessionUIKit/Components/SwiftUI/SessionProBadge+SwiftUI.swift index 94b482f009..6d0e239115 100644 --- a/SessionUIKit/Components/SwiftUI/SessionProBadge+SwiftUI.swift +++ b/SessionUIKit/Components/SwiftUI/SessionProBadge+SwiftUI.swift @@ -4,15 +4,17 @@ import SwiftUI public struct SessionProBadge_SwiftUI: View { private let size: SessionProBadge.Size + private let themeBackgroundColor: ThemeValue - public init(size: SessionProBadge.Size) { + public init(size: SessionProBadge.Size, themeBackgroundColor: ThemeValue = .primary) { self.size = size + self.themeBackgroundColor = themeBackgroundColor } public var body: some View { ZStack { RoundedRectangle(cornerRadius: size.cornerRadius) - .fill(themeColor: .primary) + .fill(themeColor: themeBackgroundColor) Image("session_pro") .resizable() From 84ec23f2d7932d21c2f18f08247c1e2e8d2fe49a Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 14 Aug 2025 16:03:39 +1000 Subject: [PATCH 003/162] pro badges in conversation list --- .../Conversations/Input View/InputView.swift | 2 +- .../ConversationTitleView.swift | 2 +- Session/Shared/FullConversationCell.swift | 13 +++++++++++- .../SessionThreadViewModel.swift | 5 ++++- .../Modals & Toast/ConfirmationModal.swift | 2 +- SessionUIKit/Components/SessionProBadge.swift | 21 ++++++++++++------- .../Components/SwiftUI/ProCTAModal.swift | 2 +- .../AttachmentTextToolbar.swift | 2 +- 8 files changed, 34 insertions(+), 15 deletions(-) diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 74316f9525..f47504e31a 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -179,7 +179,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M }() private lazy var sessionProBadge: SessionProBadge = { - let result: SessionProBadge = SessionProBadge(size: .small) + let result: SessionProBadge = SessionProBadge(size: .medium) result.isHidden = !dependencies[feature: .sessionProEnabled] || dependencies[cache: .libSession].isSessionPro return result diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index c34e6362cc..254ea323f9 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -40,7 +40,7 @@ final class ConversationTitleView: UIView { }() private lazy var sessionProBadge: SessionProBadge = { - let result: SessionProBadge = SessionProBadge(size: .small) + let result: SessionProBadge = SessionProBadge(size: .medium) result.isHidden = true return result diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index dc3aecf9d1..b844cbfdc5 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -34,6 +34,13 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC return result }() + + private lazy var sessionProBadge: SessionProBadge = { + let result: SessionProBadge = SessionProBadge(size: .small) + result.isHidden = true + + return result + }() private lazy var unreadCountView: UIView = { let result: UIView = UIView() @@ -225,7 +232,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC // Label stack view let topLabelSpacer = UIView.hStretchingSpacer() - [ displayNameLabel, isPinnedIcon, unreadCountView, unreadImageView, hasMentionView, topLabelSpacer, timestampLabel ].forEach{ view in + [ displayNameLabel, sessionProBadge, isPinnedIcon, unreadCountView, unreadImageView, hasMentionView, topLabelSpacer, timestampLabel ].forEach{ view in topLabelStackView.addArrangedSubview(view) } @@ -300,6 +307,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC string: cellViewModel.displayName, attributes: [ .themeForegroundColor: ThemeValue.textPrimary ] ) + sessionProBadge.isHidden = !cellViewModel.isSessionPro(using: dependencies) } public func updateForMessageSearchResult( @@ -328,6 +336,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC string: cellViewModel.displayName, attributes: [ .themeForegroundColor: ThemeValue.textPrimary ] ) + sessionProBadge.isHidden = !cellViewModel.isSessionPro(using: dependencies) snippetLabel.themeAttributedText = getHighlightedSnippet( content: Interaction.previewText( variant: (cellViewModel.interactionVariant ?? .standardIncoming), @@ -378,6 +387,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC textColor: .textPrimary, using: dependencies ) + sessionProBadge.isHidden = !cellViewModel.isSessionPro(using: dependencies) switch cellViewModel.threadVariant { case .contact, .community: bottomLabelStackView.isHidden = true @@ -440,6 +450,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC using: dependencies ) displayNameLabel.text = cellViewModel.displayName + sessionProBadge.isHidden = !cellViewModel.isSessionPro(using: dependencies) timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay if cellViewModel.threadContactIsTyping == true { diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index fb488faf30..39956d8e6c 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -335,7 +335,10 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D } public func isSessionPro(using dependencies: Dependencies) -> Bool { - return dependencies.mutate(cache: .libSession) { [profile] in $0.validateProProof(for: profile)} + guard threadIsNoteToSelf == false && threadVariant != .community else { + return false + } + return dependencies.mutate(cache: .libSession) { [threadId] in $0.validateSessionProState(for: threadId)} } // MARK: - Marking as Read diff --git a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift index 8a53863bb1..1de8d5667d 100644 --- a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift +++ b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift @@ -186,7 +186,7 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { }() private lazy var proImageStackView: UIStackView = { - let proBadge: SessionProBadge = SessionProBadge(size: .small) + let proBadge: SessionProBadge = SessionProBadge(size: .medium) let label: UILabel = UILabel() label.font = .systemFont(ofSize: Values.smallFontSize) label.themeTextColor = .textSecondary diff --git a/SessionUIKit/Components/SessionProBadge.swift b/SessionUIKit/Components/SessionProBadge.swift index 4439409dd6..9d09245fac 100644 --- a/SessionUIKit/Components/SessionProBadge.swift +++ b/SessionUIKit/Components/SessionProBadge.swift @@ -4,40 +4,45 @@ import UIKit public class SessionProBadge: UIView { public enum Size { - case mini, small, large + case mini, small, medium, large var width: CGFloat { switch self { case .mini: return 24 - case .small: return 40 + case .small: return 32 + case .medium: return 40 case .large: return 52 } } var height: CGFloat { switch self { case .mini: return 11 - case .small: return 18 + case .small: return 14.5 + case .medium: return 18 case .large: return 26 } } var cornerRadius: CGFloat { switch self { - case .mini: return 3 - case .small: return 4 + case .mini: return 2.5 + case .small: return 3.5 + case .medium: return 4 case .large: return 6 } } var proFontHeight: CGFloat { switch self { case .mini: return 5 - case .small: return 7 + case .small: return 6 + case .medium: return 7 case .large: return 11 } } var proFontWidth: CGFloat { switch self { - case .mini: return 18 - case .small: return 28 + case .mini: return 17 + case .small: return 24 + case .medium: return 28 case .large: return 40 } } diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index 20105dff65..b4d21a8fc0 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -240,7 +240,7 @@ public struct ProCTAModal: View { .font(.Body.largeRegular) .foregroundColor(themeColor: .textSecondary) - SessionProBadge_SwiftUI(size: .small) + SessionProBadge_SwiftUI(size: .medium) } } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift index 26204358e0..853bf206bb 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift @@ -90,7 +90,7 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate { }() private lazy var sessionProBadge: SessionProBadge = { - let result: SessionProBadge = SessionProBadge(size: .small) + let result: SessionProBadge = SessionProBadge(size: .medium) result.isHidden = !dependencies[feature: .sessionProEnabled] || dependencies[cache: .libSession].isSessionPro return result From eb178ef11d902abfb6d8e95811427eba9f33cc5f Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 14 Aug 2025 16:41:24 +1000 Subject: [PATCH 004/162] fix the cancel button position in quote draft view --- .../Conversations/Message Cells/Content Views/QuoteView.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index 0257ec422d..ede93d29df 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -87,8 +87,6 @@ final class QuoteView: UIView { let mainStackView = UIStackView(arrangedSubviews: []) mainStackView.axis = .horizontal mainStackView.spacing = smallSpacing - mainStackView.isLayoutMarginsRelativeArrangement = true - mainStackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: smallSpacing) mainStackView.alignment = .center // Content view From 134073312df6ed893f70a8278f4d78a5dd937344 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 15 Aug 2025 11:32:18 +1000 Subject: [PATCH 005/162] feat: label with pro badge and refactor --- Session.xcodeproj/project.pbxproj | 4 + .../Content Views/QuoteView.swift | 18 ++- .../Message Cells/VisibleMessageCell.swift | 54 ++++----- .../ConversationTitleView.swift | 28 ++--- Session/Shared/FullConversationCell.swift | 25 ++-- .../Components/SessionLabelWithProBadge.swift | 112 ++++++++++++++++++ 6 files changed, 162 insertions(+), 79 deletions(-) create mode 100644 SessionUIKit/Components/SessionLabelWithProBadge.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index cbb03010ff..1f02e0dc09 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -175,6 +175,7 @@ 942ADDD42D9F9613006E0BB0 /* NewTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942ADDD32D9F960C006E0BB0 /* NewTagView.swift */; }; 942BA9412E4487F7007C4595 /* LightBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9402E4487EE007C4595 /* LightBox.swift */; }; 942BA9BF2E4ABBA1007C4595 /* _030_LastProfileUpdateTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9BE2E4ABB9F007C4595 /* _030_LastProfileUpdateTimestamp.swift */; }; + 942BA9C12E4EA5CB007C4595 /* SessionLabelWithProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */; }; 943C6D822B75E061004ACE64 /* Message+DisappearingMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */; }; 945D9C582D6FDBE7003C4C0C /* _005_AddJobUniqueHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945D9C572D6FDBE7003C4C0C /* _005_AddJobUniqueHash.swift */; }; 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */ = {isa = PBXBuildFile; productRef = 946F5A722D5DA3AC00A5ADCE /* Punycode */; }; @@ -1550,6 +1551,7 @@ 942ADDD32D9F960C006E0BB0 /* NewTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTagView.swift; sourceTree = ""; }; 942BA9402E4487EE007C4595 /* LightBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightBox.swift; sourceTree = ""; }; 942BA9BE2E4ABB9F007C4595 /* _030_LastProfileUpdateTimestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _030_LastProfileUpdateTimestamp.swift; sourceTree = ""; }; + 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionLabelWithProBadge.swift; sourceTree = ""; }; 94367C422C6C828500814252 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+DisappearingMessages.swift"; sourceTree = ""; }; 943C6D832B86B5F1004ACE64 /* Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localization.swift; sourceTree = ""; }; @@ -3357,6 +3359,7 @@ 942256932C23F8DD00C0FDBF /* SwiftUI */, B8B5BCEB2394D869003823C9 /* SessionButton.swift */, 94CD95BA2E08D9D40097754D /* SessionProBadge.swift */, + 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */, FD52090228B4680F006098F6 /* RadioButton.swift */, B8BB82B02390C37000BA5194 /* SearchBar.swift */, B8BB82B82394911B00BA5194 /* Separator.swift */, @@ -6126,6 +6129,7 @@ 94AAB1512E1F753500A6FA18 /* CyclicGradientView.swift in Sources */, FD8A5B1E2DBF4BBC004C689B /* ScreenLock+Errors.swift in Sources */, 942BA9412E4487F7007C4595 /* LightBox.swift in Sources */, + 942BA9C12E4EA5CB007C4595 /* SessionLabelWithProBadge.swift in Sources */, FD2272E62C351378004D8A6C /* SUIKImageFormat.swift in Sources */, 7BF8D1FB2A70AF57005F1D6E /* SwiftUI+Theme.swift in Sources */, FDB11A612DD5BDCC00BEF49F /* ImageDataManager.swift in Sources */, diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index ede93d29df..fb3f5fe7a7 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -195,7 +195,10 @@ final class QuoteView: UIView { .defaulting(to: ThemedAttributedString(string: "messageErrorOriginal".localized(), attributes: [ .themeForegroundColor: targetThemeColor ])) // Label stack view - let authorLabel = UILabel() + let authorLabel = SessionLabelWithProBadge( + proBadgeSize: .mini, + proBadgeThemeBackgroundColor: proBadgeThemeColor + ) authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) authorLabel.text = { guard !currentUserSessionIds.contains(authorId) else { return "you".localized() } @@ -217,17 +220,10 @@ final class QuoteView: UIView { authorLabel.themeTextColor = targetThemeColor authorLabel.lineBreakMode = .byTruncatingTail authorLabel.numberOfLines = 1 + authorLabel.isHidden = (authorLabel.text == nil) + authorLabel.isProBadgeHidden = !dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: authorId) } - let sessionProBadge: SessionProBadge = SessionProBadge(size: .mini, themeBackgroundColor: proBadgeThemeColor) - sessionProBadge.isHidden = !dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: authorId) } - - let authorStackView: UIStackView = UIStackView(arrangedSubviews: [ authorLabel, sessionProBadge, UIView.hStretchingSpacer() ]) - authorStackView.axis = .horizontal - authorStackView.spacing = 3 - authorStackView.alignment = .center - authorStackView.isHidden = (authorLabel.text == nil) - - let labelStackView = UIStackView(arrangedSubviews: [ authorStackView, bodyLabel ]) + let labelStackView = UIStackView(arrangedSubviews: [ authorLabel, bodyLabel ]) labelStackView.axis = .vertical labelStackView.spacing = labelStackViewSpacing labelStackView.distribution = .equalCentering diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index ebf89e01f8..07e458adc8 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -26,12 +26,12 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { override var contextSnapshotView: UIView? { return snContentView } // Constraints - internal lazy var authorStackViewTopConstraint = authorStackView.pin(.top, to: .top, of: self) - private lazy var authorStackViewHeightConstraint = authorStackView.set(.height, to: 0) + internal lazy var authorLabelTopConstraint = authorLabel.pin(.top, to: .top, of: self) + private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0) private lazy var profilePictureViewLeadingConstraint = profilePictureView.pin(.leading, to: .leading, of: self, withInset: VisibleMessageCell.groupThreadHSpacing) internal lazy var contentViewLeadingConstraint1 = snContentView.pin(.leading, to: .trailing, of: profilePictureView, withInset: VisibleMessageCell.groupThreadHSpacing) private lazy var contentViewLeadingConstraint2 = snContentView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: VisibleMessageCell.gutterSize) - private lazy var contentViewTopConstraint = snContentView.pin(.top, to: .bottom, of: authorStackView, withInset: VisibleMessageCell.authorStackViewBottomSpacing) + private lazy var contentViewTopConstraint = snContentView.pin(.top, to: .bottom, of: authorLabel, withInset: VisibleMessageCell.authorLabelBottomSpacing) internal lazy var contentViewTrailingConstraint1 = snContentView.pin(.trailing, to: .trailing, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing) private lazy var contentViewTrailingConstraint2 = snContentView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -VisibleMessageCell.gutterSize) private lazy var contentBottomConstraint = snContentView.bottomAnchor @@ -84,23 +84,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { return result }() - private lazy var authorLabel: UILabel = { - let result = UILabel() + private lazy var authorLabel: SessionLabelWithProBadge = { + let result = SessionLabelWithProBadge(proBadgeSize: .mini) result.font = .boldSystemFont(ofSize: Values.smallFontSize) - return result - }() - - private lazy var sessionProBadge: SessionProBadge = { - let result = SessionProBadge(size: .mini) - result.isHidden = true - return result - }() - - private lazy var authorStackView: UIStackView = { - let result = UIStackView(arrangedSubviews: [ authorLabel, sessionProBadge, UIView.hStretchingSpacer() ]) - result.axis = .horizontal - result.spacing = 3 - result.alignment = .center + result.isProBadgeHidden = true return result }() @@ -193,9 +180,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { // MARK: - Settings private static let messageStatusImageViewSize: CGFloat = 12 - private static let authorStackViewBottomSpacing: CGFloat = 4 + private static let authorLabelBottomSpacing: CGFloat = 4 private static let groupThreadHSpacing: CGFloat = 12 - private static let authorStackViewInset: CGFloat = 12 + private static let authorLabelInset: CGFloat = 12 private static let replyButtonSize: CGFloat = 24 private static let maxBubbleTranslationX: CGFloat = 40 private static let swipeToReplyThreshold: CGFloat = 110 @@ -225,9 +212,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { super.setUpViewHierarchy() // Author label - addSubview(authorStackView) - authorStackViewTopConstraint.isActive = true - authorStackViewHeightConstraint.isActive = true + addSubview(authorLabel) + authorLabelTopConstraint.isActive = true + authorLabelHeightConstraint.isActive = true // Profile picture view addSubview(profilePictureView) @@ -252,8 +239,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { replyButton.center(.vertical, in: snContentView) // Remaining constraints - authorStackView.pin(.leading, to: .leading, of: snContentView, withInset: VisibleMessageCell.authorStackViewInset) - authorStackView.pin(.trailing, to: .trailing, of: self, withInset: -Values.mediumSpacing) + authorLabel.pin(.leading, to: .leading, of: snContentView, withInset: VisibleMessageCell.authorLabelInset) + authorLabel.pin(.trailing, to: .trailing, of: self, withInset: -Values.mediumSpacing) // Under bubble content addSubview(underBubbleStackView) @@ -350,7 +337,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { contentViewLeadingConstraint1.isActive = cellViewModel.variant.isIncoming contentViewLeadingConstraint1.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : VisibleMessageCell.contactThreadHSpacing) contentViewLeadingConstraint2.isActive = cellViewModel.variant.isOutgoing - contentViewTopConstraint.constant = (cellViewModel.senderName == nil ? 0 : VisibleMessageCell.authorStackViewBottomSpacing) + contentViewTopConstraint.constant = (cellViewModel.senderName == nil ? 0 : VisibleMessageCell.authorLabelBottomSpacing) contentViewTrailingConstraint1.isActive = cellViewModel.variant.isOutgoing contentViewTrailingConstraint2.isActive = cellViewModel.variant.isIncoming @@ -373,17 +360,16 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { bubbleView.isAccessibilityElement = true // Author label - authorStackViewTopConstraint.constant = (shouldAddTopInset ? Values.mediumSpacing : 0) - authorStackView.isHidden = (cellViewModel.senderName == nil) + authorLabelTopConstraint.constant = (shouldAddTopInset ? Values.mediumSpacing : 0) + authorLabel.isHidden = (cellViewModel.senderName == nil) authorLabel.text = cellViewModel.senderName authorLabel.themeTextColor = .textPrimary + authorLabel.isProBadgeHidden = !dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: cellViewModel.authorId)} - sessionProBadge.isHidden = !dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: cellViewModel.authorId)} - - let authorLabelAvailableWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * VisibleMessageCell.authorStackViewInset) + let authorLabelAvailableWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * VisibleMessageCell.authorLabelInset) let authorLabelAvailableSpace = CGSize(width: authorLabelAvailableWidth, height: .greatestFiniteMagnitude) let authorLabelSize = authorLabel.sizeThatFits(authorLabelAvailableSpace) - authorStackViewHeightConstraint.constant = (cellViewModel.senderName != nil ? authorLabelSize.height : 0) + authorLabelHeightConstraint.constant = (cellViewModel.senderName != nil ? authorLabelSize.height : 0) // Flip horizontally for RTL languages replyIconImageView.transform = CGAffineTransform.identity @@ -1015,7 +1001,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let location = gestureRecognizer.location(in: self) - if profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)) || authorStackView.bounds.contains(authorStackView.convert(location, from: self)) + if profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)) || authorLabel.bounds.contains(authorLabel.convert(location, from: self)) { delegate?.showUserProfileModal(for: cellViewModel) } diff --git a/Session/Conversations/Views & Modals/ConversationTitleView.swift b/Session/Conversations/Views & Modals/ConversationTitleView.swift index 254ea323f9..090b46e07a 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -27,30 +27,18 @@ final class ConversationTitleView: UIView { private lazy var stackViewLeadingConstraint: NSLayoutConstraint = stackView.pin(.leading, to: .leading, of: self) private lazy var stackViewTrailingConstraint: NSLayoutConstraint = stackView.pin(.trailing, to: .trailing, of: self) - private lazy var titleLabel: UILabel = { - let result: UILabel = UILabel() + private lazy var titleLabel: SessionLabelWithProBadge = { + let result: SessionLabelWithProBadge = SessionLabelWithProBadge( + proBadgeSize: .medium, + withStretchingSpacer: false + ) result.accessibilityIdentifier = "Conversation header name" result.accessibilityLabel = "Conversation header name" result.isAccessibilityElement = true result.font = Fonts.Headings.H5 result.themeTextColor = .textPrimary result.lineBreakMode = .byTruncatingTail - - return result - }() - - private lazy var sessionProBadge: SessionProBadge = { - let result: SessionProBadge = SessionProBadge(size: .medium) - result.isHidden = true - - return result - }() - - private lazy var titleStackView: UIStackView = { - let result: UIStackView = UIStackView(arrangedSubviews: [ titleLabel, sessionProBadge ]) - result.axis = .horizontal - result.alignment = .center - result.spacing = 4 + result.isProBadgeHidden = true return result }() @@ -61,7 +49,7 @@ final class ConversationTitleView: UIView { }() private lazy var stackView: UIStackView = { - let result = UIStackView(arrangedSubviews: [ titleStackView, labelCarouselView ]) + let result = UIStackView(arrangedSubviews: [ titleLabel, labelCarouselView ]) result.axis = .vertical result.alignment = .center result.spacing = 2 @@ -151,7 +139,7 @@ final class ConversationTitleView: UIView { self.titleLabel.text = name self.titleLabel.accessibilityLabel = name self.titleLabel.font = (shouldHaveSubtitle ? Fonts.Headings.H6 : Fonts.Headings.H5) - self.sessionProBadge.isHidden = !isSessionPro + self.titleLabel.isProBadgeHidden = !isSessionPro self.labelCarouselView.isHidden = !shouldHaveSubtitle // Contact threads also have the call button to compensate for diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index b844cbfdc5..27d7df7d53 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -26,18 +26,15 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC dataManager: nil ) - private lazy var displayNameLabel: UILabel = { - let result: UILabel = UILabel() + private lazy var displayNameLabel: SessionLabelWithProBadge = { + let result: SessionLabelWithProBadge = SessionLabelWithProBadge( + proBadgeSize: .small, + withStretchingSpacer: false + ) result.font = .boldSystemFont(ofSize: Values.mediumFontSize) result.themeTextColor = .textPrimary result.lineBreakMode = .byTruncatingTail - - return result - }() - - private lazy var sessionProBadge: SessionProBadge = { - let result: SessionProBadge = SessionProBadge(size: .small) - result.isHidden = true + result.isProBadgeHidden = true return result }() @@ -232,7 +229,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC // Label stack view let topLabelSpacer = UIView.hStretchingSpacer() - [ displayNameLabel, sessionProBadge, isPinnedIcon, unreadCountView, unreadImageView, hasMentionView, topLabelSpacer, timestampLabel ].forEach{ view in + [ displayNameLabel, isPinnedIcon, unreadCountView, unreadImageView, hasMentionView, topLabelSpacer, timestampLabel ].forEach{ view in topLabelStackView.addArrangedSubview(view) } @@ -307,7 +304,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC string: cellViewModel.displayName, attributes: [ .themeForegroundColor: ThemeValue.textPrimary ] ) - sessionProBadge.isHidden = !cellViewModel.isSessionPro(using: dependencies) + displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) } public func updateForMessageSearchResult( @@ -336,7 +333,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC string: cellViewModel.displayName, attributes: [ .themeForegroundColor: ThemeValue.textPrimary ] ) - sessionProBadge.isHidden = !cellViewModel.isSessionPro(using: dependencies) + displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) snippetLabel.themeAttributedText = getHighlightedSnippet( content: Interaction.previewText( variant: (cellViewModel.interactionVariant ?? .standardIncoming), @@ -387,7 +384,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC textColor: .textPrimary, using: dependencies ) - sessionProBadge.isHidden = !cellViewModel.isSessionPro(using: dependencies) + displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) switch cellViewModel.threadVariant { case .contact, .community: bottomLabelStackView.isHidden = true @@ -450,7 +447,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC using: dependencies ) displayNameLabel.text = cellViewModel.displayName - sessionProBadge.isHidden = !cellViewModel.isSessionPro(using: dependencies) + displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay if cellViewModel.threadContactIsTyping == true { diff --git a/SessionUIKit/Components/SessionLabelWithProBadge.swift b/SessionUIKit/Components/SessionLabelWithProBadge.swift new file mode 100644 index 0000000000..a2a31ad242 --- /dev/null +++ b/SessionUIKit/Components/SessionLabelWithProBadge.swift @@ -0,0 +1,112 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public class SessionLabelWithProBadge: UIView { + public var font: UIFont { + get { label.font } + set { label.font = newValue } + } + + public var text: String? { + get { label.text } + set { + guard label.text != newValue else { return } + label.text = newValue + } + } + + public var themeAttributedText: ThemedAttributedString? { + get { label.themeAttributedText } + set { + guard label.themeAttributedText != newValue else { return } + label.themeAttributedText = newValue + } + } + + public var themeTextColor: ThemeValue? { + get { label.themeTextColor } + set { label.themeTextColor = newValue } + } + + public var textAlignment: NSTextAlignment { + get { label.textAlignment } + set { label.textAlignment = newValue } + } + + public var lineBreakMode: NSLineBreakMode { + get { label.lineBreakMode } + set { label.lineBreakMode = newValue } + } + + public var numberOfLines: Int { + get { label.numberOfLines } + set { label.numberOfLines = newValue } + } + + public var isProBadgeHidden: Bool { + get { sessionProBadge.isHidden } + set { sessionProBadge.isHidden = newValue } + } + + private let proBadgeSize: SessionProBadge.Size + private let proBadgeThemeBackgroundColor: ThemeValue + private let withStretchingSpacer: Bool + + // MARK: - UI Components + + private let label: UILabel = UILabel() + + private lazy var sessionProBadge: SessionProBadge = { + let result: SessionProBadge = SessionProBadge(size: proBadgeSize, themeBackgroundColor: proBadgeThemeBackgroundColor) + result.isHidden = isProBadgeHidden + + return result + }() + + private lazy var stackView: UIStackView = { + let result: UIStackView = UIStackView( + arrangedSubviews: + [ + label, + sessionProBadge, + withStretchingSpacer ? UIView.hStretchingSpacer() : nil + ] + .compactMap { $0 } + ) + result.axis = .horizontal + result.spacing = { + switch proBadgeSize { + case .mini: return 3 + default: return 4 + } + }() + result.alignment = .center + + return result + }() + + // MARK: - Initialization + + public init( + proBadgeSize: SessionProBadge.Size, + proBadgeThemeBackgroundColor: ThemeValue = .primary, + withStretchingSpacer: Bool = true + ) { + self.proBadgeSize = proBadgeSize + self.proBadgeThemeBackgroundColor = proBadgeThemeBackgroundColor + self.withStretchingSpacer = withStretchingSpacer + + super.init(frame: .zero) + self.addSubview(stackView) + stackView.pin(to: self) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func sizeThatFits(_ size: CGSize) -> CGSize { + return label.sizeThatFits(size) + } +} From 3c6e04e867e1b76b83b80e45cbf82e3aa38b919a Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Tue, 19 Aug 2025 09:27:42 +1000 Subject: [PATCH 006/162] feat: pro badge on App title --- Session/Home/HomeVC.swift | 2 +- .../Views/ThemeMessagePreviewView.swift | 4 +-- Session/Shared/BaseVC.swift | 26 ++++++++++++++++--- .../Components/SessionLabelWithProBadge.swift | 2 +- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index a313630232..1c516c45f7 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -293,7 +293,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi serviceNetwork: self.viewModel.state.serviceNetwork, forceOffline: self.viewModel.state.forceOffline ) - setUpNavBarSessionHeading() + setUpNavBarSessionHeading(currentUserSessionProState: viewModel.dependencies[singleton: .sessionProState]) // Recovery phrase reminder view.addSubview(seedReminderView) diff --git a/Session/Settings/Views/ThemeMessagePreviewView.swift b/Session/Settings/Views/ThemeMessagePreviewView.swift index 2e365f5f7a..166bbab9f7 100644 --- a/Session/Settings/Views/ThemeMessagePreviewView.swift +++ b/Session/Settings/Views/ThemeMessagePreviewView.swift @@ -35,7 +35,7 @@ final class ThemeMessagePreviewView: UIView { ) // Remove built-in padding - result.authorStackViewTopConstraint.constant = 0 + result.authorLabelTopConstraint.constant = 0 result.contentViewLeadingConstraint1.constant = 0 return result @@ -59,7 +59,7 @@ final class ThemeMessagePreviewView: UIView { ) // Remove built-in padding - result.authorStackViewTopConstraint.constant = 0 + result.authorLabelTopConstraint.constant = 0 result.contentViewTrailingConstraint1.constant = 0 return result diff --git a/Session/Shared/BaseVC.swift b/Session/Shared/BaseVC.swift index 2e3d122f45..441b5bf632 100644 --- a/Session/Shared/BaseVC.swift +++ b/Session/Shared/BaseVC.swift @@ -2,8 +2,10 @@ import UIKit import SessionUIKit +import Combine public class BaseVC: UIViewController { + private var disposables: Set = Set() public var onViewWillAppear: ((UIViewController) -> Void)? public var onViewWillDisappear: ((UIViewController) -> Void)? public var onViewDidDisappear: ((UIViewController) -> Void)? @@ -81,16 +83,34 @@ public class BaseVC: UIViewController { navigationItem.titleView = container } - internal func setUpNavBarSessionHeading() { + internal func setUpNavBarSessionHeading(currentUserSessionProState: SessionProManagerType) { let headingImageView = UIImageView( image: UIImage(named: "SessionHeading")? .withRenderingMode(.alwaysTemplate) ) headingImageView.themeTintColor = .textPrimary headingImageView.contentMode = .scaleAspectFit - headingImageView.set(.width, to: 150) + headingImageView.set(.width, to: 140) headingImageView.set(.height, to: Values.mediumFontSize) - navigationItem.titleView = headingImageView + let sessionProBadge: SessionProBadge = SessionProBadge(size: .medium) + sessionProBadge.isHidden = !currentUserSessionProState.isSessionProSubject.value + + let stackView: UIStackView = UIStackView(arrangedSubviews: [ headingImageView, sessionProBadge ]) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = 0 + + currentUserSessionProState.isSessionProPublisher + .subscribe(on: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink( + receiveValue: { [weak sessionProBadge] isPro in + sessionProBadge?.isHidden = !isPro + } + ) + .store(in: &disposables) + + navigationItem.titleView = stackView } } diff --git a/SessionUIKit/Components/SessionLabelWithProBadge.swift b/SessionUIKit/Components/SessionLabelWithProBadge.swift index a2a31ad242..0b42ce6371 100644 --- a/SessionUIKit/Components/SessionLabelWithProBadge.swift +++ b/SessionUIKit/Components/SessionLabelWithProBadge.swift @@ -59,7 +59,7 @@ public class SessionLabelWithProBadge: UIView { private lazy var sessionProBadge: SessionProBadge = { let result: SessionProBadge = SessionProBadge(size: proBadgeSize, themeBackgroundColor: proBadgeThemeBackgroundColor) - result.isHidden = isProBadgeHidden + result.isHidden = true return result }() From 122a1a77c5ce9877c37f14a0059dd4711054570f Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Tue, 19 Aug 2025 17:13:27 +1000 Subject: [PATCH 007/162] feat: badges in Message info screen --- Session.xcodeproj/project.pbxproj | 6 +- .../Conversations/ConversationViewModel.swift | 3 +- .../Message Cells/VisibleMessageCell.swift | 4 +- .../MessageInfoScreen.swift | 213 ++++++++++++++---- .../Database/Models/Profile.swift | 10 +- .../Config Handling/LibSession+Pro.swift | 5 + .../Shared Models/MessageViewModel.swift | 11 +- .../ProfilePictureView+Convenience.swift | 2 +- .../Components/ProfilePictureView.swift | 2 +- .../Components}/SRCopyableLabel.swift | 6 +- .../Components/SessionLabelWithProBadge.swift | 10 +- 11 files changed, 210 insertions(+), 62 deletions(-) rename {Session/Shared/Views => SessionUIKit/Components}/SRCopyableLabel.swift (84%) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 1f02e0dc09..0604ffd8c6 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -176,6 +176,7 @@ 942BA9412E4487F7007C4595 /* LightBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9402E4487EE007C4595 /* LightBox.swift */; }; 942BA9BF2E4ABBA1007C4595 /* _030_LastProfileUpdateTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9BE2E4ABB9F007C4595 /* _030_LastProfileUpdateTimestamp.swift */; }; 942BA9C12E4EA5CB007C4595 /* SessionLabelWithProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */; }; + 942BA9C22E53F694007C4595 /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; 943C6D822B75E061004ACE64 /* Message+DisappearingMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */; }; 945D9C582D6FDBE7003C4C0C /* _005_AddJobUniqueHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945D9C572D6FDBE7003C4C0C /* _005_AddJobUniqueHash.swift */; }; 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */ = {isa = PBXBuildFile; productRef = 946F5A722D5DA3AC00A5ADCE /* Punycode */; }; @@ -401,7 +402,6 @@ C3CA3AC8255CDB2900F4C6D4 /* spanish.txt in Resources */ = {isa = PBXBuildFile; fileRef = C3CA3AC7255CDB2900F4C6D4 /* spanish.txt */; }; C3D0972B2510499C00F6E3E4 /* BackgroundPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D0972A2510499C00F6E3E4 /* BackgroundPoller.swift */; }; C3D90A5C25773A25002C9DF5 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; - C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; D2179CFC16BB0B3A0006F3AB /* CoreTelephony.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2179CFB16BB0B3A0006F3AB /* CoreTelephony.framework */; }; D2179CFE16BB0B480006F3AB /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2179CFD16BB0B480006F3AB /* SystemConfiguration.framework */; }; D221A08E169C9E5E00537ABF /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D221A08D169C9E5E00537ABF /* UIKit.framework */; }; @@ -3358,6 +3358,7 @@ 94CD96282E1B855E0097754D /* Input View */, 942256932C23F8DD00C0FDBF /* SwiftUI */, B8B5BCEB2394D869003823C9 /* SessionButton.swift */, + C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */, 94CD95BA2E08D9D40097754D /* SessionProBadge.swift */, 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */, FD52090228B4680F006098F6 /* RadioButton.swift */, @@ -4455,7 +4456,6 @@ FD71164728E2CE8700B47552 /* SessionCell+AccessoryView.swift */, FD71164528E2CC1300B47552 /* SessionHighlightingBackgroundLabel.swift */, 7B71A98E2925E2A600E54854 /* SessionFooterView.swift */, - C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */, ); path = Views; sourceTree = ""; @@ -6078,6 +6078,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 942BA9C22E53F694007C4595 /* SRCopyableLabel.swift in Sources */, 942256952C23F8DD00C0FDBF /* AttributedText.swift in Sources */, 942256992C23F8DD00C0FDBF /* Toast.swift in Sources */, C331FF972558FA6B00070591 /* Fonts.swift in Sources */, @@ -6845,7 +6846,6 @@ 94B3DC172AF8592200C88531 /* QuoteView_SwiftUI.swift in Sources */, 4C4AE6A1224AF35700D4AF6F /* SendMediaNavigationController.swift in Sources */, B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */, - C3DAB3242480CB2B00725F25 /* SRCopyableLabel.swift in Sources */, 7B9F71D02852EEE2006DFE7B /* Emoji+Category.swift in Sources */, FD78EA0B2DDFE45E00D55B50 /* Interaction+UI.swift in Sources */, 7BAADFCC27B0EF23007BCF92 /* CallVideoView.swift in Sources */, diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 353fa71a6e..39b8ae211c 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -745,7 +745,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold sentTimestampMs: Double(sentTimestampMs) ), linkPreviewUrl: linkPreviewDraft?.urlString, - isProMessage: dependencies[cache: .libSession].isSessionPro, + isProMessage: (text.defaulting(to: "").utf16.count > LibSession.CharacterLimit), using: dependencies ) let optimisticAttachments: [Attachment]? = attachments @@ -775,6 +775,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold body: interaction.body, expiresStartedAtMs: interaction.expiresStartedAtMs, expiresInSeconds: interaction.expiresInSeconds, + isProMessage: interaction.isProMessage, isSenderModeratorOrAdmin: { switch threadData.threadVariant { case .group, .legacyGroup: diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 07e458adc8..c8c8d2b7d3 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -1147,7 +1147,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { } } - private static func getMaxHeightAfterTruncation(for cellViewModel: MessageViewModel) -> CGFloat { + public static func getMaxHeightAfterTruncation(for cellViewModel: MessageViewModel) -> CGFloat { return CGFloat(maxNumberOfLinesAfterTruncation) * UIFont.systemFont(ofSize: getFontSize(for: cellViewModel)).lineHeight } @@ -1349,7 +1349,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { return attributedText } - static func getBodyTappableLabel( + public static func getBodyTappableLabel( for cellViewModel: MessageViewModel, with availableWidth: CGFloat, textColor: ThemeValue, diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index e614b81b5f..46b90d4bea 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -5,22 +5,50 @@ import SessionUIKit import SessionSnodeKit import SessionUtilitiesKit import SessionMessagingKit +import Lucide struct MessageInfoScreen: View { @EnvironmentObject var host: HostWrapper @State var index = 1 @State var feedbackMessage: String? = nil + @State var isExpanded: Bool = false static private let cornerRadius: CGFloat = 17 var actions: [ContextMenuVC.Action] var messageViewModel: MessageViewModel let dependencies: Dependencies - var isMessageFailed: Bool { - return [.failed, .failedToSync].contains(messageViewModel.state) + let isMessageFailed: Bool + let isCurrentUser: Bool + let profileInfo: ProfilePictureView.Info? + var proFeatures: [String] = [] + var proCTAVariant: ProCTAModal.Variant = .generic + + public init(actions: [ContextMenuVC.Action], messageViewModel: MessageViewModel, using dependencies: Dependencies) { + self.actions = actions + self.messageViewModel = messageViewModel + self.dependencies = dependencies + + self.isMessageFailed = [.failed, .failedToSync].contains(messageViewModel.state) + self.isCurrentUser = (messageViewModel.currentUserSessionIds ?? []).contains(messageViewModel.authorId) + self.profileInfo = ProfilePictureView.getProfilePictureInfo( + size: .message, + publicKey: ( + // Prioritise the profile.id because we override it for + // messages sent by the current user in communities + messageViewModel.profile?.id ?? + messageViewModel.authorId + ), + threadVariant: .contact, // Always show the display picture in 'contact' mode + displayPictureUrl: nil, + profile: messageViewModel.profile, + profileIcon: (messageViewModel.isSenderModeratorOrAdmin ? .crown : .none), + using: dependencies + ).info + + (self.proFeatures, self.proCTAVariant) = getProFeaturesInfo() } - private var isCurrentUser: Bool { (messageViewModel.currentUserSessionIds ?? []).contains(messageViewModel.authorId) } var body: some View { ZStack (alignment: .topLeading) { @@ -184,7 +212,7 @@ struct MessageInfoScreen: View { ) { InfoBlock(title: "attachmentsFileId".localized()) { Text(attachment.downloadUrl.map { Attachment.fileId(for: $0) } ?? "") - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -193,7 +221,7 @@ struct MessageInfoScreen: View { ) { InfoBlock(title: "attachmentsFileType".localized()) { Text(attachment.contentType) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -201,7 +229,7 @@ struct MessageInfoScreen: View { InfoBlock(title: "attachmentsFileSize".localized()) { Text(Format.fileSize(attachment.byteCount)) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -216,7 +244,7 @@ struct MessageInfoScreen: View { }() InfoBlock(title: "attachmentsResolution".localized()) { Text(resolution) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -228,7 +256,7 @@ struct MessageInfoScreen: View { }() InfoBlock(title: "attachmentsDuration".localized()) { Text(duration) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -256,15 +284,54 @@ struct MessageInfoScreen: View { alignment: .leading, spacing: Values.mediumSpacing ) { + // Pro feature message + if proFeatures.count > 0 { + VStack( + alignment: .leading, + spacing: Values.mediumSpacing + ) { + HStack(spacing: Values.verySmallSpacing) { + SessionProBadge_SwiftUI(size: .small) + Text("message".localized()) + .font(.Body.extraLargeBold) + .foregroundColor(themeColor: .textPrimary) + } + .onTapGesture { + showSessionProCTAIfNeeded() + } + + Text("proMessageInfoFeatures".localized()) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textPrimary) + + VStack( + alignment: .leading, + spacing: Values.smallSpacing + ) { + ForEach(self.proFeatures, id: \.self) { feature in + HStack(spacing: Values.smallSpacing) { + AttributedText(Lucide.Icon.circleCheck.attributedString(size: 17)) + .font(.system(size: 17)) + .foregroundColor(themeColor: .primary) + + Text(feature) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textPrimary) + } + } + } + } + } + InfoBlock(title: "sent".localized()) { Text(messageViewModel.dateForUI.fromattedForMessageInfo) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } InfoBlock(title: "received".localized()) { Text(messageViewModel.receivedDateForUI.fromattedForMessageInfo) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .textPrimary) } @@ -272,7 +339,7 @@ struct MessageInfoScreen: View { let failureText: String = messageViewModel.mostRecentFailureText ?? "messageStatusFailedToSend".localized() InfoBlock(title: "theError".localized() + ":") { Text(failureText) - .font(.system(size: Values.mediumFontSize)) + .font(.Body.largeRegular) .foregroundColor(themeColor: .danger) } } @@ -281,28 +348,12 @@ struct MessageInfoScreen: View { HStack( spacing: 10 ) { - let (info, additionalInfo) = ProfilePictureView.getProfilePictureInfo( - size: .message, - publicKey: ( - // Prioritise the profile.id because we override it for - // messages sent by the current user in communities - messageViewModel.profile?.id ?? - messageViewModel.authorId - ), - threadVariant: .contact, // Always show the display picture in 'contact' mode - displayPictureUrl: nil, - profile: messageViewModel.profile, - profileIcon: (messageViewModel.isSenderModeratorOrAdmin ? .crown : .none), - using: dependencies - ) - let size: ProfilePictureView.Size = .list - - if let info: ProfilePictureView.Info = info { + if let info: ProfilePictureView.Info = self.profileInfo { ProfilePictureSwiftUI( size: size, info: info, - additionalInfo: additionalInfo, + additionalInfo: nil, dataManager: dependencies[singleton: .imageDataManager] ) .frame( @@ -316,20 +367,28 @@ struct MessageInfoScreen: View { alignment: .leading, spacing: Values.verySmallSpacing ) { - if isCurrentUser { - Text("you".localized()) - .bold() - .font(.system(size: Values.mediumLargeFontSize)) - .foregroundColor(themeColor: .textPrimary) - } - else if !messageViewModel.authorName.isEmpty { - Text(messageViewModel.authorName) - .bold() - .font(.system(size: Values.mediumLargeFontSize)) - .foregroundColor(themeColor: .textPrimary) + HStack(spacing: Values.verySmallSpacing) { + if isCurrentUser { + Text("you".localized()) + .font(.Body.extraLargeBold) + .foregroundColor(themeColor: .textPrimary) + } + else if !messageViewModel.authorName.isEmpty { + Text(messageViewModel.authorName) + .font(.Body.extraLargeBold) + .foregroundColor(themeColor: .textPrimary) + } + + if (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: messageViewModel.authorId)}) { + SessionProBadge_SwiftUI(size: .small) + .onTapGesture { + showSessionProCTAIfNeeded() + } + } } + Text(messageViewModel.authorId) - .font(.spaceMono(size: Values.smallFontSize)) + .font(.Display.base) .foregroundColor(themeColor: .textPrimary) } } @@ -419,6 +478,43 @@ struct MessageInfoScreen: View { .toastView(message: $feedbackMessage) } + private func getProFeaturesInfo() -> (proFeatures: [String], proCTAVariant: ProCTAModal.Variant) { + var proFeatures: [String] = [] + var proCTAVariant: ProCTAModal.Variant = .generic + + guard dependencies[feature: .sessionProEnabled] else { return (proFeatures, proCTAVariant) } + + if (dependencies.mutate(cache: .libSession) { $0.shouldShowProBadge(for: messageViewModel.profile) }) { + proFeatures.append("Session Pro Badge") // TODO: Localization + } + + if (messageViewModel.isProMessage || messageViewModel.body.defaulting(to: "").utf16.count > LibSession.CharacterLimit) { + proFeatures.append("proIncreasedMessageLengthFeature".localized()) + proCTAVariant = (proFeatures.count > 1 ? .generic : .longerMessages) + } + + if ImageDataManager.isAnimatedImage(profileInfo?.source?.imageData) { + proFeatures.append("proAnimatedDisplayPictureFeature".localized()) + proCTAVariant = (proFeatures.count > 1 ? .generic : .animatedProfileImage(isSessionProActivated: false)) + } + + return (proFeatures, proCTAVariant) + } + + private func showSessionProCTAIfNeeded() { + guard dependencies[feature: .sessionProEnabled] && (!dependencies[cache: .libSession].isSessionPro) else { + return + } + let sessionProModal: ModalHostingViewController = ModalHostingViewController( + modal: ProCTAModal( + delegate: dependencies[singleton: .sessionProState], + variant: proCTAVariant, + dataManager: dependencies[singleton: .imageDataManager], + ) + ) + self.host.controller?.present(sessionProModal, animated: true) + } + private func showMediaFullScreen(attachment: Attachment) { if let mediaGalleryView = MediaGalleryViewModel.createDetailViewController( for: messageViewModel.threadId, @@ -440,6 +536,7 @@ struct MessageInfoScreen: View { struct MessageBubble: View { @State private var maxWidth: CGFloat? + @State private var isExpanded: Bool = false static private let cornerRadius: CGFloat = 18 static private let inset: CGFloat = 12 @@ -457,6 +554,15 @@ struct MessageBubble: View { var body: some View { ZStack { let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: messageViewModel) - 2 * Self.inset) + let maxHeight: CGFloat = VisibleMessageCell.getMaxHeightAfterTruncation(for: messageViewModel) + let height: CGFloat = VisibleMessageCell.getBodyTappableLabel( + for: messageViewModel, + with: maxWidth, + textColor: bodyLabelTextColor, + searchText: nil, + delegate: nil, + using: dependencies + ).height VStack( alignment: .leading, @@ -520,7 +626,18 @@ struct MessageBubble: View { ) { AttributedText(bodyText) .foregroundColor(themeColor: bodyLabelTextColor) - .padding(.all, Self.inset) + .padding(.horizontal, Self.inset) + .frame( + maxHeight: (isExpanded ? .infinity : maxHeight) + ) + } + + if (maxHeight < height && !isExpanded) { + Text("messageBubbleReadMore".localized()) + .bold() + .font(.system(size: Values.smallFontSize)) + .foregroundColor(themeColor: bodyLabelTextColor) + .padding(.horizontal, Self.inset) } } else { @@ -547,6 +664,10 @@ struct MessageBubble: View { } } } + .padding(.vertical, Self.inset) + .onTapGesture { + self.isExpanded = true + } } } } @@ -563,8 +684,7 @@ struct InfoBlock: View where Content: View { spacing: Values.verySmallSpacing ) { Text(self.title) - .bold() - .font(.system(size: Values.mediumLargeFontSize)) + .font(.Body.extraLargeBold) .foregroundColor(themeColor: .textPrimary) self.content() } @@ -584,7 +704,7 @@ final class MessageInfoViewController: SessionHostingViewController Bool { + guard let profile = profile, dependencies[feature: .sessionProEnabled] else { return false } + return dependencies[feature: .allUsersSessionPro] || (profile.showProBadge == true) + } + func getCurrentUserProProof() -> String? { guard isSessionPro else { return nil diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index b6d6acbb89..b2c8fb7a0c 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -42,6 +42,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, case rawBody case expiresStartedAtMs case expiresInSeconds + case isProMessage case state case hasBeenReadByRecipient @@ -125,6 +126,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public let rawBody: String? public let expiresStartedAtMs: Double? public let expiresInSeconds: TimeInterval? + public let isProMessage: Bool public let state: Interaction.State public let hasBeenReadByRecipient: Bool @@ -235,6 +237,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, rawBody: self.rawBody, expiresStartedAtMs: self.expiresStartedAtMs, expiresInSeconds: self.expiresInSeconds, + isProMessage: self.isProMessage, state: (state ?? self.state), hasBeenReadByRecipient: self.hasBeenReadByRecipient, mostRecentFailureText: (mostRecentFailureText ?? self.mostRecentFailureText), @@ -296,6 +299,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, rawBody: self.body, expiresStartedAtMs: self.expiresStartedAtMs, expiresInSeconds: self.expiresInSeconds, + isProMessage: self.isProMessage, state: self.state, hasBeenReadByRecipient: self.hasBeenReadByRecipient, mostRecentFailureText: self.mostRecentFailureText, @@ -480,6 +484,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, rawBody: self.body, expiresStartedAtMs: self.expiresStartedAtMs, expiresInSeconds: self.expiresInSeconds, + isProMessage: self.isProMessage, state: self.state, hasBeenReadByRecipient: self.hasBeenReadByRecipient, mostRecentFailureText: self.mostRecentFailureText, @@ -731,6 +736,7 @@ public extension MessageViewModel { self.rawBody = nil self.expiresStartedAtMs = nil self.expiresInSeconds = nil + self.isProMessage = false self.state = .sent self.hasBeenReadByRecipient = false @@ -782,6 +788,7 @@ public extension MessageViewModel { body: String?, expiresStartedAtMs: Double?, expiresInSeconds: TimeInterval?, + isProMessage: Bool, state: Interaction.State = .sending, isSenderModeratorOrAdmin: Bool, currentUserProfile: Profile, @@ -815,6 +822,7 @@ public extension MessageViewModel { self.rawBody = body self.expiresStartedAtMs = expiresStartedAtMs self.expiresInSeconds = expiresInSeconds + self.isProMessage = isProMessage self.state = state self.hasBeenReadByRecipient = false @@ -936,7 +944,7 @@ public extension MessageViewModel { let linkPreview: TypedTableAlias = TypedTableAlias() let linkPreviewAttachment: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .linkPreviewAttachment) - let numColumnsBeforeLinkedRecords: Int = 24 + let numColumnsBeforeLinkedRecords: Int = 25 let finalGroupSQL: SQL = (groupSQL ?? "") let request: SQLRequest = """ SELECT @@ -962,6 +970,7 @@ public extension MessageViewModel { \(interaction[.body]), \(interaction[.expiresStartedAtMs]), \(interaction[.expiresInSeconds]), + \(interaction[.isProMessage]), \(interaction[.state]), (\(interaction[.recipientReadTimestampMs]) IS NOT NULL) AS \(ViewModel.Columns.hasBeenReadByRecipient), \(interaction[.mostRecentFailureText]), diff --git a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift index 6a0ddff983..541fc63587 100644 --- a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift +++ b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift @@ -42,7 +42,7 @@ public extension ProfilePictureView { additionalProfile: Profile? = nil, additionalProfileIcon: ProfileIcon = .none, using dependencies: Dependencies - ) -> (Info?, Info?) { + ) -> (info: Info?, additionalInfo: Info?) { let explicitPath: String? = try? dependencies[singleton: .displayPictureManager].path( for: displayPictureUrl ) diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index 88d7afe668..d216d7f804 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -12,7 +12,7 @@ public final class ProfilePictureView: UIView { case currentUser(SessionProManagerType) } - let source: ImageDataManager.DataSource? + public let source: ImageDataManager.DataSource? let animationBehaviour: AnimationBehaviour let renderingMode: UIImage.RenderingMode? let themeTintColor: ThemeValue? diff --git a/Session/Shared/Views/SRCopyableLabel.swift b/SessionUIKit/Components/SRCopyableLabel.swift similarity index 84% rename from Session/Shared/Views/SRCopyableLabel.swift rename to SessionUIKit/Components/SRCopyableLabel.swift index 7c9476ccc2..1b908b944a 100644 --- a/Session/Shared/Views/SRCopyableLabel.swift +++ b/SessionUIKit/Components/SRCopyableLabel.swift @@ -7,7 +7,7 @@ import UIKit -@objc class SRCopyableLabel : UILabel { +@objc public class SRCopyableLabel : UILabel { override public var canBecomeFirstResponder: Bool { return true } @@ -29,7 +29,7 @@ import UIKit )) } - override func copy(_ sender: Any?) { + public override func copy(_ sender: Any?) { UIPasteboard.general.string = text UIMenuController.shared.hideMenu(from: self) } @@ -42,7 +42,7 @@ import UIKit } } - override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { return (action == #selector(copy(_:))) } } diff --git a/SessionUIKit/Components/SessionLabelWithProBadge.swift b/SessionUIKit/Components/SessionLabelWithProBadge.swift index 0b42ce6371..7ffb7eab26 100644 --- a/SessionUIKit/Components/SessionLabelWithProBadge.swift +++ b/SessionUIKit/Components/SessionLabelWithProBadge.swift @@ -49,13 +49,21 @@ public class SessionLabelWithProBadge: UIView { set { sessionProBadge.isHidden = newValue } } + public override var isUserInteractionEnabled: Bool { + get { super.isUserInteractionEnabled } + set { + super.isUserInteractionEnabled = newValue + label.isUserInteractionEnabled = newValue + } + } + private let proBadgeSize: SessionProBadge.Size private let proBadgeThemeBackgroundColor: ThemeValue private let withStretchingSpacer: Bool // MARK: - UI Components - private let label: UILabel = UILabel() + private let label: SRCopyableLabel = SRCopyableLabel() private lazy var sessionProBadge: SessionProBadge = { let result: SessionProBadge = SessionProBadge(size: proBadgeSize, themeBackgroundColor: proBadgeThemeBackgroundColor) From c595c5490f222fe04aeaa439a17dfaa307c6e83c Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Wed, 20 Aug 2025 13:36:14 +1000 Subject: [PATCH 008/162] feat: contact list with pro badge --- .../Closed Groups/EditGroupViewModel.swift | 11 +++- .../MessageInfoScreen.swift | 10 ++-- Session/Meta/Translations/InfoPlist.xcstrings | 52 ++++++++++++++++++- .../Settings/BlockedContactsViewModel.swift | 15 ++++-- Session/Shared/UserListViewModel.swift | 16 +++++- .../Components/SessionLabelWithProBadge.swift | 3 +- SessionUIKit/Components/SessionProBadge.swift | 32 ++++++++---- 7 files changed, 118 insertions(+), 21 deletions(-) diff --git a/Session/Closed Groups/EditGroupViewModel.swift b/Session/Closed Groups/EditGroupViewModel.swift index 01916f533c..6ac2e3ffed 100644 --- a/Session/Closed Groups/EditGroupViewModel.swift +++ b/Session/Closed Groups/EditGroupViewModel.swift @@ -298,7 +298,16 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl font: .title, accessibility: Accessibility( identifier: "Contact" - ) + ), + extraViewGenerator: (dependencies.mutate(cache: .libSession) { $0.validateProProof(for: memberInfo.profile) }) ? + { + let result: UIView = UIView() + let probadge: SessionProBadge = SessionProBadge(size: .small) + result.addSubview(probadge) + probadge.pin(to: result, withInset: 4) + return result + } + : nil ), subtitle: (!isUpdatedGroup ? nil : SessionCell.TextInfo( memberInfo.value.statusDescription, diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 46b90d4bea..fcc98e5e70 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -300,9 +300,13 @@ struct MessageInfoScreen: View { showSessionProCTAIfNeeded() } - Text("proMessageInfoFeatures".localized()) - .font(.Body.largeRegular) - .foregroundColor(themeColor: .textPrimary) + Text( + "proMessageInfoFeatures" + .put(key: "app_pro", value: Constants.app_pro) + .localized() + ) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textPrimary) VStack( alignment: .leading, diff --git a/Session/Meta/Translations/InfoPlist.xcstrings b/Session/Meta/Translations/InfoPlist.xcstrings index 8199912edc..3d97c3284c 100644 --- a/Session/Meta/Translations/InfoPlist.xcstrings +++ b/Session/Meta/Translations/InfoPlist.xcstrings @@ -1507,7 +1507,7 @@ "az" : { "stringUnit" : { "state" : "translated", - "value" : "Session, səsli və görüntülü zənglər edə bilmək üçün daxili şəbəkəyə müraciət etməlidir." + "value" : "Session, səsli və görüntülü zənglər edə bilmək üçün lokal şəbəkəyə erişməlidir." } }, "ca" : { @@ -1546,6 +1546,18 @@ "value" : "Session bezonas aliron al loka reto por fari voĉajn kaj video vokojn." } }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session necesita acceso a la red local para realizar llamadas de voz y video." + } + }, + "es-ES" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session necesita acceso a la red local para realizar llamadas de voz y video." + } + }, "fr" : { "stringUnit" : { "state" : "translated", @@ -1564,6 +1576,18 @@ "value" : "A(z) Session alkalmazásnak hozzáférésre van szüksége a helyi hálózathoz a hang- és videohívások indításához." } }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session necessita dell'accesso alla rete locale per effettuare chiamate vocali e video." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session は音声・ビデオ通話を行うためにローカルネットワークへのアクセスが必要です。" + } + }, "ko" : { "stringUnit" : { "state" : "translated", @@ -1582,6 +1606,18 @@ "value" : "Session potrzebuje dostępu do sieci lokalnej, aby wykonywać połączenia głosowe i wideo." } }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session precisa de acesso à rede local para efetuar chamadas de voz e vídeo." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session are nevoie de acces la rețeaua locală pentru a efectua apeluri vocale și video." + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -1594,6 +1630,12 @@ "value" : "Session behöver åtkomst till det lokala nätverket för att kunna ringa röst och videosamtal." } }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session uygulamasının sesli ve görüntülü arama yapabilmesi için yerel ağa erişmesi gerekiyor." + } + }, "uk" : { "stringUnit" : { "state" : "translated", @@ -1611,6 +1653,12 @@ "state" : "translated", "value" : "Session需要访问本地网络才能进行语音和视频通话。" } + }, + "zh-TW" : { + "stringUnit" : { + "state" : "translated", + "value" : "Session 需要存取本地網路以進行語音與視訊通話。" + } } } }, @@ -2117,7 +2165,7 @@ "az" : { "stringUnit" : { "state" : "translated", - "value" : "Session qoşmaları və medianı saxlamaq üçün anbara müraciət etməlidir." + "value" : "Session qoşmaları və medianı saxlamaq üçün anbara erişməlidir." } }, "bal" : { diff --git a/Session/Settings/BlockedContactsViewModel.swift b/Session/Settings/BlockedContactsViewModel.swift index 48ef8d91d4..84019f4f60 100644 --- a/Session/Settings/BlockedContactsViewModel.swift +++ b/Session/Settings/BlockedContactsViewModel.swift @@ -297,9 +297,18 @@ public class BlockedContactsViewModel: SessionTableViewModel, NavigatableStateHo SessionCell.Info( id: model, leadingAccessory: .profile(id: model.id, profile: model.profile), - title: ( - model.profile?.displayName() ?? - model.id.truncated() + title: SessionCell.TextInfo( + (model.profile?.displayName() ?? model.id.truncated()), + font: .title, + extraViewGenerator: (viewModel.dependencies.mutate(cache: .libSession) { $0.validateProProof(for: model.profile) }) ? + { + let result: UIView = UIView() + let probadge: SessionProBadge = SessionProBadge(size: .small) + result.addSubview(probadge) + probadge.pin(to: result, withInset: 4) + return result + } + : nil ), trailingAccessory: .radio( isSelected: state.selectedIds.contains(model.id) diff --git a/Session/Shared/UserListViewModel.swift b/Session/Shared/UserListViewModel.swift index ad543a2ea0..8af904d433 100644 --- a/Session/Shared/UserListViewModel.swift +++ b/Session/Shared/UserListViewModel.swift @@ -149,8 +149,20 @@ class UserListViewModel: SessionTableVie profile: userInfo.profile, profileIcon: (showProfileIcons ? userInfo.value.profileIcon : .none) ), - title: title, - subtitle: userInfo.itemDescription(using: dependencies), + title: SessionCell.TextInfo( + title, + font: .title, + extraViewGenerator: (dependencies.mutate(cache: .libSession) { $0.validateProProof(for: userInfo.profile) }) ? + { + let result: UIView = UIView() + let probadge: SessionProBadge = SessionProBadge(size: .small) + result.addSubview(probadge) + probadge.pin(to: result, withInset: 4) + return result + } + : nil + ), + subtitle: SessionCell.TextInfo(userInfo.itemDescription(using: dependencies), font: .subtitle), trailingAccessory: trailingAccessory, styling: SessionCell.StyleInfo( subtitleTintColor: userInfo.itemDescriptionColor(using: dependencies), diff --git a/SessionUIKit/Components/SessionLabelWithProBadge.swift b/SessionUIKit/Components/SessionLabelWithProBadge.swift index 7ffb7eab26..22d4c45e9e 100644 --- a/SessionUIKit/Components/SessionLabelWithProBadge.swift +++ b/SessionUIKit/Components/SessionLabelWithProBadge.swift @@ -66,7 +66,8 @@ public class SessionLabelWithProBadge: UIView { private let label: SRCopyableLabel = SRCopyableLabel() private lazy var sessionProBadge: SessionProBadge = { - let result: SessionProBadge = SessionProBadge(size: proBadgeSize, themeBackgroundColor: proBadgeThemeBackgroundColor) + let result: SessionProBadge = SessionProBadge(size: proBadgeSize) + result.themeBackgroundColor = proBadgeThemeBackgroundColor result.isHidden = true return result diff --git a/SessionUIKit/Components/SessionProBadge.swift b/SessionUIKit/Components/SessionProBadge.swift index 9d09245fac..dad890f3e8 100644 --- a/SessionUIKit/Components/SessionProBadge.swift +++ b/SessionUIKit/Components/SessionProBadge.swift @@ -48,14 +48,22 @@ public class SessionProBadge: UIView { } } - private let size: Size + public var size: Size { + didSet { + widthConstraint.constant = size.width + heightConstraint.constant = size.height + proImageWidthConstraint.constant = size.proFontWidth + proImageHeightConstraint.constant = size.proFontHeight + self.layer.cornerRadius = size.cornerRadius + } + } // MARK: - Initialization - public init(size: Size, themeBackgroundColor: ThemeValue = .primary) { + public init(size: Size = .small) { self.size = size super.init(frame: .zero) - self.setupView(themeBackgroundColor) + setUpViewHierarchy() } public override init(frame: CGRect) { @@ -67,6 +75,7 @@ public class SessionProBadge: UIView { } // MARK: - UI + private lazy var proImageView: UIImageView = { let result: UIImageView = UIImageView(image: UIImage(named: "session_pro")) result.contentMode = .scaleAspectFit @@ -74,16 +83,21 @@ public class SessionProBadge: UIView { return result }() - private func setupView(_ themeBackgroundColor: ThemeValue) { + private var widthConstraint: NSLayoutConstraint! + private var heightConstraint: NSLayoutConstraint! + private var proImageWidthConstraint: NSLayoutConstraint! + private var proImageHeightConstraint: NSLayoutConstraint! + + private func setUpViewHierarchy() { self.addSubview(proImageView) - proImageView.set(.height, to: self.size.proFontHeight) - proImageView.set(.width, to: self.size.proFontWidth) + proImageWidthConstraint = proImageView.set(.height, to: self.size.proFontHeight) + proImageHeightConstraint = proImageView.set(.width, to: self.size.proFontWidth) proImageView.center(in: self) - self.themeBackgroundColor = themeBackgroundColor + self.themeBackgroundColor = .primary self.clipsToBounds = true self.layer.cornerRadius = self.size.cornerRadius - self.set(.width, to: self.size.width) - self.set(.height, to: self.size.height) + widthConstraint = self.set(.width, to: self.size.width) + heightConstraint = self.set(.height, to: self.size.height) } } From 4b5c1f0040c4fde6af56d2d2723f530bf7fb31f0 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Wed, 20 Aug 2025 17:22:53 +1000 Subject: [PATCH 009/162] wip: pro badges in settings screen --- Session.xcodeproj/project.pbxproj | 12 +-- Session/Settings/SettingsViewModel.swift | 84 ++++++++++++------- .../Shared/Types/SessionCell+Accessory.swift | 35 ++++++++ .../Shared/Types/SessionCell+Styling.swift | 3 + .../Views/SessionCell+AccessoryView.swift | 32 ++++++- Session/Shared/Views/SessionCell.swift | 1 + Session/Utilities/UIImage+Scaling.swift | 18 ---- Session/Utilities/UILabel+Interaction.swift | 16 ---- SessionUIKit/Components/SessionProBadge.swift | 18 ++-- .../Utilities/UILabel+Utilities.swift | 33 ++++++++ 10 files changed, 172 insertions(+), 80 deletions(-) delete mode 100644 Session/Utilities/UIImage+Scaling.swift delete mode 100644 Session/Utilities/UILabel+Interaction.swift create mode 100644 SessionUIKit/Utilities/UILabel+Utilities.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 8cc77c4c7b..4437ade2fa 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -177,6 +177,7 @@ 942BA9BF2E4ABBA1007C4595 /* _030_LastProfileUpdateTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9BE2E4ABB9F007C4595 /* _030_LastProfileUpdateTimestamp.swift */; }; 942BA9C12E4EA5CB007C4595 /* SessionLabelWithProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */; }; 942BA9C22E53F694007C4595 /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; + 942BA9C42E55AB54007C4595 /* UILabel+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9C32E55AB51007C4595 /* UILabel+Utilities.swift */; }; 943C6D822B75E061004ACE64 /* Message+DisappearingMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */; }; 945D9C582D6FDBE7003C4C0C /* _005_AddJobUniqueHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945D9C572D6FDBE7003C4C0C /* _005_AddJobUniqueHash.swift */; }; 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */ = {isa = PBXBuildFile; productRef = 946F5A722D5DA3AC00A5ADCE /* Punycode */; }; @@ -243,7 +244,6 @@ B835247925C38D880089A44F /* MessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B835247825C38D880089A44F /* MessageCell.swift */; }; B835249B25C3AB650089A44F /* VisibleMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B835249A25C3AB650089A44F /* VisibleMessageCell.swift */; }; B83524A525C3BA4B0089A44F /* InfoMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83524A425C3BA4B0089A44F /* InfoMessageCell.swift */; }; - B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */; }; B849789625D4A2F500D0D0B3 /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B849789525D4A2F500D0D0B3 /* LinkPreviewView.swift */; }; B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84A89BB25DE328A0040017D /* ProfilePictureVC.swift */; }; B85357BF23A1AE0800AAF6CD /* SeedReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85357BE23A1AE0800AAF6CD /* SeedReminderView.swift */; }; @@ -252,7 +252,6 @@ B86BD08623399CEF000F5AE3 /* SeedModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86BD08523399CEF000F5AE3 /* SeedModal.swift */; }; B877E24226CA12910007970A /* CallVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B877E24126CA12910007970A /* CallVC.swift */; }; B877E24626CA13BA0007970A /* CallVC+Camera.swift in Sources */ = {isa = PBXBuildFile; fileRef = B877E24526CA13BA0007970A /* CallVC+Camera.swift */; }; - B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */; }; B879D449247E1BE300DB3608 /* PathVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B879D448247E1BE300DB3608 /* PathVC.swift */; }; B8856CF7256F105E001CE70E /* OWSAudioPlayer.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */; }; @@ -1553,6 +1552,7 @@ 942BA9402E4487EE007C4595 /* LightBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightBox.swift; sourceTree = ""; }; 942BA9BE2E4ABB9F007C4595 /* _030_LastProfileUpdateTimestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _030_LastProfileUpdateTimestamp.swift; sourceTree = ""; }; 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionLabelWithProBadge.swift; sourceTree = ""; }; + 942BA9C32E55AB51007C4595 /* UILabel+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Utilities.swift"; sourceTree = ""; }; 94367C422C6C828500814252 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+DisappearingMessages.swift"; sourceTree = ""; }; 943C6D832B86B5F1004ACE64 /* Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localization.swift; sourceTree = ""; }; @@ -1622,7 +1622,6 @@ B835247825C38D880089A44F /* MessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCell.swift; sourceTree = ""; }; B835249A25C3AB650089A44F /* VisibleMessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibleMessageCell.swift; sourceTree = ""; }; B83524A425C3BA4B0089A44F /* InfoMessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoMessageCell.swift; sourceTree = ""; }; - B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Scaling.swift"; sourceTree = ""; }; B84664F4235022F30083A1CD /* MentionUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionUtilities.swift; sourceTree = ""; }; B849789525D4A2F500D0D0B3 /* LinkPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewView.swift; sourceTree = ""; }; B84A89BB25DE328A0040017D /* ProfilePictureVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePictureVC.swift; sourceTree = ""; }; @@ -1633,7 +1632,6 @@ B86BD08523399CEF000F5AE3 /* SeedModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeedModal.swift; sourceTree = ""; }; B877E24126CA12910007970A /* CallVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVC.swift; sourceTree = ""; }; B877E24526CA13BA0007970A /* CallVC+Camera.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CallVC+Camera.swift"; sourceTree = ""; }; - B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Interaction.swift"; sourceTree = ""; }; B879D448247E1BE300DB3608 /* PathVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathVC.swift; sourceTree = ""; }; B879D44A247E1D9200DB3608 /* PathStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathStatusView.swift; sourceTree = ""; }; B885D5F52334A32100EE0D8E /* UIView+Constraints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Constraints.swift"; sourceTree = ""; }; @@ -2717,8 +2715,6 @@ FDB3DA8C2E24881200148F8D /* ImageLoading+Convenience.swift */, FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */, FDB3DA832E1CA21C00148F8D /* UIActivityViewController+Utilities.swift */, - B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */, - B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */, FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */, FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */, C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */, @@ -3348,6 +3344,7 @@ FD71161F28D97ABC00B47552 /* UIImage+Utilities.swift */, B885D5F52334A32100EE0D8E /* UIView+Constraints.swift */, C33100272559000A00070591 /* UIView+Utilities.swift */, + 942BA9C32E55AB51007C4595 /* UILabel+Utilities.swift */, ); path = Utilities; sourceTree = ""; @@ -6107,6 +6104,7 @@ C331FFE32558FB0000070591 /* TabBar.swift in Sources */, FD37E9D528A1FCE8003AE748 /* Theme+OceanLight.swift in Sources */, FDF848F129406A30007DCAE5 /* Format.swift in Sources */, + 942BA9C42E55AB54007C4595 /* UILabel+Utilities.swift in Sources */, FD8A5B112DBF34BD004C689B /* Date+Utilities.swift in Sources */, FDB348632BE3774000B716C2 /* BezierPathView.swift in Sources */, FD8A5B292DC060E2004C689B /* Double+Utilities.swift in Sources */, @@ -6731,7 +6729,6 @@ FD12A8432AD63BF600EEBA0D /* ObservableTableSource.swift in Sources */, FD52090528B4915F006098F6 /* PrivacySettingsViewModel.swift in Sources */, 7BAF54D027ACCEEC003D12F8 /* EmptySearchResultCell.swift in Sources */, - B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */, FD37E9D928A230F2003AE748 /* TraitObservingWindow.swift in Sources */, B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */, FD848B8F283EF2A8000E298B /* UIScrollView+Utilities.swift in Sources */, @@ -6758,7 +6755,6 @@ C374EEF425DB31D40073A857 /* VoiceMessageRecordingView.swift in Sources */, 7B1581E6271FD2A100848B49 /* VideoPreviewVC.swift in Sources */, 9422568A2C23F8C800C0FDBF /* LoadingScreen.swift in Sources */, - B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */, 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */, 4C090A1B210FD9C7001FD7F9 /* HapticFeedback.swift in Sources */, FDE754FA2C9BB0B0002A2623 /* NotificationPresenter.swift in Sources */, diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 8f8ade916a..afee6010a6 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -42,6 +42,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl enum NavItem: Equatable { case close + case edit case qrCode } @@ -49,8 +50,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl case profileInfo case sessionId - case donationAndCommunity - case network + case sessionProAndCommunity + case donationAndnetwork case settings case helpAndData @@ -66,7 +67,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl var style: SessionTableSectionStyle { switch self { case .sessionId: return .titleSeparator - case .donationAndCommunity, .network, .settings, .helpAndData: return .padding + case .sessionProAndCommunity, .donationAndnetwork, .settings, .helpAndData: return .padding default: return .none } } @@ -79,9 +80,10 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl case sessionId case idActions - case donate + case sessionPro case inviteAFriend + case donate case path case sessionNetwork @@ -112,7 +114,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl lazy var rightNavItems: AnyPublisher<[SessionNavItem], Never> = [ SessionNavItem( id: .qrCode, - image: UIImage(named: "QRCode")? + image: Lucide.image(icon: .qrCode, size: 24)? .withRenderingMode(.alwaysTemplate), style: .plain, accessibilityIdentifier: "View QR code", @@ -123,6 +125,24 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl viewController.setNavBarTitle("qrCode".localized()) self?.transitionToScreen(viewController) } + ), + SessionNavItem( + id: .edit, + image: Lucide.image(icon: .pencil, size: 22)? + .withRenderingMode(.alwaysTemplate), + style: .plain, + accessibilityIdentifier: "Edit Profile Name", + action: { [weak self] in + Task { @MainActor [weak self] in + guard let self = self else { return } + self.transitionToScreen( + ConfirmationModal( + info: self.updateDisplayName(current: self.internalState.profile.displayName()) + ), + transitionType: .present + ) + } + } ) ] @@ -263,7 +283,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl switch (state.serviceNetwork, state.forceOffline) { case (.testnet, false): return .letter("T", false) // stringlint:ignore case (.testnet, true): return .letter("T", true) // stringlint:ignore - default: return .none + default: return .pencil } }() ), @@ -286,18 +306,17 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl state.profile.displayName(), font: .titleLarge, alignment: .center, - interaction: .editable - ), - trailingAccessory: .icon( - .pencil, - size: .small, - customTint: .textSecondary + interaction: .editable, + textTailingView: ( + viewModel.dependencies[cache: .libSession].isSessionPro ? + SessionProBadge(size: .medium) : + nil + ) ), styling: SessionCell.StyleInfo( alignment: .centerHugging, customPadding: SessionCell.Padding( top: Values.smallSpacing, - leading: IconSize.small.size, bottom: Values.mediumSpacing, interItem: 0 ), @@ -368,20 +387,19 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) ] ) - let donationAndCommunity: SectionModel = SectionModel( - model: .donationAndCommunity, + let sessionProAndCommunity: SectionModel = SectionModel( + model: .sessionProAndCommunity, elements: [ SessionCell.Info( - id: .donate, - leadingAccessory: .icon( - .heart, - customTint: .sessionButton_border - ), - title: "donate".localized(), + id: .sessionPro, + leadingAccessory: .proBadge(size: .small), + title: Constants.app_pro, styling: SessionCell.StyleInfo( tintColor: .sessionButton_border ), - onTap: { [weak viewModel] in viewModel?.openDonationsUrl() } + onTap: { [weak viewModel] in + // TODO: Implement + } ), SessionCell.Info( id: .inviteAFriend, @@ -405,9 +423,21 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) ] ) - let network: SectionModel = SectionModel( - model: .network, + let donationAndNetwork: SectionModel = SectionModel( + model: .donationAndnetwork, elements: [ + SessionCell.Info( + id: .donate, + leadingAccessory: .icon( + .heart, + customTint: .sessionButton_border + ), + title: "donate".localized(), + styling: SessionCell.StyleInfo( + tintColor: .sessionButton_border + ), + onTap: { [weak viewModel] in viewModel?.openDonationsUrl() } + ), SessionCell.Info( id: .path, leadingAccessory: .custom( @@ -425,9 +455,6 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl .withRenderingMode(.alwaysTemplate) ), title: Constants.network_name, - trailingAccessory: .custom( - info: NewTagView.Info() - ), onTap: { [weak viewModel, dependencies = viewModel.dependencies] in let viewController: SessionHostingViewController = SessionHostingViewController( rootView: SessionNetworkScreen( @@ -580,7 +607,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl elements: helpAndDataElements ) - return [profileInfo, sessionId, donationAndCommunity, network, settings, helpAndData] + return [profileInfo, sessionId, sessionProAndCommunity, donationAndNetwork, settings, helpAndData] } public lazy var footerView: AnyPublisher = Just(VersionFooterView( @@ -628,6 +655,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl self?.updatedName != current }, cancelStyle: .alert_text, + hasCloseButton: true, dismissOnConfirm: false, onConfirm: { [weak self] modal in guard diff --git a/Session/Shared/Types/SessionCell+Accessory.swift b/Session/Shared/Types/SessionCell+Accessory.swift index ed8d6b0a97..13d56c755b 100644 --- a/Session/Shared/Types/SessionCell+Accessory.swift +++ b/Session/Shared/Types/SessionCell+Accessory.swift @@ -35,6 +35,14 @@ public extension SessionCell { // MARK: - DSL public extension SessionCell.Accessory { + static func proBadge( + size: SessionProBadge.Size + ) -> SessionCell.Accessory { + return SessionCell.AccessoryConfig.ProBadge( + proBadgeSize: size + ) + } + static func icon( _ icon: Lucide.Icon, size: IconSize = .medium, @@ -214,6 +222,33 @@ public extension SessionCell.Accessory { // stringlint:ignore_contents public extension SessionCell.AccessoryConfig { + // MARK: - Pro Badge + + class ProBadge: SessionCell.Accessory { + override public var viewIdentifier: String { + "pro-badge" + } + + public let proBadgeSize: SessionProBadge.Size + + fileprivate init(proBadgeSize: SessionProBadge.Size) { + self.proBadgeSize = proBadgeSize + super.init(accessibility: Accessibility(identifier: "Session Pro Badge")) + } + + // MARK: - Conformance + + override public func hash(into hasher: inout Hasher) { + proBadgeSize.hash(into: &hasher) + } + + override fileprivate func isEqual(to other: SessionCell.Accessory) -> Bool { + guard let rhs: ProBadge = other as? ProBadge else { return false } + + return (proBadgeSize == rhs.proBadgeSize) + } + } + // MARK: - Icon class Icon: SessionCell.Accessory { diff --git a/Session/Shared/Types/SessionCell+Styling.swift b/Session/Shared/Types/SessionCell+Styling.swift index a83a09a0da..1d8657a564 100644 --- a/Session/Shared/Types/SessionCell+Styling.swift +++ b/Session/Shared/Types/SessionCell+Styling.swift @@ -20,6 +20,7 @@ public extension SessionCell { let editingPlaceholder: String? let interaction: Interaction let accessibility: Accessibility? + let textTailingView: UIView? let extraViewGenerator: (() -> UIView)? private let fontStyle: FontStyle @@ -32,6 +33,7 @@ public extension SessionCell { editingPlaceholder: String? = nil, interaction: Interaction = .none, accessibility: Accessibility? = nil, + textTailingView: UIView? = nil, extraViewGenerator: (() -> UIView)? = nil ) { self.text = text @@ -40,6 +42,7 @@ public extension SessionCell { self.editingPlaceholder = editingPlaceholder self.interaction = interaction self.accessibility = accessibility + self.textTailingView = textTailingView self.extraViewGenerator = extraViewGenerator } diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index 2cae2e3c31..110f7db3f8 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -73,7 +73,6 @@ extension SessionCell { if let newView: UIView = maybeView { addSubview(newView) - newView.pin(to: self) layout(view: newView, accessory: accessory) } @@ -156,6 +155,9 @@ extension SessionCell { using dependencies: Dependencies ) -> UIView? { switch accessory { + case is SessionCell.AccessoryConfig.ProBadge: + return SessionProBadge(size: .small) + case is SessionCell.AccessoryConfig.Icon: return createIconView(using: dependencies) @@ -191,6 +193,9 @@ extension SessionCell { private func layout(view: UIView?, accessory: Accessory) { switch accessory { + case let accessory as SessionCell.AccessoryConfig.ProBadge: + layoutProBadgeView(view, size: accessory.proBadgeSize) + case let accessory as SessionCell.AccessoryConfig.Icon: layoutIconView(view, iconSize: accessory.iconSize, shouldFill: accessory.shouldFill) @@ -231,6 +236,9 @@ extension SessionCell { using dependencies: Dependencies ) { switch accessory { + case let accessory as SessionCell.AccessoryConfig.ProBadge: + configureProBadgeView(view, tintColor: tintColor) + case let accessory as SessionCell.AccessoryConfig.Icon: configureIconView(view, accessory, tintColor: tintColor) @@ -274,6 +282,22 @@ extension SessionCell { } } + // MARK: -- Pro Badge + + private func layoutProBadgeView(_ view: UIView?, size: SessionProBadge.Size) { + guard let badgeView: SessionProBadge = view as? SessionProBadge else { return } + badgeView.size = size + badgeView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing) + badgeView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing) + badgeView.pin(.top, to: .top, of: self) + badgeView.pin(.bottom, to: .bottom, of: self) + } + + private func configureProBadgeView(_ view: UIView?, tintColor: ThemeValue) { + guard let badgeView: SessionProBadge = view as? SessionProBadge else { return } + badgeView.themeBackgroundColor = tintColor + } + // MARK: -- Icon private func createIconView(using dependencies: Dependencies) -> SessionImageView { @@ -295,6 +319,8 @@ extension SessionCell { imageView.set(.height, to: iconSize.size) imageView.pin(.leading, to: .leading, of: self, withInset: (shouldFill ? 0 : Values.smallSpacing)) imageView.pin(.trailing, to: .trailing, of: self, withInset: (shouldFill ? 0 : -Values.smallSpacing)) + imageView.pin(.top, to: .top, of: self) + imageView.pin(.bottom, to: .bottom, of: self) fixedWidthConstraint.isActive = (iconSize.size <= fixedWidthConstraint.constant) minWidthConstraint.isActive = !fixedWidthConstraint.isActive } @@ -573,6 +599,8 @@ extension SessionCell { radioBorderView.center(.vertical, in: self) radioBorderView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing) minWidthConstraint.isActive = true + + view.pin(to: self) } private func configureHighlightingBackgroundLabelAndRadioView( @@ -756,6 +784,8 @@ extension SessionCell { view.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing) view.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing) + view.pin(.top, to: .top, of: self) + view.pin(.bottom, to: .bottom, of: self) } private func configureCustomView(_ view: UIView?, _ accessory: SessionCell.AccessoryConfig.AnyCustom) { diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index 4bcc1408f3..dfc98654ec 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -564,6 +564,7 @@ public class SessionCell: UITableViewCell { titleLabel.accessibilityIdentifier = info.title?.accessibility?.identifier titleLabel.accessibilityLabel = info.title?.accessibility?.label titleLabel.isHidden = (info.title == nil) + titleLabel.attachTrailing(view: info.title?.textTailingView) titleTextField.text = info.title?.text titleTextField.textAlignment = (info.title?.textAlignment ?? .left) titleTextField.placeholder = info.title?.editingPlaceholder diff --git a/Session/Utilities/UIImage+Scaling.swift b/Session/Utilities/UIImage+Scaling.swift deleted file mode 100644 index bc6208071f..0000000000 --- a/Session/Utilities/UIImage+Scaling.swift +++ /dev/null @@ -1,18 +0,0 @@ -import UIKit - -extension UIImage { - - func scaled(to size: CGSize) -> UIImage { - var rect = CGRect.zero - let aspectRatio = min(size.width / self.size.width, size.height / self.size.height) - rect.size.width = self.size.width * aspectRatio - rect.size.height = self.size.height * aspectRatio - rect.origin.x = (size.width - rect.size.width) / 2 - rect.origin.y = (size.height - rect.size.height) / 2 - UIGraphicsBeginImageContextWithOptions(size, false, 0) - draw(in: rect) - let result = UIGraphicsGetImageFromCurrentImageContext()! - UIGraphicsEndImageContext() - return result - } -} diff --git a/Session/Utilities/UILabel+Interaction.swift b/Session/Utilities/UILabel+Interaction.swift deleted file mode 100644 index 4c7a5e92c5..0000000000 --- a/Session/Utilities/UILabel+Interaction.swift +++ /dev/null @@ -1,16 +0,0 @@ -import UIKit - -extension UILabel { - - func characterIndex(for point: CGPoint) -> Int { - let textStorage = NSTextStorage(attributedString: attributedText!) - let layoutManager = NSLayoutManager() - textStorage.addLayoutManager(layoutManager) - let textContainer = NSTextContainer(size: bounds.size) - textContainer.lineFragmentPadding = 0 - textContainer.maximumNumberOfLines = numberOfLines - textContainer.lineBreakMode = lineBreakMode - layoutManager.addTextContainer(textContainer) - return layoutManager.characterIndex(for: point, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil) - } -} diff --git a/SessionUIKit/Components/SessionProBadge.swift b/SessionUIKit/Components/SessionProBadge.swift index dad890f3e8..76d0a26919 100644 --- a/SessionUIKit/Components/SessionProBadge.swift +++ b/SessionUIKit/Components/SessionProBadge.swift @@ -6,7 +6,7 @@ public class SessionProBadge: UIView { public enum Size { case mini, small, medium, large - var width: CGFloat { + public var width: CGFloat { switch self { case .mini: return 24 case .small: return 32 @@ -14,7 +14,7 @@ public class SessionProBadge: UIView { case .large: return 52 } } - var height: CGFloat { + public var height: CGFloat { switch self { case .mini: return 11 case .small: return 14.5 @@ -22,7 +22,7 @@ public class SessionProBadge: UIView { case .large: return 26 } } - var cornerRadius: CGFloat { + public var cornerRadius: CGFloat { switch self { case .mini: return 2.5 case .small: return 3.5 @@ -30,7 +30,7 @@ public class SessionProBadge: UIView { case .large: return 6 } } - var proFontHeight: CGFloat { + public var proFontHeight: CGFloat { switch self { case .mini: return 5 case .small: return 6 @@ -38,7 +38,7 @@ public class SessionProBadge: UIView { case .large: return 11 } } - var proFontWidth: CGFloat { + public var proFontWidth: CGFloat { switch self { case .mini: return 17 case .small: return 24 @@ -60,9 +60,9 @@ public class SessionProBadge: UIView { // MARK: - Initialization - public init(size: Size = .small) { + public init(size: Size) { self.size = size - super.init(frame: .zero) + super.init(frame: CGRect(x: 0, y: 0, width: size.width, height: size.height)) setUpViewHierarchy() } @@ -90,8 +90,8 @@ public class SessionProBadge: UIView { private func setUpViewHierarchy() { self.addSubview(proImageView) - proImageWidthConstraint = proImageView.set(.height, to: self.size.proFontHeight) - proImageHeightConstraint = proImageView.set(.width, to: self.size.proFontWidth) + proImageHeightConstraint = proImageView.set(.height, to: self.size.proFontHeight) + proImageWidthConstraint = proImageView.set(.width, to: self.size.proFontWidth) proImageView.center(in: self) self.themeBackgroundColor = .primary diff --git a/SessionUIKit/Utilities/UILabel+Utilities.swift b/SessionUIKit/Utilities/UILabel+Utilities.swift new file mode 100644 index 0000000000..5de2ad4708 --- /dev/null +++ b/SessionUIKit/Utilities/UILabel+Utilities.swift @@ -0,0 +1,33 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public extension UILabel { + /// Appends a rendered snapshot of `view` as an inline image attachment. + func attachTrailing(view: UIView?, spacing: String = " ") { + guard let view = view, view.bounds.size != .zero else { return } + + let base = NSMutableAttributedString() + if let existing = attributedText, existing.length > 0 { + base.append(existing) + } else if let t = text { + base.append(NSAttributedString(string: t, attributes: [.font: font as Any, .foregroundColor: textColor as Any])) + } + + let img = view.toImage(isOpaque: view.isOpaque, scale: UIScreen.main.scale) + let attachment = NSTextAttachment() + attachment.image = img + + // Vertical alignment tweak to align to baseline + let cap = font?.capHeight ?? 0 + let dy = (cap - view.bounds.height) / 2 + attachment.bounds = CGRect(x: 0, y: dy, width: view.bounds.width, height: view.bounds.height) + + base.append(NSAttributedString(string: spacing)) + base.append(NSAttributedString(attachment: attachment)) + + attributedText = base + numberOfLines = 0 + lineBreakMode = .byWordWrapping + } +} From 6696c8435a1cb3c84482b5542f9b85a0095d7c79 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 21 Aug 2025 09:33:55 +1000 Subject: [PATCH 010/162] feat: pro badges in settings screen --- Session/Settings/SettingsViewModel.swift | 7 ++----- Session/Shared/Types/SessionCell+Styling.swift | 6 +++--- Session/Shared/Views/SessionCell.swift | 2 +- SessionUIKit/Components/SessionProBadge.swift | 10 ++++++++++ SessionUIKit/Utilities/UILabel+Utilities.swift | 11 +++++------ 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index afee6010a6..1278a90cc6 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -307,9 +307,9 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl font: .titleLarge, alignment: .center, interaction: .editable, - textTailingView: ( + textTailing: ( viewModel.dependencies[cache: .libSession].isSessionPro ? - SessionProBadge(size: .medium) : + SessionProBadge(size: .medium).toImage() : nil ) ), @@ -433,9 +433,6 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl customTint: .sessionButton_border ), title: "donate".localized(), - styling: SessionCell.StyleInfo( - tintColor: .sessionButton_border - ), onTap: { [weak viewModel] in viewModel?.openDonationsUrl() } ), SessionCell.Info( diff --git a/Session/Shared/Types/SessionCell+Styling.swift b/Session/Shared/Types/SessionCell+Styling.swift index 1d8657a564..f1a66e6abe 100644 --- a/Session/Shared/Types/SessionCell+Styling.swift +++ b/Session/Shared/Types/SessionCell+Styling.swift @@ -20,7 +20,7 @@ public extension SessionCell { let editingPlaceholder: String? let interaction: Interaction let accessibility: Accessibility? - let textTailingView: UIView? + let textTailing: UIImage? let extraViewGenerator: (() -> UIView)? private let fontStyle: FontStyle @@ -33,7 +33,7 @@ public extension SessionCell { editingPlaceholder: String? = nil, interaction: Interaction = .none, accessibility: Accessibility? = nil, - textTailingView: UIView? = nil, + textTailing: UIImage? = nil, extraViewGenerator: (() -> UIView)? = nil ) { self.text = text @@ -42,7 +42,7 @@ public extension SessionCell { self.editingPlaceholder = editingPlaceholder self.interaction = interaction self.accessibility = accessibility - self.textTailingView = textTailingView + self.textTailing = textTailing self.extraViewGenerator = extraViewGenerator } diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index dfc98654ec..52087f9f16 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -564,7 +564,7 @@ public class SessionCell: UITableViewCell { titleLabel.accessibilityIdentifier = info.title?.accessibility?.identifier titleLabel.accessibilityLabel = info.title?.accessibility?.label titleLabel.isHidden = (info.title == nil) - titleLabel.attachTrailing(view: info.title?.textTailingView) + titleLabel.attachTrailing(info.title?.textTailing) titleTextField.text = info.title?.text titleTextField.textAlignment = (info.title?.textAlignment ?? .left) titleTextField.placeholder = info.title?.editingPlaceholder diff --git a/SessionUIKit/Components/SessionProBadge.swift b/SessionUIKit/Components/SessionProBadge.swift index 76d0a26919..6eb2237b7e 100644 --- a/SessionUIKit/Components/SessionProBadge.swift +++ b/SessionUIKit/Components/SessionProBadge.swift @@ -100,4 +100,14 @@ public class SessionProBadge: UIView { widthConstraint = self.set(.width, to: self.size.width) heightConstraint = self.set(.height, to: self.size.height) } + + public func toImage() -> UIImage? { + self.proImageView.frame = CGRect( + x: (size.width - size.proFontWidth) / 2, + y: (size.height - size.proFontHeight) / 2, + width: size.proFontWidth, + height: size.proFontHeight + ) + return self.toImage(isOpaque: self.isOpaque, scale: UIScreen.main.scale) + } } diff --git a/SessionUIKit/Utilities/UILabel+Utilities.swift b/SessionUIKit/Utilities/UILabel+Utilities.swift index 5de2ad4708..dc96af22e5 100644 --- a/SessionUIKit/Utilities/UILabel+Utilities.swift +++ b/SessionUIKit/Utilities/UILabel+Utilities.swift @@ -4,8 +4,8 @@ import UIKit public extension UILabel { /// Appends a rendered snapshot of `view` as an inline image attachment. - func attachTrailing(view: UIView?, spacing: String = " ") { - guard let view = view, view.bounds.size != .zero else { return } + func attachTrailing(_ image: UIImage?, spacing: String = " ") { + guard let image = image, image.size != .zero else { return } let base = NSMutableAttributedString() if let existing = attributedText, existing.length > 0 { @@ -14,14 +14,13 @@ public extension UILabel { base.append(NSAttributedString(string: t, attributes: [.font: font as Any, .foregroundColor: textColor as Any])) } - let img = view.toImage(isOpaque: view.isOpaque, scale: UIScreen.main.scale) let attachment = NSTextAttachment() - attachment.image = img + attachment.image = image // Vertical alignment tweak to align to baseline let cap = font?.capHeight ?? 0 - let dy = (cap - view.bounds.height) / 2 - attachment.bounds = CGRect(x: 0, y: dy, width: view.bounds.width, height: view.bounds.height) + let dy = (cap - image.size.height) / 2 + attachment.bounds = CGRect(x: 0, y: dy, width: image.size.width, height: image.size.height) base.append(NSAttributedString(string: spacing)) base.append(NSAttributedString(attachment: attachment)) From f7a720bf6c76aa18c974850c6d2d0687ca0e58fa Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 21 Aug 2025 17:17:44 +1000 Subject: [PATCH 011/162] wip: conversation setting screen with pro badge --- .../Settings/ThreadSettingsViewModel.swift | 191 +++++++++++------- .../ic_crown.imageset/Contents.json | 12 ++ .../ic_crown.imageset/Union.pdf | Bin 0 -> 4473 bytes .../Shared/Types/SessionCell+Accessory.swift | 61 ++++++ .../Shared/Types/SessionCell+Styling.swift | 6 +- .../Views/SessionCell+AccessoryView.swift | 52 +++++ .../Components/ProfilePictureView.swift | 8 +- SessionUIKit/Components/Separator.swift | 6 +- 8 files changed, 256 insertions(+), 80 deletions(-) create mode 100644 Session/Meta/Images.xcassets/ic_crown.imageset/Contents.json create mode 100644 Session/Meta/Images.xcassets/ic_crown.imageset/Union.pdf diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 131ce7fee5..2e20b69a07 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -11,7 +11,7 @@ import SignalUtilitiesKit import SessionUtilitiesKit import SessionSnodeKit -class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource { +class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, ObservableTableSource { public let dependencies: Dependencies public let navigatableState: NavigatableState = NavigatableState() public let state: TableDataState = TableDataState() @@ -29,6 +29,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob self?.onDisplayPictureSelected?(.image(identifier: identifier, data: resultImageData)) } ) + // TODO: Refactor this with SessionThreadViewModel + private var threadViewModelSubject: CurrentValueSubject // MARK: - Initialization @@ -42,36 +44,35 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob self.threadId = threadId self.threadVariant = threadVariant self.didTriggerSearch = didTriggerSearch + self.threadViewModelSubject = CurrentValueSubject(nil) } // MARK: - Config - enum NavState { - case standard - case editing - } - enum NavItem: Equatable { case edit - case cancel - case done } public enum Section: SessionTableSection { case conversationInfo + case sessionId + case sessionIdNoteToSelf case content case adminActions case destructiveActions public var title: String? { switch self { - case .adminActions: return "adminSettings".localized() + case .sessionId: return "accountId".localized() + case .sessionIdNoteToSelf: return "accountIdYours".localized() + case .adminActions: return "adminSettings".localized() default: return nil } } public var style: SessionTableSectionStyle { switch self { + case .sessionId, .sessionIdNoteToSelf: return .titleSeparator case .destructiveActions: return .padding case .adminActions: return .titleRoundedContent default: return .none @@ -81,6 +82,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob public enum TableItem: Differentiable { case avatar + case qrCode case displayName case contactName case threadDescription @@ -109,6 +111,47 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob case debugDeleteAttachmentsBeforeNow } + lazy var rightNavItems: AnyPublisher<[SessionNavItem], Never> = threadViewModelSubject + .map { [weak self] threadViewModel -> [SessionNavItem] in + guard let threadViewModel: SessionThreadViewModel = threadViewModel else { return [] } + + let currentUserIsClosedGroupAdmin: Bool = ( + [.legacyGroup, .group].contains(threadViewModel.threadVariant) && + threadViewModel.currentUserIsClosedGroupAdmin == true + ) + + let canEditDisplayName: Bool = ( + threadViewModel.threadIsNoteToSelf != true && + ( + threadViewModel.threadVariant == .contact || + currentUserIsClosedGroupAdmin + ) + ) + + guard canEditDisplayName else { return [] } + + return [ + SessionNavItem( + id: .edit, + image: Lucide.image(icon: .pencil, size: 22)? + .withRenderingMode(.alwaysTemplate), + style: .plain, + accessibilityIdentifier: "Edit Nick Name", + action: { [weak self] in + guard + let info: ConfirmationModal.Info = self?.updateDisplayNameModal( + threadViewModel: threadViewModel, + currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin + ) + else { return } + + self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) + } + ) + ] + } + .eraseToAnyPublisher() + // MARK: - Content private struct State: Equatable { @@ -124,7 +167,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob } lazy var observation: TargetObservation = ObservationBuilderOld - .databaseObservation(self) { [dependencies, threadId = self.threadId] db -> State in + .databaseObservation(self) { [ weak self, dependencies, threadId = self.threadId] db -> State in let userSessionId: SessionId = dependencies[cache: .general].sessionId let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel .conversationSettingsQuery(threadId: threadId, userSessionId: userSessionId) @@ -133,6 +176,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob .fetchOne(db, id: threadId) .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) + self?.threadViewModelSubject.send(threadViewModel) + return State( threadViewModel: threadViewModel, disappearingMessagesConfig: disappearingMessagesConfig @@ -165,17 +210,13 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob isGroup && threadViewModel.currentUserIsClosedGroupAdmin == true ) - let canEditDisplayName: Bool = ( - threadViewModel.threadIsNoteToSelf != true && ( - threadViewModel.threadVariant == .contact || - currentUserIsClosedGroupAdmin - ) - ) let isThreadHidden: Bool = ( threadViewModel.threadShouldBeVisible != true && threadViewModel.threadPinnedPriority == LibSession.hiddenPriority ) + // MARK: - Conversation Info + let conversationInfoSection: SectionModel = SectionModel( model: .conversationInfo, elements: [ @@ -187,27 +228,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob threadVariant: threadViewModel.threadVariant, displayPictureUrl: threadViewModel.threadDisplayPictureUrl, profile: threadViewModel.profile, - profileIcon: { - guard - threadViewModel.threadVariant == .group && - currentUserIsClosedGroupAdmin && - dependencies[feature: .updatedGroupsAllowDisplayPicture] - else { return .none } - - // If we already have a display picture then the main profile gets the icon - return (threadViewModel.threadDisplayPictureUrl != nil ? .rightPlus : .none) - }(), additionalProfile: threadViewModel.additionalProfile, - additionalProfileIcon: { - guard - threadViewModel.threadVariant == .group && - currentUserIsClosedGroupAdmin && - dependencies[feature: .updatedGroupsAllowDisplayPicture] - else { return .none } - - // No display picture means the dual-profile so the additionalProfile gets the icon - return .rightPlus - }(), accessibility: nil ), styling: SessionCell.StyleInfo( @@ -227,27 +248,40 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob } ), + SessionCell.Info( + id: .qrCode, + accessory: .qrCode( + for: threadId, + hasBackground: false, + logo: "SessionWhite40", // stringlint:ignore + themeStyle: ThemeManager.currentTheme.interfaceStyle + ), + styling: SessionCell.StyleInfo( + alignment: .centerHugging, + customPadding: SessionCell.Padding(bottom: Values.smallSpacing), + backgroundStyle: .noBackground + ), + onTap: { [weak self] in + } + ), SessionCell.Info( id: .displayName, title: SessionCell.TextInfo( threadViewModel.displayName, font: .titleLarge, - alignment: .center - ), - trailingAccessory: (!canEditDisplayName ? nil : - .icon( - .pencil, - size: .small, - customTint: .textSecondary + alignment: .center, + textTailing: ( + (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }) ? + SessionProBadge(size: .medium).toImage() : + nil ) ), styling: SessionCell.StyleInfo( alignment: .centerHugging, customPadding: SessionCell.Padding( top: Values.smallSpacing, - leading: (!canEditDisplayName ? nil : IconSize.small.size), bottom: { - guard threadViewModel.threadVariant != .contact else { return Values.smallSpacing } + guard threadViewModel.threadVariant != .contact else { return Values.mediumSpacing } guard threadViewModel.threadDescription == nil else { return Values.smallSpacing } return Values.largeSpacing @@ -311,36 +345,49 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob accessibility: Accessibility( identifier: "Description", label: threadDescription - ) - ) - }, - - (threadViewModel.threadVariant != .contact ? nil : - SessionCell.Info( - id: .sessionId, - subtitle: SessionCell.TextInfo( - threadViewModel.id, - font: .monoSmall, - alignment: .center, - interaction: .copy - ), - styling: SessionCell.StyleInfo( - customPadding: SessionCell.Padding( - top: Values.smallSpacing, - bottom: Values.largeSpacing - ), - backgroundStyle: .noBackground ), - accessibility: Accessibility( - identifier: "Session ID", - label: threadViewModel.id - ) + onTap: { [weak self] in + guard + let info: ConfirmationModal.Info = self?.updateDisplayNameModal( + threadViewModel: threadViewModel, + currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin + ) + else { return } + + self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) + } ) - ) + } ].compactMap { $0 } ) + // MARK: - Session Id + + let sessionIdSection: SectionModel = SectionModel( + model: (threadViewModel.threadIsNoteToSelf == true ? .sessionIdNoteToSelf : .sessionId), + elements: [ + SessionCell.Info( + id: .sessionId, + subtitle: SessionCell.TextInfo( + threadViewModel.id, + font: .monoLarge, + alignment: .center, + interaction: .copy + ), + styling: SessionCell.StyleInfo( + customPadding: SessionCell.Padding(bottom: Values.smallSpacing), + backgroundStyle: .noBackground + ), + accessibility: Accessibility( + identifier: "Session ID", + label: threadViewModel.id + ) + ) + ] + ) + // MARK: - Users kicked from groups + guard !currentUserKickedFromGroup else { return [ conversationInfoSection, @@ -387,6 +434,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob } // MARK: - Standard Actions + let standardActionsSection: SectionModel = SectionModel( model: .content, elements: [ @@ -597,7 +645,9 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob ) ].compactMap { $0 } ) + // MARK: - Admin Actions + let adminActionsSection: SectionModel? = ( !currentUserIsClosedGroupAdmin ? nil : SectionModel( @@ -677,7 +727,9 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob ].compactMap { $0 } ) ) + // MARK: - Destructive Actions + let destructiveActionsSection: SectionModel = SectionModel( model: .destructiveActions, elements: [ @@ -1111,6 +1163,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob return [ conversationInfoSection, + (threadViewModel.threadVariant != .contact ? nil : sessionIdSection), standardActionsSection, adminActionsSection, destructiveActionsSection diff --git a/Session/Meta/Images.xcassets/ic_crown.imageset/Contents.json b/Session/Meta/Images.xcassets/ic_crown.imageset/Contents.json new file mode 100644 index 0000000000..4171612590 --- /dev/null +++ b/Session/Meta/Images.xcassets/ic_crown.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Union.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/ic_crown.imageset/Union.pdf b/Session/Meta/Images.xcassets/ic_crown.imageset/Union.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e57702e74d3d2e63e8595161ae07cf9c8b9cd113 GIT binary patch literal 4473 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 Id5t^&2fw`DYybcN literal 0 HcmV?d00001 diff --git a/Session/Shared/Types/SessionCell+Accessory.swift b/Session/Shared/Types/SessionCell+Accessory.swift index 13d56c755b..40fa4c6f9e 100644 --- a/Session/Shared/Types/SessionCell+Accessory.swift +++ b/Session/Shared/Types/SessionCell+Accessory.swift @@ -35,6 +35,20 @@ public extension SessionCell { // MARK: - DSL public extension SessionCell.Accessory { + static func qrCode( + for string: String, + hasBackground: Bool, + logo: String? = nil, + themeStyle: UIUserInterfaceStyle + ) -> SessionCell.Accessory { + return SessionCell.AccessoryConfig.QRCode( + for: string, + hasBackground: hasBackground, + logo: logo, + themeStyle: themeStyle + ) + } + static func proBadge( size: SessionProBadge.Size ) -> SessionCell.Accessory { @@ -222,6 +236,53 @@ public extension SessionCell.Accessory { // stringlint:ignore_contents public extension SessionCell.AccessoryConfig { + // MARK: - QRCode + + class QRCode: SessionCell.Accessory { + override public var viewIdentifier: String { + "qr-code" + } + + public let string: String + public let hasBackground: Bool + public let logo: String? + public let themeStyle: UIUserInterfaceStyle + + fileprivate init( + for string: String, + hasBackground: Bool, + logo: String? = nil, + themeStyle: UIUserInterfaceStyle + ) { + self.string = string + self.hasBackground = hasBackground + self.logo = logo + self.themeStyle = themeStyle + + super.init(accessibility: Accessibility(identifier: "Session QRCode")) + } + + // MARK: - Conformance + + override public func hash(into hasher: inout Hasher) { + string.hash(into: &hasher) + hasBackground.hash(into: &hasher) + logo?.hash(into: &hasher) + themeStyle.hash(into: &hasher) + } + + override fileprivate func isEqual(to other: SessionCell.Accessory) -> Bool { + guard let rhs: QRCode = other as? QRCode else { return false } + + return ( + string == rhs.string && + hasBackground == rhs.hasBackground && + logo == rhs.logo && + themeStyle == rhs.themeStyle + ) + } + } + // MARK: - Pro Badge class ProBadge: SessionCell.Accessory { diff --git a/Session/Shared/Types/SessionCell+Styling.swift b/Session/Shared/Types/SessionCell+Styling.swift index f1a66e6abe..55c7f56d8d 100644 --- a/Session/Shared/Types/SessionCell+Styling.swift +++ b/Session/Shared/Types/SessionCell+Styling.swift @@ -115,16 +115,14 @@ public extension SessionCell { var font: UIFont { switch self { case .title: return .boldSystemFont(ofSize: 16) - case .titleLarge: return .systemFont(ofSize: Values.veryLargeFontSize, weight: .medium) + case .titleLarge: return Fonts.Headings.H4 case .titleRegular: return .systemFont(ofSize: 16) case .subtitle: return .systemFont(ofSize: 14) case .subtitleBold: return .boldSystemFont(ofSize: 14) case .monoSmall: return Fonts.spaceMono(ofSize: Values.smallFontSize) - case .monoLarge: return Fonts.spaceMono( - ofSize: (isIPhone5OrSmaller ? Values.mediumFontSize : Values.largeFontSize) - ) + case .monoLarge: return Fonts.Display.extraLarge } } } diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index 110f7db3f8..7237dd5a3d 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -155,6 +155,9 @@ extension SessionCell { using dependencies: Dependencies ) -> UIView? { switch accessory { + case is SessionCell.AccessoryConfig.QRCode: + return createQRCodeView() + case is SessionCell.AccessoryConfig.ProBadge: return SessionProBadge(size: .small) @@ -193,6 +196,9 @@ extension SessionCell { private func layout(view: UIView?, accessory: Accessory) { switch accessory { + case let accessory as SessionCell.AccessoryConfig.QRCode: + layoutQRCodeView(view) + case let accessory as SessionCell.AccessoryConfig.ProBadge: layoutProBadgeView(view, size: accessory.proBadgeSize) @@ -236,6 +242,9 @@ extension SessionCell { using dependencies: Dependencies ) { switch accessory { + case let accessory as SessionCell.AccessoryConfig.QRCode: + configureQRCodeView(view, accessory) + case let accessory as SessionCell.AccessoryConfig.ProBadge: configureProBadgeView(view, tintColor: tintColor) @@ -282,6 +291,49 @@ extension SessionCell { } } + // MARK: -- QRCode + + private func createQRCodeView() -> UIView { + let result: UIView = UIView() + result.layer.cornerRadius = 10 + result.layer.masksToBounds = true + + let qrCodeImageView: UIImageView = UIImageView() + qrCodeImageView.contentMode = .scaleAspectFit + + result.addSubview(qrCodeImageView) + qrCodeImageView.pin(to: result, withInset: Values.smallSpacing) + result.set(.width, to: 190) + result.set(.height, to: 190) + + return result + } + + private func layoutQRCodeView(_ view: UIView?) { + guard let view: UIView = view else { return } + + view.pin(to: self) + fixedWidthConstraint.constant = 190 + fixedWidthConstraint.isActive = true + } + + private func configureQRCodeView(_ view: UIView?, _ accessory: SessionCell.AccessoryConfig.QRCode) { + guard + let backgroundView: UIView = view, + let qrCodeImageView: UIImageView = view?.subviews.first as? UIImageView + else { return } + + let backgroundThemeColor: ThemeValue = (accessory.themeStyle == .light ? .backgroundSecondary : .textPrimary) + let qrCodeThemeColor: ThemeValue = (accessory.themeStyle == .light ? .textPrimary : .backgroundPrimary) + let qrCodeImage: UIImage = QRCode + .generate(for: accessory.string, hasBackground: accessory.hasBackground, iconName: accessory.logo) + .withRenderingMode(.alwaysTemplate) + + qrCodeImageView.image = qrCodeImage + qrCodeImageView.themeTintColor = qrCodeThemeColor + backgroundView.themeBackgroundColor = backgroundThemeColor + } + // MARK: -- Pro Badge private func layoutProBadgeView(_ view: UIView?, size: SessionProBadge.Size) { diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index d216d7f804..172b33e336 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -62,7 +62,7 @@ public final class ProfilePictureView: UIView { switch self { case .navigation, .message: return 26 case .list: return 46 - case .hero: return 80 + case .hero: return 90 case .modal: return 90 } } @@ -106,8 +106,8 @@ public final class ProfilePictureView: UIView { var isLeadingAligned: Bool { switch self { - case .none, .crown, .letter: return true - case .rightPlus, .pencil: return false + case .none, .letter: return true + case .rightPlus, .pencil, .crown: return false } } } @@ -418,7 +418,7 @@ public final class ProfilePictureView: UIView { label.isHidden = true case .crown: - imageView.image = UIImage(systemName: "crown.fill") + imageView.image = UIImage(named: "ic_crown")?.withRenderingMode(.alwaysTemplate) imageView.contentMode = .scaleAspectFit imageView.themeTintColor = .dynamicForPrimary( .green, diff --git a/SessionUIKit/Components/Separator.swift b/SessionUIKit/Components/Separator.swift index d35add8fda..bbf1da9f0d 100644 --- a/SessionUIKit/Components/Separator.swift +++ b/SessionUIKit/Components/Separator.swift @@ -25,7 +25,7 @@ public final class Separator: UIView { private lazy var titleLabel: UILabel = { let result = UILabel() - result.font = .systemFont(ofSize: Values.smallFontSize) + result.font = Fonts.Body.baseRegular result.themeTextColor = .textSecondary result.textAlignment = .center @@ -72,8 +72,8 @@ public final class Separator: UIView { titleLabel.center(.vertical, in: self) roundedLine.pin(.top, to: .top, of: self) roundedLine.pin(.top, to: .top, of: titleLabel, withInset: -6) - roundedLine.pin(.leading, to: .leading, of: titleLabel, withInset: -10) - roundedLine.pin(.trailing, to: .trailing, of: titleLabel, withInset: 10) + roundedLine.pin(.leading, to: .leading, of: titleLabel, withInset: -30) + roundedLine.pin(.trailing, to: .trailing, of: titleLabel, withInset: 30) roundedLine.pin(.bottom, to: .bottom, of: titleLabel, withInset: 6) roundedLine.pin(.bottom, to: .bottom, of: self) leftLine.pin(.leading, to: .leading, of: self) From 69cdf8f1b6638a825883a09d3c803c2977da33ba Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 22 Aug 2025 15:59:33 +1000 Subject: [PATCH 012/162] feat: profile picture and qrcode in ucs --- .../Settings/ThreadSettingsViewModel.swift | 132 ++++++++++-------- .../Shared/SessionTableViewController.swift | 5 +- Session/Shared/Types/SessionCell+Info.swift | 5 +- .../Views/SessionCell+AccessoryView.swift | 28 +++- .../Config Handling/LibSession+Pro.swift | 12 +- .../ProfilePictureView+Convenience.swift | 2 +- .../Components/ProfilePictureView.swift | 40 +++++- .../Components/SwiftUI/UserProfileModel.swift | 2 +- 8 files changed, 153 insertions(+), 73 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 2e20b69a07..46ae5e0249 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -29,6 +29,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi self?.onDisplayPictureSelected?(.image(identifier: identifier, data: resultImageData)) } ) + private var previousProfileImageStatus: ProfileImageStatus? + private var currentProfileImageStatus: ProfileImageStatus? // TODO: Refactor this with SessionThreadViewModel private var threadViewModelSubject: CurrentValueSubject @@ -45,9 +47,16 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi self.threadVariant = threadVariant self.didTriggerSearch = didTriggerSearch self.threadViewModelSubject = CurrentValueSubject(nil) + self.previousProfileImageStatus = nil + self.currentProfileImageStatus = .normal } // MARK: - Config + enum ProfileImageStatus: Equatable { + case normal + case expanded + case qrCode + } enum NavItem: Equatable { case edit @@ -183,9 +192,15 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi disappearingMessagesConfig: disappearingMessagesConfig ) } - .compactMap { [weak self] current -> [SectionModel]? in self?.content(current) } + .compactMap { [weak self] current -> [SectionModel]? in + self?.content( + current, + currentProfileImageStatus: self?.currentProfileImageStatus, + previousProfileImageStatus: self?.previousProfileImageStatus + ) + } - private func content(_ current: State) -> [SectionModel] { + private func content(_ current: State, currentProfileImageStatus: ProfileImageStatus?, previousProfileImageStatus: ProfileImageStatus?) -> [SectionModel] { // If we don't get a `SessionThreadViewModel` then it means the thread was probably deleted // so dismiss the screen guard let threadViewModel: SessionThreadViewModel = current.threadViewModel else { @@ -220,49 +235,62 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let conversationInfoSection: SectionModel = SectionModel( model: .conversationInfo, elements: [ - SessionCell.Info( - id: .avatar, - accessory: .profile( - id: threadViewModel.id, - size: .hero, - threadVariant: threadViewModel.threadVariant, - displayPictureUrl: threadViewModel.threadDisplayPictureUrl, - profile: threadViewModel.profile, - additionalProfile: threadViewModel.additionalProfile, - accessibility: nil - ), - styling: SessionCell.StyleInfo( - alignment: .centerHugging, - customPadding: SessionCell.Padding(bottom: Values.smallSpacing), - backgroundStyle: .noBackground - ), - onTap: { [weak self] in - switch (threadViewModel.threadVariant, threadViewModel.threadDisplayPictureUrl, currentUserIsClosedGroupAdmin) { - case (.contact, _, _): self?.viewDisplayPicture(threadViewModel: threadViewModel) - case (.group, _, true): - self?.updateGroupDisplayPicture(currentUrl: threadViewModel.threadDisplayPictureUrl) + (currentProfileImageStatus == .qrCode ? + SessionCell.Info( + id: .qrCode, + accessory: .qrCode( + for: threadId, + hasBackground: false, + logo: "SessionWhite40", // stringlint:ignore + themeStyle: ThemeManager.currentTheme.interfaceStyle + ), + styling: SessionCell.StyleInfo( + alignment: .centerHugging, + customPadding: SessionCell.Padding(bottom: Values.smallSpacing), + backgroundStyle: .noBackground + ), + onTap: { [weak self] in + self?.currentProfileImageStatus = previousProfileImageStatus + self?.previousProfileImageStatus = currentProfileImageStatus + self?.forceRefresh(type: .postDatabaseQuery) + } + ) : + SessionCell.Info( + id: .avatar, + accessory: .profile( + id: threadViewModel.id, + size: (currentProfileImageStatus == .expanded ? .expanded : .hero), + threadVariant: threadViewModel.threadVariant, + displayPictureUrl: threadViewModel.threadDisplayPictureUrl, + profile: threadViewModel.profile, + profileIcon: ((threadViewModel.threadIsNoteToSelf || threadVariant == .group) ? .none : .qrCode), + additionalProfile: threadViewModel.additionalProfile, + accessibility: nil + ), + styling: SessionCell.StyleInfo( + alignment: .centerHugging, + customPadding: SessionCell.Padding(bottom: Values.smallSpacing), + backgroundStyle: .noBackground + ), + onTapView: { [weak self] targetView in + let didTapQRCodeIcon: Bool = !(targetView is ProfilePictureView) - case (_, .some, _): self?.viewDisplayPicture(threadViewModel: threadViewModel) - default: break + switch (threadViewModel.threadVariant, currentUserIsClosedGroupAdmin, didTapQRCodeIcon) { + case (.group, true, _): + self?.updateGroupDisplayPicture(currentUrl: threadViewModel.threadDisplayPictureUrl) + case (.group, _, _): + break + case (_, _, true): + self?.currentProfileImageStatus = .qrCode + self?.previousProfileImageStatus = currentProfileImageStatus + self?.forceRefresh(type: .postDatabaseQuery) + case (_, _, false): + self?.currentProfileImageStatus = (currentProfileImageStatus == .expanded ? .normal : .expanded) + self?.previousProfileImageStatus = currentProfileImageStatus + self?.forceRefresh(type: .postDatabaseQuery) + } } - - } - ), - SessionCell.Info( - id: .qrCode, - accessory: .qrCode( - for: threadId, - hasBackground: false, - logo: "SessionWhite40", // stringlint:ignore - themeStyle: ThemeManager.currentTheme.interfaceStyle - ), - styling: SessionCell.StyleInfo( - alignment: .centerHugging, - customPadding: SessionCell.Padding(bottom: Values.smallSpacing), - backgroundStyle: .noBackground - ), - onTap: { [weak self] in - } + ) ), SessionCell.Info( id: .displayName, @@ -1172,24 +1200,6 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi // MARK: - Functions - private func viewDisplayPicture(threadViewModel: SessionThreadViewModel) { - guard - let fileUrl: String = threadViewModel.threadDisplayPictureUrl, - let path: String = try? dependencies[singleton: .displayPictureManager].path(for: fileUrl) - else { return } - - let navController: UINavigationController = StyledNavigationController( - rootViewController: ProfilePictureVC( - imageSource: .url(URL(fileURLWithPath: path)), - title: threadViewModel.displayName, - using: dependencies - ) - ) - navController.modalPresentationStyle = .fullScreen - - self.transitionToScreen(navController, transitionType: .present) - } - private func inviteUsersToCommunity(threadViewModel: SessionThreadViewModel) { guard let name: String = threadViewModel.openGroupName, diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index 439421c5e3..e261a051f6 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -632,7 +632,10 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa return cell.trailingAccessoryView.touchedView(touchLocation) - case (is SessionCell.AccessoryConfig.HighlightingBackgroundLabelAndRadio, _): + case + (is SessionCell.AccessoryConfig.HighlightingBackgroundLabelAndRadio, _), + (is SessionCell.AccessoryConfig.DisplayPicture, _), + (is SessionCell.AccessoryConfig.QRCode, _): guard let touchLocation: UITouch = touchLocation, !cell.leadingAccessoryView.isHidden diff --git a/Session/Shared/Types/SessionCell+Info.swift b/Session/Shared/Types/SessionCell+Info.swift index cd32ff0e8a..0d499b8752 100644 --- a/Session/Shared/Types/SessionCell+Info.swift +++ b/Session/Shared/Types/SessionCell+Info.swift @@ -126,7 +126,8 @@ public extension SessionCell.Info { isEnabled: Bool = true, accessibility: Accessibility? = nil, confirmationInfo: ConfirmationModal.Info? = nil, - onTap: (@MainActor () -> Void)? = nil + onTap: (@MainActor () -> Void)? = nil, + onTapView: (@MainActor (UIView?) -> Void)? = nil ) { self.id = id self.position = position @@ -140,7 +141,7 @@ public extension SessionCell.Info { self.accessibility = accessibility self.confirmationInfo = confirmationInfo self.onTap = onTap - self.onTapView = nil + self.onTapView = onTapView } // leadingAccessory, trailingAccessory diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index 7237dd5a3d..b3496ccc45 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -98,6 +98,10 @@ extension SessionCell { let localPoint: CGPoint = touch.location(in: label) return (label.bounds.contains(localPoint) ? label : self) + case (let profilePictureView as ProfilePictureView, _): + let localPoint: CGPoint = touch.location(in: profilePictureView) + + return profilePictureView.getTouchedView(from: localPoint) default: return self } @@ -296,7 +300,6 @@ extension SessionCell { private func createQRCodeView() -> UIView { let result: UIView = UIView() result.layer.cornerRadius = 10 - result.layer.masksToBounds = true let qrCodeImageView: UIImageView = UIImageView() qrCodeImageView.contentMode = .scaleAspectFit @@ -306,6 +309,29 @@ extension SessionCell { result.set(.width, to: 190) result.set(.height, to: 190) + let iconImageView: UIImageView = UIImageView( + image: UIImage(named: "ic_user_round_fill")? + .withRenderingMode(.alwaysTemplate) + ) + iconImageView.contentMode = .scaleAspectFit + iconImageView.set(.width, to: 18) + iconImageView.set(.height, to: 18) + iconImageView.themeTintColor = .black + + let iconBackgroudView: UIView = UIView() + iconBackgroudView.themeBackgroundColor = .primary + iconBackgroudView.set(.width, to: 33) + iconBackgroudView.set(.height, to: 33) + iconBackgroudView.layer.cornerRadius = 16.5 + iconBackgroudView.layer.masksToBounds = true + + iconBackgroudView.addSubview(iconImageView) + iconImageView.center(in: iconBackgroudView) + + result.addSubview(iconBackgroudView) + iconBackgroudView.pin(.top, to: .top, of: result, withInset: -10) + iconBackgroudView.pin(.trailing, to: .trailing, of: result, withInset: 17) + return result } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift index 663a2b94eb..b550cc78e6 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift @@ -36,8 +36,16 @@ public extension LibSessionCacheType { return dependencies[feature: .allUsersSessionPro] } - func validateSessionProState(for sessionId: String?) -> Bool { - guard let sessionId = sessionId, dependencies[feature: .sessionProEnabled] else { return false } + func validateSessionProState(for threadId: String?) -> Bool { + guard let threadId = threadId, dependencies[feature: .sessionProEnabled] else { return false } + let threadVariant = dependencies[singleton: .storage].read { db in + try SessionThread + .select(SessionThread.Columns.variant) + .filter(id: threadId) + .asRequest(of: SessionThread.Variant.self) + .fetchOne(db) + } + guard threadVariant != .community else { return false } return dependencies[feature: .allUsersSessionPro] } diff --git a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift index 541fc63587..3ef0404589 100644 --- a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift +++ b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift @@ -76,7 +76,7 @@ public extension ProfilePictureView { switch size { case .navigation, .message: return .image("SessionWhite16", #imageLiteral(resourceName: "SessionWhite16")) case .list: return .image("SessionWhite24", #imageLiteral(resourceName: "SessionWhite24")) - case .hero, .modal: return .image("SessionWhite40", #imageLiteral(resourceName: "SessionWhite40")) + case .hero, .modal, .expanded: return .image("SessionWhite40", #imageLiteral(resourceName: "SessionWhite40")) } }(), animationBehaviour: .generic(true), diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index 172b33e336..05d39cf87a 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -48,6 +48,7 @@ public final class ProfilePictureView: UIView { case list case hero case modal + case expanded public var viewSize: CGFloat { switch self { @@ -55,6 +56,7 @@ public final class ProfilePictureView: UIView { case .list: return 46 case .hero: return 110 case .modal: return 90 + case .expanded: return 190 } } @@ -64,15 +66,15 @@ public final class ProfilePictureView: UIView { case .list: return 46 case .hero: return 90 case .modal: return 90 + case .expanded: return 190 } } public var multiImageSize: CGFloat { switch self { - case .navigation, .message: return 18 // Shouldn't be used + case .navigation, .message, .modal, .expanded: return 18 // Shouldn't be used case .list: return 32 case .hero: return 80 - case .modal: return 90 } } @@ -82,6 +84,7 @@ public final class ProfilePictureView: UIView { case .list: return 16 case .hero: return 24 case .modal: return 24 // Shouldn't be used + case .expanded: return 33 } } } @@ -92,6 +95,7 @@ public final class ProfilePictureView: UIView { case rightPlus case letter(Character, Bool) case pencil + case qrCode func iconVerticalInset(for size: Size) -> CGFloat { switch (self, size) { @@ -107,7 +111,7 @@ public final class ProfilePictureView: UIView { var isLeadingAligned: Bool { switch self { case .none, .letter: return true - case .rightPlus, .pencil, .crown: return false + case .rightPlus, .pencil, .crown, .qrCode: return false } } } @@ -170,6 +174,8 @@ public final class ProfilePictureView: UIView { private var profileIconBottomConstraint: NSLayoutConstraint! private var profileIconBackgroundLeadingAlignConstraint: NSLayoutConstraint! private var profileIconBackgroundTrailingAlignConstraint: NSLayoutConstraint! + private var profileIconBackgroundTopAlignConstraint: NSLayoutConstraint! + private var profileIconBackgroundBottomAlignConstraint: NSLayoutConstraint! private var profileIconBackgroundWidthConstraint: NSLayoutConstraint! private var profileIconBackgroundHeightConstraint: NSLayoutConstraint! private var additionalProfileIconTopConstraint: NSLayoutConstraint! @@ -355,11 +361,14 @@ public final class ProfilePictureView: UIView { profileIconLabel.pin(to: profileIconBackgroundView) profileIconBackgroundLeadingAlignConstraint = profileIconBackgroundView.pin(.leading, to: .leading, of: imageContainerView) profileIconBackgroundTrailingAlignConstraint = profileIconBackgroundView.pin(.trailing, to: .trailing, of: imageContainerView) - profileIconBackgroundView.pin(.bottom, to: .bottom, of: imageContainerView) + profileIconBackgroundTopAlignConstraint = profileIconBackgroundView.pin(.top, to: .top, of: imageContainerView) + profileIconBackgroundBottomAlignConstraint = profileIconBackgroundView.pin(.bottom, to: .bottom, of: imageContainerView) profileIconBackgroundWidthConstraint = profileIconBackgroundView.set(.width, to: size.iconSize) profileIconBackgroundHeightConstraint = profileIconBackgroundView.set(.height, to: size.iconSize) profileIconBackgroundLeadingAlignConstraint.isActive = false profileIconBackgroundTrailingAlignConstraint.isActive = false + profileIconBackgroundTopAlignConstraint.isActive = false + profileIconBackgroundBottomAlignConstraint.isActive = false additionalProfileIconTopConstraint = additionalProfileIconImageView.pin( .top, @@ -428,6 +437,8 @@ public final class ProfilePictureView: UIView { backgroundView.themeBackgroundColor = .profileIcon_background imageView.isHidden = false label.isHidden = true + profileIconBackgroundTopAlignConstraint.isActive = false + profileIconBackgroundBottomAlignConstraint.isActive = true case .rightPlus: imageView.image = UIImage(systemName: "plus", withConfiguration: UIImage.SymbolConfiguration(weight: .semibold)) @@ -436,6 +447,8 @@ public final class ProfilePictureView: UIView { backgroundView.themeBackgroundColor = .primary imageView.isHidden = false label.isHidden = true + profileIconBackgroundTopAlignConstraint.isActive = false + profileIconBackgroundBottomAlignConstraint.isActive = true case .letter(let character, let dangerMode): label.themeTextColor = (dangerMode ? .textPrimary : .backgroundPrimary) @@ -450,7 +463,19 @@ public final class ProfilePictureView: UIView { backgroundView.themeBackgroundColor = .primary imageView.isHidden = false label.isHidden = true + profileIconBackgroundTopAlignConstraint.isActive = false + profileIconBackgroundBottomAlignConstraint.isActive = true + case .qrCode: + imageView.image = Lucide.image(icon: .qrCode, size: (size == .expanded ? 20 : 14))?.withRenderingMode(.alwaysTemplate) + imageView.contentMode = .center + imageView.themeTintColor = .black + backgroundView.themeBackgroundColor = .primary + imageView.isHidden = false + label.isHidden = true + profileIconBackgroundTopAlignConstraint.isActive = true + profileIconBackgroundBottomAlignConstraint.isActive = false + trailingAlignConstraint.constant = (size == .expanded ? -8 : 0) } } @@ -623,6 +648,13 @@ public final class ProfilePictureView: UIView { .store(in: &disposables) } } + + public func getTouchedView(from localPoint: CGPoint) -> UIView { + if profileIconBackgroundView.frame.contains(localPoint) { + return profileIconBackgroundView + } + return self + } } import SwiftUI diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift index b63e3c512f..d0c2c87565 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift @@ -85,7 +85,7 @@ public struct UserProfileModel: View { ) if info.sessionId != nil { - let (buttonSize, iconSize): (CGFloat, CGFloat) = isProfileImageExpanding ? (33, 20) : (20, 12) + let (buttonSize, iconSize): (CGFloat, CGFloat) = isProfileImageExpanding ? (33, 20) : (24, 14) AttributedText(Lucide.Icon.qrCode.attributedString(size: iconSize, baselineOffset: 0)) .font(.system(size: iconSize)) .foregroundColor(themeColor: .black) From 86b6eb47fe5b5188947d6a708c3fba9e9daa7694 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 22 Aug 2025 17:29:53 +1000 Subject: [PATCH 013/162] wip: Pro CTA Modal for group activated --- Session.xcodeproj/project.pbxproj | 12 ++++ .../Settings/ThreadSettingsViewModel.swift | 52 +++++++++++++--- Session/Meta/WebPImages/GroupAdminCTA.webp | Bin 0 -> 542130 bytes Session/Meta/WebPImages/GroupNonAdminCTA.webp | Bin 0 -> 548464 bytes .../Shared/SessionTableViewController.swift | 9 +++ Session/Shared/Views/SessionCell.swift | 2 +- .../Components/SwiftUI/ProCTAModal.swift | 56 +++++++++++++----- .../Utilities/UILabel+Utilities.swift | 48 +++++++++++++++ 8 files changed, 154 insertions(+), 25 deletions(-) create mode 100644 Session/Meta/WebPImages/GroupAdminCTA.webp create mode 100644 Session/Meta/WebPImages/GroupNonAdminCTA.webp diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 4437ade2fa..415ac1b0c1 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -153,6 +153,10 @@ 940943402C7ED62300D9D2E0 /* StartupError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9409433F2C7ED62300D9D2E0 /* StartupError.swift */; }; 941375BB2D5184C20058F244 /* HTTPHeader+SessionNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 941375BA2D5184B60058F244 /* HTTPHeader+SessionNetwork.swift */; }; 941375BD2D5195F30058F244 /* KeyValueStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 941375BC2D5195F30058F244 /* KeyValueStore.swift */; }; + 9420CAC62E584B5800F738F6 /* GroupAdminCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 9420CAC42E584B5800F738F6 /* GroupAdminCTA.webp */; }; + 9420CAC72E584B5800F738F6 /* GroupNonAdminCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 9420CAC52E584B5800F738F6 /* GroupNonAdminCTA.webp */; }; + 9420CAC82E584B5800F738F6 /* GroupAdminCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 9420CAC42E584B5800F738F6 /* GroupAdminCTA.webp */; }; + 9420CAC92E584B5800F738F6 /* GroupNonAdminCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 9420CAC52E584B5800F738F6 /* GroupNonAdminCTA.webp */; }; 942256802C23F8BB00C0FDBF /* StartConversationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422567D2C23F8BB00C0FDBF /* StartConversationScreen.swift */; }; 942256812C23F8BB00C0FDBF /* NewMessageScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422567E2C23F8BB00C0FDBF /* NewMessageScreen.swift */; }; 942256822C23F8BB00C0FDBF /* InviteAFriendScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422567F2C23F8BB00C0FDBF /* InviteAFriendScreen.swift */; }; @@ -1529,6 +1533,8 @@ 941375BA2D5184B60058F244 /* HTTPHeader+SessionNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPHeader+SessionNetwork.swift"; sourceTree = ""; }; 941375BC2D5195F30058F244 /* KeyValueStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyValueStore.swift; sourceTree = ""; }; 941375BE2D5196D10058F244 /* Number+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Number+Utilities.swift"; sourceTree = ""; }; + 9420CAC42E584B5800F738F6 /* GroupAdminCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = GroupAdminCTA.webp; sourceTree = ""; }; + 9420CAC52E584B5800F738F6 /* GroupNonAdminCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = GroupNonAdminCTA.webp; sourceTree = ""; }; 9422567D2C23F8BB00C0FDBF /* StartConversationScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StartConversationScreen.swift; sourceTree = ""; }; 9422567E2C23F8BB00C0FDBF /* NewMessageScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewMessageScreen.swift; sourceTree = ""; }; 9422567F2C23F8BB00C0FDBF /* InviteAFriendScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InviteAFriendScreen.swift; sourceTree = ""; }; @@ -2898,6 +2904,8 @@ 94CD963F2E1BABE90097754D /* WebPImages */ = { isa = PBXGroup; children = ( + 9420CAC42E584B5800F738F6 /* GroupAdminCTA.webp */, + 9420CAC52E584B5800F738F6 /* GroupNonAdminCTA.webp */, 94B6BB052E431B6300E718BB /* AnimatedProfileCTAAnimationCropped.webp */, 94AAB15B2E24C97400A6FA18 /* AnimatedProfileCTA.webp */, 94AAB1562E24BD3700A6FA18 /* PinnedConversationsCTA.webp */, @@ -5656,6 +5664,8 @@ 4535186E1FC635DD00210559 /* MainInterface.storyboard in Resources */, B8D07406265C683A00F77E07 /* ElegantIcons.ttf in Resources */, FD86FDA42BC51C5400EC251B /* PrivacyInfo.xcprivacy in Resources */, + 9420CAC62E584B5800F738F6 /* GroupAdminCTA.webp in Resources */, + 9420CAC72E584B5800F738F6 /* GroupNonAdminCTA.webp in Resources */, 3478504C1FD7496D007B8332 /* Images.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -5726,6 +5736,8 @@ 45A2F005204473A3002E978A /* NewMessage.aifc in Resources */, 45B74A882044AAB600CD42F8 /* aurora.aifc in Resources */, 45B74A742044AAB600CD42F8 /* aurora-quiet.aifc in Resources */, + 9420CAC82E584B5800F738F6 /* GroupAdminCTA.webp in Resources */, + 9420CAC92E584B5800F738F6 /* GroupNonAdminCTA.webp in Resources */, 7B0EFDF4275490EA00FFAAE7 /* ringing.mp3 in Resources */, 45B74A852044AAB600CD42F8 /* bamboo.aifc in Resources */, 45B74A782044AAB600CD42F8 /* bamboo-quiet.aifc in Resources */, diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 46ae5e0249..03e6d6de0c 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -322,15 +322,31 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi identifier: "Username", label: threadViewModel.displayName ), - onTap: { [weak self] in - guard - let info: ConfirmationModal.Info = self?.updateDisplayNameModal( - threadViewModel: threadViewModel, - currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin - ) - else { return } + onTapView: { [weak self, threadId, dependencies] targetView in + guard targetView is SessionProBadge else { + guard + let info: ConfirmationModal.Info = self?.updateDisplayNameModal( + threadViewModel: threadViewModel, + currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin + ) + else { return } + + self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) + return + } - self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) + let proCTAModalVariant: ProCTAModal.Variant = { + switch threadViewModel.threadVariant { + case .group: + return .groupLimit( + isAdmin: currentUserIsClosedGroupAdmin, + isSessionProActivated: (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }) + ) + default: return .generic + } + }() + + self?.showSessionProCTAIfNeeded(proCTAModalVariant) } ), @@ -2046,4 +2062,24 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi case (.community, _), (.legacyGroup, false), (.group, false): return nil } } + + private func showSessionProCTAIfNeeded(_ variant: ProCTAModal.Variant) { + let shouldShowProCTA: Bool = { + guard dependencies[feature: .sessionProEnabled] else { return false } + if case .groupLimit = variant { return true } + return !dependencies[cache: .libSession].isSessionPro + }() + + guard shouldShowProCTA else { return } + + let sessionProModal: ModalHostingViewController = ModalHostingViewController( + modal: ProCTAModal( + delegate: dependencies[singleton: .sessionProState], + variant: variant, + dataManager: dependencies[singleton: .imageDataManager], + ) + ) + + self.transitionToScreen(sessionProModal, transitionType: .present) + } } diff --git a/Session/Meta/WebPImages/GroupAdminCTA.webp b/Session/Meta/WebPImages/GroupAdminCTA.webp new file mode 100644 index 0000000000000000000000000000000000000000..7b78b71ad8323c59ffec39c0218ec2d54adfde2c GIT binary patch literal 542130 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�Sv`;?gw({Q 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 z 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

*w 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

D`CoT!+rzKUhYc)l1f`yMx4Y5 zOzd@2&A>DBSOX>yd6hhtrz!S3cSo<^ypd4s@gmeGzF2ZrO8p{Ynw%5%F{=ylgXG}e zsuZIlwShXAX}Z1~jcw4YN_5N|5Z@?k#7!1Q|4E{&-$wRbL%<@8XOehxPi0fXO$IQd zBpK}=*UTHy76Pxi))T!u=oyHYLmB1V@z(#9e6Slnn2Q;}n=D zR&rrDIy~coNfz7J^rDk02HFIRQ_P~DiBK<5CQ$R&O%AVKKvBlR`hizo5HTRMPbRc^I34TGtx@$^$ zsYA3DdP8I|Mu5pBgj#bB%Iiijk;x;avMTlk>qgOzYBy)Hxp>l10Rgj>>;36f2THBH zK^YNjc;`igPkDD-=CUD{PaJ;3deC>QJ%O0n^y=iDdU^4*yUaByVG9Ng$?p%Bo%931 zc{O_j50WTIPs{c*a9Cm~8F3gOgpIBA)i!M?YcL_V;oGXb(SZN3t^uWcJvB^<1^^&( z)EF6<%EL-{jR?U97rh-O`e!3fG!O%f2%0R9P%*qi@DnMM!@$Xb2966Jk5sB9<^f(y zIP+oc(x}=}#5@`hcn+t` z&@yS18tCN1;b1!302M)oEAL{~Zd^O`9G9>RTxvjgpTZt4X*-d@IHae8IxjI`dw$ZyP zeX!blxg+_&xuZZ_jrJusvc_^mU?IKKMbgB493fNW2v~ru?iIOwRl^h%@be#)p%;9> zPBVy)TY9`>wU}8#NStU~2gD}FdTX{hG*atWno%jGK1-fYh>I-9h5(~r8I6*MzvF0) z5B^i5{c(;GtV%(oAl-Nh(=7p@lCn7{sq?(STU){lq#=lWu`DcU#Gw)F(8^$GufEo1 zf^Em!#BGGgx%?1o$!^B^$!nWjb@@(`{3lvMRLe++t@~VfERz260}qM<`kbaU7l+s_ zP{HxgRhMIwE=4P^ky?&cA#rTF@(p`swW1V+KHZzjm|dK-t=X5Mqaa0(CCiaH63lK@ zeWXi2W?^5sA)Dq2(Q{lfAii3dR67;jyslF#v}S1%3OZzVJ4)>!6Pb|f`3SIulhwh%Sv^W zemkw@KShcD_fKAkKh9W{lyji^2_dfdd@#yKh_v6BixqO>G1tN;b%A9RjFKT-Q4Q3Q zu+|K3JZ%MvGg;VX0PB*XOv#|6>+Pw?sabmlls{e5K6kBG)FDjcqgLB{pPB zWs=VZ6AK6821Eiwh`RnQTf~nw?Qi2CPxZRdE4!AGBO0$0X6~Q{q+sW3!x4zWt~JDj zps6?cpQ^bQwH^Qf)Q%TPSqb~+$W;ByjjRW_a0;jI0&`r1T<1L$aj!65FDTS!dM zyWyq#;1>Yc2i^8tUJqE25_4Y3X8y<-NC#W$(vK|eqJ>d^KZTr&@U0PE;~Lwy5p-U$ zUYUu@*j7bZ+T~Ob`!OG%K*@Ku=&CE2c8EMdiWCQ}W}FZ*pm+!o%g++#q*o>eWXfSFy zlhUk`AyD^XOa_t=4fWZn@-?fnu{P%rZOxCa8>b%Up9C{?U2LEL0mk)N#SodHV;c#d zU;ok^Si>BkAvUGrs&0~IJmzqyH-w#~IcMPz_wtUb^{PblH&6ZGvS|&E?^Frs?-(il?A3 ziBE5t-rn%=Uyv4AduDGAkzu@nU=TC(U#ES>vzh!!Fb4=EqT&I!^v{NBMH{F-253j#vm>rQw89JfTuH(4ZNL-Hf0-1(w* zHKANao`^!rt?cve$l(zUS0JT&HKRc?j>=P9QuZpS%gA%D!xFsyK_d_t8dp-BWKBbW zFWyTZj;pyt3O7BR27||V+lS@KQzl?=Au$5)oM4@{lYEJr32Q?!iSi~+Kn9CgPx0q_ za8ev)%RJFuFZ2MFc^4oxulE-QWjo(GpqvV|qp1tW~b@)?PZqev_E>q0d*LMr_7Ld@F27*zj zVS#ISLC>&e5j+_bxEqb=D~-)wnEua6w`!Rwi@H&A4ot$$4t_YiP#6YlgI6VhkJ8Gd5?e!_J3(kmA?a1`iX9d%6(6Aw!V zwb^Wd=4U6wn&|`KriE>tP|FX6j(JJ_P@-AbD&$M3NioM}wqI^xNWHAk!hOu>8bKD5 z<%xt0ahHkG`UZrh;~|kZqZFl~JTh=D$!tsEo{xb3EGyN{k4hKmd}l$)Aq7%MejTD0 zo`RQ|x;I5>8?H82Js#yW_oE)+{E+DN6gXAGj*SxX9xAAqE=VLvM$~GoA!&yADacw{ zYtR|fgIeniJr^~->EzLsayGx;)rBgtrWzKzv@>lmZc$v^xgU-vOyzdr{R-hyWSS?_ zl{A$VW1y2Tq}dSNVv)F3!qzlo___i84mke8;N;h9Dx>{asCJkkVOs*1!(F;U85C>` ztjKs)?C>&;n`D0(8Dbyp$k&m@xhoad(DktHP?^)+?`j)(@#i=A^7G9_V1q1QQQz|R zRZv=z`CKknaTtA_#W2pzcM1;&$K&Z-?Yg-yiw)GDxCI)*oSs$DV%Tg4CMjipT^`U+ zpmUj9@7+t-{vxCS8)V@v7=sRe|K6m^!T4VK zZzdzDnEJUL*)Vs{#ks7fz%T3@q7nVz@@iI*%3KuCG)?8Mb}t;8@Te*$d_j~+EaS)U zBz25<+zYy7qv1bh4@BvICgzfjz-K}!OZ7hYd6AjGi}=rZqeckjUI3m=?>m12q+*oX1ArAYMr^Za?TL~1ne)X#{{a8&H+924Nqr zQqjhw#DgtcPrjX+G1yL-|*(no*+Wx@VsGIS} zPl<`vRC#4N8YhPt+oz{A1D2<#oR*6Z{&UyX4#Pg@YBQ@ikqOMO*80PDi(GkAr0p8< z6x|S(k+1U}1Kw$b1>4rzefZgWC8#5DzJN_GtU%tLrzY7MWCO0)w zkpZ|iA-R7jBT}_a;4h!_i!2lA18j4JGKqMm0O}o&;6i(&D3mtk%<1QT&@rx>M-f2; zW^N!l8AtD{z3MutD#8kt6fF488#Wd9lRwv$h-bxxu^~ERxjJ7Q#Dc%G>7PC|^|`Q( zBtHT@z8?-oim2ek>K2eZX<8sDtfq;N@*k}vb?xO2Dea!9ii>694^5=U$?ZM?-(h8|eXL}i5Atm-% zuD**u2=pY3i!*441bJT~8Mw-KLF>BMOg0%koNSx%+1_*wGxn=XBcI(T)6A!j`xU0p zT!Sbw5UJYQVwK5}vynfy+;iHj~GQ=HA(@&8Q?_P;xzUs_#}GqK}c(Q^avcJbmGO z1Yvqk#r8TwYTnW-^C&|))P>fmLyyUG;}nz-)^>uwBlxF)39kJ_`fh4#U_=blT~#wu&qtu6Ak7&Q$KQwC6iqa;;l>abZ92 z*APOCxf!&F7(T5@iVJ{orjs<^2C`PKViliXwHbIUH+CFyE@>A^Y=Ed&ONdCM&DwA5 zj9TU+NmHVsT+yk>gi!-lM3CWD>qaG;4AS$Y^&)IA!Fw%x#DjTis#Gpd;&1$dxQ80x z+z1jLIbzs?Xxu_%%idlYRnt^RXf^&KiFvOKzJQ6^&r>87! zPExN+n{aU`qQ1kV+Eav#6$m$98Bw<0S6e!o>R3JvEki}BTd&|Nms~qAT4oj|?PSWwk;%cu;mk$+1|S zNr9+q5QXT>iCGSBXYTZ99f*HK*FIf+SR1~HB}Vj}1QIRSE9$4YaCK{bL`DG)wKIS2 zkVvId$3iP~iTrM?6=p7_Ho$1kv(#_+Oj~=aNX&Qm8vzRKK54g4uR#1Dny4}1vnR#M ze~YRC=G21my9i+<#(D!1ZpjkTQ6+d5QUx}(`qSa`gMFBo8o?IJVBY0<*9}Z$J)V>A!7T z89+~2=qFbs)vZZL!Nu?v=S-e5vPS2 zSE33bx`A-Ll$X?;iaCNk6>(9fj<|@N&Ze`m8g=XoG3U-1BIKC8(#s!I^y`N2#MPE~ z(g`vYP%{b*X&R#PN{(LX`SfL(r43dZ<=#7wUL5U~PS($Dwu%c4MaJwp9x?adg>>~Xc18vnZPI3ykjaI~%msUYb*ZLyD~XT-WlQDa5jG%I z@{~>xvB~M?JhN2ut&VDM;oS1Lvh-l2BoZaa^3BdsK1DqeB+>um?Sh5&ePUlLxw-*yJ$+!WztI*5+S?TK zpp^m3_0(BP#=IBDoYK8=f-;>fVa}Q6jQ}S3>YP8;js>3hfkC|J;{wz%3~0iNgdmGj$&EWvO6>d)V)o z#K$uhVPoB9bl)rzgr{tbMj&8fDZ;J9auNsnjiK;xC#FyI>~m-}R(9^?Vjz7-BxdcG z$|zhU7&UFx#CF!PB!LwrpMG7otbvTPsBnZ;-qQdV=EwhXT)DwBQ$+wEPm{L}^T3$? zSc-;+q<+1iJFsZcd98>5^ddrk`wQ%1L6zT1h|>MQab9JO!G#D7+t%FD8sCb%T!S8j z{|7&h6!bC9)hZ4XxK;bk@)8t-xuHUm$55WoY=~f-qDipVBt)^ovMk$bI|-RVT#F` z!9!vF9)Y$tJQR&#!-n~}mYvpPAME+#O?@hN`2eX91S(3Tnj^tr57;ya+$DX0zwTiG zTH&Zy5t+~G4t6rOp*4p?|Bi2fz$!i%vU6%#H5s(h)4mU;wnA(W+rbL!8z$5$WZ_Bql8;yuDJQ%LkREaiP)J%3WQiTDLM;GoV zr-8Ta3=Y)p&QcjyM#epQ@NI9^&7_kO3>2AKB4!42boYwUJsd7>lET2uqOGB>l&{KP zfW%pv$;P>6Eh6VK)%X1-u$XRLVpNAY3 zXXlNHyb@m+ZZs&rb%CG$T%q(}?NgrZA28(J*M!g}KuX(As52)_>s2hV3R`YDV9ygL z2>bJFjd>@^3Wg}v-Ax}94Utblcf^|F26^X3KC_|$QDxGJ4ziEZWl$o+-b)HnD6yri z2rlXXP>(wyn!R=0-VQTwiH9ihv=tw zkXg|<(t8G(-t#IjNyQlpR+u!oP#o&_*(^H$oro}_No{lXj_jPK&R-TjGQVsup?JzO z`@(Dv%5-~Js^DC^;&@dd0>QQNz`{)YWHIn2E+4gD6WlP# zt=t)}rzo0MTw{yBWy(?~e=?6uK&F`B|5b$LD=>`7X1o4NicFq{Lcf21YU*uiY9Y%- zYv73BL|J+afRs+ZwkCeLTc(XfOLAJaaAc?nFuHE>mZgsxrOheGD!^2ICZRLGtC^qT z@Yf4QOB{0(k$$-E=8)TDL*|%-&naZl{MJD6p=63ionx%|MbX@7zy8tpRhCjC=mXm_ zM1Ww&D1{Qvei9v5!LvQt7yP^l-|4o+V!aF+qX_SQV`^B*7!ypWk~z%e(#HIewQ&fY zK`1tvpoXVgj#$7IB29V@Dp~k;Z?;)JV!at#;ny<b2lV*YxIVKAJw}G+`p)A(P3; z!8zhb?Z>k4l;ZfsSgO68g5%c+Jm~s(gMyiwK|b>O&P4}DN>eKb;8ch$A9u7cA(^t6 zX3qu>uH?lkfJ3|scmICoQg6c?U133{sX+UdY-J&{{4N>!@ht`(Hi-Gi@VsF!WR(m3J+jKn-d7>hVH>TNMo%#_clPSOyv(eBMi<@-oWhTj)(;&}+hUC(GRKu&QLOgL zkuB}GJJ^nljQXst_VMd_^DipCQ#T-$=NSBJTIWcKRgq{ULLNkcr}}LB>g4|&WRMH| zb07E9_E2WE)fv2A!V{4qd%1@DIJ5IuEz2$9mv!uyjX5iBEt1FT5rigaHe9MTVcUcP zQ}zrzS!j58Wa#Mq8A=;bPg+T4!f+xmuhoLEa#nNS6a+tkhoSeY&`$W2GjiAuB7*O_ zGDU3wl7@QuO>%#Fx%tt|yMT;lgY*!jVeG;jd zNzOtVZkwp-Yq1kSK^&WHuKH5AOK(nq2!m$kWwkU1lw2H@lqWF>DY9w;Eq-Ibelq$| zOJEvtclTh+H7afjry)Tm8t68=r|Rw0Yi|mo>J3wEC}82{SnH&=1z$5Q1t}?eTLr!0 ztF_b$8}_9!LAV42AK@O#s0P%tx3@MpnSye|l_)m>tf}Y&%N(HXkb# z!{@y~7IUk3`IB6o7|I95{H49p7;Y5|cAuWLEX!U`&73boH|8Ize6kp!z`~~<-}%k4 zupLgy<8=uM>KTW&6#-Hfm4S0$Ert(uCo6YHXQ^ZD(@4L?aWLmY5{s?e1_AOKYcWz( zu{pY}M!4&Jg~ymAD8NDAh_u8cmPbzZRx-hjcTtWtGySk`Cb^IpitY~>4Me3MF#%ojl0?I^+p%APG?H?49dlBi`o**zep7B~ z_7}0Cq7ZTrla7e$3KY`>%n%mBG00 z?yOx{;lzIGW0&JG9exTS0kuU|wtue53Jkn`fOv2_2=Sn)rku>5Z1rOS*a!M>%P^c; zD0g@UB?}565|25H_iZ_fjs^U!A-qeQ-u7T^&{&K~OStvI0c?Yskx2Rdvmlrf^}Jn}z&w;PGXTB^Co-C@t=}NQV@KLj zDqy+Dz-i7$L;?J6x%qA*2He+VX}=RrXNDgc(y(DOe(X7nvD62;u8ObtHoXz^}`j zv^g>H9AF((2mDIFDo4!24FAX27KVyW6^bdg0th75I;Akd9t0X?+h`7}4C2;WX_*?w zzjpi^gta$C`c->Dn(mbLlnn3=0CibtKCwtSWC^h0I()MUmZs4y|-_wV7f7 z8|l+W>O?O|>$_+~ zzB4RcHl37%-ukYt*l!T3!q=&hRQmgD6U~ZMm!v1p7E6WU8Hb86Y1NM`1mvvv<<@sT zDIOOs4O(`v^uY}2f!}`M;+u7Q8RPq+y6e6#p%_)y3r{QxrkbG0rKk93<(_{ETM?Ez zO4FrMUZsC{ZK62%A3B(X2(Jw*#={wir!6CsFwfCWmX-1`N;`-5(RCoUgxp=jnvu#T z#SlR3V~sn_R8CS1YY-i)IB2pYfT$BfzCe%iHS6?06U7yqM8Z5?9z&w^+s`k~4iE*J z)meK-ziYkOWC+ewaOHTW;?8vX=vx}PN1xV?%vB% zO|Qy5IhRq5PW$={*G9Sn<$jfSBinm+Jf1Q zwb);2Mx<1+t&r54fMjIE5mIUwIhfj&OUa-r_sSAqwOtIO9cN-KT#&D4Nb>4iT105D z1cz^!5g#L<#P$?GWmkVe)GQ0V{^o0dsxG?V8zuZ29hsS|-DuW$#Dl2@{WoCGl;Ch=&0=ckC)ZWCPg(4`YD z#z$hd2q#a#zHu;10Js|$dwgjqW8YW-Efo1Ihx4xo1aoAKKuFQQjx@$U zYVBFsZGz_E^YQ4oySH-xm|!P)@q{FIV66Z78kD{UFPYE$HujPlMJ_cwIm7l!B-ik6v8q1JXN zjqrBpe0$j7m1{!yQwsLZlp(>@>d&!;{fU=PSV=b+0>a)!_BCMg>BDN<(bFvz<>4xn z9>z}KttDvVg1*t2wV_0Eb8fcMuu!UR?!Gs398#>6ce!s4G>enQ661oF+rRX4T<;`^ z6axM!1p;Gub3jw`5dZJjPJG^jHx;8^2~I5T{(;dS$vHL%hC0#h3R?f3xs$@t4-~KQ z-XbTaZQBt9zfC3;E^^30?UW+s8R+S?SPeSu8i4tWeH}i(+&v$h?uK;E|Kp^7<1*0i z%SfU-3R5OsKHeN|yRy2bvkQhmAtwx>g&B9e%sW_%(f@8_(zHZJlV(JcmvcdV<;~u} zP+^@0a`NI&MV^}g!5ddvI$rV>t_CjA3)|{3#fX1mTnhn&J@y^&yK=TB$j(1g6L@By zw97HG-K{RH^U|Owa5F_FoBBRfh$rOH;71LnZ+@JEcyhxk!Hf@T=L`U zl?L+aejUI&>_~v%?Phxb&f+a*MsYp#qS7P8i-_^+d&$~{JvNfjs!@X47r9pHN+lD; zI(-(7g~)OxmHVNdBbpqZ$$NZ`rL4aVz>Y^yj84SUomE8ta8Io4`@SyFv_UsFXn5bZ z*pA8pkT^;d08z--laM zd=EUh9R~rEa*A!=GB1NF6Nb!j%B7G10&Pb;gieI47VCyLR$V z3=70I-Pe04Cv8iS!b$W=UNbUkN*o?}*qCP#jmEovK8d*!#m1u$NoTa$EM=7!b6l*M z3NT=p?(I=&)IZoGOpBLSDi&uk}X7>8nw8qV_w6cBcBW_SdHfi8N}oCYDzxFEyE zIpKzmf+h~WVsUdjlP<~-Hnp{M?OOHzDwqZa<;+HP$t|9yIQ8#>NWnHnpwf~VC` z4IAhbvrsay@~3uVlZ-b0ot0Ha?RW9tJdq#Hi%Cc2L39E z*JbT2yt<&JfzkLmyR+4Nk}}iIRJLEb4Iu$~cl3&~@OZ)T&b3OsLi+Mrsocz zaJvZT;8j9xPBF+KiVWOiQ-IE5!*F(nwNd(xG1o(m^*mQ{@F)e%{*rjf2CQVt2HI>o zTzAyO5mSV)o3i)t{UWj%YL{FAHFeeL_n8wQ_(hMjDt)eZad7RDbDLGCJ&?(dMoV&6IxFSbf81kse zUJkn<=PDN54R};H1I|A$(;5`sYh()775DzC58EP5?Z7Z|?z3X!?m5@eD8Vi&u zMyeZ-cn!^cM+a8SO%T4OmmwvO?S#4S z61yWc@A(X@=iS*cPF4YDzg2m$hZ7`Rk7m~Upi`yP?unyWZw9C-$bidv!LONOAV z+v-9SBPA#}x_Yn-P*9mShR8m4DJuoc%LH@-D^N&Q>3IZ|yG(%#jE9Emqaz^lv~*b# zwX;iAmw4%bF4xA1P`dG!d5>AiGhte`%UfQ}VjXPKeR1^~TZzRvfV?F=m9B3bGTIJJ zo1z49W;6S2#Y6OY8lOY{U6$fIB=I~WJ2o%UYS9qwuI~0uiCb05_LSO~fa2PCBD6;7G3)-S|f_`a3Lj7wVO~_?cXED<45LorvZ=v`!GK+R5 zloOYMb{uk`khM1an(SGQvSU@MVy+xWo`N7N_QVl!6aGMwBc{l+U)7l`Rm) z>3`n+CHygbMLL4a88nnQUid#i0#8Zt2k4kJ*K231Z{N-RBYj^QeTLnKS}fDx;xx79 zDOueZ<3G&-#&b)1W}hVmBN&I8O{x#5!=!N6S=!bAzw^uQgxFIv*Pm5L!L`LIw2(EY*jtxM+!ttd4o zwk__o7Xdy~^+n&)wc=;u&!2&;GS&uG6P#~+rAL6?vZnR-o1h`~%Bmt@@f!2{;8yxM zx_UD-I}P6X-=#=jYGd~PTgP@XImb-MfIS`(Tcj9hj^0<-Y3qbL@wN4yqYLspt@2Go zC}CCvMNjx4jt0dT3sbT4vn(S{-L}&E_pA&COBCbqDF9Rw#A(_h%gYiMRCkQKj-~L2 zJ^rMREJeBdv-j5_ycu%t+>&PONUCtp*e}qHQ@NTAs?7qvnKEluN^4E#ZV4xmU0)9K zf*@xMZ8xZwJ8O^QOCt0vH)JljZopvAUsr(DU= zIF=3jjLw4At*!~zE%+C=E_)`vH|c>vzgeG36JAao=loBa+MY{SaeCuiJ<|dSCHaUM z@|s2xbyxXadj}~0abe>=wjw^$dm8d-;=%MhY*5toL4B>)xexzNc9+~>(k*Rm{W1i z@W*`se<9L##08Z?m+3MEorLGjU?*7H6GygRYg7p8{UHQDpA4-%mk!OGnSwXNnpl~w ztk=*iJQFNTZ4$L7JTYC^cK?ooC>`X{6hYP;<+=j@&wS;l(B40;kvF(ghy4 zw1rIL@mzk?Ro-q#M$g%Im)DXjwum~bXhK!t7DdN~0ia55?zb^<=_2K4i+qo*(C|$)dWSO= z-0I`FN=Z}z1Xk* zj@OEV6#m$KhL_OFOu|1DF>-p9ZGS_sHWwH}C>p?aP2s-WIobs$v9?{*wjl%m<9;C0 zwRDf!r6T?O<;8*%i;{ce+|o3T+~urZKz(c$+&m;o}*7NYn#xI)oMRJiy`MT$k0 z7vs(Kc-r!_A)nQ7guE^jxX(c=!Wk{|B;G7ZRS{I}N+?VP>9dAIfVq2oz74u!D5ED$ z7swh+6lT=AEC zyU9YFc1twnQwPZzezq6Z`r%U8n^V})~`hl-4 zP;{R*SOR4B;)78jm*hSS6K$jJI47-60k-HQTc?Oc;!>RNsWigB}t+Q%9mN@fR> znB_~-@)Ue-(BsXD;Tk`Ugg_STR4-ktlO0^PZZ?9r};@s%763r z3*{)F!jFUp6Rsb)ArZO5xBnP@;w~FYR|x!^GYIY>T$*t?ux8l- zSc9L@_|uv^IRXmG^cUuEGR}f&tyNWOk$wdu)z`ktqQ_A*w%r9;!HH6SX!w=@n-K*$ z(sT%HpY{d7S`c)0F0Yw{Q;se@P;=M}X3rV&uf%97%mqSiSm z3Pc~H#vw0i``|;-!>_`v&>?BbcSJ^t&-Voy^fs)b$t=(F9ckZ9z=lLWbNFvSDqAge zh6suMOJt17tZ3%K*#^htv0}hF@>KL_1VMxN7j!010_+GU=zV|gT~P*t753XYj7M1@ zxOl2SV8z%nH4~8yjr+-9SsZ^7v>}(QkE09S#5b4uCgX&Q!b&%|12mb zI3CFIFXIHj6bt}VRVGCaM*D>vSh*P2c=4ctE7{P6jusB+Mfy6_P%|=)8tb3-eH1)+ zyWKq|aK#~J4lFKRTDZdkc&9F$g1D??1A>pzju120WW{%UWWsm}-kemm&bxICV#KlSlG>{S$jEAuah39tc_}RW@QMX;LWR+EK%>HR< z%9gKwze{XVK`~^e8zCNA1S8e#b&!l#pCx6^IvmU2z;*GvhLUdzNuaC&%U-|FP_}B> zO>~!~MVwl%?&FN2lFS@%WOWu$bP2iOB<|Gb5|$cGSP;>x0rq22;`KkhLTW*$ieDN(z03C=JDh<#iF7uV-K? zgzRgVgLJpPF)rKt>3eXB6k*j!y`Td5frT*5OKE0g)(nli8=H=$Ow~}hXAV@(rwz!I zayg7j9w{{frN`kRcz6Hw>y63hN!=y!&)mw(f(Jcdmb>hj9AS4LYuXo%db(ol6S}>0 z=vWuiCVpmCixI``XWV6&mRH8|(>ifw;*2#|R;l6c4yP%Qiwyy4-Zw!nL&!08GbliX z_$yqSMMq1j?DX=~?xTsIsEluT#-sagO2~38xQzKKxF_5|-~;YWFMG!zg(FV%6vkk@=w>hk~Fj{ zR)&;NUJH1BHlx&rloWhuz$!PIs63>Fa6$b|+r**{jqGN%#UU;vJ%|z7@xyvRa(4UJ z!Hq_njA*^90!hz(4!Pja{?}2hP&isK^H-sPlVyp^;6-nLXSdEL2b1-Z#X>)`Fml(}0z9@%os zxfhH44T1K_l3Yw#unaE>Kk8%$LCcS0h_T-`{S&L!IAYmuD)i@&cQ-{P(SV*%#!1Na zPIi6wz|&Ng7Ww0vPjl5|9f05=rB;>x1h#QR;kn2%8jMOC7b>8≪-wWkeII$NnN4 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⁣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Νqj4189=xpaP^33$pn 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%0Dj31R&n&er@IZN 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%0D&`{pB;mp%M=CwO_t*wK^``|WGJ)lLUaBXA~gkn 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 literal 0 HcmV?d00001 diff --git a/Session/Meta/WebPImages/GroupNonAdminCTA.webp b/Session/Meta/WebPImages/GroupNonAdminCTA.webp new file mode 100644 index 0000000000000000000000000000000000000000..aabe43ff1dbe487c7438941f0686979d76407400 GIT binary patch literal 548464 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

(`#OM%n|q&Ehf=)h9Uch9 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�e4K2_tR|6% zOuTuN2K`)Ln~wJRcjjo|dPqX-5>8CBTvw zjky;0_v9v?uf(7jeMGQ!EBG%7PJG5O;~w4)f3-r72JhW`YOkXmy=&VQ8}h~1X++Ce#ad@5jX z+jTlQigx(m+G?dGxGxG|UpiAse5@y!V5JMQT0lHNWU+8$Zl})KfM#?-F=HPXb{N7@ zldJtS{}E<|@1l~n2PdYo@Br{Q39(p#SwNHM-x7{g5|^Br^fmONAg2p6TK-97wE51- zJJc|Unxu&!Do2wtqAF?Q5PT)h$1Nb=xNY3qie8egO0chj{@VvM!8i0E>(pHayu#M$ zn!PVmk~y#K*IXb6b{V$*vuB$NX6(~cS=&ih9OQf=4{!yUw{wmo_v)ZMu>(G#AVeyX ztun$3T;7-bH{uUs+wCwV0r+4v6MP!%6+rAOr{VUk9u^ly>c?LgUK;o}8eBQwwmajT z6PYK_gPNrq1pCJblGL0%IRZq+(i>J$e(I59vQ?%VN)-!xzL=-hn5g7UeHSg_%mOqo z`vW-Ql-?gmsHVk12JjN>sgj;o6hJ#HPhqF53!%J%X1RT%&jfbHOIa>;H$nEBa-AV- z=$&t5nU>tePrNWy%hUnbCBETGEPO3Jt9Qq@h{r&+h*r)a5~A^TWqcWoX|pAE9oj9wV_eS!ai)z`U@` z3t?6W!SqKBm2TpnZ@OaUzeuc4=WlRQi=V?M1XC*G3FSW#JvE``N1m^{mZ<#iedmIQ zsC}~kxb7O#{rahuvXnS+$xXTU@a;?tNcoHZobGWAKLccIt2PaDhrG)*aOs5sCxTcMg7yQkrSb70uT1N-Lb# z|Lz+a=UQg9ywa@p>({IrM?Th@(}o)=N&^I{N>BujtE7-%HNRjKhTPgH-RRs}=v@B> z03n;t9q-&lp=NQHLZm#BjtrCQCal~|uKo@+-~?231HF-SptDL78*aR3XnG25Bvkxw zFF+ad;;$9toqMfNucKlA!K3KFtUQ)d5p6ggl^~LgQkHjsm^ z(G%n-S#H&m>7-0Doz>Ok)UV3%Is)_ge--~!^-aD{M5Mcv!}oL$*n1L+(!D;1rh#!N zX1c&p);iRhZ$GQCmjkf#-e8iyluT({#WCJr`k2Lb?_y|JP3-}*cF{HD?XsZTMpxtW zB5?HH$^qs}4`KEQXJ%EDfD>sPLH#26(|Ybl2`&{l7|=wmaHPqkM44Oy+Cc%W052}O z7S#*L#R}WaDg^S=b&89x-R%Ps+Ut{Z3XbN#V#{3BQ6g`Tj6+F>Fb?E2mZ4=6GRy(4 zLy{%hNaZRP@f#=4<*M zA9b{jb#7pMEqM*eTjJJkkp*V#4bIa^+L+cUpwzO%ng_UDrFiR@N%LTYogfrFQA}xY zr`$+}g@J7FxB_NInm4fi4mimFXc)_P?DPBETH(@%BR$FM<&8s~8aSX_P~f;u!M32> z3mwc^UUO+%rk~sSpn6fD=hom_YiQG?1yL3pwQsABdOzSC!Jq$AnTFCTY)G&nGZ%H0 zQ=G{l6~7Z2K1@b*{!awS?pYC6#+(@kp1^DWb?X8v-d77C)@E(plaHQDJ{8&4{j### zFxoZ`FPImIF~8Ed8?WcX1Fb|^E-`F3dMeO?d070j177|_x9sdOn>v~-k4TA{6XGAP z7?zCb&$i+RG(;VJm|pbmae?>nEZ(X+K8CTejW5+ai7Ek<5;fXwJ_d;?6-Hq(Yp7U* zO3!kmlLY~3?_LU&Xw#z)U*>`J?i*|TSkN|fFEbD>z8@n=(9*DY&H{`Ot$BQ8zI@5> z_}{^T++Cd^-PTizw)phVQ;7??;@l2nnT(&X*hBB`OLxewRaj>0Ya~i}`}@d1QnjyR zzK{$MG?ie3JcWF2Dbp2TiXUZQ(@>X@#w}=1iD&@@Qw~Cuh&`rKRq+-a&z3fNab4Vn zKOHTCdgYyQ;0l2)c!8PwhDVG`g2*teUE|QW+vqM9a)|g8$8M~D%p+`-U>?CnXbF4u zY@dMv_Z9vQkc|vZrkpuo0ZF(nH+o)!CjO@o%h1%>njMPcJU{h;>m0N@Nxt@ z2ozSYLKaXl6fkDoM~vVoxuKVhW_=!afoQk`}E=AgRnf6LiZ3{gK zu-&1PRehx|(3t_M_IJVmP+jSrz96M{HuQM~H*aj@#rNc>de4C&3}DaVHb4bzsGC`W@_q2IhA)gs)j^G&-Qs3UK&KIm-z>{Qo< zYi@aZYVgLGMEh(~EM2B^EQ?+WAW#a1(xFwE&layS2N$bEFLlv&`qFeOsln6LdqTLb zUFZa-8pa1|18RIu`kzc;ngI3Tve~}H(6|%Q9KKA9DCkwO)X~TXhIdZr;{UY_aZ#p6t!3O z1@SgLM5aIx=@fl7u2;Ye;6KO)@N`xp+J)875!^P|6s0+`ew5vpFTyO=Uq*7WYiP5K zm9)w!nG!RSe&xji#r0#%hj@y(HV3Ai@7w-+8$HfDUFlR2c09y98{Hd!MyutEd*g7y zdHeHSA3$^uPU>vO^A#FL6D7(1(>19ifhDYz(xbQlo}yFKZY*#jhmVHTkt)YV4$Ixc z?%~I&O$X7`dFdDBVWobJ&tY zZ$XggjsEse^0GL;(D?QLhI=2rj~}iF$b5XI`TN1LK`&veMt|V$v#63S_ul)*Bpzhq z6fzF{yf>e#hLkOjpUPv!uPrPNuXWrC_~9%Dzlcg)CCxi)Z9AEivsu<#`WC&I?dPw& zEnj*1(^7gNS^0Q_Y^gK$=O)#Y#t0X^_PIGkP7zTN5JG)TE2h?S$N`e&Y(#0O-~@{%t#f5@qpq6~h*PzThKfA{Yq$GP#jf&Qs|gIzBZ>P$CKT9ebj9)?#6W9j zt}LdfAl77*B`jj86!Do5q}|Y5vvJZqmby#QB)_Sar*)OPf2`As;{NGjcUrDdNH53gS$AUi=$>?uj(I z9Si1PSGP_w8Gj{ImmrpTXLzxNc2L)x?@(85dFc#>SX)WZc3PXGt+-HBW>bvKXM~qI zB7)} zXXTf$dn68K%`1 zPS|bSQ@r~>&>C}pDe9`W)}KW>`g+1?1V*>VHJ_FGGIj2HE_&vJ7m{Qjk$G-H&M)^L zh(_W(-z5-Rfn+ca6%^K|Y(7|s-968tCb%H|iq99D(qf zFzJCR8?bX4i=TG*R6I8x^IvB);?}fGgj zb^(vi$j+684m)@$FkCnu1CM9CJo?nx*K^7Tfzi?YRbBO9Ncawe>(6E9+qwpb>E8_H zx9%%d^np=gw}AV-Y5LjJUiEy+{~+W|Ll_GHmP3X=c9J#(3IRjn^Kdo!TuIEfj)UDI zQc|~yGq_P#Lj{L2O`b-WfC>5TB2skOfW@sQ1<)-ZvDMxAKM_xoR7WyzsfnOad_yN@<6?qaR2>41cZM;;%KcXgIdf={pM)mI_hj;<(YBC?_WMt?3wVVAY9B znp8h0F(ewS_hiRSm&Z;j16eae@sTd*1%JlY6=f8c0~6A&+c-PyS{M^HPouoa=!9~t zxPU6_o=O{oxvFO7byR-z62itAKOxtZbqTI`_Sl)nRZI4ZFNZNkKpSwY zuQD-pDj?9ztKCB$Zf*_W`6w75{fp!zvIo9LJ^+d*VlkUV=0B09a;t=>Q6h!EytJ}7 zxbU%Xqf4J%{Tl2XH=YTrZKxN?@M>LzzR}>z$!3`e-6YTqt@icXl@DPyv6ad;tYSYH zO@o(LGY}b2PG&kK;D#;E?!>fI`^U%$kMNFf=`!g&vp<^?;IIikqDhGCF?!_SxHOK; zOmQdVpVuPyBFK-7PX|(BU!;76S7(jkhoarmluVDIhA9Tiy;*%RlCF>7oo-Gxj*0Om z=PPjmgqvbI-fI9o;2OYj?IInEapraNARnKza;pUS15ig^lTk>x2~7I`(Cn!Z&QD#t z3Utg*j0V_)Wk_D}M5|5}iOv`(ntuTZtb+LD1cy>4D38Xb7Wx$(2`E-hk5tMsyr+d(H(FD$AL~E0n6b)eOED$hc_5eRR(FHcq-RCEHOH#3c-JO}2ly?Pc znpp4M?Hg!#g6Rq8DBMr5Flw>E=zi3X&>%-N>%oc;lp4cy9DsI@`UDI;0T~Nh)T5zn zmp3?no%B)Y-K3c(omBAod92tUc+bHiLkE+)gHPLbZ383af4qGUio+jlwfX9W&OPV_wL5xFXK9YvL<&%fI?E$!{Xyda&&NQLz<; z5xT$4x)+5#4P_#(+oe%BEVB&}q|p?t`J|R4PwQ4hFFCD#P6Gzd9|KmWYYxa>>1Zr{ zNZNkmgX}1$FEI&^v7nl6N`ez5u;ZXITkNv{+bL=cCo3m309ILX!L-Vu4i#MN<1^u1 z78-2XC#JqLC=xxBl!bjaM6my&0w4xK3cZq0d%s(8fWwDU1wJAJdBI+<_hu`PKVG;=5e|fPBJ1L(sGz4%R7_`ox*E#) zR~10G@mS!_QO~m%kuKrOl@x#6%ao@QfyCeAmvN-97ZFZcKNw@$uS{lodpkHx*1OjW zk|55JjZz8l z_^Dk&CSfJgUM*z;y%q}AR#L9n3N=jkDPCs+d5Nl*V?`OT{%5HrVamk`-#3)vjnfqe z^@P{_*I4!MdY_el`%(GCrGI5o3sJ>bN{ z`jizz_9M^l=z}Ft&~>bO`TtZ&7|!p~!_X#D3Wj6*s|0xutqmB0h|pHvoG|5yKTw9h z{0DndKm!N%r*2~ymVMq}bWco)1Hm8$p_7)CkJK1118E5(v-aSL&1Ac?@r`&enSl{0 zJB5vs8kjWFSWdY_Q>+do9jpp!s+Z_W(=V%Xs(L%0*UM4?56-MCeHg8Ux2U ztxD}oVa)$|#=&T99^C9%u;>SH?Fc?rr%)AgWL^BZ1@r^p?uc$#U;$hhCw#@XnU94B zW&G&v%#~YiU9rgM1Zd5>1{zbHgS=_~ZF_9;PD6+)?9NmHQiXhS2!L69UN~f>j=e@ff8jrGT=_hS*ml2uZ_1aktBxJ3`M$&RmcXhvAS;^gRm7?uPmvSxLsb z*_iNKs-Gosl6Zo{bA^L9`Q9cA|0ka)T7EvHC1rdBTteRaz+J(uNoWD&DvZAt)w92Un)2bmB zCn_v9*t7D2!l}Nr2qdOJj5G1tVH_}cU4TOO@hdEtrZpjmn4e-!4m`cEO=1UK#YYd}LD!OfJ9-V#| zfmA(zqA&83{yUYAT1LeOD5fB{`{JE2Rz_F&4Cn1TmK?OjA7M$HAh+Qn^X2w;WMz0F zSeFxz1kM-BevRALu?ONx*;Zf7U1kr?jz!d!!44^EMyao<4{jm_>VFfrfavhpVDK!> z;TbLUfw5{7oKUHZN+L5LZkjStP(dv-*W6~)=7(ueP5SfAi|Hz{33fzFBAv+66)>n7 zW>XVB926o$fvT(sEP6=#@#Ur?n}d&CCl)1s8n@sIrME9c?%zM0Qp)pQkDJbfFq!Qz zuSS5|U<(>uChN#U4l|$z9*i)#mG-p`0%bpx=zn+M^yoKTL7n)4fj&^)oNfqC$8ywp z_EIki;DeT5mh!tU=wLORR*PgEnI78ne{;}+_Lz#zR3;Lj2pGI5t;#)#)Jn?L3Sw)gp` z7;0Sa6ExQ-mgILZ|42OmWxE2%c_;Y%QkqZ((@&EZ-@zC>i{Mqa?hLbx()(bO2?q_U<(QA~N< zyYCp*YI&;mQ=!OpFZ3KOd+$3%Eind%+py4etrnC#04&=v^PCk&G#!{1hbW%8O<}nEL;syTbO<3=c3Sh!olJ_>H8Bz#ghUJr~yt0_{C$z*);0!(1=I|05AmXU!98%ni2m5rAS3yU^ zAA`En9cP>DR-?vVH2M6~R|fJN@>qBUNf2cu#WG9Cr;t8R3|4}%W{#Z(pcM?`R{te6 za6eHk7d0pki!J&18eXQau&rjTC6Z<}R6r}m67%?Ms|^*#5gl(?wE}%v$znA>&fzOGk|gScX<;%(mH%CfPj0mMJ5UTd z@zRIDw{~HXJ=orI|oY7|ATh3N(oby4=fS>fy$l&ksI)qXGn8p zF5g2ZMxrib6@O95k@V!?`e%2I;Omnej5{VR`2VT+XmIK&EXubuh%(>QwwpeTsov@RHY}p8&PqsDrZ|-J=9(PWzjSnKK4R^WEzHhZQOCJp{wjvR zxBlNt7(;b(cH;@oQ9)ra8&sceVIgysb0-{0^$PnU0zBpb%jR(T_7rJ&66Xk4m-Cl%J4ezvqR2-Dd!!b@O}?t=%gFgiz$<0Q6}5+o@E+GvK#+ z8s91Z@d{_)XlsMS&luj2Nr^j;H~eW@IE*cs3B?W5{S62*@BrK%eIAQ)7|#S>E=}Aw z)M{gNjfIzKhLgKk(ae_R%s_>09@&2K50b_QYdOso3(fUcnQ3un6~CIU-UQWorcN=; zWuO^pMQ>xt<}0I!n)6fF~W>_^7tNO213`aORqHBC<^*Op|tLexm2c#eNGJZ zVTu2be$ZtEYwY8U(#c+2F7-$Bn9X9La&@bezh8-9p|3f1`f||GZfZ(1JmX=CGUX)g zGdxl(51h4m9>!&s96xjDeL>#eK>>5J+N?VOKsS4WC-{?DMa*c!0Fd-+o$;2tp+||R zR#qARSwo_A4bVCb#ax}Bs^FIeF7ba$eT%8(PB3k?Eej#3H7(4FU*Of8Q)y%yBk4{ZU2r+2z8y_AH{rG~z*gI(M2_lk z{8WxQ{SFztVVIJEGb%PLl{^cD^l2REn7WFU8MEsJ65D5Eby3V;(v8u`j#HeH%&_zD zSMF-egOTKp8&c(oB;7Xz1>$+E`R9!wU(bf>d1$gO@Us{=Bg4J#RHAU@pXd_}AOOI4 zC?NF^?&XvTb$hQl;rv+9qS#<2#S+#135z)VWLi@I<$CpGc#y-$VdpH1Xv;Ur*ymgT zK7Wmdvyd3y1Ie%s^<S6-ZyrnF4akZBvB%{wfR!q`b-aMg^)fWxWB&kbCVUtHYPK; zW759Ycc2?I6FxG6V){6rL8AwF-0=kH*V7Y23P(Y=sh!8W+Etn5OGvw6MYvhSoWHSq zkK4Vr9``vZOhYrsFEF7su}%i^6>%?JaAnEBzefA z!lE$ZuotxK91H7Jaau$8&?;5hb+Y@(jm<*uE%yb70=ht{4M>d1ytyvbypX`-XfcRh1XcZMWGsm=rPUL8&a{~q5~sOOi)a)U)z08rcHRZi=qJv9R*; zh%0U5!>Y42+yKA|E3lC(VQE-veFdSg)_dGr5?y=ee|s{@wLPQ#Iu`Y2+B2{A6DZ7(?V z3~^4-gvu0QAT3T6#*jN1a~GBb=Bga*TnB$^H80g_2o6P1mvIs`$o zvgI|lPUC9q-J5{upB&%o^L$f!h1V;5>nh?7YbrCrQbfI`y)iGkP5=+}sA)uS`A-ll z_tz2JH#7%)Oo}Pp($PK&COGCXLI(p69PRs*5WQImNTfR+9dMEz4*P^v zyA2g!OI%A{&>$nmgDkc>LJ0nlC5%ic@jgrXVQVki2!l0kVj>8C1;*OyfQ=aa1(~A_UtIO?WQhdy3ec zUE6;lXfo_FVI}Fv6MH`w)=CSAY6r1^(M~(`{k0TOkqX?d4dZpg7bOu$ssKC!+Z5fF z+mKo3;MthyW=H*aol>((KJHIe35p$s9x-|tGR#ALAGd>?ee4X*7iF+_nq!JwM+LF4{-#E8 z?v>~KH!$!0+nGi&&jW!wrNAcY*|}wvoGiDg*Is}^o~3jEYy|8Pzqu}js9>cRe_C`& zq|X;#xBM}fP!%$jMX?qWWj!A#5}ALd6`>B1l4GoZEnYHAbp64xO;Wqdl_T#K3>$hE z*XQa2`|(Sy_f%3|`=GQVKtA2296Hkf{RY2TJMqM9MkhW*kiVX<1jADOpAl@1oiW;siEQ+Hd`a$6P7SyaO z>EkBSjOFnuW;(8XUiSOPvp!drkaN?(X4h&V-O+X5z)`!`7`r_H$71hmMtYf7W(Z>( zF8v=y@F1+{7{AAre^@(aO0NehwmTFp3z?g=GjT6|nw*aXrxg11s9CTwMsKL?+c>k! z%?Ecub~-GS!njuFSONoUb$Zp~p#YFZ{a@Os2`c-c7k88x>V}ZUsocu#w-2eWb(u3= z+A(+6aR6qzrV|P04MowPk;TBFucEA^%k8;Q6m#rRA~g%|Fl~wmuT3B7nDH1H>OHz}6oSaS=v)E2TSD^+?iRE@VF= zO@0`x^|%<=^{aNR1QBx!Fr4oSqCr;be%v}CZ8>EtC!3s(M|gGBJiRHaVI_Y)yQd&_ zI#S-8B%jj4Hb$Hu9pf6?L0F8?il_GxHME>}yTBaJ!rhIMjo>We@B5x28Y`RDkvsg* zj@_C|ZKT|SylL>X6Hkh*GQ0(vX+0TGuo@>%CR73~)!%KsRAZ?pXi`{)uUkwELaPFnmkuft55|+ zb=r3jSlEj~=3i{m%>SHT`FDHnVh-U5PxzSheNb2Zwa*RJ@ke#s_D2_^2OlNJ4M#e& z5y~`-XeX})Ir1hPIl2{Oqx>8hMGbDh$9x@m1n6ZwlGL5j8L4Y?zF&cuz*3m13Y6RD zp;kO=866pzi-34LD(jJ;GTuJ+iD$u#updUzyC3>f>RO1n&d;7Eb;bju__?uY8|FFa z_3ZzI+t@2a&;9o%O*|?;f1E0Y0*?f7xiiIvq)qR=^tw5;Ewww760Iva=Y431mB0%c z$A2fi_L!=ZcwuLzdz1EE5kAIaJzOqeA_vxgsV{%-N^u%WRnX95!X0C?wiKJO&Vmio4?wb*>!7g^aggFLMTkk-;;8c9A zUkq?VnF?qWBoT8Q1sZqph~}*FooF_2m#&>F3Usrd8lxq5ZxxHuUEmv|hgGDqf+)Pa zrl@71x>|nt+6#LvvDN!k2Q}5_U&bW0Y<1 zm78Sjyiy_m{jfQx`(I!ukHK~uH7Q!cso@AE42S0ERX5i5u{{aO^etGQ@N;QBO`F1` ze5*!rm#&CTm&)uz;&q&E)YvwG9}CbVOn^$Q>aDnZtqjR77A18qMdeJ%`SnUY(6@_q zcz?-zJuC`-9x5ULzhU+OuoeW{?_F(?4rQ}sMac`BYBR-gR&VNUPyeZ`)tH+7HH@qG zeT^Lc>RL0v+IUIIrCZC{m$>eO{TE}0=f(`5;QY4@WBDkrQ<4N(*Rn1#6%C`*?ZE># zOClwqq0!ljTmPB63?OUE8t_FsQZ7CxW+lRhw|4QJfcYiTpzz;@%VgEb_#n#W9`#yV zjdDGlAX6+?8Uq4N7tt}C)0Of+BrnEBRJ;I9x$H7~52*N8 zAN;H+IcS)ZL5OMRKct|MPz-kIhDf^f869Vt>CF-JvmofOZsn@e3UCz1@t0&>!$kC+ zl^9flj=+4&d9)Ap&6t>5bf$H-4LLD*$t#(;sPrE=8 zQ6;aW+4p>lIc&r`1Mz>lX|6ngs>PPv-6Tj;?PMo1m6R8T zII4O5tq??FBlFt&4GjXR+pdSVr?kser{6bUGI5}+O1!2-ZMN($U8LZ1I_7{kT|4US zA-VnkCfg$3T-5=uwSRZ<1LjEnjbH+nv3oz9lJbDea{xws>9)RsE9gi{4zsT(!zsmTB_5qs+S>sGD_#= z5u_k^0GAYD>u7I~&Bfz_gE@{&0K=7g)-*9U`h)!1@m-%8`qQl>s}D&W%)C6Cv=Yd* zHSUwkNTN=%j(ljTJNU(R$K}SaU}sGUdYhvTORHnYh2SC5+wTFUJtCEzuFei$+*s&$ zLMp7{s+JAWu;lG*y&7iEYIw(aT#SS{iyWQ3)b*chB5Or0<O(0q9rDa&^5m66b7_lm7w^BGS z7btI%{?ww}qekG~La5A5B*;rv*G68!OrB@K{l3DsDPuqtk&u_yupQt^^@C7UPI(1B zbywb(SqO#G!<5aL%($Y^z2j}hxnos()bTH_S?!)i-0pEIjqB@a1GZ>ln18O@Ds)0Z zF}q^AhQ{SdJ@%V&2P({_llWR7rJGzzjhSK3p`g09Wz+8s^f^l>KE$6r@bLtRV*t_k zEdTbp3h>a&%xkb##>|wd*C=PwTlt`Ye|QVHXjPMAwVq433pX*W%Cl+Fe<;)X2ltfX zguoPLGwnDG(WwsM+sn;%|Fx*mAMHF+`at0}vX!g3rWNn-idv(OzY+V;tXLdXVO_Zt z5Y9F^IjjU=zk=Yo8RGki?Y5&Y##>`~2Kgq$7jih9)yAC*{p*_oG70@fjfI(z=wSGD z4_cx|mQmVY9+v5jxgRek916Jg3q|+pjr>n~ZvK}zaAOU-nyhw$#Q@5$$Ij2>upgJ6 z)ZnS}x`SRdcvW>luNWxT9SZ+XsO`xa2VxIcS+}%9rO55Ri}wkx`kE7TVfqF zBMo_@F6mZLin%m$M%ZN^Box3gw~xg4V;Pn4X-(|r2`}~M>L~GB%HD%2-+uSbR!MU@ z{t;pMuZk-JL+5--T3ury|Ln?vDpUQ*nRu2a@)!O1%+cw92MGiQ67&@K8guap+B^n& z5W}k%8}Ijp03c|Ig{>Hoq&((sOv;F$?Fon?e1d z;;8OW_=TnVu;K0mJe#?Tl2ZkDlZaV+BwE4FbUtB%v6wH}ra28$hJ`Doe(N=EkGhAx zf=B=W0005(AMz~y_KLh)O@PRtb^Z$Xdi<6Zl#lV+)k`_(uE)iJL9$*~Z`jYHop?2}NVSg7U)Wias6 zF!}A#rt6b#okUZ)4!Wd)BI9*5bFYO&aIDe?<>abOAeEuV;5 zm&Gm1jco$gLX)7yk6gW2LAKVnvNuuS;mF2SE!5$7P(1F13`4^dJH)Fy9gGs<_UVoR zYHy2lf2}Of75({S=c%P;u1^)i{}W#P((0vZ6LpbNP1xLyb|+hV>P7Xbb_`mgb`ZR+$2IB>=%i}YOTY) zP_&vZFvAdQ53*h`dunOj` znEd*0uDWIU&yQ``+CcX9slK?0LQWpVlE^;XBB7(`nJ?>37 z$X8wM^bZ9yOe_$pZ16Uc51)z-3483J4yHBi=m7%8TJdhh4s8mNzLWmXXAdJ0l8J)eX_UmiM`=xrN2gi%GBZlpXDyJ$O5NN6o_jE7EP2}cz5Z)B8XHbX4y?mj|?_;1qOpu-JEQ+qYlPuRaQv!Ic? z*6I*Qeg?shxs|m!0tq&h^a@t zX*uVBgS4R#IRsifQI`oGdUq?Z8YE8z6*^xOL-qnQu9nyWdl+8sZ(fO48Xp09Y_%g< z+axPFI$68Zv9nUKx8tun=x9gOdoz7$@q9Xj&Ud%1ONA>eU|O4Hl%WbxU!{d2mf@dF z9~@hevr`5j$kX>I+|)=Na~AnOjk;(VsT9-w#q zXX5YZkQ|F5DQfV-Jj>Ess-Dd;fRUWNU*p|4wY$+fNhe}(y-Q>TeP)PR;}+xL7NWYMxIpu^7PgqgAk`%-Y}!RMVFZ+@aIZ97Xf zbGpX27Z>H|3Iz)nmJD;6`ATfzCAy+kD_XvJ4+ZF%At+S(3H@d}m;q(zL1-tQF<#C! zu>Mf)k4WTka750EmNwa4TPAJ>&%ooL-yH1-meXu+2MulOO>EyD`_R+N2hBu`fWt`$ zDuO}PDnv+i{GTy>x9x0*^~U&5{bIvn!RTCgC5E$W zJh6#c*Aa>sNff!cC?DJbjmFVKHj;aba~Up*4Z>gB51g(zJ8Y>LFoz%86unKjI&7s@ zUK^sqpBr)2s(EismY6;MtlILfPBTI*^C0#gGmsG+DPR$ci%&F(Y7ncMZ z{Psp!q!{P3&)rv%oZ9=OrzX=DfeHsTTwr<4adHY(AJ|8)3_vEsM8T*P*eAd)CofLD zYD}K~HU}G`)j^~N98IEdSeBD_4-s%3pEBP*u3c{BrPdop6_tEf%U>I?<)T5V|6}}* zbajI^%15JwPY-T#@{%g{K)W&1DKxEx&9<*Ju8&QZ^l+#V#6E%{7!ee|RMZXX55(`6 zFfWG~F$iv#*)b9zBNUd8>!6icSVfm8zMs~U>Wjg!?|9YX;k>lO*}RWkXu&@=~mYrmWixQ6bu$%;^p74l*#YZriLO(gK>YKvi{NVJ39=+{@+ zm*Q;KLs&?CQo*Y%R;=PDDnT4~WyRk%L-z<{G~lSFW6&xaoY#dX^N#rHF0i&$CM7T* zfj)d)s&|ddnH>}PIdtC+CN`g(cwR`MF0~}UcV~|sF^jlRnx6S_r{&(~YM7=|Te-aV z>XO}@I7{pRP|<&7FBXs2vBV&%nPv7?2u0a@o>#&4xdzqtRk9td?IjOn>3~8nc)61~ zE3-ohPwy274>}PK^9$RJZU>?_YMFBHL-SKj26-ZgUXyP5O#db?1tiig#%MoXfDPh_ z^77BU(x2qEzab}E*zq*QXUx5z4E;YVdvtNsevsZwEpd=LF`TKIv!7v}fh=Bn6ons( zM$Q{#_4=1-;U6YlH@GM18^5~&X7)0v<}e&=#s}XZ&;4{F!p2A=#|fr_iRvPs%RsIU zbE^DwpllD^Dr3lSr!-wmKX54>+0m(YZM?GQ$;F6+29II=Y~2xwZ-Pk{8!Ji_hHxE( z=rX}ou57CPF4M(E&dHHDxU}O#2CT&%nN6s-f_ZMAD?MdtoPg({Gq2bWtqyCXIqDVp z46We9h;Z!G!(CcSlF55e$mZ&aU;$i8#fIRl z-KQzf!l@etT8)Bi9w~_Q(x!uKr_N-RWb-g$I4D@)G@wh$mLxa38|5i3X`3*De!we=5J$pzARjpF!a0T8IrAwCW+ie7AFFRNceFCi^|tp5LiAy}*RC{0G_y zM9TWR+00!NgUkGdiZ7DY695s=a{OOxzCpYW1X=;l@Bpmo=DAoh1{#-kF?Z_@k?SC3 zogm{auU@%TWD`&dG*(eP#H#Y<=kKqoRIgU*Ro`%CY7_Mtijvx=T#Xf4&=%aBeJN8~IaTsepjb_B4^8d_S8 z!?f5PTj^k|`xsjI1cj0A+#riuPmP2utdQ2Y_Z86=u}L9rt+)?QxwvtVxqa-f)Bu6Jj*Zb^v3gv;{1SFWpANk4rYpa}2++ki3tzjAc;KkbQHJvD7g z`ZyB3C!iY{4Kd_rEOpU*(P#t=sC@Z@F-lS9pRRjn2azA;2}{@5l=cG#8T86UP@CIN16uInqR|yN{SLkt|`5#-Ck>jiY<507=KfB zlmXsJFU(a7D_(35uO05QRswLCwQK>WTNGIoMF`k{UgP9@ zO7#8Dao7=6d5jMV0jqzdRk z>d?XEf`_gt2Ed0jPT~W;SQY$<#F&e!HD{ZbQ)yg1y|GRT=84&Ey?_|PIPU$gxK6K3Cifjk{2$lgzSDnt=@1p01wExEWb(s+$1($J}qE!u4FkhBtBZ> zMjjt-(2%ds$J&_{?j>>YMMc?ODrV}Br&a(evoz~OJM=YKW`N7%XfURu6$(j@AJMT> zAr1S6T{3!w=qm^N-P~xq?<$vfN>`5hE+z?bOmvbRn7e7h%;-?>BNPNzUZVI|Y9>ju z5SJ!_jr$Pidk5QrdE`M3c1rk;-D7m~=$CRJ zqf99qGV)P%AF%=Vi4x6(X5k{OMyK9$Q~{*x7>L9(+rc9N2dB~YR*0Jyrn6v7rvKHk zd4i+D|4K{9k|IN0Z3E|oOQC6T>@OjjC{jys!@MnYPqx9C`TeYoC6IurpdctCR&J-NTvooSpl$F*0In9~-CMx7tRNe}0HF$^bmiw#J;T+#E=jw^ zUdxzECm-xLppS@d46|+tI&A^IAC2CM4LI!yN%*EMXC7YqNXeJD(Vr|Y=TXqvJW}aw=lggv(f}a|cn!Gw~iuG;0*-R%QWKV5e9V5xb zOPiDAQ2Fh}kf}R4?SQ#j#R6hR>F2P3$D9C6dd1lRQTsjG+u0lL%segh=;x^O=g@K_ z*6Qe!?$P*?@Z?SUA@Y2Rwl0y^P)&D4R3MzI+v32Oxi|mF^@A{QXijm|R?zqWA@c$v zIXUBCt~1+KX0SRipSQ`Q$g>QA+}ELYrOdb3);vZ|V#$*#Mt=L;OX}t+A^P`vt>X`_ z&l-Q4w^yRsk7)m*I0QPb=yRB6)WE{PK@9+bRCRj(q)h|cn3S7I?MTLXTAJotfmDm! z%THqex|#)i;pa&*+$0_Li*8ndHFKN~ytSnmr6-e1j;2(+l6u)ER_MQfh}*AYn5UOt7my03|*3WvUo$sXIB41 z@40JuRC@OxM_)p1id_!Om7F7dE`(f+!h(|nU!tpc;-l5)E%ADvO-x56(ILqSUtt zuu`}OFZn}b277Jv;SKazx{nI0G5{VuU)5>BO$IMz#5+ox>8a_dbVu3;u(cIj#OZw= z)k~ocW05G)-q>FA#3Y)TxgBWS$UFqt;GuVfAZ3+U&N8v$VsGDn%G)d>-77?()J>(H z#6n|t@q!fcCV^D#mtF1ylsob9@~BzK)8z5?pl{Q@ND@&BD1(}*Mprex6UIh{?+Q<_ z`K75--AGZh_M|hACxLtK|2-eh0W(>PRrgR<^NW{Xma=Jm$;&t2bTZ#8f|?B~#Q&D! zs=p*-aLEa9FxxQ}{JoS)nUwMh$!v5$-*Im={zjVB#;q|(0?QGxk~cHwIJpp6yreGm z90aqQyPV`g-};Qb(D#LTxG}$yyyFsyXL2ZDwSH+w?mszpxSs?t&Hs*9SkfYmwRyW2 zt#!R_k+=-n#(Q!`#_9kN;J4xWgW5U9`;;I70ss4%igMT0G-`$2Vzm~IQcyWGsH|WU zXk~u>Y`s8``DCZ=)Ty4U>%G}UwgF3JyL$~ywx`aDqTT6yV}IC32#I;D@5jJqigy$2{e^7bRnD;bq^rjYKJm85lkv zbRrjWUjr}oEt)1WWw4W_uNU}Zji`6p4e1njI`oMSMjvIDQ})G5jJ%EJ3fE;ASl^ee3>Qj6-YNF!TtrquYH#ffUw=p2pC=?Z(wd?X3|`*K|mozAR!^g-f&`)ehh!!^&ovW zAR*RY%_^57qR37>BV~VLxP)`yoY9BMDf_|_ED`dM;e`TJGAW9tyoh%*dDIb^GCF2~ zUOy1DXmECtL4`{lbEiYl8?bu5emPlNm>oUAI}(Ge)00%3SASX>S7bJa$dyqk6|RQI zmfAO8wT^Y;VmN$LFo#*B-BtTWJL(MlsW{73MxgF9;dV-ZY*56mR2kYeTEH}saj1+pC)AefEy@!B(Wl5 zxcDYT1xZ@>t&<<~H~dLEp1pm1FVqRAqaB=AqG-e0=tl^leYV*1KA7q{ZS8V|VATp< zlwp*40d1~@@1}Q{?M#Hr2y_oJz^blJhU)Q^E~0o-9iH)K?+1pHTM`}tXrLO(&egM6 zaAaPdoBs4=-DnF_cM)VI{(tflnum(b$-7X5a;KcWZCo#Wpz$6wT&Lq`QP_jr*m2UK zu zr7gbQ&oHAM;S%$o<|&B$LrOq2flDP+F%^N`_;%YPk4FJvACTt0{M>Quu-$5kg%}S& z`mtq$#T}|}kTJ$j>x%-aW$XBiX>@h!Pq{iUV&0XvM0TtE_jbyB=xC?A$0vP{&Fa>5 zpc9@1VHwUlok4zP5$pP9G`VP8hwr`Fn`3uA*t9$3CZ%41P)FqG*dOl(annfau8Um7 zB!4;am(=8%u#k@!-q^Gp^SXV?F^E1!QrjG2+;aR#4L)53A+P!#OxfV;SUE=&^`(U{ zMXfJ2UrH-KSD?gI8K3W$2#SofZsGaCen|lM`TP#}1|?(Y9Tu0MH1;I8QjibrSyza* z&&a^h{@Nelaq2{MFkKI>&aLnKy%W_-Y3z&_1>o{{NVCxA#^V#eny02{P?r;FFJ1#NzX?Oyr!{D|ce#v4HH=o} zxOxKK+~(z9h=g#F(;W5}xxqVMWX;2{iB+Pfy zhv~Z3YeGp3ziUU{fBvY1D>Qm_o*`p!Hf&-YI&X{%n9ZR)gNCy~ox25}96aHiG9C7{ zDKaGcOYp!JwLZcz@A{@_268k)@vv>xVn2EbJy zA+BkC3N3zBUQlmI{nh6554`Gz$O@=BN|=5h)D>wfE}Lp7PH|PC@sUL28;Oo!!d%U9 zE*^a)Dl{~U^52iAQJ#qB_{feC~_&hV$8@GX`-5%4iXcp+V*rWtD0Ft(|*;NLRRZxhmbCzRzt=e;n#2c`-!ZX zB1RsIAB-I#r^Ysg1g6JYS#nqc>5d}G=d!#nqyRg(DuiqoP&+90+e?-fZ`cjta@}nP z;F;JE9i&#(ricT*DjDx|Kl-|G18Ezkm&ympyjA-amq@7OzJev71OHvZ_54rSSEboG zEVkU7bZ@6d=Cl!SMm}gs{tZimS!oP_KF?5pJJQY>wn-hVuA^F0{E*j>t&MhUM&jgj zNI#C?%;hD4t#H~8o&psf`^k|6Vzh>5+sywpi0-l}SH|4mDSnnmW($rk4$GGj+mo?3 zE#lXVV^npivQhym^H;}rKM#5zivZ(3W+T&kdZijP$ZD>JsNi6xmJo)SZ0duX*^8iu z@l;DA;CpU+Ed+jUSQ&IG*dD`7ndrIfCUv65ZiG}KYrj1|RBW*#r5d_;c?OG(16$d4BJAA0!w+AWZHEDW6 zcBG2PQPEOuxNYH1O7A~LCNwrm_%|AA2~-^*nCkx=uzvw@2r^uh~YTLKO= z%A3hq7J9IB44T+Y#J;^h71LH>>g7)C^2*luGXGG{H5`4F{Q|Z8PMYWvpqn@T#l(At z2C_su=e(n_r1oRTPwW-tI<)4%`-3c}Dkso+N%GQ(54j?4N?6{sTXD#gwjDK+9T<1d z*B=X=%bKuTBPbDrb2*L2pzEtvBhl1$d*)TE;1lHvN$2z!7Bjt|C*6P*k8@T84%tEi zH5@9|j_xzA?aiiJq_);MYj*xs65lO^MS|Hl0+nmb-3l%Hr5{7$one)vKm6ANhc9J> z9`%PvCo_$a>Fs+5T9w>jq|4{Qh5=1Vz7hX~-?U+;F&Lb#JMH^ZXGEukLD4o(-iWxuv!iL2;%X zqHC#>p2(9H%${82dg-a+Y#qhD~I&o@OhmrEVGk7n<&w0J!Ke?DT^w|0UA_dlWUbm zVJ9d5AMlocvlRiKnf1x(IC_s-w5Ow`Z@nqbI#5XePL3_Bq~6%uDZwC)y^Y<^kkxMd zD+acDaMUmwn&O6oD#3levY*x)^?lrYR}RH&&h?SkgpHsR6CHBi{%sM>uEnxWs^cv9 zM6lAcs;cpndb9u<%?8P}76ve9nWV?d8u%{@HdZ~k1qx>+5fnVd_twNYoJQy7eKKa> z2$n_R;uazsatLYpE)c9{vqXB58gp~lT4tS}nE`*GT;KJ*(MCB<5(%N7x|ZRPUNq4< zKOyK=`Bv-O=}yJ3-09ix%9H_mLst7DX-4SCMK#rg?<5O!HSf${#(ydy%_mOoyI*&- zpTNlRI&3&s3bT;CC3}I>H}?v=y@A5apD4!)se_Q)cOE*JM~`0Qwi8{s5*KGu6Thuv zL>}R4zbm{cX5=wnw!hzmlPG~zYSn`Zr|vM>56`%T9JSh*nd&&dLSvsYrnl(~7cnsj zsASdvoJ0v?blhr>;GpR%cFs zDEsE=BtpdIkk|%CAQXHcEUWmZ?WUVCu~dL@B@}Ac7KYRvWpHyLR1IQ?!#Y1JKE(L| zgJbrkm&m4uFrh^jRB@|0Dm06oEv8Ko7Oc>%ZFJV$afw~awcxO5+{&sMZBG#hSxXpo?D>FyCc--?59|$;1%N$N#Xv0 zJnxYjKrPpWj^3Hz?Wfb;ko)LE(`CmS1iOs}Jxs%3F6QIA33mhK{*|44xd7NXsS)&FoPn+Y{rr!`MVx_l-7F55pAV-?y( zkq<@g3Q64b6MdaO9y?nC=auC9l8_wph%I`EKPo{NZWyin{4;;Cid3}_s$w{UVk=@V-Au^&UC6?-peRu5>`CD8R7!HUug9Wnmu8t1~EPQabQ&GCi`*z#WCN<$9fa7 z@*@tJH^;@;LC-XE^EKWI_APdqbSly5xx7l6DJZ(K4G&ff5d1_jyXha$Gze*hO<{}~ z_dxXmmtrbSljnw^YYFZg$VR=aB8Q+06_mO=C^u66<8eT4!C-6FT)gDt*siT@z5G(#OYUyMs~oZc!}n^|zHwZkm3 z_I^0gyw9i|YlxvFoBTVeyc$`6#kkMAWrHn_LWXLhlF*ku>M;$;bOmpqzZ~)_Cr=IL~G40zu$ON)dQLEB#$cuV#vQd8^^XyS*|qxX1;{) zy@uV`ON5$Uz(*@sglpfojiATbUH(>GC<>z3_O)0ED!qBD{;;R+cM;R%Z>e(kx>L?t zatFoOICs*P*@q<}X4e(`c^sCTX&ST7#G zMw|UYe#x}bYi_asSNc(7lg@xustEY}r{gO5ifo8BOgxz%XK@{=;ERt+Sx-1{{yiTWHc*YON8VoV&L*}lDMg?$3t=l%wcyuSaTL}?E@9sh12nMKajAdmC# zAu@1q$|Pfwaz+E}h_CL+_^B0N7JVyj64yx3xp=i1*GL=q+5RKc87?eaAXmqfbJy#~ zt_COMLoek1MR}%=DFJPp%AcN-Nv*I?dSi);aJ8z1&`?i~4VXceW5jI^v_>oG8{+in z_{RkX|2z7L@x*a0EX_E;080BU%0I>0On(&bLW+28P{a%D``fqB_R5O|Z31Bz4*^KFgV&{5F3jGI`$xt{l&CDO71a4IM6Hc);>4rCY|#=Ej+_{$1;NIeMGI zA=jn0n=1$qIz)-Kxecq4Pc-U~7>o&SjI zpC-!54agp9G|TI?FV0cFknQp|x4f+zW8$f^%Fbp>EwQRxDe+)%pb|@+zSUf1cvwYE zepBAPnV)d9rcm9Eeq+uL_cw8T>o35Rp`RYx;)U1&QueYaVIYF)#=gU#7vo)Gbe{t- zaowIg0ta)lsHdL@F4}~TafDoaNSdyU;Q=3D8u{O4i%#Zn4me&pJ`Xh!ux(RI!7)%Z zP`j?1ree;a`We0F92EMqqvrmKa7i+db&CQi*IJg3Ls#QueZ+3byv8+u075{$zqMK; zb@&UFVWk3M_~r(COtX>dD>bO1vxj5a0XJgvo_f|zQPYoS;y>KB%fqc-1TXR%SuUig zi(oa#;@|sYM079|qcK>%prIcGcJ}(C;@GX^nU%J}o|>@)e8ia6jp` zXgi_dBMt9stQVr~1<@>$;d5|U5x(Cm2>Y2--Ti2|QMf>ayzHR};8Dc{kFNNsO5-Qg zkt8#h{+EXX=i%o4VvqliZ?RtUhLtr%-XbdDtYe4aO^Q&1h70EQF*g>u+AkrC&55Rk zOuj+5q}G9kYUu7=78MEUV2fjo(s2%l5axy-k*gdX$O18=?4Dz3j-yxc8qHO~Nd~D9 z%oWD)70z`3JAGtZ2qZyDR-sLv!u`Td5Pp(pME6I-xB;dX6fv72*`CGI_VmTIp)9hM zzMLR3RmhjOWxY^tzi{tBI*y)EpSSpaJ*QkM?strvi>Q|F%=u~R*#0OGVRqY%Zz$Jo zRIEjLgL<+Dd>pEso|m*Bx-QD)O2E&;&T6Fl?rKnfJqG3+;eIyx|3McldomvUi_&m6 zyFWZ>8;(OKHQue}z-54w;a%T=IGxby1b5{@W8HC2YjpOZfGUOqV=&_H?rj_V#dCH2 zTZT4Bn;&~-0GK!vY>>^Rb=Qlbo0%30e*VxyFoC!f;QQw=pN?LhdAd~x~C=T8!=}!K&1=d$s_VYeJ=SA)_LAJ#Iv74vsc;uWDHnbz!EfZv0tO2Dtca z2s(VW0?NP?49l?trG+NXB7zW|03Mrzv=eeX?9Z@IFQ+m%hT(bi0H~qMqk4ZOu(aig z3CP|fVFBA_ag(6iPWFHR0UiQtKBr9@;)dyc$}>VoU+e zEGrbyrO|kyKQYI!0LMDm4iDfIjptlASiA~-qB>@vROPM$tFhh=b3PRHu&~{j~ z!7Hn-OdAkQ>s^fX5fyvM1s+6zQlP~wz>b^?!g&S~myj!xBa*w9?N<-q~MiN>H38(SWRyMw*7 z)$Kcv%lgGjB!#GjhZk}2x~dt2a2se*bZ6_TJ59og^lMEF#U zW|sD;#S)iP_bH7UDz(%?&xj&G(7hWkMBaBX^#w%HTF3tmHUKS&mVOL8k0vNxa2L>R zmMe2H`qNe@I-}tPF%8aV7*zu2hPwlQ5&i0kEdr~RPfJ0;bu}nW&OwCT-w`kTL165d z4nSegMK6qpK=>aP{~P{w7IB%`iLp3xEYIyApFy2yL(NOK^iNxSD1iC(gHYAzq-tg? zxrJt_6yEKZ4qRUr*=A$Ac-JAyP%5rwPRhkPP9Y{SCryzqMuIZ>Y6QgT&DzdNdgq01 zyvU|Ad316HU<*@xMEIPwei^I52(`i0pgm}Xm&^U!acKkYXPENEC8u_B^41A?*fvi9 zy}37wOnb!P+IMmlAkb^4OP5|L)@L4rpx=HU?th$4>g56}2izbYVo=p@`8}qW`8{e` z_dTl1?r1b z(|X3YBxL12(u{{jAFE!;=Yq`AHBBE1vC4j2de!eOmEi}d3R@`jCvOC)3FPg2GPLan z=SinX^fC(>)KemHp~(Pm;GVyR)X<9LRf&{6EDeT2Ah81cjsqK1A7+N*Quo?dy_)iC z+qK3=Qs8#}fAujPG4;u0G9c|6f|Ax8X*`Nif3O-QFTw;}gkwpX*A@rAV${)S@06>` z#V1^!ur=R1oQKG(!k~^%fy?gKZ%$(@89$BkKY#i47P6x60CRVT#1ZdUfZrU!Phe$6 z`T^4Jsf66qS+pyGicfbsC)%5Glz(WBUUQX;2gQ+OisRz5Ma~*n2Yv{VE;`32h3#rL ztIex%w+Fb+rws69SUy05_#NjJQ=+3^t?=17_fcmbt#gWTs?y)OlGe`Gk9{>!fdV}y z$#p_>oFQ9d^llzf&v&}!_-KC(qt6%0F@rtv zUqBB0h&+$pzxSBz!16EQgY{rs@msBAx6>V2e!`rH_~O^&SR*D-diVD4{!QSxdW?ok z_hKkvVK2ptO|+=3%%CLh3R>-twlxP>=uQ6=3cXO;shPF~V`6VFC z&7JZOqHgNCI+JGTSedX@;mVg_El!s$0C+7m*LjusrEeMm{LAJ$HY_IQzK;{@P>S-P zw@mDJmvRwt{bmOV83T@+AKDSqoJ@ffFIv4ZZBMo1ar(XZU`-v5U!h2DH$h6!~#o;jjeJ)HlE| zwuD=ml$O;Tzz=BFJEXpw_Fb2=J-KSIxa>(?EG;HPGpU!2J#|v`rcoUFjMRVkt2Dh+ zn{=go(%5tO`{*g{EO*YxwFql|SU~{gfaMsZd<^fhM1zuqe!I_6?FTjtlyx9x8To9X zblxniO4+H6!g6@ecC*T;gbCFqbEN3628(22dT#wa=&}p+WA0^C6F>kGiHpx?a##HA zwV$At1*Pnd-{nw+Y>*a@L|OVbL?#Xwx69`-z1tK{OTurx;Rc%WHvA$AESiz#T-Co~ zVNrZmXM612vnJ->BDgCkYEq3B$02DD^Vl)v;C9LC9i6K=>rr7QErc|2{9rgyhia#+ADbm3}wA$9T$1Uzz~kXVIIm!xnLSr?QiC@WSD z>Av=k*E(4b8$++Ds~V-jE6$8GmIzwh-#sSXznRZ<-~H{D#WwAiFOvMafWCDVE!tY%zWmk zrvmr!97Uq8WY?2Z?mwSG+ODJMV1^qGnQ8)uza+z8k1jvu;rX5}n7325!FoG=E-%dF zUxv?#RX;~o6$5f=TwjWIt=RrGfKpL6YAY@ip8n>}wdDGL2NjXku7k32;p0p#KIHDY ze5v0QS8dIjlF9;wKgSp;8To|LQk>m{>?wPY48GG1jHG0b8{9V<`MJF>Il9?v6|08# zUPl1~BPlvZVio6lAv)Y(49TiOB@V|rr!*ZX^ji#koO8=wiBS1&A-*yuLW_u znVp=ECb_qyiEq2rL_oJtB)o46>CAdYw;|0OuOTR>>P&a@v>X)xZs;S4+RRY&Zrf(r zFOVfRhQuw7B~BVA2c7{=7x``Hmwj^9@iW)N{Oj3qt72?vbU_4)AU+4c=Ba5~g-z-h zkWBz&Z`x5d_fj3o9yHyDnuNG$WXuhd39fLHO%y>&{QJacG&ozTnvg)C!wfVo?;i;9 z0M!k9RN~T!Y2UZ^#$+}B!?!Y+FGksbNOQ@VD92QxCgvj98lgL`9f!){BUs~4WH>5w zTg7vFKTt`B4oatXo$Zf!yL63*2nWxga7=q0;IQnP!SfVrGxd3d@Y*dXJzX|WCnKAJ zG^E(g=?y(^zBh=SOW$qv3~mj@OUm8|k-zoM?F|JM=4>^Q|j@pvyH5|K5+!=@1=S6jgWx5 z0xgN-7CVrSxGzL^Z}ZH#UWRD#eFk*a+q@&sZ5(wx1GJRdZ2$1XhS=cYdg1^NB=6xG z14gLa>ME%?{YDmgT4gpqJ@Emv8(Rwp@~F_WXJ-8PPq}LYE;F4_Kr{^|NI=AC0-HAm?giB@T@6?LRY!g$cl~$<=ONvE(ZbVyfFYe0+VpSet7nm3<%51XwBMe^^I3G z^?T^smO`lJ(1h z$m+P@(@wiw6$zoc_@;YT`x@LhBEP2s=px~IH6gOE)qKe0xQ8_(EEh5V*GNc7H`*Uc zsQ@sl;$Li+VMQ|1{!sZV2v0yT-0TcZZREmLyshnxXpqD_TwTmh zfAVU-Y^tV4O=|QO;$kDfS+Q`pq>SNTg(dBu{}z2IEj_#iREN7CzV+zK(V8jm@01GN zRaZRMIc?0*QuG-5F&>j0A%{nNk+G+Z{~iqXVe5Y4yM5sIO|A(Ga7`Ew?dkC#ZTFdV z{aE=qZGV>_H&ZK(6dJW^AVXL2!@M{ayT2CqG2WY)lAa;r+rpEBn1P*vrv@Hs|2sv& z0ln)E;qeBfD!REyN4sOlhD_l{_60Zg4Qo64&ES|C-bZtEQ)uoTiJnk^{2L1b-4r5a zk6vfR-5q)Nf8Amry`vwK!rt}?wokBq+r&j5#5H@Nt&Nn7m_+$|U;jj5^{&L2B{vUZlYvKb%ZpxaYH;Sp+@D>c4l4MwNZVtU(x>{mnhUq zwYRIfTP0xm?~r=etbbV2a|tmJm^K0b^l#P+Y=<3sJl;v4i?!2wyWe_61wbW`x?U8T ztb5q-a?*Q-p|r-bOR!joDm|%DVoiKQ!51w%?*f@MmE`_~)5Pnf@cTi}kq*tJ^dmm& z!&R~)1DauQhnw>Gv3+u&eUJb&+O58IuEF%8hLrX2k&Z^Efu0gn^xL(VTFhx5SV|Jr z+FbLT5c4q8JissF@G*E`mUkb3rD|HbC{}ZqR`4V-&d8OwX%W@1K;0yvGf=*jMhn3J({&EQPDDcCF!JtI%<3*q&_noeZhDs1 zT$k&dStg3i#526f1v0YzY>59R9@!L7r@1ywaVtf@sKkb-l%A|$06!wDNWo-lq*`2m z92-T$y9Ncx+Gg>Zs1e1!ns2kC-m2C3QZ4_{eE~?7^hhhTn8pBV)|io|f&~C}tER}g z>RT?7WSsRae_XI)YEDthR)!k&Y)m5mtECaW3|}s#KjTzFIu+L79QLhtQAc@NoVQnC zv;TB_MrP{}C8n2I6=%7sE3Locpo*JUV~Z8rY<0rlq8^0H@i_EltUa1kWi+u9<5svq z%{GGZo+SDRnU7z)xw%n&FqA*XeF^>Pp5-0%L~UQnQ{A+j~J{yH)UG z^pr32A5^^!$s7E(GIZs-W*XOI|0DjGSEMXBX$e61tB{xd$<+*^M2>h)FH17hKY_8Q z;BiymyX1fYSO%<~BjSZL4JA1Q8p zkgKTq9zgCr3Q+SbcLtZ1NNnjv1ft7!-Ez&2z~m?pYeB9JInoNA%J^dE2@J`)YqpU2 zg86;5xBZx7Sgj3Y<=!=BmnGz&r{m%`C2n7Q)=v8V++MOmd=oaWMy5mNY0aWD&B4GM zg7D3ybN)@g0k(4px0`hyi!D}AI<16#1!m3|e+^T<2nMTIuXv4jG1FAG2}q1sQtG7F zFk;z-LI-lNGzC0nf7flmz8{&jGe$~tY&Tt2H@BUIkE81kMN&Yr3Y)62JlM*!NOI0g zWtGO(D5cHvf-_d|l-q8ls&RbXg{<7rfdrg}cCY`jK6>zN#$ZAl4 zLiD0c`dm@m%PbW5ZMPOE)tNpCpMLgJvUq z>IDaE5d*eZ%gP7p-<_L_XfRm6##z8m;i@ririw%=k$ZI59~Z!$O!$VGf%vO+3UP-B zJ1Do^^!%EPFL7eQacJkMD+j#s0QGkY9dcgLs5?iyNN^CcadgNqM}5l8`cm57mE$o zh5I@aBbQ;qZtu!c&YwSK%&&G!49cTa@otx%23bqr51UAmUIWOC>W)x}inSklFVLla zP6*1r$lu88{&~WDsp5xCjNKoyHMn``_-=;MqO}DXPOMcWY-_&daGCm}pmp%NhP2tQC?1d$KPQe@{_m~iaZ&(Ity=5VuabIj&P8RJ zq=mj!eSv?5FqRoYKy?v^U&*7zOqeJ7ami_}yA0|HHnKOwofWw!o{EunWzfe}Ct@3h z{8Rp0c>tPTQSE0cKhkOP0S`U5p}0>o6Ea6#T}}iW-8rhCkK1ZEMZ^Oid@AO*N1b@uG4AM(ql0gdrMnf`EF%*LyTe5L(HB#6=7@Lo zgvD_1LJEMcLATX{*;w^nEM@V239Ff!JAEpw;3F)wMC z$DN10QlaCHS-}C9msQ!B2YtT9&r5OKnj{uF6*W%$G?`N z*{_^`!CB{M+A@(H4*rf+Gblq~$^v%{bcjwX9mJAS&|o5MqBy7_QhQjO`DW}Wd25`Ncwbkt|n5GYfha`}dhgF%lwN z)ehF81KrK>fgLL$o(_n$W_i7ZEXB3k0O|%e?Cg7?De=2u%O_*p;jQ*UsNTKymqZOv zu}{xjTU1|tlUKZxHtmC~yjf$uBv#D^KO{{Ld_uuihH{9TUk_r9vVa4WC(7U|DkDh; z1}zJlDYm#KP8pF1?5$>?tMk*SgP|(e_0^J0`ORM12 zd=~J_-dE5Hr{nylVm2(5R9#xb0OJE4K^@=0n7kB>o?1HU?uVvduA*9tVS#82f1b-(m4h<2Lmhx(E zS01~2wp{)I-3&FC4H`*2rC}0sk8vNix`4Op`dh8&^NHckFc@zDOuyULhPNmFMn^kC zAsq%doqJ>Jt5bFtGC$)=+epi4See91M>4Pz~#o6i$}EN!pOVkT`Q$eN6)us zPIhZ{Ny(YNat*Jp#si}20D>^SiwymGP{dfI*0DrORFzm5W_B*)j_3DIDonZCri#m= zNrUk#ZUh(=3M0Lj4Yi9~`!%b$OP4K=FB2FgAFTfZ9$8O?DMClHx0U#R>D{h-Zh z*?`H7kLd831LcGHw54PzoZi_Wh%s*;?N`h`3Q{+zzH?Qe4WP~qSipmpLn3}pKP+d< z{zc6VBY%*0tEt~wAdU|~JBiC_5dl4l&ytTn2oR(kv-A0W$BO_kw5s`~V6f-0I9f)p zb08!((p(q{BA#x zhgKjq-eUVn%VrBunxpZi;{6jq2g8mfS^N~uKhn@ON&`HvBSF^m5A4Q0Nz~!_8$J|q zZmb1f)h!_o?;=hOzvKOF9jPyFhD%R3$c^!e^J_kwzQCtfN|^BN+6eJwWd?bbRBeqS z=q>!D+iePT9B^b1<2YqW*R%eEYbo7DVpV?tS>5FaY##EBdSgU7hPmBC5+7yqWRDxvL0G`J**oMGm z&|B6W9sZ37+y$*p@hH(0@aKUa0HJEuxDgqM zWDaMO{H(O(`s6U(mg{KBCRfQF*3#bILVd0rUP%bpu+RB_r~d?G-&D#yCccwE5(vXZ zG)ebG^dD3^gy9C^E}#ral`JYdt&B&sASR}2&DMqox{~W($KhNduxIVGA6B&a#R)g+ zj`*8$m_LBtRxokTl*{lo?{q;JY@o#!3eZh3X%B?#NZxo& zLCY}|IXmLp?v={R(VE~{A*p3~hU!M!{8^ZL=cvA}-2(TV{9qJb&dDw){S(E8k{d?l z8He`q4L+P);qanc*pXF?x0==CgmMi0g%i9UgqwC8j*s)t8 z^-CcWlWX`02r&jT9WL6}5z@_Z;I7qOpM%4BWb_prNP)nFPF}^6FsUqfIdC+s@4saG zEM?K(KxbC9HC-V=`H#tSduMK8znqS0yt#M=yD_54 zi%+#*0d_n3PiI#(d0u8_(xi|GWC{SImi^rhLRF=$_W(fb^HkHXYODtUU)UuLB zI>ng_;N1uj_#Nbv+7FzfQ1QfUV#H#L_eUof8uELsl`vF_HAQe*kuyrtYI2m+KHta> z=sA8TmO<1zz``R{R+1D0_J@jPy(K#AFK#Y=b`*0;c-AyX_Fz&3PonR6ElABHiShcW zLoeo)*~Ct2!3w!Y_{iAC+=HcUj)_UqR?OYadL&bD^ZQW>T}t&xM9;E?v9aBPu`xTU z%XbPm%dF2H*X()8{!&EKl8yP3Of1nE)YCab^DipijK+1Z@*oOR7j$drHR)kyYlStX z)@@qAmJm;RIm1;XEYwOi>EE+;V$CSZ>0fWKZoWUT1|YQLe$KM9Q>fvn8u z7Wv_HZ5dxd6X12JH?45mqTK%|OYklLNJPm-Y6Tg#RA;4AMhmu{IPX4o0g6oiQ8*V6 zEZco4w!1LNJ*Zc`^5nyZzwYqXkw>^~4g48~C*-5>^E}K?E!O3!1BETqc5!d%c6A6D zqZ{WHX(5AM#1oGS114pJPsSVG@D52$ZtY?tTaLu=y>R&$q(Jg8ZJ_s2C>k@vrJ15m zVP3Sm&}Q@uUSN{cdox-bVBCqLZ$Z3(n9_wg9;Xx6U!bc6estnCYrQS=4zJy{#RZQo z!4x(vH?J?hvm(OAt9{qqpp7SB6qYthG?OVwuCn2?PtvOrfx_(^ZLZ%+dYKuz`%KgY zQI=(TG8po{UlAVQcC*FF^VEA6caUZ;*m3M2hB~=~HTFFywQ9L)Udv}{X(%XPCeXuZ ziLl@C_3hGVXAD|$9+E^U6TC$8`_6r)UqwV3Q}e|%ZeXxieH!=HxGvuD`zqc!#%TaE zi4T-YixTqfUHeGFjX+>97DmWn|KvbV?NkHeq*k2nD*$!aq*MR>1tW^~#$(eHc388S zy45MCqPjJgS3f@cigAE z3vAe~XY;@`GcjOr=5UU-6K0ff`R3xpSm_p{F<2Zl^|?YH+cV{$k$MCbq|iY;Dy`O; zz763?R@CoUO6e;F9coNA%cVi_qbdQEz1waOX=e|ZT3KfDUISW#*F`-2nOY7_2PQ%g zY-{P9Yxd&YZ(BwXCc+$MHnR3?`JjgP_t{ESsJ1|?gve#8M6knjb>bmHLt0ivN!qSp zJ3{G2g{qyue@o$QMPm88lZTuTUxoeW(o}z4)MmY$W;^X}q3E75^$O~(pKxiHxqOT! ztzOZuz6RF7P>4_~r{qkWnT)is;yrL>b7KK))6v`UZYVqC%YOqLWvUKUP=JD%88Cll zFm~UAb{gqSaVsWC!kkA0*Ba(4;5~g3L;;}sUf#!BZ0KqQ;Nn8yRaFS4CzX(i5yAO- z9>`o6G)6=sN5|kv)YYi^G&y(w8!Jdi$}9qgVhOsoyuQdTP2<)xf1r;`K273Jg8*Ry zXWEys9eRVJy68ud=a`r#BauwOX6SJOZnD$k(o+nY)TflYcARh19EiY!tiCT+ABmA! z`l`auPDRLYgjf8*a~TWqW7|7-%K=$Ug%&+xV|QZl*cG|zD3`uQQOmmNMJ=(8{ZeGG zu=?HoJ~SeUPf#EhFTlSixpmg;2VS#%*Mw0Lph0x_6Y3h%ai*mrZ!JUMS3i?9RZ`8! zOx}z|mdmYfq{vd;#yV7r`C~b90>r8K-)<81G}C6JebF3%V;4KB6&cS5%d!0WC(ACc zNdhdQvvBk4T~A$u$2S=XG50^n%B0H%~--q5?YAke#vQu4F=usu&!7Ek6DB%F5FGY!t?YovVQ0lrnLs(;94$Y9)zdHbE zaLQ=o;67aWMQuMajZ^Wj4W)TbbN2utq*$d3=_N(yIxT}B53GAdo@sZ`;oDp0y}1{} z`qN;cEY&m5Pr0XTvuBZHe%?F<;?;KJIMm9$@eJd=-xEG?lyY4!uX$ zt0cbW1`A`$b&(S?9cjBqE;y;bOQ=A^Djn3dx3)<@4wZU~)+oFoBDmc>XzAZi2_o^9 zBYv0cI|svqa)bFLr(rTAM?lV4%3s9S!+@LACHnZC*c5|9ZykxYO~|-yu9NZZfHDCMlhy5gu;JbKCr+dSSHeA6Wu|PLv!Pk=@n}&vmj24N zR+cKq!Ws01zM2|DFjiXUCEqTetTznLbI+Ee&|p^1RljavVP0p1=0IsY9O^mRY5)KL z00dTe&jDuOSq@s6P$g84+LIyU_%)O~%n!8wQhHO!amu2dS5T9&tNY{hWBVeXADja; z#2sGDtF>|sxNMgu^rOG_Wj5RHr614$0`X~f@&D95e@RlSw95g$yTx*T^fcWCNT|p28a7RkL_OFX1lEM(dtsn-Eb9Xkf&N7Q}g_643mdML+a1+x1FDcHhmJFr}-lQSXddJV2*p zo6qZtR+3BYB`}9qD0wYPO2qPA^2DdEF#wB*mdOe2ejlM~2`MhY!P`4IatpFgZXxL8 zn#xC303M7QmxP*MvuIk(v=50LJE@M}NDPeK!o^rL#DV`x15SygRn8T0B7mVFl6F6H zj;LeS=`zlyp_82mQFbeP%}zcKjVwf+k!kllk?~azV@r7Al)nmZpe?rP)EUq6WA`n7 z9uz&sF@<*7H-;mZxFm*u5Cnx_r;B8ZW2u0=PJW)2|9*%n1M4f_L5OyFjq*F!asIhZ z;tx#$Ax?~5dr}l_NLL$UfF*9>#PAhBN!r*=zJrzPj#zboEiEzBn5<|9158;!1Lu0H z!KwFvaQ4^1gWQH7>9`+Re$6esr4j9D-zRgMR(~6YGtM5_JQ8WG z@tA&GODND$;5k_D;k0nRwwt<@+|$CyXJdVK^XfVd8cF=i6>8vZ&wzRhe=&=7O9c=# z8wXQ*;=BEO+g})3C1meP40)h?5T2bDhjCQWb4<@QB=LNtMdl5RTEeL&1hf%7+Yx zE-rv?_RVhxiC^_Dc(6+C7OV%Gp`-)sAIrJ^`qE7KRqT=atjUmOnKvAaPAkTHVj^rC zD|&SY*_v3j!UqxcVvX;oQ`w0Xlw66&stX{64yG{x7ny#NK4^k=N3St--9~uP$nr$g0L%`E7IrWOKiz6oUN>8Rv;5=!utbQH1e6^ZAJ&Je zI?Cpze&t{wM22$oM)X{HWw~_#j$P|b+MC+ZRYA_yw%3_!8({3k-_Ws?9Tyd1Yu+>q zMe2{(!t=_4IIVsK9|heQ0sMKFZO|h8tVp|Q;NmEue0;)!ZRT((e!xEi@*lp`e&6l{M_U@UfJRoeYLy1Zc8GZ{e$VjxV|tc6Kr#Yt#dT z%a9!HzplHzE96cdfveVWAaaB>!4I84*Bv zz6RtMssp5p}HRHEBqEXphTI`PZQs?eYF}ycPxw%bc7)p{V@YpV zA`^WyW`?!-;fc4gr3HZ`$)CBhV~@FQ=1IjAaj}u8yCox1?^hPOx)sbD3NQz!JQ z&{8s6R)v7gE_v;q9yiKq<>?;~X5i+Jn16Q_mVY0RuIluoEUKG9;dn0A=m5BUlfBfb zOeWj5c*c7w*CTrX<9fO5DG->k{i4hgM#~%&u`it`?^t9O zzZ;!sQ<4xQC$+U$*mIudfS#cQV7QIyycLZbCqSQ=T6F_!dl^kbbOE3j)AX9X#aA)T zF8+?G@OkY~i>*_rjKAvA=7DZbUQnm0q|Us~pP8TH$?K+yw?tyUGKfPP0?z_zEY0e`4woC$I%qdLm_Xy7 zQOz?2`~Q|ELha~~MKfpMTd1iTk;}*RB!;4q|NqH{%twxLg4y8)FLyH}=Y2JNlqZ zowk$IA)RRS3*edhtUdkNk{oCVZ!>pAW|4I-hw09;6WcB0tL#Xq;e0^{>05O3WXUeI zmi|t9I2>w>zvw;lKq5CxcNX z59)7ax9>XN_cBkBEMnMxM)LA9WPhC6y$nCrtT_JNs`WV_)P&;Fh`t@{N+*Ys#zRes zKi>MW8d&j3oe-!n#ui%awl2oi9n~;66N_VJ%oVve%bai|k*!XK8xNIBv`)k?t5!?b zyqjuKP)rnertC)TQG>zV9{>2PoiIxzwJBZ)6lo%bplpjI2>(}ze$PFV#JJ5$LEku4PoV-xZa0Le10kl@vV6FADt@jYrTOFz_Y z(pn;hb3U!|P@g+s<_Gaf;!m5B#Drwk+p%(S?s_AYu-n5bKj$C)4|BK=TMz*Q!X zysb_(jfSyqShTxy$GJ1AxSASI*(LE;i+(}yqPX0b`HqoToVnoj7}+s@Q%W6dsFpl8 zA)^a)7~u&ZM_;+Xbte}_7#<9e;HBLVzBO64Ub>zT(c|`UhpwaO%j5UExUa;jZ|hIc zvYy_EC8z={?8#)eVnM|4_xNnP~peK&^@R`&uwMN}ND z5nyZ?5+oxj9?^U8sfgVa?N;B&gxJ7&SUF6Ab+!DG1a;lH#AA=}m7S*jKT%_ZtAoRq z9umtB3;2#|w4SO`_CSlEnV*)5%EQG_^XuE0k}ywzneab_h{ZR9;?<+zUm^_mgr$#k zu`y5Uc4SVt*agXSqd{17{XR?cJu*=OcQ8%trgLdz#nWHnt)V6sd0Ly7T)4jbO*(#+ZUp6cq7?xQt; z`5N;`c~ID>FP2K6o`6Ctborng*Ni1z?(r`#UkZ%tRzb+5oYn%+`6y0VR1wXk-I+`p ztl_K-w=jzO1f*jE3MG>SH zSuWfD;JFNht0NmwEpw>mF(khk`$ii}ed*7`-4GML9h@+TdAY?nKym;5`XUP(r;0*w zag?cfg_xr*?H3XJrNqFpu!NrAwl0LWJ1RQcvbtjboX>ueLSJ;xi*F{U_NSHTlGI2J-4T@xv8sI4Pf$jCBa}|Lu!iUznfWzfm9}@hMJ$R2VunlhnalWe?aB(y^COP05 z3gqj#5N4?%S#h|WH~Y>gd`&1XD|@^N8CYbgivmfq6* z{CMRWLYXZ>iL}a7Yhz{DLa{HmGc;->@xvp15MXtqVA$wUQ?xCP|aCm@mS^%V9@gZ)oC-VeJ`qqDU^_$Rkw z-28Z7UY4Pvxtt@-2ViRYQQ_$?vV2)AtM);-z~eXrWL8sa@r&U%8$a?5(S)92g!Qjy zvs3M?)c1gZ!Baw?8c%{8_I26Vv)0Fl!>ok2zYtM)fZVdz0j_g{M`x2L){1?E;}D_* zmvF0b*#}|jl5Q}!x=Nw#XyzN=qlW~Ydiu4s5vP?CyyziTbN5dT5+qQ~2Aa0?sZ1CX z1eG|+?f#EM)Y+)ddWLXg>Dc8Qev2W|h1=Qe+R2zhOtSzj4MYZX|@Kh^ZG z&}IP;s{ybluDwT`X>}iG0D(l^bsdaYr1d60iA@BP76 zxr2B_;rvdT35KvGAxu_KZ`dGP(ACsAqbQEBvh52b&_GNHite-eYHtES`h~jL?qDs^M0|R*4HE z;Jc*^k4zRAn9jA*^CR0TT^wuPExTCw;M8%ZX&v<7yWDjUhWO(R*wt(3R!zK9?lB33 z#BA&o(|XqN9QF87!ibFPh-p?vEeD!5829>L!OXZp0TUF%^z#2tP&M zTH1&EM0Jmiy{zI=yIg_3+tnuHFp(iE?%AAl*mB2}-Z^|8N2r0D3Tsr^ z--&&T|19P1rqehtWTRy>OGt;n+#8n`+wu^)lB=u5dr*TD@5^Z?3FbYG*=Ts->$Zo~ z=W09V_OAQzyt|8zsph_A@%UkkaFeS(lbiir;FTHqXRfFQzC%h-@$|Fdysn5oozC1AJFkm5&I`Psa1jE&*+N^@>eRe5S z%&Rmz)i_o}@sDa}MII(-?~23gG!CkG5I94Vu$_l#KuURBH^mL=`fHl)4oOgJaC*sl_Z)`Iq~VAtLf6=_MxYXf=6pb&yiBRCWxC+{npU<1SP|!QBC^WyT zC6Ln|Dak7*=C{}_jP^R~FiEQgm z;v@yd*PR(?h=b$ha`(=n>`fEYO6LxR$>M!a^bp*|NgwFeWR^FaV{ShYgdYIw6zW)s zo5qi8)ha@@PGRr5=_+1!dcAP8JqWGYvWDHE_(iww!y~9NFp$ekRzY0*F@_@|w|aY7 zB_F4jtIfzp22JzU4pS~EX1b8rR$m+9AQ}KD$s|e zyoywYqfVxw){cjX26?%i%*9~@wd9B`HC;eCpqE0^pyb?dJ)VZkL|-XB?RijD5WPZF z29Kw|+3ccv;FYIya)cy(G6)C!f}L$!B1 zQ!S#R>g?Lx3HM0X=a&_}>fbv+AElSAQm!yM>%cjj>zp070t7iwk0JraQXh(u7b1k8 z4~g$B-r&|Gl*ReGDz$~C7Dys>DtL&e#QDd%bF**>W^~=y@-3`h==fHJ;eu-==`#KHBSy zI*t*CQU(XewO1y}I>?>?K|sF0eLC?2AD=^`ZlMebS-+rx;kD=0F^AaT(xrlgr0F7Z zu)J>y09+$-nQ_vPpfqV8Qc$I7Y1{y=-HC|GacD4gX%g`I)eK&Km3~f!LFgH{_j0sRV0~0~TaJg_) zb)*z1Rs}n6>FZ6T*3B)ibeRZdZsA5Ko$7Hv?JFRc-6^Q9m>=bPB%sR7s|;K6J{_9x z&wHyFxX)smTcT!9`Y`Dbns3$it0N=>zMZI>umjl!rPxDO4e0gv*l>15M_7_JP~*{@ zw2W~y!sbw-)Jz26tQkv-?^Dc7YT77Q$e$(f{=h+n5X@oX+x%(HnWn^C=Va7SK$Vhc z2Hyo#MLEpDv&kTlHxNdr9HkIxh|sklnZD>bc-+t230H^+gG#THV_P!W1nhD7Hub(J zBU`CAiJ!y}4sR8$-y7~PNMUd}4SPh)b)OF$tKI;R#)1wA(o}QILXJPbh>!lN4$(eG zm9=vb&B~(=t}}#xc`5@aAb41AmI+m4z_yN9Df`sOX{BJm56tfYq{D>4XTADBQ$1=C zCl`=JK^>{5)38@tPf4d0+Tb+OQli*2xqokTBb-9V6ckUV_r0@|OIWDc8~~z1OS~yy z;4x*^{6tc361Ve~WZP`#%c?FKeI<|}D+2=nTox(;aA2Ht?n8r?&^@mv0wt|iC-dpl zSZhg{@W7+;fBxl8T)>WYK^16_O;+GVBE{MDN@r9id=lF6-dUmAjZ@3~v%nC26ePDG z&OGl)q6N~`UfD#LKyLQkbDGiO^I(Bx0%04RNG8-UqcQ9HCMDSltzb^NP z--rd+T|;Xb!DVLte&!>G+pyx5@&;?+Il~>%0Oz_^!B6wOTm#BvrzN1FB^lxaOa}Ey zDVl_J9KrWqA*;(1o6NaK*x87_+mk;7C%~C6sVArCQQ<*U(_N@eq!lQNJcd7N9F-*& zEQG3)!n9^Es@PVQvO5V(;VKj1!kyrRmsS9;PiQI6=SzA9_2SqtV<`9KE@k=6#ln99 z8L>>`>QFi6Y6Xh@HV}wt`Jd97;W`9Px~B-wSPGmcG)D3rdGAV~bo2Oy(L;4m%S+>( z;nI1HO@y{aQ6<5}idccS+${@%P~y-8mbR)?WZMH8p+LJR50oM{Ja>dQ+E_L9)zs7Z zE3_T)XXH|Hp}&Eo_fBcCenTH^QifKh3VXw*lG%vWwlgeCX#0PDA+8|%#s@$o?JLFV zGF2J(2^5AI8y7$q5M?D)eG{lo$gsie{!}X%7Q>jx>J1#ATV`Vn#RyR|8Io>u#-wpA z9L%W8z0X>dOgcwmE_}(WrxP=gKn@ZWEw8)|jOro>rgt%N{G*i0g6<6En$)R}!jqoU z+E;LuC-#Jj61GN$keg@N#v+g}m*6DaAD*B<4h8m%Po0w6p`+&W%7TZ!z$FqKo_hoG zM+FrfQ0XQm83w1G047gBLv#JUo9Q4F&XSuFlu+EeJ}2;MW1R_&=H`4$$VVJ7QAgR> z>yc>21J%oPBNepQ$6`rRQSA&2#2{50eervYby9U-vZ3cCa@NGn*~?_`SdvzxmY=e7 zfE9L>RX)97B>gAcwj#dYK;Q*!_FkNptL?`WZBAVjt+c;&`A_)FXjYdEthrvD0-`G- zppbvE9A3avx;U_Z@NSwW#o?E3ROs3vVvd4!aMP^#_!%LJDT7)mt0xvz$}q8}eAN_N zwbe|HDb4Ob{s6r5>A+X?Zx3UlU~@Jz{cB%VPgEQy8?EXXPp98qyu_p>3pR&{jTz2= zuxW8?^CjR@ma7d@!)61}|K8~%^d$nt7a55vv`^D2(@}({Jz@uX^4jnOWo+*av@-nb(+x5 zvtW`*sMK%Faan(k)b%D!MM9A!w2!CuVcAP2l0ZdLH{1?>!LB}1YjzXzuA1iFTa+eK z=&(_fv1^xulVqUgh#BW`t>$uAm7Mz_3MU2JPbx>-BmB&uYh4u<(t&s~aT~`VKjr{0 zr%8wa+h8#Ir)z- z;6D3tpnpVpZ&0f*KZQ`mSu8l(NrG^Jrrj5am(YGVOg!dqiRy2Qe!eTyIDARLldk1E zK)yVO!V@~E>z;v(evEEkh)&2GEfd|&g>RjXf@$Ueuh;J_yq(vu^T)0wRk|P!qpLf+ z`i~&mr&S!bh}f@-)l%%d5<-qs_)R9JwUaZJUlP_oY>%` zsg&RSm<32hRv@uvKA)M&ycwQqWq@ipqNg%%H&nUz^3oH_L4DcYv-_{^vzO$Q)?RPi znnO-GA}jk?;OQIpezw;GInc(d*!!6?+bbit=XisXmGK25gnO zYsZ}os-yB`s5W1&$V9}!%Nx^5sPQlPQnnmobTl^0Ogw2)U|AGrqYCLNV4)tB?;++K zlAG;vfA3GvOxpKDE?|8*4M$@k>4CVIOV%v(whcFumjbp&5pFVi^ejYn9n@Z-Yx%p| zi7`uQ7nJFfP5E1TO2G0z^z6J*eCpc^^vkiSDN>L|ELc9hQK-E(P?sYb;qMWrW{aF6 zaqlc>P^ADkuYu$cl-<5CD_AUxuZUzI#-5D~!>WF}yt$R=?`I7j4wmwCYk}8pihmMp zcjXAm2?N;4qH5}kjzoGB(#>~~z?@KLmk4i(C=KZ0c&SdZfd5+x5dm@-$=6*lPI4lg zAZ{PVTGIe|5S4cmf&I#kHSsWq?dDkS+;{TL@N4KUQl*kg+j+|2n z!kcLr<<^tG_CN_u`+9XAB`ChsyQoD%-+*_!jKuioGHF4K^}(0(G;V0e2BJjP4h>FJvxeDweZH&)1~|u)7BtW94ewj<&T806*9mDmp*EyLC_gUzsHf*0V}gS$^)nTiy{9uDU= zUX%GR*{AP1a0Yc3d}k_byrExsZ633u-fzK`I^z8@)+Q%_1SYocjRCAM)2_=&H#171R(-#jI{%l%5byfzGyIl0-5^(}XET0%0Y+M{8Qo{F~R`X)JHI12RV-P<7kMouOLb<6k zemvDuzEF?r@pA<|Gl(oe6{n(O~sDYnK5*C4tnEl;H z z>@^l(ibDUgXex(ZoJYHKthzv3(eFc;rYIuQTMstRA~g>NSK@!G8f!S${sbb)xa1Q5 z_OU&9l<~w8SKEwSH?BcD4=F=43icj9OLmfnmo}c`7z~+ zX*BKkWogO_Kdv?HaZ1#_L^D4O*#z^&IZkDiUrZHGN3-yn|SJdeBC5eF$zuW-C zG)>78*G(z`J@3=FfumCewkb3^6u`tmrkC#;hzsVym1^T1Q6pa3i5gjNcaCtz6^rb-1a?xJz!S73S#KR- zbBR<`$Y_dde-yPEkA^=PMLodKk&;@htXb7-cSa zqGeTss`jY$#T(<~ylVFP=A^7jT-Q*!UjXOu38DFN!Ml^`gZt|3Q>+BGmC>h341iy& z#?_5Jg7N8(f*@#!;a+RU=ipvjo85wFsDwPdmgjUjEMh?_T6h3$LOxZE@A%Ou0{5*7 zK#o@!{^isSNrTdYdB^+kA>wf?@^yIr2GAMW+8+Wk{CEx$zv!paERoan3=+{q6vuLp z>T!>_sgNm`H-j$YX<`K1ybjakK)Q6L;8t^gC$+`zyYCljpqD>L6D*~c}>p3B7;3@XH3FU zr@?pcH6X_c-RNOj9%D-3)xN}@cDC(b>7#2lwkuua7hlyq7sRF~fVASporAjPxMrnR znhD+daUED9ZUoEZ2!%V%1^C)G4^A{@u!D=(@LP5i+&(`*EA1T??|Kd%OTjrSv4$zl zG?U7>^e<#|+|87}g&rhHv#gB=21&&aR&BUX8U**Xcdr~3WL>iNB23L#pm{{DXP-U> zk&2`Pe|e6|+Mts7^;>fF+|x2}>6oNF>basH@eI9Fq%*tB^t0%hDjj0$F^{>|FaCu% z5q^^ICEg9aal2DmiEiFw={$4kC4!!qZ52hf?VAwO?pFKpIS4t87%b4=!9)DKjnUnx z{;AOLas}#4y(c2Ig#h7xCkF87f3Q0`}685H!H!8~{7dY#y0clEd4OEO8j7^f7nBP<=`B_6vSWsvYzBR+H9RCF!!A69tYEnA&OA#3PloPasR;V4Mc? z3&=gM+P*u(L6Mz9icLt3))5L<-*2!{iAO-lfp*R*o$3LMAOFeU_q;R_@H$np?P%tm z+5yw`*7Eqh;U>5SdTSQE-0)S{yumI{c}UENO{CNC_V_hO~0cxgZJR zmbd&s0~7bjgmZJ{S>Zs@zAy~dc_bD4a-LpI0qBscd4n31tyG(Dt2G<4DsH3D#aD}^m@Gm`q*@j_|21{GF9ggT|9$4 zqMikhlgt=^4R%$kM9_!t~6w5RLIdAa(kSp zy{bq-8YtZ0Ftk_-@X#6_u(l*OMMR5TAtmtYc7_+G*fV7~Y6rfkB41i&HE^E4uRk*B zS~~qiwr%)c=BN~#vD!x^X{%FT74vS1MJ1G*cbCPc0p% zQieX#yGb{VTyExDj`G&3W7AQwEUrK9!;He8CHfu{X0 zh@E?`BXXU>L=o+4*z`zpv{gPeY?ImIBrf}(%`!n!hVZi9Y5uGW2@bga*)p8v-X_c)>38fH+hpggvReiwz8W*BD9rFH}YEy z%mz=iY6M7Xn_q)Nx~UJ2w9Wdx4MjcvzWp+20nP)$ZGQv*;;a3U83(|TV7KozqMbtl zyQ-OOIjgTkldfMj-}QwKViQ)6s|INdTM@6TEQp4TkZcetPqpKx)Wvu1^s_Uu$p5Ik zBlYe1Ql~Ot{FLp&92<|7)3HvyQoSzzR!T!2Tm0^;)hJ` z3dW{N%I$G`R3BbgYXK=IH1n(PA| zN<=~tX~u68M25gArOs&MB!(LQHxc3ZT(CnOXm;Dqq(m-Tly&2g?}T`lp8-k zAau<>%xcQXH|#t1f>|e@!bs*UNDfrI!d8{|VPK|7S= zGMw^!X`+_OI8Uqb1VXT(;?;4>aQ?L@Z2`}*cDKF99*oV=@xpMPu`K$T3>?tat_*)n zg^^}Ha%9hAOcuxRLvMN^Txn3P z95Iu7=e->fr+q3q2OA*&ez;02iy$n4-~cC3n$N=}P3&B~ri`4zYYEUnk~Rle*_!R= zlQug^Rv{lW{8}!VAK5hMpL7gCPB~STBKI9o^uTB3R^6>_*kIn!QOYyy4-(G^o2}fp z(`A5CIANn0luIP=L?F=(jUSRE<>J^CFSni_Ckz3UxtuT1D3hz%eVDcGT4UkELMm5A zM>P}L?stO#91qot%E@KrwSaRzUEH?1Ne^HtSX6>2qFkg*TI4+SLjeaPvVH5JOe?Xs z!U7U*44KQndcd7hJL3a`%uE`2auc1R?3;E>iUw_aYt{OCy-IoE%T}cKzu_uL={KHi z*mGQ%Ak{!F``@EJnm%%u?rsR@C@3-QWWxaJa0yD+zYC zkNLKcNQED~jC$VAmSDy^&9+bzK@t&KbV#vY6XfzY&D|!My!7Y9Y_l4;PGZ6#JUJqK zVFI%)7V`ffek|^z)qF*?FmvFuvhtL|W?NtaQ-c2Ko%2HNdELp?Ua9}Ec={z6meTz}8 zu0OH-22W~`2S%n)d4r*#=>X{w2+0qEtQvELFSARJXze)KgYV`QUK-M%lsSv$eDC#& zmB7j@$S0~Jfk>T2X9TxNsdT%MI%hyWrNvfz3uvxT!ik)bH|rQw&`H)*7zZp@XIXI= z3+Jj%6DzHBR50aAtp}~#HZs_zy47P~(jo$N&?OR$Rm!U(1tPll)Kf203;?$>>tZ#V zOh(ITLvi`dSmi6CJiF|Sg?qAmPq(YTKr6D?2|X^E_8R{)b>7M5F^dLcTprlb3tZ!2 z1dWO+49($FlqLDTlAr1ld*R0TBsBqF)7*}$s%5k(w~CiiR6h1dEy7B2O4ONtn)q=F zaQrP)_$adK8507=t&EQ<{IRyHPn}FI?b~J6JT#D!R<+(o=e$J`gUqBrx;VtG*Bw37J2IL83##7t#FLZjm(EwCVDw=FN~ z=K5w+Cr5}fF2qReF~JrRtyO1X|I7!iKyD*&mNTgorra>{?M)7^-Vd|Vn9FJ>BDQypO%f^G$65n3H5Y!-h#yJ98W55 zi>0Mm(-}u7++{XoFxb3M4S8Ahzo&dg2EW6*E}*Dm4~|<9le=aEcxU{>MB=LkwwFU@ zBDB2>7wzvAEFcnY<8WaSzk?#HB_EGky|qpLyoK02hL!oqZmJeH1MC2&(-K3{+{h?b z>fmkNeC!u{&3gtPyi{Vs0R>fg*Ev!AAAL2D0s;!x0MW>*vMVnYTM5Bp-1;VjX}}1} zE-p`Yk`WV;lPUCXzie!6aN%e?vrO z9Ij9>?|dWO2FoD9FO&TS*CQt&O@LtfZTz5T^F6~q%2fX=d3tbw?+i3aJ`ipggtt7& z@tT!OQtba`l&6nCuSL49`((KQ`yJ3Hl5bbW>)rk3qRT@l%>VB zKYPvgO;ddg0UGwxsLB%1nxd88B`dq@BHzKL6!+Sip)LF%?4nbK+7KPsf z9PM$P(p4dVI;k4M^GlE=j)<5rm?eo?1I!c_^x)x59?Qov_!gzOc6&<96K|`gB&;6@ zRg}r3d8Gouo3AC9ZOB{myqi4%45Y)OtyHc{Mbj}yolMO&D7Tiqzl6%f^F*dAX1!C$GBNy zTrdb%F9d5J=$RuucOb#Sl%4BrR*);AeaSc>y_?RW<>{)R#1CP*sN(tj;KfMcF&UXj z_)E1Q(2g!K^d+Nt#@KnJ3T??n8 z$)6*5O}MOk0B$ldGj%;r9=`y@++I4_2k;G}dCbOQ3EIc@1THpdtB0hfNg3-22N_Fn z)4?y}Edz~XV!N@N^_?V99rV$?Ird)+ zORi@+WU8Y`WI|z|oc-cVSvGIREJZaPS&>Cg?s-B0mzj6LHC_%8YT%qwHx1rjc$Z8F zIS8bVa4f(*EBV#8WV(-iUFR4 zo%Z*-95VlvcdF`jRKprl=zsYZQo9=k23DnKN|clV3AxihQeD}Liou#mhAY_wH{1IM zF4DxC(msy5`03EB1ch0@e?c5?u~_yhuev*|Y;=gx5vL@`;%BI3+>txg&!(G1U5GrF zQR|giGfS-dUM_FeH#{#u*01zg?z)r56~8 z66RR5MSQ~+42z&z8FsA1s>>JmO@=aS_@}{waWIM#X8-ah+#;PAjS#;d3cEI#Qx5Ox z%yQErrxlYIJZH2EjPfA%J!y5MUWN5@^q)S5rbD5CF||(*>upLizi`3-elh|o&{8s; zTVH-UV32-(>?4%o^Qxsv{2H=JPTZo`1A;|vUsf&?|01r&G_2D)@i}~6&{R`1^4#g{ zJrPJYB3DsLodOJ~`rKQ5%YufH1Qg+iDba<#}0{oCzFRBOG{Qjo`KWXSD0LRjud zC=Q%ZeWMK-&R2ZlZWxtoX?Y(dr4J?@o?a3DFM81J{xKpa6>Dk+vnPabylQ)h4(wY% zChy$oM%dhn?JReKSi9J{)0MW*kM$1HZ1V1k>4)rT7~W+)5ri9vKdJq??MIYFgfj!t)RCj9EtyWx#+y{)$cfyj#R$`%c~{#@Ae zp(>xG1GCx9Kv$ZuzReFq&amuK4n6qIG{6#$s7&97%7WU3N8>LU_ZQGoxYk`Q|3Ig}C6 zdZ|?tjH1;5ltXeGWkvvH1RR0<;?PWnLloWWJJRSohPIUjMW!V^`pivZ4+Hi( zAmU{e`mT`ZcuFgQ zt=kTb8$aXKFzwbE_e`g9KWkI@R2SiqP8{+!A8?79jCvsyv`26anoA(lBN%5>4_t^; z+%;KvVbL`rN753>_|D_Mh;ZbW-Ta!|7i&on!R~yBgju@!=%guBD0=$s^j2VhS&yG3 z#rSww@pn$ichs@6RvvC*({}MG)7?Nsf995!tfdpKQduGDVfdW){x^ghcYfr9$-|C1 zDp-w@?iz1H8Wv-D3Zg-~Qxg}k&>qxfO;fO#c&54n3w?83CvsT%Kp{D35b`1iaQYGO zftxZK!y|8im?W(rxMB)*%KYL3OCh2~3eblBlUmq}Nby}=D-`HNG}g=|4OI)K2{bF* zF8Rd0$-K*tq~NJaS4?ITSJ{@4`zZ|`*#Au|b)>$%xVH(yB$r-Rr>?b{&sGKZhZa>u z+HezRA6HchDvN9C_Natm8988Wl;c?A5lo_q$g;Zt)?IES`@Y~Ca{v_zx^NP>Nr!HX->9z|O8Gx_SK_ajQn5T?G=HE?FJn`uySen~9GJGCiM zR}11^J4I=ZqI9JXJtt502_1*{wNw+UOH10HQC2?zMRVP^vIz*Dt|!@bb&_5erE;0B zn7W)M>5n{>Cjw+BW1tCCASjozkr-ay%$w)WFTx=9hHBeqq)?ZS8pw9`Q}hVJZZ?(& zt>C~E@a%Ai^Ku{->R#p@TY4W!Jmq>$~?s;vZVG8>eOi@kP@Z`Xs}gYhkX;Vktz(EVNP-ECtT<{A7M z>_a(fXuhgMay|MsyY9P+TWjdt|L!Hzi&4603aRH=+D{waba2HNw~FW(eVQRFtTzwxzH~2r5%n(Efk{F8I4#Z}-I7 zh4VIf7@ASrv!DGw!N?G$m_SN(8G+*CN8xaVb|kCI;M5RZ0&=DRa} z06S21!vup0QX#Pq&@r5axA@yjEMN6dExdvsCL~ba_}~Q-R+TEHw3Xe0l?V_n%Pfq! z$9~HEE(c8Li+F+8*|veOCaq6i7V~@wKw@#2d-^A^kl)4??`H5BS;sFm7Mc|*y!Ewx zWU}2r%^ADbwvenYW=2TAwh!_D6>{Q-ghtQM-M$Ai$KhG5-d`I`6O9qW0!B^gWS1_2 z%ZGbTD9-ioX@-axwVr6akP6p=nNa%%!Mm1q#1gDO@0Ns0j=MMQ2#m`jU$ccIC0*;! zPH}?kQ_@`Dl$H4#-ir!bi{}p%XJJ1U%RJcVl*Fno7)kKEN3S_3l5x{Y*UY z<})lfxCe!JjR=I%3KgxamKXE7*9raK2-_+xkLLB%dn) zAf<>8y8p>0(BR`WfkWG!XaiNL1&Ws5slDLjZq-v2n*iXBpsuE4nc)#NvG{s(w+pct zw&zkh7JjUYEb~%VqTO#i)OPwob&Irq57EQA3Y?B1<8wtI zKk+Ud6M)lfP2Tt=DuM>{D{5&m%;z-ed%7L9(mRL$Q6Sh(&OQ|jBO~ zFNh*$T=d}7DAGC)FgCq0+4gbX+@rJ=4Y~LXkBJB!XbMZXOA4 zCkDW7U1F3x!}{)=AzAnNaX`R911%zD9Ve*BA$|B9rpV0wWp73$ zyz_>O`}HPTeYa}1qliYt9+@cTwmrt~_i$HdSU-!$HTM}t`E76Q$ZdRJoNvuj0s=$8 zPfojble<%~u;;jHmTx=c|G+XKSfPIWf|e1aJ-W>!eUPHILj){_HA%Mma)lQBM_CP^ z5G?n3&GFqM|GLN_8~iPC3E8=8@bO)0$AunUKJ%{5GX1y{pJ`oDq`Q%-~wGr7MNExH)m<8{DU#;o6p9LnHmxNAyEffL+Pz@KXn z1>J2I;yW$cj2s-cXEPg|!NYoiR!yGHdQkA^S~Q2MX{jG`HELW2Q6kAwH5}Ijke0&Z z0c%fHSOel?^{Ezhn07}cABwyTemCkQkdwS)Fo;{C-?&c;WBuH^FAYVbg%56^;RYt@ zcgIHJaReSH%*8klxF-NCuUT`!UZR8(lfoCtHu`8h_0LVT=IV|IfL<*w-aq{?cI!+; z@Bj@C5qxDz!$)L7MKe~tsSNnwm3zyf5fwiqJyXCcp9Px^5hq$M=X;U`V*}opb{zW3 zVU*Xiw+*=c7ym|OI}*W`TLEx0is9<8S#W~`x*-PEx$H_W3=vk?ejE>SN% zaps+ju#5{~`Ml>3B9WP3*~&!6pZKXuILw{D$b_OVck3YhkoyU_326nKP>K+aOO4N^ zHbFF0xFFAc3e?h1S(5rHVtRS!^4c#R|03cQ3xFfH9Rg&yVnUtg)M$K`V@@Vgm(j{4 zSc$Nkr2S@X#>;99zbKkADXu=+EubPiqdS@S=3%OP=)55LVNFo2vK!qGm*KC9-KQ;# z(8&vX`xiWFrGyG|J7w5TzLG*!&FVRPe1W~Nt%em?{zx6wdl-^5JzKc(Te_RHi=VbJ z=uy``oR^-=vL8*+(88_W#iuo+FT!HNyXj2!0A<-XlLTAgPzcU+RJaldHb0=iGT6 zpG<%X_Y*tY9*~rbjp{1N?!JW>X%7)4?S5x34~DeNS*lL9bko*Bz1X*cCy8RQ~RO)^ZrnY@X`nrwD8XN?Vzl*pIa66{@-eqCh% z6Mdq`ws{fIb_7x_Kk11eb-A2qU7adIt>Uv!J9DXwD^p8CCa%qTSX)X6V_5oIT@$Ks8H^LU!fMeP#kvNlyBdw<>4}i~K;kg~?cWR_ZuH}?qC4ia6 zKCqBh#z0M}cfqDOI!tIjAz6|my$Ihzge33?NmbI}$a3g2@Va&aJqcWuCCMBSSE^>E zLEVOPAEZj-RS?qOE&b@-1d@|#;LmK{v}tI-UVbJ&_0j4upJZI=Jk8JmMw{3~?UN|z zm(Mv0^{1WEmoc9+secHaU+lkvXWSj7qG$`96JYWuae7m0A_4z$`8{YJMETd@*ON2z z;fm#&K9##oBz`}XQeb@pXDaFVKV@`j^;cp)y!&3`vYxm`o zyN>KbN?)eoj_EbieO<$g76EAJYTjb@wQ&Y@U2XKbC90A2Nu2m9%;_08QPZQ;811g& zk`1^-_A10C1@Hg6!{9>!Ohn^s#`2a_sfC-LqmAT#LCU}`hW};+cHwKcG`x#6U*%Ga zXztfX1@5jY1fB?bCQe}E2y5_ew^^bo6PNUAO3J|;%hEC{4AV>=i(*cetP+2#Vpw{o zcoK~QqIgdFS;CuPB)ChIIglBR&#)pqsx7x2MgEMS4BV;W4YP@@n&T&vb4$&*URf}E zvXHOElqZ((F*$CZUjF`s3}ifKYGgvP{&=>3Zs%{>i zBYgp8*xyn}e)!=7Ll3*Mk|P z6x?f}e*=kv@OuM<%j=gy=ZMDLm+Qb{u&_>Izaj6-_?c7D?SUJmzf?Z)d>^nVJ9IlS zdQ56nvmzC%k~_s46q?U-$0c?`f8DRg+LnlV+6C1;E3zGj&CZtuuWf${DIWU8aQZI?tX*x>^4 z%d2^yZp;3J5q*v~iHyF%rRPIM=JKH{ z8um^rN37YCb3oOqyt_KStF@|w1recEbmh2zA?@ZbP$|P#fJR(pqykh69`>y~E5zDM zA?E6QN#h_d*pxsCiu|4PDlYba=fUX+TCU^_DFGKRrXkmRckfGHFV%Hc94GZYFJlyMxCABJ{O zzS#dblOgl^mFN_2OXXC3l)$3)lW?26Jw`9bIwu#l%w>+8&uuyr_7(8s7iKrYAB9gfvN+_XP=ZKFWrMox z_|!%i9jbAwEHSlaZAC@)3d!D0fm)OifJNgRrW=?-f_9p}HJ>#L=MK!CO7!+%L=wv# z=KOgvo1RkaUSgbx7mar?_NfrQjXBkwV#6|lVOR?P=t0X@8g-#LJfHHFOkd*~YcVq0 z+{(C$4;uE?FD4;g@Cp5O>C@KhGdeO6L(c|Pgzcv9C>n|O*$Di@B7D&T=BE8s8DXcz zs5z7Jt+SOv6OCMXza}ENC_FVFRrOqn7k_s_(nAgwbpaqJR0Q_tNSK$Pup7of;_o46 zC*x_^z8(CVU+t|xTDFULjgs4?+SfJ#tq%IilC`zR!0OacO(F}`TuR15D?s%_0(9h1 zsL7f+q)0kng1d&0_`{wYA?WbepduVB7GU z_U^#`O~?gKLR7lETK_9I#%Eu`&vm3F<(5MlCmd0!rDB1GO80^>Z)jL0nwoUQB6X|y zE5L^n68)sryjpk0Lpq$&>a(P-Z)&J#JD?Ow7o*4xnGX8L(Bc=M6+=Q8dkXDgU>_(! zeQreKT)=KRfy~z_V!mnp!)!VLh!*dglogY*+ zRc?P%ZP8z5bm|q<3jPy=mo{j!uFHcKRc?C|J|HfkI{UlBp>W3$^;ypU6`BP@T8|kO z^;gVDJrg+BN^hS?O4=$Ah^mtLoeFd)lEOpBLN|paMleqYa!iu+gYqIlW{JMuQVl|6 z0fmy^aQ?H5XFt&PhNkIiO~DH`V%ncc6c-%97^d8Yi?as10j!xooKbk?L_Bvk{?fdX z?!Xz#e>91!p|;hq!bjVnTS;r5V+YaNZU_C)M&adTwxUSgSp670#(@@VdBto;>Q3I! ziT89nB5@_q?)xI^yeqt6$5JYR3~m~+0^yFjfz+{p*Xj4CvcE=3%foF16_vLERmc?IRP&gcG&6J2!2@|Jf_di=7ATx$Fc_j^Q ztPQKlJ?5<)i}ZxfMzypyHyAKJdp{nrQ#ViSOLxvNcl;6qO|B7x5|X}9P1$Y`e?<4RjM}Xu8mx} zb3$H`cofx+_?|7Iyv*YT5F3_nGq|spROWXgEPJYCo-bo{s#GQ?ck}%jC$a}TS*$r_VlrB2?+ zY39=v1!`O8@oDfzuGHvXHcmz>|Cz&33qJ+MeIvGGc%}^AsLZT)!O3#B0jhs|$dz5|mEf1fEk^U{@#FtZMEo3PE!qoW-)D(}By=&8l_cNHNVc8>da)gQ~V5H*xm*G>^ydHbLD z)Rc-hi?}?YI%Wy|Vn@&~4!6q|Ll8-CZ9nhdD2e10!u^E=Xl- zlpb6DS=W1BbXR~`0LYVty{%%+L0W`jr})sU?79-0!Q>k-_hD-z z!*p7^ok(wcn$Q6o8I4Ybi_pPJKdF-D)-8pI$ggOhVSrL}S9wgaXeS1;H77+*Ijb z{eZnu-nT78%T@R(_$n0MR`C+h9Amz>(8&RNvCkdj8OZu_?D1nBgi!j6jrc-wG)h|> zDPlh7%1G-m`4y2CqB-y|fEbwu-{976K2 z3Hdes%GoV8cCv=8W2G|91zJPj=*wOxkD-OnQZj7#XnJiwv^5G<>mvc8GEU=%@OP2; z_0{{%-&N*OSqvfvM9f+zYb|r9z|N>{Tt%vFLx9JPk@*X{$bffG!|`g|3FT(>zI1>0 z4=WbOozKioRSAD%zg`DG=t2;UQu3PV0RI5j!c~EUQwdJ7$~J;D3!ks`Zx}>kAoYA{ zBx0$&A)t5%@b$ap%1K%r3gS%?2J-u_EX=M4nqE2~r^ZN5VC?1$q$f7>_Z>vRv4?Z{ zKGGOjlk+FN6G(_4{CrNd#&KR7Lahx4Q}x=ARVU`Ymx}yUrvOVpw7)?5;`xo(%{mp4 z{8=H3MBGH<^-9eA+phWL3mf;=7DuY=Of(81k>DAuj?@8cuaGiTKX=EiUZV8|=2vB; zO1$RC^F((AJw=qV(dB?gRuHm>82?WhD`Y*fizThz0~@~cIRPg;F)*5*;O3UW6np*u z7}W)1)Nw5s`F%Tr0)e=1(frR5qvZ@Aci4i_&CeNnw~ezU9GBW5k#Pw;H=Y_UQK+k) zEV;`$0FRK0F5^HW2w#4=yF!r;-Q2G_xUo9x)mcFDwda@@CSASrMNT$kd!$FNW>j_x z@8Q;=c(IX^Q`Lg}8D`Z)DN$xUvm|W(G~$yuDjm(>EjDM7ouh(saJbq!{yZ4UhO;(U z`FsYXfUm9=Nt9cB&E2gHxU`si4HjoydROh}86|vzZS-vj3tPbtkg3+S$gWV@v)-`u zjkm=vG4j<=xY|~}y6W7PcdE$p<2wE}rhFtjG}KgM8lj}d_4SU!6eGf8s6mZl;yrop zTU4x?zu~a-b$WM?RGDFj9F;yhtC{45@5jwsOQ~YrbT1pyuzRipU`@H+m=Sliv%G`h zmc_3?ot}Tp1f^}CC7&?bb;;cd1Vo~VytlIZbAzQc#j-8hXNwCI6U`=Y6tajTy-b63+$vdZ>+z}dR^5dm!Y2=J5dD7Pb`F9?X0AH zg1o@ycSZc@B`d_0?ShjK#JIjY*5mB7VMnZ1o@IQ{T-iPQUNMX1wAUxyHw%>3Z}!OY z+{QX7&y)F!2#8JtaX-59Z=-WP|G}c(8lCp>j9FzCT*|03v^)iHSw#>(~(cRX3rkYT#AHRE}b}^l7d4)2_K|oj0cmAm-&sz z$HM?1_cVU9Na*dj2kL^xgVNGR@vTCIx3rqr|4PkvFiZPuo&B)bEApU!QeO1E0HJ*V z+D%TEsPZ#xTw4Wm`?4Uy+V%nb1s(O$n%DL|xz@f}%tgx=^?f;8G!w+MyXKqe3IDz| zc0Y3Ne&QB%uvBdN1O7d^Yk$5lYR2tu?z(r$^$}CXf3U!ZCr{Nm|*eV738ivaq zN+m8B5HiqS0!Wz$solxh{n3T56oI* zvMB_lj8tQUDGO%(6JYwsSVAflmHA9L;pUZ4oL;SLI>jWdng299m`r83e2|4dOzU|B z#^c_K72Q?f-4j}!6kjY4Cn-x~2YBdVD+EIXA$L;99fn%d^^~$rt556m*JnuC;9`ET z*1q?inVQwVw}kTp1b9s}lM%Pme(cIT2O5vS1m5?>7xOBGI3ES-EFagg;Cq1|F?Q^(CQashk zE}YpyOzo+&;|k3Bz|C_myW;hnILC+a=6S15-ZHBmF!Fl2*LT1ELRqTD1tK%M*8nTC z$`GRdx1YqRH(Nh?j1C<$RSZ3G`dUQR3r|o4=@FY?QY?Y#)13xm^%%D`?gREd{ z9P!4jy*C^<*gy?-%yh=L*&nvgrg-5$>mf&<02<=2+u z=ME0v`*hB(J2y%%Qb<`X5al!+zYMThO78IKB(W$2-k9Ry+UkD{$E^l~3>g@~&>y?t zaH}qR;`}F)0iDx+u>(bHHLr1BfQ{PpE0C(c z*xdt$z4{5vDyn@Fr7xk56(O1^%TBQHZsn)r>FoX&j3Ptm9H7;y6p5>|RCu@&5Y0Hk zWu>_;*RD03D0Y|RY)nsvYlwE5%b?)lrLK+E3B2jiFpWXn45sWX^d_VO+dlm=O=n*` zcos_BvrTRNUDb?MzP~<1mX?pi7l8NaZzSF96(D4TE&)F&a`{I6^irnw>Vr>3cwVT^ zgXLLB>x!!VA#ozg3GnymGj)9Itq8AvH$^}`f$$R&f2I3`Ly&3+wLT5VBVhjuqfOV> ziq5lTT?y`sVE$Dj!PN%gM#G6Pm1-PWp%ktLk95KIew!vHwj$4^MddI46{tuj1KMu` zAh`?oVu`X-qLBhKt~3VBI4J)Zz=EM~s?cv{)?Uaazpj?#Ar(t2W`V%$h&37y?m`F3 zw_St2O_lwB5o#bXa%mV$asz-r_3jBXFl$X$np4(OBIlXz)1KbYvVP|eD;e8|JA^ax zW-MUc#-+LdZ6R=GBQC^OId$;b`D3k*hVhf{)&F~m@LOfNC7#YOXraV>-9&-5VDf31 zK?sAtbPzL7wg03W&;yiC5@CTs|E2+_deLYyDk4T}EJrx^HE8=u&y11*4LosQyGvvi zjVF55ZpbA&#wEGuzRP2cW59+sBq#amcZd*7GBIZ5r%HH;_>NZ?8KC(6u-r<%)O52J z_jJ(8DY7t%7K9I0Wm)(HonqE0-pv-qXQO9I)Vr@{*>g%ztXdWoRpi@Dr~%-+NwM$z zw(nQf@3o>AT+T*o7$f(kqxLqU2D9nYdX_4Ev)e{Jw{|Yq25LFSV?=HNk+tWJGKP0NDT4kkFY#+Pt1|DzO}XwuS>N#cDmO9$2&7c4P5a6uaq|xFwo?8$35Bi3HRh%VS6!I%WKuD{L>Y$X>n8=f5;n1xHyddqK2 zr!@*+II&QpX0=hLrh?m^(43OB{%n6#^I*aYYKKV)Z9R?&$RtR=BND@ZSroqWK;hwI2R?v* z!IRs`GRWSu$MmNvci-dNmzM{fn%unhSGR-Yzki?OfbVv`OK9`YmkWZ+OX9IkqjHm! z-hf$=Aw>&hIzDcBZdqMY172rYV#1z?@4t7whQ2cur%GLk{__>=t#s*SJ5<*A686IM zVhq4O!)L^lhVOBNn*NXn#4cnlcuFp0C28-!*&$Lz4k;1Z-d%En5C!Pu03j4(QEM4I zkjhM)>^J39{Aq7U;vKW`oR3&SSao7g{6XR|JA!Gz<%h-dDT#=SFGTQ&OCz zKyT9SuG)_^7PA?&HN(Ue^tCEm^Ku%0$NwxoF63p|0cW#>fEvVPm7XGNg(Q$}W{OdN zZclIdcVu!>=HllhJA5Tsuj`JmGv#8)(~72i9O(ggZpK8H?h9*o-tx#HIC-XkrL$Ht zC|iOUGse}?NQLCV@(PzzG)6wra z6F&=q(N1GO$UDsFbn*GotEeKF8Lm}Md*thHf^lxmWsS~FEm@Zs&QzUvln8Z^i-zFAeQ!*(^v1Jdk z*oU*X92IO#!X#4Q&EbAbQ~YE(KZ!ZKEs(}c;Uis7%`1jD6LpjRRsL7OXm55xacE%$ zt0oJ*`laRyl3EJg@^PIkXgtTYn0X^Q4G+!hf&@`2_*u(Hj>&{9Hb!r%a$CU1HeHKP zS|b4$Btqu2L(0aedeBI_RtJ~n#-TfJ=PYpxF%CUdWtXJeF1_dB)r?3$E}y869;hnK z{Q;~LlEj%GJpaLhRtaLkCZMG3j_Wz%JC4B}?b0wHUV)J;>#4rc$KbwShyE~uQZ%5j z7|wAX7-f^Nl0DmE#t4UY)EoXMUc_8upql06>HC*&-HNQH`s!qrVzSOavK{0#LbQ}`D5JEktIs&Q18N%kTY<_lqeREt(xk&{qMqreB!w_ z#5L6Se|nM3V?+hMdG+AR3FNZ!JK~>AIAJY(!T)VZGTYgvgQ6;-%Caa5rHg%z?;N~ex;U_O{MWI!Ml$Od}mNH#u3bpH?;0>qKVE=kf7KX%jdBgYh>>?CzE7zYpY<%LC=fagT$A#+Yg4;DH@uq@1V#$H4{ z6H(&M=$-v!D0KU*+HAXM5?_%fjKNO(XW?i49qv9b#G>h32mf7;3wYEA2|kXvbss2} z0>0#xARVQrjHf~8lLX@?&Iv<(zZ%i(*}HoV*-w?;R;c~;X_5bbz>&S?ku>+o@c_2{HlCYGe`H}<}2g+s`> zw^`2pR0>tt8hl&zu^W}bSb86^`F~KOSGm)mZo`^or{sAIjYVZc^4vhcAmxPVhduy# z!{&Q@8EOC5z}r4Vg@kV0BYq=ToZe!`ORc6|xsl-)#q2YU-*(g1k_9pJD`2Xc-(${F z_lEGt<&H_zZnE;~3@lb#uG<#TpxoGK!W-m~X@a*%~dMaZ#@R#qp14XwM1d}>`9ToA7+lTGs#b`d1z6#M*#ZqXeqoP%sW^chHh#3`B)2yq&^>s7m-^Q zg9^MFw};_LQjCpCiMJX4sR?2`$KL*uHsRiufN~B66%5Lf75+XTJN_SmjB5aaNpF;d zcPwd|WuM~9el&D2`=(Y&4-VXj3>hb|`yeqHSl+a%sxkxFJ z)_bcLW`DKlzJ(99&WCS8nBq$VTbxezN`ad|@0H=x)lz zzD9h~((!ge;h(I?AYi(U;QM=?e{bSI-AzlGGxrBv1GV8|WV0<&F?O}O$;T*U%;7W+ z?b}Ir{#!D<38CrH!&G7{aU$^A5*^T_D_Dq$A znDRcDKY;dKpFgO*-L>dGN8#6UU#M?yXy`y>Tn$7|HnUL^(E|l|(;d`p- zuvoFUc@aQNc}+$ z9?)n{?~|%y94%oUzXPl=OyB7!7`pvcJ{!3?_g`Bem!^^$OCr_MQ-&dgrSK}Hn-ahs zR>cri-&mYP&s!c`l~$bK43^oYSBC~tHCd)&m$F8@4-gfYddp%Qh)!WhuTjrL6MP%6AQUGQ*O^U)FT>E~;T32o z1!;ADy5?dsv>XM)113F37#tW@`Iv}3cX;e+T|OoJUI|;z{ACaZ?EfUXz@7o=zPEo` zos0)8NYf%6EAbvKoUrm>K~NK^^BB=_&V$KR)-=#dPm<1K1Cd6RJM7hxnCsnh-Xx+c z5tesSL&o3r@t6})^`=o-C+G*E_VQGuJ!-eUVE_{);P*h|?x<#TT++3L`W;0Dxyf=gjB-*^+qfDHgdgE-x_&pbG2#Mw7B8i;Eqr4oo8jm zZmF5^%C->XFaUO4^o_Vt|I~(jq%jH`Rp?hz{&a3K#_{yCV~tJG1+g>bTEN{QXV7Wj zyE0jYgu$+T&cBI-*QBFv!yKaIrSDOdu$^K?<=jr4T1W>jQ#w1;6J#E@8?xl3y^d9d zIf$d4%C(I*HakJ+dB6SlgV3)5eabcpN7RV8uskMvR@icI(31CDnrSsLx=9{Gzzpo@ zO$6fVfb=0mjG0h89S{WMUHQnbAjNIwW6s^`=nJQVYlw)> zc0g~H5L?`C&m{WNGd;fu&Zf&AP3_et5Xm%CjDVm->)Sa5C-@oMV!2)Ogyw2K2E`3NH?E2M!bE6r&>3qX`z=0@^org8 zvQmL;ljyWSx{jkxfR>hpicNZw-C6Aejg|r8a)3VP_lzLcZb?!uJG-ZT*@N{@7d_~T z>d8;ReLaI=Nr-KHM&h8|!>h97*69rU7+Nf|v9tD_KS7F$Fvj$rUZxqK5F0=;E=jhq#vNmFmFeMZZo)=gCf=84Ri#V{GX+kvgmC!gc| z8W3T_(uDlI%OI*m@2|n;4nq3SiQBnmAKFrP@=ii+h%s|R#$w~x;0d!$OAwnx$ZGR- z))`#S-6O!{)CKINjQkH}Q7DFdC)=TJ@$0x|ufRL9gaHtroHFOL>TZsNdka@6L|^?5 z>X_oicm&iR!Zjgj01k2ERY*J0iy$&EUR<|)l7BnjNX&$88l1l2Ap$`ziB9bV9lGh0 z+4C0V5~KG74xG~Fd(G$;n6%9gAO|ELz3q}_*llx-`5xxzUEZXsLjG9k1@Pfalrdcj z;4ak6epdIuv!`Ok)%Q5w+zwl#fO_x9WIuPPg1xW4&97}&>F-`L(%9BGeUnqBa3Ja0 zn(eBV2w8zLv9^|vpB~{N?22Fah+IQt8X^EYY&c(_ioHj4fe5mgF0+rjN9y$Uz#u~; z{v;KzfxKzn^x+%@!c`92 zjag6+OmeLq=di&I)vDk7U05RT=&2mmraC5h!uCvm@v{gQgD3+p7j zsxx`ACyaCFZRmOO5%b=-f=1EEI2$^Q*tVkZ-cqQFi+M8J5!koj7TP|ZDdh>PCOmMf zlYdS))AZhl*!_$QY5&~WUY{O;A0`57dW0Sg(OwNxQO>VzHb5#N-gHp6K59nOiFJql zB5jRJ2s6!QdOA09Ym1_&e|Y&E4=toW+dg6$Is8ycObYg`g454vX3oa3t)$g0L3OBk zP;r~(QdlI5h|$@ROLQ z-9jRz7mshVLCBogV8y|6lS-yS$H+$8UgSOfzdVo#EYqi4PZ%Mg#vi{{adPX}{KH79 z6rfY+!RE}_Uwuqacs7Qvts#f!0M-u<_~tDiJ<7l#Q3@Hmb^2hn%MTsP#npK zF`Leh_FtgN#X&Xi8{Z&|LF)igR1MWW09Rw0^2kH(aaSo?naEv^u-xP|-2p3jnD`Wfu5yi={bJZ+tWk(?DtU z!BH%fW=THBq=nigIUL27>}CcaHptd5+Uv%oRO1#w@Z59+6K#1z%roG#iOpd*8R0qc zf?V(i^KV|a2-#SUjLiHrtEnE>2+|~GXPmBqIg1;qZG4*D1M(%am=VET6~`H&84_+O9wO|=MuX{RQe?`lnozbDic>Lzvou{ zn&>Wl5b;BE%!v2d%ba69$ZS(%l|cqN>%b|@NOOVWj#%j=q*;$>y5K2bog9IcIIomL4<0b8?@0K}?HFv1)+N zXD_E7xjQB3zfAJ--MGRyV&=v!B|^}luS5Mow+reRKCN^%J8Z`>E0#CUxz1h+gP2L&N*2-R)^LG*`sc}(h@FBTKyke(%MY9DAXTFjyn;Nm)jg!(#r zKATXEAiL`F+RirWx1we#`pyCDFOwAn-h**b&Hv5ZRNSrOxB)=I5~uU0SM zxjb&$J4O3}lfLF++I};_Tc@FQp{Ol5%8^^2nM~vr3_~+xWs9y+t0E{}c~!KIVC9!mkz|)nl=kyv2g7wd|X;i#$XkYB>{p)sdS7(K~#- zmQ-(>VW8U6AIoAdO?cZ2oJ#xb|$lL(onJ>`Dp;ZUHL$J=Av%)ZX3@7 zx#Rp1&nlpfpV4FxkViutLF)?YhLS0FE~E;}R+Ink2hvYzX(Y&eV2v*zyK?EI9oo)s zJCp@@x8N4(I7XJp@X$%hBz5g%E%VoJ7_-1pCn+VUc5bw?=;|&mkT>GLGtAX%<| zoZYav9^j?dbtXy_ly}x+)gWRPW)-$9v%vTi$hAElLkUyq8>A*iWiW2j<0KpjAqsLo z8y2bJMN9WHJ2_P=*9>$MtNxi{)WSDFma@=e5OeI*q5LIQ=kgH$tO%_9dwzOQT5rS)k$Qe>XXm9@zma%3oy<#iuLsci(& z((4T6vkSZP$r?P%3iH3sdKYbW7m+&LxhXqez|&DFpDAXrb1XCQmX%q@TJC*!eZ`4F6n;{DRaE9EWBRTo}1&_)Wc~on+lsS zqLn5YI~9KTfR#b%b%=y(N9SIsduz2E#$yDu)`5ZJ*=dkPNZ%In0zdSP|HU zlA5#(D$lms!!`e&ZbnL~AQ*Iz;`oLaO<&BvXzEA8EV}@AxBpr~3_>f&2gBR=9f;e1 z1?0q2JMCw{4Kg93cx$GoSJTk-h`s7?Sj2$Tl3qJV=a~APsn6~l)$#f7k_B#NA;cCc z>TD5<0{K`P;6Wai$Ag2zH2hB-muP~y#)~RE*S$uOW&^^e@CS@$4b!s=U4drA@Q~-M z;_lX4kBu<+d_JQPn!Nu@IPXT~+WelEBO@Z>>|E%RSAFk5m|u&v9G_(WtNxN5o)%&Gl#OAz6!_LACTIF> zTr?i6jxK`}Y_5&39RpxmqkpOVT=B0>ylu~P4-05JTS$dK9ME1BJ`(XR#deLS$)5L9 z;+HV-@W@2wk8URHZhBWk>)Zps%ythF>y(sE<>%WmmKq(rFj0fAEVaqi!z09j6vK#6 zK0+=f62I-=;;ImTb*`zq^5FOal{sy7s!DWQnjmc#dByC-*{CkoPdHo_h2k=;@!6hf zqDNwcuaaS{&)%hE`JhVb5Esc5$T}EX`*#BYyO?@67{-jQYGFPUDx>#Ni=`AYH5+Nx z0aamMMuAN!>N2W8nUWev1IXclL2AW#g|Vfn5=pJ%p7)5LZ?0su zzMItM4_98hax9Xp+B$&j`OLoUSMSYK4!M&`K9yh|djd?vQSn}yGWE(+WA$Fu6@!sW zkqqz2NBwVy*$d^D2rEczXAB*9ryCF#RwJziHlK02r%x4);PX@Cb*}v=c=TY_&p)^{ zQ6Sz`8);kQpIRb5ab+8=AUD^P zTu!fQ>ckY@cgc75M&STC%9J%ES`~yB3g2iK5mE$LI zJ-*Mnl=U8#IwU0bG70JPP8M}tVc?Pa_oTl6%72799tr~iw-qJVag!k$fQ;S&?t8Jb zGgDJ!0?ks2m?k1122Sz(w}tGd^pOnW>jCOJm_Y|ZTM@zSNp`?R8_38&+ibD`E`Y0z zxEBnjli)Y-&G-YLzN^>JGp+Ob#W;OL(VuN4@tq0EVYJPFQ|wz{O0s#Q`DuWC{Q_uv z**7>SL_72$$S5J8h)=d__fP#w=i*AM%f+_oV`0jTCn>Q>jMW}(3nSfSHN2Y61hfc- z|JRfvoF+!Av9Dc^MgR#ywzRf0fuO8_fPo*!kY5moLU!hhO0l#BvV+8Dr3urH58X^f zg+};Ut7U1^zAG?$7~j7$xBkG$?{Suh!|I0%UB4DRZ?{ow#oyRo5LXoS_MZLhLBRtuT1LA zAYB#zN*X@t#i~!qxCOw8lIkuU(QuUoyIhZ7dF#GX9GfBkVevsdZhQRx4HMbFI>9Y~ z#lZ&VkA{aeY^a)q+f4xvPt2j-Cu0VSrV6mE4iqe@-`FYLN7oqocl&a>UZv0Zl z@E2ItWQy~?^$pkO9&!H%F%7UeRz%jZv>UP7NLh6gMgXGCqTn;*xi~o`LE1>uVs)oW zujs@C4h5cJR7Sau!J7AvaRBG*e0~S#@Y^46JkFH_x!Y4x zj~h^fp=^|V=tyI_&<0lgQ{2RRjvt=4yh_a`$oDC{-i-~f`Hi|1McB_TZ8$$f+Y|W7m6dv={ni7 z;3jp+mo*a4)XF~_)L#tU0XVf1&^T-3ng;?{(Y~R9ZiAEThcS1akeT!Q0L7E70>O=A&l;dX(j+zsIeD(?$a#EhP9HJurPhKG1>!P zf{5AQYfj7wHL3=>!%Z3_wMnR)zX;>Qk3%&Lj9mPd&tHz^XOQz}R@}DPY6d0uP2cD@ z#v9w74o#pdkUc8{t6cjk@z2 zC|tVy_|~ugL@l;OiX@$Tc;Z63kHSoo3aS9t-i$p9cWS@tIs^SeUI)O+LqkaEr>yre zD9&yQisio75Q>_!ZMs5*_5BZ`>?uap?Yo0Owf4rsEzD%d@O;|^0}fo{yJgC42rpLa$jgmjoEm^-bL!fx9yY&O|CYAcDXVu&dJa@XI+eu9^yp+XLEym{f z`yde|-+>`m&&!F#3ni*jcm>PvTY>CwZ)NeCCGh*4#f%{_*RayO)(=K&N|P`@2+ed; zee-ZBj>}GyX8(+OQD`>St1Uh7Q#zfL3`MN!L?1$PO*(DOsMMWCX=LUX3r}*|r^hcL zbMMn4p7@>d(}Kp`SL5nAEb*Ox@l&N2J`2OEa@;lmK@z>LDBrvBS}pqieFx;bsxEWZ zhI^k-Oc8MrXpax&D}J-;rodru#a!l~(5?z(TQIpGzSk0$ zv^*$zz%+C`zY{2ruMUyX!yW?)w@zBrQTlr3@?ekEZ7+!abby#1A*;( zH4^Nb7KiA$?v4MKvP~=&ZoPYrjcd6DCCOT{k@I+GY2f{0w@P8kUsek=2{D0zEI8x* zY8&Y_5z2qKYH;`8V|97!SOayuBd^6K!ZZ{#+`csUh})2zqyVqxoA~hkk0BA}F*j;p zc*^VCY{qc2KiH_6x=je3GWF*BWpM6IHDQ~ONqvJT-N~t|DxeGsotMbc%cRZiM`kUV z<6n7G{%`cUUP@j!gT7Y6pLOwqdI-caH3`w+np+oW>XnNy0{1+qY!*FX{qdYXD^M{k zcbXdowcI?a0odGE(Ifw5*6lK5fza$mCwYK~Gym&jbh>&eO2P9nR>NC#ea5V6?JrUVFwaQ&tZ?lXiX5712_QouLc6Vt5`n%ytv@y%CMT2kp>@j@g<6(sBa1z4opR!=hRc!3Ik_J!m13$7T#$K+FTy=3l%PZ!A7`p^4K!r!Bq>M6}c>m1IIF?2$g_ zqev-Tq|=gdmMGJ%PfjcY$Bo52c0`%tDtoJJp$;2s)SRYU73RuwD|WbaK24ujEGSg< za`Cm*?W#lmY_ZON1dA~xZD2T)WBxxkY@Xen!-an-o0lN6l}(iaS)Z5e7MSCGJ>bY2J8_yDDpRp~SXV3U?V)e`=kK zZ&&Kob7Wm+``H3W?sE?{Ffw`{7LC+!rmVqll{TEUO&$aEYO#+66e;j0SMrbd+e%p+ zKNB}EZ@X86u@?iAO)2Cw>xx1Gn=R4yQnPl$pzS89sT-{1m*m`fsjq9$hu1t1w%Nqn zB`A1{pt|*pg%%bp>$qp{Y^f7xlmn#}_qgmOxvsCabtc$Win&a3CE|NqIX-Uj(q>P# zbnqz*1E1KHBBxr}lY;wqI&GE{8nCg&n?OTAl)J)jn7nC0!XqYsC@mRH3zj7Sc;j<)CnYv%H^&H$hX<0GF~}jps|aq`DK!o&ZVo;$uHsa6E#& z4>?bHx{hn1oNzCvah}kD==^D)%r+<(0E4qHsq5U7gN;jFC*?$BOiFg7)i5Sj09V0@fRC(;-7o zpb`LQ1{i@)WGzg?nKO#S!lZ)zzv^Z%zjB^yUTVcjoDozGQc$a_#kHx&|6MhJzwNk9 zw~i%tL-8;i@y>Q%Yh>Plk+IKFo5|gu|memMR?r8{+=T&u+RN88+ z9=}=VZ|F6J-oe;xvcD<@04dmn%=J2!j8ttqAdTDVBgrA@iF(BNYpbDVxYD)QX&&(0 zx+&C`=R9@k3hVE@UM0eLO0i$)mgA!0QYG)Z+Ub4%5k7h&iYb0A8K4_lbIO4JSvt_; zO>8JeibMq($!Q-cq@8c;LD>3Ww%gciBQhtY2bxe?c#L*hu~9E>wh9ofVPR6iL82TB zTINgzN5TMd$&j)NV0+d4k{w(JfZ$YNNH|4uBY|(A;rjZy_~oeDG@WzVL)U8%=Y%Ka|66Dee!;O0OxpUP zhjo~$xUg~HvyP@Q4qAofW@)$P3X#%wU2;YBJS-wWR?e8g9NjZ*=qJ~!ADv4wdILw7 z^ZciGDS6P}t=lC7{RALKG`_J5NfXq&U%sKh6Kxq-TI7q*yP&#QG2kFHA57((%srSA`GFvcxc6yTi2)B3`-%8* z-O351_W<>0X=r@>iA#CkEfQ+xa1UURsU4e>Q@!a1&zxl9uRdLqyY`-n?`J)0`Sw8ZkVC7uCnwb2F}$#b zyx)(~N0_`tCH^22KhUiiznRIHa#X9)&K@E>x()?aBAck7_sSpr#=bybAZ`JB<0)0t zK*&ze%MPwL{J1*I;VK+D=+?z%)0*Djl8cyick-ao*Pneo;w2y=H|veD%P4N=$NY*v z+Gb^$9CunJ6WN&v^6=s2hJzS-T9W|)C9>&LnFL$DWq7E1B)J8oPKQi(mxk49v7sVd zRuKDCu}7~J`ud;K8ka1bkU@FCaquqsRvu631O6(hg~h4{8FQJ93PM$2&n8<@@aEZh z55~6??aGexy4;@aVCEB>bK%b^3>!9T90vm?51_4*sm_2#*c{lM_Y0s`&QaK+}0xi^e1{AwAhmmAH;iH7R6rPS!Kbg#se0$rL5ffUU#0M_�n*tXsC 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 literal 0 HcmV?d00001 diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index e261a051f6..2b24846e95 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -617,6 +617,15 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa let touchLocation: UITouch? = cell.lastTouchLocation cell.lastTouchLocation = nil + if + info.title?.textTailing != nil, + let localPoint: CGPoint = touchLocation?.location(in: cell.titleLabel), + cell.titleLabel.bounds.contains(localPoint), + cell.titleLabel.isPointOnTrailingAttachment(localPoint) == true + { + return SessionProBadge(size: .large) + } + switch (info.leadingAccessory, info.trailingAccessory) { case (_, is SessionCell.AccessoryConfig.HighlightingBackgroundLabel): return (!cell.trailingAccessoryView.isHidden ? cell.trailingAccessoryView : cell) diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index 52087f9f16..2ebc170264 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -97,7 +97,7 @@ public class SessionCell: UITableViewCell { return result }() - fileprivate let titleLabel: SRCopyableLabel = { + public let titleLabel: SRCopyableLabel = { let result: SRCopyableLabel = SRCopyableLabel() result.translatesAutoresizingMaskIntoConstraints = false result.isUserInteractionEnabled = false diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index b4d21a8fc0..9d300c0cef 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -10,7 +10,7 @@ public struct ProCTAModal: View { case longerMessages case animatedProfileImage(isSessionProActivated: Bool) case morePinnedConvos(isGrandfathered: Bool) - case groupLimit(isAdmin: Bool) + case groupLimit(isAdmin: Bool, isSessionProActivated: Bool) // stringlint:ignore_contents public var backgroundImageName: String { @@ -23,8 +23,13 @@ public struct ProCTAModal: View { return "AnimatedProfileCTA.webp" case .morePinnedConvos: return "PinnedConversationsCTA.webp" - case .groupLimit(let isAdmin): - return isAdmin ? "" : "" + case .groupLimit(let isAdmin, let isSessionProActivated): + switch (isAdmin, isSessionProActivated) { + case (false, false): + return "GroupNonAdminCTA.webp" + default: + return "GroupAdminCTA.webp" + } } } // stringlint:ignore_contents @@ -73,11 +78,19 @@ public struct ProCTAModal: View { "proCallToActionPinnedConversationsMoreThan" .put(key: "app_pro", value: Constants.app_pro) .localized() - case .groupLimit: - return "proUserProfileModalCallToAction" - .put(key: "app_pro", value: Constants.app_pro) - .put(key: "app_name", value: Constants.app_name) - .localized() + case .groupLimit(let isAdmin, let isSessionProActivated): + switch (isAdmin, isSessionProActivated) { + case (_, true): + return "proGroupActivatedDescription".localized() + case (true, false): + return "proUserProfileModalCallToAction" + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "app_name", value: Constants.app_name) + .localized() + case (false, false): + return "Want to upgrade this group to Pro? Tell one of the group admins to upgrade to Pro" // TODO: Localised + } + } } @@ -107,13 +120,16 @@ public struct ProCTAModal: View { "proFeatureListLargerGroups".localized(), "proFeatureListLoadsMore".localized() ] - case .groupLimit(let isAdmin): - return !isAdmin ? [] : - [ - "proFeatureListLargerGroups".localized(), - "proFeatureListLongerMessages".localized(), - "proFeatureListLoadsMore".localized() - ] + case .groupLimit(let isAdmin, let isSessionProActivated): + switch (isAdmin, isSessionProActivated) { + case (true, false): + return [ + "proFeatureListLargerGroups".localized(), + "proFeatureListLongerMessages".localized(), + "proFeatureListLoadsMore".localized() + ] + default: return [] + } } } } @@ -222,6 +238,14 @@ public struct ProCTAModal: View { .font(.Headings.H4) .foregroundColor(themeColor: .textPrimary) } + } else if case .groupLimit(_, let isSessionProActivated) = variant, isSessionProActivated { + HStack(spacing: Values.smallSpacing) { + SessionProBadge_SwiftUI(size: .large) + + Text("proGroupActivated".localized()) + .font(.Headings.H4) + .foregroundColor(themeColor: .textPrimary) + } } else { HStack(spacing: Values.smallSpacing) { Text("upgradeTo".localized()) @@ -280,7 +304,7 @@ public struct ProCTAModal: View { // Buttons let onlyShowCloseButton: Bool = { - if case .groupLimit(let isAdmin) = variant, !isAdmin { return true } + if case .groupLimit(let isAdmin, let isSessionProActivated) = variant, (!isAdmin || isSessionProActivated) { return true } if case .animatedProfileImage(let isSessionProActivated) = variant, isSessionProActivated { return true } return false }() diff --git a/SessionUIKit/Utilities/UILabel+Utilities.swift b/SessionUIKit/Utilities/UILabel+Utilities.swift index dc96af22e5..5070d91def 100644 --- a/SessionUIKit/Utilities/UILabel+Utilities.swift +++ b/SessionUIKit/Utilities/UILabel+Utilities.swift @@ -29,4 +29,52 @@ public extension UILabel { numberOfLines = 0 lineBreakMode = .byWordWrapping } + + /// Returns true if `point` (in this label's coordinate space) hits a drawn NSTextAttachment. + /// Works with multi-line labels, alignment, and truncation. + func isPointOnTrailingAttachment(_ point: CGPoint, hitPadding: CGFloat = 0) -> Bool { + guard let attributed = attributedText, attributed.length > 0 else { return false } + + // Reuse the general function but also ensure the attachment range ends at string end. + // We re-run the minimal parts to get the effectiveRange. + let layoutManager = NSLayoutManager() + let textContainer = NSTextContainer(size: CGSize(width: bounds.width, height: .greatestFiniteMagnitude)) + textContainer.lineFragmentPadding = 0 + textContainer.maximumNumberOfLines = numberOfLines + textContainer.lineBreakMode = lineBreakMode + + let textStorage = NSTextStorage(attributedString: attributed) + textStorage.addLayoutManager(layoutManager) + layoutManager.addTextContainer(textContainer) + layoutManager.ensureLayout(for: textContainer) + + let glyphRange = layoutManager.glyphRange(for: textContainer) + if glyphRange.length == 0 { return false } + let textBounds = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + + var textOrigin = CGPoint.zero + switch textAlignment { + case .center: textOrigin.x = (bounds.width - textBounds.width) / 2.0 + case .right: textOrigin.x = bounds.width - textBounds.width + case .natural where effectiveUserInterfaceLayoutDirection == .rightToLeft: + textOrigin.x = bounds.width - textBounds.width + default: break + } + + let pt = CGPoint(x: point.x - textOrigin.x, y: point.y - textOrigin.y) + if !textBounds.insetBy(dx: -hitPadding, dy: -hitPadding).contains(pt) { return false } + + let idx = layoutManager.characterIndex(for: pt, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil) + guard idx < attributed.length else { return false } + + var range = NSRange(location: 0, length: 0) + guard attributed.attribute(.attachment, at: idx, effectiveRange: &range) is NSTextAttachment, + NSMaxRange(range) == attributed.length else { + return false + } + + let attGlyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) + let attRect = layoutManager.boundingRect(forGlyphRange: attGlyphRange, in: textContainer) + return attRect.insetBy(dx: -hitPadding, dy: -hitPadding).contains(pt) + } } From c135636813f0e4e7c6552d5fba20ebc4f8a8af97 Mon Sep 17 00:00:00 2001 From: Bilb <1544279+Bilb@users.noreply.github.com> Date: Mon, 25 Aug 2025 00:36:50 +0000 Subject: [PATCH 014/162] [Automated] Update translations from Crowdin --- .../Meta/Translations/Localizable.xcstrings | 2861 ++++++----------- SessionUIKit/Style Guide/Constants.swift | 2 + 2 files changed, 961 insertions(+), 1902 deletions(-) diff --git a/Session/Meta/Translations/Localizable.xcstrings b/Session/Meta/Translations/Localizable.xcstrings index 5e4040a9a3..96a62985df 100644 --- a/Session/Meta/Translations/Localizable.xcstrings +++ b/Session/Meta/Translations/Localizable.xcstrings @@ -65016,7 +65016,7 @@ } } }, - "blockedContactsmanageDescription" : { + "blockedContactsManageDescription" : { "extractionState" : "manual", "localizations" : { "en" : { @@ -78435,478 +78435,10 @@ "callsVoiceAndVideoModalDescription" : { "extractionState" : "manual", "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jou IP is sigbaar vir jou oproepmaat en 'n Oxen Foundation-bediener terwyl beta-oproepe gebruik word." - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "عنوان IP الخاص بك مرئي لشريك الاتصال وخادم Oxen Foundation أثناء استخدام المكالمات التجريبية." - } - }, - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Beta zənglərini istifadə edərkən IP ünvanınız zəng tərəfdaşınıza və Oxen Foundation serverinə görünür." - } - }, - "bal" : { - "stringUnit" : { - "state" : "translated", - "value" : "ما گپ درخواست قبول کردی آپ کہ آئیں طرفه IP پدنی Oxen Foundation کہ سرورے ہ مغامیگیا." - } - }, - "be" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваш IP будзе бачныя вашаму партнёру па званках і серверу Oxen Foundation падчас выкарыстання бэта-званкоў." - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вашият IP е видим за вашият партньор по време нослужване на beta обаждания и Oxen Foundation сървър." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "বেটা কলগুলি ব্যবহার করার সময় আপনার আইপি আপনার কল পার্টনার এবং একটি Oxen Foundation সার্ভারের কাছে দৃশ্যমান হবে।" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "La vostra IP és visible a la vostra parella de trucada i a un servidor de Oxen Foundation mentre utilitzeu trucades beta." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaše IP adresa je při používání beta hovorů viditelná pro vašeho volacího partnera a server Oxen Foundation." - } - }, - "cy" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mae eich GPS yn weladwy i'ch partner galwad ac i Wasanaeth Node Foundation Oxen wrth ddefnyddio galwadau beta." - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Din IP synlig for din opkaldspartner og en Oxen Foundation-server, mens du bruger betaopkald." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Deine IP ist für deinen Gesprächspartner und einen Oxen Foundation Server sichtbar, während du Beta-Anrufe nutzt." - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Η διεύθυνση IP σας είναι ορατή στον συνομιλητή σας και σε έναν διακομιστή του Oxen Foundation κατά τη χρήση κλήσεων beta." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your IP is visible to your call partner and a Session Technology Foundation server while using beta calls." - } - }, - "eo" : { - "stringUnit" : { - "state" : "translated", - "value" : "Via IP-adreso estas videbla al via vokopartnero kaj al servilo de Oxen Foundation dum vi uzas betajn vokojn." - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tu IP es visible para tu compañero de llamada y un servidor de la Oxen Foundation mientras usas llamadas beta." - } - }, - "es-ES" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tu IP es visible para tu socio de llamada y un servidor de Oxen Foundation mientras usas las llamadas beta." - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "Teie IP on nähtav teie kõnepartnerile ja Oxen Foundation serverile beetakõnede ajal." - } - }, - "eu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zure IPa ikusgai egongo da zure dei-bikotekidearentzako eta Oxen Foundation zerbitzari batentzako, beta-deiak egiten ari bazara." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "آدرس IP شما برای مخاطب تماس شما و همچنین سرور Oxen Foundation در هنگام استفاده از تماس آزمایشی مشخص خواهد بود." - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP-osoitteesi on näkyvissä puhelun aikana vastaanottajalle ja Oxen Foundation palvelimelle, kun käytät beta-puheluita." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ang iyong IP ay nakikita ng iyong kasamahan sa tawag at isang server ng Oxen Foundation habang gumagamit ng beta calls." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre adresse IP est visible par votre interlocuteur et un serveur d'Oxen Foundation pendant que vous utilisez des appels beta." - } - }, - "gl" : { - "stringUnit" : { - "state" : "translated", - "value" : "O teu IP é visible para o teu compañeire de chamada e un servidor da Oxen Foundation mentres utilizas chamadas beta." - } - }, - "ha" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP ɗinku yana bayyane ga abokin kiran ku da sabar Oxen Foundation yayin amfani da ƙiran beta." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "כתובת ה-IP שלך גלויה לשותפת השיחה שלך ולשרת קרן Oxen בעת שימוש בשיחות בטא." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "अपने खाते में एन्क्रिप्टेड संदेश भेजते समय आपका IP आपके कॉल पार्टनर और Oxen Foundation सर्वर को दिखाई देगा।" - } - }, - "hr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaš IP je vidljiv vašem sugovorniku i poslužitelju Oxen Foundation dok koristite beta pozive." - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Az IP címed látható a hívópartner és egy Oxen Foundation szerver számára a béta hívások használata közben." - } - }, - "hy-AM" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ձեր IP հասցեն տեսանելի է ձեր զանգի գործընկերոջը և մի 'Oxen Foundation' սերվերին ծիծլած օգտագործման ժամանակ։" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP Anda terlihat oleh mitra panggilan Anda dan server Oxen Foundation saat menggunakan panggilan beta." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Il tuo IP è visibile all'utente che stai chiamando e a un server di Oxen Foundation durante l'utilizzo delle chiamate beta." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "音声通話とビデオ通話の使用中、あなたのIPはあなたの通話相手とOxen Foundationサーバーに表示されます。" - } - }, - "ka" : { - "stringUnit" : { - "state" : "translated", - "value" : "თქვენი IP გააშკარავდება თქვენი ზარის პარტნიორს და Oxen Foundation-ის სერვერს, ბეტა ზარების გამოყენებისას." - } - }, - "km" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP អ្នក​ត្រូវបាន​លេចឮ​ច្បាស់នៅពេល​អ្នក​ប្រើកិច្ចប្រជុំ Beta ក្នុង Oxen Foundation។" - } - }, - "kn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ಬೇಟ ಕಾಲ್ಲನ್ನು ಬಳಸಿದಾಗ ನಿಮ್ಮ IP ಕರೆ ಸಹವಾಸಿಗೂ ಮತ್ತು Oxen Foundation ಸರ್ವರ್‌ಗೆ ಗೋಚರುತ್ತದೆ." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "베타 통화를 사용하는 동안 IP가 호출 파트너와 Oxen Foundation 서버에 보입니다." - } - }, - "ku" : { - "stringUnit" : { - "state" : "translated", - "value" : "پته‌ی IP ی تۆ بۆ شەریکەکەی تیپە و سێروێری Oxen Foundation پشت بە پشت ڕەنگە بونی بوو بێت کاتێک بەکارهێنانی بەتاکوڵ بوون بوزیەکان." - } - }, - "ku-TR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Adresa IP̧ya te bi dirising ti paşinspectek an hîn ya Fundaceya Oxen bêyê dîtin." - } - }, - "lg" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP yo efulugettaka mu mateeka ne server ya Oxen Foundation nga okozeza omitting ekikugya ekiriotto." - } - }, - "lt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Naudojant beta skambučius, jūsų IP adresas matomas jūsų pašnekovui ir Oxen Foundation serveriui." - } - }, - "lv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Izmantojot beta zvanus, jūsu IP ir redzams jūsu zvana partnerim un Oxen Foundation serverim." - } - }, - "mk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вашето IP е видливо за вашиот партнер за повик и серверот на Oxen Foundation додека користите бета повици." - } - }, - "mn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Таны IP хаягийг ярианы хамтрагч болон Oxen Foundation сервер харагдана." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Alamat IP anda boleh dilihat oleh rakan panggilan anda dan pelayan Oxen Foundation semasa menggunakan panggilan beta." - } - }, - "my" : { - "stringUnit" : { - "state" : "translated", - "value" : "သင့် IP ကို ဖုန်းခေါ်စဉ်တွင် သင်၏ ချိန်းညွှန်းပါတနာများနှင့် Oxen Foundation ဆာဗာကို မြင်ရပါသည်။" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP-adressen din er synlig for samtalepartneren din og en Oxen Foundation-server mens du bruker betasamtaler." - } - }, - "nb-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Din IP er synlig for samtalepartneren din og en Oxen Foundation-server mens du bruker beta-samtaler." - } - }, - "ne-NP" : { - "stringUnit" : { - "state" : "translated", - "value" : "तपाईंको आइपी तपाईको कल पार्टनर र ओक्सन फाउन्डेसन सर्भरलाई बेटा कलहरू प्रयोग गर्दा देखिनेछ।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Uw IP is zichtbaar voor uw oproep partner en een Oxen Foundation server tijdens het gebruik van bètagesprekken." - } - }, - "nn-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP-adressa di er synlig for samtalepartnaren din og ein Oxen Foundation-server mens du bruker beta-samtaler." - } - }, - "ny" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP yanu ikuwoneka kwa mnzake wanu ndi seva ya Oxen Foundation mukamagwiritsa ntchito mayitanidwe ozungulira." - } - }, - "pa-IN" : { - "stringUnit" : { - "state" : "translated", - "value" : "ਤੁਹਾਡੀ ਕਾਲ ਦੇ ਦੌਰਾਨ ਤੁਹਾਡਾ IP ਸਹਿਯੋਗੀ ਅਤੇ ਇਕ Oxen Foundation ਸਰਵਰ ਨੂੰ ਦੇਖਾਈ ਦੇਵੇਗਾ।" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Podczas korzystania z połączeń w wersji beta Twój adres IP jest widoczny dla partnera rozmowy i serwera Oxen Foundation." - } - }, - "ps" : { - "stringUnit" : { - "state" : "translated", - "value" : "ستاسو IP ستاسو د زنګ ملګري او یو Oxen Foundation سرور ته ښکاره کیږي کله چې بیتا زنګونه کاروئ." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Seu IP estará visível para seu parceiro de chamada e para um servidor da Oxen Foundation enquanto usa chamadas beta." - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "O seu IP está visível para seu parceiro de chamada e para um servidor Oxen Foundation enquanto usa chamadas beta." - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP-ul dumneavoastră este vizibil către partenerul de apel și către un server al Oxen Foundation atunci când utilizați apeluri beta." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваш IP виден вашему собеседнику и серверу Oxen Foundation при использовании бета-вызовов." - } - }, - "sh" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tvoj IP je vidljiv tvom partneru na pozivu i serveru Oxen Foundation dok koristiš beta pozive." - } - }, - "si-LK" : { - "stringUnit" : { - "state" : "translated", - "value" : "ඔබගේ IP වැටීමට වයිස් සහ Oxen Foundation සේවාදායකයකු වෙත දැක්වේදීද පරීක්ෂණ ඇමතුම් භාවිතා කිරීමේදී." - } - }, - "sk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaša IP adresa je viditeľná vášmu volajúcemu partnerovi a serveru Oxen Foundation pri používaní beta hovory." - } - }, - "sl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaš IP je viden vašemu partnerskemu klicatelju in strežniku Oxen Foundation med uporabo beta klicev." - } - }, - "sq" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP-ja juaj është e dukshme për partnerin tuaj të thirrjes dhe një server të Fondacionit Oxen gjatë përdorimit të thirrjeve beta." - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваша IP адреса је видљива вашем сајговорнику и серверу Oxen Foundation док користите бета позиве." - } - }, - "sr-Latn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaš IP je vidljiv vašem partneru za poziv i serveru Oxen Foundation dok koristite beta pozive." - } - }, - "sv-SE" : { - "stringUnit" : { - "state" : "translated", - "value" : "Din IP är synlig för din samtalspartner och en Oxen Foundation server när du använder beta-samtal." - } - }, - "sw" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP yako inaonekana kwa mshirika wako wa simu na seva ya Oxen Foundation wakati unatumia simu za beta." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அழைப்பு பிறையாளர் மற்றும் Oxen Foundation சர்வரில் உங்கள் ஐபி காட்டப்படவும் செய்தி சேவையின்போது." - } - }, - "te" : { - "stringUnit" : { - "state" : "translated", - "value" : "బీటా కాల్‌లను ఉపయోగిస్తున్నప్పుడు మీ ఐపి మీ కాల్ భాగస్వామికి మరియు ఒక Oxen Foundation సర్వర్‌కు కనిపిస్తుంది." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP ของคุณจะสามารถมองเห็นได้โดยคู่สายของคุณและเซิร์ฟเวอร์ของ Oxen Foundation ในขณะที่ใช้เบต้าการโทร" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Deneme aramaları sırasında IP adresiniz arama ortağınıza ve Oxen Foundation sunucusuna görünür olacaktır." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваша IP-адреса видима вашому партнеру по дзвінку та серверу Oxen Foundation при використанні бета-дзвінків." - } - }, - "ur-IN" : { - "stringUnit" : { - "state" : "translated", - "value" : "بیٹا کالز استعمال کرتے وقت آپ کا آئی پی آپ کے کال پارٹنر اور ایک اوکسن فاؤنڈیشن سرور کو نظر آئے گا۔" - } - }, - "uz" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hozirgi parolingiz noto'g'ri." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Địa chỉ IP của bạn hiển thị với đối tác cuộc gọi và máy chủ Oxen Foundation trong khi sử dụng cuộc gọi beta." - } - }, - "xh" : { - "stringUnit" : { - "state" : "translated", - "value" : "I-IP yakho iyabonakala komnxibelele wakho nakwiOxen Foundation Server ngelixa usebenzisa i-beta calls." - } - }, - "zh-CN" : { - "stringUnit" : { - "state" : "translated", - "value" : "在使用测试版通话时,您的IP会暴露给您的通话对象和Oxen Foundation服务器。" - } - }, - "zh-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "您在使用測試版通話時,您的 IP 將會被通話夥伴和 Oxen Foundation 伺服器看到。" + "value" : "Your IP is visible to your call partner and a {session_foundation} server while using beta calls." } } } @@ -83725,24 +83257,24 @@ } } }, - "change" : { + "cancelPlan" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Change" + "value" : "Cancel Plan" } } } }, - "changePasswordDescription" : { + "change" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Change your password for {app_name}. Locally stored data will be re-encrypted with your new password." + "value" : "Change" } } } @@ -84226,6 +83758,17 @@ } } }, + "changePasswordModalDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change your password for {app_name}. Locally stored data will be re-encrypted with your new password." + } + } + } + }, "clear" : { "extractionState" : "manual", "localizations" : { @@ -126324,6 +125867,17 @@ } } }, + "currentPassword" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current Password" + } + } + } + }, "cut" : { "extractionState" : "manual", "localizations" : { @@ -238366,6 +237920,17 @@ } } }, + "important" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Important" + } + } + } + }, "incognitoKeyboard" : { "extractionState" : "manual", "localizations" : { @@ -252864,6 +252429,17 @@ } } }, + "links" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Links" + } + } + } + }, "loadAccount" : { "extractionState" : "manual", "localizations" : { @@ -258624,6 +258200,17 @@ } } }, + "logs" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logs" + } + } + } + }, "manageMembers" : { "extractionState" : "manual", "localizations" : { @@ -293816,6 +293403,17 @@ } } }, + "newPassword" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New Password" + } + } + } + }, "next" : { "extractionState" : "manual", "localizations" : { @@ -294295,6 +293893,17 @@ } } }, + "nextSteps" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next Steps" + } + } + } + }, "nicknameDescription" : { "extractionState" : "manual", "localizations" : { @@ -324740,6 +324349,28 @@ } } }, + "onDevice" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On your {deviceType} device" + } + } + } + }, + "onDeviceDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open this {app_name} account on an {deviceType} device logged into the {platformAccount} you originally signed up with. Then, change your plan via the Session Pro settings." + } + } + } + }, "onionRoutingPath" : { "extractionState" : "manual", "localizations" : { @@ -329069,6 +328700,17 @@ } } }, + "openStoreWebsite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open {platformStore} Website" + } + } + } + }, "openSurvey" : { "extractionState" : "manual", "localizations" : { @@ -330169,967 +329811,25 @@ } } }, - "passwordChangedDescription" : { + "passwordChangedDescriptionToast" : { "extractionState" : "manual", "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jou wagwoord is verander. Hou dit asseblief veilig." - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تم تغيير كلمة المرور الخاصة بك. احفظها في مامن." - } - }, - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Parolunuz dəyişdirildi. Lütfən, onu güvəndə saxlayın." - } - }, - "bal" : { - "stringUnit" : { - "state" : "translated", - "value" : "ما گپ درخواست قبول کردی بیک اپلیکیشن پاسکوڈ ناقض کردی. براہپس محفوظے کہ." - } - }, - "be" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваш пароль быў зменены. Захавайце яго ў бяспецы." - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вашата парола беше променена. Моля, пазете я безопасно." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "আপনার পাসওয়ার্ড পরিবর্তন করা হয়েছে। দয়া করে এটি নিরাপদ রাখুন।" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "La vostra contrasenya s'ha definit. Mantingueu-la segura." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tvé heslo bylo změněno. Pečlivě si ho odlož." - } - }, - "cy" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mae eich cyfrinair wedi'i newid. Cadwch ef yn ddiogel." - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Din adgangskode er blevet ændret. Venligst hold den sikker." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dein Passwort wurde geändert. Bitte bewahre es sicher auf." - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ο κωδικός πρόσβασής σας έχει αλλάξει. Παρακαλώ κρατήστε τον ασφαλή." - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Your password has been changed. Please keep it safe." } - }, - "eo" : { - "stringUnit" : { - "state" : "translated", - "value" : "Via pasvorto estas ŝanĝita. Bonvolu konservi ĝin sekura." - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tu contraseña ha sido cambiada. Por favor, manténla segura." - } - }, - "es-ES" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tu contraseña ha sido cambiada. Por favor, manténla segura." - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "Teie parool on muudetud. Hoidke seda turvaliselt." - } - }, - "eu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zure pasahitza aldatu da. Gorde seguru batean." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رمز عبور شما تغییر کرد. لطفا آن را در جای امنی نگهداری کنید." - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Salasanasi on vaihdettu. Pidä se turvassa." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nabago na ang iyong password. Pakisuyong itago ito." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre mot de passe a été changé. Veuillez le conserver en sécurité." - } - }, - "gl" : { - "stringUnit" : { - "state" : "translated", - "value" : "O teu contrasinal foi cambiado. Por favor, mantéñeo seguro." - } - }, - "ha" : { - "stringUnit" : { - "state" : "translated", - "value" : "An canza kalmar sirrinku. Da fatan za a kiyaye shi lafiya." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הסיסמה שלך השתנתה. שמור עליה בבטחה." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "आपका पासवर्ड बदल दिया गया है। कृपया इसे सुरक्षित रखें।" - } - }, - "hr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaša lozinka je promijenjena. Molimo, čuvajte je na sigurnom." - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "A jelszó megváltozott. Tartsd biztonságos helyen!" - } - }, - "hy-AM" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ձեր գաղտնաբառը փոխվել է։ Խնդրում ենք անվտանգ պահել։" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kata sandi anda telah diubah. Harap untuk menjaganya." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "La tua password è stata modificata. Per favore tienila al sicuro." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "パスワードが変更されました。安全に保管してください。" - } - }, - "ka" : { - "stringUnit" : { - "state" : "translated", - "value" : "თქვენი პაროლი შეცვლილია. გთხოვთ, შეინახეთ იგი უსაფრთხოდ." - } - }, - "km" : { - "stringUnit" : { - "state" : "translated", - "value" : "ពាក្យសម្ងាត់ របស់អ្នកត្រូវ​បាន​ប្តូរ។ សូមរក្សា​វា​ឲ្យ​មាន​សុវត្ថិភាព។" - } - }, - "kn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ನಿಮ್ಮ ಗುಪ್ತಪದವನ್ನು ಬದಲಾಯಿಸಲಾಗಿದೆ. ಅದು ಸುರಕ್ಷಿತವಾಗಿರಿಸಿ." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "비밀번호 변경이 완료되었습니다. 안전히 관리하시기 바랍니다." - } - }, - "ku" : { - "stringUnit" : { - "state" : "translated", - "value" : "وشەی پەرەسەدت گۆڕدرا. تکایە ئەوە بەندەن پارێزەر بێت." - } - }, - "ku-TR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Te jîrêbandeya we yê danîn Muhafize mane sihîn bike." - } - }, - "lg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Password yo ekabatiddwa. Kaakasa nti bagutemye mu kifo ekitalemerera." - } - }, - "lt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jūsų slaptažodis buvo pakeistas. Prašome saugoti jį saugiai." - } - }, - "lv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jūsu parole tika nomainīta. Lūdzu, saglabājiet to drošībā." - } - }, - "mk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вашата лозинка е променета. Ве молиме чувајте ја безбедно." - } - }, - "mn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Таны нууц үг солигдож байна. Нууц үгээ хамгаалж байгаарай." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kata laluan anda telah ditukar. Sila simpan dengan selamat." - } - }, - "my" : { - "stringUnit" : { - "state" : "translated", - "value" : "သင်၏ စကားဝှက် ပြောင်းလဲ ပြီးပါပြီ။ ထိန်းသိမ်းပါ။" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Passordet ditt er endret. Vennligst oppbevar det trygt." - } - }, - "nb-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Passordet ditt er endret. Vennligst oppbevar det trygt." - } - }, - "ne-NP" : { - "stringUnit" : { - "state" : "translated", - "value" : "तपाईँको पासवर्ड परिवर्तन भयो। कृपया यसलाई सुरक्षित राख्नुहोस्।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Uw wachtwoord is gewijzigd. Hou het veilig." - } - }, - "nn-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Passordet ditt er blitt endra. Vennligst oppbevar det trygt." - } - }, - "ny" : { - "stringUnit" : { - "state" : "translated", - "value" : "Password yanu yasinthidwa. Chonde sungani mosamala." - } - }, - "pa-IN" : { - "stringUnit" : { - "state" : "translated", - "value" : "ਤੁਹਾਡਾ ਪਾਸਵਰਡ ਬਦਲਿਆ ਗਿਆ ਹੈ। ਕਿਰਪਾ ਕਰਕੇ ਇਸਨੂੰ ਸੁਰੱਖਿਅਤ ਰੱਖੋ।" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zmieniono hasło. Zachowaj je w bezpiecznym miejscu." - } - }, - "ps" : { - "stringUnit" : { - "state" : "translated", - "value" : "ستاسو پاسورډ بدل شوی. مهرباني وکړۍ، دا خوندي وساتئ." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sua senha foi alterada. Por favor, mantenha-a segura." - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "A sua palavra-passe foi alterada. Por favor, mantenha-a segura." - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Parola ta a fost schimbată. Te rugăm să o păstrezi în siguranță." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваш пароль был изменен. Пожалуйста, храните его в безопасном месте." - } - }, - "sh" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tvoja šifra je promijenjena. Molimo, čuvaj je na sigurnom." - } - }, - "si-LK" : { - "stringUnit" : { - "state" : "translated", - "value" : "ඔබගේ මුරපදය වෙනස් කර ඇත. කරුණාකර එය ආරක්ෂිතව තබා ගන්න." - } - }, - "sk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaše heslo bolo zmenené. Uchovajte ho prosím v bezpečí." - } - }, - "sl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaše geslo je bilo spremenjeno. Prosim, hranite ga na varnem mestu." - } - }, - "sq" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fjalëkalimi juaj është ndryshuar. Ju lutemi ta mbani të sigurt." - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваша лозинка је промењена. Молимо вас да је сачувате." - } - }, - "sr-Latn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaša lozinka je promenjena. Čuvajte je na sigurnom mestu." - } - }, - "sv-SE" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ditt lösenord har ändrats. Håll det säkert." - } - }, - "sw" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nenosiri lako limebadilishwa. Tafadhali lihifadhi salama." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "உங்களின் கடவுச்சொல் மாற்றப்பட்டுள்ளது. தயவுசெய்து அதை பாதுகாப்பாக வைத்திருங்கள்." - } - }, - "te" : { - "stringUnit" : { - "state" : "translated", - "value" : "మీ పాస్‌వర్డ్ మార్పు జరిగింది. దయచేసి దాన్ని సురక్షితంగా ఉంచండి." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "รหัสผ่านของคุณได้รับการเปลี่ยนแปลงแล้ว กรุณารักษาเอาไว้ให้ปลอดภัย" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Şifreniz değiştirildi. Lütfen güvende tutunuz." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваш пароль змінено. Будь ласка, збережіть його в безпеці." - } - }, - "ur-IN" : { - "stringUnit" : { - "state" : "translated", - "value" : "آپ کا پاس ورڈ تبدیل ہو گیا ہے۔ براہ کرم اسے محفوظ رکھیں۔" - } - }, - "uz" : { - "stringUnit" : { - "state" : "translated", - "value" : "Xabar so'rovingiz hozirda kutilmoqda." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mật khẩu của bạn đã được đổi. Hãy giữ nó cẩn thận." - } - }, - "xh" : { - "stringUnit" : { - "state" : "translated", - "value" : "Iphasiwedi yakho itshintshiwe. Nceda uyigcine ikhuselekile." - } - }, - "zh-CN" : { - "stringUnit" : { - "state" : "translated", - "value" : "您的密码已经设定。请妥善保管。" - } - }, - "zh-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "您的密碼變更完成。請注意保管。" - } } } }, - "passwordChangeDescription" : { + "passwordChangeShortDescription" : { "extractionState" : "manual", "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verander die wagwoord wat benodig word om {app_name} te ontsluit." - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تغيير كلمة السر المطلوبة لفتح {app_name}." - } - }, - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} kilidini açmaq üçün tələb olunan parolu dəyişdir." - } - }, - "bal" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} کو ان لاک کرنے کے لئے درکار پاس ورڈ تبدیل کریں۔" - } - }, - "be" : { - "stringUnit" : { - "state" : "translated", - "value" : "Змяніць пароль для разблакоўкі {app_name}." - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Сменете паролата, изисквана за отключване на {app_name}." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} আনলক করতে প্রয়োজনীয় পাসওয়ার্ড পরিবর্তন করুন।" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Canvia la contrasenya requerida per desblocar {app_name}." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Změňte heslo pro odemykání {app_name}." - } - }, - "cy" : { - "stringUnit" : { - "state" : "translated", - "value" : "Newid y cyfrinair sy'n angenrheidiol i ddatgloi {app_name}." - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skift adgangskoden, der kræves for at låse {app_name} op." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Das Passwort zum Entsperren von {app_name} ändern." - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Αλλαγή του κωδικού πρόσβασης που απαιτείται για το ξεκλείδωμα του {app_name}." - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Change the password required to unlock {app_name}." } - }, - "eo" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ŝanĝi la pasvorton, kiu necesas por malŝlosi {app_name}." - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cambiar la contraseña necesaria para desbloquear {app_name}." - } - }, - "es-ES" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cambiar la contraseña requerida para desbloquear {app_name}." - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "Muuda parooli, mida on vaja {app_name} avamiseks." - } - }, - "eu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Change the password required to unlock {app_name}." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رمز عبور مورد نیاز برای باز کردن {app_name} را تغییر بده." - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaihda {app_name} in avaukseen käytettävä salasana." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Palitan ang password na kinakailangan para i-unlock ang {app_name}." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modifier le mot de passe requis pour déverrouiller {app_name}" - } - }, - "gl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cambia o contrasinal necesario para desbloquear {app_name}." - } - }, - "ha" : { - "stringUnit" : { - "state" : "translated", - "value" : "Canza kalmar sirrin da ake bukata don buɗe {app_name}." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שנה את הסיסמה הנדרשת לפתיחת {app_name}." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} को अनलॉक करने के लिए आवश्यक पासवर्ड बदलें।" - } - }, - "hr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Promijenite lozinku potrebnu za otključavanje {app_name}." - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} alkalmazás jelszavának megváltoztatása." - } - }, - "hy-AM" : { - "stringUnit" : { - "state" : "translated", - "value" : "Փոխեք {app_name}-ն ապակողպելու համար պահանջվող գաղտնաբառը:" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ubah kata sandi yang diperlukan untuk membuka kunci {app_name}." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cambia la password richiesta per sbloccare {app_name}." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name}のロック解除に必要なパスワードを変更します" - } - }, - "ka" : { - "stringUnit" : { - "state" : "translated", - "value" : "პაროლის შეცვლა აუცილებელია {app_name}-ის გახსნისთვის." - } - }, - "km" : { - "stringUnit" : { - "state" : "translated", - "value" : "ប្ដូរពាក្យសម្ងាត់ដែលបានតម្រូវឲ្យមានដើម្បីឈប់ទប់ស្កាត់ {app_name}។" - } - }, - "kn" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} ತೆಗೆಯಲು ಬೇಕಾದ ಪಾಸ್ವರ್ಡ್ ಬದಲಾಯಿಸಿ." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} 잠금 해제 시 사용되는 비밀번호를 변경합니다." - } - }, - "ku" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} وشە نهێنی بگۆڕە بۆ کردنەوەی" - } - }, - "ku-TR" : { - "stringUnit" : { - "state" : "translated", - "value" : "şîfreyê ku ji bo vekirina {app_name} lazim e biguherîne." - } - }, - "lg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Change the password required to unlock {app_name}." - } - }, - "lo" : { - "stringUnit" : { - "state" : "translated", - "value" : "ປ່ຽນລະຫັດຕົກທາງທີ່ຈະເຜີດ {app_name}." - } - }, - "lt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pakeisti slaptažodį, reikalingą atrakinti {app_name}." - } - }, - "lv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mainīt paroli, kas nepieciešama {app_name} atbloķēšanai." - } - }, - "mk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Смени ја лозинката што е потребна за отклучување {app_name}." - } - }, - "mn" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} -г нээхийн тулд шаардлагатай нууц үгийг өөрчлөх." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tukar kata laluan yang diperlukan untuk membuka kunci {app_name}." - } - }, - "my" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} ဖြင့် လော့ခ်ဖွင့်ရန် လျှို့ဝှက် စကားဝှက် ပြောင်းပါ" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Endre passordet som kreves for å låse opp {app_name}." - } - }, - "nb-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Endre passordet som kreves for å låse opp {app_name}." - } - }, - "ne-NP" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} अनलक गर्न आवश्यक पासवर्ड परिवर्तन गर्नुहोस्।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wijzig het wachtwoord dat nodig is om {app_name} te ontgrendelen." - } - }, - "nn-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Endre passordet som krevst for å låsa opp {app_name}." - } - }, - "ny" : { - "stringUnit" : { - "state" : "translated", - "value" : "Change the password required to unlock {app_name}." - } - }, - "pa-IN" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} ਨੂੰ ਅਨਲੌਕ ਕਰਨ ਲਈ ਲੋੜੀਂਦੇ ਪਾਸਵਰਡ ਨੂੰ ਬਦਲੋ।" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zmień hasło wymagane do odblokowania aplikacji {app_name}." - } - }, - "ps" : { - "stringUnit" : { - "state" : "translated", - "value" : "د {app_name} خلاصولو لپاره اړین پاسورډ بدل کړئ." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Altere a senha necessária para desbloquear {app_name}." - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Altere a palavra-passe, necessária para desbloquear {app_name}." - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Schimbați parola necesară pentru a debloca {app_name}." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Измените пароль, необходимый для разблокировки {app_name}." - } - }, - "sh" : { - "stringUnit" : { - "state" : "translated", - "value" : "Promeni lozinku potrebnu za otključavanje {app_name}." - } - }, - "si-LK" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} අගුළු විවෘත කිරීමට අවශ්‍ය මුරපදය වෙනස් කරන්න." - } - }, - "sk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zmeňte heslo potrebné na odomknutie {app_name}." - } - }, - "sl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Spremeni geslo potrebno za odklepanje {app_name}." - } - }, - "sq" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ndryshoni fjalëkalimin e kërkuar për të zhbllokuar {app_name}." - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Измените лозинку потребну за откључавање {app_name}." - } - }, - "sr-Latn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Promenite lozinku koja je potrebna za otključavanje {app_name}." - } - }, - "sv-SE" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ändra lösenordet som krävs för att låsa upp {app_name}." - } - }, - "sw" : { - "stringUnit" : { - "state" : "translated", - "value" : "Badilisha nywila inayohitajika kufungua {app_name}." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} ேத்தUnlock ச்சபட செய்ய வேண்டிய கடவுச்சொல்லை மாற்றவும்." - } - }, - "te" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} ని అన్‌లాక్ చేయడానికి అవసరమైన పాస్‌వర్డ్ మార్చండి." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เปลี่ยนรหัสผ่านที่ใช้ปลดล็อก {app_name}" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} kilidini açmak için gereken parolayı değiştirin." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Змінити пароль, необхідний для розблокування {app_name}." - } - }, - "ur-IN" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} کو ان لاک کرنے کے لیے مطلوبہ پاس ورڈ تبدیل کریں۔" - } - }, - "uz" : { - "stringUnit" : { - "state" : "translated", - "value" : "O'zingizga {app_name}ni ochish uchun zarur parolni o'zgartiring." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Đổi mật khẩu cần thiết để mở khóa {app_name}." - } - }, - "xh" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tshintsha i-password efunekayo ukusikhulula {app_name}." - } - }, - "zh-CN" : { - "stringUnit" : { - "state" : "translated", - "value" : "更改{app_name}的解锁密码" - } - }, - "zh-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "更改解鎖 {app_name} 所需的密碼。" - } } } }, @@ -332108,17 +330808,6 @@ } } }, - "passwordDescription" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Require password to unlock {app_name} on startup." - } - } - } - }, "passwordEnter" : { "extractionState" : "manual", "localizations" : { @@ -336075,492 +334764,24 @@ } } }, - "passwordRemovedDescription" : { + "passwordRemovedDescriptionToast" : { "extractionState" : "manual", "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jou wagwoord is verwyder." - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تمت إزالة كلمة السر الخاصة بك." - } - }, - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Parolunuz silindi." - } - }, - "bal" : { - "stringUnit" : { - "state" : "translated", - "value" : "ما گپ درخواست قبول کردی بیک پاسکوڈ ہٹاٹی." - } - }, - "be" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваш пароль быў выдалены." - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вашата парола беше премахната." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "আপনার পাসওয়ার্ড সরানো হয়েছে।" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "La vostra contrasenya s'ha eliminat." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaše heslo bylo odstraněno." - } - }, - "cy" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mae eich cyfrinair wedi'i dynnu." - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Din adgangskode er blevet fjernet." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dein Passwort wurde entfernt." - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ο κωδικός πρόσβασής σας έχει αφαιρεθεί." - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Your password has been removed." } - }, - "eo" : { - "stringUnit" : { - "state" : "translated", - "value" : "Via pasvorto estas forigita." - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tu contraseña ha sido eliminada." - } - }, - "es-ES" : { - "stringUnit" : { - "state" : "translated", - "value" : "Has eliminado tu contraseña." - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "Teie parool on eemaldatud." - } - }, - "eu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zure pasahitza kendu da." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گذرواژه شما حذف شده است." - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Salasanasi on on poistettu." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ang iyong password ay naalis na." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre mot de passe a été supprimé." - } - }, - "gl" : { - "stringUnit" : { - "state" : "translated", - "value" : "O teu contrasinal foi eliminado." - } - }, - "ha" : { - "stringUnit" : { - "state" : "translated", - "value" : "An cire kalmar sirrinku." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הסיסמה שלך הוסרה." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "आपका पासवर्ड हटा दिया गया है।" - } - }, - "hr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaša lozinka je uklonjena." - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "A jelszavadat eltávolítottuk." - } - }, - "hy-AM" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ձեր գաղտնաբառը հեռացվել է։" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kata sandi Anda telah dihapus." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "La tua password è stata rimossa." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "パスワードを削除しました。" - } - }, - "ka" : { - "stringUnit" : { - "state" : "translated", - "value" : "თქვენი პაროლი წაშლილია." - } - }, - "km" : { - "stringUnit" : { - "state" : "translated", - "value" : "ពាក្យសម្ងាត់ របស់អ្នកត្រូវបានលុបចេញ។" - } - }, - "kn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ನಿಮ್ಮ ಗುಪ್ತಪದವನ್ನು ತೆಗೆದುಹಾಕಲಾಗಿದೆ." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "당신의 비밀번호가 제거되었습니다." - } - }, - "ku" : { - "stringUnit" : { - "state" : "translated", - "value" : "وشەی پەرەسەدت وەکبێژاند." - } - }, - "ku-TR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zoom" - } - }, - "lg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Password yo ekatutuzzibwa." - } - }, - "lt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jūsų slaptažodis buvo pašalintas." - } - }, - "lv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jūsu parole tika noņemta." - } - }, - "mk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вашата лозинка е отстранета." - } - }, - "mn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Таны нууц үг устгагдсан." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kata laluan anda telah dibuang." - } - }, - "my" : { - "stringUnit" : { - "state" : "translated", - "value" : "သင်၏ စကားဝှက် ဖယ်ရှားပြီးပါပြီ။" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Passordet ditt er fjernet." - } - }, - "nb-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Passordet ditt har blitt fjernet." - } - }, - "ne-NP" : { - "stringUnit" : { - "state" : "translated", - "value" : "तपाईँको पासवर्ड हटाइएको छ।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Uw wachtwoord is verwijderd." - } - }, - "nn-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Passordet ditt er blitt fjerna." - } - }, - "ny" : { - "stringUnit" : { - "state" : "translated", - "value" : "Password yanu yachotsedwa." - } - }, - "pa-IN" : { - "stringUnit" : { - "state" : "translated", - "value" : "ਤੁਹਾਡਾ ਪਾਸਵਰਡ ਹਟਾ ਦਿੱਤਾ ਗਿਆ ਹੈ।" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Usunięto hasło" - } - }, - "ps" : { - "stringUnit" : { - "state" : "translated", - "value" : "ستاسو پاسورډ لرې شوی دی." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sua senha foi removida." - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "A sua palavra-passe foi removida." - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Parola ta a fost eliminată." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваш пароль удален." - } - }, - "sh" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tvoja šifra je uklonjena." - } - }, - "si-LK" : { - "stringUnit" : { - "state" : "translated", - "value" : "ඔබගේ මුරපදය ඉවත් කර ඇත." - } - }, - "sk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaše heslo bolo odstránené." - } - }, - "sl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaše geslo je bilo odstranjeno." - } - }, - "sq" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fjalëkalimi juaj është hequr." - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваша лозинка је уклоњена." - } - }, - "sr-Latn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaša lozinka je uklonjena." - } - }, - "sv-SE" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ditt lösenord har tagits bort." - } - }, - "sw" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nenosiri lako limeondolewa." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "உங்களின் கடவுச்சொல் நீக்கப்பட்டது." - } - }, - "te" : { - "stringUnit" : { - "state" : "translated", - "value" : "మీ పాస్‌వర్డ్ తొలగించబడింది." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "รหัสผ่านของคุณถูกลบแล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Parolanız kaldırıldı." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваш пароль був видалений." - } - }, - "ur-IN" : { - "stringUnit" : { - "state" : "translated", - "value" : "آپ کا پاس ورڈ ہٹا دیا گیا ہے۔" - } - }, - "uz" : { - "stringUnit" : { - "state" : "translated", - "value" : "Parolingiz saqlandi. Iltimos, uni xavfsiz joyda saqlang." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mật khẩu của bạn đã được gỡ bỏ." - } - }, - "xh" : { - "stringUnit" : { - "state" : "translated", - "value" : "Iphasiwedi yakho isusiwe." - } - }, - "zh-CN" : { - "stringUnit" : { - "state" : "translated", - "value" : "您的密码已被移除。" - } - }, - "zh-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "已移除密碼。" - } } } }, - "passwordRemoveDescription" : { + "passwordRemoveShortDescription" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Remove your current password for {app_name}. Locally stored data will be re-encrypted with a randomly generated key, stored on your device." + "value" : "Remove the password required to unlock {app_name}" } } } @@ -337055,13 +335276,24 @@ } } }, - "passwordSetDescription" : { + "passwordSetDescriptionToast" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Set a password for {app_name}. Locally stored data will be encrypted with this password. You will be asked to enter this password each time {app_name} starts." + "value" : "Your password has been set. Please keep it safe." + } + } + } + }, + "passwordSetShortDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Require password to unlock {app_name} on startup." } } } @@ -351180,6 +349412,28 @@ } } }, + "plusLoadsMore" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plus Loads More..." + } + } + } + }, + "plusLoadsMoreDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New features coming soon to {pro}. Discover what's next on the {pro} Roadmap" + } + } + } + }, "preferences" : { "extractionState" : "manual", "localizations" : { @@ -351806,6 +350060,28 @@ } } }, + "proAllSet" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You're all set!" + } + } + } + }, + "proAllSetDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {app_pro} plan was updated! You will be billed when your current {pro} plan is automatically renewed on {date}." + } + } + } + }, "proAlreadyPurchased" : { "extractionState" : "manual", "localizations" : { @@ -352449,6 +350725,28 @@ } } }, + "proAnimatedDisplayPictures" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animated Display Pictures" + } + } + } + }, + "proAnimatedDisplayPicturesDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set animated GIFs and WebP images as your display picture." + } + } + } + }, "proAnimatedDisplayPicturesNonProModalDescription" : { "extractionState" : "manual", "localizations" : { @@ -352580,6 +350878,17 @@ } } }, + "proAutoRenewTime" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} auto-renewing in {time}" + } + } + } + }, "proBadge" : { "extractionState" : "manual", "localizations" : { @@ -352699,6 +351008,83 @@ } } }, + "proBadges" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Badges" + } + } + } + }, + "proBadgesDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show your support for {app_name} with an exclusive badge next to your display name." + } + } + } + }, + "proBadgesSent" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{count} {pro} Badges Sent" + } + } + } + }, + "proBadgeVisible" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show {app_pro} badge to other users" + } + } + } + }, + "proBilledAnnually" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{price} Billed Annually" + } + } + } + }, + "proBilledMonthly" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{price} Billed Monthly" + } + } + } + }, + "proBilledQuarterly" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{price} Billed Quarterly" + } + } + } + }, "proCallToActionLongerMessages" : { "extractionState" : "manual", "localizations" : { @@ -353098,6 +351484,94 @@ } } }, + "processingRefundRequest" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{platformAccount} is processing your refund request" + } + } + } + }, + "proDiscountTooltip" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your current plan is already discounted by\r\n{percent}% of the full {app_pro} price." + } + } + } + }, + "proExpired" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expired" + } + } + } + }, + "proExpiredDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unfortunately, your {pro} plan has expired. Renew to keep accessing the exclusive perks and features of {app_pro}." + } + } + } + }, + "proExpiringSoon" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expiring Soon" + } + } + } + }, + "proExpiringSoonDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {pro} plan is expiring in {time}. Update your plan to keep accessing the exclusive perks and features of {app_pro}." + } + } + } + }, + "proFaq" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} FAQ" + } + } + } + }, + "proFaqDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Find answers to common questions in the {app_name} FAQ." + } + } + } + }, "proFeatureListAnimatedDisplayPicture" : { "extractionState" : "manual", "localizations" : { @@ -353765,6 +352239,17 @@ } } }, + "proFeatures" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} Features" + } + } + } + }, "profile" : { "extractionState" : "manual", "localizations" : { @@ -356883,6 +355368,28 @@ } } }, + "proGroupsUpgraded" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{count} Groups Upgraded" + } + } + } + }, + "proImportantDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requesting a refund is final. If approved, your {pro} plan will be canceled immediately and you will lose access to all {pro} features." + } + } + } + }, "proIncreasedAttachmentSizeFeature" : { "extractionState" : "manual", "localizations" : { @@ -357121,6 +355628,61 @@ } } }, + "proLargerGroups" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Larger Groups" + } + } + } + }, + "proLargerGroupsDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Groups you are an admin in are automatically upgraded to support 300 members." + } + } + } + }, + "proLongerMessages" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Longer Messages" + } + } + } + }, + "proLongerMessagesDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You can send messages up to 10,000 characters in all conversations." + } + } + } + }, + "proLongerMessagesSent" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{count} Longer Messages Sent" + } + } + } + }, "proMessageInfoFeatures" : { "extractionState" : "manual", "localizations" : { @@ -359333,6 +357895,303 @@ } } }, + "proPinnedConversations" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{count} Pinned Conversations" + } + } + } + }, + "proPlanActivatedAuto" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {app_pro} plan is active!

Your plan will automatically renew for another {time} on {date}. Updates to your plan take effect when {pro} is next renewed." + } + } + } + }, + "proPlanActivatedAutoShort" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {app_pro} plan is active!

Your plan will automatically renew for another {time} on {date}." + } + } + } + }, + "proPlanActivatedNotAuto" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {app_pro} plan will expire on {date}.

Update your plan now to ensure uninterrupted access to exclusive Pro features." + } + } + } + }, + "proPlanExpireDate" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {app_pro} plan will expire on {date}." + } + } + } + }, + "proPlanNotFound" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} Plan Not Found" + } + } + } + }, + "proPlanNotFoundDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No active plan was found for your account. If you believe this is a mistake, please reach out to {app_name} support for assistance." + } + } + } + }, + "proPlanRecover" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recover {pro} Plan" + } + } + } + }, + "proPlanRenew" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew {pro} Plan" + } + } + } + }, + "proPlanRenewDesktop" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Currently, {pro} plans can only be purchased and renewed via the {platformStore} or {platformStore} Stores. Because you are using {app_name} Desktop, you're not able to renew your plan here.

{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platformStore} and {platformStore} Stores. {pro} Roadmap" + } + } + } + }, + "proPlanRenewDesktopLinked" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew your plan in the {app_pro} settings on a linked device with {app_name} installed via the {platformStore} or {platformStore} Store." + } + } + } + }, + "proPlanRenewDesktopStore" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew your plan on the {platformStore} website using the {platformAccount} you signed up for {pro} with." + } + } + } + }, + "proPlanRenewStart" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew your {app_pro} plan to start using powerful {app_pro} features again." + } + } + } + }, + "proPlanRenewSupport" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {app_pro} plan has been renewed! Thank you for supporting the {network_name}." + } + } + } + }, + "proPlanRestored" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} Plan Restored" + } + } + } + }, + "proPlanRestoredDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A valid plan for {app_pro} was detected and your {pro} status has been restored!" + } + } + } + }, + "proPriceOneMonth" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 Month - {monthlyPrice} / Month" + } + } + } + }, + "proPriceThreeMonths" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "3 Months - {monthlyPrice} / Month" + } + } + } + }, + "proPriceTwelveMonths" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "12 Months - {monthlyPrice} / Month" + } + } + } + }, + "proRefundDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "We’re sorry to see you go. Here's what you need to know before requesting a refund." + } + } + } + }, + "proRefunding" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refunding {pro}" + } + } + } + }, + "proRefundingDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refunds for {app_pro} plans are handled exclusively by {platformAccount} through the {platformStore} Store.

Due to {platformAccount} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued." + } + } + } + }, + "proRefundNextSteps" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{platformAccount} is now processing your refund request. This typically takes 24-48 hours. Depending on their decision, you may see your {pro} status change in {app_name}." + } + } + } + }, + "proRefundRequestSessionSupport" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your refund request will be handled by {app_name} Support.

Request a refund by hitting the button below and completing the refund request form.

While {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume." + } + } + } + }, + "proRefundRequestStorePolicies" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your refund request will be handled exclusively by {platformAccount} through the {platformAccount} website.

Due to {platformAccount} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued." + } + } + } + }, + "proRefundSupport" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please contact {platformAccount} for further updates on your refund request. Due to {platformAccount} refund policies, {app_name} developers have no ability to influence the outcome of refund requests.

{platformStore} Refund Support" + } + } + } + }, + "proRequestedRefund" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refund Requested" + } + } + } + }, "proSendMore" : { "extractionState" : "manual", "localizations" : { @@ -359470,6 +358329,72 @@ } } }, + "proStats" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {pro} Stats" + } + } + } + }, + "proStatsTooltip" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} stats reflect usage on this device and may appear differently on linked devices" + } + } + } + }, + "proSupportDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Need help with your {pro} plan? Submit a request to the support team." + } + } + } + }, + "proTosPrivacy" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "By updating, you agree to the {app_pro} Terms of Service and Privacy Policy" + } + } + } + }, + "proUnlimitedPins" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlimited Pins" + } + } + } + }, + "proUnlimitedPinsDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Organize all your chats with unlimited pinned conversations." + } + } + } + }, "proUserProfileModalCallToAction" : { "extractionState" : "manual", "localizations" : { @@ -376291,6 +375216,28 @@ } } }, + "removePasswordModalDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove your current password for {app_name}. Locally stored data will be re-encrypted with a randomly generated key, stored on your device." + } + } + } + }, + "renew" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew" + } + } + } + }, "reply" : { "extractionState" : "manual", "localizations" : { @@ -376770,6 +375717,17 @@ } } }, + "requestRefund" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Request Refund" + } + } + } + }, "resend" : { "extractionState" : "manual", "localizations" : { @@ -397802,6 +396760,17 @@ } } }, + "sessionProBeta" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{app_pro} Beta" + } + } + } + }, "sessionRecoveryPassword" : { "extractionState" : "manual", "localizations" : { @@ -399400,6 +398369,17 @@ } } }, + "setPasswordModalDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set a password for {app_name}. Locally stored data will be encrypted with this password. You will be asked to enter this password each time {app_name} starts." + } + } + } + }, "settingsRestartDescription" : { "extractionState" : "manual", "localizations" : { @@ -407279,6 +406259,17 @@ } } }, + "theReturn" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Return" + } + } + } + }, "tooltipAccountIdVisible" : { "extractionState" : "manual", "localizations" : { @@ -414274,6 +413265,39 @@ } } }, + "updatePlan" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update Plan" + } + } + } + }, + "updatePlanNonOriginator" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Because you originally signed up for {app_pro} via a {platformAccount}, you'll need to use the same {platformAccount} to update your plan." + } + } + } + }, + "updatePlanTwo" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Two ways to update your plan:" + } + } + } + }, "updateProfileInformation" : { "extractionState" : "manual", "localizations" : { @@ -418293,6 +417317,17 @@ } } }, + "urlOpenDescriptionAlternative" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Links will open in your browser." + } + } + } + }, "useFastMode" : { "extractionState" : "manual", "localizations" : { @@ -418772,6 +417807,28 @@ } } }, + "viaStoreWebsite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Via the {platformStore} website" + } + } + } + }, + "viaStoreWebsiteDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change your plan using the {platformAccount} you used to sign up with, via the {platformStore} website." + } + } + } + }, "video" : { "extractionState" : "manual", "localizations" : { diff --git a/SessionUIKit/Style Guide/Constants.swift b/SessionUIKit/Style Guide/Constants.swift index 7ff7fa8564..85ccadd231 100644 --- a/SessionUIKit/Style Guide/Constants.swift +++ b/SessionUIKit/Style Guide/Constants.swift @@ -15,4 +15,6 @@ public enum Constants { public static let usd_name_short: String = "USD" public static let session_network_data_price: String = "Price data powered by CoinGecko
Accurate at {date_time}" public static let app_pro: String = "Session Pro" + public static let session_foundation: String = "Session Foundation" + public static let pro: String = "Pro" } From de7c0b0e74e29cc008d7e59328511f13b606816b Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 25 Aug 2025 12:02:00 +1000 Subject: [PATCH 015/162] feat: group activated pro modal --- .../Settings/ThreadSettingsViewModel.swift | 27 +++++++++---------- .../Components/SwiftUI/ProCTAModal.swift | 21 +++++++++++---- .../Utilities/UILabel+Utilities.swift | 2 +- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 03e6d6de0c..0c89a21ebb 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -29,8 +29,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi self?.onDisplayPictureSelected?(.image(identifier: identifier, data: resultImageData)) } ) - private var previousProfileImageStatus: ProfileImageStatus? - private var currentProfileImageStatus: ProfileImageStatus? + private var profileImageStatus: (previous: ProfileImageStatus?, current: ProfileImageStatus?) // TODO: Refactor this with SessionThreadViewModel private var threadViewModelSubject: CurrentValueSubject @@ -47,8 +46,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi self.threadVariant = threadVariant self.didTriggerSearch = didTriggerSearch self.threadViewModelSubject = CurrentValueSubject(nil) - self.previousProfileImageStatus = nil - self.currentProfileImageStatus = .normal + self.profileImageStatus = (previous: nil, current: .normal) } // MARK: - Config @@ -195,12 +193,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi .compactMap { [weak self] current -> [SectionModel]? in self?.content( current, - currentProfileImageStatus: self?.currentProfileImageStatus, - previousProfileImageStatus: self?.previousProfileImageStatus + profileImageStatus: self?.profileImageStatus ) } - private func content(_ current: State, currentProfileImageStatus: ProfileImageStatus?, previousProfileImageStatus: ProfileImageStatus?) -> [SectionModel] { + private func content(_ current: State, profileImageStatus: (previous: ProfileImageStatus?, current: ProfileImageStatus?)?) -> [SectionModel] { // If we don't get a `SessionThreadViewModel` then it means the thread was probably deleted // so dismiss the screen guard let threadViewModel: SessionThreadViewModel = current.threadViewModel else { @@ -235,7 +232,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let conversationInfoSection: SectionModel = SectionModel( model: .conversationInfo, elements: [ - (currentProfileImageStatus == .qrCode ? + (profileImageStatus?.current == .qrCode ? SessionCell.Info( id: .qrCode, accessory: .qrCode( @@ -250,8 +247,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi backgroundStyle: .noBackground ), onTap: { [weak self] in - self?.currentProfileImageStatus = previousProfileImageStatus - self?.previousProfileImageStatus = currentProfileImageStatus + self?.profileImageStatus = (previous: profileImageStatus?.current, current: profileImageStatus?.previous) self?.forceRefresh(type: .postDatabaseQuery) } ) : @@ -259,7 +255,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi id: .avatar, accessory: .profile( id: threadViewModel.id, - size: (currentProfileImageStatus == .expanded ? .expanded : .hero), + size: (profileImageStatus?.current == .expanded ? .expanded : .hero), threadVariant: threadViewModel.threadVariant, displayPictureUrl: threadViewModel.threadDisplayPictureUrl, profile: threadViewModel.profile, @@ -281,12 +277,13 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi case (.group, _, _): break case (_, _, true): - self?.currentProfileImageStatus = .qrCode - self?.previousProfileImageStatus = currentProfileImageStatus + self?.profileImageStatus = (previous: profileImageStatus?.current, current: .qrCode) self?.forceRefresh(type: .postDatabaseQuery) case (_, _, false): - self?.currentProfileImageStatus = (currentProfileImageStatus == .expanded ? .normal : .expanded) - self?.previousProfileImageStatus = currentProfileImageStatus + self?.profileImageStatus = ( + previous: profileImageStatus?.current, + current: (profileImageStatus?.current == .expanded ? .normal : .expanded) + ) self?.forceRefresh(type: .postDatabaseQuery) } } diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index 9d300c0cef..7f90c201af 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -268,11 +268,22 @@ public struct ProCTAModal: View { } } - Text(variant.subtitle) - .font(.Body.largeRegular) - .foregroundColor(themeColor: .textSecondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) + if + case .groupLimit(_, let isSessionProActivated) = variant, isSessionProActivated, + let proBadgeImage: UIImage = SessionProBadge(size: .small).toImage() + { + (Text(variant.subtitle) + Text(" \(Image(uiImage: proBadgeImage))").baselineOffset(-2)) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } else { + Text(variant.subtitle) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textSecondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } } // Benefits diff --git a/SessionUIKit/Utilities/UILabel+Utilities.swift b/SessionUIKit/Utilities/UILabel+Utilities.swift index 5070d91def..1ab1098384 100644 --- a/SessionUIKit/Utilities/UILabel+Utilities.swift +++ b/SessionUIKit/Utilities/UILabel+Utilities.swift @@ -30,7 +30,7 @@ public extension UILabel { lineBreakMode = .byWordWrapping } - /// Returns true if `point` (in this label's coordinate space) hits a drawn NSTextAttachment. + /// Returns true if `point` (in this label's coordinate space) hits a drawn NSTextAttachment at the end of the string. /// Works with multi-line labels, alignment, and truncation. func isPointOnTrailingAttachment(_ point: CGPoint, hitPadding: CGFloat = 0) -> Bool { guard let attributed = attributedText, attributed.length > 0 else { return false } From 1f979a7d78bd058541690535619ad52adce68fb9 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 25 Aug 2025 14:18:32 +1000 Subject: [PATCH 016/162] feat: user profile modal in message info screen --- .../ConversationVC+Interaction.swift | 8 ++ .../Settings/ThreadSettingsViewModel.swift | 2 +- .../MessageInfoScreen.swift | 73 ++++++++++++++++++- .../Settings/PrivacySettingsViewModel.swift | 6 +- .../Components/SwiftUI/AttributedText.swift | 9 ++- 5 files changed, 93 insertions(+), 5 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 71cc502c84..5307aa06e5 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -2218,6 +2218,14 @@ extension ConversationVC: let messageInfoViewController = MessageInfoViewController( actions: actions, messageViewModel: finalCellViewModel, + threadCanWrite: (viewModel.threadData.threadCanWrite == true), + onStartThread: { [weak self] in + self?.startThread( + with: cellViewModel.authorId, + openGroupServer: cellViewModel.threadOpenGroupServer, + openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey + ) + }, using: viewModel.dependencies ) DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 0c89a21ebb..8ac29aad09 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -2073,7 +2073,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi modal: ProCTAModal( delegate: dependencies[singleton: .sessionProState], variant: variant, - dataManager: dependencies[singleton: .imageDataManager], + dataManager: dependencies[singleton: .imageDataManager] ) ) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index fcc98e5e70..0f23af83e2 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -18,6 +18,8 @@ struct MessageInfoScreen: View { var actions: [ContextMenuVC.Action] var messageViewModel: MessageViewModel + let threadCanWrite: Bool + let onStartThread: (() -> Void)? let dependencies: Dependencies let isMessageFailed: Bool let isCurrentUser: Bool @@ -25,9 +27,17 @@ struct MessageInfoScreen: View { var proFeatures: [String] = [] var proCTAVariant: ProCTAModal.Variant = .generic - public init(actions: [ContextMenuVC.Action], messageViewModel: MessageViewModel, using dependencies: Dependencies) { + public init( + actions: [ContextMenuVC.Action], + messageViewModel: MessageViewModel, + threadCanWrite: Bool, + onStartThread: (() -> Void)?, + using dependencies: Dependencies + ) { self.actions = actions self.messageViewModel = messageViewModel + self.threadCanWrite = threadCanWrite + self.onStartThread = onStartThread self.dependencies = dependencies self.isMessageFailed = [.failed, .failedToSync].contains(messageViewModel.state) @@ -397,6 +407,9 @@ struct MessageInfoScreen: View { } } } + .onTapGesture { + showUserProfileModal() + } } .frame( maxWidth: .infinity, @@ -513,12 +526,62 @@ struct MessageInfoScreen: View { modal: ProCTAModal( delegate: dependencies[singleton: .sessionProState], variant: proCTAVariant, - dataManager: dependencies[singleton: .imageDataManager], + dataManager: dependencies[singleton: .imageDataManager] ) ) self.host.controller?.present(sessionProModal, animated: true) } + func showUserProfileModal() { + guard threadCanWrite else { return } + // FIXME: Add in support for starting a thread with a 'blinded25' id (disabled until we support this decoding) + guard (try? SessionId.Prefix(from: messageViewModel.authorId)) != .blinded25 else { return } + + guard let profileInfo: ProfilePictureView.Info = profileInfo else { return } + + let (sessionId, blindedId): (String?, String?) = { + guard (try? SessionId.Prefix(from: messageViewModel.authorId)) == .blinded15 else { + return (messageViewModel.authorId, nil) + } + let lookup: BlindedIdLookup? = dependencies[singleton: .storage].read { db in + try? BlindedIdLookup.fetchOne(db, id: messageViewModel.authorId) + } + return (lookup?.sessionId, messageViewModel.authorId) + }() + + let qrCodeImage: UIImage? = { + guard let sessionId: String = sessionId else { return nil } + return QRCode.generate(for: sessionId, hasBackground: false, iconName: "SessionWhite40") // stringlint:ignore + }() + + let isMessasgeRequestsEnabled: Bool = { + guard messageViewModel.threadVariant == .community else { return true } + return messageViewModel.profile?.blocksCommunityMessageRequests != true + }() + + let userProfileModal: ModalHostingViewController = ModalHostingViewController( + modal: UserProfileModel( + info: .init( + sessionId: sessionId, + blindedId: blindedId, + qrCodeImage: qrCodeImage, + profileInfo: profileInfo, + displayName: messageViewModel.authorName, + nickname: messageViewModel.profile?.displayName( + for: messageViewModel.threadVariant, + ignoringNickname: true + ), + isProUser: dependencies.mutate(cache: .libSession, { $0.validateProProof(for: messageViewModel.profile) }), + isMessageRequestsEnabled: isMessasgeRequestsEnabled, + onStartThread: self.onStartThread, + onProBadgeTapped: self.showSessionProCTAIfNeeded + ), + dataManager: dependencies[singleton: .imageDataManager], + ) + ) + self.host.controller?.present(userProfileModal, animated: true, completion: nil) + } + private func showMediaFullScreen(attachment: Attachment) { if let mediaGalleryView = MediaGalleryViewModel.createDetailViewController( for: messageViewModel.threadId, @@ -703,11 +766,15 @@ final class MessageInfoViewController: SessionHostingViewController Void)?, using dependencies: Dependencies ) { let messageInfoView = MessageInfoScreen( actions: actions, messageViewModel: messageViewModel, + threadCanWrite: threadCanWrite, + onStartThread: onStartThread, using: dependencies ) @@ -774,6 +841,8 @@ struct MessageInfoView_Previews: PreviewProvider { MessageInfoScreen( actions: actions, messageViewModel: messageViewModel, + threadCanWrite: true, + onStartThread: nil, using: Dependencies.createEmpty() ) } diff --git a/Session/Settings/PrivacySettingsViewModel.swift b/Session/Settings/PrivacySettingsViewModel.swift index 6653336dd1..36c020d21d 100644 --- a/Session/Settings/PrivacySettingsViewModel.swift +++ b/Session/Settings/PrivacySettingsViewModel.swift @@ -532,7 +532,11 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "callsVoiceAndVideoBeta".localized(), - body: .text("callsVoiceAndVideoModalDescription".localized()), + body: .text( + "callsVoiceAndVideoModalDescription" + .put(key: "session_foundation", value: Constants.session_foundation) + .localized() + ), showCondition: .disabled, confirmTitle: "theContinue".localized(), confirmStyle: .danger, diff --git a/SessionUIKit/Components/SwiftUI/AttributedText.swift b/SessionUIKit/Components/SwiftUI/AttributedText.swift index 0fc516159e..916205f4d9 100644 --- a/SessionUIKit/Components/SwiftUI/AttributedText.swift +++ b/SessionUIKit/Components/SwiftUI/AttributedText.swift @@ -10,6 +10,7 @@ struct AttributedTextBlock { let underlineThemeColor: ThemeValue? let strikethroughThemeColor: ThemeValue? let baselineOffset: CGFloat? + let currentUserMentionBackground: (color: ThemeValue?, cornerRadius: CGFloat?, padding: CGFloat?) } public struct AttributedText: View { @@ -36,6 +37,11 @@ public struct AttributedText: View { let underlineThemeColor = (attribute[.themeUnderlineColor] as? ThemeValue) let strikethroughThemeColor = (attribute[.themeStrikethroughColor] as? ThemeValue) let baselineOffset = (attribute[.baselineOffset] as? CGFloat) + let currentUserMentionBackground = ( + color: attribute[.currentUserMentionBackgroundColor] as? ThemeValue, + cornerRadius: attribute[.currentUserMentionBackgroundCornerRadius] as? CGFloat, + padding: attribute[.currentUserMentionBackgroundPadding] as? CGFloat + ) descriptions.append( AttributedTextBlock( content: substring, @@ -44,7 +50,8 @@ public struct AttributedText: View { foregroundThemeColor: foregroundThemeColor, underlineThemeColor: underlineThemeColor, strikethroughThemeColor: strikethroughThemeColor, - baselineOffset: baselineOffset + baselineOffset: baselineOffset, + currentUserMentionBackground: currentUserMentionBackground ) ) }) From e8d867a0b3464f3916e64cf357592f94670e4751 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 25 Aug 2025 16:12:22 +1000 Subject: [PATCH 017/162] feat: open lightbox for qrcode in ucs --- .../Settings/ThreadSettingsViewModel.swift | 59 +++++++++++++++++-- .../MessageInfoScreen.swift | 20 ++++++- .../Views/SessionCell+AccessoryView.swift | 13 ++-- .../SessionThreadViewModel.swift | 18 ++++++ .../Components/SwiftUI/LightBox.swift | 6 ++ .../Components/SwiftUI/UserProfileModel.swift | 4 +- SessionUIKit/Utilities/QRCode.swift | 2 +- 7 files changed, 106 insertions(+), 16 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 8ac29aad09..1ca7102ade 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -1,5 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +import SwiftUI import Foundation import Combine import Lucide @@ -236,19 +237,25 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi SessionCell.Info( id: .qrCode, accessory: .qrCode( - for: threadId, + for: threadViewModel.getQRCodeString(), hasBackground: false, logo: "SessionWhite40", // stringlint:ignore - themeStyle: ThemeManager.currentTheme.interfaceStyle + themeStyle: ThemeManager.currentTheme.interfaceStyle ), styling: SessionCell.StyleInfo( alignment: .centerHugging, customPadding: SessionCell.Padding(bottom: Values.smallSpacing), backgroundStyle: .noBackground ), - onTap: { [weak self] in - self?.profileImageStatus = (previous: profileImageStatus?.current, current: profileImageStatus?.previous) - self?.forceRefresh(type: .postDatabaseQuery) + onTapView: { [weak self] targetView in + let didTapProfileIcon: Bool = !(targetView is UIImageView) + + if didTapProfileIcon { + self?.profileImageStatus = (previous: profileImageStatus?.current, current: profileImageStatus?.previous) + self?.forceRefresh(type: .postDatabaseQuery) + } else { + self?.showQRCodeLightBox(for: threadViewModel) + } } ) : SessionCell.Info( @@ -2079,4 +2086,46 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi self.transitionToScreen(sessionProModal, transitionType: .present) } + + private func showQRCodeLightBox(for threadViewModel: SessionThreadViewModel) { + let qrCodeImage: UIImage = QRCode.generate( + for: threadViewModel.getQRCodeString(), + hasBackground: false, + iconName: "SessionWhite40" // stringlint:ignore + ) + .withRenderingMode(.alwaysTemplate) + + let viewController = SessionHostingViewController( + rootView: LightBox( + itemsToShare: [ + QRCode.qrCodeImageWithTintAndBackground( + image: qrCodeImage, + themeStyle: ThemeManager.currentTheme.interfaceStyle, + size: CGSize(width: 400, height: 400), + insets: UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + ) + ] + ) { + VStack { + Spacer() + + QRCodeView( + qrCodeImage: qrCodeImage, + themeStyle: ThemeManager.currentTheme.interfaceStyle + ) + .aspectRatio(1, contentMode: .fit) + .frame( + maxWidth: .infinity, + maxHeight: .infinity + ) + + Spacer() + } + .backgroundColor(themeColor: .newConversation_background) + }, + customizedNavigationBackground: .backgroundSecondary + ) + viewController.modalPresentationStyle = .fullScreen + self.transitionToScreen(viewController, transitionType: .present) + } } diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 0f23af83e2..e620787c46 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -459,8 +459,7 @@ struct MessageInfoScreen: View { .foregroundColor(themeColor: tintColor) .frame(width: 26, height: 26) Text(actions[index].title) - .bold() - .font(.system(size: Values.mediumLargeFontSize)) + .font(.Headings.H8) .foregroundColor(themeColor: tintColor) } .frame(maxWidth: .infinity, alignment: .topLeading) @@ -537,7 +536,22 @@ struct MessageInfoScreen: View { // FIXME: Add in support for starting a thread with a 'blinded25' id (disabled until we support this decoding) guard (try? SessionId.Prefix(from: messageViewModel.authorId)) != .blinded25 else { return } - guard let profileInfo: ProfilePictureView.Info = profileInfo else { return } + guard let profileInfo: ProfilePictureView.Info = ProfilePictureView.getProfilePictureInfo( + size: .message, + publicKey: ( + // Prioritise the profile.id because we override it for + // messages sent by the current user in communities + messageViewModel.profile?.id ?? + messageViewModel.authorId + ), + threadVariant: .contact, // Always show the display picture in 'contact' mode + displayPictureUrl: nil, + profile: messageViewModel.profile, + profileIcon: .none, + using: dependencies + ).info else { + return + } let (sessionId, blindedId): (String?, String?) = { guard (try? SessionId.Prefix(from: messageViewModel.authorId)) == .blinded15 else { diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index b3496ccc45..26ad2649dc 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -92,16 +92,21 @@ extension SessionCell { // MARK: - Interaction func touchedView(_ touch: UITouch) -> UIView { - switch (currentContentView, currentContentView?.subviews.first) { - case (let label as SessionHighlightingBackgroundLabel, _), - (_, let label as SessionHighlightingBackgroundLabel): + switch (currentContentView, currentContentView?.subviews.first, currentContentView?.subviews.last) { + case (let label as SessionHighlightingBackgroundLabel, _, _), + (_, let label as SessionHighlightingBackgroundLabel, _): let localPoint: CGPoint = touch.location(in: label) return (label.bounds.contains(localPoint) ? label : self) - case (let profilePictureView as ProfilePictureView, _): + case (let profilePictureView as ProfilePictureView, _, _): let localPoint: CGPoint = touch.location(in: profilePictureView) return profilePictureView.getTouchedView(from: localPoint) + + case (_, let qrCodeImageView as UIImageView , .some(let profileIcon)): + let localPoint: CGPoint = touch.location(in: profileIcon) + + return (profileIcon.bounds.contains(localPoint) ? profileIcon : qrCodeImageView) default: return self } diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 39956d8e6c..7740db4bc5 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -341,6 +341,24 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D return dependencies.mutate(cache: .libSession) { [threadId] in $0.validateSessionProState(for: threadId)} } + public func getQRCodeString() -> String { + switch self.threadVariant { + case .contact, .legacyGroup, .group: + return self.threadId + + case .community: + guard + let urlString: String = LibSession.communityUrlFor( + server: self.openGroupServer, + roomToken: self.openGroupRoomToken, + publicKey: self.openGroupPublicKey + ) + else { return "" } + + return urlString + } + } + // MARK: - Marking as Read public enum ReadTarget { diff --git a/SessionUIKit/Components/SwiftUI/LightBox.swift b/SessionUIKit/Components/SwiftUI/LightBox.swift index b9af52e625..fbcee7e8c5 100644 --- a/SessionUIKit/Components/SwiftUI/LightBox.swift +++ b/SessionUIKit/Components/SwiftUI/LightBox.swift @@ -9,6 +9,12 @@ public struct LightBox: View { public var itemsToShare: [UIImage] = [] public var content: () -> Content + public init(title: String? = nil, itemsToShare: [UIImage], content: @escaping () -> Content) { + self.title = title + self.itemsToShare = itemsToShare + self.content = content + } + public var body: some View { NavigationView { content() diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift index d0c2c87565..ca0d077ea4 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModel.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModel.swift @@ -73,7 +73,7 @@ public struct UserProfileModel: View { ) .scaleEffect(scale, anchor: .topLeading) .onTapGesture { - withAnimation { + withAnimation(.easeInOut(duration: 0.1)) { self.isProfileImageExpanding.toggle() } } @@ -375,8 +375,6 @@ public struct UserProfileModel: View { viewController.modalPresentationStyle = .fullScreen self.host.controller?.present(viewController, animated: true) } - - } public extension UserProfileModel { diff --git a/SessionUIKit/Utilities/QRCode.swift b/SessionUIKit/Utilities/QRCode.swift index 2ed69031d4..78c7f56e61 100644 --- a/SessionUIKit/Utilities/QRCode.swift +++ b/SessionUIKit/Utilities/QRCode.swift @@ -77,7 +77,7 @@ public enum QRCode { return finalImage ?? qrUIImage } - static func qrCodeImageWithTintAndBackground( + public static func qrCodeImageWithTintAndBackground( image: UIImage, themeStyle: UIUserInterfaceStyle, size: CGSize? = nil, From 18920ae4763c63f3b5e7bf5eddfaaab05526a5c7 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 25 Aug 2025 17:09:46 +1000 Subject: [PATCH 018/162] fix: read more for message bubble cells with link preview and other messages at the edge of line limit --- .../Content Views/LinkPreviewView.swift | 11 +++++-- .../Message Cells/VisibleMessageCell.swift | 33 ++++++++++++++++--- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift index 7ec1765625..3a630f21e3 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift @@ -85,6 +85,7 @@ final class LinkPreviewView: UIView { }() var bodyTappableLabel: TappableLabel? + var bodyTappableLabelHeight: CGFloat = 0 // MARK: - Initialization @@ -202,18 +203,22 @@ final class LinkPreviewView: UIView { bodyTappableLabelContainer.subviews.forEach { $0.removeFromSuperview() } if let cellViewModel: MessageViewModel = cellViewModel { - let bodyTappableLabel = VisibleMessageCell.getBodyTappableLabel( + let (bodyTappableLabel, height) = VisibleMessageCell.getBodyTappableLabel( for: cellViewModel, with: maxWidth, textColor: (bodyLabelTextColor ?? .textPrimary), searchText: lastSearchText, delegate: delegate, using: dependencies - ).label + ) self.bodyTappableLabel = bodyTappableLabel + self.bodyTappableLabelHeight = height bodyTappableLabelContainer.addSubview(bodyTappableLabel) - bodyTappableLabel.pin(to: bodyTappableLabelContainer, withInset: 12) + bodyTappableLabel.pin(.leading, to: .leading, of: bodyTappableLabelContainer, withInset: 12) + bodyTappableLabel.pin(.top, to: .top, of: bodyTappableLabelContainer, withInset: 12) + bodyTappableLabel.pin(.trailing, to: .trailing, of: bodyTappableLabelContainer, withInset: -12) + bodyTappableLabel.pin(.bottom, to: .bottom, of: bodyTappableLabelContainer) } if state is LinkPreview.DraftState { diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 0c017352fc..52011c08fa 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -517,10 +517,16 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { guard cellViewModel.cellType != .textOnlyMessage else { let inset: CGFloat = 12 let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset) + let lineHeight: CGFloat = UIFont.systemFont(ofSize: VisibleMessageCell.getFontSize(for: cellViewModel)).lineHeight if let linkPreview: LinkPreview = cellViewModel.linkPreview { switch linkPreview.variant { case .standard: + // Stack view + let stackView = UIStackView(arrangedSubviews: []) + stackView.axis = .vertical + stackView.spacing = 2 + let linkPreviewView: LinkPreviewView = LinkPreviewView(maxWidth: maxWidth) linkPreviewView.update( with: LinkPreview.SentState( @@ -536,10 +542,26 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { using: dependencies ) self.linkPreviewView = linkPreviewView - bubbleView.addSubview(linkPreviewView) - linkPreviewView.pin(to: bubbleView, withInset: 0) + stackView.addArrangedSubview(linkPreviewView) + readMoreButton.themeTextColor = bodyLabelTextColor + let maxHeight: CGFloat = VisibleMessageCell.getMaxHeightAfterTruncation(for: cellViewModel) + self.bodayTappableLabelHeightConstraint = linkPreviewView.bodyTappableLabel?.set( + .height, + to: (shouldExpanded ? linkPreviewView.bodyTappableLabelHeight : min(linkPreviewView.bodyTappableLabelHeight, maxHeight)) + ) + if ((linkPreviewView.bodyTappableLabelHeight - maxHeight >= lineHeight) && !shouldExpanded) { + stackView.addArrangedSubview(readMoreButton) + readMoreButton.isHidden = false + readMoreButton.transform = CGAffineTransform(translationX: inset, y: 0) + } + + bubbleView.addSubview(stackView) + stackView.pin([UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.top], to: bubbleView) + stackView.pin(.bottom, to: .bottom, of: bubbleView, withInset: -inset) snContentView.addArrangedSubview(bubbleBackgroundView) self.bodyTappableLabel = linkPreviewView.bodyTappableLabel + self.bodyTappableLabelHeight = linkPreviewView.bodyTappableLabelHeight + case .openGroupInvitation: let openGroupInvitationView: OpenGroupInvitationView = OpenGroupInvitationView( @@ -598,7 +620,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { .height, to: (shouldExpanded ? height : min(height, maxHeight)) ) - if (height > maxHeight && !shouldExpanded) { + if ((height - maxHeight >= lineHeight) && !shouldExpanded) { stackView.addArrangedSubview(readMoreButton) readMoreButton.isHidden = false } @@ -642,6 +664,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { /// Add any quote & body if present let inset: CGFloat = 12 let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset) + let lineHeight: CGFloat = UIFont.systemFont(ofSize: VisibleMessageCell.getFontSize(for: cellViewModel)).lineHeight switch (cellViewModel.quote, cellViewModel.body) { /// Both quote and body @@ -685,7 +708,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { .height, to: (shouldExpanded ? height : min(height, maxHeight)) ) - if (height > maxHeight && !shouldExpanded) { + if ((height - maxHeight >= lineHeight) && !shouldExpanded) { stackView.addArrangedSubview(readMoreButton) readMoreButton.isHidden = false } @@ -722,7 +745,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { .height, to: (shouldExpanded ? height : min(height, maxHeight)) ) - if (height > maxHeight && !shouldExpanded) { + if ((height - maxHeight > UIFont.systemFont(ofSize: VisibleMessageCell.getFontSize(for: cellViewModel)).lineHeight) && !shouldExpanded) { stackView.addArrangedSubview(readMoreButton) readMoreButton.isHidden = false } From b6c3a36614e5946e9030cbba7b13ab95676ffdb1 Mon Sep 17 00:00:00 2001 From: ThomasSession <171472362+ThomasSession@users.noreply.github.com> Date: Mon, 25 Aug 2025 23:18:18 +0000 Subject: [PATCH 019/162] [Automated] Update translations from Crowdin --- .../Meta/Translations/Localizable.xcstrings | 3015 ++++++----------- SessionUIKit/Style Guide/Constants.swift | 2 + 2 files changed, 1028 insertions(+), 1989 deletions(-) diff --git a/Session/Meta/Translations/Localizable.xcstrings b/Session/Meta/Translations/Localizable.xcstrings index 5e4040a9a3..4004b494e0 100644 --- a/Session/Meta/Translations/Localizable.xcstrings +++ b/Session/Meta/Translations/Localizable.xcstrings @@ -30956,6 +30956,17 @@ } } }, + "appProBadge" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{app_pro} Badge" + } + } + } + }, "attachment" : { "extractionState" : "manual", "localizations" : { @@ -65016,7 +65027,7 @@ } } }, - "blockedContactsmanageDescription" : { + "blockedContactsManageDescription" : { "extractionState" : "manual", "localizations" : { "en" : { @@ -78435,478 +78446,10 @@ "callsVoiceAndVideoModalDescription" : { "extractionState" : "manual", "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jou IP is sigbaar vir jou oproepmaat en 'n Oxen Foundation-bediener terwyl beta-oproepe gebruik word." - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "عنوان IP الخاص بك مرئي لشريك الاتصال وخادم Oxen Foundation أثناء استخدام المكالمات التجريبية." - } - }, - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Beta zənglərini istifadə edərkən IP ünvanınız zəng tərəfdaşınıza və Oxen Foundation serverinə görünür." - } - }, - "bal" : { - "stringUnit" : { - "state" : "translated", - "value" : "ما گپ درخواست قبول کردی آپ کہ آئیں طرفه IP پدنی Oxen Foundation کہ سرورے ہ مغامیگیا." - } - }, - "be" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваш IP будзе бачныя вашаму партнёру па званках і серверу Oxen Foundation падчас выкарыстання бэта-званкоў." - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вашият IP е видим за вашият партньор по време нослужване на beta обаждания и Oxen Foundation сървър." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "বেটা কলগুলি ব্যবহার করার সময় আপনার আইপি আপনার কল পার্টনার এবং একটি Oxen Foundation সার্ভারের কাছে দৃশ্যমান হবে।" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "La vostra IP és visible a la vostra parella de trucada i a un servidor de Oxen Foundation mentre utilitzeu trucades beta." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaše IP adresa je při používání beta hovorů viditelná pro vašeho volacího partnera a server Oxen Foundation." - } - }, - "cy" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mae eich GPS yn weladwy i'ch partner galwad ac i Wasanaeth Node Foundation Oxen wrth ddefnyddio galwadau beta." - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Din IP synlig for din opkaldspartner og en Oxen Foundation-server, mens du bruger betaopkald." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Deine IP ist für deinen Gesprächspartner und einen Oxen Foundation Server sichtbar, während du Beta-Anrufe nutzt." - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Η διεύθυνση IP σας είναι ορατή στον συνομιλητή σας και σε έναν διακομιστή του Oxen Foundation κατά τη χρήση κλήσεων beta." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your IP is visible to your call partner and a Session Technology Foundation server while using beta calls." - } - }, - "eo" : { - "stringUnit" : { - "state" : "translated", - "value" : "Via IP-adreso estas videbla al via vokopartnero kaj al servilo de Oxen Foundation dum vi uzas betajn vokojn." - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tu IP es visible para tu compañero de llamada y un servidor de la Oxen Foundation mientras usas llamadas beta." - } - }, - "es-ES" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tu IP es visible para tu socio de llamada y un servidor de Oxen Foundation mientras usas las llamadas beta." - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "Teie IP on nähtav teie kõnepartnerile ja Oxen Foundation serverile beetakõnede ajal." - } - }, - "eu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zure IPa ikusgai egongo da zure dei-bikotekidearentzako eta Oxen Foundation zerbitzari batentzako, beta-deiak egiten ari bazara." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "آدرس IP شما برای مخاطب تماس شما و همچنین سرور Oxen Foundation در هنگام استفاده از تماس آزمایشی مشخص خواهد بود." - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP-osoitteesi on näkyvissä puhelun aikana vastaanottajalle ja Oxen Foundation palvelimelle, kun käytät beta-puheluita." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ang iyong IP ay nakikita ng iyong kasamahan sa tawag at isang server ng Oxen Foundation habang gumagamit ng beta calls." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre adresse IP est visible par votre interlocuteur et un serveur d'Oxen Foundation pendant que vous utilisez des appels beta." - } - }, - "gl" : { - "stringUnit" : { - "state" : "translated", - "value" : "O teu IP é visible para o teu compañeire de chamada e un servidor da Oxen Foundation mentres utilizas chamadas beta." - } - }, - "ha" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP ɗinku yana bayyane ga abokin kiran ku da sabar Oxen Foundation yayin amfani da ƙiran beta." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "כתובת ה-IP שלך גלויה לשותפת השיחה שלך ולשרת קרן Oxen בעת שימוש בשיחות בטא." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "अपने खाते में एन्क्रिप्टेड संदेश भेजते समय आपका IP आपके कॉल पार्टनर और Oxen Foundation सर्वर को दिखाई देगा।" - } - }, - "hr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaš IP je vidljiv vašem sugovorniku i poslužitelju Oxen Foundation dok koristite beta pozive." - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Az IP címed látható a hívópartner és egy Oxen Foundation szerver számára a béta hívások használata közben." - } - }, - "hy-AM" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ձեր IP հասցեն տեսանելի է ձեր զանգի գործընկերոջը և մի 'Oxen Foundation' սերվերին ծիծլած օգտագործման ժամանակ։" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP Anda terlihat oleh mitra panggilan Anda dan server Oxen Foundation saat menggunakan panggilan beta." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Il tuo IP è visibile all'utente che stai chiamando e a un server di Oxen Foundation durante l'utilizzo delle chiamate beta." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "音声通話とビデオ通話の使用中、あなたのIPはあなたの通話相手とOxen Foundationサーバーに表示されます。" - } - }, - "ka" : { - "stringUnit" : { - "state" : "translated", - "value" : "თქვენი IP გააშკარავდება თქვენი ზარის პარტნიორს და Oxen Foundation-ის სერვერს, ბეტა ზარების გამოყენებისას." - } - }, - "km" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP អ្នក​ត្រូវបាន​លេចឮ​ច្បាស់នៅពេល​អ្នក​ប្រើកិច្ចប្រជុំ Beta ក្នុង Oxen Foundation។" - } - }, - "kn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ಬೇಟ ಕಾಲ್ಲನ್ನು ಬಳಸಿದಾಗ ನಿಮ್ಮ IP ಕರೆ ಸಹವಾಸಿಗೂ ಮತ್ತು Oxen Foundation ಸರ್ವರ್‌ಗೆ ಗೋಚರುತ್ತದೆ." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "베타 통화를 사용하는 동안 IP가 호출 파트너와 Oxen Foundation 서버에 보입니다." - } - }, - "ku" : { - "stringUnit" : { - "state" : "translated", - "value" : "پته‌ی IP ی تۆ بۆ شەریکەکەی تیپە و سێروێری Oxen Foundation پشت بە پشت ڕەنگە بونی بوو بێت کاتێک بەکارهێنانی بەتاکوڵ بوون بوزیەکان." - } - }, - "ku-TR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Adresa IP̧ya te bi dirising ti paşinspectek an hîn ya Fundaceya Oxen bêyê dîtin." - } - }, - "lg" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP yo efulugettaka mu mateeka ne server ya Oxen Foundation nga okozeza omitting ekikugya ekiriotto." - } - }, - "lt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Naudojant beta skambučius, jūsų IP adresas matomas jūsų pašnekovui ir Oxen Foundation serveriui." - } - }, - "lv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Izmantojot beta zvanus, jūsu IP ir redzams jūsu zvana partnerim un Oxen Foundation serverim." - } - }, - "mk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вашето IP е видливо за вашиот партнер за повик и серверот на Oxen Foundation додека користите бета повици." - } - }, - "mn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Таны IP хаягийг ярианы хамтрагч болон Oxen Foundation сервер харагдана." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Alamat IP anda boleh dilihat oleh rakan panggilan anda dan pelayan Oxen Foundation semasa menggunakan panggilan beta." - } - }, - "my" : { - "stringUnit" : { - "state" : "translated", - "value" : "သင့် IP ကို ဖုန်းခေါ်စဉ်တွင် သင်၏ ချိန်းညွှန်းပါတနာများနှင့် Oxen Foundation ဆာဗာကို မြင်ရပါသည်။" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP-adressen din er synlig for samtalepartneren din og en Oxen Foundation-server mens du bruker betasamtaler." - } - }, - "nb-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Din IP er synlig for samtalepartneren din og en Oxen Foundation-server mens du bruker beta-samtaler." - } - }, - "ne-NP" : { - "stringUnit" : { - "state" : "translated", - "value" : "तपाईंको आइपी तपाईको कल पार्टनर र ओक्सन फाउन्डेसन सर्भरलाई बेटा कलहरू प्रयोग गर्दा देखिनेछ।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Uw IP is zichtbaar voor uw oproep partner en een Oxen Foundation server tijdens het gebruik van bètagesprekken." - } - }, - "nn-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP-adressa di er synlig for samtalepartnaren din og ein Oxen Foundation-server mens du bruker beta-samtaler." - } - }, - "ny" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP yanu ikuwoneka kwa mnzake wanu ndi seva ya Oxen Foundation mukamagwiritsa ntchito mayitanidwe ozungulira." - } - }, - "pa-IN" : { - "stringUnit" : { - "state" : "translated", - "value" : "ਤੁਹਾਡੀ ਕਾਲ ਦੇ ਦੌਰਾਨ ਤੁਹਾਡਾ IP ਸਹਿਯੋਗੀ ਅਤੇ ਇਕ Oxen Foundation ਸਰਵਰ ਨੂੰ ਦੇਖਾਈ ਦੇਵੇਗਾ।" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Podczas korzystania z połączeń w wersji beta Twój adres IP jest widoczny dla partnera rozmowy i serwera Oxen Foundation." - } - }, - "ps" : { - "stringUnit" : { - "state" : "translated", - "value" : "ستاسو IP ستاسو د زنګ ملګري او یو Oxen Foundation سرور ته ښکاره کیږي کله چې بیتا زنګونه کاروئ." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Seu IP estará visível para seu parceiro de chamada e para um servidor da Oxen Foundation enquanto usa chamadas beta." - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "O seu IP está visível para seu parceiro de chamada e para um servidor Oxen Foundation enquanto usa chamadas beta." - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP-ul dumneavoastră este vizibil către partenerul de apel și către un server al Oxen Foundation atunci când utilizați apeluri beta." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваш IP виден вашему собеседнику и серверу Oxen Foundation при использовании бета-вызовов." - } - }, - "sh" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tvoj IP je vidljiv tvom partneru na pozivu i serveru Oxen Foundation dok koristiš beta pozive." - } - }, - "si-LK" : { - "stringUnit" : { - "state" : "translated", - "value" : "ඔබගේ IP වැටීමට වයිස් සහ Oxen Foundation සේවාදායකයකු වෙත දැක්වේදීද පරීක්ෂණ ඇමතුම් භාවිතා කිරීමේදී." - } - }, - "sk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaša IP adresa je viditeľná vášmu volajúcemu partnerovi a serveru Oxen Foundation pri používaní beta hovory." - } - }, - "sl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaš IP je viden vašemu partnerskemu klicatelju in strežniku Oxen Foundation med uporabo beta klicev." - } - }, - "sq" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP-ja juaj është e dukshme për partnerin tuaj të thirrjes dhe një server të Fondacionit Oxen gjatë përdorimit të thirrjeve beta." - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваша IP адреса је видљива вашем сајговорнику и серверу Oxen Foundation док користите бета позиве." - } - }, - "sr-Latn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaš IP je vidljiv vašem partneru za poziv i serveru Oxen Foundation dok koristite beta pozive." - } - }, - "sv-SE" : { - "stringUnit" : { - "state" : "translated", - "value" : "Din IP är synlig för din samtalspartner och en Oxen Foundation server när du använder beta-samtal." - } - }, - "sw" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP yako inaonekana kwa mshirika wako wa simu na seva ya Oxen Foundation wakati unatumia simu za beta." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "அழைப்பு பிறையாளர் மற்றும் Oxen Foundation சர்வரில் உங்கள் ஐபி காட்டப்படவும் செய்தி சேவையின்போது." - } - }, - "te" : { - "stringUnit" : { - "state" : "translated", - "value" : "బీటా కాల్‌లను ఉపయోగిస్తున్నప్పుడు మీ ఐపి మీ కాల్ భాగస్వామికి మరియు ఒక Oxen Foundation సర్వర్‌కు కనిపిస్తుంది." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "IP ของคุณจะสามารถมองเห็นได้โดยคู่สายของคุณและเซิร์ฟเวอร์ของ Oxen Foundation ในขณะที่ใช้เบต้าการโทร" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Deneme aramaları sırasında IP adresiniz arama ortağınıza ve Oxen Foundation sunucusuna görünür olacaktır." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваша IP-адреса видима вашому партнеру по дзвінку та серверу Oxen Foundation при використанні бета-дзвінків." - } - }, - "ur-IN" : { - "stringUnit" : { - "state" : "translated", - "value" : "بیٹا کالز استعمال کرتے وقت آپ کا آئی پی آپ کے کال پارٹنر اور ایک اوکسن فاؤنڈیشن سرور کو نظر آئے گا۔" - } - }, - "uz" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hozirgi parolingiz noto'g'ri." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Địa chỉ IP của bạn hiển thị với đối tác cuộc gọi và máy chủ Oxen Foundation trong khi sử dụng cuộc gọi beta." - } - }, - "xh" : { - "stringUnit" : { - "state" : "translated", - "value" : "I-IP yakho iyabonakala komnxibelele wakho nakwiOxen Foundation Server ngelixa usebenzisa i-beta calls." - } - }, - "zh-CN" : { - "stringUnit" : { - "state" : "translated", - "value" : "在使用测试版通话时,您的IP会暴露给您的通话对象和Oxen Foundation服务器。" - } - }, - "zh-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "您在使用測試版通話時,您的 IP 將會被通話夥伴和 Oxen Foundation 伺服器看到。" + "value" : "Your IP is visible to your call partner and a {session_foundation} server while using beta calls." } } } @@ -83725,24 +83268,24 @@ } } }, - "change" : { + "cancelPlan" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Change" + "value" : "Cancel Plan" } } } }, - "changePasswordDescription" : { + "change" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Change your password for {app_name}. Locally stored data will be re-encrypted with your new password." + "value" : "Change" } } } @@ -84226,6 +83769,17 @@ } } }, + "changePasswordModalDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change your password for {app_name}. Locally stored data will be re-encrypted with your new password." + } + } + } + }, "clear" : { "extractionState" : "manual", "localizations" : { @@ -126324,6 +125878,28 @@ } } }, + "currentPassword" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current Password" + } + } + } + }, + "currentPlan" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current Plan" + } + } + } + }, "cut" : { "extractionState" : "manual", "localizations" : { @@ -127047,7 +126623,7 @@ "ja" : { "stringUnit" : { "state" : "translated", - "value" : "データベースエラーが発生しました。

\nトラブルシューティングのために、アプリのログをエクスポートして共有してください。この操作が失敗した場合は、{app_name} を再インストールし、アカウントを復元してください。" + "value" : "データベースエラーが発生しました。

トラブルシューティングのために、アプリのログをエクスポートして共有してください。この操作が失敗した場合は、{app_name} を再インストールし、アカウントを復元してください。" } }, "ko" : { @@ -238366,6 +237942,17 @@ } } }, + "important" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Important" + } + } + } + }, "incognitoKeyboard" : { "extractionState" : "manual", "localizations" : { @@ -252864,6 +252451,17 @@ } } }, + "links" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Links" + } + } + } + }, "loadAccount" : { "extractionState" : "manual", "localizations" : { @@ -258624,6 +258222,17 @@ } } }, + "logs" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logs" + } + } + } + }, "manageMembers" : { "extractionState" : "manual", "localizations" : { @@ -258785,6 +258394,17 @@ } } }, + "managePro" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manage {pro}" + } + } + } + }, "max" : { "extractionState" : "manual", "localizations" : { @@ -293816,6 +293436,17 @@ } } }, + "newPassword" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New Password" + } + } + } + }, "next" : { "extractionState" : "manual", "localizations" : { @@ -294295,6 +293926,17 @@ } } }, + "nextSteps" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next Steps" + } + } + } + }, "nicknameDescription" : { "extractionState" : "manual", "localizations" : { @@ -324740,6 +324382,28 @@ } } }, + "onDevice" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On your {device_type} device" + } + } + } + }, + "onDeviceDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, change your plan via the {app_pro} settings." + } + } + } + }, "onionRoutingPath" : { "extractionState" : "manual", "localizations" : { @@ -329069,6 +328733,17 @@ } } }, + "openStoreWebsite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open {platform_store} Website" + } + } + } + }, "openSurvey" : { "extractionState" : "manual", "localizations" : { @@ -330169,967 +329844,25 @@ } } }, - "passwordChangedDescription" : { + "passwordChangedDescriptionToast" : { "extractionState" : "manual", "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jou wagwoord is verander. Hou dit asseblief veilig." - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تم تغيير كلمة المرور الخاصة بك. احفظها في مامن." - } - }, - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Parolunuz dəyişdirildi. Lütfən, onu güvəndə saxlayın." - } - }, - "bal" : { - "stringUnit" : { - "state" : "translated", - "value" : "ما گپ درخواست قبول کردی بیک اپلیکیشن پاسکوڈ ناقض کردی. براہپس محفوظے کہ." - } - }, - "be" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваш пароль быў зменены. Захавайце яго ў бяспецы." - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вашата парола беше променена. Моля, пазете я безопасно." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "আপনার পাসওয়ার্ড পরিবর্তন করা হয়েছে। দয়া করে এটি নিরাপদ রাখুন।" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "La vostra contrasenya s'ha definit. Mantingueu-la segura." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tvé heslo bylo změněno. Pečlivě si ho odlož." - } - }, - "cy" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mae eich cyfrinair wedi'i newid. Cadwch ef yn ddiogel." - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Din adgangskode er blevet ændret. Venligst hold den sikker." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dein Passwort wurde geändert. Bitte bewahre es sicher auf." - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ο κωδικός πρόσβασής σας έχει αλλάξει. Παρακαλώ κρατήστε τον ασφαλή." - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Your password has been changed. Please keep it safe." } - }, - "eo" : { - "stringUnit" : { - "state" : "translated", - "value" : "Via pasvorto estas ŝanĝita. Bonvolu konservi ĝin sekura." - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tu contraseña ha sido cambiada. Por favor, manténla segura." - } - }, - "es-ES" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tu contraseña ha sido cambiada. Por favor, manténla segura." - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "Teie parool on muudetud. Hoidke seda turvaliselt." - } - }, - "eu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zure pasahitza aldatu da. Gorde seguru batean." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رمز عبور شما تغییر کرد. لطفا آن را در جای امنی نگهداری کنید." - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Salasanasi on vaihdettu. Pidä se turvassa." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nabago na ang iyong password. Pakisuyong itago ito." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre mot de passe a été changé. Veuillez le conserver en sécurité." - } - }, - "gl" : { - "stringUnit" : { - "state" : "translated", - "value" : "O teu contrasinal foi cambiado. Por favor, mantéñeo seguro." - } - }, - "ha" : { - "stringUnit" : { - "state" : "translated", - "value" : "An canza kalmar sirrinku. Da fatan za a kiyaye shi lafiya." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הסיסמה שלך השתנתה. שמור עליה בבטחה." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "आपका पासवर्ड बदल दिया गया है। कृपया इसे सुरक्षित रखें।" - } - }, - "hr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaša lozinka je promijenjena. Molimo, čuvajte je na sigurnom." - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "A jelszó megváltozott. Tartsd biztonságos helyen!" - } - }, - "hy-AM" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ձեր գաղտնաբառը փոխվել է։ Խնդրում ենք անվտանգ պահել։" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kata sandi anda telah diubah. Harap untuk menjaganya." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "La tua password è stata modificata. Per favore tienila al sicuro." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "パスワードが変更されました。安全に保管してください。" - } - }, - "ka" : { - "stringUnit" : { - "state" : "translated", - "value" : "თქვენი პაროლი შეცვლილია. გთხოვთ, შეინახეთ იგი უსაფრთხოდ." - } - }, - "km" : { - "stringUnit" : { - "state" : "translated", - "value" : "ពាក្យសម្ងាត់ របស់អ្នកត្រូវ​បាន​ប្តូរ។ សូមរក្សា​វា​ឲ្យ​មាន​សុវត្ថិភាព។" - } - }, - "kn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ನಿಮ್ಮ ಗುಪ್ತಪದವನ್ನು ಬದಲಾಯಿಸಲಾಗಿದೆ. ಅದು ಸುರಕ್ಷಿತವಾಗಿರಿಸಿ." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "비밀번호 변경이 완료되었습니다. 안전히 관리하시기 바랍니다." - } - }, - "ku" : { - "stringUnit" : { - "state" : "translated", - "value" : "وشەی پەرەسەدت گۆڕدرا. تکایە ئەوە بەندەن پارێزەر بێت." - } - }, - "ku-TR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Te jîrêbandeya we yê danîn Muhafize mane sihîn bike." - } - }, - "lg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Password yo ekabatiddwa. Kaakasa nti bagutemye mu kifo ekitalemerera." - } - }, - "lt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jūsų slaptažodis buvo pakeistas. Prašome saugoti jį saugiai." - } - }, - "lv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jūsu parole tika nomainīta. Lūdzu, saglabājiet to drošībā." - } - }, - "mk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вашата лозинка е променета. Ве молиме чувајте ја безбедно." - } - }, - "mn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Таны нууц үг солигдож байна. Нууц үгээ хамгаалж байгаарай." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kata laluan anda telah ditukar. Sila simpan dengan selamat." - } - }, - "my" : { - "stringUnit" : { - "state" : "translated", - "value" : "သင်၏ စကားဝှက် ပြောင်းလဲ ပြီးပါပြီ။ ထိန်းသိမ်းပါ။" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Passordet ditt er endret. Vennligst oppbevar det trygt." - } - }, - "nb-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Passordet ditt er endret. Vennligst oppbevar det trygt." - } - }, - "ne-NP" : { - "stringUnit" : { - "state" : "translated", - "value" : "तपाईँको पासवर्ड परिवर्तन भयो। कृपया यसलाई सुरक्षित राख्नुहोस्।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Uw wachtwoord is gewijzigd. Hou het veilig." - } - }, - "nn-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Passordet ditt er blitt endra. Vennligst oppbevar det trygt." - } - }, - "ny" : { - "stringUnit" : { - "state" : "translated", - "value" : "Password yanu yasinthidwa. Chonde sungani mosamala." - } - }, - "pa-IN" : { - "stringUnit" : { - "state" : "translated", - "value" : "ਤੁਹਾਡਾ ਪਾਸਵਰਡ ਬਦਲਿਆ ਗਿਆ ਹੈ। ਕਿਰਪਾ ਕਰਕੇ ਇਸਨੂੰ ਸੁਰੱਖਿਅਤ ਰੱਖੋ।" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zmieniono hasło. Zachowaj je w bezpiecznym miejscu." - } - }, - "ps" : { - "stringUnit" : { - "state" : "translated", - "value" : "ستاسو پاسورډ بدل شوی. مهرباني وکړۍ، دا خوندي وساتئ." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sua senha foi alterada. Por favor, mantenha-a segura." - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "A sua palavra-passe foi alterada. Por favor, mantenha-a segura." - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Parola ta a fost schimbată. Te rugăm să o păstrezi în siguranță." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваш пароль был изменен. Пожалуйста, храните его в безопасном месте." - } - }, - "sh" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tvoja šifra je promijenjena. Molimo, čuvaj je na sigurnom." - } - }, - "si-LK" : { - "stringUnit" : { - "state" : "translated", - "value" : "ඔබගේ මුරපදය වෙනස් කර ඇත. කරුණාකර එය ආරක්ෂිතව තබා ගන්න." - } - }, - "sk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaše heslo bolo zmenené. Uchovajte ho prosím v bezpečí." - } - }, - "sl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaše geslo je bilo spremenjeno. Prosim, hranite ga na varnem mestu." - } - }, - "sq" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fjalëkalimi juaj është ndryshuar. Ju lutemi ta mbani të sigurt." - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваша лозинка је промењена. Молимо вас да је сачувате." - } - }, - "sr-Latn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaša lozinka je promenjena. Čuvajte je na sigurnom mestu." - } - }, - "sv-SE" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ditt lösenord har ändrats. Håll det säkert." - } - }, - "sw" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nenosiri lako limebadilishwa. Tafadhali lihifadhi salama." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "உங்களின் கடவுச்சொல் மாற்றப்பட்டுள்ளது. தயவுசெய்து அதை பாதுகாப்பாக வைத்திருங்கள்." - } - }, - "te" : { - "stringUnit" : { - "state" : "translated", - "value" : "మీ పాస్‌వర్డ్ మార్పు జరిగింది. దయచేసి దాన్ని సురక్షితంగా ఉంచండి." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "รหัสผ่านของคุณได้รับการเปลี่ยนแปลงแล้ว กรุณารักษาเอาไว้ให้ปลอดภัย" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Şifreniz değiştirildi. Lütfen güvende tutunuz." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваш пароль змінено. Будь ласка, збережіть його в безпеці." - } - }, - "ur-IN" : { - "stringUnit" : { - "state" : "translated", - "value" : "آپ کا پاس ورڈ تبدیل ہو گیا ہے۔ براہ کرم اسے محفوظ رکھیں۔" - } - }, - "uz" : { - "stringUnit" : { - "state" : "translated", - "value" : "Xabar so'rovingiz hozirda kutilmoqda." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mật khẩu của bạn đã được đổi. Hãy giữ nó cẩn thận." - } - }, - "xh" : { - "stringUnit" : { - "state" : "translated", - "value" : "Iphasiwedi yakho itshintshiwe. Nceda uyigcine ikhuselekile." - } - }, - "zh-CN" : { - "stringUnit" : { - "state" : "translated", - "value" : "您的密码已经设定。请妥善保管。" - } - }, - "zh-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "您的密碼變更完成。請注意保管。" - } } } }, - "passwordChangeDescription" : { + "passwordChangeShortDescription" : { "extractionState" : "manual", "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verander die wagwoord wat benodig word om {app_name} te ontsluit." - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تغيير كلمة السر المطلوبة لفتح {app_name}." - } - }, - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} kilidini açmaq üçün tələb olunan parolu dəyişdir." - } - }, - "bal" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} کو ان لاک کرنے کے لئے درکار پاس ورڈ تبدیل کریں۔" - } - }, - "be" : { - "stringUnit" : { - "state" : "translated", - "value" : "Змяніць пароль для разблакоўкі {app_name}." - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Сменете паролата, изисквана за отключване на {app_name}." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} আনলক করতে প্রয়োজনীয় পাসওয়ার্ড পরিবর্তন করুন।" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Canvia la contrasenya requerida per desblocar {app_name}." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Změňte heslo pro odemykání {app_name}." - } - }, - "cy" : { - "stringUnit" : { - "state" : "translated", - "value" : "Newid y cyfrinair sy'n angenrheidiol i ddatgloi {app_name}." - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skift adgangskoden, der kræves for at låse {app_name} op." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Das Passwort zum Entsperren von {app_name} ändern." - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Αλλαγή του κωδικού πρόσβασης που απαιτείται για το ξεκλείδωμα του {app_name}." - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Change the password required to unlock {app_name}." } - }, - "eo" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ŝanĝi la pasvorton, kiu necesas por malŝlosi {app_name}." - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cambiar la contraseña necesaria para desbloquear {app_name}." - } - }, - "es-ES" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cambiar la contraseña requerida para desbloquear {app_name}." - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "Muuda parooli, mida on vaja {app_name} avamiseks." - } - }, - "eu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Change the password required to unlock {app_name}." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "رمز عبور مورد نیاز برای باز کردن {app_name} را تغییر بده." - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaihda {app_name} in avaukseen käytettävä salasana." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Palitan ang password na kinakailangan para i-unlock ang {app_name}." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modifier le mot de passe requis pour déverrouiller {app_name}" - } - }, - "gl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cambia o contrasinal necesario para desbloquear {app_name}." - } - }, - "ha" : { - "stringUnit" : { - "state" : "translated", - "value" : "Canza kalmar sirrin da ake bukata don buɗe {app_name}." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "שנה את הסיסמה הנדרשת לפתיחת {app_name}." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} को अनलॉक करने के लिए आवश्यक पासवर्ड बदलें।" - } - }, - "hr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Promijenite lozinku potrebnu za otključavanje {app_name}." - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} alkalmazás jelszavának megváltoztatása." - } - }, - "hy-AM" : { - "stringUnit" : { - "state" : "translated", - "value" : "Փոխեք {app_name}-ն ապակողպելու համար պահանջվող գաղտնաբառը:" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ubah kata sandi yang diperlukan untuk membuka kunci {app_name}." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cambia la password richiesta per sbloccare {app_name}." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name}のロック解除に必要なパスワードを変更します" - } - }, - "ka" : { - "stringUnit" : { - "state" : "translated", - "value" : "პაროლის შეცვლა აუცილებელია {app_name}-ის გახსნისთვის." - } - }, - "km" : { - "stringUnit" : { - "state" : "translated", - "value" : "ប្ដូរពាក្យសម្ងាត់ដែលបានតម្រូវឲ្យមានដើម្បីឈប់ទប់ស្កាត់ {app_name}។" - } - }, - "kn" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} ತೆಗೆಯಲು ಬೇಕಾದ ಪಾಸ್ವರ್ಡ್ ಬದಲಾಯಿಸಿ." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} 잠금 해제 시 사용되는 비밀번호를 변경합니다." - } - }, - "ku" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} وشە نهێنی بگۆڕە بۆ کردنەوەی" - } - }, - "ku-TR" : { - "stringUnit" : { - "state" : "translated", - "value" : "şîfreyê ku ji bo vekirina {app_name} lazim e biguherîne." - } - }, - "lg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Change the password required to unlock {app_name}." - } - }, - "lo" : { - "stringUnit" : { - "state" : "translated", - "value" : "ປ່ຽນລະຫັດຕົກທາງທີ່ຈະເຜີດ {app_name}." - } - }, - "lt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pakeisti slaptažodį, reikalingą atrakinti {app_name}." - } - }, - "lv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mainīt paroli, kas nepieciešama {app_name} atbloķēšanai." - } - }, - "mk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Смени ја лозинката што е потребна за отклучување {app_name}." - } - }, - "mn" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} -г нээхийн тулд шаардлагатай нууц үгийг өөрчлөх." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tukar kata laluan yang diperlukan untuk membuka kunci {app_name}." - } - }, - "my" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} ဖြင့် လော့ခ်ဖွင့်ရန် လျှို့ဝှက် စကားဝှက် ပြောင်းပါ" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Endre passordet som kreves for å låse opp {app_name}." - } - }, - "nb-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Endre passordet som kreves for å låse opp {app_name}." - } - }, - "ne-NP" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} अनलक गर्न आवश्यक पासवर्ड परिवर्तन गर्नुहोस्।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wijzig het wachtwoord dat nodig is om {app_name} te ontgrendelen." - } - }, - "nn-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Endre passordet som krevst for å låsa opp {app_name}." - } - }, - "ny" : { - "stringUnit" : { - "state" : "translated", - "value" : "Change the password required to unlock {app_name}." - } - }, - "pa-IN" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} ਨੂੰ ਅਨਲੌਕ ਕਰਨ ਲਈ ਲੋੜੀਂਦੇ ਪਾਸਵਰਡ ਨੂੰ ਬਦਲੋ।" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zmień hasło wymagane do odblokowania aplikacji {app_name}." - } - }, - "ps" : { - "stringUnit" : { - "state" : "translated", - "value" : "د {app_name} خلاصولو لپاره اړین پاسورډ بدل کړئ." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Altere a senha necessária para desbloquear {app_name}." - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Altere a palavra-passe, necessária para desbloquear {app_name}." - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Schimbați parola necesară pentru a debloca {app_name}." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Измените пароль, необходимый для разблокировки {app_name}." - } - }, - "sh" : { - "stringUnit" : { - "state" : "translated", - "value" : "Promeni lozinku potrebnu za otključavanje {app_name}." - } - }, - "si-LK" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} අගුළු විවෘත කිරීමට අවශ්‍ය මුරපදය වෙනස් කරන්න." - } - }, - "sk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zmeňte heslo potrebné na odomknutie {app_name}." - } - }, - "sl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Spremeni geslo potrebno za odklepanje {app_name}." - } - }, - "sq" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ndryshoni fjalëkalimin e kërkuar për të zhbllokuar {app_name}." - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Измените лозинку потребну за откључавање {app_name}." - } - }, - "sr-Latn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Promenite lozinku koja je potrebna za otključavanje {app_name}." - } - }, - "sv-SE" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ändra lösenordet som krävs för att låsa upp {app_name}." - } - }, - "sw" : { - "stringUnit" : { - "state" : "translated", - "value" : "Badilisha nywila inayohitajika kufungua {app_name}." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} ேத்தUnlock ச்சபட செய்ய வேண்டிய கடவுச்சொல்லை மாற்றவும்." - } - }, - "te" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} ని అన్‌లాక్ చేయడానికి అవసరమైన పాస్‌వర్డ్ మార్చండి." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เปลี่ยนรหัสผ่านที่ใช้ปลดล็อก {app_name}" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} kilidini açmak için gereken parolayı değiştirin." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Змінити пароль, необхідний для розблокування {app_name}." - } - }, - "ur-IN" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_name} کو ان لاک کرنے کے لیے مطلوبہ پاس ورڈ تبدیل کریں۔" - } - }, - "uz" : { - "stringUnit" : { - "state" : "translated", - "value" : "O'zingizga {app_name}ni ochish uchun zarur parolni o'zgartiring." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Đổi mật khẩu cần thiết để mở khóa {app_name}." - } - }, - "xh" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tshintsha i-password efunekayo ukusikhulula {app_name}." - } - }, - "zh-CN" : { - "stringUnit" : { - "state" : "translated", - "value" : "更改{app_name}的解锁密码" - } - }, - "zh-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "更改解鎖 {app_name} 所需的密碼。" - } } } }, @@ -332108,17 +330841,6 @@ } } }, - "passwordDescription" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Require password to unlock {app_name} on startup." - } - } - } - }, "passwordEnter" : { "extractionState" : "manual", "localizations" : { @@ -336075,492 +334797,24 @@ } } }, - "passwordRemovedDescription" : { + "passwordRemovedDescriptionToast" : { "extractionState" : "manual", "localizations" : { - "af" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jou wagwoord is verwyder." - } - }, - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تمت إزالة كلمة السر الخاصة بك." - } - }, - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Parolunuz silindi." - } - }, - "bal" : { - "stringUnit" : { - "state" : "translated", - "value" : "ما گپ درخواست قبول کردی بیک پاسکوڈ ہٹاٹی." - } - }, - "be" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваш пароль быў выдалены." - } - }, - "bg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вашата парола беше премахната." - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "আপনার পাসওয়ার্ড সরানো হয়েছে।" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "La vostra contrasenya s'ha eliminat." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaše heslo bylo odstraněno." - } - }, - "cy" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mae eich cyfrinair wedi'i dynnu." - } - }, - "da" : { - "stringUnit" : { - "state" : "translated", - "value" : "Din adgangskode er blevet fjernet." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dein Passwort wurde entfernt." - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ο κωδικός πρόσβασής σας έχει αφαιρεθεί." - } - }, "en" : { "stringUnit" : { "state" : "translated", "value" : "Your password has been removed." } - }, - "eo" : { - "stringUnit" : { - "state" : "translated", - "value" : "Via pasvorto estas forigita." - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tu contraseña ha sido eliminada." - } - }, - "es-ES" : { - "stringUnit" : { - "state" : "translated", - "value" : "Has eliminado tu contraseña." - } - }, - "et" : { - "stringUnit" : { - "state" : "translated", - "value" : "Teie parool on eemaldatud." - } - }, - "eu" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zure pasahitza kendu da." - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "گذرواژه شما حذف شده است." - } - }, - "fi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Salasanasi on on poistettu." - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ang iyong password ay naalis na." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre mot de passe a été supprimé." - } - }, - "gl" : { - "stringUnit" : { - "state" : "translated", - "value" : "O teu contrasinal foi eliminado." - } - }, - "ha" : { - "stringUnit" : { - "state" : "translated", - "value" : "An cire kalmar sirrinku." - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "הסיסמה שלך הוסרה." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "आपका पासवर्ड हटा दिया गया है।" - } - }, - "hr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaša lozinka je uklonjena." - } - }, - "hu" : { - "stringUnit" : { - "state" : "translated", - "value" : "A jelszavadat eltávolítottuk." - } - }, - "hy-AM" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ձեր գաղտնաբառը հեռացվել է։" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kata sandi Anda telah dihapus." - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "La tua password è stata rimossa." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "パスワードを削除しました。" - } - }, - "ka" : { - "stringUnit" : { - "state" : "translated", - "value" : "თქვენი პაროლი წაშლილია." - } - }, - "km" : { - "stringUnit" : { - "state" : "translated", - "value" : "ពាក្យសម្ងាត់ របស់អ្នកត្រូវបានលុបចេញ។" - } - }, - "kn" : { - "stringUnit" : { - "state" : "translated", - "value" : "ನಿಮ್ಮ ಗುಪ್ತಪದವನ್ನು ತೆಗೆದುಹಾಕಲಾಗಿದೆ." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "당신의 비밀번호가 제거되었습니다." - } - }, - "ku" : { - "stringUnit" : { - "state" : "translated", - "value" : "وشەی پەرەسەدت وەکبێژاند." - } - }, - "ku-TR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zoom" - } - }, - "lg" : { - "stringUnit" : { - "state" : "translated", - "value" : "Password yo ekatutuzzibwa." - } - }, - "lt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jūsų slaptažodis buvo pašalintas." - } - }, - "lv" : { - "stringUnit" : { - "state" : "translated", - "value" : "Jūsu parole tika noņemta." - } - }, - "mk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вашата лозинка е отстранета." - } - }, - "mn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Таны нууц үг устгагдсан." - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kata laluan anda telah dibuang." - } - }, - "my" : { - "stringUnit" : { - "state" : "translated", - "value" : "သင်၏ စကားဝှက် ဖယ်ရှားပြီးပါပြီ။" - } - }, - "nb" : { - "stringUnit" : { - "state" : "translated", - "value" : "Passordet ditt er fjernet." - } - }, - "nb-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Passordet ditt har blitt fjernet." - } - }, - "ne-NP" : { - "stringUnit" : { - "state" : "translated", - "value" : "तपाईँको पासवर्ड हटाइएको छ।" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Uw wachtwoord is verwijderd." - } - }, - "nn-NO" : { - "stringUnit" : { - "state" : "translated", - "value" : "Passordet ditt er blitt fjerna." - } - }, - "ny" : { - "stringUnit" : { - "state" : "translated", - "value" : "Password yanu yachotsedwa." - } - }, - "pa-IN" : { - "stringUnit" : { - "state" : "translated", - "value" : "ਤੁਹਾਡਾ ਪਾਸਵਰਡ ਹਟਾ ਦਿੱਤਾ ਗਿਆ ਹੈ।" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Usunięto hasło" - } - }, - "ps" : { - "stringUnit" : { - "state" : "translated", - "value" : "ستاسو پاسورډ لرې شوی دی." - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sua senha foi removida." - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "A sua palavra-passe foi removida." - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Parola ta a fost eliminată." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваш пароль удален." - } - }, - "sh" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tvoja šifra je uklonjena." - } - }, - "si-LK" : { - "stringUnit" : { - "state" : "translated", - "value" : "ඔබගේ මුරපදය ඉවත් කර ඇත." - } - }, - "sk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaše heslo bolo odstránené." - } - }, - "sl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaše geslo je bilo odstranjeno." - } - }, - "sq" : { - "stringUnit" : { - "state" : "translated", - "value" : "Fjalëkalimi juaj është hequr." - } - }, - "sr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваша лозинка је уклоњена." - } - }, - "sr-Latn" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vaša lozinka je uklonjena." - } - }, - "sv-SE" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ditt lösenord har tagits bort." - } - }, - "sw" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nenosiri lako limeondolewa." - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "உங்களின் கடவுச்சொல் நீக்கப்பட்டது." - } - }, - "te" : { - "stringUnit" : { - "state" : "translated", - "value" : "మీ పాస్‌వర్డ్ తొలగించబడింది." - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "รหัสผ่านของคุณถูกลบแล้ว" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Parolanız kaldırıldı." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ваш пароль був видалений." - } - }, - "ur-IN" : { - "stringUnit" : { - "state" : "translated", - "value" : "آپ کا پاس ورڈ ہٹا دیا گیا ہے۔" - } - }, - "uz" : { - "stringUnit" : { - "state" : "translated", - "value" : "Parolingiz saqlandi. Iltimos, uni xavfsiz joyda saqlang." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mật khẩu của bạn đã được gỡ bỏ." - } - }, - "xh" : { - "stringUnit" : { - "state" : "translated", - "value" : "Iphasiwedi yakho isusiwe." - } - }, - "zh-CN" : { - "stringUnit" : { - "state" : "translated", - "value" : "您的密码已被移除。" - } - }, - "zh-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "已移除密碼。" - } } } }, - "passwordRemoveDescription" : { + "passwordRemoveShortDescription" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Remove your current password for {app_name}. Locally stored data will be re-encrypted with a randomly generated key, stored on your device." + "value" : "Remove the password required to unlock {app_name}" } } } @@ -337055,13 +335309,24 @@ } } }, - "passwordSetDescription" : { + "passwordSetDescriptionToast" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Set a password for {app_name}. Locally stored data will be encrypted with this password. You will be asked to enter this password each time {app_name} starts." + "value" : "Your password has been set. Please keep it safe." + } + } + } + }, + "passwordSetShortDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Require password to unlock {app_name} on startup." } } } @@ -351180,6 +349445,28 @@ } } }, + "plusLoadsMore" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plus Loads More..." + } + } + } + }, + "plusLoadsMoreDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New features coming soon to {pro}. Discover what's next on the {pro} Roadmap {icon}" + } + } + } + }, "preferences" : { "extractionState" : "manual", "localizations" : { @@ -351806,6 +350093,28 @@ } } }, + "proAllSet" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You're all set!" + } + } + } + }, + "proAllSetDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {app_pro} plan was updated! You will be billed when your current {pro} plan is automatically renewed on {date}." + } + } + } + }, "proAlreadyPurchased" : { "extractionState" : "manual", "localizations" : { @@ -352449,6 +350758,28 @@ } } }, + "proAnimatedDisplayPictures" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animated Display Pictures" + } + } + } + }, + "proAnimatedDisplayPicturesDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set animated GIFs and WebP images as your display picture." + } + } + } + }, "proAnimatedDisplayPicturesNonProModalDescription" : { "extractionState" : "manual", "localizations" : { @@ -352580,121 +350911,101 @@ } } }, - "proBadge" : { + "proAutoRenewTime" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} Nişanı" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} Insígnia" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odznak {app_pro}" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro}-Abzeichen" - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "{app_pro} Badge" - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "Insignia de {app_pro}" - } - }, - "es-ES" : { - "stringUnit" : { - "state" : "translated", - "value" : "Insignia de {app_pro}" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Badge {app_pro}" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} बैज" + "value" : "{pro} auto-renewing in {time}" } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Badge {app_pro}" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} バッジ" - } - }, - "nl" : { + } + } + }, + "proBadge" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "{app_pro}-badge" + "value" : "{pro} Badge" } - }, - "pl" : { + } + } + }, + "proBadges" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Odznaka {app_pro}" + "value" : "Badges" } - }, - "pt-PT" : { + } + } + }, + "proBadgesDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Distintivo {app_pro}" + "value" : "Show your support for {app_name} with an exclusive badge next to your display name." } - }, - "ro" : { + } + } + }, + "proBadgesSent" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Insigna {app_pro}" + "value" : "{count} {pro} Badges Sent" } - }, - "sv-SE" : { + } + } + }, + "proBadgeVisible" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "{app_pro}-märke" + "value" : "Show {app_pro} badge to other users" } - }, - "uk" : { + } + } + }, + "proBilledAnnually" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "{app_pro} значок" + "value" : "{price} Billed Annually" } - }, - "zh-CN" : { + } + } + }, + "proBilledMonthly" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "{app_pro} 徽章" + "value" : "{price} Billed Monthly" } - }, - "zh-TW" : { + } + } + }, + "proBilledQuarterly" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "{app_pro} 徽章" + "value" : "{price} Billed Quarterly" } } } @@ -353098,6 +351409,94 @@ } } }, + "processingRefundRequest" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{platform_account} is processing your refund request" + } + } + } + }, + "proDiscountTooltip" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your current plan is already discounted by{percent}% of the full {app_pro} price." + } + } + } + }, + "proExpired" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expired" + } + } + } + }, + "proExpiredDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unfortunately, your {pro} plan has expired. Renew to keep accessing the exclusive perks and features of {app_pro}." + } + } + } + }, + "proExpiringSoon" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expiring Soon" + } + } + } + }, + "proExpiringSoonDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {pro} plan is expiring in {time}. Update your plan to keep accessing the exclusive perks and features of {app_pro}." + } + } + } + }, + "proFaq" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} FAQ" + } + } + } + }, + "proFaqDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Find answers to common questions in the {app_name} FAQ." + } + } + } + }, "proFeatureListAnimatedDisplayPicture" : { "extractionState" : "manual", "localizations" : { @@ -353765,6 +352164,17 @@ } } }, + "proFeatures" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} Features" + } + } + } + }, "profile" : { "extractionState" : "manual", "localizations" : { @@ -356883,6 +355293,28 @@ } } }, + "proGroupsUpgraded" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{count} Groups Upgraded" + } + } + } + }, + "proImportantDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requesting a refund is final. If approved, your {pro} plan will be canceled immediately and you will lose access to all {pro} features." + } + } + } + }, "proIncreasedAttachmentSizeFeature" : { "extractionState" : "manual", "localizations" : { @@ -357121,6 +355553,61 @@ } } }, + "proLargerGroups" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Larger Groups" + } + } + } + }, + "proLargerGroupsDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Groups you are an admin in are automatically upgraded to support 300 members." + } + } + } + }, + "proLongerMessages" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Longer Messages" + } + } + } + }, + "proLongerMessagesDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You can send messages up to 10,000 characters in all conversations." + } + } + } + }, + "proLongerMessagesSent" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{count} Longer Messages Sent" + } + } + } + }, "proMessageInfoFeatures" : { "extractionState" : "manual", "localizations" : { @@ -359333,6 +357820,347 @@ } } }, + "proPercentOff" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{percent}% Off" + } + } + } + }, + "proPinnedConversations" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{count} Pinned Conversations" + } + } + } + }, + "proPlanActivatedAuto" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {app_pro} plan is active!

Your plan will automatically renew for another {current_plan} on {date}. Updates to your plan take effect when {pro} is next renewed." + } + } + } + }, + "proPlanActivatedAutoShort" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {app_pro} plan is active!

Your plan will automatically renew for another {current_plan} on {date}." + } + } + } + }, + "proPlanActivatedNotAuto" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {app_pro} plan will expire on {date}.

Update your plan now to ensure uninterrupted access to exclusive Pro features." + } + } + } + }, + "proPlanExpireDate" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {app_pro} plan will expire on {date}." + } + } + } + }, + "proPlanNotFound" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} Plan Not Found" + } + } + } + }, + "proPlanNotFoundDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No active plan was found for your account. If you believe this is a mistake, please reach out to {app_name} support for assistance." + } + } + } + }, + "proPlanPlatformRefund" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Because you originally signed up for {app_pro} via the {platform_store} Store, you'll need to use the same {platform_account} to request a refund." + } + } + } + }, + "proPlanPlatformRefundLong" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Because you originally signed up for {app_pro} via the {platform_store} Store, your refund request will be processed by {app_name} Support.

Request a refund by hitting the button below and completing the refund request form.

While {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume." + } + } + } + }, + "proPlanRecover" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recover {pro} Plan" + } + } + } + }, + "proPlanRenew" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew {pro} Plan" + } + } + } + }, + "proPlanRenewDesktop" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store} Stores. Because you are using {app_name} Desktop, you're not able to renew your plan here.

{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store} Stores. {pro} Roadmap" + } + } + } + }, + "proPlanRenewDesktopLinked" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew your plan in the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store} Store." + } + } + } + }, + "proPlanRenewDesktopStore" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew your plan on the {platform_store} website using the {platform_account} you signed up for {pro} with." + } + } + } + }, + "proPlanRenewStart" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew your {app_pro} plan to start using powerful {app_pro} features again." + } + } + } + }, + "proPlanRenewSupport" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {app_pro} plan has been renewed! Thank you for supporting the {network_name}." + } + } + } + }, + "proPlanRestored" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} Plan Restored" + } + } + } + }, + "proPlanRestoredDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A valid plan for {app_pro} was detected and your {pro} status has been restored!" + } + } + } + }, + "proPlanSignUp" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Because you originally signed up for {app_pro} via the {platform_store} Store, you'll need to use your {platform_account} to update your plan." + } + } + } + }, + "proPriceOneMonth" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 Month - {monthly_price} / Month" + } + } + } + }, + "proPriceThreeMonths" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "3 Months - {monthly_price} / Month" + } + } + } + }, + "proPriceTwelveMonths" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "12 Months - {monthly_price} / Month" + } + } + } + }, + "proRefundDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "We’re sorry to see you go. Here's what you need to know before requesting a refund." + } + } + } + }, + "proRefunding" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refunding {pro}" + } + } + } + }, + "proRefundingDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refunds for {app_pro} plans are handled exclusively by {platform_account} through the {platform_store} Store.

Due to {platform_account} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued." + } + } + } + }, + "proRefundNextSteps" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{platform_account} is now processing your refund request. This typically takes 24-48 hours. Depending on their decision, you may see your {pro} status change in {app_name}." + } + } + } + }, + "proRefundRequestSessionSupport" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your refund request will be handled by {app_name} Support.

Request a refund by hitting the button below and completing the refund request form.

While {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume." + } + } + } + }, + "proRefundRequestStorePolicies" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your refund request will be handled exclusively by {platform_account} through the {platform_account} website.

Due to {platform_account} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued." + } + } + } + }, + "proRefundSupport" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please contact {platform_account} for further updates on your refund request. Due to {platform_account} refund policies, {app_name} developers have no ability to influence the outcome of refund requests.

{platform_store} Refund Support" + } + } + } + }, + "proRequestedRefund" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refund Requested" + } + } + } + }, "proSendMore" : { "extractionState" : "manual", "localizations" : { @@ -359470,6 +358298,83 @@ } } }, + "proSettings" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} Settings" + } + } + } + }, + "proStats" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {pro} Stats" + } + } + } + }, + "proStatsTooltip" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} stats reflect usage on this device and may appear differently on linked devices" + } + } + } + }, + "proSupportDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Need help with your {pro} plan? Submit a request to the support team." + } + } + } + }, + "proTosPrivacy" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "By updating, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon}" + } + } + } + }, + "proUnlimitedPins" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlimited Pins" + } + } + } + }, + "proUnlimitedPinsDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Organize all your chats with unlimited pinned conversations." + } + } + } + }, "proUserProfileModalCallToAction" : { "extractionState" : "manual", "localizations" : { @@ -374753,6 +373658,17 @@ } } }, + "refundPlanNonOriginatorApple" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Because you originally signed up for {app_pro} via a different {platform_account}, you'll need to use that {platform_account} to update your plan." + } + } + } + }, "remainingCharactersOverTooltip" : { "extractionState" : "manual", "localizations" : { @@ -376291,6 +375207,28 @@ } } }, + "removePasswordModalDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove your current password for {app_name}. Locally stored data will be re-encrypted with a randomly generated key, stored on your device." + } + } + } + }, + "renew" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew" + } + } + } + }, "reply" : { "extractionState" : "manual", "localizations" : { @@ -376770,6 +375708,17 @@ } } }, + "requestRefund" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Request Refund" + } + } + } + }, "resend" : { "extractionState" : "manual", "localizations" : { @@ -397802,6 +396751,17 @@ } } }, + "sessionProBeta" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{app_pro} Beta" + } + } + } + }, "sessionRecoveryPassword" : { "extractionState" : "manual", "localizations" : { @@ -399400,6 +398360,17 @@ } } }, + "setPasswordModalDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set a password for {app_name}. Locally stored data will be encrypted with this password. You will be asked to enter this password each time {app_name} starts." + } + } + } + }, "settingsRestartDescription" : { "extractionState" : "manual", "localizations" : { @@ -407279,6 +406250,17 @@ } } }, + "theReturn" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Return" + } + } + } + }, "tooltipAccountIdVisible" : { "extractionState" : "manual", "localizations" : { @@ -414274,6 +413256,28 @@ } } }, + "updatePlan" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update Plan" + } + } + } + }, + "updatePlanTwo" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Two ways to update your plan:" + } + } + } + }, "updateProfileInformation" : { "extractionState" : "manual", "localizations" : { @@ -418293,6 +417297,17 @@ } } }, + "urlOpenDescriptionAlternative" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Links will open in your browser." + } + } + } + }, "useFastMode" : { "extractionState" : "manual", "localizations" : { @@ -418772,6 +417787,28 @@ } } }, + "viaStoreWebsite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Via the {platform_store} website" + } + } + } + }, + "viaStoreWebsiteDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change your plan using the {platform_account} you used to sign up with, via the {platform_store} website." + } + } + } + }, "video" : { "extractionState" : "manual", "localizations" : { diff --git a/SessionUIKit/Style Guide/Constants.swift b/SessionUIKit/Style Guide/Constants.swift index 7ff7fa8564..85ccadd231 100644 --- a/SessionUIKit/Style Guide/Constants.swift +++ b/SessionUIKit/Style Guide/Constants.swift @@ -15,4 +15,6 @@ public enum Constants { public static let usd_name_short: String = "USD" public static let session_network_data_price: String = "Price data powered by CoinGecko
Accurate at {date_time}" public static let app_pro: String = "Session Pro" + public static let session_foundation: String = "Session Foundation" + public static let pro: String = "Pro" } From 40aacf43a6f45ae141ccb79604f8d8471006955c Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Tue, 26 Aug 2025 09:40:35 +1000 Subject: [PATCH 020/162] clean up --- Session/Media Viewing & Editing/MessageInfoScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index e620787c46..fa8334ae07 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -590,7 +590,7 @@ struct MessageInfoScreen: View { onStartThread: self.onStartThread, onProBadgeTapped: self.showSessionProCTAIfNeeded ), - dataManager: dependencies[singleton: .imageDataManager], + dataManager: dependencies[singleton: .imageDataManager] ) ) self.host.controller?.present(userProfileModal, animated: true, completion: nil) From 96175cb1ccca25ee5c91b50439a5968655de3425 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Tue, 26 Aug 2025 11:28:07 +1000 Subject: [PATCH 021/162] fix unit test --- .../MessageInfoScreen.swift | 2 +- .../ThreadSettingsViewModelSpec.swift | 36 +++---------------- 2 files changed, 6 insertions(+), 32 deletions(-) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index fa8334ae07..655dd32d7e 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -501,7 +501,7 @@ struct MessageInfoScreen: View { guard dependencies[feature: .sessionProEnabled] else { return (proFeatures, proCTAVariant) } if (dependencies.mutate(cache: .libSession) { $0.shouldShowProBadge(for: messageViewModel.profile) }) { - proFeatures.append("Session Pro Badge") // TODO: Localization + proFeatures.append("appProBadge".put(key: "app_pro", value: Constants.app_pro).localized()) } if (messageViewModel.isProMessage || messageViewModel.body.defaulting(to: "").utf16.count > LibSession.CharacterLimit) { diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index 5f9fdb51ac..d81829f623 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -246,20 +246,12 @@ class ThreadSettingsViewModelSpec: AsyncSpec { expect(item?.title?.text).to(equal("TestUser")) } - // MARK: ---- has an edit icon - it("has an edit icon") { - let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) - .toEventuallyNot(beNil()) - .retrieveValue() - expect(item?.trailingAccessory).toNot(beNil()) - } - // MARK: ---- presents a confirmation modal when tapped it("presents a confirmation modal when tapped") { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - await item?.onTap?() + await item?.onTapView?(UIView()) await expect(screenTransitions.first?.destination) .toEventually(beAKindOf(ConfirmationModal.self)) expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) @@ -275,7 +267,7 @@ class ThreadSettingsViewModelSpec: AsyncSpec { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - await item?.onTap?() + await item?.onTapView?(UIView()) await expect(screenTransitions.first?.destination) .toEventually(beAKindOf(ConfirmationModal.self)) @@ -460,21 +452,12 @@ class ThreadSettingsViewModelSpec: AsyncSpec { setupTestSubscriptions() } - // MARK: ------ has an edit icon - it("has an edit icon") { - let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) - .toEventuallyNot(beNil()) - .retrieveValue() - expect(item?.trailingAccessory).toNot(beNil()) - } - // MARK: ------ presents a confirmation modal when tapped it("presents a confirmation modal when tapped") { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - expect(item?.trailingAccessory).toNot(beNil()) - await item?.onTap?() + await item?.onTapView?(UIView()) await expect(screenTransitions.first?.destination) .toEventually(beAKindOf(ConfirmationModal.self)) expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) @@ -589,21 +572,12 @@ class ThreadSettingsViewModelSpec: AsyncSpec { setupTestSubscriptions() } - // MARK: ------ has an edit icon - it("has an edit icon") { - let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) - .toEventuallyNot(beNil()) - .retrieveValue() - expect(item?.trailingAccessory).toNot(beNil()) - } - // MARK: ------ presents a confirmation modal when tapped it("presents a confirmation modal when tapped") { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - expect(item?.trailingAccessory).toNot(beNil()) - await item?.onTap?() + await item?.onTapView?(UIView()) await expect(screenTransitions.first?.destination) .toEventually(beAKindOf(ConfirmationModal.self)) expect(screenTransitions.first?.transition).to(equal(TransitionType.present)) @@ -631,7 +605,7 @@ class ThreadSettingsViewModelSpec: AsyncSpec { let item: Item? = await expect(item(section: .conversationInfo, id: .displayName)) .toEventuallyNot(beNil()) .retrieveValue() - await item?.onTap?() + await item?.onTapView?(UIView()) await expect(screenTransitions.first?.destination) .toEventually(beAKindOf(ConfirmationModal.self)) From 457f4f939963e512e5e68ae038c67fe1287ae3f2 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Tue, 26 Aug 2025 17:12:35 +1000 Subject: [PATCH 022/162] fix message bubble in message info screen --- Session.xcodeproj/project.pbxproj | 4 + .../MessageInfoScreen.swift | 18 +- .../SwiftUI/TappableLabel+SwiftUI.swift | 191 ++++++++++++++++++ SessionUIKit/Components/TappableLabel.swift | 29 ++- 4 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 SessionUIKit/Components/SwiftUI/TappableLabel+SwiftUI.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 415ac1b0c1..cd88d3b86b 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -182,6 +182,7 @@ 942BA9C12E4EA5CB007C4595 /* SessionLabelWithProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */; }; 942BA9C22E53F694007C4595 /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; 942BA9C42E55AB54007C4595 /* UILabel+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9C32E55AB51007C4595 /* UILabel+Utilities.swift */; }; + 94363E552E5D84010004EE43 /* TappableLabel+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94363E542E5D83F60004EE43 /* TappableLabel+SwiftUI.swift */; }; 943C6D822B75E061004ACE64 /* Message+DisappearingMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */; }; 945D9C582D6FDBE7003C4C0C /* _005_AddJobUniqueHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945D9C572D6FDBE7003C4C0C /* _005_AddJobUniqueHash.swift */; }; 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */ = {isa = PBXBuildFile; productRef = 946F5A722D5DA3AC00A5ADCE /* Punycode */; }; @@ -1559,6 +1560,7 @@ 942BA9BE2E4ABB9F007C4595 /* _030_LastProfileUpdateTimestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _030_LastProfileUpdateTimestamp.swift; sourceTree = ""; }; 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionLabelWithProBadge.swift; sourceTree = ""; }; 942BA9C32E55AB51007C4595 /* UILabel+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Utilities.swift"; sourceTree = ""; }; + 94363E542E5D83F60004EE43 /* TappableLabel+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TappableLabel+SwiftUI.swift"; sourceTree = ""; }; 94367C422C6C828500814252 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+DisappearingMessages.swift"; sourceTree = ""; }; 943C6D832B86B5F1004ACE64 /* Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localization.swift; sourceTree = ""; }; @@ -2842,6 +2844,7 @@ 942256932C23F8DD00C0FDBF /* SwiftUI */ = { isa = PBXGroup; children = ( + 94363E542E5D83F60004EE43 /* TappableLabel+SwiftUI.swift */, 942BA9402E4487EE007C4595 /* LightBox.swift */, 94B6BB032E3B208200E718BB /* Seperator+SwiftUI.swift */, 94B6BAFF2E3AE83500E718BB /* QRCodeView.swift */, @@ -6111,6 +6114,7 @@ FD09B7E328865FDA00ED0B66 /* HighlightMentionBackgroundView.swift in Sources */, FD3FAB632AEB9A1500DC5421 /* ToastController.swift in Sources */, C331FFE72558FB0000070591 /* SNTextField.swift in Sources */, + 94363E552E5D84010004EE43 /* TappableLabel+SwiftUI.swift in Sources */, 942256962C23F8DD00C0FDBF /* CompatibleScrollingVStack.swift in Sources */, FD71165B28E6DDBC00B47552 /* StyledNavigationController.swift in Sources */, C331FFE32558FB0000070591 /* TabBar.swift in Sources */, diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 655dd32d7e..0c8ea7f254 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -615,6 +615,8 @@ struct MessageInfoScreen: View { } } +// MARK: - MessageBubble + struct MessageBubble: View { @State private var maxWidth: CGFloat? @State private var isExpanded: Bool = false @@ -634,7 +636,7 @@ struct MessageBubble: View { var body: some View { ZStack { - let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: messageViewModel) - 2 * Self.inset) + let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: messageViewModel, includingOppositeGutter: false) - 2 * Self.inset) let maxHeight: CGFloat = VisibleMessageCell.getMaxHeightAfterTruncation(for: messageViewModel) let height: CGFloat = VisibleMessageCell.getBodyTappableLabel( for: messageViewModel, @@ -705,9 +707,9 @@ struct MessageBubble: View { searchText: nil, using: dependencies ) { - AttributedText(bodyText) - .foregroundColor(themeColor: bodyLabelTextColor) + TappableLabel_SwiftUI(themeAttributedText: bodyText, maxWidth: maxWidth) .padding(.horizontal, Self.inset) + .padding(.top, Self.inset) .frame( maxHeight: (isExpanded ? .infinity : maxHeight) ) @@ -727,6 +729,7 @@ struct MessageBubble: View { if let attachment: Attachment = messageViewModel.attachments?.first(where: { $0.isAudio }){ // TODO: Playback Info and check if playing function is needed VoiceMessageView_SwiftUI(attachment: attachment) + .padding(.top, Self.inset) } case .audio, .genericAttachment: if let attachment: Attachment = messageViewModel.attachments?.first { @@ -736,6 +739,7 @@ struct MessageBubble: View { textColor: bodyLabelTextColor ) .modifier(MaxWidthEqualizer.notify) + .padding(.top, Self.inset) .frame( width: maxWidth, alignment: .leading @@ -745,7 +749,7 @@ struct MessageBubble: View { } } } - .padding(.vertical, Self.inset) + .padding(.bottom, Self.inset) .onTapGesture { self.isExpanded = true } @@ -753,6 +757,8 @@ struct MessageBubble: View { } } +// MARK: - InfoBlock + struct InfoBlock: View where Content: View { let title: String let content: () -> Content @@ -776,6 +782,8 @@ struct InfoBlock: View where Content: View { } } +// MARK: - MessageInfoViewController + final class MessageInfoViewController: SessionHostingViewController { init( actions: [ContextMenuVC.Action], @@ -807,6 +815,8 @@ final class MessageInfoViewController: SessionHostingViewController Container { + let result: TappableLabel = TappableLabel() + result.setContentHuggingPriority(.required, for: .horizontal) + result.setContentHuggingPriority(.required, for: .vertical) + // 🔑 Allow SwiftUI to compress vertically so .frame(maxHeight:) can apply + result.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + result.themeAttributedText = themeAttributedText + result.themeBackgroundColor = .clear + result.isOpaque = false + result.isUserInteractionEnabled = true + + return Container(label: result, maxWidth: maxWidth) + } + + public func updateUIView(_ container: Container, context: Context) { + container.label.themeAttributedText = themeAttributedText + container.maxWidth = maxWidth + container.invalidateIntrinsicContentSize() + container.setNeedsLayout() + container.layoutIfNeeded() + } + + public final class Container: UIView { + let label: TappableLabel + var maxWidth: CGFloat + private var widthCap: NSLayoutConstraint? + + init(label: TappableLabel, maxWidth: CGFloat) { + self.label = label + self.maxWidth = maxWidth + super.init(frame: .zero) + + addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: topAnchor), + label.leadingAnchor.constraint(equalTo: leadingAnchor), + label.trailingAnchor.constraint(equalTo: trailingAnchor), + label.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + // Container hugs; don't stretch to parent + setContentHuggingPriority(.required, for: .horizontal) + setContentCompressionResistancePriority(.required, for: .horizontal) + setContentHuggingPriority(.required, for: .vertical) + // 🔑 Also allow the container to compress vertically + setContentCompressionResistancePriority(.defaultLow, for: .vertical) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + public override func layoutSubviews() { + super.layoutSubviews() + + // Use the actual size SwiftUI assigned after .frame(maxHeight:) + let assignedWidth = min(bounds.width, maxWidth) + let assignedHeight = bounds.height + + // Make UILabel compute multi-line correctly + label.preferredMaxLayoutWidth = assignedWidth + + // Keep label’s internal text container width in sync for taps/highlights + label.textContainer.size = CGSize(width: assignedWidth, height: assignedHeight > 0 ? assignedHeight : .greatestFiniteMagnitude) + + // Decide truncation based on the final assigned height + guard let text = label.attributedText, text.length > 0, assignedWidth > 0 else { return } + + let info = layoutInfo(for: text, width: assignedWidth) // unlimited lines at this width + let total = info.totalHeight + + if assignedHeight > 0 && assignedHeight + 0.5 < total { + // Height is capped → compute how many lines fit and truncate tail + let linesFit = max(1, fittedLineCount(fromBottoms: info.lineBottoms, cap: assignedHeight)) + if label.numberOfLines != linesFit || label.lineBreakMode != .byTruncatingTail { + label.numberOfLines = linesFit + label.lineBreakMode = .byTruncatingTail + } + } else { + // No cap or content fits → unlimited wrapping + if label.numberOfLines != 0 || label.lineBreakMode != .byWordWrapping { + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + } + } + } + + public override var intrinsicContentSize: CGSize { + guard let text = label.attributedText, text.length > 0 else { + return label.intrinsicContentSize + } + // Hug natural (single-line) width if it fits; else wrap to maxWidth + let natural = measure(text, constrainedToWidth: nil) + if natural.width <= maxWidth { + return natural + } else { + let wrapped = measure(text, constrainedToWidth: maxWidth) + return CGSize(width: maxWidth, height: wrapped.height) + } + } + + public override func sizeThatFits(_ size: CGSize) -> CGSize { + // Respect a smaller proposed width (e.g., inside tight parents) + let cap = min(size.width > 0 ? size.width : .greatestFiniteMagnitude, maxWidth) + guard let text = label.attributedText, text.length > 0 else { + return label.sizeThatFits(CGSize(width: cap, height: .greatestFiniteMagnitude)) + } + let natural = measure(text, constrainedToWidth: nil) + if natural.width <= cap { + return natural + } else { + let wrapped = measure(text, constrainedToWidth: cap) + return CGSize(width: cap, height: wrapped.height) + } + } + + // MARK: - Measurement helpers (local to this file) + + private func fittedLineCount(fromBottoms bottoms: [CGFloat], cap: CGFloat) -> Int { + var count = 0 + for b in bottoms { + if b <= cap { count += 1 } else { break } + } + return count + } + + /// Unlimited-lines measurement + per-line bottoms at a given width. + private func layoutInfo(for text: NSAttributedString, width: CGFloat) -> (totalHeight: CGFloat, lineBottoms: [CGFloat]) { + let storage = NSTextStorage(attributedString: text) + let layout = NSLayoutManager() + let container = NSTextContainer(size: CGSize(width: width, height: .greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + container.lineBreakMode = .byWordWrapping + container.maximumNumberOfLines = 0 + layout.addTextContainer(container) + storage.addLayoutManager(layout) + + _ = layout.glyphRange(for: container) + + var lineBottoms: [CGFloat] = [] + var glyphIndex = 0 + while glyphIndex < layout.numberOfGlyphs { + var lineRange = NSRange(location: 0, length: 0) + let frag = layout.lineFragmentUsedRect(forGlyphAt: glyphIndex, + effectiveRange: &lineRange, + withoutAdditionalLayout: true) + lineBottoms.append(ceil(frag.maxY)) + glyphIndex = NSMaxRange(lineRange) + } + + let used = layout.usedRect(for: container) + return (totalHeight: ceil(used.height), lineBottoms: lineBottoms) + } + + // Kept for intrinsicContentSize / width-hugging path + private func measure(_ text: NSAttributedString, constrainedToWidth width: CGFloat?) -> CGSize { + let storage = NSTextStorage(attributedString: text) + let layout = NSLayoutManager() + let container = NSTextContainer(size: CGSize(width: width ?? .greatestFiniteMagnitude, + height: .greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + container.lineBreakMode = label.lineBreakMode + container.maximumNumberOfLines = 0 + layout.addTextContainer(container) + storage.addLayoutManager(layout) + + _ = layout.glyphRange(for: container) + let used = layout.usedRect(for: container) + // ceil to avoid fractional clipping + return CGSize(width: ceil(used.width), height: ceil(used.height)) + } + } +} diff --git a/SessionUIKit/Components/TappableLabel.swift b/SessionUIKit/Components/TappableLabel.swift index f750fca998..bca946f830 100644 --- a/SessionUIKit/Components/TappableLabel.swift +++ b/SessionUIKit/Components/TappableLabel.swift @@ -17,7 +17,7 @@ public class TappableLabel: UILabel { public private(set) var links: [String: NSRange] = [:] private lazy var highlightedMentionBackgroundView: HighlightMentionBackgroundView = HighlightMentionBackgroundView(targetLabel: self) private(set) var layoutManager = NSLayoutManager() - private(set) var textContainer = NSTextContainer(size: CGSize.zero) + public private(set) var textContainer = NSTextContainer(size: CGSize.zero) private(set) var textStorage = NSTextStorage() { didSet { textStorage.addLayoutManager(layoutManager) @@ -101,12 +101,39 @@ public class TappableLabel: UILabel { super.layoutSubviews() textContainer.size = bounds.size + + if preferredMaxLayoutWidth != bounds.width { + preferredMaxLayoutWidth = bounds.width + invalidateIntrinsicContentSize() + } + highlightedMentionBackgroundView.frame = self.frame.insetBy( dx: -highlightedMentionBackgroundView.maxPadding, dy: -highlightedMentionBackgroundView.maxPadding ) } + public override var intrinsicContentSize: CGSize { + // Compute layout with the current/expected width + let width = preferredMaxLayoutWidth > 0 ? preferredMaxLayoutWidth : bounds.width + let targetWidth = (width > 0) ? width : UIScreen.main.bounds.width + + textContainer.size = CGSize(width: targetWidth, height: .greatestFiniteMagnitude) + _ = layoutManager.glyphRange(for: textContainer) // forces layout + let used = layoutManager.usedRect(for: textContainer) + + // Ceil to avoid fractional sizes causing extra lines/clipping + return CGSize(width: ceil(used.width), height: ceil(used.height)) + } + + public override func sizeThatFits(_ size: CGSize) -> CGSize { + let targetWidth = size.width > 0 ? size.width : UIScreen.main.bounds.width + textContainer.size = CGSize(width: targetWidth, height: .greatestFiniteMagnitude) + _ = layoutManager.glyphRange(for: textContainer) + let used = layoutManager.usedRect(for: textContainer) + return CGSize(width: min(ceil(used.width), targetWidth), height: ceil(used.height)) + } + // MARK: - Functions private func findLinksAndRange(attributeString: NSAttributedString) { From 97fe40462db928785f67ba7c06cb9a52b196155d Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Wed, 27 Aug 2025 09:31:41 +1000 Subject: [PATCH 023/162] clean --- SessionUIKit/Components/SwiftUI/TappableLabel+SwiftUI.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/SessionUIKit/Components/SwiftUI/TappableLabel+SwiftUI.swift b/SessionUIKit/Components/SwiftUI/TappableLabel+SwiftUI.swift index 8f87708be8..ee8a1be8cc 100644 --- a/SessionUIKit/Components/SwiftUI/TappableLabel+SwiftUI.swift +++ b/SessionUIKit/Components/SwiftUI/TappableLabel+SwiftUI.swift @@ -21,7 +21,6 @@ public struct TappableLabel_SwiftUI: UIViewRepresentable { let result: TappableLabel = TappableLabel() result.setContentHuggingPriority(.required, for: .horizontal) result.setContentHuggingPriority(.required, for: .vertical) - // 🔑 Allow SwiftUI to compress vertically so .frame(maxHeight:) can apply result.setContentCompressionResistancePriority(.defaultLow, for: .vertical) result.themeAttributedText = themeAttributedText result.themeBackgroundColor = .clear @@ -58,11 +57,9 @@ public struct TappableLabel_SwiftUI: UIViewRepresentable { label.bottomAnchor.constraint(equalTo: bottomAnchor) ]) - // Container hugs; don't stretch to parent setContentHuggingPriority(.required, for: .horizontal) setContentCompressionResistancePriority(.required, for: .horizontal) setContentHuggingPriority(.required, for: .vertical) - // 🔑 Also allow the container to compress vertically setContentCompressionResistancePriority(.defaultLow, for: .vertical) } @@ -132,8 +129,6 @@ public struct TappableLabel_SwiftUI: UIViewRepresentable { } } - // MARK: - Measurement helpers (local to this file) - private func fittedLineCount(fromBottoms bottoms: [CGFloat], cap: CGFloat) -> Int { var count = 0 for b in bottoms { From 345ab9b6a642e7bb11be2cdec654edf62a3bc6f2 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 12 Sep 2025 11:32:35 +1000 Subject: [PATCH 024/162] fix merge --- Session/Media Viewing & Editing/MessageInfoScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 0c8ea7f254..9539bd8a0b 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -574,7 +574,7 @@ struct MessageInfoScreen: View { }() let userProfileModal: ModalHostingViewController = ModalHostingViewController( - modal: UserProfileModel( + modal: UserProfileModal( info: .init( sessionId: sessionId, blindedId: blindedId, From 3bf67ed84e868e37e605099cc829bc73b03ef129 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Wed, 17 Sep 2025 11:38:05 +0800 Subject: [PATCH 025/162] Updated notification content to group message request type notifications --- Session/Meta/AppDelegate.swift | 2 +- .../Notifications/NotificationPresenter.swift | 9 ++++----- .../NotificationsManagerType.swift | 7 +++++++ .../Types/NotificationContent.swift | 17 ++++++++++++++++- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 9252c1016e..004af7d4a8 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -921,7 +921,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // need to handle this behavior for legacy UINotification users anyway, we "allow" all // notification options here, and rely on the shared logic in NotificationPresenter to // honor notification sound preferences for both modern and legacy users. - completionHandler([.badge, .banner, .sound]) + completionHandler([.badge, .banner, .sound, .list]) } } diff --git a/Session/Notifications/NotificationPresenter.swift b/Session/Notifications/NotificationPresenter.swift index c335c898bd..c8785ff55d 100644 --- a/Session/Notifications/NotificationPresenter.swift +++ b/Session/Notifications/NotificationPresenter.swift @@ -339,11 +339,10 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, switch shouldPresentNotification { case true: - let shouldGroupNotification: Bool = ( - content.threadVariant == .community && - content.identifier == content.threadId - ) - + let shouldGroupNotification = ((content.threadVariant == .community && + content.identifier == content.threadId) || + content.groupingIdentifier != nil) + if shouldGroupNotification { /// Only set a trigger for grouped notifications if we don't already have one if trigger == nil { diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift index b844d336dc..861208aa68 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift @@ -352,6 +352,12 @@ public extension NotificationsManagerType { threadVariant: threadVariant ) + var groupingIdentifier: NotificationGroupingType? { + // Currently only supported custom notification grouping is `messageRequest` + // others are grouped using `threadId` + isMessageRequest ? .messageRequest : nil + } + /// Ensure we should be showing a notification for the thread try ensureWeShouldShowNotification( message: message, @@ -383,6 +389,7 @@ public extension NotificationsManagerType { } }(), category: .incomingMessage, + groupingIdentifier: groupingIdentifier, title: try notificationTitle( cat: cat, message: message, diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift index 2557384fb4..6cb99852ca 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift @@ -3,11 +3,20 @@ import UIKit import UserNotifications +// Add more later if any push notification needs to be customly grouped +// Currently default grouping is via `threadId` +public enum NotificationGroupingType: CaseIterable { + case messageRequest + + var name: String { "message-request-grouping-identifier" } +} + public struct NotificationContent { public let threadId: String? public let threadVariant: SessionThread.Variant? public let identifier: String public let category: NotificationCategory + public let groupingIdentifier: NotificationGroupingType? public let title: String? public let body: String? public let delay: TimeInterval? @@ -22,6 +31,7 @@ public struct NotificationContent { threadVariant: SessionThread.Variant?, identifier: String, category: NotificationCategory, + groupingIdentifier: NotificationGroupingType? = nil, title: String? = nil, body: String? = nil, delay: TimeInterval? = nil, @@ -33,6 +43,7 @@ public struct NotificationContent { self.threadVariant = threadVariant self.identifier = identifier self.category = category + self.groupingIdentifier = groupingIdentifier self.title = title self.body = body self.delay = delay @@ -67,7 +78,11 @@ public struct NotificationContent { content.categoryIdentifier = category.identifier content.userInfo = userInfo - if let threadId: String = threadId { content.threadIdentifier = threadId } + if let threadId = threadId { + let groupName = groupingIdentifier?.name ?? threadId + content.threadIdentifier = groupName + } + if let title: String = title { content.title = title } if let body: String = body { content.body = body } From ee2295cf68993f5ea140a3889654c8df64d123de Mon Sep 17 00:00:00 2001 From: mikoldin Date: Thu, 18 Sep 2025 09:12:34 +0800 Subject: [PATCH 026/162] Updated notification grouping cases --- .../Notifications/NotificationPresenter.swift | 10 ++++---- .../NotificationsManagerType.swift | 13 ++++------ .../Types/NotificationContent.swift | 24 +++++++++++++------ .../MessageReceiverGroupsSpec.swift | 2 ++ .../NotificationsManagerSpec.swift | 2 ++ 5 files changed, 32 insertions(+), 19 deletions(-) diff --git a/Session/Notifications/NotificationPresenter.swift b/Session/Notifications/NotificationPresenter.swift index c8785ff55d..b60ed79b92 100644 --- a/Session/Notifications/NotificationPresenter.swift +++ b/Session/Notifications/NotificationPresenter.swift @@ -230,6 +230,7 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, threadVariant: threadVariant, identifier: threadId, category: .errorMessage, + groupingIdentifier: .threadId(threadId), body: "messageErrorDelivery".localized(), sound: notificationSettings.sound, userInfo: notificationUserInfo(threadId: threadId, threadVariant: threadVariant), @@ -339,11 +340,12 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, switch shouldPresentNotification { case true: - let shouldGroupNotification = ((content.threadVariant == .community && - content.identifier == content.threadId) || - content.groupingIdentifier != nil) + let shouldDelayNotificationForBatching: Bool = ( + content.threadVariant == .community && + content.identifier == content.threadId + ) - if shouldGroupNotification { + if shouldDelayNotificationForBatching { /// Only set a trigger for grouped notifications if we don't already have one if trigger == nil { trigger = UNTimeIntervalNotificationTrigger( diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift index 861208aa68..8e65f4550d 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift @@ -351,13 +351,7 @@ public extension NotificationsManagerType { threadId: threadId, threadVariant: threadVariant ) - - var groupingIdentifier: NotificationGroupingType? { - // Currently only supported custom notification grouping is `messageRequest` - // others are grouped using `threadId` - isMessageRequest ? .messageRequest : nil - } - + /// Ensure we should be showing a notification for the thread try ensureWeShouldShowNotification( message: message, @@ -389,7 +383,10 @@ public extension NotificationsManagerType { } }(), category: .incomingMessage, - groupingIdentifier: groupingIdentifier, + groupingIdentifier: (isMessageRequest ? + .messageRequest : + .threadId(threadId) + ), title: try notificationTitle( cat: cat, message: message, diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift index 6cb99852ca..33e11a2699 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationContent.swift @@ -1,14 +1,24 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import UIKit import UserNotifications // Add more later if any push notification needs to be customly grouped // Currently default grouping is via `threadId` -public enum NotificationGroupingType: CaseIterable { +public enum NotificationGroupingType: Equatable { case messageRequest + case threadId(String) + case none - var name: String { "message-request-grouping-identifier" } + var key: String? { + switch self { + case .messageRequest: "message-request-grouping-identifier" + case .threadId(let indentifier): indentifier + case .none: nil + } + } } public struct NotificationContent { @@ -16,7 +26,7 @@ public struct NotificationContent { public let threadVariant: SessionThread.Variant? public let identifier: String public let category: NotificationCategory - public let groupingIdentifier: NotificationGroupingType? + public let groupingIdentifier: NotificationGroupingType public let title: String? public let body: String? public let delay: TimeInterval? @@ -31,7 +41,7 @@ public struct NotificationContent { threadVariant: SessionThread.Variant?, identifier: String, category: NotificationCategory, - groupingIdentifier: NotificationGroupingType? = nil, + groupingIdentifier: NotificationGroupingType = .none, title: String? = nil, body: String? = nil, delay: TimeInterval? = nil, @@ -64,6 +74,7 @@ public struct NotificationContent { threadVariant: threadVariant, identifier: identifier, category: category, + groupingIdentifier: groupingIdentifier, title: (title ?? self.title), body: (body ?? self.body), delay: self.delay, @@ -78,9 +89,8 @@ public struct NotificationContent { content.categoryIdentifier = category.identifier content.userInfo = userInfo - if let threadId = threadId { - let groupName = groupingIdentifier?.name ?? threadId - content.threadIdentifier = groupName + if let groupIdentifier = groupingIdentifier.key { + content.threadIdentifier = groupIdentifier } if let title: String = title { content.title = title } diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift index debc33045b..361f01f613 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift @@ -650,6 +650,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, identifier: "\(groupId.hexString)-1", category: .incomingMessage, + groupingIdentifier: .messageRequest, title: Constants.app_name, body: "messageRequestsNew".localized(), sound: .defaultNotificationSound, @@ -801,6 +802,7 @@ class MessageReceiverGroupsSpec: QuickSpec { threadVariant: .group, identifier: "\(groupId.hexString)-1", category: .incomingMessage, + groupingIdentifier: .threadId(groupId.hexString), title: "notificationsIosGroup" .put(key: "name", value: "0511...1111") .put(key: "conversation_name", value: "TestGroupName") diff --git a/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift b/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift index 5d7db29b23..8681ce24c8 100644 --- a/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift @@ -1344,6 +1344,7 @@ class NotificationsManagerSpec: QuickSpec { threadVariant: .contact, identifier: "05\(TestConstants.publicKey)-TestId", category: .incomingMessage, + groupingIdentifier: .threadId("05\(TestConstants.publicKey)"), title: "0588...c65b", body: "Test", sound: .note, @@ -1400,6 +1401,7 @@ class NotificationsManagerSpec: QuickSpec { threadVariant: .contact, identifier: "00000000-0000-0000-0000-000000000001", category: .incomingMessage, + groupingIdentifier: .threadId("05\(TestConstants.publicKey)"), title: "0588...c65b", body: "emojiReactsNotification" .put(key: "emoji", value: "A") From 2a3f88ba416f36e4611fb8c1a7bc392fcfe149f1 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Thu, 18 Sep 2025 13:52:06 +0800 Subject: [PATCH 027/162] Updated input disabled field and use better string for communities --- Session/Conversations/ConversationVC.swift | 4 ++-- .../Shared Models/SessionThreadViewModel.swift | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index fd52a451dc..35a489efcc 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -826,7 +826,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa { if updatedThreadData.threadCanWrite == true { self.showInputAccessoryView() - } else { + } else if updatedThreadData.threadCanWrite == false && updatedThreadData.threadVariant != .community { self.hideInputAccessoryView() } @@ -1494,7 +1494,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // If we explicitly can't write to the thread then the input will be hidden but they keyboard // still reports that it takes up size, so just report 0 height in that case - if viewModel.threadData.threadCanWrite == false { + if viewModel.threadData.threadCanWrite == false && viewModel.threadData.threadVariant != .community { keyboardEndFrame = CGRect( x: UIScreen.main.bounds.minX, y: UIScreen.main.bounds.maxY, diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index a85fc4b1d2..d1bcf45a58 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -275,6 +275,13 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D ) } + if threadVariant == .community && threadCanWrite == false { + return MessageInputState( + allowedInputTypes: .none, + message: "permissionsWriteCommunity".localized() + ) + } + return MessageInputState( allowedInputTypes: (threadRequiresApproval == false && threadIsMessageRequest == false ? .all : From 280141914780a43fff0d8af19b787e38a5b235b0 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Thu, 18 Sep 2025 14:55:22 +0800 Subject: [PATCH 028/162] Fix delete emoji icon too large --- .../Views & Modals/ReactionListSheet.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Session/Conversations/Views & Modals/ReactionListSheet.swift b/Session/Conversations/Views & Modals/ReactionListSheet.swift index e723a66c1b..9e568bb8a2 100644 --- a/Session/Conversations/Views & Modals/ReactionListSheet.swift +++ b/Session/Conversations/Views & Modals/ReactionListSheet.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Lucide import DifferenceKit import SessionUIKit import SessionMessagingKit @@ -452,11 +453,12 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { authorId.truncated(threadVariant: self.messageViewModel.threadVariant) ), trailingAccessory: (!canRemoveEmoji ? nil : - .icon( - UIImage(named: "X")? - .withRenderingMode(.alwaysTemplate), - size: .medium - ) + .icon( + Lucide.image(icon: .x, size: IconSize.medium.size)? + .withRenderingMode(.alwaysTemplate), + size: .medium, + pinEdges: [.right] + ) ), styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge), isEnabled: (self.messageViewModel.currentUserSessionIds ?? []).contains(authorId) From 5c12f47a207604f90e0b01c84d473e104ad16f4e Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 19 Sep 2025 14:46:42 +1000 Subject: [PATCH 029/162] Fixed some merge issues --- Session.xcodeproj/project.pbxproj | 12 +++++------- Session/Shared/Views/SessionCell.swift | 1 + 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 828ad5bd1d..1c633e8b83 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -259,7 +259,6 @@ B8856D72256F1421001CE70E /* OWSWindowManager.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */; settings = {ATTRIBUTES = (Public, ); }; }; B8856DE6256F15F2001CE70E /* String+SSK.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB3F255A580C00E217F9 /* String+SSK.swift */; }; B8856E09256F1676001CE70E /* UIDevice+featureSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */; }; - B886B4A92398BA1500211ABE /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B886B4A82398BA1500211ABE /* QRCode.swift */; }; B88FA7F2260C3EB10049422F /* OpenGroupSuggestionGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88FA7F1260C3EB10049422F /* OpenGroupSuggestionGrid.swift */; }; B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */; }; B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B894D0742339EDCF00B4D94D /* NukeDataModal.swift */; }; @@ -1053,11 +1052,12 @@ FDE521A62E0E6C8C00061B8E /* MockNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */; }; FDE6E99829F8E63A00F93C5D /* Accessibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE6E99729F8E63A00F93C5D /* Accessibility.swift */; }; FDE71B032E77CCEE0023F5F9 /* HTTPHeader+FileServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B022E77CCE90023F5F9 /* HTTPHeader+FileServer.swift */; }; + FDE71B052E77E1AA0023F5F9 /* ObservableKey+SessionNetworkingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B042E77E1A00023F5F9 /* ObservableKey+SessionNetworkingKit.swift */; }; FDE71B0B2E79352D0023F5F9 /* DeveloperSettingsGroupsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B0A2E7935250023F5F9 /* DeveloperSettingsGroupsViewModel.swift */; }; FDE71B0D2E793B250023F5F9 /* DeveloperSettingsProViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B0C2E793B1F0023F5F9 /* DeveloperSettingsProViewModel.swift */; }; FDE71B0F2E7A195B0023F5F9 /* Session - Anonymous Messenger.storekit in Resources */ = {isa = PBXBuildFile; fileRef = FDE71B0E2E7A195B0023F5F9 /* Session - Anonymous Messenger.storekit */; }; FDE71B5F2E7A73570023F5F9 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FDE71B5E2E7A73560023F5F9 /* StoreKit.framework */; }; - FDE71B052E77E1AA0023F5F9 /* ObservableKey+SessionNetworkingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE71B042E77E1A00023F5F9 /* ObservableKey+SessionNetworkingKit.swift */; }; + FDE71B602E7D17750023F5F9 /* _045_LastProfileUpdateTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */; }; FDE7549B2C940108002A2623 /* MessageViewModel+DeletionActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE7549A2C940108002A2623 /* MessageViewModel+DeletionActions.swift */; }; FDE7549D2C9961A4002A2623 /* CommunityPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE7549C2C9961A4002A2623 /* CommunityPoller.swift */; }; FDE754A12C9A60A6002A2623 /* Crypto+OpenGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754A02C9A60A6002A2623 /* Crypto+OpenGroup.swift */; }; @@ -1542,7 +1542,6 @@ 9409433F2C7ED62300D9D2E0 /* StartupError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupError.swift; sourceTree = ""; }; 941375BA2D5184B60058F244 /* HTTPHeader+SessionNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPHeader+SessionNetwork.swift"; sourceTree = ""; }; 941375BC2D5195F30058F244 /* KeyValueStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyValueStore.swift; sourceTree = ""; }; - 941375BE2D5196D10058F244 /* Number+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Number+Utilities.swift"; sourceTree = ""; }; 9420CAC42E584B5800F738F6 /* GroupAdminCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = GroupAdminCTA.webp; sourceTree = ""; }; 9420CAC52E584B5800F738F6 /* GroupNonAdminCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = GroupNonAdminCTA.webp; sourceTree = ""; }; 9422567D2C23F8BB00C0FDBF /* StartConversationScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StartConversationScreen.swift; sourceTree = ""; }; @@ -1566,10 +1565,10 @@ 9422EE2A2B8C3A97004C740D /* String+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Utilities.swift"; sourceTree = ""; }; 942ADDD32D9F960C006E0BB0 /* NewTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTagView.swift; sourceTree = ""; }; 942BA9402E4487EE007C4595 /* LightBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightBox.swift; sourceTree = ""; }; + 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _045_LastProfileUpdateTimestamp.swift; sourceTree = ""; }; 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionLabelWithProBadge.swift; sourceTree = ""; }; 942BA9C32E55AB51007C4595 /* UILabel+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Utilities.swift"; sourceTree = ""; }; 94363E542E5D83F60004EE43 /* TappableLabel+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TappableLabel+SwiftUI.swift"; sourceTree = ""; }; - 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _045_LastProfileUpdateTimestamp.swift; sourceTree = ""; }; 94367C422C6C828500814252 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+DisappearingMessages.swift"; sourceTree = ""; }; 943C6D832B86B5F1004ACE64 /* Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localization.swift; sourceTree = ""; }; @@ -1651,7 +1650,6 @@ B879D448247E1BE300DB3608 /* PathVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathVC.swift; sourceTree = ""; }; B879D44A247E1D9200DB3608 /* PathStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathStatusView.swift; sourceTree = ""; }; B885D5F52334A32100EE0D8E /* UIView+Constraints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Constraints.swift"; sourceTree = ""; }; - B886B4A82398BA1500211ABE /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = ""; }; B88FA7B726045D100049422F /* SOGSAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SOGSAPI.swift; sourceTree = ""; }; B88FA7F1260C3EB10049422F /* OpenGroupSuggestionGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupSuggestionGrid.swift; sourceTree = ""; }; B893063E2383961A005EAA8E /* ScanQRCodeWrapperVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanQRCodeWrapperVC.swift; sourceTree = ""; }; @@ -2335,11 +2333,11 @@ FDE521A12E0D23A200061B8E /* ObservableKey+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ObservableKey+SessionMessagingKit.swift"; sourceTree = ""; }; FDE6E99729F8E63A00F93C5D /* Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Accessibility.swift; sourceTree = ""; }; FDE71B022E77CCE90023F5F9 /* HTTPHeader+FileServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPHeader+FileServer.swift"; sourceTree = ""; }; + FDE71B042E77E1A00023F5F9 /* ObservableKey+SessionNetworkingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ObservableKey+SessionNetworkingKit.swift"; sourceTree = ""; }; FDE71B0A2E7935250023F5F9 /* DeveloperSettingsGroupsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsGroupsViewModel.swift; sourceTree = ""; }; FDE71B0C2E793B1F0023F5F9 /* DeveloperSettingsProViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsProViewModel.swift; sourceTree = ""; }; FDE71B0E2E7A195B0023F5F9 /* Session - Anonymous Messenger.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Session - Anonymous Messenger.storekit"; sourceTree = ""; }; FDE71B5E2E7A73560023F5F9 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; - FDE71B042E77E1A00023F5F9 /* ObservableKey+SessionNetworkingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ObservableKey+SessionNetworkingKit.swift"; sourceTree = ""; }; FDE7214F287E50D50093DF33 /* ProtoWrappers.py */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.python; path = ProtoWrappers.py; sourceTree = ""; }; FDE72150287E50D50093DF33 /* LintLocalizableStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LintLocalizableStrings.swift; sourceTree = ""; }; FDE7549A2C940108002A2623 /* MessageViewModel+DeletionActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageViewModel+DeletionActions.swift"; sourceTree = ""; }; @@ -6767,6 +6765,7 @@ B8DE1FB626C22FCB0079C9CE /* CallMessage.swift in Sources */, FDD23AEC2E458F980057E853 /* _024_ResetUserConfigLastHashes.swift in Sources */, FD245C50285065C700B966DD /* VisibleMessage+Quote.swift in Sources */, + FDE71B602E7D17750023F5F9 /* _045_LastProfileUpdateTimestamp.swift in Sources */, FDD23AE82E458DD40057E853 /* _002_SUK_SetupStandardJobs.swift in Sources */, FD72BDA12BE368C800CF6CF6 /* UIWindowLevel+Utilities.swift in Sources */, FD4C4E9C2B02E2A300C72199 /* DisplayPictureError.swift in Sources */, @@ -6876,7 +6875,6 @@ FD09797027FA6FF300936362 /* Profile.swift in Sources */, FD245C56285065EA00B966DD /* SNProto.swift in Sources */, FDE7549D2C9961A4002A2623 /* CommunityPoller.swift in Sources */, - 942BA9BF2E4ABBA1007C4595 /* _045_LastProfileUpdateTimestamp.swift in Sources */, FDE754F12C9BB08B002A2623 /* Crypto+LibSession.swift in Sources */, FD09798B27FD1CFE00936362 /* Capability.swift in Sources */, C3BBE0C72554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift in Sources */, diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index 32142cb6d9..61f88f1025 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -530,6 +530,7 @@ public class SessionCell: UITableViewCell { titleLabel.accessibilityIdentifier = info.title?.accessibility?.identifier titleLabel.accessibilityLabel = info.title?.accessibility?.label titleLabel.isHidden = (info.title == nil) + titleLabel.attachTrailing(info.title?.textTailing) subtitleLabel.isUserInteractionEnabled = (info.subtitle?.interaction == .copy) subtitleLabel.font = info.subtitle?.font subtitleLabel.themeTextColor = info.styling.subtitleTintColor From a64fbc45c73a2d9e65de3a792e77399ef5422da2 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 19 Sep 2025 15:37:37 +1000 Subject: [PATCH 030/162] Fixed an issue with the dev setting not updating the UI --- .../Settings/ThreadSettingsViewModel.swift | 6 ++-- .../DeveloperSettingsProViewModel.swift | 33 ++++++++++--------- Session/Settings/SettingsViewModel.swift | 26 +++++++++++---- .../Shared/SessionTableViewController.swift | 4 +-- .../Shared/Types/SessionCell+Styling.swift | 10 +++--- Session/Shared/Views/SessionCell.swift | 2 +- SessionUIKit/Components/SessionProBadge.swift | 2 +- .../Components/SwiftUI/ProCTAModal.swift | 6 ++-- SessionUIKit/Utilities/UIView+Utilities.swift | 2 +- 9 files changed, 54 insertions(+), 37 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index d2bfdb647c..9231a43a6b 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -302,10 +302,10 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi threadViewModel.displayName, font: .titleLarge, alignment: .center, - textTailing: ( + trailingImage: ( (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }) ? - SessionProBadge(size: .medium).toImage() : - nil + ("ProBadge", SessionProBadge(size: .medium).toImage()) : + nil ) ), styling: SessionCell.StyleInfo( diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index 0eb37764ab..3301dd94af 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -75,7 +75,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case restoreProSubscription case proStatus - case proIncomingMessages + case allUsersSessionPro // MARK: - Conformance @@ -90,7 +90,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .restoreProSubscription: return "restoreProSubscription" case .proStatus: return "proStatus" - case .proIncomingMessages: return "proIncomingMessages" + case .allUsersSessionPro: return "allUsersSessionPro" } } @@ -108,7 +108,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .restoreProSubscription: result.append(.restoreProSubscription); fallthrough case .proStatus: result.append(.proStatus); fallthrough - case .proIncomingMessages: result.append(.proIncomingMessages) + case .allUsersSessionPro: result.append(.allUsersSessionPro) } return result @@ -131,7 +131,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let purchaseTransactionId: String? let mockCurrentUserSessionPro: Bool - let treatAllIncomingMessagesAsProMessages: Bool + let allUsersSessionPro: Bool @MainActor public func sections(viewModel: DeveloperSettingsProViewModel, previousState: State) -> [SectionModel] { DeveloperSettingsProViewModel.sections( @@ -145,7 +145,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold .feature(.sessionProEnabled), .updateScreen(DeveloperSettingsProViewModel.self), .feature(.mockCurrentUserSessionPro), - .feature(.treatAllIncomingMessagesAsProMessages) + .feature(.allUsersSessionPro) ] static func initialState(using dependencies: Dependencies) -> State { @@ -159,7 +159,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold purchaseTransactionId: nil, mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionPro], - treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages] + allUsersSessionPro: dependencies[feature: .allUsersSessionPro] ) } } @@ -199,7 +199,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold purchaseStatus: purchaseStatus, purchaseTransactionId: purchaseTransactionId, mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionPro], - treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages] + allUsersSessionPro: dependencies[feature: .allUsersSessionPro] ) } @@ -312,19 +312,20 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } ), SessionCell.Info( - id: .proIncomingMessages, - title: "All Pro Incoming Messages", + id: .allUsersSessionPro, + title: "Everyone is a Pro", subtitle: """ Treat all incoming messages as Pro messages. + Treat all contacts, groups as Session Pro. """, trailingAccessory: .toggle( - state.treatAllIncomingMessagesAsProMessages, - oldValue: previousState.treatAllIncomingMessagesAsProMessages + state.allUsersSessionPro, + oldValue: previousState.allUsersSessionPro ), onTap: { [dependencies = viewModel.dependencies] in dependencies.set( - feature: .treatAllIncomingMessagesAsProMessages, - to: !state.treatAllIncomingMessagesAsProMessages + feature: .allUsersSessionPro, + to: !state.allUsersSessionPro ) } ) @@ -340,7 +341,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let features: [FeatureConfig] = [ .sessionProEnabled, .mockCurrentUserSessionPro, - .treatAllIncomingMessagesAsProMessages + .allUsersSessionPro ] features.forEach { feature in @@ -357,8 +358,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold dependencies.set(feature: .mockCurrentUserSessionPro, to: nil) } - if dependencies.hasSet(feature: .treatAllIncomingMessagesAsProMessages) { - dependencies.set(feature: .treatAllIncomingMessagesAsProMessages, to: nil) + if dependencies.hasSet(feature: .allUsersSessionPro) { + dependencies.set(feature: .allUsersSessionPro, to: nil) } } diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index ac81fe4414..7720a7d5dd 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -33,7 +33,10 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl @MainActor init(using dependencies: Dependencies) { self.dependencies = dependencies - self.internalState = State.initialState(userSessionId: dependencies[cache: .general].sessionId) + self.internalState = State.initialState( + userSessionId: dependencies[cache: .general].sessionId, + isSessionPro: dependencies[cache: .libSession].isSessionPro + ) bindState() } @@ -151,6 +154,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl public struct State: ObservableKeyProvider { let userSessionId: SessionId let profile: Profile + let isSessionPro: Bool let serviceNetwork: ServiceNetwork let forceOffline: Bool let developerModeEnabled: Bool @@ -165,15 +169,18 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl .profile(userSessionId.hexString), .feature(.serviceNetwork), .feature(.forceOffline), + .feature(.mockCurrentUserSessionPro), .setting(.developerModeEnabled), .setting(.hideRecoveryPasswordPermanently) + // TODO: [PRO] Need to observe changes to the users pro status ] } - static func initialState(userSessionId: SessionId) -> State { + static func initialState(userSessionId: SessionId, isSessionPro: Bool) -> State { return State( userSessionId: userSessionId, profile: Profile.defaultFor(userSessionId.hexString), + isSessionPro: isSessionPro, serviceNetwork: .mainnet, forceOffline: false, developerModeEnabled: false, @@ -207,6 +214,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ) async -> State { /// Store mutable copies of the data to update var profile: Profile = previousState.profile + var isSessionPro: Bool = previousState.isSessionPro var serviceNetwork: ServiceNetwork = previousState.serviceNetwork var forceOffline: Bool = previousState.forceOffline var developerModeEnabled: Bool = previousState.developerModeEnabled @@ -256,12 +264,18 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl forceOffline = updatedValue } + else if event.key == .feature(.mockCurrentUserSessionPro) { + guard let updatedValue: Bool = event.value as? Bool else { return } + + isSessionPro = updatedValue + } } /// Generate the new state return State( userSessionId: previousState.userSessionId, profile: profile, + isSessionPro: isSessionPro, serviceNetwork: serviceNetwork, forceOffline: forceOffline, developerModeEnabled: developerModeEnabled, @@ -306,11 +320,9 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl state.profile.displayName(), font: .titleLarge, alignment: .center, - interaction: .editable, - textTailing: ( - viewModel.dependencies[cache: .libSession].isSessionPro ? - SessionProBadge(size: .medium).toImage() : - nil + trailingImage: (state.isSessionPro ? + ("ProBadge", SessionProBadge(size: .medium).toImage()) : + nil ) ), styling: SessionCell.StyleInfo( diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index 6abde9686e..c307c31470 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -565,7 +565,7 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa guard info.isEnabled else { return } // Get the view that was tapped (for presenting on iPad) - let tappedView: UIView? = { + let tappedView: UIView? = { () -> UIView? in guard let cell: SessionCell = tableView.cellForRow(at: indexPath) as? SessionCell else { return nil } @@ -575,7 +575,7 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa cell.lastTouchLocation = nil if - info.title?.textTailing != nil, + info.title?.trailingImage != nil, let localPoint: CGPoint = touchLocation?.location(in: cell.titleLabel), cell.titleLabel.bounds.contains(localPoint), cell.titleLabel.isPointOnTrailingAttachment(localPoint) == true diff --git a/Session/Shared/Types/SessionCell+Styling.swift b/Session/Shared/Types/SessionCell+Styling.swift index 21f483c349..3a45d9dd53 100644 --- a/Session/Shared/Types/SessionCell+Styling.swift +++ b/Session/Shared/Types/SessionCell+Styling.swift @@ -18,7 +18,7 @@ public extension SessionCell { let editingPlaceholder: String? let interaction: Interaction let accessibility: Accessibility? - let textTailing: UIImage? + let trailingImage: (id: String, image: UIImage)? let extraViewGenerator: (() -> UIView)? private let fontStyle: FontStyle @@ -31,7 +31,7 @@ public extension SessionCell { editingPlaceholder: String? = nil, interaction: Interaction = .none, accessibility: Accessibility? = nil, - textTailing: UIImage? = nil, + trailingImage: (id: String, image: UIImage)? = nil, extraViewGenerator: (() -> UIView)? = nil ) { self.text = text @@ -40,7 +40,7 @@ public extension SessionCell { self.editingPlaceholder = editingPlaceholder self.interaction = interaction self.accessibility = accessibility - self.textTailing = textTailing + self.trailingImage = trailingImage self.extraViewGenerator = extraViewGenerator } @@ -53,6 +53,7 @@ public extension SessionCell { interaction.hash(into: &hasher) editingPlaceholder.hash(into: &hasher) accessibility.hash(into: &hasher) + trailingImage?.id.hash(into: &hasher) } public static func == (lhs: TextInfo, rhs: TextInfo) -> Bool { @@ -62,7 +63,8 @@ public extension SessionCell { lhs.textAlignment == rhs.textAlignment && lhs.interaction == rhs.interaction && lhs.editingPlaceholder == rhs.editingPlaceholder && - lhs.accessibility == rhs.accessibility + lhs.accessibility == rhs.accessibility && + lhs.trailingImage?.id == rhs.trailingImage?.id ) } } diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index 61f88f1025..906bd623ee 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -530,7 +530,7 @@ public class SessionCell: UITableViewCell { titleLabel.accessibilityIdentifier = info.title?.accessibility?.identifier titleLabel.accessibilityLabel = info.title?.accessibility?.label titleLabel.isHidden = (info.title == nil) - titleLabel.attachTrailing(info.title?.textTailing) + titleLabel.attachTrailing(info.title?.trailingImage?.image) subtitleLabel.isUserInteractionEnabled = (info.subtitle?.interaction == .copy) subtitleLabel.font = info.subtitle?.font subtitleLabel.themeTextColor = info.styling.subtitleTintColor diff --git a/SessionUIKit/Components/SessionProBadge.swift b/SessionUIKit/Components/SessionProBadge.swift index 6eb2237b7e..328ce186e5 100644 --- a/SessionUIKit/Components/SessionProBadge.swift +++ b/SessionUIKit/Components/SessionProBadge.swift @@ -101,7 +101,7 @@ public class SessionProBadge: UIView { heightConstraint = self.set(.height, to: self.size.height) } - public func toImage() -> UIImage? { + public func toImage() -> UIImage { self.proImageView.frame = CGRect( x: (size.width - size.proFontWidth) / 2, y: (size.height - size.proFontHeight) / 2, diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index 7f90c201af..2467fcf442 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -269,9 +269,11 @@ public struct ProCTAModal: View { } if - case .groupLimit(_, let isSessionProActivated) = variant, isSessionProActivated, - let proBadgeImage: UIImage = SessionProBadge(size: .small).toImage() + case .groupLimit(_, let isSessionProActivated) = variant, + isSessionProActivated { + let proBadgeImage: UIImage = SessionProBadge(size: .small).toImage() + (Text(variant.subtitle) + Text(" \(Image(uiImage: proBadgeImage))").baselineOffset(-2)) .font(.Body.largeRegular) .foregroundColor(themeColor: .textSecondary) diff --git a/SessionUIKit/Utilities/UIView+Utilities.swift b/SessionUIKit/Utilities/UIView+Utilities.swift index b5518ad1f0..28a13ba074 100644 --- a/SessionUIKit/Utilities/UIView+Utilities.swift +++ b/SessionUIKit/Utilities/UIView+Utilities.swift @@ -3,7 +3,7 @@ import UIKit public extension UIView { - func toImage(isOpaque: Bool, scale: CGFloat) -> UIImage? { + func toImage(isOpaque: Bool, scale: CGFloat) -> UIImage { let format = UIGraphicsImageRendererFormat() format.scale = scale format.opaque = isOpaque From bafe40e294138f7474af1c006ed5702f38c0dad0 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Fri, 19 Sep 2025 13:52:49 +0800 Subject: [PATCH 031/162] Show empty video inset as soon as any video starts --- Session/Calls/CallVC.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index 85b9f8c668..8ae8308449 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -380,6 +380,15 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel UIView.animate(withDuration: 0.25) { let remoteVideoView: RemoteVideoView = self.floatingViewVideoSource == .remote ? self.floatingRemoteVideoView : self.fullScreenRemoteVideoView remoteVideoView.alpha = isEnabled ? 1 : 0 + + // Retain floating view visibility if either of the feeds are enabled + var hideFloatingContainer: Bool { + !(isEnabled || self.call.isVideoEnabled) + } + + // Shows floating camera to allow user to switch to fullscreen or floating + // even if the other party has not yet turned on their video feed. + self.floatingViewContainer.isHidden = hideFloatingContainer } if self.callInfoLabelStackView.alpha < 0.5 { @@ -722,7 +731,13 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel @objc private func operateCamera() { if (call.isVideoEnabled) { - floatingViewContainer.isHidden = true + // Hides local video feed + (floatingViewVideoSource == .local + ? floatingLocalVideoView + : fullScreenLocalVideoView).alpha = 0 + + floatingViewContainer.isHidden = !call.isRemoteVideoEnabled + cameraManager.stop() videoButton.themeTintColor = .textPrimary videoButton.themeBackgroundColor = .backgroundSecondary From 2d8c5f965c9a92c39cac221ea337133dba09f39e Mon Sep 17 00:00:00 2001 From: mikoldin Date: Mon, 22 Sep 2025 09:34:15 +0800 Subject: [PATCH 032/162] Updated variable flag definition to constant --- Session/Calls/CallVC.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index 8ae8308449..cde9fa28e6 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -381,14 +381,12 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel let remoteVideoView: RemoteVideoView = self.floatingViewVideoSource == .remote ? self.floatingRemoteVideoView : self.fullScreenRemoteVideoView remoteVideoView.alpha = isEnabled ? 1 : 0 - // Retain floating view visibility if either of the feeds are enabled - var hideFloatingContainer: Bool { - !(isEnabled || self.call.isVideoEnabled) - } + // Retain floating view visibility if any of the video feeds are enabled + let isAnyVideoFeedEnabled: Bool = (isEnabled || self.call.isVideoEnabled) // Shows floating camera to allow user to switch to fullscreen or floating // even if the other party has not yet turned on their video feed. - self.floatingViewContainer.isHidden = hideFloatingContainer + self.floatingViewContainer.isHidden = !isAnyVideoFeedEnabled } if self.callInfoLabelStackView.alpha < 0.5 { From 03f1862da24a65a3b24397bdaf707cd21f1be325 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Tue, 23 Sep 2025 16:30:22 +0800 Subject: [PATCH 033/162] Add handling of enter key from external keyboard --- .../Conversations/ConversationVC+Interaction.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index ad0541a19d..142fdb86c0 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -112,6 +112,20 @@ extension ConversationVC: navigationController?.pushViewController(viewController, animated: true) } + // MARK: - External keyboard + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + for press in presses { + guard let key = press.key else { continue } + + if key.keyCode == .keyboardReturnOrEnter && key.modifierFlags.isEmpty { + // Enter only -> send + handleSendButtonTapped() + return + } + } + super.pressesBegan(presses, with: event) + } + // MARK: - Call @objc func startCall(_ sender: Any?) { From b11d8bc394a35bda604174fe70a7eb500e3b0cd1 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 24 Sep 2025 15:29:28 +1000 Subject: [PATCH 034/162] Started working on new reupload logic for user display pics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Started working on the updated logic for the ReuploadUserDisplayPictureJob • Pulled across a few changes from the refactored networking • Pulled across the profile_updated changes from the animated profile pictures PR --- Session.xcodeproj/project.pbxproj | 34 ++- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../ConversationVC+Interaction.swift | 2 +- Session/Onboarding/Onboarding.swift | 4 +- .../DeveloperSettingsViewModel.swift | 27 ++- Session/Settings/SettingsViewModel.swift | 5 +- SessionMessagingKit/Configuration.swift | 7 +- .../_007_SMK_SetupStandardJobs.swift | 2 +- .../Migrations/_012_AddJobPriority.swift | 2 +- .../_028_GenerateInitialUserConfigDumps.swift | 3 +- .../_045_LastProfileUpdateTimestamp.swift | 21 ++ .../Database/Models/Profile.swift | 54 ++--- .../Jobs/DisplayPictureDownloadJob.swift | 34 +-- .../Jobs/ReuploadUserDisplayPictureJob.swift | 203 ++++++++++++++++++ .../Jobs/UpdateProfilePictureJob.swift | 79 ------- .../Config Handling/LibSession+Contacts.swift | 61 ++---- .../LibSession+GroupMembers.swift | 12 +- .../Config Handling/LibSession+Shared.swift | 10 +- .../LibSession+UserGroups.swift | 3 +- .../LibSession+UserProfile.swift | 21 +- .../LibSession+SessionMessagingKit.swift | 30 +-- .../VisibleMessage+Profile.swift | 17 +- .../Protos/Generated/SNProto.swift | 14 ++ .../Protos/Generated/SessionProtos.pb.swift | 17 ++ .../Protos/SessionProtos.proto | 1 + .../AttachmentUploader.swift | 4 +- .../MessageReceiver+Groups.swift | 8 +- .../MessageReceiver+MessageRequests.swift | 4 +- .../MessageReceiver+VisibleMessages.swift | 2 +- .../MessageSender+Groups.swift | 2 +- .../Sending & Receiving/MessageSender.swift | 7 +- .../Utilities/DisplayPictureManager.swift | 4 +- ...rrentUser.swift => Profile+Updating.swift} | 98 ++++++--- .../Jobs/DisplayPictureDownloadJobSpec.swift | 23 +- .../LibSession/LibSessionUtilSpec.swift | 20 +- .../Open Groups/OpenGroupManagerSpec.swift | 1 + .../MessageReceiverGroupsSpec.swift | 8 +- .../MessageSenderGroupsSpec.swift | 4 +- .../Pollers/CommunityPollerSpec.swift | 2 +- .../_TestUtilities/MockLibSessionCache.swift | 4 +- .../FileServer/FileServer.swift | 2 +- .../FileServer/FileServerAPI.swift | 53 ++++- .../FileServer/FileServerEndpoint.swift | 6 + .../Types/HTTPHeader+FileServer.swift | 9 + .../LibSession/LibSession+Networking.swift | 75 +++---- .../Models/FileUploadResponse.swift | 16 +- SessionNetworkingKit/SOGS/SOGSAPI.swift | 87 +++++--- .../SessionNetwork/SessionNetworkAPI.swift | 22 +- SessionNetworkingKit/Types/Destination.swift | 117 ++++------ SessionNetworkingKit/Types/Network.swift | 7 +- .../Types/PreparedRequest+Sending.swift | 8 +- .../Types/PreparedRequest.swift | 25 ++- SessionNetworkingKit/Types/Request.swift | 2 +- SessionTests/Database/DatabaseSpec.swift | 3 +- SessionTests/Onboarding/OnboardingSpec.swift | 80 +++++-- SessionUtilitiesKit/Database/Models/Job.swift | 5 +- SessionUtilitiesKit/General/Feature.swift | 4 + .../Types/UserDefaultsType.swift | 4 +- 58 files changed, 887 insertions(+), 496 deletions(-) create mode 100644 SessionMessagingKit/Database/Migrations/_045_LastProfileUpdateTimestamp.swift create mode 100644 SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift delete mode 100644 SessionMessagingKit/Jobs/UpdateProfilePictureJob.swift rename SessionMessagingKit/Utilities/{Profile+CurrentUser.swift => Profile+Updating.swift} (71%) create mode 100644 SessionNetworkingKit/FileServer/Types/HTTPHeader+FileServer.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 705fb6d0b5..19e494cb89 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -498,7 +498,7 @@ FD22727C2C32911C004D8A6C /* GroupPromoteMemberJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272642C32911B004D8A6C /* GroupPromoteMemberJob.swift */; }; FD22727E2C32911C004D8A6C /* GarbageCollectionJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272662C32911B004D8A6C /* GarbageCollectionJob.swift */; }; FD22727F2C32911C004D8A6C /* GetExpirationJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272672C32911B004D8A6C /* GetExpirationJob.swift */; }; - FD2272812C32911C004D8A6C /* UpdateProfilePictureJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD22726A2C32911C004D8A6C /* UpdateProfilePictureJob.swift */; }; + FD2272812C32911C004D8A6C /* ReuploadUserDisplayPictureJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD22726A2C32911C004D8A6C /* ReuploadUserDisplayPictureJob.swift */; }; FD2272832C337830004D8A6C /* GroupPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272822C337830004D8A6C /* GroupPoller.swift */; }; FD2272A92C33E337004D8A6C /* ContentProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272952C33E335004D8A6C /* ContentProxy.swift */; }; FD2272AA2C33E337004D8A6C /* UpdatableTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272962C33E335004D8A6C /* UpdatableTimestamp.swift */; }; @@ -642,7 +642,7 @@ FD3E0C84283B5835002A425C /* SessionThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */; }; FD3F2EE72DE6CC4100FD6849 /* NotificationsManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3F2EE62DE6CC3B00FD6849 /* NotificationsManagerSpec.swift */; }; FD3F2EF22DF273D900FD6849 /* ThemedAttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3F2EF12DF273D100FD6849 /* ThemedAttributedString.swift */; }; - FD3FAB592ADF906300DC5421 /* Profile+CurrentUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3FAB582ADF906300DC5421 /* Profile+CurrentUser.swift */; }; + FD3FAB592ADF906300DC5421 /* Profile+Updating.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3FAB582ADF906300DC5421 /* Profile+Updating.swift */; }; FD3FAB5F2AE9BC2200DC5421 /* EditGroupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3FAB5E2AE9BC2200DC5421 /* EditGroupViewModel.swift */; }; FD3FAB612AEA194E00DC5421 /* UserListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3FAB602AEA194E00DC5421 /* UserListViewModel.swift */; }; FD3FAB632AEB9A1500DC5421 /* ToastController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3FAB622AEB9A1500DC5421 /* ToastController.swift */; }; @@ -1098,6 +1098,8 @@ FDE755242C9BC1D1002A2623 /* Publisher+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE755232C9BC1D1002A2623 /* Publisher+Utilities.swift */; }; FDEF573E2C40F2A100131302 /* GroupUpdateMemberLeftNotificationMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDEF573D2C40F2A100131302 /* GroupUpdateMemberLeftNotificationMessage.swift */; }; FDEF57712C44D2D300131302 /* GeoLite2-Country-Blocks-IPv4 in Resources */ = {isa = PBXBuildFile; fileRef = FDEF57702C44D2D300131302 /* GeoLite2-Country-Blocks-IPv4 */; }; + FDEFDC6C2E8361E000EBCD81 /* HTTPHeader+FileServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDEFDC6B2E8361DB00EBCD81 /* HTTPHeader+FileServer.swift */; }; + FDEFDC6E2E83A74300EBCD81 /* _045_LastProfileUpdateTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDEFDC6D2E83A74200EBCD81 /* _045_LastProfileUpdateTimestamp.swift */; }; FDF01FAD2A9ECC4200CAF969 /* SingletonConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF01FAC2A9ECC4200CAF969 /* SingletonConfig.swift */; }; FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; }; FDF0B7422804EA4F004C14C5 /* _007_SMK_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7412804EA4F004C14C5 /* _007_SMK_SetupStandardJobs.swift */; }; @@ -1887,7 +1889,7 @@ FD2272642C32911B004D8A6C /* GroupPromoteMemberJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPromoteMemberJob.swift; sourceTree = ""; }; FD2272662C32911B004D8A6C /* GarbageCollectionJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GarbageCollectionJob.swift; sourceTree = ""; }; FD2272672C32911B004D8A6C /* GetExpirationJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GetExpirationJob.swift; sourceTree = ""; }; - FD22726A2C32911C004D8A6C /* UpdateProfilePictureJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdateProfilePictureJob.swift; sourceTree = ""; }; + FD22726A2C32911C004D8A6C /* ReuploadUserDisplayPictureJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReuploadUserDisplayPictureJob.swift; sourceTree = ""; }; FD2272822C337830004D8A6C /* GroupPoller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPoller.swift; sourceTree = ""; }; FD2272952C33E335004D8A6C /* ContentProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentProxy.swift; sourceTree = ""; }; FD2272962C33E335004D8A6C /* UpdatableTimestamp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdatableTimestamp.swift; sourceTree = ""; }; @@ -1992,7 +1994,7 @@ FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModel.swift; sourceTree = ""; }; FD3F2EE62DE6CC3B00FD6849 /* NotificationsManagerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManagerSpec.swift; sourceTree = ""; }; FD3F2EF12DF273D100FD6849 /* ThemedAttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemedAttributedString.swift; sourceTree = ""; }; - FD3FAB582ADF906300DC5421 /* Profile+CurrentUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Profile+CurrentUser.swift"; sourceTree = ""; }; + FD3FAB582ADF906300DC5421 /* Profile+Updating.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Profile+Updating.swift"; sourceTree = ""; }; FD3FAB5E2AE9BC2200DC5421 /* EditGroupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditGroupViewModel.swift; sourceTree = ""; }; FD3FAB602AEA194E00DC5421 /* UserListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListViewModel.swift; sourceTree = ""; }; FD3FAB622AEB9A1500DC5421 /* ToastController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastController.swift; sourceTree = ""; }; @@ -2382,6 +2384,8 @@ FDEF576D2C44C1DF00131302 /* GeoLite2-Country-Locations-ru.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = "GeoLite2-Country-Locations-ru.csv"; sourceTree = ""; }; FDEF576E2C44C1DF00131302 /* GeoLite2-Country-Locations-zh-CN.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = "GeoLite2-Country-Locations-zh-CN.csv"; sourceTree = ""; }; FDEF57702C44D2D300131302 /* GeoLite2-Country-Blocks-IPv4 */ = {isa = PBXFileReference; lastKnownFileType = file; name = "GeoLite2-Country-Blocks-IPv4"; path = "Countries/GeoLite2-Country-Blocks-IPv4"; sourceTree = ""; }; + FDEFDC6B2E8361DB00EBCD81 /* HTTPHeader+FileServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPHeader+FileServer.swift"; sourceTree = ""; }; + FDEFDC6D2E83A74200EBCD81 /* _045_LastProfileUpdateTimestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _045_LastProfileUpdateTimestamp.swift; sourceTree = ""; }; FDF01FAC2A9ECC4200CAF969 /* SingletonConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingletonConfig.swift; sourceTree = ""; }; FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreview.swift; sourceTree = ""; }; FDF0B73F280402C4004C14C5 /* Job.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Job.swift; sourceTree = ""; }; @@ -3429,8 +3433,8 @@ FD2272632C32911B004D8A6C /* MessageSendJob.swift */, FD2272572C32911A004D8A6C /* ProcessPendingGroupMemberRemovalsJob.swift */, FD22725D2C32911B004D8A6C /* RetrieveDefaultOpenGroupRoomsJob.swift */, + FD22726A2C32911C004D8A6C /* ReuploadUserDisplayPictureJob.swift */, FD2272532C32911A004D8A6C /* SendReadReceiptsJob.swift */, - FD22726A2C32911C004D8A6C /* UpdateProfilePictureJob.swift */, ); path = Jobs; sourceTree = ""; @@ -3677,7 +3681,7 @@ FDAA167C2AC528A200DDBF77 /* Preferences+Sound.swift */, C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */, C38EF306255B6DBE007E1867 /* OWSWindowManager.m */, - FD3FAB582ADF906300DC5421 /* Profile+CurrentUser.swift */, + FD3FAB582ADF906300DC5421 /* Profile+Updating.swift */, FD16AB602A1DD9B60083D849 /* ProfilePictureView+Convenience.swift */, C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */, FDE754FF2C9BB0FA002A2623 /* SessionEnvironment.swift */, @@ -4083,6 +4087,7 @@ FD78E9F72DDD742100D55B50 /* _042_MoveSettingsToLibSession.swift */, FD05594D2E012D1A00DC48CE /* _043_RenameAttachments.swift */, 94CD95C02E0CBF1C0097754D /* _044_AddProMessageFlag.swift */, + FDEFDC6D2E83A74200EBCD81 /* _045_LastProfileUpdateTimestamp.swift */, ); path = Migrations; sourceTree = ""; @@ -4372,6 +4377,7 @@ children = ( FD6B92C42E77AD01004463B5 /* Crypto */, FD6B92A42E77A37A004463B5 /* Models */, + FDEFDC6A2E8361D400EBCD81 /* Types */, FD6B928B2E779DC8004463B5 /* FileServer.swift */, FD6B928F2E779EDA004463B5 /* FileServerAPI.swift */, FD6B928D2E779E95004463B5 /* FileServerEndpoint.swift */, @@ -5155,6 +5161,14 @@ path = Countries/SourceData; sourceTree = ""; }; + FDEFDC6A2E8361D400EBCD81 /* Types */ = { + isa = PBXGroup; + children = ( + FDEFDC6B2E8361DB00EBCD81 /* HTTPHeader+FileServer.swift */, + ); + path = Types; + sourceTree = ""; + }; FDF01FAE2A9ED0C800CAF969 /* Dependency Injection */ = { isa = PBXGroup; children = ( @@ -6371,6 +6385,7 @@ FDF848C229405C5A007DCAE5 /* OxenDaemonRPCRequest.swift in Sources */, FDF848DC29405C5B007DCAE5 /* RevokeSubaccountRequest.swift in Sources */, FDF848D029405C5B007DCAE5 /* UpdateExpiryResponse.swift in Sources */, + FDEFDC6C2E8361E000EBCD81 /* HTTPHeader+FileServer.swift in Sources */, FD6B92E82E77C5B7004463B5 /* PushNotificationEndpoint.swift in Sources */, FD6B92AC2E77A993004463B5 /* SOGSEndpoint.swift in Sources */, FD6B92922E779FC8004463B5 /* SessionNetwork.swift in Sources */, @@ -6708,19 +6723,20 @@ B8DE1FB626C22FCB0079C9CE /* CallMessage.swift in Sources */, FDD23AEC2E458F980057E853 /* _024_ResetUserConfigLastHashes.swift in Sources */, FD245C50285065C700B966DD /* VisibleMessage+Quote.swift in Sources */, + FDEFDC6E2E83A74300EBCD81 /* _045_LastProfileUpdateTimestamp.swift in Sources */, FDD23AE82E458DD40057E853 /* _002_SUK_SetupStandardJobs.swift in Sources */, FD72BDA12BE368C800CF6CF6 /* UIWindowLevel+Utilities.swift in Sources */, FD4C4E9C2B02E2A300C72199 /* DisplayPictureError.swift in Sources */, FDD23AE22E457CE50057E853 /* _008_SNK_YDBToGRDBMigration.swift in Sources */, FD5C7307284F103B0029977D /* MessageReceiver+MessageRequests.swift in Sources */, C3A71D0B2558989C0043A11F /* MessageWrapper.swift in Sources */, - FD3FAB592ADF906300DC5421 /* Profile+CurrentUser.swift in Sources */, + FD3FAB592ADF906300DC5421 /* Profile+Updating.swift in Sources */, FDEF573E2C40F2A100131302 /* GroupUpdateMemberLeftNotificationMessage.swift in Sources */, FDD23ADF2E457CAA0057E853 /* _016_ThemePreferences.swift in Sources */, FDB11A5D2DD300D300BEF49F /* SNProtoContent+Utilities.swift in Sources */, FDE755002C9BB0FA002A2623 /* SessionEnvironment.swift in Sources */, FDB5DADC2A95D840002C8721 /* GroupUpdateMemberChangeMessage.swift in Sources */, - FD2272812C32911C004D8A6C /* UpdateProfilePictureJob.swift in Sources */, + FD2272812C32911C004D8A6C /* ReuploadUserDisplayPictureJob.swift in Sources */, FDB5DAE02A95D84D002C8721 /* GroupUpdateMemberLeftMessage.swift in Sources */, FD8FD7622C37B7BD001E38C7 /* Position.swift in Sources */, 7B93D07127CF194000811CB6 /* MessageRequestResponse.swift in Sources */, @@ -10422,7 +10438,7 @@ repositoryURL = "https://github.com/session-foundation/libsession-util-spm"; requirement = { kind = exactVersion; - version = 1.5.1; + version = 1.5.6; }; }; FD6A38E72C2A630E00762359 /* XCRemoteSwiftPackageReference "CocoaLumberjack" */ = { diff --git a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 40c1f21016..36a72ad9fd 100644 --- a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/session-foundation/libsession-util-spm", "state" : { - "revision" : "ba7d5f08e4eb71a2efe744df2ad677d8c180c6bb", - "version" : "1.5.1" + "revision" : "a092eb8fa4bbc93756530e08b6c281d9eda06c61", + "version" : "1.5.6" } }, { diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index ad0541a19d..ddf3b0f18f 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -821,7 +821,7 @@ extension ConversationVC: fallback: .none, using: dependencies ), - sentTimestamp: (Double(optimisticData.interaction.timestampMs) / 1000), + profileUpdateTimestamp: currentUserProfile.profileLastUpdated, using: dependencies ) } diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 322bf5b1ee..5dda7496ea 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -409,13 +409,13 @@ extension Onboarding { .upsert(db) try Profile .filter(id: userSessionId.hexString) - .updateAll(db, Profile.Columns.lastNameUpdate.set(to: nil)) + .updateAll(db, Profile.Columns.profileLastUpdated.set(to: nil)) try Profile.updateIfNeeded( db, publicKey: userSessionId.hexString, displayNameUpdate: .currentUserUpdate(displayName), displayPictureUpdate: .none, - sentTimestamp: dependencies.dateNow.timeIntervalSince1970, + profileUpdateTimestamp: dependencies.dateNow.timeIntervalSince1970, using: dependencies ) diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift index b32ecb10ad..462ca0244e 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift @@ -74,6 +74,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case proConfig case groupConfig + case shortenFileTTL case animationsEnabled case showStringKeys case truncatePubkeysInLogs @@ -114,7 +115,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .proConfig: return "proConfig" case .groupConfig: return "groupConfig" - + + case .shortenFileTTL: return "shortenFileTTL" case .animationsEnabled: return "animationsEnabled" case .showStringKeys: return "showStringKeys" case .truncatePubkeysInLogs: return "truncatePubkeysInLogs" @@ -158,6 +160,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .proConfig: result.append(.proConfig); fallthrough case .groupConfig: result.append(.groupConfig); fallthrough + case .shortenFileTTL: result.append(.shortenFileTTL); fallthrough case .animationsEnabled: result.append(.animationsEnabled); fallthrough case .showStringKeys: result.append(.showStringKeys); fallthrough case .truncatePubkeysInLogs: result.append(.truncatePubkeysInLogs); fallthrough @@ -198,6 +201,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, let developerMode: Bool let versionBlindedID: String? + let shortenFileTTL: Bool let animationsEnabled: Bool let showStringKeys: Bool let truncatePubkeysInLogs: Bool @@ -241,6 +245,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, cache.get(.developerModeEnabled) }, versionBlindedID: versionBlindedID, + shortenFileTTL: dependencies[feature: .shortenFileTTL], animationsEnabled: dependencies[feature: .animationsEnabled], showStringKeys: dependencies[feature: .showStringKeys], truncatePubkeysInLogs: dependencies[feature: .truncatePubkeysInLogs], @@ -334,6 +339,21 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, let general: SectionModel = SectionModel( model: .general, elements: [ + SessionCell.Info( + id: .shortenFileTTL, + title: "Shorten File TTL", + subtitle: "Set the TTL for files in the cache to 1 minute", + trailingAccessory: .toggle( + current.shortenFileTTL, + oldValue: previous?.shortenFileTTL + ), + onTap: { [weak self] in + self?.updateFlag( + for: .shortenFileTTL, + to: !current.shortenFileTTL + ) + } + ), SessionCell.Info( id: .animationsEnabled, title: "Animations Enabled", @@ -767,6 +787,11 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, .importDatabase, .advancedLogging, .resetAppReviewPrompt: break /// These are actions rather than values stored as "features" so no need to do anything + case .shortenFileTTL: + guard dependencies.hasSet(feature: .shortenFileTTL) else { return } + + updateFlag(for: .shortenFileTTL, to: nil) + case .animationsEnabled: guard dependencies.hasSet(feature: .animationsEnabled) else { return } diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index f8fa6a6a42..1071b8c059 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -692,7 +692,10 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl guard let imageData: Data = source.imageData else { return } self?.updateProfile( - displayPictureUpdate: .currentUserUploadImageData(imageData), + displayPictureUpdate: .currentUserUploadImageData( + data: imageData, + isReupload: false + ), onComplete: { [weak modal] in modal?.close() } ) diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index 5260915ddc..abaa1a54e1 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -47,7 +47,8 @@ public enum SNMessagingKit { // Just to make the external API nice _041_RenameTableSettingToKeyValueStore.self, _042_MoveSettingsToLibSession.self, _043_RenameAttachments.self, - _044_AddProMessageFlag.self + _044_AddProMessageFlag.self, + _045_LastProfileUpdateTimestamp.self ] public static func configure(using dependencies: Dependencies) { @@ -56,7 +57,7 @@ public enum SNMessagingKit { // Just to make the external API nice .disappearingMessages: DisappearingMessagesJob.self, .failedMessageSends: FailedMessageSendsJob.self, .failedAttachmentDownloads: FailedAttachmentDownloadsJob.self, - .updateProfilePicture: UpdateProfilePictureJob.self, + .reuploadUserDisplayPicture: ReuploadUserDisplayPictureJob.self, .retrieveDefaultOpenGroupRooms: RetrieveDefaultOpenGroupRoomsJob.self, .garbageCollection: GarbageCollectionJob.self, .messageSend: MessageSendJob.self, @@ -87,7 +88,7 @@ public enum SNMessagingKit { // Just to make the external API nice (.disappearingMessages, .recurringOnLaunch, true, false), (.failedMessageSends, .recurringOnLaunch, true, false), (.failedAttachmentDownloads, .recurringOnLaunch, true, false), - (.updateProfilePicture, .recurringOnActive, false, false), + (.reuploadUserDisplayPicture, .recurringOnActive, false, false), (.retrieveDefaultOpenGroupRooms, .recurringOnActive, false, false), (.garbageCollection, .recurringOnActive, false, false), (.failedGroupInvitesAndPromotions, .recurringOnLaunch, true, false) diff --git a/SessionMessagingKit/Database/Migrations/_007_SMK_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_007_SMK_SetupStandardJobs.swift index f7057035e0..9f39ec77ef 100644 --- a/SessionMessagingKit/Database/Migrations/_007_SMK_SetupStandardJobs.swift +++ b/SessionMessagingKit/Database/Migrations/_007_SMK_SetupStandardJobs.swift @@ -41,7 +41,7 @@ enum _007_SMK_SetupStandardJobs: Migration { true ), ( - \(Job.Variant.updateProfilePicture.rawValue), + \(Job.Variant.reuploadUserDisplayPicture.rawValue), \(Job.Behaviour.recurringOnActive.rawValue), false ), diff --git a/SessionMessagingKit/Database/Migrations/_012_AddJobPriority.swift b/SessionMessagingKit/Database/Migrations/_012_AddJobPriority.swift index 93a2c68752..ba8ac98552 100644 --- a/SessionMessagingKit/Database/Migrations/_012_AddJobPriority.swift +++ b/SessionMessagingKit/Database/Migrations/_012_AddJobPriority.swift @@ -23,7 +23,7 @@ enum _012_AddJobPriority: Migration { 5: [Job.Variant._legacy_getSnodePool], 4: [Job.Variant.syncPushTokens], 3: [Job.Variant.retrieveDefaultOpenGroupRooms], - 2: [Job.Variant.updateProfilePicture], + 2: [Job.Variant.reuploadUserDisplayPicture], 1: [Job.Variant.garbageCollection] ] diff --git a/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift b/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift index a53031dd46..76f27c71dd 100644 --- a/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift +++ b/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift @@ -63,7 +63,8 @@ enum _028_GenerateInitialUserConfigDumps: Migration { try cache.updateProfile( displayName: (userProfile?["name"] ?? ""), displayPictureUrl: userProfile?["profilePictureUrl"], - displayPictureEncryptionKey: userProfile?["profileEncryptionKey"] + displayPictureEncryptionKey: userProfile?["profileEncryptionKey"], + isReuploadProfilePicture: false ) try LibSession.updateNoteToSelf( diff --git a/SessionMessagingKit/Database/Migrations/_045_LastProfileUpdateTimestamp.swift b/SessionMessagingKit/Database/Migrations/_045_LastProfileUpdateTimestamp.swift new file mode 100644 index 0000000000..89163827fb --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_045_LastProfileUpdateTimestamp.swift @@ -0,0 +1,21 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +enum _045_LastProfileUpdateTimestamp: Migration { + static let identifier: String = "LastProfileUpdateTimestamp" + static let minExpectedRunDuration: TimeInterval = 0.1 + static var createdTables: [(FetchableRecord & TableRecord).Type] = [] + + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { + try db.alter(table: "Profile") { t in + t.drop(column: "lastNameUpdate") + t.drop(column: "lastBlocksCommunityMessageRequests") + t.rename(column: "displayPictureLastUpdated", to: "profileLastUpdated") + } + + MigrationExecution.updateProgress(1) + } +} diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 26f06fbb0c..34b8178298 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -21,15 +21,13 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet case id case name - case lastNameUpdate case nickname case displayPictureUrl case displayPictureEncryptionKey - case displayPictureLastUpdated + case profileLastUpdated case blocksCommunityMessageRequests - case lastBlocksCommunityMessageRequests } /// The id for the user that owns the profile (Note: This could be a sessionId, a blindedId or some future variant) @@ -38,9 +36,6 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet /// The name of the contact. Use this whenever you need the "real", underlying name of a user (e.g. when sending a message). public let name: String - /// The timestamp (in seconds since epoch) that the name was last updated - public let lastNameUpdate: TimeInterval? - /// A custom name for the profile set by the current user public let nickname: String? @@ -52,37 +47,30 @@ public struct Profile: Codable, Sendable, Identifiable, Equatable, Hashable, Fet /// The key with which the profile is encrypted. public let displayPictureEncryptionKey: Data? - /// The timestamp (in seconds since epoch) that the profile picture was last updated - public let displayPictureLastUpdated: TimeInterval? + /// The timestamp (in seconds since epoch) that the profile was last updated + public let profileLastUpdated: TimeInterval? /// A flag indicating whether this profile has reported that it blocks community message requests public let blocksCommunityMessageRequests: Bool? - /// The timestamp (in seconds since epoch) that the `blocksCommunityMessageRequests` setting was last updated - public let lastBlocksCommunityMessageRequests: TimeInterval? - // MARK: - Initialization public init( id: String, name: String, - lastNameUpdate: TimeInterval? = nil, nickname: String? = nil, displayPictureUrl: String? = nil, displayPictureEncryptionKey: Data? = nil, - displayPictureLastUpdated: TimeInterval? = nil, - blocksCommunityMessageRequests: Bool? = nil, - lastBlocksCommunityMessageRequests: TimeInterval? = nil + profileLastUpdated: TimeInterval? = nil, + blocksCommunityMessageRequests: Bool? = nil ) { self.id = id self.name = name - self.lastNameUpdate = lastNameUpdate self.nickname = nickname self.displayPictureUrl = displayPictureUrl self.displayPictureEncryptionKey = displayPictureEncryptionKey - self.displayPictureLastUpdated = displayPictureLastUpdated + self.profileLastUpdated = profileLastUpdated self.blocksCommunityMessageRequests = blocksCommunityMessageRequests - self.lastBlocksCommunityMessageRequests = lastBlocksCommunityMessageRequests } } @@ -104,13 +92,11 @@ extension Profile: CustomStringConvertible, CustomDebugStringConvertible { Profile( id: \(id), name: \(name), - lastNameUpdate: \(lastNameUpdate.map { "\($0)" } ?? "null"), nickname: \(nickname.map { "\($0)" } ?? "null"), displayPictureUrl: \(displayPictureUrl.map { "\"\($0)\"" } ?? "null"), displayPictureEncryptionKey: \(displayPictureEncryptionKey?.toHexString() ?? "null"), - displayPictureLastUpdated: \(displayPictureLastUpdated.map { "\($0)" } ?? "null"), - blocksCommunityMessageRequests: \(blocksCommunityMessageRequests.map { "\($0)" } ?? "null"), - lastBlocksCommunityMessageRequests: \(lastBlocksCommunityMessageRequests.map { "\($0)" } ?? "null") + profileLastUpdated: \(profileLastUpdated.map { "\($0)" } ?? "null"), + blocksCommunityMessageRequests: \(blocksCommunityMessageRequests.map { "\($0)" } ?? "null") ) """ } @@ -137,13 +123,11 @@ public extension Profile { self = Profile( id: try container.decode(String.self, forKey: .id), name: try container.decode(String.self, forKey: .name), - lastNameUpdate: try? container.decode(TimeInterval?.self, forKey: .lastNameUpdate), - nickname: try? container.decode(String?.self, forKey: .nickname), + nickname: try container.decodeIfPresent(String.self, forKey: .nickname), displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: displayPictureKey, - displayPictureLastUpdated: try? container.decode(TimeInterval?.self, forKey: .displayPictureLastUpdated), - blocksCommunityMessageRequests: try? container.decode(Bool?.self, forKey: .blocksCommunityMessageRequests), - lastBlocksCommunityMessageRequests: try? container.decode(TimeInterval?.self, forKey: .lastBlocksCommunityMessageRequests) + profileLastUpdated: try container.decodeIfPresent(TimeInterval.self, forKey: .profileLastUpdated), + blocksCommunityMessageRequests: try container.decodeIfPresent(Bool.self, forKey: .blocksCommunityMessageRequests) ) } @@ -152,13 +136,11 @@ public extension Profile { try container.encode(id, forKey: .id) try container.encode(name, forKey: .name) - try container.encodeIfPresent(lastNameUpdate, forKey: .lastNameUpdate) try container.encodeIfPresent(nickname, forKey: .nickname) try container.encodeIfPresent(displayPictureUrl, forKey: .displayPictureUrl) try container.encodeIfPresent(displayPictureEncryptionKey, forKey: .displayPictureEncryptionKey) - try container.encodeIfPresent(displayPictureLastUpdated, forKey: .displayPictureLastUpdated) + try container.encodeIfPresent(profileLastUpdated, forKey: .profileLastUpdated) try container.encodeIfPresent(blocksCommunityMessageRequests, forKey: .blocksCommunityMessageRequests) - try container.encodeIfPresent(lastBlocksCommunityMessageRequests, forKey: .lastBlocksCommunityMessageRequests) } } @@ -218,13 +200,11 @@ public extension Profile { return Profile( id: id, name: "", - lastNameUpdate: nil, nickname: nil, displayPictureUrl: nil, displayPictureEncryptionKey: nil, - displayPictureLastUpdated: nil, - blocksCommunityMessageRequests: nil, - lastBlocksCommunityMessageRequests: nil + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil ) } @@ -434,13 +414,11 @@ public extension Profile { return Profile( id: id, name: (name ?? self.name), - lastNameUpdate: lastNameUpdate, nickname: (nickname ?? self.nickname), displayPictureUrl: (displayPictureUrl ?? self.displayPictureUrl), displayPictureEncryptionKey: displayPictureEncryptionKey, - displayPictureLastUpdated: displayPictureLastUpdated, - blocksCommunityMessageRequests: blocksCommunityMessageRequests, - lastBlocksCommunityMessageRequests: lastBlocksCommunityMessageRequests + profileLastUpdated: profileLastUpdated, + blocksCommunityMessageRequests: blocksCommunityMessageRequests ) } } diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index edae3c0108..fd2b544796 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -64,19 +64,20 @@ public enum DisplayPictureDownloadJob: JobExecutor { } } .tryMap { (preparedDownload: Network.PreparedRequest) -> Network.PreparedRequest<(Data, String, URL?)> in + let downloadUrl: URL? = try? preparedDownload.generateUrl() + guard let filePath: String = try? dependencies[singleton: .displayPictureManager].path( - for: (preparedDownload.destination.url?.absoluteString) - .defaulting(to: preparedDownload.destination.urlPathAndParamsString) + for: (downloadUrl?.absoluteString ?? preparedDownload.path) ) else { throw DisplayPictureError.invalidPath } guard !dependencies[singleton: .fileManager].fileExists(atPath: filePath) else { - throw DisplayPictureError.alreadyDownloaded(preparedDownload.destination.url) + throw DisplayPictureError.alreadyDownloaded(downloadUrl) } return preparedDownload.map { _, data in - (data, filePath, preparedDownload.destination.url) + (data, filePath, downloadUrl) } } .flatMap { $0.send(using: dependencies) } @@ -190,7 +191,7 @@ public enum DisplayPictureDownloadJob: JobExecutor { db, Profile.Columns.displayPictureUrl.set(to: url), Profile.Columns.displayPictureEncryptionKey.set(to: encryptionKey), - Profile.Columns.displayPictureLastUpdated.set(to: details.timestamp), + Profile.Columns.profileLastUpdated.set(to: details.timestamp), using: dependencies ) db.addProfileEvent(id: id, change: .displayPictureUrl(url)) @@ -257,7 +258,7 @@ extension DisplayPictureDownloadJob { public struct Details: Codable, Hashable { public let target: Target - public let timestamp: TimeInterval + public let timestamp: TimeInterval? // MARK: - Hashable @@ -270,7 +271,7 @@ extension DisplayPictureDownloadJob { // MARK: - Initialization - public init?(target: Target, timestamp: TimeInterval) { + public init?(target: Target, timestamp: TimeInterval?) { guard target.isValid else { return nil } self.target = { @@ -297,7 +298,7 @@ extension DisplayPictureDownloadJob { let key: Data = profile.displayPictureEncryptionKey, let details: Details = Details( target: .profile(id: profile.id, url: url, encryptionKey: key), - timestamp: (profile.displayPictureLastUpdated ?? 0) + timestamp: profile.profileLastUpdated ) else { return nil } @@ -309,7 +310,7 @@ extension DisplayPictureDownloadJob { let key: Data = group.displayPictureEncryptionKey, let details: Details = Details( target: .group(id: group.id, url: url, encryptionKey: key), - timestamp: 0 + timestamp: nil ) else { return nil } @@ -324,7 +325,7 @@ extension DisplayPictureDownloadJob { roomToken: openGroup.roomToken, server: openGroup.server ), - timestamp: 0 + timestamp: nil ) else { return nil } @@ -341,11 +342,16 @@ extension DisplayPictureDownloadJob { case .profile(let id, let url, let encryptionKey): guard let latestProfile: Profile = try? Profile.fetchOne(db, id: id) else { return false } + /// If the data matches what is stored in the database then we should be fine to consider it valid (it may be that + /// we are re-downloading a profile due to some invalid state) + let dataMatches: Bool = ( + encryptionKey == latestProfile.displayPictureEncryptionKey && + url == latestProfile.displayPictureUrl + ) + return ( - timestamp >= (latestProfile.displayPictureLastUpdated ?? 0) || ( - encryptionKey == latestProfile.displayPictureEncryptionKey && - url == latestProfile.displayPictureUrl - ) + Profile.shouldUpdateProfile(timestamp, profile: latestProfile, using: dependencies) || + dataMatches ) case .group(let id, let url,_): diff --git a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift new file mode 100644 index 0000000000..6246a773c9 --- /dev/null +++ b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift @@ -0,0 +1,203 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import GRDB +import SessionUtilitiesKit +import SessionNetworkingKit + +// MARK: - Log.Category + +private extension Log.Category { + static let cat: Log.Category = .create("ReuploadUserDisplayPictureJob", defaultLevel: .info) +} + +// MARK: - ReuploadUserDisplayPictureJob + +public enum ReuploadUserDisplayPictureJob: JobExecutor { + public static let maxFailureCount: Int = -1 + public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false + private static let maxExtendTTLFrequency: TimeInterval = (60 * 60 * 2) + private static let maxDisplayPictureTTL: TimeInterval = (60 * 60 * 24 * 14) + private static let maxReuploadFrequency: TimeInterval = (maxDisplayPictureTTL - (60 * 60 * 24 * 2)) + + public static func run( + _ job: Job, + scheduler: S, + success: @escaping (Job, Bool) -> Void, + failure: @escaping (Job, Error, Bool) -> Void, + deferred: @escaping (Job) -> Void, + using dependencies: Dependencies + ) { + /// Don't run when inactive or not in main app + guard dependencies[defaults: .appGroup, key: .isMainAppActive] else { + return deferred(job) + } + + Task { + // TODO: Wait until we've received a poll response before running the logic? + // TODO: Check whether the image needs to be reprocessed + // TODO: Try to extend the TTL + + let lastAttempt: Date = ( + dependencies[defaults: .standard, key: .lastUserDisplayPictureRefresh] ?? + Date.distantPast + ) + + /// Only try to extend the TTL of the users display pic if enough time has passed since the last attempt + guard dependencies.dateNow.timeIntervalSince(lastAttempt) > maxExtendTTLFrequency else { + /// Reset the `nextRunTimestamp` value just in case the last run failed so we don't get stuck in a loop endlessly + /// deferring the job + if let jobId: Int64 = job.id { + try await dependencies[singleton: .storage].writeAsync { db in + try Job + .filter(id: jobId) + .updateAll(db, Job.Columns.nextRunTimestamp.set(to: 0)) + } + + } + Log.info(.cat, "Deferred as not enough time has passed since the last update") + return scheduler.schedule { + deferred(job) + } + } + + /// Retrieve the users profile data + let profile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } + + /// If we don't have a display pic then no need to do anything + guard let displayPictureUrl: URL = profile.displayPictureUrl.map({ URL(string: $0) }) else { + Log.info(.cat, "User has no display picture") + return scheduler.schedule { + success(job, false) + } + } + + // let displayPictureUpdate: DisplayPictureManager.Update = profile.displayPictureUrl + // .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } + // .map { dependencies[singleton: .fileManager].contents(atPath: $0) } + // .map { .currentUserUploadImageData($0) } + // .defaulting(to: .none) + + /// Try to extend the TTL of the existing profile pic first + do { + let preparedRequest: Network.PreparedRequest = try Network.FileServer.preparedExtend( + url: displayPictureUrl, + ttl: maxDisplayPictureTTL, + serverPubkey: Network.FileServer.fileServerPublicKey, + using: dependencies + ) + var response: FileUploadResponse? + var requestError: Error? + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + + preparedRequest + .send(using: dependencies) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: break /// The `receiveValue` closure will handle + case .failure(let error): + requestError = error + semaphore.signal() + } + }, + receiveValue: { _, fileUploadResponse in + response = fileUploadResponse + semaphore.signal() + } + ) + + /// Wait for the request to complete + semaphore.wait() + + // TODO: If it's a `NotFound` error then we should do the standard reupload logic + + /// If we get a `404` it means we couldn't extend the TTL of the file so need to re-upload + switch (response, requestError) { + case (_, NetworkError.notFound): break + case (_, .some(let error)): + return scheduler.schedule { + failure(job, error, false) + } + + case (.none, .none): break + /// An unknown error occured (we got no response and no error - shouldn't be possible) + return scheduler.schedule { + failure(job, DisplayPictureError.uploadFailed, false) + } + + case (.some, .none): + Log.info(.cat, "Existing profile expiration extended") + + return scheduler.schedule { + success(job, false) + } + } + + /// Determine whether we need to re-process the display picture before re-uploading it + var needsReprocessing: Bool = ((profile.profileLastUpdated ?? 0) == 0) + + if !needsReprocessing { + try? dependencies[singleton: .displayPictureManager].path(for: $0) + displayPictureUrl + } + + + //profile.pro + // TODO: If `shortenFileTTL` is set then reupload even if it's less than the 12 day timeout + // TODO: Update the timestamp on successful extend + // dependencies[defaults: .standard, key: .lastUserDisplayPictureReupload] = dependencies.dateNow + } + catch { + failure(job, error, false) + } + + // // Only re-upload the profile picture if enough time has passed since the last upload + // guard + // let lastAttempt: Date = dependencies[defaults: .standard, key: .lastProfilePictureReuploadAttempt], + // dependencies.dateNow.timeIntervalSince(lastProfilePictureUpload) > (14 * 24 * 60 * 60) + // else { + // // Reset the `nextRunTimestamp` value just in case the last run failed so we don't get stuck + // // in a loop endlessly deferring the job + // if let jobId: Int64 = job.id { + // dependencies[singleton: .storage].write { db in + // try Job + // .filter(id: jobId) + // .updateAll(db, Job.Columns.nextRunTimestamp.set(to: 0)) + // } + // } + // + // Log.info(.cat, "Deferred as not enough time has passed since the last update") + // return deferred(job) + // } + // + // /// **Note:** The `lastProfilePictureUpload` value is updated in `DisplayPictureManager` + // let profile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } + // let displayPictureUpdate: DisplayPictureManager.Update = profile.displayPictureUrl + // .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } + // .map { dependencies[singleton: .fileManager].contents(atPath: $0) } + // .map { .currentUserUploadImageData($0) } + // .defaulting(to: .none) + // + // Profile + // .updateLocal( + // displayPictureUpdate: displayPictureUpdate, + // using: dependencies + // ) + // .subscribe(on: scheduler, using: dependencies) + // .receive(on: scheduler, using: dependencies) + // .sinkUntilComplete( + // receiveCompletion: { result in + // switch result { + // case .failure(let error): failure(job, error, false) + // case .finished: + // Log.info(.cat, "Profile successfully updated") + // success(job, false) + // } + // } + // ) + } + } +} diff --git a/SessionMessagingKit/Jobs/UpdateProfilePictureJob.swift b/SessionMessagingKit/Jobs/UpdateProfilePictureJob.swift deleted file mode 100644 index cf1f402590..0000000000 --- a/SessionMessagingKit/Jobs/UpdateProfilePictureJob.swift +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine -import GRDB -import SessionUtilitiesKit - -// MARK: - Log.Category - -private extension Log.Category { - static let cat: Log.Category = .create("UpdateProfilePictureJob", defaultLevel: .info) -} - -// MARK: - UpdateProfilePictureJob - -public enum UpdateProfilePictureJob: JobExecutor { - public static let maxFailureCount: Int = -1 - public static let requiresThreadId: Bool = false - public static let requiresInteractionId: Bool = false - - public static func run( - _ job: Job, - scheduler: S, - success: @escaping (Job, Bool) -> Void, - failure: @escaping (Job, Error, Bool) -> Void, - deferred: @escaping (Job) -> Void, - using dependencies: Dependencies - ) { - // Don't run when inactive or not in main app - guard dependencies[defaults: .appGroup, key: .isMainAppActive] else { - return deferred(job) // Don't need to do anything if it's not the main app - } - - // Only re-upload the profile picture if enough time has passed since the last upload - guard - let lastProfilePictureUpload: Date = dependencies[defaults: .standard, key: .lastProfilePictureUpload], - dependencies.dateNow.timeIntervalSince(lastProfilePictureUpload) > (14 * 24 * 60 * 60) - else { - // Reset the `nextRunTimestamp` value just in case the last run failed so we don't get stuck - // in a loop endlessly deferring the job - if let jobId: Int64 = job.id { - dependencies[singleton: .storage].write { db in - try Job - .filter(id: jobId) - .updateAll(db, Job.Columns.nextRunTimestamp.set(to: 0)) - } - } - - Log.info(.cat, "Deferred as not enough time has passed since the last update") - return deferred(job) - } - - /// **Note:** The `lastProfilePictureUpload` value is updated in `DisplayPictureManager` - let profile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } - let displayPictureUpdate: DisplayPictureManager.Update = profile.displayPictureUrl - .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } - .map { dependencies[singleton: .fileManager].contents(atPath: $0) } - .map { .currentUserUploadImageData($0) } - .defaulting(to: .none) - - Profile - .updateLocal( - displayPictureUpdate: displayPictureUpdate, - using: dependencies - ) - .subscribe(on: scheduler, using: dependencies) - .receive(on: scheduler, using: dependencies) - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .failure(let error): failure(job, error, false) - case .finished: - Log.info(.cat, "Profile successfully updated") - success(job, false) - } - } - ) - } -} diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index 40a00940db..f61612f7b1 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -24,6 +24,7 @@ internal extension LibSession { Profile.Columns.nickname, Profile.Columns.displayPictureUrl, Profile.Columns.displayPictureEncryptionKey, + Profile.Columns.profileLastUpdated, DisappearingMessagesConfiguration.Columns.isEnabled, DisappearingMessagesConfiguration.Columns.type, DisappearingMessagesConfiguration.Columns.durationSeconds @@ -36,8 +37,7 @@ internal extension LibSessionCacheType { func handleContactsUpdate( _ db: ObservingDatabase, in config: LibSession.Config?, - oldState: [ObservableKey: Any], - serverTimestampMs: Int64 + oldState: [ObservableKey: Any] ) throws { guard configNeedsDump(config) else { return } guard case .contacts(let conf) = config else { @@ -48,7 +48,6 @@ internal extension LibSessionCacheType { // actually a bug) let targetContactData: [String: ContactData] = try LibSession.extractContacts( from: conf, - serverTimestampMs: serverTimestampMs, using: dependencies ).filter { $0.key != userSessionId.hexString } @@ -62,24 +61,18 @@ internal extension LibSessionCacheType { // observation system can't differ between update calls which do and don't change anything) let contact: Contact = Contact.fetchOrCreate(db, id: sessionId, using: dependencies) let profile: Profile = Profile.fetchOrCreate(db, id: sessionId) - let profileNameShouldBeUpdated: Bool = ( - !data.profile.name.isEmpty && - profile.name != data.profile.name && - (profile.lastNameUpdate ?? 0) < (data.profile.lastNameUpdate ?? 0) - ) - let profilePictureShouldBeUpdated: Bool = ( - ( + let profileUpdated: Bool = ((profile.profileLastUpdated ?? 0) < (data.profile.profileLastUpdated ?? 0)) + + if (profileUpdated || (profile.nickname != data.profile.nickname)) { + let profileNameShouldBeUpdated: Bool = ( + !data.profile.name.isEmpty && + profile.name != data.profile.name + ) + let profilePictureShouldBeUpdated: Bool = ( profile.displayPictureUrl != data.profile.displayPictureUrl || profile.displayPictureEncryptionKey != data.profile.displayPictureEncryptionKey - ) && - (profile.displayPictureLastUpdated ?? 0) < (data.profile.displayPictureLastUpdated ?? 0) - ) + ) - if - profileNameShouldBeUpdated || - profile.nickname != data.profile.nickname || - profilePictureShouldBeUpdated - { try profile.upsert(db) try Profile .filter(id: sessionId) @@ -89,9 +82,6 @@ internal extension LibSessionCacheType { (!profileNameShouldBeUpdated ? nil : Profile.Columns.name.set(to: data.profile.name) ), - (!profileNameShouldBeUpdated ? nil : - Profile.Columns.lastNameUpdate.set(to: data.profile.lastNameUpdate) - ), (profile.nickname == data.profile.nickname ? nil : Profile.Columns.nickname.set(to: data.profile.nickname) ), @@ -101,8 +91,8 @@ internal extension LibSessionCacheType { (profile.displayPictureEncryptionKey != data.profile.displayPictureEncryptionKey ? nil : Profile.Columns.displayPictureEncryptionKey.set(to: data.profile.displayPictureEncryptionKey) ), - (!profilePictureShouldBeUpdated ? nil : - Profile.Columns.displayPictureLastUpdated.set(to: data.profile.displayPictureLastUpdated) + (!profileUpdated ? nil : + Profile.Columns.profileLastUpdated.set(to: data.profile.profileLastUpdated) ) ].compactMap { $0 }, using: dependencies @@ -344,6 +334,10 @@ public extension LibSession { contact.set(\.profile_pic.url, to: info.displayPictureUrl) contact.set(\.profile_pic.key, to: info.displayPictureEncryptionKey) + if let profileLastUpdated: Int64 = info.profileLastUpdated { + contact.set(\.profile_updated, to: profileLastUpdated) + } + // Attempts retrieval of the profile picture (will schedule a download if // needed via a throttled subscription on another thread to prevent blocking) // @@ -512,19 +506,6 @@ internal extension LibSession { existingContactIds.contains($0.id) } - // Update the user profile first (if needed) - if let updatedUserProfile: Profile = updatedProfiles.first(where: { $0.id == userSessionId.hexString }) { - try dependencies.mutate(cache: .libSession) { cache in - try cache.performAndPushChange(db, for: .userProfile, sessionId: userSessionId) { _ in - try cache.updateProfile( - displayName: updatedUserProfile.name, - displayPictureUrl: updatedUserProfile.displayPictureUrl, - displayPictureEncryptionKey: updatedUserProfile.displayPictureEncryptionKey - ) - } - } - } - try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .contacts, sessionId: userSessionId) { config in try LibSession @@ -740,6 +721,7 @@ extension LibSession { let nickname: String? let displayPictureUrl: String? let displayPictureEncryptionKey: Data? + let profileLastUpdated: Int64? let disappearingMessagesInfo: DisappearingMessageInfo? let priority: Int32? @@ -775,6 +757,7 @@ extension LibSession { nickname: profile?.nickname, displayPictureUrl: profile?.displayPictureUrl, displayPictureEncryptionKey: profile?.displayPictureEncryptionKey, + profileLastUpdated: profile?.profileLastUpdated.map { Int64($0) }, disappearingMessagesInfo: disappearingMessagesConfig.map { DisappearingMessageInfo( isEnabled: $0.isEnabled, @@ -797,6 +780,7 @@ extension LibSession { nickname: String? = nil, displayPictureUrl: String? = nil, displayPictureEncryptionKey: Data? = nil, + profileLastUpdated: Int64? = nil, disappearingMessagesInfo: DisappearingMessageInfo? = nil, priority: Int32? = nil, created: TimeInterval? = nil @@ -810,6 +794,7 @@ extension LibSession { self.nickname = nickname self.displayPictureUrl = displayPictureUrl self.displayPictureEncryptionKey = displayPictureEncryptionKey + self.profileLastUpdated = profileLastUpdated self.disappearingMessagesInfo = disappearingMessagesInfo self.priority = priority self.created = created @@ -851,7 +836,6 @@ internal struct ContactData { internal extension LibSession { static func extractContacts( from conf: UnsafeMutablePointer?, - serverTimestampMs: Int64, using dependencies: Dependencies ) throws -> [String: ContactData] { var infiniteLoopGuard: Int = 0 @@ -875,11 +859,10 @@ internal extension LibSession { let profileResult: Profile = Profile( id: contactId, name: contact.get(\.name), - lastNameUpdate: (TimeInterval(serverTimestampMs) / 1000), nickname: contact.get(\.nickname, nullIfEmpty: true), displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : contact.get(\.profile_pic.key)), - displayPictureLastUpdated: (TimeInterval(serverTimestampMs) / 1000) + profileLastUpdated: TimeInterval(contact.profile_updated) ) let configResult: DisappearingMessagesConfiguration = DisappearingMessagesConfiguration( threadId: contactId, diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift index 8774ca003d..dd81620304 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift @@ -124,8 +124,7 @@ internal extension LibSessionCacheType { let groupProfiles: Set? = try? LibSession.extractProfiles( from: conf, - groupSessionId: groupSessionId, - serverTimestampMs: serverTimestampMs + groupSessionId: groupSessionId ) groupProfiles?.forEach { profile in @@ -134,7 +133,7 @@ internal extension LibSessionCacheType { publicKey: profile.id, displayNameUpdate: .contactUpdate(profile.name), displayPictureUpdate: .from(profile, fallback: .none, using: dependencies), - sentTimestamp: TimeInterval(Double(serverTimestampMs) * 1000), + profileUpdateTimestamp: (profile.profileLastUpdated ?? 0), using: dependencies ) } @@ -501,8 +500,7 @@ internal extension LibSession { static func extractProfiles( from conf: UnsafeMutablePointer?, - groupSessionId: SessionId, - serverTimestampMs: Int64 + groupSessionId: SessionId ) throws -> Set { var infiniteLoopGuard: Int = 0 var result: [Profile] = [] @@ -522,14 +520,12 @@ internal extension LibSession { Profile( id: member.get(\.session_id), name: member.get(\.name), - lastNameUpdate: TimeInterval(Double(serverTimestampMs) / 1000), nickname: nil, displayPictureUrl: member.get(\.profile_pic.url, nullIfEmpty: true), displayPictureEncryptionKey: (member.get(\.profile_pic.url, nullIfEmpty: true) == nil ? nil : member.get(\.profile_pic.key) ), - displayPictureLastUpdated: TimeInterval(Double(serverTimestampMs) / 1000), - lastBlocksCommunityMessageRequests: nil + profileLastUpdated: TimeInterval(member.profile_updated) ) ) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift index ce4d1986dd..2d69301415 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift @@ -761,6 +761,7 @@ public extension LibSession.Cache { let displayNameInMessage: String? = (visibleMessage?.sender != contactId ? nil : visibleMessage?.profile?.displayName?.nullIfEmpty ) + let profileLastUpdatedInMessage: TimeInterval? = visibleMessage?.profile?.updateTimestampSeconds let fallbackProfile: Profile? = displayNameInMessage.map { Profile(id: contactId, name: $0) } guard var cContactId: [CChar] = contactId.cString(using: .utf8) else { @@ -782,11 +783,10 @@ public extension LibSession.Cache { return Profile( id: contactId, name: String(cString: profileNamePtr), - lastNameUpdate: nil, nickname: nil, displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : displayPic.get(\.key)), - displayPictureLastUpdated: nil + profileLastUpdated: profileLastUpdatedInMessage ) } @@ -812,11 +812,10 @@ public extension LibSession.Cache { return Profile( id: contactId, name: (displayNameInMessage ?? member.get(\.name)), - lastNameUpdate: nil, nickname: nil, displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : member.get(\.profile_pic.key)), - displayPictureLastUpdated: nil + profileLastUpdated: TimeInterval(member.get(\.profile_updated)) ) } @@ -838,11 +837,10 @@ public extension LibSession.Cache { return Profile( id: contactId, name: (displayNameInMessage ?? contact.get(\.name)), - lastNameUpdate: nil, nickname: contact.get(\.nickname, nullIfEmpty: true), displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : contact.get(\.profile_pic.key)), - displayPictureLastUpdated: nil + profileLastUpdated: TimeInterval(contact.get( \.profile_updated)) ) } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift index c9724f15b1..ea081d9c1b 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift @@ -39,8 +39,7 @@ internal extension LibSession { internal extension LibSessionCacheType { func handleUserGroupsUpdate( _ db: ObservingDatabase, - in config: LibSession.Config?, - serverTimestampMs: Int64 + in config: LibSession.Config? ) throws { guard configNeedsDump(config) else { return } guard case .userGroups(let conf) = config else { diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index 8957a66c65..84f661266d 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -11,7 +11,8 @@ internal extension LibSession { static let columnsRelatedToUserProfile: [Profile.Columns] = [ Profile.Columns.name, Profile.Columns.displayPictureUrl, - Profile.Columns.displayPictureEncryptionKey + Profile.Columns.displayPictureEncryptionKey, + Profile.Columns.profileLastUpdated ] static let syncedSettings: [String] = [ @@ -25,8 +26,7 @@ internal extension LibSessionCacheType { func handleUserProfileUpdate( _ db: ObservingDatabase, in config: LibSession.Config?, - oldState: [ObservableKey: Any], - serverTimestampMs: Int64 + oldState: [ObservableKey: Any] ) throws { guard configNeedsDump(config) else { return } guard case .userProfile(let conf) = config else { @@ -39,10 +39,12 @@ internal extension LibSessionCacheType { let profileName: String = String(cString: profileNamePtr) let displayPic: user_profile_pic = user_profile_get_pic(conf) let displayPictureUrl: String? = displayPic.get(\.url, nullIfEmpty: true) + let profileLastUpdateTimestamp: TimeInterval = TimeInterval(user_profile_get_profile_updated(conf)) let updatedProfile: Profile = Profile( id: userSessionId.hexString, name: profileName, - displayPictureUrl: (oldState[.profile(userSessionId.hexString)] as? Profile)?.displayPictureUrl + displayPictureUrl: (oldState[.profile(userSessionId.hexString)] as? Profile)?.displayPictureUrl, + profileLastUpdated: profileLastUpdateTimestamp ) if let profile: Profile = oldState[.profile(userSessionId.hexString)] as? Profile { @@ -73,7 +75,7 @@ internal extension LibSessionCacheType { filePath: filePath ) }(), - sentTimestamp: TimeInterval(Double(serverTimestampMs) / 1000), + profileUpdateTimestamp: profileLastUpdateTimestamp, using: dependencies ) @@ -208,7 +210,8 @@ public extension LibSession.Cache { func updateProfile( displayName: String, displayPictureUrl: String?, - displayPictureEncryptionKey: Data? + displayPictureEncryptionKey: Data?, + isReuploadProfilePicture: Bool ) throws { guard let config: LibSession.Config = config(for: .userProfile, sessionId: userSessionId) else { throw LibSessionError.invalidConfigObject(wanted: .userProfile, got: nil) @@ -233,6 +236,12 @@ public extension LibSession.Cache { var profilePic: user_profile_pic = user_profile_pic() profilePic.set(\.url, to: displayPictureUrl) profilePic.set(\.key, to: displayPictureEncryptionKey) + + switch isReuploadProfilePicture { + case true: user_profile_set_reupload_pic(conf, profilePic) + case false: user_profile_set_pic(conf, profilePic) + } + user_profile_set_pic(conf, profilePic) try LibSessionError.throwIfNeeded(conf) diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index 217af7d5ae..de193f1adb 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -744,7 +744,7 @@ public extension LibSession { case .contacts(let conf): return try LibSession - .extractContacts(from: conf, serverTimestampMs: -1, using: dependencies) + .extractContacts(from: conf, using: dependencies) .reduce(into: [:]) { result, next in result[.contact(next.key)] = next.value.contact result[.profile(next.key)] = next.value.profile @@ -794,16 +794,14 @@ public extension LibSession { try handleUserProfileUpdate( db, in: config, - oldState: oldState, - serverTimestampMs: latestServerTimestampMs + oldState: oldState ) case .contacts: try handleContactsUpdate( db, in: config, - oldState: oldState, - serverTimestampMs: latestServerTimestampMs + oldState: oldState ) case .convoInfoVolatile: @@ -815,8 +813,7 @@ public extension LibSession { case .userGroups: try handleUserGroupsUpdate( db, - in: config, - serverTimestampMs: latestServerTimestampMs + in: config ) case .groupInfo: @@ -1044,7 +1041,8 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT func updateProfile( displayName: String, displayPictureUrl: String?, - displayPictureEncryptionKey: Data? + displayPictureEncryptionKey: Data?, + isReuploadProfilePicture: Bool ) throws func canPerformChange( @@ -1171,20 +1169,25 @@ public extension LibSessionCacheType { loadState(db, requestId: nil) } - func addEvent(key: ObservableKey, value: AnyHashable?) { + func addEvent(key: ObservableKey, value: T?) { addEvent(ObservedEvent(key: key, value: value)) } - func addEvent(key: Setting.BoolKey, value: AnyHashable?) { + func addEvent(key: Setting.BoolKey, value: T?) { addEvent(ObservedEvent(key: .setting(key), value: value)) } - func addEvent(key: Setting.EnumKey, value: AnyHashable?) { + func addEvent(key: Setting.EnumKey, value: T?) { addEvent(ObservedEvent(key: .setting(key), value: value)) } func updateProfile(displayName: String) throws { - try updateProfile(displayName: displayName, displayPictureUrl: nil, displayPictureEncryptionKey: nil) + try updateProfile( + displayName: displayName, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + isReuploadProfilePicture: false + ) } var profile: Profile { @@ -1319,7 +1322,8 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { func updateProfile( displayName: String, displayPictureUrl: String?, - displayPictureEncryptionKey: Data? + displayPictureEncryptionKey: Data?, + isReuploadProfilePicture: Bool ) throws {} func canPerformChange( diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index 2f3867a06d..87530aece0 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -10,6 +10,7 @@ public extension VisibleMessage { public let displayName: String? public let profileKey: Data? public let profilePictureUrl: String? + public let updateTimestampSeconds: TimeInterval? public let blocksCommunityMessageRequests: Bool? // MARK: - Initialization @@ -18,6 +19,7 @@ public extension VisibleMessage { displayName: String, profileKey: Data? = nil, profilePictureUrl: String? = nil, + updateTimestampSeconds: TimeInterval? = nil, blocksCommunityMessageRequests: Bool? = nil ) { let hasUrlAndKey: Bool = (profileKey != nil && profilePictureUrl != nil) @@ -25,6 +27,7 @@ public extension VisibleMessage { self.displayName = displayName self.profileKey = (hasUrlAndKey ? profileKey : nil) self.profilePictureUrl = (hasUrlAndKey ? profilePictureUrl : nil) + self.updateTimestampSeconds = updateTimestampSeconds self.blocksCommunityMessageRequests = blocksCommunityMessageRequests } @@ -40,6 +43,7 @@ public extension VisibleMessage { displayName: displayName, profileKey: proto.profileKey, profilePictureUrl: profileProto.profilePicture, + updateTimestampSeconds: TimeInterval(profileProto.lastUpdateSeconds), blocksCommunityMessageRequests: (proto.hasBlocksCommunityMessageRequests ? proto.blocksCommunityMessageRequests : nil) ) } @@ -60,6 +64,10 @@ public extension VisibleMessage { profileProto.setProfilePicture(profilePictureUrl) } + if let updateTimestampSeconds: TimeInterval = updateTimestampSeconds { + profileProto.setLastUpdateSeconds(UInt64(updateTimestampSeconds)) + } + dataMessageProto.setProfile(try profileProto.build()) return dataMessageProto } @@ -87,7 +95,8 @@ public extension VisibleMessage { return VMProfile( displayName: displayName, profileKey: proto.profileKey, - profilePictureUrl: profileProto.profilePicture + profilePictureUrl: profileProto.profilePicture, + updateTimestampSeconds: TimeInterval(profileProto.lastUpdateSeconds) ) } @@ -106,6 +115,9 @@ public extension VisibleMessage { messageRequestResponseProto.setProfileKey(profileKey) profileProto.setProfilePicture(profilePictureUrl) } + if let updateTimestampSeconds: TimeInterval = updateTimestampSeconds { + profileProto.setLastUpdateSeconds(UInt64(updateTimestampSeconds)) + } do { messageRequestResponseProto.setProfile(try profileProto.build()) return try messageRequestResponseProto.build() @@ -122,7 +134,8 @@ public extension VisibleMessage { Profile( displayName: \(displayName ?? "null"), profileKey: \(profileKey?.description ?? "null"), - profilePictureUrl: \(profilePictureUrl ?? "null") + profilePictureUrl: \(profilePictureUrl ?? "null"), + updateTimestampSeconds: \(updateTimestampSeconds ?? 0) ) """ } diff --git a/SessionMessagingKit/Protos/Generated/SNProto.swift b/SessionMessagingKit/Protos/Generated/SNProto.swift index 14aaa60e99..633e60c3a7 100644 --- a/SessionMessagingKit/Protos/Generated/SNProto.swift +++ b/SessionMessagingKit/Protos/Generated/SNProto.swift @@ -1296,6 +1296,9 @@ extension SNProtoDataExtractionNotification.SNProtoDataExtractionNotificationBui if let _value = profilePicture { builder.setProfilePicture(_value) } + if hasLastUpdateSeconds { + builder.setLastUpdateSeconds(lastUpdateSeconds) + } return builder } @@ -1313,6 +1316,10 @@ extension SNProtoDataExtractionNotification.SNProtoDataExtractionNotificationBui proto.profilePicture = valueParam } + @objc public func setLastUpdateSeconds(_ valueParam: UInt64) { + proto.lastUpdateSeconds = valueParam + } + @objc public func build() throws -> SNProtoLokiProfile { return try SNProtoLokiProfile.parseProto(proto) } @@ -1344,6 +1351,13 @@ extension SNProtoDataExtractionNotification.SNProtoDataExtractionNotificationBui return proto.hasProfilePicture } + @objc public var lastUpdateSeconds: UInt64 { + return proto.lastUpdateSeconds + } + @objc public var hasLastUpdateSeconds: Bool { + return proto.hasLastUpdateSeconds + } + private init(proto: SessionProtos_LokiProfile) { self.proto = proto } diff --git a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift index b6fc8ff3bd..40f49dead2 100644 --- a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift +++ b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift @@ -619,12 +619,23 @@ struct SessionProtos_LokiProfile { /// Clears the value of `profilePicture`. Subsequent reads from it will return its default value. mutating func clearProfilePicture() {self._profilePicture = nil} + /// Timestamp of the last profile update + var lastUpdateSeconds: UInt64 { + get {return _lastUpdateSeconds ?? 0} + set {_lastUpdateSeconds = newValue} + } + /// Returns true if `lastUpdateSeconds` has been explicitly set. + var hasLastUpdateSeconds: Bool {return self._lastUpdateSeconds != nil} + /// Clears the value of `lastUpdateSeconds`. Subsequent reads from it will return its default value. + mutating func clearLastUpdateSeconds() {self._lastUpdateSeconds = nil} + var unknownFields = SwiftProtobuf.UnknownStorage() init() {} fileprivate var _displayName: String? = nil fileprivate var _profilePicture: String? = nil + fileprivate var _lastUpdateSeconds: UInt64? = nil } struct SessionProtos_DataMessage { @@ -2321,6 +2332,7 @@ extension SessionProtos_LokiProfile: SwiftProtobuf.Message, SwiftProtobuf._Messa static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "displayName"), 2: .same(proto: "profilePicture"), + 3: .same(proto: "lastUpdateSeconds"), ] mutating func decodeMessage(decoder: inout D) throws { @@ -2331,6 +2343,7 @@ extension SessionProtos_LokiProfile: SwiftProtobuf.Message, SwiftProtobuf._Messa switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self._displayName) }() case 2: try { try decoder.decodeSingularStringField(value: &self._profilePicture) }() + case 3: try { try decoder.decodeSingularUInt64Field(value: &self._lastUpdateSeconds) }() default: break } } @@ -2347,12 +2360,16 @@ extension SessionProtos_LokiProfile: SwiftProtobuf.Message, SwiftProtobuf._Messa try { if let v = self._profilePicture { try visitor.visitSingularStringField(value: v, fieldNumber: 2) } }() + try { if let v = self._lastUpdateSeconds { + try visitor.visitSingularUInt64Field(value: v, fieldNumber: 3) + } }() try unknownFields.traverse(visitor: &visitor) } static func ==(lhs: SessionProtos_LokiProfile, rhs: SessionProtos_LokiProfile) -> Bool { if lhs._displayName != rhs._displayName {return false} if lhs._profilePicture != rhs._profilePicture {return false} + if lhs._lastUpdateSeconds != rhs._lastUpdateSeconds {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/SessionMessagingKit/Protos/SessionProtos.proto b/SessionMessagingKit/Protos/SessionProtos.proto index 13ca774aec..dd848b18e9 100644 --- a/SessionMessagingKit/Protos/SessionProtos.proto +++ b/SessionMessagingKit/Protos/SessionProtos.proto @@ -115,6 +115,7 @@ message DataExtractionNotification { message LokiProfile { optional string displayName = 1; optional string profilePicture = 2; + optional uint64 lastUpdateSeconds = 3; // Timestamp of the last profile update } message DataMessage { diff --git a/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift b/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift index c543b0dd4b..d0bcf07c4b 100644 --- a/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift +++ b/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift @@ -99,7 +99,7 @@ public final class AttachmentUploader { return ( attachment, try Network.PreparedRequest.cached( - FileUploadResponse(id: fileId), + FileUploadResponse(id: fileId, uploaded: nil, expires: nil), endpoint: endpoint, using: dependencies ), @@ -127,7 +127,7 @@ public final class AttachmentUploader { return ( attachment, try Network.PreparedRequest.cached( - FileUploadResponse(id: fileId), + FileUploadResponse(id: fileId, uploaded: nil, expires: nil), endpoint: endpoint, using: dependencies ), diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift index 79ca54ca61..eb4a2cc6ac 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift @@ -146,7 +146,7 @@ extension MessageReceiver { displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, - sentTimestamp: TimeInterval(Double(sentTimestampMs) / 1000), + profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) } @@ -250,7 +250,7 @@ extension MessageReceiver { displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, - sentTimestamp: TimeInterval(Double(sentTimestampMs) / 1000), + profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) } @@ -611,7 +611,7 @@ extension MessageReceiver { displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, - sentTimestamp: TimeInterval(Double(sentTimestampMs) / 1000), + profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) } @@ -628,7 +628,7 @@ extension MessageReceiver { name: $0, displayPictureUrl: profile.profilePictureUrl, displayPictureEncryptionKey: profile.profileKey, - displayPictureLastUpdated: (Double(sentTimestampMs) / 1000) + profileLastUpdated: profile.updateTimestampSeconds ) } }, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index 1ad39c5a05..aa966d9d39 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -26,14 +26,12 @@ extension MessageReceiver { // Update profile if needed (want to do this regardless of whether the message exists or // not to ensure the profile info gets sync between a users devices at every chance) if let profile = message.profile { - let messageSentTimestamp: TimeInterval = TimeInterval(Double(message.sentTimestampMs ?? 0) / 1000) - try Profile.updateIfNeeded( db, publicKey: senderId, displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .none, using: dependencies), - sentTimestamp: messageSentTimestamp, + profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index a5ddfb44bf..c55b69ae4d 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -43,7 +43,7 @@ extension MessageReceiver { displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, - sentTimestamp: messageSentTimestamp, + profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index 8defb0e98d..a0129669ac 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -38,7 +38,7 @@ extension MessageSender { } return dependencies[singleton: .displayPictureManager] - .prepareAndUploadDisplayPicture(imageData: displayPictureData) + .prepareAndUploadDisplayPicture(imageData: displayPictureData, compression: true) .mapError { error -> Error in error } .map { Optional($0) } .eraseToAnyPublisher() diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 78ed522564..8752d80003 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -172,7 +172,8 @@ public final class MessageSender { VisibleMessage.VMProfile( displayName: profile.name, profileKey: profile.displayPictureEncryptionKey, - profilePictureUrl: profile.displayPictureUrl + profilePictureUrl: profile.displayPictureUrl, + updateTimestampSeconds: profile.profileLastUpdated ) } } @@ -271,6 +272,7 @@ public final class MessageSender { displayName: profile.name, profileKey: profile.displayPictureEncryptionKey, profilePictureUrl: profile.displayPictureUrl, + updateTimestampSeconds: profile.profileLastUpdated, blocksCommunityMessageRequests: !checkForCommunityMessageRequests ) } @@ -336,7 +338,8 @@ public final class MessageSender { VisibleMessage.VMProfile( displayName: profile.name, profileKey: profile.displayPictureEncryptionKey, - profilePictureUrl: profile.displayPictureUrl + profilePictureUrl: profile.displayPictureUrl, + updateTimestampSeconds: profile.profileLastUpdated ) } diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index 6663fcf0f8..d83f554566 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -34,7 +34,7 @@ public class DisplayPictureManager { case contactUpdateTo(url: String, key: Data, filePath: String) case currentUserRemove - case currentUserUploadImageData(Data) + case currentUserUploadImageData(data: Data, isReupload: Bool) case currentUserUpdateTo(url: String, key: Data, filePath: String) case groupRemove @@ -182,7 +182,7 @@ public class DisplayPictureManager { // MARK: - Uploading - public func prepareAndUploadDisplayPicture(imageData: Data) -> AnyPublisher { + public func prepareAndUploadDisplayPicture(imageData: Data, compression: Bool) -> AnyPublisher { return Just(()) .setFailureType(to: DisplayPictureError.self) .tryMap { [dependencies] _ -> (Network.PreparedRequest, String, Data) in diff --git a/SessionMessagingKit/Utilities/Profile+CurrentUser.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift similarity index 71% rename from SessionMessagingKit/Utilities/Profile+CurrentUser.swift rename to SessionMessagingKit/Utilities/Profile+Updating.swift index 0796e4f761..bb641eb9f8 100644 --- a/SessionMessagingKit/Utilities/Profile+CurrentUser.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -78,12 +78,13 @@ public extension Profile { } } + let profileUpdateTimestampMs: Double = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() try Profile.updateIfNeeded( db, publicKey: userSessionId.hexString, displayNameUpdate: displayNameUpdate, displayPictureUpdate: displayPictureUpdate, - sentTimestamp: dependencies.dateNow.timeIntervalSince1970, + profileUpdateTimestamp: TimeInterval(profileUpdateTimestampMs / 1000), using: dependencies ) Log.info(.profile, "Successfully updated user profile.") @@ -91,11 +92,12 @@ public extension Profile { .mapError { _ in DisplayPictureError.databaseChangesFailed } .eraseToAnyPublisher() - case .currentUserUploadImageData(let data): + case .currentUserUploadImageData(let data, let isReupload): return dependencies[singleton: .displayPictureManager] - .prepareAndUploadDisplayPicture(imageData: data) + .prepareAndUploadDisplayPicture(imageData: data, compression: !isReupload) .mapError { $0 as Error } .flatMapStorageWritePublisher(using: dependencies, updates: { db, result in + let profileUpdateTimestamp: TimeInterval = (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) try Profile.updateIfNeeded( db, publicKey: userSessionId.hexString, @@ -105,11 +107,12 @@ public extension Profile { key: result.encryptionKey, filePath: result.filePath ), - sentTimestamp: dependencies.dateNow.timeIntervalSince1970, + profileUpdateTimestamp: profileUpdateTimestamp, + isReuploadCurrentUserProfilePicture: isReupload, using: dependencies ) - dependencies[defaults: .standard, key: .lastProfilePictureUpload] = dependencies.dateNow + dependencies[defaults: .standard, key: .lastUserDisplayPictureRefresh] = dependencies.dateNow Log.info(.profile, "Successfully updated user profile.") }) .mapError { error in @@ -120,7 +123,36 @@ public extension Profile { } .eraseToAnyPublisher() } - } + } + + /// To try to maintain backwards compatibility with profile changes we want to continue to accept profile changes from old clients if + /// we haven't received a profile update from a new client yet otherwise, if we have, then we should only accept profile changes if + /// they are newer that our cached version of the profile data + static func shouldUpdateProfile( + _ profileUpdateTimestamp: TimeInterval?, + profile: Profile, + using dependencies: Dependencies + ) -> Bool { + /// We should consider `libSession` the source-of-truth for profile data for contacts so try to retrieve the profile data from + /// there before falling back to the one fetched from the database + let targetProfile: Profile = ( + dependencies.mutate(cache: .libSession) { $0.profile(contactId: profile.id) } ?? + profile + ) + let finalProfileUpdateTimestamp: TimeInterval = (profileUpdateTimestamp ?? 0) + let finalCachedProfileUpdateTimestamp: TimeInterval = (targetProfile.profileLastUpdated ?? 0) + + /// If neither the profile update or the cached profile have a timestamp then we should just always accept the update + /// + /// **Note:** We check if they are equal to `0` here because the default value from `libSession` will be `0` + /// rather than `null` + guard finalProfileUpdateTimestamp != 0 || finalCachedProfileUpdateTimestamp != 0 else { + return true + } + + /// Otherwise we should only accept the update if it's newer than our cached value + return (finalProfileUpdateTimestamp > finalCachedProfileUpdateTimestamp) + } static func updateIfNeeded( _ db: ObservingDatabase, @@ -128,36 +160,24 @@ public extension Profile { displayNameUpdate: DisplayNameUpdate = .none, displayPictureUpdate: DisplayPictureManager.Update, blocksCommunityMessageRequests: Bool? = nil, - sentTimestamp: TimeInterval, + profileUpdateTimestamp: TimeInterval?, + isReuploadCurrentUserProfilePicture: Bool = false, using dependencies: Dependencies ) throws { let isCurrentUser = (publicKey == dependencies[cache: .general].sessionId.hexString) let profile: Profile = Profile.fetchOrCreate(db, id: publicKey) var profileChanges: [ConfigColumnAssignment] = [] - /// There were some bugs (somewhere) where some of these timestamps valid could be in seconds or milliseconds so we need to try to - /// detect this and convert it to proper seconds (if we don't then we will never update the profile) - func convertToSections(_ maybeValue: Double?) -> TimeInterval { - guard let value: Double = maybeValue else { return 0 } - - if value > 9_000_000_000_000 { // Microseconds - return (value / 1_000_000) - } else if value > 9_000_000_000 { // Milliseconds - return (value / 1000) - } - - return TimeInterval(value) // Seconds + guard shouldUpdateProfile(profileUpdateTimestamp, profile: profile, using: dependencies) else { + return } // Name - // FIXME: This 'lastNameUpdate' approach is buggy - we should have a timestamp on the ConvoInfoVolatile - switch (displayNameUpdate, isCurrentUser, (sentTimestamp > convertToSections(profile.lastNameUpdate))) { - case (.none, _, _): break - case (.currentUserUpdate(let name), true, _), (.contactUpdate(let name), false, true): + switch (displayNameUpdate, isCurrentUser) { + case (.none, _): break + case (.currentUserUpdate(let name), true), (.contactUpdate(let name), false): guard let name: String = name, !name.isEmpty, name != profile.name else { break } - profileChanges.append(Profile.Columns.lastNameUpdate.set(to: sentTimestamp)) - if profile.name != name { profileChanges.append(Profile.Columns.name.set(to: name)) db.addProfileEvent(id: publicKey, change: .name(name)) @@ -168,9 +188,8 @@ public extension Profile { } // Blocks community message requests flag - if let blocksCommunityMessageRequests: Bool = blocksCommunityMessageRequests, sentTimestamp > convertToSections(profile.lastBlocksCommunityMessageRequests) { + if let blocksCommunityMessageRequests: Bool = blocksCommunityMessageRequests { profileChanges.append(Profile.Columns.blocksCommunityMessageRequests.set(to: blocksCommunityMessageRequests)) - profileChanges.append(Profile.Columns.lastBlocksCommunityMessageRequests.set(to: sentTimestamp)) } // Profile picture & profile key @@ -180,8 +199,6 @@ public extension Profile { preconditionFailure("Invalid options for this function") case (.contactRemove, false), (.currentUserRemove, true): - profileChanges.append(Profile.Columns.displayPictureLastUpdated.set(to: sentTimestamp)) - if profile.displayPictureEncryptionKey != nil { profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: nil)) } @@ -203,7 +220,7 @@ public extension Profile { shouldBeUnique: true, details: DisplayPictureDownloadJob.Details( target: .profile(id: profile.id, url: url, encryptionKey: key), - timestamp: sentTimestamp + timestamp: profileUpdateTimestamp ) ), canStartJob: dependencies[singleton: .appContext].isMainApp @@ -218,16 +235,16 @@ public extension Profile { if key != profile.displayPictureEncryptionKey && key.count == DisplayPictureManager.aes256KeyByteLength { profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: key)) } - - profileChanges.append(Profile.Columns.displayPictureLastUpdated.set(to: sentTimestamp)) } /// Don't want profiles in messages to modify the current users profile info so ignore those cases default: break } - // Persist any changes + /// Persist any changes if !profileChanges.isEmpty { + profileChanges.append(Profile.Columns.profileLastUpdated.set(to: profileUpdateTimestamp)) + try profile.upsert(db) try Profile @@ -237,6 +254,21 @@ public extension Profile { profileChanges, using: dependencies ) + + /// We don't automatically update the current users profile data when changed in the database so need to manually + /// trigger the update + if isCurrentUser, let updatedProfile = try? Profile.fetchOne(db, id: publicKey) { + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userProfile, sessionId: dependencies[cache: .general].sessionId) { _ in + try cache.updateProfile( + displayName: updatedProfile.name, + displayPictureUrl: updatedProfile.displayPictureUrl, + displayPictureEncryptionKey: updatedProfile.displayPictureEncryptionKey, + isReuploadProfilePicture: isReuploadCurrentUserProfilePicture + ) + } + } + } } } } diff --git a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift index 8cdfca3459..10ff5d9500 100644 --- a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift @@ -23,7 +23,10 @@ class DisplayPictureDownloadJobSpec: QuickSpec { dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) } @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { $0.defaultInitialSetup() } + initialSetup: { + $0.defaultInitialSetup() + $0.when { $0.profile(contactId: .any, threadId: .any, threadVariant: .any, visibleMessage: .any) }.thenReturn(nil) + } ) @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), @@ -587,7 +590,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { name: "test", displayPictureUrl: nil, displayPictureEncryptionKey: nil, - displayPictureLastUpdated: nil + profileLastUpdated: nil ) mockStorage.write { db in try profile.insert(db) } job = Job( @@ -705,7 +708,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { name: "test", displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - displayPictureLastUpdated: 1234567890 + profileLastUpdated: 1234567890 ) mockStorage.write { db in _ = try Profile.deleteAll(db) @@ -754,7 +757,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { .updateAll( db, Profile.Columns.displayPictureEncryptionKey.set(to: Data([1, 2, 3])), - Profile.Columns.displayPictureLastUpdated.set(to: 9999999999) + Profile.Columns.profileLastUpdated.set(to: 9999999999) ) } } @@ -777,7 +780,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { name: "test", displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - displayPictureLastUpdated: 1234567891 + profileLastUpdated: 1234567891 ) )) } @@ -791,7 +794,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { .updateAll( db, Profile.Columns.displayPictureUrl.set(to: "testUrl"), - Profile.Columns.displayPictureLastUpdated.set(to: 9999999999) + Profile.Columns.profileLastUpdated.set(to: 9999999999) ) } } @@ -814,7 +817,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { name: "test", displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - displayPictureLastUpdated: 1234567891 + profileLastUpdated: 1234567891 ) )) } @@ -827,7 +830,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { try Profile .updateAll( db, - Profile.Columns.displayPictureLastUpdated.set(to: 9999999999) + Profile.Columns.profileLastUpdated.set(to: 9999999999) ) } } @@ -859,7 +862,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { name: "test", displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - displayPictureLastUpdated: 1234567891 + profileLastUpdated: 1234567891 ) )) } @@ -874,7 +877,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { name: "test", displayPictureUrl: "http://oxen.io/100/", displayPictureEncryptionKey: encryptionKey, - displayPictureLastUpdated: 1234567891 + profileLastUpdated: 1234567891 ) )) } diff --git a/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift index 56ab349c08..84c3252952 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift @@ -882,12 +882,20 @@ fileprivate extension LibSessionUtilSpec { expect(pushData5.pointee.seqno).to(equal(3)) expect(pushData6.pointee.seqno).to(equal(3)) - // They should have resolved the conflict to the same thing: - expect(String(cString: user_profile_get_name(conf)!)).to(equal("Nibbler")) - expect(String(cString: user_profile_get_name(conf2)!)).to(equal("Nibbler")) - // (Note that they could have also both resolved to "Raz" here, but the hash of the serialized - // message just happens to have a higher hash -- and thus gets priority -- for this particular - // test). + // They should have resolved the conflict to the same thing - since the configs set + // a timestamp to the current time when modifying the profile (and we don't have a + // mechanism via the C API to set it directly, we can do this directly in the C++ but + // not here) we don't actually know whether "Nibbler" or "Raz" will win here so instead + // the best we can do is insure they match each other, and that they match one of the options + let confNamePtr: UnsafePointer? = user_profile_get_name(conf) + let conf2NamePtr: UnsafePointer? = user_profile_get_name(conf2) + try require(confNamePtr).toNot(beNil()) + try require(conf2NamePtr).toNot(beNil()) + let confName: String = String(cString: confNamePtr!) + let conf2Name: String = String(cString: conf2NamePtr!) + expect(Set(["Nibbler", "Raz"])).to(contain(confName)) + expect(Set(["Nibbler", "Raz"])).to(contain(conf2Name)) + expect(confName).to(equal(conf2Name)) // Since only one of them set a profile pic there should be no conflict there: let pic3: user_profile_pic = user_profile_get_pic(conf) diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index f0933e8d92..9d2d2d4cb3 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -196,6 +196,7 @@ class OpenGroupManagerSpec: QuickSpec { @TestState(defaults: .appGroup, in: dependencies) var mockAppGroupDefaults: MockUserDefaults! = MockUserDefaults( initialSetup: { defaults in defaults.when { $0.bool(forKey: .any) }.thenReturn(false) + defaults.when { $0.object(forKey: .any) }.thenReturn(nil) } ) @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift index e42508adee..de9094033a 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift @@ -68,7 +68,7 @@ class MessageReceiverGroupsSpec: QuickSpec { initialSetup: { network in network .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } - .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1"))) + .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1", uploaded: nil, expires: nil))) network .when { $0.getSwarm(for: .any) } .thenReturn([ @@ -404,7 +404,8 @@ class MessageReceiverGroupsSpec: QuickSpec { displayName: "TestName", profileKey: Data((0.., LibSessionCacheType { mockNoReturn(generics: [T.self], args: [key, value]) } - func updateProfile(displayName: String, displayPictureUrl: String?, displayPictureEncryptionKey: Data?) throws { - try mockThrowingNoReturn(args: [displayName, displayPictureUrl, displayPictureEncryptionKey]) + func updateProfile(displayName: String, displayPictureUrl: String?, displayPictureEncryptionKey: Data?, isReuploadProfilePicture: Bool) throws { + try mockThrowingNoReturn(args: [displayName, displayPictureUrl, displayPictureEncryptionKey, isReuploadProfilePicture]) } func canPerformChange( diff --git a/SessionNetworkingKit/FileServer/FileServer.swift b/SessionNetworkingKit/FileServer/FileServer.swift index 90fdec9e08..da8f5b2501 100644 --- a/SessionNetworkingKit/FileServer/FileServer.swift +++ b/SessionNetworkingKit/FileServer/FileServer.swift @@ -8,7 +8,7 @@ import SessionUtilitiesKit public extension Network { enum FileServer { internal static let fileServer = "http://filev2.getsession.org" - internal static let fileServerPublicKey = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59" + public static let fileServerPublicKey = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59" internal static let legacyFileServer = "http://88.99.175.227" internal static let legacyFileServerPublicKey = "7cb31905b55cd5580c686911debf672577b3fb0bff81df4ce2d5c4cb3a7aaa69" diff --git a/SessionNetworkingKit/FileServer/FileServerAPI.swift b/SessionNetworkingKit/FileServer/FileServerAPI.swift index 4b3304b3dd..40e4747f9f 100644 --- a/SessionNetworkingKit/FileServer/FileServerAPI.swift +++ b/SessionNetworkingKit/FileServer/FileServerAPI.swift @@ -12,11 +12,18 @@ public extension Network.FileServer { requestAndPathBuildTimeout: TimeInterval? = nil, using dependencies: Dependencies ) throws -> Network.PreparedRequest { + var headers: [HTTPHeader: String] = [:] + + if dependencies[feature: .shortenFileTTL] { + headers = [.fileCustomTTL : "60"] + } + return try Network.PreparedRequest( request: Request( endpoint: .file, destination: .serverUpload( server: FileServer.fileServer, + headers: headers, x25519PublicKey: FileServer.fileServerPublicKey, fileName: nil ), @@ -31,6 +38,7 @@ public extension Network.FileServer { static func preparedDownload( url: URL, + serverPubkey: String, using dependencies: Dependencies ) throws -> Network.PreparedRequest { return try Network.PreparedRequest( @@ -38,12 +46,53 @@ public extension Network.FileServer { endpoint: .directUrl(url), destination: .serverDownload( url: url, - x25519PublicKey: FileServer.fileServerPublicKey, + x25519PublicKey: serverPubkey, fileName: nil ) ), responseType: Data.self, - requestTimeout: Network.fileUploadTimeout, + requestTimeout: Network.fileDownloadTimeout, + using: dependencies + ) + } + + static func preparedExtend( + fileId: String, + ttl: TimeInterval, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + return try Network.PreparedRequest( + request: Request( + endpoint: .extend(fileId), + destination: .server( + method: .post, + server: FileServer.fileServer, + headers: [.fileCustomTTL: "\(ttl)"], + x25519PublicKey: FileServer.fileServerPublicKey + ) + ), + responseType: FileUploadResponse.self, + using: dependencies + ) + } + + static func preparedExtend( + url: URL, + ttl: TimeInterval, + serverPubkey: String, + using dependencies: Dependencies + ) throws -> Network.PreparedRequest { + return try Network.PreparedRequest( + request: Request( + endpoint: .extendUrl(url), + destination: .server( + method: .post, + url: url, + headers: [.fileCustomTTL: "\(ttl)"], + x25519PublicKey: serverPubkey + ) + ), + responseType: FileUploadResponse.self, using: dependencies ) } diff --git a/SessionNetworkingKit/FileServer/FileServerEndpoint.swift b/SessionNetworkingKit/FileServer/FileServerEndpoint.swift index 5f23b85624..dd9cc2f7e7 100644 --- a/SessionNetworkingKit/FileServer/FileServerEndpoint.swift +++ b/SessionNetworkingKit/FileServer/FileServerEndpoint.swift @@ -1,4 +1,6 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation @@ -8,6 +10,8 @@ public extension Network.FileServer { case fileIndividual(String) case directUrl(URL) case sessionVersion + case extend(String) + case extendUrl(URL) public static var name: String { "FileServer.Endpoint" } @@ -17,6 +21,8 @@ public extension Network.FileServer { case .fileIndividual(let fileId): return "file/\(fileId)" case .directUrl(let url): return url.path.removingPrefix("/") case .sessionVersion: return "session_version" + case .extend(let fileId): return "/file/\(fileId)/extend" + case .extendUrl(let url): return "\(url.path.removingPrefix("/"))/extend" } } } diff --git a/SessionNetworkingKit/FileServer/Types/HTTPHeader+FileServer.swift b/SessionNetworkingKit/FileServer/Types/HTTPHeader+FileServer.swift new file mode 100644 index 0000000000..5edb20c045 --- /dev/null +++ b/SessionNetworkingKit/FileServer/Types/HTTPHeader+FileServer.swift @@ -0,0 +1,9 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation + +extension HTTPHeader { + static let fileCustomTTL: HTTPHeader = "X-FS-TTL" +} diff --git a/SessionNetworkingKit/LibSession/LibSession+Networking.swift b/SessionNetworkingKit/LibSession/LibSession+Networking.swift index 53fabef8b7..b62547b0fb 100644 --- a/SessionNetworkingKit/LibSession/LibSession+Networking.swift +++ b/SessionNetworkingKit/LibSession/LibSession+Networking.swift @@ -109,16 +109,18 @@ class LibSessionNetwork: NetworkType { .eraseToAnyPublisher() } - func send( - _ body: Data?, - to destination: Network.Destination, + func send( + endpoint: E, + destination: Network.Destination, + body: Data?, requestTimeout: TimeInterval, requestAndPathBuildTimeout: TimeInterval? ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { switch destination { case .server, .serverUpload, .serverDownload, .cached: return sendRequest( - to: destination, + endpoint: endpoint, + destination: destination, body: body, requestTimeout: requestTimeout, requestAndPathBuildTimeout: requestAndPathBuildTimeout @@ -128,7 +130,8 @@ class LibSessionNetwork: NetworkType { guard body != nil else { return Fail(error: NetworkError.invalidPreparedRequest).eraseToAnyPublisher() } return sendRequest( - to: destination, + endpoint: endpoint, + destination: destination, body: body, requestTimeout: requestTimeout, requestAndPathBuildTimeout: requestAndPathBuildTimeout @@ -143,7 +146,8 @@ class LibSessionNetwork: NetworkType { return getSwarm(for: swarmPublicKey) .tryFlatMapWithRandomSnode(retry: retryCount, using: dependencies) { [weak self] snode in try self.validOrThrow().sendRequest( - to: .snode(snode, swarmPublicKey: swarmPublicKey), + endpoint: endpoint, + destination: .snode(snode, swarmPublicKey: swarmPublicKey), body: body, requestTimeout: requestTimeout, requestAndPathBuildTimeout: requestAndPathBuildTimeout @@ -168,7 +172,8 @@ class LibSessionNetwork: NetworkType { else { throw NetworkError.invalidPreparedRequest } return try self.validOrThrow().sendRequest( - to: .snode(snode, swarmPublicKey: swarmPublicKey), + endpoint: endpoint, + destination: .snode(snode, swarmPublicKey: swarmPublicKey), body: updatedBody, requestTimeout: requestTimeout, requestAndPathBuildTimeout: requestAndPathBuildTimeout @@ -227,9 +232,10 @@ class LibSessionNetwork: NetworkType { } // MARK: - Internal Functions - + private func sendRequest( - to destination: Network.Destination, + endpoint: (any EndpointType), + destination: Network.Destination, body: T?, requestTimeout: TimeInterval, requestAndPathBuildTimeout: TimeInterval? @@ -286,8 +292,8 @@ class LibSessionNetwork: NetworkType { ctx ) - case .server: - try destination.withUnsafePointer { cServerDestination in + case .server(let info): + try info.withUnsafePointer(endpoint: endpoint) { cServerDestination in network_send_onion_request_to_server_destination( network, cServerDestination, @@ -305,10 +311,10 @@ class LibSessionNetwork: NetworkType { ) } - case .serverUpload(_, let fileName): + case .serverUpload(let info, let fileName): guard !cPayloadBytes.isEmpty else { throw NetworkError.invalidPreparedRequest } - try destination.withUnsafePointer { cServerDestination in + try info.withUnsafePointer(endpoint: endpoint) { cServerDestination in network_upload_to_server( network, cServerDestination, @@ -327,8 +333,8 @@ class LibSessionNetwork: NetworkType { ) } - case .serverDownload: - try destination.withUnsafePointer { cServerDestination in + case .serverDownload(let info): + try info.withUnsafePointer(endpoint: endpoint) { cServerDestination in network_download_from_server( network, cServerDestination, @@ -566,41 +572,30 @@ extension network_service_node: @retroactive CAccessible, @retroactive CMutable // MARK: - Convenience -private extension Network.Destination { - func withUnsafePointer(_ body: (network_server_destination) throws -> Result) throws -> Result { - let method: HTTPMethod - let url: URL - let headers: [HTTPHeader: String]? - let x25519PublicKey: String - - switch self { - case .snode, .randomSnode, .randomSnodeLatestNetworkTimeTarget, .cached: throw NetworkError.invalidPreparedRequest - case .server(let info), .serverUpload(let info, _), .serverDownload(let info): - method = info.method - url = try info.url - headers = info.headers - x25519PublicKey = String(info.x25519PublicKey - .suffix(64)) // Quick way to drop '05' prefix if present - } +private extension Network.Destination.ServerInfo { + func withUnsafePointer(endpoint: (any EndpointType), _ body: (network_server_destination) throws -> Result) throws -> Result { + let x25519PublicKey: String = String(x25519PublicKey.suffix(64)) // Quick way to drop '05' prefix if present - guard let host: String = url.host else { throw NetworkError.invalidURL } + guard let host: String = self.host else { throw NetworkError.invalidURL } guard x25519PublicKey.count == 64 || x25519PublicKey.count == 66 else { throw LibSessionError.invalidCConversion } - let targetScheme: String = (url.scheme ?? "https") - let endpoint: String = url.path - .appending(url.query.map { value in "?\(value)" } ?? "") - let port: UInt16 = UInt16(url.port ?? (targetScheme == "https" ? 443 : 80)) - let headerKeys: [String] = (headers?.map { $0.key } ?? []) - let headerValues: [String] = (headers?.map { $0.value } ?? []) + let targetScheme: String = (self.scheme ?? "https") + let pathWithParams: String = Network.Destination.generatePathWithParams( + endpoint: endpoint, + queryParameters: queryParameters + ) + let port: UInt16 = UInt16(self.port ?? (targetScheme == "https" ? 443 : 80)) + let headerKeys: [String] = headers.map { $0.key } + let headerValues: [String] = headers.map { $0.value } let headersSize = headerKeys.count // Use scoped closure to avoid manual memory management (crazy nesting but it ends up safer) return try method.rawValue.withCString { cMethodPtr in try targetScheme.withCString { cTargetSchemePtr in try host.withCString { cHostPtr in - try endpoint.withCString { cEndpointPtr in + try pathWithParams.withCString { cPathWithParamsPtr in try x25519PublicKey.withCString { cX25519PubkeyPtr in try headerKeys.withUnsafeCStrArray { headerKeysPtr in try headerValues.withUnsafeCStrArray { headerValuesPtr in @@ -608,7 +603,7 @@ private extension Network.Destination { method: cMethodPtr, protocol: cTargetSchemePtr, host: cHostPtr, - endpoint: cEndpointPtr, + endpoint: cPathWithParamsPtr, port: port, x25519_pubkey: cX25519PubkeyPtr, headers: headerKeysPtr.baseAddress, diff --git a/SessionNetworkingKit/Models/FileUploadResponse.swift b/SessionNetworkingKit/Models/FileUploadResponse.swift index 0f7b328d84..08c101a2ae 100644 --- a/SessionNetworkingKit/Models/FileUploadResponse.swift +++ b/SessionNetworkingKit/Models/FileUploadResponse.swift @@ -4,9 +4,13 @@ import Foundation public struct FileUploadResponse: Codable { public let id: String + public let uploaded: TimeInterval? + public let expires: TimeInterval? - public init(id: String) { + public init(id: String, uploaded: TimeInterval?, expires: TimeInterval?) { self.id = id + self.uploaded = uploaded + self.expires = expires } } @@ -20,12 +24,18 @@ extension FileUploadResponse { // that and convert the value to a string so we can be consistent (SOGS is able to handle // an array of Strings for the `files` param when posting a message just fine) if let intValue: Int64 = try? container.decode(Int64.self, forKey: .id) { - self = FileUploadResponse(id: "\(intValue)") + self = FileUploadResponse( + id: "\(intValue)", + uploaded: try container.decodeIfPresent(TimeInterval.self, forKey: .uploaded), + expires: try container.decodeIfPresent(TimeInterval.self, forKey: .expires) + ) return } self = FileUploadResponse( - id: try container.decode(String.self, forKey: .id) + id: try container.decode(String.self, forKey: .id), + uploaded: try container.decodeIfPresent(TimeInterval.self, forKey: .uploaded), + expires: try container.decodeIfPresent(TimeInterval.self, forKey: .expires) ) } } diff --git a/SessionNetworkingKit/SOGS/SOGSAPI.swift b/SessionNetworkingKit/SOGS/SOGSAPI.swift index 981058ae32..642fe362c4 100644 --- a/SessionNetworkingKit/SOGS/SOGSAPI.swift +++ b/SessionNetworkingKit/SOGS/SOGSAPI.swift @@ -1226,14 +1226,12 @@ public extension Network.SOGS { // MARK: - Authentication fileprivate static func signatureHeaders( - url: URL, method: HTTPMethod, + pathAndParamsString: String, body: Data?, authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> [HTTPHeader: String] { - let path: String = url.path - .appending(url.query.map { value in "?\(value)" }) let method: String = method.rawValue let timestamp: Int = Int(floor(dependencies.dateNow.timeIntervalSince1970)) @@ -1263,7 +1261,7 @@ public extension Network.SOGS { .appending(contentsOf: nonce) .appending(contentsOf: timestampBytes) .appending(contentsOf: method.bytes) - .appending(contentsOf: path.bytes) + .appending(contentsOf: pathAndParamsString.bytes) .appending(contentsOf: bodyHash ?? []) /// Sign the above message @@ -1369,12 +1367,62 @@ public extension Network.SOGS { preparedRequest: Network.PreparedRequest, using dependencies: Dependencies ) throws -> Network.Destination { + /// Handle the cached and invalid cases first (no need to sign them) + switch preparedRequest.destination { + case .cached: return preparedRequest.destination + case .snode, .randomSnode: throw NetworkError.unauthorised + default: break + } + guard let signingData: AdditionalSigningData = preparedRequest.additionalSignatureData as? AdditionalSigningData else { throw SOGSError.signingFailed } - return try preparedRequest.destination - .signed(data: signingData, body: preparedRequest.body, using: dependencies) + let signatureHeaders: [HTTPHeader: String] = try Network.SOGS.signatureHeaders( + method: preparedRequest.method, + pathAndParamsString: preparedRequest.path, + body: preparedRequest.body, + authMethod: signingData.authMethod, + using: dependencies + ) + + switch preparedRequest.destination { + case .server(let info): + return .server( + info: Network.Destination.ServerInfo( + method: info.method, + server: info.server, + queryParameters: info.queryParameters, + headers: info.headers.updated(with: signatureHeaders), + x25519PublicKey: info.x25519PublicKey + ) + ) + + case .serverUpload(let info, let fileName): + return .serverUpload( + info: Network.Destination.ServerInfo( + method: info.method, + server: info.server, + queryParameters: info.queryParameters, + headers: info.headers.updated(with: signatureHeaders), + x25519PublicKey: info.x25519PublicKey + ), + fileName: fileName + ) + + case .serverDownload(let info): + return .serverDownload( + info: Network.Destination.ServerInfo( + method: info.method, + server: info.server, + queryParameters: info.queryParameters, + headers: info.headers.updated(with: signatureHeaders), + x25519PublicKey: info.x25519PublicKey + ) + ) + + case .snode, .randomSnode, .randomSnodeLatestNetworkTimeTarget, .cached: throw SOGSError.signingFailed + } } } @@ -1387,30 +1435,3 @@ private extension Network.SOGS { } } } - -private extension Network.Destination { - func signed(data: Network.SOGS.AdditionalSigningData, body: Data?, using dependencies: Dependencies) throws -> Network.Destination { - switch self { - case .snode, .randomSnode, .randomSnodeLatestNetworkTimeTarget: throw NetworkError.unauthorised - case .cached: return self - case .server(let info): return .server(info: try info.signed(data, body, using: dependencies)) - case .serverUpload(let info, let fileName): - return .serverUpload(info: try info.signed(data, body, using: dependencies), fileName: fileName) - - case .serverDownload(let info): - return .serverDownload(info: try info.signed(data, body, using: dependencies)) - } - } -} - -private extension Network.Destination.ServerInfo { - func signed(_ data: Network.SOGS.AdditionalSigningData, _ body: Data?, using dependencies: Dependencies) throws -> Network.Destination.ServerInfo { - return updated(with: try Network.SOGS.signatureHeaders( - url: url, - method: method, - body: body, - authMethod: data.authMethod, - using: dependencies - )) - } -} diff --git a/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift b/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift index 1d11aa7888..8c24c622d2 100644 --- a/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift +++ b/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift @@ -101,18 +101,24 @@ public extension Network.SessionNetwork { using dependencies: Dependencies ) throws -> Network.Destination { guard - let url: URL = preparedRequest.destination.url, + let url: URL = try? preparedRequest.generateUrl(), case let .server(info) = preparedRequest.destination else { throw NetworkError.invalidPreparedRequest } return .server( - info: info.updated( - with: try signatureHeaders( - url: url, - method: preparedRequest.method, - body: preparedRequest.body, - using: dependencies - ) + info: Network.Destination.ServerInfo( + method: info.method, + server: info.server, + queryParameters: info.queryParameters, + headers: info.headers.updated( + with: try signatureHeaders( + url: url, + method: preparedRequest.method, + body: preparedRequest.body, + using: dependencies + ) + ), + x25519PublicKey: info.x25519PublicKey ) ) } diff --git a/SessionNetworkingKit/Types/Destination.swift b/SessionNetworkingKit/Types/Destination.swift index 9e48a355e4..6c79d3a555 100644 --- a/SessionNetworkingKit/Types/Destination.swift +++ b/SessionNetworkingKit/Types/Destination.swift @@ -8,32 +8,19 @@ import SessionUtilitiesKit public extension Network { enum Destination: Equatable { public struct ServerInfo: Equatable { - private static let invalidServer: String = "INVALID_SERVER" - private static let invalidUrl: URL = URL(fileURLWithPath: "INVALID_URL") - - private let server: String - private let queryParameters: [HTTPQueryParam: String] - private let _url: URL - private let _pathAndParamsString: String - public let method: HTTPMethod + public let server: String + public let queryParameters: [HTTPQueryParam: String] public let headers: [HTTPHeader: String] public let x25519PublicKey: String - public var url: URL { - get throws { - guard _url != ServerInfo.invalidUrl else { throw NetworkError.invalidURL } - - return _url - } - } - public var pathAndParamsString: String { - get throws { - guard _url != ServerInfo.invalidUrl else { throw NetworkError.invalidPreparedRequest } - - return _pathAndParamsString - } - } + // Use iOS URL processing to extract the values from `server` + + public var host: String? { URL(string: server)?.host } + public var scheme: String? { URL(string: server)?.scheme } + public var port: Int? { URL(string: server)?.port } + + // MARK: - Initialization public init( method: HTTPMethod, @@ -42,9 +29,6 @@ public extension Network { headers: [HTTPHeader: String], x25519PublicKey: String ) { - self._url = ServerInfo.invalidUrl - self._pathAndParamsString = "" - self.method = method self.server = server self.queryParameters = queryParameters @@ -56,53 +40,23 @@ public extension Network { method: HTTPMethod, url: URL, server: String?, - pathAndParamsString: String?, queryParameters: [HTTPQueryParam: String] = [:], headers: [HTTPHeader: String], x25519PublicKey: String - ) { - self._url = url - self._pathAndParamsString = (pathAndParamsString ?? url.path) - + ) throws { self.method = method - self.server = { + self.server = try { if let explicitServer: String = server { return explicitServer } if let urlHost: String = url.host { return "\(url.scheme.map { "\($0)://" } ?? "")\(urlHost)" } - return ServerInfo.invalidServer + throw NetworkError.invalidURL }() self.queryParameters = queryParameters self.headers = headers self.x25519PublicKey = x25519PublicKey } - - fileprivate func updated(for endpoint: E) throws -> ServerInfo { - let pathAndParamsString: String = generatePathsAndParams(endpoint: endpoint, queryParameters: queryParameters) - - return ServerInfo( - method: method, - url: try (URL(string: "\(server)\(pathAndParamsString)") ?? { throw NetworkError.invalidURL }()), - server: server, - pathAndParamsString: pathAndParamsString, - queryParameters: queryParameters, - headers: headers, - x25519PublicKey: x25519PublicKey - ) - } - - public func updated(with headers: [HTTPHeader: String]) -> ServerInfo { - return ServerInfo( - method: method, - url: _url, - server: server, - pathAndParamsString: _pathAndParamsString, - queryParameters: queryParameters, - headers: self.headers.updated(with: headers), - x25519PublicKey: x25519PublicKey - ) - } } case snode(LibSession.Snode, swarmPublicKey: String?) @@ -126,11 +80,10 @@ public extension Network { } } - public var url: URL? { + public var server: String? { switch self { - case .server(let info), .serverUpload(let info, _), .serverDownload(let info): return try? info.url - case .snode, .randomSnode, .randomSnodeLatestNetworkTimeTarget: return nil - case .cached: return nil + case .server(let info), .serverUpload(let info, _), .serverDownload(let info): return info.server + default: return nil } } @@ -144,11 +97,12 @@ public extension Network { } } - public var urlPathAndParamsString: String { + public var queryParameters: [HTTPQueryParam: String] { switch self { case .server(let info), .serverUpload(let info, _), .serverDownload(let info): - return ((try? info.pathAndParamsString) ?? "") - default: return "" + return info.queryParameters + + default: return [:] } } @@ -168,6 +122,23 @@ public extension Network { )) } + public static func server( + method: HTTPMethod = .get, + url: URL, + queryParameters: [HTTPQueryParam: String] = [:], + headers: [HTTPHeader: String] = [:], + x25519PublicKey: String + ) throws -> Destination { + return .server(info: try ServerInfo( + method: method, + url: url, + server: nil, + queryParameters: queryParameters, + headers: headers, + x25519PublicKey: x25519PublicKey + )) + } + public static func serverUpload( server: String, queryParameters: [HTTPQueryParam: String] = [:], @@ -194,11 +165,10 @@ public extension Network { x25519PublicKey: String, fileName: String? ) throws -> Destination { - return .serverDownload(info: ServerInfo( + return .serverDownload(info: try ServerInfo( method: .get, url: url, server: nil, - pathAndParamsString: nil, headers: headers, x25519PublicKey: x25519PublicKey )) @@ -225,7 +195,7 @@ public extension Network { // MARK: - Convenience - internal static func generatePathsAndParams(endpoint: E, queryParameters: [HTTPQueryParam: String]) -> String { + internal static func generatePathWithParams(endpoint: E, queryParameters: [HTTPQueryParam: String]) -> String { return [ "/\(endpoint.path)", queryParameters @@ -237,17 +207,6 @@ public extension Network { .joined(separator: "?") } - internal func withGeneratedUrl(for endpoint: E) throws -> Destination { - switch self { - case .server(let info): return .server(info: try info.updated(for: endpoint)) - case .serverUpload(let info, let fileName): - return .serverUpload(info: try info.updated(for: endpoint), fileName: fileName) - case .serverDownload(let info): return .serverDownload(info: try info.updated(for: endpoint)) - - default: return self - } - } - // MARK: - Equatable public static func == (lhs: Destination, rhs: Destination) -> Bool { diff --git a/SessionNetworkingKit/Types/Network.swift b/SessionNetworkingKit/Types/Network.swift index a11afeb54c..f7dd1251ec 100644 --- a/SessionNetworkingKit/Types/Network.swift +++ b/SessionNetworkingKit/Types/Network.swift @@ -42,9 +42,10 @@ public protocol NetworkType { func getSwarm(for swarmPublicKey: String) -> AnyPublisher, Error> func getRandomNodes(count: Int) -> AnyPublisher, Error> - func send( - _ body: Data?, - to destination: Network.Destination, + func send( + endpoint: E, + destination: Network.Destination, + body: Data?, requestTimeout: TimeInterval, requestAndPathBuildTimeout: TimeInterval? ) -> AnyPublisher<(ResponseInfoType, Data?), Error> diff --git a/SessionNetworkingKit/Types/PreparedRequest+Sending.swift b/SessionNetworkingKit/Types/PreparedRequest+Sending.swift index a49bae9cd0..d71a25c8dd 100644 --- a/SessionNetworkingKit/Types/PreparedRequest+Sending.swift +++ b/SessionNetworkingKit/Types/PreparedRequest+Sending.swift @@ -7,7 +7,13 @@ import SessionUtilitiesKit public extension Network.PreparedRequest { func send(using dependencies: Dependencies) -> AnyPublisher<(ResponseInfoType, R), Error> { return dependencies[singleton: .network] - .send(body, to: destination, requestTimeout: requestTimeout, requestAndPathBuildTimeout: requestAndPathBuildTimeout) + .send( + endpoint: endpoint, + destination: destination, + body: body, + requestTimeout: requestTimeout, + requestAndPathBuildTimeout: requestAndPathBuildTimeout + ) .decoded(with: self, using: dependencies) .retry(retryCount, using: dependencies) .handleEvents( diff --git a/SessionNetworkingKit/Types/PreparedRequest.swift b/SessionNetworkingKit/Types/PreparedRequest.swift index 67c1697324..1e94d94b8e 100644 --- a/SessionNetworkingKit/Types/PreparedRequest.swift +++ b/SessionNetworkingKit/Types/PreparedRequest.swift @@ -230,7 +230,10 @@ public extension Network { self.method = request.destination.method self.endpoint = request.endpoint self.endpointName = E.name - self.path = request.destination.urlPathAndParamsString + self.path = Destination.generatePathWithParams( + endpoint: endpoint, + queryParameters: request.destination.queryParameters + ) self.headers = request.destination.headers self.batchEndpoints = batchEndpoints @@ -331,6 +334,26 @@ public extension Network { self.b64 = b64 self.bytes = bytes } + + // MARK: - Functions + + public func generateUrl() throws -> URL { + switch destination { + case .server(let info), .serverUpload(let info, _), .serverDownload(let info): + let pathWithParams: String = Destination.generatePathWithParams( + endpoint: endpoint, + queryParameters: info.queryParameters + ) + + guard let url: URL = URL(string: "\(info.server)\(pathWithParams)") else { + throw NetworkError.invalidURL + } + + return url + + default: throw NetworkError.invalidURL + } + } } } diff --git a/SessionNetworkingKit/Types/Request.swift b/SessionNetworkingKit/Types/Request.swift index f25efccaa9..69d5ee5231 100644 --- a/SessionNetworkingKit/Types/Request.swift +++ b/SessionNetworkingKit/Types/Request.swift @@ -48,7 +48,7 @@ public struct Request { body: T? = nil ) throws { self.endpoint = endpoint - self.destination = try destination.withGeneratedUrl(for: endpoint) + self.destination = destination self.body = body } diff --git a/SessionTests/Database/DatabaseSpec.swift b/SessionTests/Database/DatabaseSpec.swift index c9b824b196..662bb23e3b 100644 --- a/SessionTests/Database/DatabaseSpec.swift +++ b/SessionTests/Database/DatabaseSpec.swift @@ -236,7 +236,8 @@ class DatabaseSpec: QuickSpec { "utilitiesKit.RenameTableSettingToKeyValueStore", "messagingKit.MoveSettingsToLibSession", "messagingKit.RenameAttachments", - "messagingKit.AddProMessageFlag" + "messagingKit.AddProMessageFlag", + "LastProfileUpdateTimestamp" ])) } diff --git a/SessionTests/Onboarding/OnboardingSpec.swift b/SessionTests/Onboarding/OnboardingSpec.swift index f060ee7e98..5b9c4c1787 100644 --- a/SessionTests/Onboarding/OnboardingSpec.swift +++ b/SessionTests/Onboarding/OnboardingSpec.swift @@ -609,13 +609,11 @@ class OnboardingSpec: AsyncSpec { Profile( id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", name: "TestCompleteName", - lastNameUpdate: 1234567890, nickname: nil, displayPictureUrl: nil, displayPictureEncryptionKey: nil, - displayPictureLastUpdated: nil, - blocksCommunityMessageRequests: nil, - lastBlocksCommunityMessageRequests: nil + profileLastUpdated: 1234567890, + blocksCommunityMessageRequests: nil ) ])) } @@ -649,13 +647,11 @@ class OnboardingSpec: AsyncSpec { Profile( id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", name: "TestCompleteName", - lastNameUpdate: nil, nickname: nil, displayPictureUrl: nil, displayPictureEncryptionKey: nil, - displayPictureLastUpdated: nil, - blocksCommunityMessageRequests: nil, - lastBlocksCommunityMessageRequests: nil + profileLastUpdated: nil, + blocksCommunityMessageRequests: nil ) )) } @@ -665,16 +661,66 @@ class OnboardingSpec: AsyncSpec { let result: [ConfigDump]? = mockStorage.read { db in try ConfigDump.fetchAll(db) } - let expectedData: Data? = Data(base64Encoded: "ZDE6IWkxZTE6JDEwNDpkMTojaTFlMTomZDE6K2ktMWUxOm4xNjpUZXN0Q29tcGxldGVOYW1lZTE6PGxsaTBlMzI66hc7V77KivGMNRmnu/acPnoF0cBJ+pVYNB2Ou0iwyWVkZWVlMTo9ZDE6KzA6MTpuMDplZTE6KGxlMTopbGUxOipkZTE6K2RlZQ==") - expect(result).to(equal([ - ConfigDump( - variant: .userProfile, - sessionId: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", - data: (expectedData ?? Data()), - timestampMs: 1234567890000 - ) - ])) + try require(result).to(haveCount(1)) + expect(result![0].variant).to(equal(.userProfile)) + expect(result![0].sessionId).to(equal(SessionId(.standard, hex: TestConstants.publicKey))) + expect(result![0].timestampMs).to(equal(1234567890000)) + + /// The data now contains a `now` timestamp so won't be an exact match anymore, but we _can_ check to ensure + /// the rest of the data matches and that the timestamps are close enough to `now` + /// + /// **Note:** The data contains non-ASCII content so we can't do a straight conversion unfortunately + let resultData: Data = result![0].data + let prefixData: Data = "d1:!i1e1:$144:d1:#i1e1:&d1:+i-1e1:Ti".data(using: .ascii)! + let infixData: Data = "e1:n16:TestCompleteName1:ti".data(using: .ascii)! + let suffixData: Data = "ee1: = resultData.range(of: prefixData), + let infixRange: Range = resultData + .range(of: infixData, in: prefixRange.upperBound.. = resultData + .range(of: suffixData, in: infixRange.upperBound.. = prefixRange.upperBound.. = infixRange.upperBound.. = Dependencies.create( + identifier: "shortenFileTTL" + ) + static let simulateAppReviewLimit: FeatureConfig = Dependencies.create( identifier: "simulateAppReviewLimit" ) diff --git a/SessionUtilitiesKit/Types/UserDefaultsType.swift b/SessionUtilitiesKit/Types/UserDefaultsType.swift index 968b66c2e4..69b31cd0bc 100644 --- a/SessionUtilitiesKit/Types/UserDefaultsType.swift +++ b/SessionUtilitiesKit/Types/UserDefaultsType.swift @@ -183,8 +183,8 @@ public extension UserDefaults.BoolKey { } public extension UserDefaults.DateKey { - /// The date/time when the users profile picture was last uploaded to the server (used to rate-limit re-uploading) - static let lastProfilePictureUpload: UserDefaults.DateKey = "lastProfilePictureUpload" + /// The date/time when we re-uploaded or extended the TTL of the users display picture (used for rate-limiting) + static let lastUserDisplayPictureRefresh: UserDefaults.DateKey = "lastProfilePictureUpload" /// The date/time when any open group last had a successful poll (used as a fallback date/time if the open group hasn't been polled /// this session) From 1c17b9778b47f379a5d413ffd8ab0b10f4753191 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Thu, 25 Sep 2025 11:32:47 +0800 Subject: [PATCH 035/162] Fix to only show approved contacts on global search --- .../Home/GlobalSearch/GlobalSearchViewController.swift | 1 + .../Shared Models/SessionThreadViewModel.swift | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index 5ae0d22528..4de1f19992 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -223,6 +223,7 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI state: .defaultContacts, data: contacts .sorted { lhs, rhs in lhs.displayName.lowercased() < rhs.displayName.lowercased() } + .filter { $0.isContactApproved == true } // Only show default contacts that have been approved via message request .reduce(into: [String: SectionModel]()) { result, next in guard !next.threadIsNoteToSelf else { result[""] = SectionModel( diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index a85fc4b1d2..db5dd6cc41 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -93,6 +93,7 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D case recentReactionEmoji case wasKickedFromGroup case groupIsDestroyed + case isContactApproved } public struct MessageInputState: Equatable { @@ -198,6 +199,9 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D public let wasKickedFromGroup: Bool? public let groupIsDestroyed: Bool? + /// Flag indicates that the contact's message request has been approved + public let isContactApproved: Bool? + // UI specific logic public var displayName: String { @@ -595,6 +599,7 @@ public extension SessionThreadViewModel { self.recentReactionEmoji = nil self.wasKickedFromGroup = false self.groupIsDestroyed = false + self.isContactApproved = false } } @@ -672,7 +677,8 @@ public extension SessionThreadViewModel { currentUserSessionIds: currentUserSessionIds, recentReactionEmoji: recentReactionEmoji, wasKickedFromGroup: wasKickedFromGroup, - groupIsDestroyed: groupIsDestroyed + groupIsDestroyed: groupIsDestroyed, + isContactApproved: isContactApproved ) } } @@ -2067,6 +2073,7 @@ public extension SessionThreadViewModel { \(contact[.rowId]) AS \(ViewModel.Columns.rowId), \(contact[.id]) AS \(ViewModel.Columns.threadId), + \(contact[.isApproved]) AS \(ViewModel.Columns.isContactApproved), \(SessionThread.Variant.contact) AS \(ViewModel.Columns.threadVariant), IFNULL(\(thread[.creationDateTimestamp]), \(currentTimestamp)) AS \(ViewModel.Columns.threadCreationDateTimestamp), '' AS \(ViewModel.Columns.threadMemberNames), From ebb0c633af6db6640a184773b4ce95013b83a550 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Thu, 25 Sep 2025 16:07:21 +0800 Subject: [PATCH 036/162] Fix valid ip address labeled as unknown country path on some locale --- Session/Utilities/IP2Country.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Session/Utilities/IP2Country.swift b/Session/Utilities/IP2Country.swift index 56b9102bba..a5275d714c 100644 --- a/Session/Utilities/IP2Country.swift +++ b/Session/Utilities/IP2Country.swift @@ -217,10 +217,21 @@ fileprivate class IP2Country: IP2CountryCacheType { guard nameCache["\(ip)-\(currentLocale)"] == nil else { return } + /// Code block checks if IP passed is unknown, not supported or blocked guard let ipAsInt: Int64 = IPv4.toInt(ip), - let countryBlockGeonameIdIndex: Int = cache.countryBlocksIPInt.firstIndex(where: { $0 > ipAsInt }).map({ $0 - 1 }), - let localeStartIndex: Int = cache.countryLocationsLocaleCode.firstIndex(where: { $0 == currentLocale }), + let countryBlockGeonameIdIndex: Int = cache.countryBlocksIPInt.firstIndex(where: { $0 > ipAsInt }).map({ $0 - 1 }) + else { return } + + /// Get local index for the current locale + /// When index is not found it should fallback to english + var validLocaleStartIndex: Int? { + cache.countryLocationsLocaleCode.firstIndex(of: currentLocale) + ?? cache.countryLocationsLocaleCode.firstIndex(of: "en") + } + + guard + let localeStartIndex: Int = validLocaleStartIndex, let countryNameIndex: Int = Array(cache.countryLocationsGeonameId[localeStartIndex...]).firstIndex(where: { geonameId in geonameId == cache.countryBlocksGeonameId[countryBlockGeonameIdIndex] }), From f6ed270609b9b1262a92413c81331618ad828bbe Mon Sep 17 00:00:00 2001 From: mikoldin Date: Fri, 26 Sep 2025 11:23:08 +0800 Subject: [PATCH 037/162] Fix log file fails to upload to google drive when shared --- Session/Settings/HelpViewModel.swift | 13 ++++++- SessionUtilitiesKit/General/Logging.swift | 43 +++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/Session/Settings/HelpViewModel.swift b/Session/Settings/HelpViewModel.swift index 4ab69ce0d1..5d527fa3e8 100644 --- a/Session/Settings/HelpViewModel.swift +++ b/Session/Settings/HelpViewModel.swift @@ -227,12 +227,23 @@ class HelpViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTa let viewController: UIViewController = dependencies[singleton: .appContext].frontMostViewController else { return } + let filePath = URL(fileURLWithPath: latestLogFilePath) + + /// To not modify the existing files generated and modified via `Log.logFilePath` + /// only the file to be shared will be sanitized by removing whitespaces + let sanitizedFileURL = Log.prepareFileForSharing(originalURL: filePath) + let showShareSheet: () -> () = { let shareVC = UIActivityViewController( - activityItems: [ URL(fileURLWithPath: latestLogFilePath) ], + activityItems: [ + sanitizedFileURL + ], applicationActivities: nil ) shareVC.completionWithItemsHandler = { _, success, _, _ in + /// Deletes file copy of the log file + Log.deleteItem(at: sanitizedFileURL) + UIActivityViewController.notifyIfNeeded(success, using: dependencies) onShareComplete?() } diff --git a/SessionUtilitiesKit/General/Logging.swift b/SessionUtilitiesKit/General/Logging.swift index 67b529d8ec..401be67f85 100644 --- a/SessionUtilitiesKit/General/Logging.swift +++ b/SessionUtilitiesKit/General/Logging.swift @@ -224,6 +224,49 @@ public enum Log { return tempFilePath } + /// New method used to clean up generated log file. It removes whitespaces that is converted to %20 + /// This path components somehow causes issues when sharing with google drive or dropbox. + /// Creates a copy of the file with the cleaned up name + static public func prepareFileForSharing(originalURL: URL) -> URL { + let fileManager = FileManager.default + + let url = originalURL + let originalFileName = url.deletingPathExtension().lastPathComponent + let fileExtension = url.pathExtension + + // Remove white spaces + let newBaseName = originalFileName.replacingOccurrences(of: " ", with: "_") + + let newFullName: String + if fileExtension.isEmpty { + newFullName = newBaseName + } else { + newFullName = "\(newBaseName).\(fileExtension)" + } + + let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(newFullName) + + do { + try fileManager.copyItem(at: originalURL, to: tempURL) + return tempURL + } catch { + return originalURL + } + } + + @discardableResult + static public func deleteItem(at itemURL: URL) -> Bool { + let fileManager = FileManager.default + + do { + try fileManager.removeItem(at: itemURL) + return true + + } catch { return false } + } + + public static func flush() { DDLog.flushLog() } From c52905a33183973c071cc5269eafdb7f5ed9cc0b Mon Sep 17 00:00:00 2001 From: mikoldin Date: Mon, 29 Sep 2025 09:56:51 +0800 Subject: [PATCH 038/162] Moved file modification handling to `Attachmentmanager` --- Session/Settings/HelpViewModel.swift | 19 ++++---- .../Utilities/AttachmentManager.swift | 29 +++++++++++++ SessionUtilitiesKit/General/Logging.swift | 43 ------------------- 3 files changed, 38 insertions(+), 53 deletions(-) diff --git a/Session/Settings/HelpViewModel.swift b/Session/Settings/HelpViewModel.swift index 5d527fa3e8..4b3ddc2932 100644 --- a/Session/Settings/HelpViewModel.swift +++ b/Session/Settings/HelpViewModel.swift @@ -224,25 +224,24 @@ class HelpViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTa guard let latestLogFilePath: String = await Log.logFilePath(using: dependencies), - let viewController: UIViewController = dependencies[singleton: .appContext].frontMostViewController + let viewController: UIViewController = dependencies[singleton: .appContext].frontMostViewController, + let sanitizedLogFilePath = try? dependencies[singleton: .attachmentManager] + .createTemporaryFileForOpening(filePath: latestLogFilePath) // Creates a copy of the log file with whitespaces on the filename removed else { return } - let filePath = URL(fileURLWithPath: latestLogFilePath) - - /// To not modify the existing files generated and modified via `Log.logFilePath` - /// only the file to be shared will be sanitized by removing whitespaces - let sanitizedFileURL = Log.prepareFileForSharing(originalURL: filePath) - let showShareSheet: () -> () = { let shareVC = UIActivityViewController( activityItems: [ - sanitizedFileURL + URL(fileURLWithPath: sanitizedLogFilePath) ], applicationActivities: nil ) shareVC.completionWithItemsHandler = { _, success, _, _ in - /// Deletes file copy of the log file - Log.deleteItem(at: sanitizedFileURL) + /// Sanity check to make sure we don't unintentionally remove a proper attachment file + if sanitizedLogFilePath.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) { + /// Deletes file copy of the log file + try? dependencies[singleton: .fileManager].removeItem(atPath: sanitizedLogFilePath) + } UIActivityViewController.notifyIfNeeded(success, using: dependencies) onShareComplete?() diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index 394cb1fef0..56c0c6dd2e 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -151,6 +151,35 @@ public final class AttachmentManager: Sendable, ThumbnailManager { return tmpPath } + public func createTemporaryFileForOpening(filePath: String) throws -> String { + /// Ensure the original file exists before generating a path for opening or trying to copy it + guard dependencies[singleton: .fileManager].fileExists(atPath: filePath) else { + throw AttachmentError.invalidData + } + + let originalUrl: URL = URL(fileURLWithPath: filePath) + let fileName: String = originalUrl.deletingPathExtension().lastPathComponent + let fileExtension: String = originalUrl.pathExtension + + /// Removes white spaces on the filename and replaces it with _ + let filenameNoExtension: String = fileName + .replacingOccurrences(of: " ", with: "_") // stringlint:ignore + + let tmpPath: String = URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) + .appendingPathComponent(filenameNoExtension) + .appendingPathExtension(fileExtension) + .path + + /// If the file already exists then we should remove it as it may not be the same file + if dependencies[singleton: .fileManager].fileExists(atPath: tmpPath) { + try dependencies[singleton: .fileManager].removeItem(atPath: tmpPath) + } + + try dependencies[singleton: .fileManager].copyItem(atPath: filePath, toPath: tmpPath) + + return tmpPath + } + public func resetStorage() { try? dependencies[singleton: .fileManager].removeItem( atPath: sharedDataAttachmentsDirPath() diff --git a/SessionUtilitiesKit/General/Logging.swift b/SessionUtilitiesKit/General/Logging.swift index 401be67f85..67b529d8ec 100644 --- a/SessionUtilitiesKit/General/Logging.swift +++ b/SessionUtilitiesKit/General/Logging.swift @@ -224,49 +224,6 @@ public enum Log { return tempFilePath } - /// New method used to clean up generated log file. It removes whitespaces that is converted to %20 - /// This path components somehow causes issues when sharing with google drive or dropbox. - /// Creates a copy of the file with the cleaned up name - static public func prepareFileForSharing(originalURL: URL) -> URL { - let fileManager = FileManager.default - - let url = originalURL - let originalFileName = url.deletingPathExtension().lastPathComponent - let fileExtension = url.pathExtension - - // Remove white spaces - let newBaseName = originalFileName.replacingOccurrences(of: " ", with: "_") - - let newFullName: String - if fileExtension.isEmpty { - newFullName = newBaseName - } else { - newFullName = "\(newBaseName).\(fileExtension)" - } - - let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent(newFullName) - - do { - try fileManager.copyItem(at: originalURL, to: tempURL) - return tempURL - } catch { - return originalURL - } - } - - @discardableResult - static public func deleteItem(at itemURL: URL) -> Bool { - let fileManager = FileManager.default - - do { - try fileManager.removeItem(at: itemURL) - return true - - } catch { return false } - } - - public static func flush() { DDLog.flushLog() } From 81780204e6a2f4182dc0fdeba407eca505ba7af2 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Mon, 29 Sep 2025 10:35:04 +0800 Subject: [PATCH 039/162] Updated `numColumnsBeforeProfiles` count for the new column --- SessionMessagingKit/Shared Models/SessionThreadViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index db5dd6cc41..3bd2545e82 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -2066,7 +2066,7 @@ public extension SessionThreadViewModel { /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before /// the `contactProfile` entry below otherwise the query will fail to parse and might throw - let numColumnsBeforeProfiles: Int = 8 + let numColumnsBeforeProfiles: Int = 9 let request: SQLRequest = """ SELECT 100 AS \(Column.rank), From 675910e334a57577cb28bcddd489e7de21fc7ec8 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Mon, 29 Sep 2025 13:16:06 +0800 Subject: [PATCH 040/162] Updated disabled conversation input buttons colors --- Session/Conversations/Input View/InputView.swift | 5 +++++ SessionUIKit/Components/Input View/InputViewButton.swift | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index b75531a7e0..345957c9f5 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -475,8 +475,13 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M UIView.animate(withDuration: 0.3) { [weak self] in self?.bottomStackView?.arrangedSubviews.forEach { $0.alpha = (updatedInputState.allowedInputTypes != .none ? 1 : 0) } + self?.attachmentsButton.alpha = (updatedInputState.allowedInputTypes == .all ? 1 : 0.4) + self?.attachmentsButton.mainButton.updateAppearance(isEnabled: updatedInputState.allowedInputTypes == .all) + self?.voiceMessageButton.alpha = (updatedInputState.allowedInputTypes == .all ? 1 : 0.4) + self?.voiceMessageButton.updateAppearance(isEnabled: updatedInputState.allowedInputTypes == .all) + self?.disabledInputLabel.alpha = (updatedInputState.allowedInputTypes != .none ? 0 : Values.mediumOpacity) } } diff --git a/SessionUIKit/Components/Input View/InputViewButton.swift b/SessionUIKit/Components/Input View/InputViewButton.swift index 184d527fa4..a63fba1213 100644 --- a/SessionUIKit/Components/Input View/InputViewButton.swift +++ b/SessionUIKit/Components/Input View/InputViewButton.swift @@ -136,6 +136,11 @@ public final class InputViewButton: UIView { ) } + public func updateAppearance(isEnabled: Bool) { + iconImageView.themeTintColor = isEnabled ? .textPrimary : .disabled + backgroundView.themeBackgroundColor = isEnabled ? .inputButton_background : .disabled + } + // MARK: - Interaction // We want to detect both taps and long presses From b1257b2e0e99a7357a815d0d1ff2db166442f7e8 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Mon, 29 Sep 2025 13:38:42 +0800 Subject: [PATCH 041/162] Centered play button for audio type attachment --- .../Attachment Approval/AttachmentPrepViewController.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift index 0844c1ee20..286cf5723e 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift @@ -220,11 +220,13 @@ public class AttachmentPrepViewController: OWSViewController { if attachment.isVideo || attachment.isAudio { let playButtonSize: CGFloat = Values.scaleFromIPhone5(70) + let playButtonVerticalOffset = attachment.isAudio ? 0 : -AttachmentPrepViewController.verticalCenterOffset + NSLayoutConstraint.activate([ playButton.centerXAnchor.constraint(equalTo: contentContainerView.centerXAnchor), playButton.centerYAnchor.constraint( equalTo: contentContainerView.centerYAnchor, - constant: -AttachmentPrepViewController.verticalCenterOffset + constant: playButtonVerticalOffset ), playButton.widthAnchor.constraint(equalToConstant: playButtonSize), playButton.heightAnchor.constraint(equalToConstant: playButtonSize), From 76bc2f5fe05c485632b844bd8d056993c4e26f3c Mon Sep 17 00:00:00 2001 From: mikoldin Date: Mon, 29 Sep 2025 16:11:40 +0800 Subject: [PATCH 042/162] Fixes inputAccessoryView disappears when dismissing emoji search modal --- Session/Conversations/ConversationVC+Interaction.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index ad0541a19d..c6ec995d67 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1064,6 +1064,14 @@ extension ConversationVC: } return } + + if !self.isFirstResponder { + // Force this object to become the First Responder. This is necessary + // to trigger the display of its associated inputAccessoryView + // and/or inputView. + self.becomeFirstResponder() + } + UIView.animate(withDuration: 0.25, animations: { self.inputAccessoryView?.isHidden = false self.inputAccessoryView?.alpha = 1 From 50aee063f21e25e300cde9922d4f89b7d013e666 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Tue, 30 Sep 2025 12:00:32 +0800 Subject: [PATCH 043/162] Added whitespace removal on other file name creation --- .../MediaPageViewController.swift | 122 +++++++++--------- .../Attachments/SignalAttachment.swift | 9 ++ .../Utilities/AttachmentManager.swift | 8 +- SessionUtilitiesKit/General/Logging.swift | 3 +- 4 files changed, 78 insertions(+), 64 deletions(-) diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 28ab4d2426..4166834227 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -498,71 +498,75 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou Log.error("[MediaPageViewController] currentViewController was unexpectedly nil") return } - guard - let path: String = try? viewModel.dependencies[singleton: .attachmentManager].createTemporaryFileForOpening( - downloadUrl: currentViewController.galleryItem.attachment.downloadUrl, - mimeType: currentViewController.galleryItem.attachment.contentType, - sourceFilename: currentViewController.galleryItem.attachment.sourceFilename - ), - viewModel.dependencies[singleton: .fileManager].fileExists(atPath: path) - else { return } - let shareVC = UIActivityViewController(activityItems: [ URL(fileURLWithPath: path) ], applicationActivities: nil) - - if UIDevice.current.isIPad { - shareVC.excludedActivityTypes = [] - shareVC.popoverPresentationController?.permittedArrowDirections = [] - shareVC.popoverPresentationController?.sourceView = self.view - shareVC.popoverPresentationController?.sourceRect = self.view.bounds - } - - shareVC.completionWithItemsHandler = { [dependencies = viewModel.dependencies] activityType, completed, returnedItems, activityError in - if let activityError = activityError { - Log.error("[MediaPageViewController] Failed to share with activityError: \(activityError)") - } - else if completed { - Log.info("[MediaPageViewController] Did share with activityType: \(activityType.debugDescription)") - } - - /// Sanity check to make sure we don't unintentionally remove a proper attachment file - if path.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) { - try? dependencies[singleton: .fileManager].removeItem(atPath: path) - } - - /// Notify any conversations to update if a message was sent via Session - UIActivityViewController.notifyIfNeeded(completed, using: dependencies) - + DispatchQueue.global(qos: .userInitiated).async { [weak self, dependencies = viewModel.dependencies] in guard - let activityType = activityType, - activityType == .saveToCameraRoll, - currentViewController.galleryItem.interactionVariant == .standardIncoming, - self.viewModel.threadVariant == .contact + let path: String = try? dependencies[singleton: .attachmentManager].createTemporaryFileForOpening( + downloadUrl: currentViewController.galleryItem.attachment.downloadUrl, + mimeType: currentViewController.galleryItem.attachment.contentType, + sourceFilename: currentViewController.galleryItem.attachment.sourceFilename + ), + dependencies[singleton: .fileManager].fileExists(atPath: path) else { return } - let threadId: String = self.viewModel.threadId - let threadVariant: SessionThread.Variant = self.viewModel.threadVariant - - dependencies[singleton: .storage].writeAsync { db in - try MessageSender.send( - db, - message: DataExtractionNotification( - kind: .mediaSaved( - timestamp: UInt64(currentViewController.galleryItem.interactionTimestampMs) - ), - sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ) - .with(DisappearingMessagesConfiguration - .fetchOne(db, id: threadId)? - .forcedWithDisappearAfterReadIfNeeded() - ), - interactionId: nil, // Show no interaction for the current user - threadId: threadId, - threadVariant: threadVariant, - using: dependencies - ) + DispatchQueue.main.async { [weak self] in + let shareVC = UIActivityViewController(activityItems: [ URL(fileURLWithPath: path) ], applicationActivities: nil) + + if UIDevice.current.isIPad, let view: UIView = self?.view { + shareVC.excludedActivityTypes = [] + shareVC.popoverPresentationController?.permittedArrowDirections = [] + shareVC.popoverPresentationController?.sourceView = view + shareVC.popoverPresentationController?.sourceRect = view.bounds + } + + shareVC.completionWithItemsHandler = { activityType, completed, returnedItems, activityError in + if let activityError = activityError { + Log.error("[MediaPageViewController] Failed to share with activityError: \(activityError)") + } + else if completed { + Log.info("[MediaPageViewController] Did share with activityType: \(activityType.debugDescription)") + } + + /// Sanity check to make sure we don't unintentionally remove a proper attachment file + if path.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) { + try? dependencies[singleton: .fileManager].removeItem(atPath: path) + } + + /// Notify any conversations to update if a message was sent via Session + UIActivityViewController.notifyIfNeeded(completed, using: dependencies) + + guard + let activityType = activityType, + activityType == .saveToCameraRoll, + currentViewController.galleryItem.interactionVariant == .standardIncoming, + self?.viewModel.threadVariant == .contact, + let threadId: String = self?.viewModel.threadId, + let threadVariant: SessionThread.Variant = self?.viewModel.threadVariant + else { return } + + dependencies[singleton: .storage].writeAsync { db in + try MessageSender.send( + db, + message: DataExtractionNotification( + kind: .mediaSaved( + timestamp: UInt64(currentViewController.galleryItem.interactionTimestampMs) + ), + sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ) + .with(DisappearingMessagesConfiguration + .fetchOne(db, id: threadId)? + .forcedWithDisappearAfterReadIfNeeded() + ), + interactionId: nil, // Show no interaction for the current user + threadId: threadId, + threadVariant: threadVariant, + using: dependencies + ) + } + } + self?.present(shareVC, animated: true, completion: nil) } } - self.present(shareVC, animated: true, completion: nil) } @objc public func didPressDelete(_ sender: Any) { diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift b/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift index adbc379e5c..a0db18cc7b 100644 --- a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift +++ b/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift @@ -37,6 +37,15 @@ extension String { } return result } + + // Remove whitesspaces and replace with "_" + public var replacingWhitespacesWithUnderscores: String { + let sanitizedFileNameComponents = components(separatedBy: .whitespaces) + + return sanitizedFileNameComponents + .filter { !$0.isEmpty } // Remove empty strings if multiple spaces were adjacent + .joined(separator: "_") // stringlint:ignore + } } extension SignalAttachmentError: LocalizedError { diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index 56c0c6dd2e..fe72bb99f4 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -122,9 +122,11 @@ public final class AttachmentManager: Sendable, ThumbnailManager { ) }() } + + let sanitizedFileName = targetFilenameNoExtension.replacingWhitespacesWithUnderscores return URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) - .appendingPathComponent(targetFilenameNoExtension) + .appendingPathComponent(sanitizedFileName) .appendingPathExtension(finalExtension) .path } @@ -162,8 +164,8 @@ public final class AttachmentManager: Sendable, ThumbnailManager { let fileExtension: String = originalUrl.pathExtension /// Removes white spaces on the filename and replaces it with _ - let filenameNoExtension: String = fileName - .replacingOccurrences(of: " ", with: "_") // stringlint:ignore + let filenameNoExtension = fileName + .replacingWhitespacesWithUnderscores let tmpPath: String = URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) .appendingPathComponent(filenameNoExtension) diff --git a/SessionUtilitiesKit/General/Logging.swift b/SessionUtilitiesKit/General/Logging.swift index 67b529d8ec..64dc28534c 100644 --- a/SessionUtilitiesKit/General/Logging.swift +++ b/SessionUtilitiesKit/General/Logging.swift @@ -192,8 +192,7 @@ public enum Log { else { return logFiles[0] } // The file is too small so lets create a temp file to share instead - let tempDirectory: String = NSTemporaryDirectory() - let tempFilePath: String = URL(fileURLWithPath: tempDirectory) + let tempFilePath: String = URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) .appendingPathComponent(URL(fileURLWithPath: logFiles[1]).lastPathComponent) .path From 4672b4accbd3894cbe0707a23d28a76040f38ace Mon Sep 17 00:00:00 2001 From: mikoldin Date: Tue, 30 Sep 2025 14:56:41 +0800 Subject: [PATCH 044/162] Added missing accessibility identifier for Missed call dialogs --- Session/Calls/Views & Modals/CallMissedTipsModal.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Session/Calls/Views & Modals/CallMissedTipsModal.swift b/Session/Calls/Views & Modals/CallMissedTipsModal.swift index eb702f6c4a..5f35b3d31b 100644 --- a/Session/Calls/Views & Modals/CallMissedTipsModal.swift +++ b/Session/Calls/Views & Modals/CallMissedTipsModal.swift @@ -28,6 +28,7 @@ final class CallMissedTipsModal: Modal { result.text = "callsMissedCallFrom" .put(key: "name", value: caller) .localized() + result.accessibilityIdentifier = "Modal heading" result.themeTextColor = .textPrimary result.textAlignment = .center @@ -44,6 +45,7 @@ final class CallMissedTipsModal: Modal { result.themeAttributedText = "callsYouMissedCallPermissions" .put(key: "name", value: caller) .localizedFormatted(in: result) + result.accessibilityIdentifier = "Modal description" return result }() @@ -102,6 +104,7 @@ final class CallMissedTipsModal: Modal { override func populateContentView() { cancelButton.setTitle("sessionSettings".localized(), for: .normal) + cancelButton.accessibilityIdentifier = "Modal button" contentView.addSubview(mainStackView) tipsIconContainerView.addSubview(tipsIconImageView) From 00b5df0de7cad95c95785dde186bb8611966c440 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Wed, 1 Oct 2025 08:49:58 +0800 Subject: [PATCH 045/162] Removed generic cancel button identifier --- Session/Calls/Views & Modals/CallMissedTipsModal.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Session/Calls/Views & Modals/CallMissedTipsModal.swift b/Session/Calls/Views & Modals/CallMissedTipsModal.swift index 5f35b3d31b..d4b17a7eaf 100644 --- a/Session/Calls/Views & Modals/CallMissedTipsModal.swift +++ b/Session/Calls/Views & Modals/CallMissedTipsModal.swift @@ -104,7 +104,6 @@ final class CallMissedTipsModal: Modal { override func populateContentView() { cancelButton.setTitle("sessionSettings".localized(), for: .normal) - cancelButton.accessibilityIdentifier = "Modal button" contentView.addSubview(mainStackView) tipsIconContainerView.addSubview(tipsIconImageView) From 57e779ce142b1ce113554dd64a54d9728f821cf3 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Wed, 1 Oct 2025 15:16:10 +1000 Subject: [PATCH 046/162] fix some minor ui issues --- Session/Conversations/Settings/ThreadSettingsViewModel.swift | 2 +- Session/Media Viewing & Editing/MessageInfoScreen.swift | 4 ++-- SessionUIKit/Components/ProfilePictureView.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index a0dc615d6d..0ac95a1262 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -144,7 +144,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi image: Lucide.image(icon: .pencil, size: 22)? .withRenderingMode(.alwaysTemplate), style: .plain, - accessibilityIdentifier: "Edit Nick Name", + accessibilityIdentifier: "Edit Nickname", action: { [weak self] in guard let info: ConfirmationModal.Info = self?.updateDisplayNameModal( diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 4f1df83cbb..647520add5 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -19,7 +19,7 @@ struct MessageInfoScreen: View { var actions: [ContextMenuVC.Action] var messageViewModel: MessageViewModel let threadCanWrite: Bool - let onStartThread: (() -> Void)? + let onStartThread: (@MainActor () -> Void)? let dependencies: Dependencies let isMessageFailed: Bool let isCurrentUser: Bool @@ -31,7 +31,7 @@ struct MessageInfoScreen: View { actions: [ContextMenuVC.Action], messageViewModel: MessageViewModel, threadCanWrite: Bool, - onStartThread: (() -> Void)?, + onStartThread: (@MainActor () -> Void)?, using dependencies: Dependencies ) { self.actions = actions diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index 466b440b0c..2ce35aee44 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -99,7 +99,7 @@ public final class ProfilePictureView: UIView { func iconVerticalInset(for size: Size) -> CGFloat { switch (self, size) { - case (.crown, .navigation), (.crown, .message): return 1 + case (.crown, .navigation), (.crown, .message): return 2 case (.crown, .list): return 3 case (.crown, .hero): return 5 From 270a5dd9801d1acf97ab87eb4e9dfac18711de5f Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Wed, 1 Oct 2025 16:28:55 +1000 Subject: [PATCH 047/162] fix: improve `isAnimated` --- Session/Media Viewing & Editing/MessageInfoScreen.swift | 2 +- Session/Settings/SettingsViewModel.swift | 2 +- SessionUIKit/Types/ImageDataManager.swift | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 647520add5..e65c79ed58 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -509,7 +509,7 @@ struct MessageInfoScreen: View { proCTAVariant = (proFeatures.count > 1 ? .generic : .longerMessages) } - if ImageDataManager.isAnimatedImage(profileInfo?.source?.imageData) { + if ImageDataManager.isAnimatedImage(profileInfo?.source) { proFeatures.append("proAnimatedDisplayPictureFeature".localized()) proCTAVariant = (proFeatures.count > 1 ? .generic : .animatedProfileImage(isSessionProActivated: false)) } diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 7ccb381921..276b0ae9f9 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -777,7 +777,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl case .image(.some(let source), _, _, _, _, _, _, _, _): guard let imageData: Data = source.imageData else { return } - let isAnimatedImage: Bool = ImageDataManager.isAnimatedImage(imageData) + let isAnimatedImage: Bool = ImageDataManager.isAnimatedImage(source) guard ( !isAnimatedImage || dependencies[cache: .libSession].isSessionPro || diff --git a/SessionUIKit/Types/ImageDataManager.swift b/SessionUIKit/Types/ImageDataManager.swift index 9e0dd29674..b0f6a3a094 100644 --- a/SessionUIKit/Types/ImageDataManager.swift +++ b/SessionUIKit/Types/ImageDataManager.swift @@ -605,7 +605,7 @@ public extension ImageDataManager { } } - fileprivate func createImageSource(options: [CFString: Any]? = nil) -> CGImageSource? { + public func createImageSource(options: [CFString: Any]? = nil) -> CGImageSource? { let finalOptions: CFDictionary = ( options ?? [ @@ -718,8 +718,8 @@ public extension ImageDataManager { // MARK: - ImageDataManager.isAnimatedImage public extension ImageDataManager { - static func isAnimatedImage(_ imageData: Data?) -> Bool { - guard let data: Data = imageData, let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else { + static func isAnimatedImage(_ dataSource: DataSource?) -> Bool { + guard let dataSource: DataSource = dataSource, let imageSource = dataSource.createImageSource() else { return false } let frameCount = CGImageSourceGetCount(imageSource) From 169438eea40604a006ae5506e8c95ce6fddc7c00 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 2 Oct 2025 10:55:58 +1000 Subject: [PATCH 048/162] Removed unused attachment caption functionality --- Session.xcodeproj/project.pbxproj | 4 - .../MediaPageViewController.swift | 87 +------- Session/Shared/CaptionView.swift | 193 ------------------ .../_045_LastProfileUpdateTimestamp.swift | 2 +- .../Database/Models/Attachment.swift | 15 +- .../AttachmentUploader.swift | 1 - .../Attachments/SignalAttachment.swift | 2 - .../AttachmentItemCollection.swift | 6 - 8 files changed, 6 insertions(+), 304 deletions(-) delete mode 100644 Session/Shared/CaptionView.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 19e494cb89..353b7490e0 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -72,7 +72,6 @@ 4C4AE6A1224AF35700D4AF6F /* SendMediaNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4AE69F224AF21900D4AF6F /* SendMediaNavigationController.swift */; }; 4C63CC00210A620B003AE45C /* SignalTSan.supp in Resources */ = {isa = PBXBuildFile; fileRef = 4C63CBFF210A620B003AE45C /* SignalTSan.supp */; }; 4C6F527C20FFE8400097DEEE /* SignalUBSan.supp in Resources */ = {isa = PBXBuildFile; fileRef = 4C6F527B20FFE8400097DEEE /* SignalUBSan.supp */; }; - 4CA46F4C219CCC630038ABDE /* CaptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA46F4B219CCC630038ABDE /* CaptionView.swift */; }; 4CA485BB2232339F004B9E7D /* PhotoCaptureViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA485BA2232339F004B9E7D /* PhotoCaptureViewController.swift */; }; 4CC613362227A00400E21A3A /* ConversationSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC613352227A00400E21A3A /* ConversationSearch.swift */; }; 70377AAB1918450100CAF501 /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70377AAA1918450100CAF501 /* MobileCoreServices.framework */; }; @@ -1445,7 +1444,6 @@ 4C4AE69F224AF21900D4AF6F /* SendMediaNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMediaNavigationController.swift; sourceTree = ""; }; 4C63CBFF210A620B003AE45C /* SignalTSan.supp */ = {isa = PBXFileReference; lastKnownFileType = text; path = SignalTSan.supp; sourceTree = ""; }; 4C6F527B20FFE8400097DEEE /* SignalUBSan.supp */ = {isa = PBXFileReference; lastKnownFileType = text; path = SignalUBSan.supp; sourceTree = ""; }; - 4CA46F4B219CCC630038ABDE /* CaptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptionView.swift; sourceTree = ""; }; 4CA485BA2232339F004B9E7D /* PhotoCaptureViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoCaptureViewController.swift; sourceTree = ""; }; 4CC613352227A00400E21A3A /* ConversationSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSearch.swift; sourceTree = ""; }; 70377AAA1918450100CAF501 /* MobileCoreServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MobileCoreServices.framework; path = System/Library/Frameworks/MobileCoreServices.framework; sourceTree = SDKROOT; }; @@ -3105,7 +3103,6 @@ FD71164028E2C83000B47552 /* Views */, C354E75923FE2A7600CE22E3 /* BaseVC.swift */, FDE754E42C9BB012002A2623 /* BezierPathView.swift */, - 4CA46F4B219CCC630038ABDE /* CaptionView.swift */, B8BB82AA238F669C00BA5194 /* FullConversationCell.swift */, 7B81FB582AB01AA8002FB267 /* LoadingIndicatorView.swift */, 4542DF53208D40AC007B4E76 /* LoadingViewController.swift */, @@ -7029,7 +7026,6 @@ 7B81FB5A2AB01B17002FB267 /* LoadingIndicatorView.swift in Sources */, 7B9F71D42852EEE2006DFE7B /* Emoji+Name.swift in Sources */, FDE71B0D2E793B250023F5F9 /* DeveloperSettingsProViewModel.swift in Sources */, - 4CA46F4C219CCC630038ABDE /* CaptionView.swift in Sources */, C328253025CA55370062D0A7 /* ContextMenuWindow.swift in Sources */, 9479981C2DD44ADC008F5CD5 /* ThreadNotificationSettingsViewModel.swift in Sources */, 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */, diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 28ab4d2426..f32a1f7642 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -48,7 +48,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou startObservingChanges() updateTitle(item: item) - updateCaption(item: item) setViewControllers([galleryPage], direction: direction, animated: isAnimated) { [weak galleryPage] _ in galleryPage?.parentDidAppear() // Trigger any custom appearance animations } @@ -122,7 +121,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou return result }() - let captionContainerView: CaptionContainerView = CaptionContainerView() var galleryRailView: GalleryRailView = GalleryRailView() var pagerScrollView: UIScrollView! @@ -167,25 +165,18 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou // e.g. when getting to media details via message details screen, there's only // one "Page" so the bounce doesn't make sense. pagerScrollView.isScrollEnabled = sliderEnabled - pagerScrollViewContentOffsetObservation = pagerScrollView.observe(\.contentOffset, options: [.new]) { [weak self] _, change in - guard let strongSelf = self else { return } - strongSelf.pagerScrollView(strongSelf.pagerScrollView, contentOffsetDidChange: change) - } - + // Views pagerScrollView.themeBackgroundColor = .newConversation_background view.themeBackgroundColor = .newConversation_background - captionContainerView.delegate = self - updateCaptionContainerVisibility() - galleryRailView.isHidden = true galleryRailView.delegate = self galleryRailView.set(.height, to: 72) footerBar.set(.height, to: 44) - let bottomStack = UIStackView(arrangedSubviews: [captionContainerView, galleryRailView, footerBar]) + let bottomStack = UIStackView(arrangedSubviews: [galleryRailView, footerBar]) bottomStack.axis = .vertical bottomStack.isLayoutMarginsRelativeArrangement = true bottomContainer.addSubview(bottomStack) @@ -200,7 +191,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou galleryRailBlockingView.pin(.bottom, to: .bottom, of: bottomStack) updateTitle(item: currentItem) - updateCaption(item: currentItem) updateMediaRail(item: currentItem) updateFooterBarButtonItems() @@ -253,23 +243,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou self.cachedPages = [:] } - // MARK: KVO - - var pagerScrollViewContentOffsetObservation: NSKeyValueObservation? - func pagerScrollView(_ pagerScrollView: UIScrollView, contentOffsetDidChange change: NSKeyValueObservedChange) { - guard let newValue = change.newValue else { - Log.error("[MediaPageViewController] newValue was unexpectedly nil") - return - } - - let width = pagerScrollView.frame.size.width - guard width > 0 else { - return - } - let ratioComplete = abs((newValue.x - width) / width) - captionContainerView.updatePagerTransition(ratioComplete: ratioComplete) - } - // MARK: View Helpers public func willBePresentedAgain() { @@ -619,25 +592,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou // MARK: UIPageViewControllerDelegate - var pendingViewController: MediaDetailViewController? - public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { - - Log.assert(pendingViewControllers.count == 1) - pendingViewControllers.forEach { viewController in - guard let pendingViewController = viewController as? MediaDetailViewController else { - Log.error("[MediaPageViewController] Unexpected mediaDetailViewController: \(viewController)") - return - } - self.pendingViewController = pendingViewController - - if let pendingCaptionText = pendingViewController.galleryItem.captionForDisplay, pendingCaptionText.count > 0 { - self.captionContainerView.pendingText = pendingCaptionText - } else { - self.captionContainerView.pendingText = nil - } - } - } - public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted: Bool) { Log.assert(previousViewControllers.count == 1) @@ -649,21 +603,11 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou // Do any cleanup for the no-longer visible view controller if transitionCompleted { - pendingViewController = nil - - // This can happen when trying to page past the last (or first) view controller - // In that case, we don't want to change the captionView. - if (previousPage != currentViewController) { - captionContainerView.completePagerTransition() - } - currentViewController?.parentDidAppear() // Trigger any custom appearance animations updateTitle(item: currentItem) updateMediaRail(item: currentItem) previousPage.zoomOut(animated: false) updateFooterBarButtonItems() - } else { - captionContainerView.pendingText = nil } } } @@ -859,10 +803,6 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou return containerView }() - private func updateCaption(item: MediaGalleryViewModel.Item?) { - captionContainerView.currentText = item?.captionForDisplay - } - private func updateTitle(item: MediaGalleryViewModel.Item?) { guard let targetItem: MediaGalleryViewModel.Item = item else { return } let threadVariant: SessionThread.Variant = self.viewModel.threadVariant @@ -952,29 +892,6 @@ extension MediaPageViewController: GalleryRailViewDelegate { } } -extension MediaPageViewController: CaptionContainerViewDelegate { - - func captionContainerViewDidUpdateText(_ captionContainerView: CaptionContainerView) { - updateCaptionContainerVisibility() - } - - // MARK: Helpers - - func updateCaptionContainerVisibility() { - if let currentText = captionContainerView.currentText, currentText.count > 0 { - captionContainerView.isHidden = false - return - } - - if let pendingText = captionContainerView.pendingText, pendingText.count > 0 { - captionContainerView.isHidden = false - return - } - - captionContainerView.isHidden = true - } -} - // MARK: - UIViewControllerTransitioningDelegate extension MediaPageViewController: UIViewControllerTransitioningDelegate { diff --git a/Session/Shared/CaptionView.swift b/Session/Shared/CaptionView.swift deleted file mode 100644 index bc1d4a2011..0000000000 --- a/Session/Shared/CaptionView.swift +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. - -import UIKit -import SessionUIKit -import SessionUtilitiesKit - -public protocol CaptionContainerViewDelegate: AnyObject { - func captionContainerViewDidUpdateText(_ captionContainerView: CaptionContainerView) -} - -public class CaptionContainerView: UIView { - - weak var delegate: CaptionContainerViewDelegate? - - var currentText: String? { - get { return currentCaptionView.text } - set { - currentCaptionView.text = newValue - delegate?.captionContainerViewDidUpdateText(self) - } - } - - var pendingText: String? { - get { return pendingCaptionView.text } - set { - pendingCaptionView.text = newValue - delegate?.captionContainerViewDidUpdateText(self) - } - } - - func updatePagerTransition(ratioComplete: CGFloat) { - if let currentText = self.currentText, currentText.count > 0 { - currentCaptionView.alpha = 1 - ratioComplete - } else { - currentCaptionView.alpha = 0 - } - - if let pendingText = self.pendingText, pendingText.count > 0 { - pendingCaptionView.alpha = ratioComplete - } else { - pendingCaptionView.alpha = 0 - } - } - - func completePagerTransition() { - updatePagerTransition(ratioComplete: 1) - - // promote "pending" to "current" caption view. - let oldCaptionView = self.currentCaptionView - self.currentCaptionView = self.pendingCaptionView - self.pendingCaptionView = oldCaptionView - self.pendingText = nil - self.currentCaptionView.alpha = 1 - self.pendingCaptionView.alpha = 0 - } - - // MARK: Initializers - - override init(frame: CGRect) { - super.init(frame: frame) - - setContentHugging(to: .required) - setCompressionResistance(to: .required) - - addSubview(currentCaptionView) - currentCaptionView.pin(.top, greaterThanOrEqualTo: .top, of: self) - currentCaptionView.pin(.leading, to: .leading, of: self) - currentCaptionView.pin(.trailing, to: .trailing, of: self) - currentCaptionView.pin(.bottom, to: .bottom, of: self) - - pendingCaptionView.alpha = 0 - addSubview(pendingCaptionView) - pendingCaptionView.pin(.top, greaterThanOrEqualTo: .top, of: self) - pendingCaptionView.pin(.leading, to: .leading, of: self) - pendingCaptionView.pin(.trailing, to: .trailing, of: self) - pendingCaptionView.pin(.bottom, to: .bottom, of: self) - } - - public required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: Subviews - - private var pendingCaptionView: CaptionView = CaptionView() - private var currentCaptionView: CaptionView = CaptionView() -} - -private class CaptionView: UIView { - - var text: String? { - get { return textView.text } - - set { - if let captionText = newValue, captionText.count > 0 { - textView.text = captionText - } else { - textView.text = nil - } - } - } - - // MARK: Subviews - - let textView: CaptionTextView = { - let textView = CaptionTextView() - - textView.font = UIFont.preferredFont(forTextStyle: .body) - textView.themeTextColor = .textPrimary - textView.themeBackgroundColor = .clear - textView.isEditable = false - textView.isSelectable = false - - return textView - }() - - let scrollFadeView: GradientView = { - let result: GradientView = GradientView() - result.themeBackgroundGradient = [ - .clear, - .black - ] - - return result - }() - - // MARK: Initializers - - override init(frame: CGRect) { - super.init(frame: frame) - - addSubview(textView) - textView.pin(toMarginsOf: self) - - addSubview(scrollFadeView) - scrollFadeView.pin(.leading, to: .leading, of: self) - scrollFadeView.pin(.trailing, to: .trailing, of: self) - scrollFadeView.pin(.bottom, to: .bottom, of: self) - scrollFadeView.set(.height, to: 20) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: UIView overrides - - override func layoutSubviews() { - super.layoutSubviews() - scrollFadeView.isHidden = !textView.doesContentNeedScroll - } - - // MARK: - - - class CaptionTextView: UITextView { - var kMaxHeight: CGFloat = Values.scaleFromIPhone5(200) - - override var text: String! { - didSet { - invalidateIntrinsicContentSize() - } - } - - override var font: UIFont? { - didSet { - invalidateIntrinsicContentSize() - } - } - - var doesContentNeedScroll: Bool { - return self.bounds.height == kMaxHeight - } - - override func layoutSubviews() { - super.layoutSubviews() - - // Enable/disable scrolling depending on wether we've clipped - // content in `intrinsicContentSize` - isScrollEnabled = doesContentNeedScroll - } - - override var intrinsicContentSize: CGSize { - var size = super.intrinsicContentSize - - if size.height == UIView.noIntrinsicMetric { - size.height = layoutManager.usedRect(for: textContainer).height + textContainerInset.top + textContainerInset.bottom - } - size.height = min(kMaxHeight, size.height) - - return size - } - } -} diff --git a/SessionMessagingKit/Database/Migrations/_045_LastProfileUpdateTimestamp.swift b/SessionMessagingKit/Database/Migrations/_045_LastProfileUpdateTimestamp.swift index 89163827fb..d35fb91035 100644 --- a/SessionMessagingKit/Database/Migrations/_045_LastProfileUpdateTimestamp.swift +++ b/SessionMessagingKit/Database/Migrations/_045_LastProfileUpdateTimestamp.swift @@ -10,7 +10,7 @@ enum _045_LastProfileUpdateTimestamp: Migration { static var createdTables: [(FetchableRecord & TableRecord).Type] = [] static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { - try db.alter(table: "Profile") { t in + try db.alter(table: "profile") { t in t.drop(column: "lastNameUpdate") t.drop(column: "lastBlocksCommunityMessageRequests") t.rename(column: "displayPictureLastUpdated", to: "profileLastUpdated") diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 072306a4d3..8b109d98cc 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -120,6 +120,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR public let digest: Data? /// Caption for the attachment + @available(*, deprecated, message: "This field is no longer sent or rendered by the clients") public let caption: String? // MARK: - Initialization @@ -140,8 +141,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR isVisualMedia: Bool? = nil, isValid: Bool = false, encryptionKey: Data? = nil, - digest: Data? = nil, - caption: String? = nil + digest: Data? = nil ) { self.id = id self.serverId = serverId @@ -159,7 +159,6 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR self.isValid = isValid self.encryptionKey = encryptionKey self.digest = digest - self.caption = caption } /// This initializer should only be used when converting from either a LinkPreview or a SignalAttachment to an Attachment (prior to upload) @@ -169,7 +168,6 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR contentType: String, dataSource: any DataSource, sourceFilename: String? = nil, - caption: String? = nil, using dependencies: Dependencies ) { guard @@ -207,7 +205,6 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR self.isValid = isValid self.encryptionKey = nil self.digest = nil - self.caption = caption } } @@ -435,8 +432,7 @@ extension Attachment { ), isValid: isValid, encryptionKey: (encryptionKey ?? self.encryptionKey), - digest: (digest ?? self.digest), - caption: self.caption + digest: (digest ?? self.digest) ) } } @@ -480,7 +476,6 @@ extension Attachment { self.isValid = false // Needs to be downloaded to be set self.encryptionKey = proto.key self.digest = proto.digest - self.caption = (proto.hasCaption ? proto.caption : nil) } public func buildProto() -> SNProtoAttachmentPointer? { @@ -497,10 +492,6 @@ extension Attachment { builder.setFileName(sourceFilename) } - if let caption: String = self.caption, !caption.isEmpty { - builder.setCaption(caption) - } - builder.setSize(UInt32(byteCount)) builder.setFlags(variant == .voiceMessage ? UInt32(SNProtoAttachmentPointer.SNProtoAttachmentPointerFlags.voiceMessage.rawValue) : diff --git a/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift b/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift index d0bcf07c4b..d90d1f9a7b 100644 --- a/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift +++ b/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift @@ -31,7 +31,6 @@ public final class AttachmentUploader { contentType: signalAttachment.mimeType, dataSource: signalAttachment.dataSource, sourceFilename: signalAttachment.sourceFilename, - caption: signalAttachment.captionText, using: dependencies ) } diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift b/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift index adbc379e5c..7ed8b82823 100644 --- a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift +++ b/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift @@ -102,7 +102,6 @@ public class SignalAttachment: Equatable { // MARK: Properties public let dataSource: (any DataSource) - public var captionText: String? public var linkPreviewDraft: LinkPreviewDraft? public var data: Data { return dataSource.data } @@ -951,7 +950,6 @@ public class SignalAttachment: Equatable { return ( lhs.dataType == rhs.dataType && - lhs.captionText == rhs.captionText && lhs.linkPreviewDraft == rhs.linkPreviewDraft && lhs.isConvertibleToTextMessage == rhs.isConvertibleToTextMessage && lhs.isConvertibleToContactShare == rhs.isConvertibleToContactShare && diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift index b636a474fc..893163ebcf 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift @@ -54,12 +54,6 @@ class SignalAttachmentItem: Equatable { } } - // MARK: - - var captionText: String? { - return attachment.captionText - } - // MARK: Equatable static func == (lhs: SignalAttachmentItem, rhs: SignalAttachmentItem) -> Bool { From 1ed3dc82514486e44510cf911f37039abb4f7f67 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Wed, 1 Oct 2025 14:15:27 +0800 Subject: [PATCH 049/162] Added iOS support deprecation banner on home screen Added `versionDeprecationWarning` feature flag --- .../Views & Modals/InfoBanner.swift | 11 +-- Session/Home/HomeVC.swift | 74 ++++++++++--------- Session/Home/HomeViewModel.swift | 31 ++++++-- .../DeveloperSettingsViewModel.swift | 33 ++++++++- SessionUtilitiesKit/General/Feature.swift | 4 + 5 files changed, 106 insertions(+), 47 deletions(-) diff --git a/Session/Conversations/Views & Modals/InfoBanner.swift b/Session/Conversations/Views & Modals/InfoBanner.swift index a76ec30f60..e7ecc44bd4 100644 --- a/Session/Conversations/Views & Modals/InfoBanner.swift +++ b/Session/Conversations/Views & Modals/InfoBanner.swift @@ -3,6 +3,7 @@ // stringlint:disable import UIKit +import Lucide import SessionUIKit final class InfoBanner: UIView { @@ -14,12 +15,12 @@ final class InfoBanner: UIView { var image: UIImage? { switch self { case .none: return nil - case .link: return UIImage(systemName: "arrow.up.right.square")?.withRenderingMode(.alwaysTemplate) + case .link: + return Lucide.image(icon: .squareArrowUpRight, size: 12)? + .withRenderingMode(.alwaysTemplate) case .close: - return UIImage( - systemName: "xmark", - withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .bold) - )?.withRenderingMode(.alwaysTemplate) + return Lucide.image(icon: .x, size: 12)? + .withRenderingMode(.alwaysTemplate) } } } diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index cf9e8ed989..28cfd632d7 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -39,11 +39,19 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi } // MARK: - UI - - private var tableViewTopConstraint: NSLayoutConstraint? - private var loadingConversationsLabelTopConstraint: NSLayoutConstraint? private var navBarProfileView: ProfilePictureView? + private lazy var bannersStackView: UIStackView = { + let result: UIStackView = UIStackView(arrangedSubviews: [ + versionSupportBanner, + seedReminderView + ]) + result.axis = .vertical + result.alignment = .fill + + return result + }() + private lazy var seedReminderView: SeedReminderView = { let result = SeedReminderView() result.accessibilityLabel = "Recovery phrase reminder" @@ -56,6 +64,23 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi return result }() + lazy var versionSupportBanner: InfoBanner = { + let result: InfoBanner = InfoBanner( + info: InfoBanner.Info( + font: .systemFont(ofSize: Values.verySmallFontSize), + message: "warningIosVersionEndingSupport" + .localizedFormatted(baseFont: .systemFont(ofSize: Values.verySmallFontSize)), + icon: .none, + tintColor: .messageBubble_outgoingText, + backgroundColor: .primary, + labelAccessibility: Accessibility(identifier: "Warning supported version banner") + ) + ) + + result.isHidden = false + return result + }() + private lazy var loadingConversationsLabel: UILabel = { let result: UILabel = UILabel() result.translatesAutoresizingMaskIntoConstraints = false @@ -300,32 +325,25 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi ) setUpNavBarSessionHeading() - // Recovery phrase reminder - view.addSubview(seedReminderView) - seedReminderView.pin(.leading, to: .leading, of: view) - seedReminderView.pin(.top, to: .top, of: view) - seedReminderView.pin(.trailing, to: .trailing, of: view) + // Banner stack view + view.addSubview(bannersStackView) + bannersStackView.pin(.leading, to: .leading, of: view) + bannersStackView.pin(.top, to: .top, of: view) + bannersStackView.pin(.trailing, to: .trailing, of: view) // Loading conversations label view.addSubview(loadingConversationsLabel) loadingConversationsLabel.pin(.leading, to: .leading, of: view, withInset: 50) loadingConversationsLabel.pin(.trailing, to: .trailing, of: view, withInset: -50) + loadingConversationsLabel.pin(.top, to: .bottom, of: bannersStackView, withInset: Values.mediumSpacing) // Table view view.addSubview(tableView) tableView.pin(.leading, to: .leading, of: view) tableView.pin(.trailing, to: .trailing, of: view) tableView.pin(.bottom, to: .bottom, of: view) - - if self.viewModel.state.showViewedSeedBanner { - loadingConversationsLabelTopConstraint = loadingConversationsLabel.pin(.top, to: .bottom, of: seedReminderView, withInset: Values.mediumSpacing) - tableViewTopConstraint = tableView.pin(.top, to: .bottom, of: seedReminderView) - } - else { - loadingConversationsLabelTopConstraint = loadingConversationsLabel.pin(.top, to: .top, of: view, withInset: Values.veryLargeSpacing) - tableViewTopConstraint = tableView.pin(.top, to: .top, of: view) - } + tableView.pin(.top, to: .bottom, of: bannersStackView) // Empty state view view.addSubview(emptyStateStackView) @@ -410,26 +428,10 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi serviceNetwork: state.serviceNetwork, forceOffline: state.forceOffline ) - + // Update the 'view seed' UI - let shouldHideSeedReminderView: Bool = !state.showViewedSeedBanner - - if seedReminderView.isHidden != shouldHideSeedReminderView { - tableViewTopConstraint?.isActive = false - loadingConversationsLabelTopConstraint?.isActive = false - seedReminderView.isHidden = !state.showViewedSeedBanner - - if state.showViewedSeedBanner { - loadingConversationsLabelTopConstraint = loadingConversationsLabel.pin(.top, to: .bottom, of: seedReminderView, withInset: Values.mediumSpacing) - tableViewTopConstraint = tableView.pin(.top, to: .bottom, of: seedReminderView) - } - else { - loadingConversationsLabelTopConstraint = loadingConversationsLabel.pin(.top, to: .top, of: view, withInset: Values.veryLargeSpacing) - tableViewTopConstraint = tableView.pin(.top, to: .top, of: view, withInset: Values.smallSpacing) - } - - view.layoutIfNeeded() - } + seedReminderView.isHidden = !state.showViewedSeedBanner + versionSupportBanner.isHidden = state.showVersionSupportBanner // Update the overall view state (loading, empty, or loaded) switch state.viewState { diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 98d29d990e..7b863f7eab 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -35,6 +35,13 @@ public class HomeViewModel: NavigatableStateHolder { private static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15) + // Reusable OS version check for initial and updated state check + // Check if the current device is running a version LESS THAN iOS 16.0 + private static var isOSVersionDeprecated: Bool { + guard #unavailable(iOS 16.0) else { return false } + return true + } + public let dependencies: Dependencies private let userSessionId: SessionId private var didPresentAppReviewPrompt: Bool = false @@ -53,7 +60,8 @@ public class HomeViewModel: NavigatableStateHolder { appReviewPromptState: AppReviewPromptModel .loadInitialAppReviewPromptState(using: dependencies), appWasInstalledPriorToAppReviewRelease: AppReviewPromptModel - .checkIfAppWasInstalledPriorToAppReviewRelease(using: dependencies) + .checkIfAppWasInstalledPriorToAppReviewRelease(using: dependencies), + showVersionSupportBanner: Self.isOSVersionDeprecated && dependencies[feature: .versionDeprecationWarning] ) /// Bind the state @@ -97,6 +105,7 @@ public class HomeViewModel: NavigatableStateHolder { let appReviewPromptState: AppReviewPromptState? let pendingAppReviewPromptState: AppReviewPromptState? let appWasInstalledPriorToAppReviewRelease: Bool + let showVersionSupportBanner: Bool @MainActor public func sections(viewModel: HomeViewModel) -> [SectionModel] { HomeViewModel.sections(state: self, viewModel: viewModel) @@ -124,7 +133,8 @@ public class HomeViewModel: NavigatableStateHolder { .userDefault(.hasVisitedPathScreen), .userDefault(.hasPressedDonateButton), .userDefault(.hasChangedTheme), - .updateScreen(HomeViewModel.self) + .updateScreen(HomeViewModel.self), + .feature(.versionDeprecationWarning) ] itemCache.values.forEach { threadViewModel in @@ -151,7 +161,12 @@ public class HomeViewModel: NavigatableStateHolder { return result } - static func initialState(using dependencies: Dependencies, appReviewPromptState: AppReviewPromptState?, appWasInstalledPriorToAppReviewRelease: Bool) -> State { + static func initialState( + using dependencies: Dependencies, + appReviewPromptState: AppReviewPromptState?, + appWasInstalledPriorToAppReviewRelease: Bool, + showVersionSupportBanner: Bool + ) -> State { return State( viewState: .loading, userProfile: Profile(id: dependencies[cache: .general].sessionId.hexString, name: ""), @@ -178,7 +193,8 @@ public class HomeViewModel: NavigatableStateHolder { itemCache: [:], appReviewPromptState: nil, pendingAppReviewPromptState: appReviewPromptState, - appWasInstalledPriorToAppReviewRelease: appWasInstalledPriorToAppReviewRelease + appWasInstalledPriorToAppReviewRelease: appWasInstalledPriorToAppReviewRelease, + showVersionSupportBanner: showVersionSupportBanner ) } } @@ -203,6 +219,7 @@ public class HomeViewModel: NavigatableStateHolder { var appReviewPromptState: AppReviewPromptState? = previousState.appReviewPromptState var pendingAppReviewPromptState: AppReviewPromptState? = previousState.pendingAppReviewPromptState let appWasInstalledPriorToAppReviewRelease: Bool = previousState.appWasInstalledPriorToAppReviewRelease + var showVersionSupportBanner: Bool = previousState.showVersionSupportBanner /// Store a local copy of the events so we can manipulate it based on the state changes var eventsToProcess: [ObservedEvent] = events @@ -395,6 +412,9 @@ public class HomeViewModel: NavigatableStateHolder { else if event.key == .feature(.forceOffline), let updatedValue = event.value as? Bool { forceOffline = updatedValue } + else if event.key == .feature(.versionDeprecationWarning), let updatedValue = event.value as? Bool { + showVersionSupportBanner = isOSVersionDeprecated && updatedValue + } } /// Next trigger should be ignored if `didShowAppReviewPrompt` is true @@ -442,7 +462,8 @@ public class HomeViewModel: NavigatableStateHolder { itemCache: itemCache, appReviewPromptState: appReviewPromptState, pendingAppReviewPromptState: pendingAppReviewPromptState, - appWasInstalledPriorToAppReviewRelease: appWasInstalledPriorToAppReviewRelease + appWasInstalledPriorToAppReviewRelease: appWasInstalledPriorToAppReviewRelease, + showVersionSupportBanner: showVersionSupportBanner ) } diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift index b32ecb10ad..41d23b7fad 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift @@ -81,6 +81,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case copyAppGroupPath case resetAppReviewPrompt case simulateAppReviewLimit + case versionDeprecationWarning case defaultLogLevel case advancedLogging @@ -122,6 +123,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .copyAppGroupPath: return "copyAppGroupPath" case .resetAppReviewPrompt: return "resetAppReviewPrompt" case .simulateAppReviewLimit: return "simulateAppReviewLimit" + case .versionDeprecationWarning: return "versionDeprecationWarning" case .defaultLogLevel: return "defaultLogLevel" case .advancedLogging: return "advancedLogging" @@ -165,6 +167,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .copyAppGroupPath: result.append(.copyAppGroupPath); fallthrough case .resetAppReviewPrompt: result.append(.resetAppReviewPrompt); fallthrough case .simulateAppReviewLimit: result.append(.simulateAppReviewLimit); fallthrough + case .versionDeprecationWarning: result.append(.versionDeprecationWarning); fallthrough case .defaultLogLevel: result.append(.defaultLogLevel); fallthrough case .advancedLogging: result.append(.advancedLogging); fallthrough @@ -217,6 +220,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, let forceSlowDatabaseQueries: Bool let updateSimulateAppReviewLimit: Bool + + let versionDeprecationWarning: Bool } let title: String = "Developer Settings" @@ -258,7 +263,10 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, communityPollLimit: dependencies[feature: .communityPollLimit], forceSlowDatabaseQueries: dependencies[feature: .forceSlowDatabaseQueries], - updateSimulateAppReviewLimit: dependencies[feature: .simulateAppReviewLimit] + + updateSimulateAppReviewLimit: dependencies[feature: .simulateAppReviewLimit], + + versionDeprecationWarning: dependencies[feature: .versionDeprecationWarning] ) } .compactMapWithPrevious { [weak self] prev, current -> [SectionModel]? in self?.content(prev, current) } @@ -441,6 +449,23 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, ) } ), + SessionCell.Info( + id: .versionDeprecationWarning, + title: "Version Deprecation Banner", + subtitle: """ + Enable the banner that warns users when their operating system (iOS 15.x or earlier) is nearing the end of support or cannot access the latest features. + """, + trailingAccessory: .toggle( + current.versionDeprecationWarning, + oldValue: previous?.versionDeprecationWarning + ), + onTap: { [weak self] in + self?.updateFlag( + for: .versionDeprecationWarning, + to: !current.versionDeprecationWarning + ) + } + ) ] ) let logging: SectionModel = SectionModel( @@ -616,6 +641,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, ) ] ) + let communities: SectionModel = SectionModel( model: .communities, elements: [ @@ -809,6 +835,11 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, guard dependencies.hasSet(feature: .debugDisappearingMessageDurations) else { return } updateFlag(for: .debugDisappearingMessageDurations, to: nil) + + case .versionDeprecationWarning: + guard dependencies.hasSet(feature: .versionDeprecationWarning) else { return } + + updateFlag(for: .versionDeprecationWarning, to: nil) case .communityPollLimit: guard dependencies.hasSet(feature: .communityPollLimit) else { return } diff --git a/SessionUtilitiesKit/General/Feature.swift b/SessionUtilitiesKit/General/Feature.swift index 0e512347e0..f42c7537ea 100644 --- a/SessionUtilitiesKit/General/Feature.swift +++ b/SessionUtilitiesKit/General/Feature.swift @@ -97,6 +97,10 @@ public extension FeatureStorage { static let simulateAppReviewLimit: FeatureConfig = Dependencies.create( identifier: "simulateAppReviewLimit" ) + + static let versionDeprecationWarning: FeatureConfig = Dependencies.create( + identifier: "versionDeprecationWarning" + ) } // MARK: - FeatureOption From c616c10c7ee771c58861fc7458aad1f72867f2b2 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 3 Oct 2025 09:06:01 +1000 Subject: [PATCH 050/162] Fixed layout bugs with contact list items and blocked contacts button --- .../Views/SessionCell+AccessoryView.swift | 9 +++--- Session/Shared/Views/SessionCell.swift | 29 +++++++++++++++++++ SessionUIKit/Components/ExpandableLabel.swift | 5 ++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index a3815587ae..8667f1c5ce 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -684,11 +684,12 @@ extension SessionCell { guard let profilePictureView: ProfilePictureView = view as? ProfilePictureView else { return } profilePictureView.size = size - profilePictureView.pin(.top, to: .top, of: self) - profilePictureView.pin(.leading, to: .leading, of: self) + profilePictureView.pin(.top, to: .top, of: self, withInset: Values.mediumSpacing) + profilePictureView.pin(.leading, to: .leading, of: self, withInset: Values.mediumSpacing) profilePictureView.pin(.trailing, to: .trailing, of: self) - profilePictureView.pin(.bottom, to: .bottom, of: self).setting(priority: .defaultHigh) - fixedWidthConstraint.constant = size.viewSize + profilePictureView.pin(.bottom, to: .bottom, of: self, withInset: -Values.mediumSpacing) + .setting(priority: .defaultHigh) + fixedWidthConstraint.constant = (size.viewSize + Values.mediumSpacing) fixedWidthConstraint.isActive = true } diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index 768773ff44..c888a40851 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -214,15 +214,44 @@ public class SessionCell: UITableViewCell { prepareForReuse() } + public override func systemLayoutSizeFitting( + _ targetSize: CGSize, + withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, + verticalFittingPriority: UILayoutPriority + ) -> CGSize { + // Force accessory views to layout first if they have custom content + leadingAccessoryView.layoutIfNeeded() + trailingAccessoryView.layoutIfNeeded() + + return super.systemLayoutSizeFitting( + targetSize, + withHorizontalFittingPriority: horizontalFittingPriority, + verticalFittingPriority: verticalFittingPriority + ) + } + public override func layoutSubviews() { super.layoutSubviews() + if titleLabel.preferredMaxLayoutWidth != titleLabel.bounds.width { + titleLabel.preferredMaxLayoutWidth = titleLabel.bounds.width + } + + if subtitleLabel.preferredMaxLayoutWidth != subtitleLabel.bounds.width { + subtitleLabel.preferredMaxLayoutWidth = subtitleLabel.bounds.width + } + + if expandableDescriptionLabel.preferredMaxLayoutWidth != expandableDescriptionLabel.bounds.width { + expandableDescriptionLabel.preferredMaxLayoutWidth = expandableDescriptionLabel.bounds.width + } + // Need to force the contentStackView to layout if needed as it might not have updated it's // sizing yet self.contentStackView.layoutIfNeeded() repositionExtraView(titleExtraView, for: titleLabel) repositionExtraView(subtitleExtraView, for: subtitleLabel) self.titleStackView.layoutIfNeeded() + self.layoutIfNeeded() } private func repositionExtraView(_ targetView: UIView?, for label: UILabel) { diff --git a/SessionUIKit/Components/ExpandableLabel.swift b/SessionUIKit/Components/ExpandableLabel.swift index b28aecb0a6..8722299e56 100644 --- a/SessionUIKit/Components/ExpandableLabel.swift +++ b/SessionUIKit/Components/ExpandableLabel.swift @@ -57,6 +57,11 @@ public class ExpandableLabel: UIView { set { label.numberOfLines = newValue } } + public var preferredMaxLayoutWidth: CGFloat { + get { label.preferredMaxLayoutWidth } + set { label.preferredMaxLayoutWidth = newValue } + } + public var maxNumberOfLines: Int = 0 { didSet { guard maxNumberOfLines != oldValue else { return } From 34b979cca5acb23013de97870be7f238610d2542 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 3 Oct 2025 09:59:59 +1000 Subject: [PATCH 051/162] Fixed profile cell layout, fixed Community 'next' button on iPhone SE --- Session/Closed Groups/EditGroupViewModel.swift | 5 ++++- .../Settings/ThreadSettingsViewModel.swift | 5 ++++- Session/Open Groups/JoinOpenGroupVC.swift | 8 +++----- Session/Settings/SettingsViewModel.swift | 5 ++++- .../Shared/SessionTableViewController.swift | 2 +- .../Shared/Types/SessionCell+Accessory.swift | 2 -- .../Views/SessionCell+AccessoryView.swift | 11 ++++++----- Session/Shared/Views/SessionCell.swift | 18 ++---------------- 8 files changed, 24 insertions(+), 32 deletions(-) diff --git a/Session/Closed Groups/EditGroupViewModel.swift b/Session/Closed Groups/EditGroupViewModel.swift index a0dd9dba89..8a4817f5dd 100644 --- a/Session/Closed Groups/EditGroupViewModel.swift +++ b/Session/Closed Groups/EditGroupViewModel.swift @@ -193,7 +193,10 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Observa ), styling: SessionCell.StyleInfo( alignment: .centerHugging, - customPadding: SessionCell.Padding(bottom: Values.smallSpacing), + customPadding: SessionCell.Padding( + leading: 0, + bottom: Values.smallSpacing + ), backgroundStyle: .noBackground ), accessibility: Accessibility( diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 8ee7a40590..89ce66950c 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -212,7 +212,10 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob ), styling: SessionCell.StyleInfo( alignment: .centerHugging, - customPadding: SessionCell.Padding(bottom: Values.smallSpacing), + customPadding: SessionCell.Padding( + leading: 0, + bottom: Values.smallSpacing + ), backgroundStyle: .noBackground ), onTap: { [weak self] in diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 0c5fa3c2cc..22bc293fc7 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -82,10 +82,8 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC // presentation type is `fullScreen` var navBarHeight: CGFloat { switch modalPresentationStyle { - case .fullScreen: - return navigationController?.navigationBar.frame.size.height ?? 0 - default: - return 0 + case .fullScreen: return (navigationController?.navigationBar.frame.size.height ?? 0) + default: return 0 } } @@ -115,7 +113,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC pageVCView.pin(.bottom, to: .bottom, of: view) let statusBarHeight: CGFloat = UIApplication.shared.statusBarFrame.size.height - let height: CGFloat = ((navigationController?.view.bounds.height ?? 0) - navBarHeight - TabBar.snHeight - statusBarHeight) + let height: CGFloat = ((navigationController?.view.bounds.height ?? 0) - (navigationController?.navigationBar.frame.size.height ?? 0) - TabBar.snHeight - statusBarHeight) let size: CGSize = CGSize(width: UIScreen.main.bounds.width, height: height) enterURLVC.constrainSize(to: size) scanQRCodePlaceholderVC.constrainSize(to: size) diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index f8fa6a6a42..e8878fc3a5 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -269,7 +269,10 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl ), styling: SessionCell.StyleInfo( alignment: .centerHugging, - customPadding: SessionCell.Padding(bottom: Values.smallSpacing), + customPadding: SessionCell.Padding( + leading: 0, + bottom: Values.smallSpacing + ), backgroundStyle: .noBackground ), accessibility: Accessibility( diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index 087091c28f..3e4502aa2e 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -96,7 +96,7 @@ class SessionTableViewController: BaseVC, UITableViewDataSource, UITa result.delegate = self result.sectionHeaderTopPadding = 0 result.rowHeight = UITableView.automaticDimension - result.estimatedRowHeight = 56 // Approximate size of an [{Icon} {Text}] SessionCell + result.estimatedRowHeight = UITableView.automaticDimension return result }() diff --git a/Session/Shared/Types/SessionCell+Accessory.swift b/Session/Shared/Types/SessionCell+Accessory.swift index 63025785d7..e1e17b647e 100644 --- a/Session/Shared/Types/SessionCell+Accessory.swift +++ b/Session/Shared/Types/SessionCell+Accessory.swift @@ -580,8 +580,6 @@ public extension SessionCell.AccessoryConfig { public let additionalProfile: Profile? public let additionalProfileIcon: ProfilePictureView.ProfileIcon - override public var shouldFitToEdge: Bool { true } - fileprivate init( id: String, size: ProfilePictureView.Size, diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index 8667f1c5ce..6412cd1e42 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -39,6 +39,8 @@ extension SessionCell { minWidthConstraint.isActive = false fixedWidthConstraint.constant = AccessoryView.minWidth fixedWidthConstraint.isActive = false + + invalidateIntrinsicContentSize() } public func update( @@ -684,12 +686,11 @@ extension SessionCell { guard let profilePictureView: ProfilePictureView = view as? ProfilePictureView else { return } profilePictureView.size = size - profilePictureView.pin(.top, to: .top, of: self, withInset: Values.mediumSpacing) - profilePictureView.pin(.leading, to: .leading, of: self, withInset: Values.mediumSpacing) + profilePictureView.pin(.top, to: .top, of: self) + profilePictureView.pin(.leading, to: .leading, of: self) profilePictureView.pin(.trailing, to: .trailing, of: self) - profilePictureView.pin(.bottom, to: .bottom, of: self, withInset: -Values.mediumSpacing) - .setting(priority: .defaultHigh) - fixedWidthConstraint.constant = (size.viewSize + Values.mediumSpacing) + profilePictureView.pin(.bottom, to: .bottom, of: self).setting(priority: .defaultHigh) + fixedWidthConstraint.constant = (size.viewSize) fixedWidthConstraint.isActive = true } diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index c888a40851..0e78a0e704 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -214,22 +214,6 @@ public class SessionCell: UITableViewCell { prepareForReuse() } - public override func systemLayoutSizeFitting( - _ targetSize: CGSize, - withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, - verticalFittingPriority: UILayoutPriority - ) -> CGSize { - // Force accessory views to layout first if they have custom content - leadingAccessoryView.layoutIfNeeded() - trailingAccessoryView.layoutIfNeeded() - - return super.systemLayoutSizeFitting( - targetSize, - withHorizontalFittingPriority: horizontalFittingPriority, - verticalFittingPriority: verticalFittingPriority - ) - } - public override func layoutSubviews() { super.layoutSubviews() @@ -348,6 +332,8 @@ public class SessionCell: UITableViewCell { subtitleLabel.isHidden = true expandableDescriptionLabel.isHidden = true botSeparator.isHidden = true + + invalidateIntrinsicContentSize() } @MainActor public func update( From 35fb351bc9b85d2257719b6343f4122f02cf8fef Mon Sep 17 00:00:00 2001 From: mikoldin Date: Fri, 3 Oct 2025 08:15:16 +0800 Subject: [PATCH 052/162] Fix wrong condition check in hiding support banner --- Session/Home/HomeVC.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 28cfd632d7..422d523fe3 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -431,7 +431,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi // Update the 'view seed' UI seedReminderView.isHidden = !state.showViewedSeedBanner - versionSupportBanner.isHidden = state.showVersionSupportBanner + versionSupportBanner.isHidden = !state.showVersionSupportBanner // Update the overall view state (loading, empty, or loaded) switch state.viewState { From 09deb0d7a02630bb3f2d4536b76b7e8e2cf8ec5a Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 3 Oct 2025 16:14:49 +1000 Subject: [PATCH 053/162] feat: add cache for pro badge images and use image for @YOU --- Session.xcodeproj/project.pbxproj | 4 + .../Components/HighlightMentionView.swift | 57 +++++++++++++++ SessionUIKit/Components/SessionProBadge.swift | 20 ++++- .../Themes/ThemedAttributedString.swift | 4 + SessionUIKit/Utilities/MentionUtilities.swift | 73 +++++++++++-------- 5 files changed, 125 insertions(+), 33 deletions(-) create mode 100644 SessionUIKit/Components/HighlightMentionView.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 8d0e92a520..10b743aef9 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -225,6 +225,7 @@ 94CD96412E1BABE90097754D /* HigherCharLimitCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */; }; 94CD96432E1BAC0F0097754D /* GenericCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963C2E1BABE90097754D /* GenericCTA.webp */; }; 94CD96452E1BAC0F0097754D /* HigherCharLimitCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */; }; + 94D716802E8F6363008294EE /* HighlightMentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */; }; 94E9BC0D2C7BFBDA006984EA /* Localization+Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */; }; A11CD70D17FA230600A2D1B1 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; A163E8AB16F3F6AA0094D68B /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A163E8AA16F3F6A90094D68B /* Security.framework */; }; @@ -1616,6 +1617,7 @@ 94CD96312E1B88C20097754D /* ExpandingAttachmentsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandingAttachmentsButton.swift; sourceTree = ""; }; 94CD963C2E1BABE90097754D /* GenericCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = GenericCTA.webp; sourceTree = ""; }; 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = HigherCharLimitCTA.webp; sourceTree = ""; }; + 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightMentionView.swift; sourceTree = ""; }; 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Localization+Style.swift"; sourceTree = ""; }; A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; A163E8AA16F3F6A90094D68B /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; @@ -3405,6 +3407,7 @@ C3C3CF8824D8EED300E1CCE7 /* SNTextView.swift */, 7BBBDC43286EAD2D00747E59 /* TappableLabel.swift */, FD09B7E228865FDA00ED0B66 /* HighlightMentionBackgroundView.swift */, + 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */, C38EF3EE255B6DF6007E1867 /* GradientView.swift */, FD8A5B0F2DBF2F14004C689B /* NavBarSessionIcon.swift */, C38EF2A3255B6D93007E1867 /* PlaceholderIcon.swift */, @@ -6294,6 +6297,7 @@ 942BA9C42E55AB54007C4595 /* UILabel+Utilities.swift in Sources */, FD8A5B112DBF34BD004C689B /* Date+Utilities.swift in Sources */, FDB348632BE3774000B716C2 /* BezierPathView.swift in Sources */, + 94D716802E8F6363008294EE /* HighlightMentionView.swift in Sources */, FD8A5B292DC060E2004C689B /* Double+Utilities.swift in Sources */, FD8A5B0E2DBF2DB1004C689B /* SessionHostingViewController.swift in Sources */, 94CD962D2E1B85920097754D /* InputViewButton.swift in Sources */, diff --git a/SessionUIKit/Components/HighlightMentionView.swift b/SessionUIKit/Components/HighlightMentionView.swift new file mode 100644 index 0000000000..82dbf00f30 --- /dev/null +++ b/SessionUIKit/Components/HighlightMentionView.swift @@ -0,0 +1,57 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +public class HighlightMentionView: UIView { + let backgroundPadding: CGFloat + + lazy var label: UILabel = { + let result = UILabel() + result.numberOfLines = 1 + return result + }() + + public init( + mentionText: String, + font: UIFont, + themeTextColor: ThemeValue, + themeBackgroundColor: ThemeValue, + backgroundCornerRadius: CGFloat, + backgroundPadding: CGFloat + ) { + self.backgroundPadding = backgroundPadding + super.init(frame: .zero) + + self.isOpaque = false + self.label.text = mentionText + self.label.themeTextColor = themeTextColor + self.label.font = font + + self.addSubview(self.label) + self.themeBackgroundColor = themeBackgroundColor + self.label.pin(to: self, withInset: backgroundPadding) + self.layer.cornerRadius = backgroundCornerRadius + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func toImage() -> UIImage { + let maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: label.font.lineHeight) + let size = self.label.sizeThatFits(maxSize) + self.label.frame = CGRect( + origin: CGPoint( + x: self.backgroundPadding, + y: self.backgroundPadding + ), + size: size + ) + self.frame.size = CGSize( + width: size.width + 2 * self.backgroundPadding, + height: size.height + 2 * self.backgroundPadding + ) + let renderedImage = self.toImage(isOpaque: self.isOpaque, scale: UIScreen.main.scale) + return renderedImage + } +} diff --git a/SessionUIKit/Components/SessionProBadge.swift b/SessionUIKit/Components/SessionProBadge.swift index 328ce186e5..0aaf9bdf51 100644 --- a/SessionUIKit/Components/SessionProBadge.swift +++ b/SessionUIKit/Components/SessionProBadge.swift @@ -3,6 +3,8 @@ import UIKit public class SessionProBadge: UIView { + static let cache: NSCache = NSCache() + public enum Size { case mini, small, medium, large @@ -46,6 +48,16 @@ public class SessionProBadge: UIView { case .large: return 40 } } + + // stringlint:ignore_contents + var cacheKey: NSString { + switch self { + case .mini: return "SessionProBadge.Mini" + case .small: return "SessionProBadge.Small" + case .medium: return "SessionProBadge.Medium" + case .large: return "SessionProBadge.Large" + } + } } public var size: Size { @@ -102,12 +114,18 @@ public class SessionProBadge: UIView { } public func toImage() -> UIImage { + if let cachedImage = SessionProBadge.cache.object(forKey: self.size.cacheKey) { + return cachedImage + } + self.proImageView.frame = CGRect( x: (size.width - size.proFontWidth) / 2, y: (size.height - size.proFontHeight) / 2, width: size.proFontWidth, height: size.proFontHeight ) - return self.toImage(isOpaque: self.isOpaque, scale: UIScreen.main.scale) + let renderedImage = self.toImage(isOpaque: self.isOpaque, scale: UIScreen.main.scale) + SessionProBadge.cache.setObject(renderedImage, forKey: self.size.cacheKey) + return renderedImage } } diff --git a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift index 9c07b2d702..33e6eaa0ab 100644 --- a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift +++ b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift @@ -155,6 +155,10 @@ public class ThemedAttributedString: Equatable, Hashable { return value.boundingRect(with: size, options: options, context: context) } + public func replaceCharacters(in range: NSRange, with attributedString: NSAttributedString) { + value.replaceCharacters(in: range, with: attributedString) + } + // MARK: - Convenience #if DEBUG diff --git a/SessionUIKit/Utilities/MentionUtilities.swift b/SessionUIKit/Utilities/MentionUtilities.swift index 58b19576b5..4fdfee7631 100644 --- a/SessionUIKit/Utilities/MentionUtilities.swift +++ b/SessionUIKit/Utilities/MentionUtilities.swift @@ -94,47 +94,56 @@ public enum MentionUtilities { ) let sizeDiff: CGFloat = (Values.smallFontSize / Values.mediumFontSize) - let result: ThemedAttributedString = ThemedAttributedString(string: string, attributes: attributes) - mentions.forEach { mention in - result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: Values.smallFontSize), range: mention.range) - + let result = ThemedAttributedString(string: string, attributes: attributes) + let mentionFont = UIFont.boldSystemFont(ofSize: Values.smallFontSize) + // Iterate in reverse so index ranges remain valid while replacing + for mention in mentions.sorted(by: { $0.range.location > $1.range.location }) { if mention.isCurrentUser && location == .incomingMessage { - // Note: The designs don't match with the dynamic sizing so these values need to be calculated - // to maintain a "rounded rect" effect rather than a "pill" effect - result.addAttribute(.currentUserMentionBackgroundCornerRadius, value: (8 * sizeDiff), range: mention.range) - result.addAttribute(.currentUserMentionBackgroundPadding, value: (3 * sizeDiff), range: mention.range) - result.addAttribute(.currentUserMentionBackgroundColor, value: ThemeValue.primary, range: mention.range) - - // Only add the additional kern if the mention isn't at the end of the string (otherwise this - // would crash due to an index out of bounds exception) - if mention.range.upperBound < result.length { - result.addAttribute(.kern, value: (3 * sizeDiff), range: NSRange(location: mention.range.upperBound, length: 1)) + // Build the rendered chip image + let image = HighlightMentionView( + mentionText: (result.string as NSString).substring(with: mention.range), + font: mentionFont, + themeTextColor: .dynamicForInterfaceStyle(light: textColor, dark: .black), + themeBackgroundColor: .primary, + backgroundCornerRadius: (8 * sizeDiff), + backgroundPadding: (3 * sizeDiff) + ).toImage() + + let attachment = NSTextAttachment() + let offsetY = (mentionFont.capHeight - image.size.height) / 2 + attachment.image = image + attachment.bounds = CGRect( + x: 0, + y: offsetY, + width: image.size.width, + height: image.size.height + ) + + let attachmentString = NSMutableAttributedString(attachment: attachment) + + // Replace the mention text with the image attachment + result.replaceCharacters(in: mention.range, with: attachmentString) + + let insertIndex = mention.range.location + attachmentString.length + if insertIndex < result.length { + result.addAttribute(.kern, value: (3 * sizeDiff), range: NSRange(location: insertIndex, length: 1)) } + continue } + result.addAttribute(.font, value: mentionFont, range: mention.range) + var targetColor: ThemeValue = textColor - - switch (location, mention.isCurrentUser) { - // 1 - Incoming messages where the mention is for the current user - case (.incomingMessage, true): - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) - - // 2 - Incoming messages where the mention is for another user - case (.incomingMessage, false): + switch location { + case .incomingMessage: targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .primary) - - // 3 - Outgoing messages - case (.outgoingMessage, _): + case .outgoingMessage: targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) - - // 4 - Mentions in quotes - case (.outgoingQuote, _): + case .outgoingQuote: targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) - case (.incomingQuote, _): + case .incomingQuote: targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .primary) - - // 5 - Mentions in quote drafts - case (.quoteDraft, _), (.styleFree, _): + case .quoteDraft, .styleFree: targetColor = .dynamicForInterfaceStyle(light: textColor, dark: textColor) } From 093735685508e0c5f13a59b780a0bd69224ba444 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 3 Oct 2025 16:45:57 +1000 Subject: [PATCH 054/162] fix message info screen --- Session.xcodeproj/project.pbxproj | 4 +++ .../MessageInfoScreen.swift | 2 +- .../Components/SwiftUI/AttributedLabel.swift | 32 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 SessionUIKit/Components/SwiftUI/AttributedLabel.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 10b743aef9..bd3f3ca56b 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -226,6 +226,7 @@ 94CD96432E1BAC0F0097754D /* GenericCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963C2E1BABE90097754D /* GenericCTA.webp */; }; 94CD96452E1BAC0F0097754D /* HigherCharLimitCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */; }; 94D716802E8F6363008294EE /* HighlightMentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */; }; + 94D716822E8FA1A0008294EE /* AttributedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716812E8FA19D008294EE /* AttributedLabel.swift */; }; 94E9BC0D2C7BFBDA006984EA /* Localization+Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */; }; A11CD70D17FA230600A2D1B1 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; A163E8AB16F3F6AA0094D68B /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A163E8AA16F3F6A90094D68B /* Security.framework */; }; @@ -1618,6 +1619,7 @@ 94CD963C2E1BABE90097754D /* GenericCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = GenericCTA.webp; sourceTree = ""; }; 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = HigherCharLimitCTA.webp; sourceTree = ""; }; 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightMentionView.swift; sourceTree = ""; }; + 94D716812E8FA19D008294EE /* AttributedLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedLabel.swift; sourceTree = ""; }; 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Localization+Style.swift"; sourceTree = ""; }; A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; A163E8AA16F3F6A90094D68B /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; @@ -2873,6 +2875,7 @@ 942256932C23F8DD00C0FDBF /* SwiftUI */ = { isa = PBXGroup; children = ( + 94D716812E8FA19D008294EE /* AttributedLabel.swift */, 94363E542E5D83F60004EE43 /* TappableLabel+SwiftUI.swift */, 942BA9402E4487EE007C4595 /* LightBox.swift */, 94B6BB032E3B208200E718BB /* Seperator+SwiftUI.swift */, @@ -6351,6 +6354,7 @@ 94AAB14F2E1F6CC100A6FA18 /* SessionProBadge+SwiftUI.swift in Sources */, 94AAB14B2E1E198200A6FA18 /* Modal+SwiftUI.swift in Sources */, 94AAB1532E1F8AE200A6FA18 /* ShineButton.swift in Sources */, + 94D716822E8FA1A0008294EE /* AttributedLabel.swift in Sources */, FD37E9D728A20B5D003AE748 /* UIColor+Utilities.swift in Sources */, 94A6B9DB2DD6BF7C00DB4B44 /* Constants+Apple.swift in Sources */, FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */, diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index e65c79ed58..1293375e9e 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -712,7 +712,7 @@ struct MessageBubble: View { searchText: nil, using: dependencies ) { - TappableLabel_SwiftUI(themeAttributedText: bodyText, maxWidth: maxWidth) + AttributedLabel(bodyText, maxWidth: maxWidth) .padding(.horizontal, Self.inset) .padding(.top, Self.inset) .frame( diff --git a/SessionUIKit/Components/SwiftUI/AttributedLabel.swift b/SessionUIKit/Components/SwiftUI/AttributedLabel.swift new file mode 100644 index 0000000000..968af2033b --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/AttributedLabel.swift @@ -0,0 +1,32 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI + +public struct AttributedLabel: UIViewRepresentable { + public typealias UIViewType = UILabel + + let themedAttributedString: ThemedAttributedString? + let maxWidth: CGFloat? + + public init(_ themedAttributedString: ThemedAttributedString?, maxWidth: CGFloat? = nil) { + self.themedAttributedString = themedAttributedString + self.maxWidth = maxWidth + } + + public func makeUIView(context: Context) -> UILabel { + let label = UILabel() + label.numberOfLines = 0 + label.themeAttributedText = themedAttributedString + label.setContentHuggingPriority(.required, for: .horizontal) + label.setContentHuggingPriority(.required, for: .vertical) + label.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + return label + } + + public func updateUIView(_ label: UILabel, context: Context) { + label.themeAttributedText = themedAttributedString + if let maxWidth = maxWidth { + label.preferredMaxLayoutWidth = maxWidth + } + } +} From eb45a02bc46838435199712bec87f4d29030573b Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 3 Oct 2025 16:57:53 +1000 Subject: [PATCH 055/162] clean up --- Session.xcodeproj/project.pbxproj | 8 - .../HighlightMentionBackgroundView.swift | 188 ------------------ .../Components/SwiftUI/AttributedText.swift | 9 +- .../SwiftUI/TappableLabel+SwiftUI.swift | 186 ----------------- SessionUIKit/Components/TappableLabel.swift | 25 --- .../Themes/ThemedAttributedString.swift | 7 +- 6 files changed, 2 insertions(+), 421 deletions(-) delete mode 100644 SessionUIKit/Components/HighlightMentionBackgroundView.swift delete mode 100644 SessionUIKit/Components/SwiftUI/TappableLabel+SwiftUI.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index bd3f3ca56b..d9349a3313 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -179,7 +179,6 @@ 942BA9C12E4EA5CB007C4595 /* SessionLabelWithProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */; }; 942BA9C22E53F694007C4595 /* SRCopyableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DAB3232480CB2A00725F25 /* SRCopyableLabel.swift */; }; 942BA9C42E55AB54007C4595 /* UILabel+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9C32E55AB51007C4595 /* UILabel+Utilities.swift */; }; - 94363E552E5D84010004EE43 /* TappableLabel+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94363E542E5D83F60004EE43 /* TappableLabel+SwiftUI.swift */; }; 943C6D822B75E061004ACE64 /* Message+DisappearingMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */; }; 94519A932E84C20700F02723 /* _045_LastProfileUpdateTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */; }; 946F5A732D5DA3AC00A5ADCE /* Punycode in Frameworks */ = {isa = PBXBuildFile; productRef = 946F5A722D5DA3AC00A5ADCE /* Punycode */; }; @@ -456,7 +455,6 @@ FD09799527FE7B8E00936362 /* Interaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799227FE693200936362 /* Interaction.swift */; }; FD09799927FFC1A300936362 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799827FFC1A300936362 /* Attachment.swift */; }; FD09799B27FFC82D00936362 /* Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799A27FFC82D00936362 /* Quote.swift */; }; - FD09B7E328865FDA00ED0B66 /* HighlightMentionBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09B7E228865FDA00ED0B66 /* HighlightMentionBackgroundView.swift */; }; FD09B7E5288670BB00ED0B66 /* _017_EmojiReacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09B7E4288670BB00ED0B66 /* _017_EmojiReacts.swift */; }; FD09B7E7288670FD00ED0B66 /* Reaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09B7E6288670FD00ED0B66 /* Reaction.swift */; }; FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */; }; @@ -1571,7 +1569,6 @@ 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _045_LastProfileUpdateTimestamp.swift; sourceTree = ""; }; 942BA9C02E4EA5BE007C4595 /* SessionLabelWithProBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionLabelWithProBadge.swift; sourceTree = ""; }; 942BA9C32E55AB51007C4595 /* UILabel+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UILabel+Utilities.swift"; sourceTree = ""; }; - 94363E542E5D83F60004EE43 /* TappableLabel+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TappableLabel+SwiftUI.swift"; sourceTree = ""; }; 94367C422C6C828500814252 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+DisappearingMessages.swift"; sourceTree = ""; }; 943C6D832B86B5F1004ACE64 /* Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localization.swift; sourceTree = ""; }; @@ -1856,7 +1853,6 @@ FD09799227FE693200936362 /* Interaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interaction.swift; sourceTree = ""; }; FD09799827FFC1A300936362 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; FD09799A27FFC82D00936362 /* Quote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quote.swift; sourceTree = ""; }; - FD09B7E228865FDA00ED0B66 /* HighlightMentionBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightMentionBackgroundView.swift; sourceTree = ""; }; FD09B7E4288670BB00ED0B66 /* _017_EmojiReacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _017_EmojiReacts.swift; sourceTree = ""; }; FD09B7E6288670FD00ED0B66 /* Reaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reaction.swift; sourceTree = ""; }; FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryViewModel.swift; sourceTree = ""; }; @@ -2876,7 +2872,6 @@ isa = PBXGroup; children = ( 94D716812E8FA19D008294EE /* AttributedLabel.swift */, - 94363E542E5D83F60004EE43 /* TappableLabel+SwiftUI.swift */, 942BA9402E4487EE007C4595 /* LightBox.swift */, 94B6BB032E3B208200E718BB /* Seperator+SwiftUI.swift */, 94B6BAFF2E3AE83500E718BB /* QRCodeView.swift */, @@ -3409,7 +3404,6 @@ B8BB82B423947F2D00BA5194 /* SNTextField.swift */, C3C3CF8824D8EED300E1CCE7 /* SNTextView.swift */, 7BBBDC43286EAD2D00747E59 /* TappableLabel.swift */, - FD09B7E228865FDA00ED0B66 /* HighlightMentionBackgroundView.swift */, 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */, C38EF3EE255B6DF6007E1867 /* GradientView.swift */, FD8A5B0F2DBF2F14004C689B /* NavBarSessionIcon.swift */, @@ -6288,10 +6282,8 @@ 7BBBDC44286EAD2D00747E59 /* TappableLabel.swift in Sources */, FD8A5B252DC05B16004C689B /* Number+Utilities.swift in Sources */, FDE5219C2E08E76C00061B8E /* SessionAsyncImage.swift in Sources */, - FD09B7E328865FDA00ED0B66 /* HighlightMentionBackgroundView.swift in Sources */, FD3FAB632AEB9A1500DC5421 /* ToastController.swift in Sources */, C331FFE72558FB0000070591 /* SNTextField.swift in Sources */, - 94363E552E5D84010004EE43 /* TappableLabel+SwiftUI.swift in Sources */, 942256962C23F8DD00C0FDBF /* CompatibleScrollingVStack.swift in Sources */, FD71165B28E6DDBC00B47552 /* StyledNavigationController.swift in Sources */, C331FFE32558FB0000070591 /* TabBar.swift in Sources */, diff --git a/SessionUIKit/Components/HighlightMentionBackgroundView.swift b/SessionUIKit/Components/HighlightMentionBackgroundView.swift deleted file mode 100644 index aad6caff04..0000000000 --- a/SessionUIKit/Components/HighlightMentionBackgroundView.swift +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import UIKit - -public extension NSAttributedString.Key { - static let currentUserMentionBackgroundColor: NSAttributedString.Key = NSAttributedString.Key(rawValue: "currentUserMentionBackgroundColor") - static let currentUserMentionBackgroundCornerRadius: NSAttributedString.Key = NSAttributedString.Key(rawValue: "currentUserMentionBackgroundCornerRadius") - static let currentUserMentionBackgroundPadding: NSAttributedString.Key = NSAttributedString.Key(rawValue: "currentUserMentionBackgroundPadding") -} - -public class HighlightMentionBackgroundView: UIView { - weak var targetLabel: UILabel? - var maxPadding: CGFloat = 0 - - init(targetLabel: UILabel) { - self.targetLabel = targetLabel - - super.init(frame: .zero) - - self.isOpaque = false - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Functions - - public func calculateMaxPadding(for attributedText: NSAttributedString) -> CGFloat { - var allMentionRadii: [CGFloat?] = [] - let path: CGMutablePath = CGMutablePath() - path.addRect(CGRect( - x: 0, - y: 0, - width: CGFloat.greatestFiniteMagnitude, - height: CGFloat.greatestFiniteMagnitude - )) - - let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString) - let frame: CTFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attributedText.length), path, nil) - let lines: [CTLine] = frame.lines - - lines.forEach { line in - let runs: [CTRun] = line.ctruns - - runs.forEach { run in - let attributes: NSDictionary = CTRunGetAttributes(run) - allMentionRadii.append( - attributes - .value(forKey: NSAttributedString.Key.currentUserMentionBackgroundPadding.rawValue) as? CGFloat - ) - } - } - - let maxRadii: CGFloat? = allMentionRadii - .compactMap { $0 } - .max() - - return (maxRadii ?? 0) - } - - // MARK: - Drawing - - override public func draw(_ rect: CGRect) { - guard - let targetLabel: UILabel = self.targetLabel, - let attributedText: NSAttributedString = targetLabel.attributedText, - let context = UIGraphicsGetCurrentContext() - else { return } - - // Need to invery the Y axis because iOS likes to render from the bottom left instead of the top left - context.textMatrix = .identity - context.translateBy(x: 0, y: bounds.size.height) - context.scaleBy(x: 1.0, y: -1.0) - - // Note: Calculations MUST happen based on the 'targetLabel' size as this class has extra padding - // which can result in calculations being off - let path = CGMutablePath() - let size = targetLabel.sizeThatFits(CGSize(width: targetLabel.bounds.width, height: .greatestFiniteMagnitude)) - path.addRect(CGRect(x: 0, y: 0, width: size.width, height: size.height), transform: .identity) - - let framesetter = CTFramesetterCreateWithAttributedString(attributedText as CFAttributedString) - let frame: CTFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, attributedText.length), path, nil) - let lines: [CTLine] = frame.lines - - var origins = [CGPoint](repeating: .zero, count: lines.count) - CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins) - - var currentMentionBounds: CGRect? = nil // Store mention bounding box - var lastMentionBackgroundColor: UIColor = .clear - var lastMentionBackgroundCornerRadius: CGFloat = 0 - - for lineIndex in 0.. Container { - let result: TappableLabel = TappableLabel() - result.setContentHuggingPriority(.required, for: .horizontal) - result.setContentHuggingPriority(.required, for: .vertical) - result.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - result.themeAttributedText = themeAttributedText - result.themeBackgroundColor = .clear - result.isOpaque = false - result.isUserInteractionEnabled = true - - return Container(label: result, maxWidth: maxWidth) - } - - public func updateUIView(_ container: Container, context: Context) { - container.label.themeAttributedText = themeAttributedText - container.maxWidth = maxWidth - container.invalidateIntrinsicContentSize() - container.setNeedsLayout() - container.layoutIfNeeded() - } - - public final class Container: UIView { - let label: TappableLabel - var maxWidth: CGFloat - private var widthCap: NSLayoutConstraint? - - init(label: TappableLabel, maxWidth: CGFloat) { - self.label = label - self.maxWidth = maxWidth - super.init(frame: .zero) - - addSubview(label) - label.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - label.topAnchor.constraint(equalTo: topAnchor), - label.leadingAnchor.constraint(equalTo: leadingAnchor), - label.trailingAnchor.constraint(equalTo: trailingAnchor), - label.bottomAnchor.constraint(equalTo: bottomAnchor) - ]) - - setContentHuggingPriority(.required, for: .horizontal) - setContentCompressionResistancePriority(.required, for: .horizontal) - setContentHuggingPriority(.required, for: .vertical) - setContentCompressionResistancePriority(.defaultLow, for: .vertical) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - public override func layoutSubviews() { - super.layoutSubviews() - - // Use the actual size SwiftUI assigned after .frame(maxHeight:) - let assignedWidth = min(bounds.width, maxWidth) - let assignedHeight = bounds.height - - // Make UILabel compute multi-line correctly - label.preferredMaxLayoutWidth = assignedWidth - - // Keep label’s internal text container width in sync for taps/highlights - label.textContainer.size = CGSize(width: assignedWidth, height: assignedHeight > 0 ? assignedHeight : .greatestFiniteMagnitude) - - // Decide truncation based on the final assigned height - guard let text = label.attributedText, text.length > 0, assignedWidth > 0 else { return } - - let info = layoutInfo(for: text, width: assignedWidth) // unlimited lines at this width - let total = info.totalHeight - - if assignedHeight > 0 && assignedHeight + 0.5 < total { - // Height is capped → compute how many lines fit and truncate tail - let linesFit = max(1, fittedLineCount(fromBottoms: info.lineBottoms, cap: assignedHeight)) - if label.numberOfLines != linesFit || label.lineBreakMode != .byTruncatingTail { - label.numberOfLines = linesFit - label.lineBreakMode = .byTruncatingTail - } - } else { - // No cap or content fits → unlimited wrapping - if label.numberOfLines != 0 || label.lineBreakMode != .byWordWrapping { - label.numberOfLines = 0 - label.lineBreakMode = .byWordWrapping - } - } - } - - public override var intrinsicContentSize: CGSize { - guard let text = label.attributedText, text.length > 0 else { - return label.intrinsicContentSize - } - // Hug natural (single-line) width if it fits; else wrap to maxWidth - let natural = measure(text, constrainedToWidth: nil) - if natural.width <= maxWidth { - return natural - } else { - let wrapped = measure(text, constrainedToWidth: maxWidth) - return CGSize(width: maxWidth, height: wrapped.height) - } - } - - public override func sizeThatFits(_ size: CGSize) -> CGSize { - // Respect a smaller proposed width (e.g., inside tight parents) - let cap = min(size.width > 0 ? size.width : .greatestFiniteMagnitude, maxWidth) - guard let text = label.attributedText, text.length > 0 else { - return label.sizeThatFits(CGSize(width: cap, height: .greatestFiniteMagnitude)) - } - let natural = measure(text, constrainedToWidth: nil) - if natural.width <= cap { - return natural - } else { - let wrapped = measure(text, constrainedToWidth: cap) - return CGSize(width: cap, height: wrapped.height) - } - } - - private func fittedLineCount(fromBottoms bottoms: [CGFloat], cap: CGFloat) -> Int { - var count = 0 - for b in bottoms { - if b <= cap { count += 1 } else { break } - } - return count - } - - /// Unlimited-lines measurement + per-line bottoms at a given width. - private func layoutInfo(for text: NSAttributedString, width: CGFloat) -> (totalHeight: CGFloat, lineBottoms: [CGFloat]) { - let storage = NSTextStorage(attributedString: text) - let layout = NSLayoutManager() - let container = NSTextContainer(size: CGSize(width: width, height: .greatestFiniteMagnitude)) - container.lineFragmentPadding = 0 - container.lineBreakMode = .byWordWrapping - container.maximumNumberOfLines = 0 - layout.addTextContainer(container) - storage.addLayoutManager(layout) - - _ = layout.glyphRange(for: container) - - var lineBottoms: [CGFloat] = [] - var glyphIndex = 0 - while glyphIndex < layout.numberOfGlyphs { - var lineRange = NSRange(location: 0, length: 0) - let frag = layout.lineFragmentUsedRect(forGlyphAt: glyphIndex, - effectiveRange: &lineRange, - withoutAdditionalLayout: true) - lineBottoms.append(ceil(frag.maxY)) - glyphIndex = NSMaxRange(lineRange) - } - - let used = layout.usedRect(for: container) - return (totalHeight: ceil(used.height), lineBottoms: lineBottoms) - } - - // Kept for intrinsicContentSize / width-hugging path - private func measure(_ text: NSAttributedString, constrainedToWidth width: CGFloat?) -> CGSize { - let storage = NSTextStorage(attributedString: text) - let layout = NSLayoutManager() - let container = NSTextContainer(size: CGSize(width: width ?? .greatestFiniteMagnitude, - height: .greatestFiniteMagnitude)) - container.lineFragmentPadding = 0 - container.lineBreakMode = label.lineBreakMode - container.maximumNumberOfLines = 0 - layout.addTextContainer(container) - storage.addLayoutManager(layout) - - _ = layout.glyphRange(for: container) - let used = layout.usedRect(for: container) - // ceil to avoid fractional clipping - return CGSize(width: ceil(used.width), height: ceil(used.height)) - } - } -} diff --git a/SessionUIKit/Components/TappableLabel.swift b/SessionUIKit/Components/TappableLabel.swift index bca946f830..f79ef2e9e7 100644 --- a/SessionUIKit/Components/TappableLabel.swift +++ b/SessionUIKit/Components/TappableLabel.swift @@ -15,7 +15,6 @@ public protocol TappableLabelDelegate: AnyObject { public class TappableLabel: UILabel { public private(set) var links: [String: NSRange] = [:] - private lazy var highlightedMentionBackgroundView: HighlightMentionBackgroundView = HighlightMentionBackgroundView(targetLabel: self) private(set) var layoutManager = NSLayoutManager() public private(set) var textContainer = NSTextContainer(size: CGSize.zero) private(set) var textStorage = NSTextStorage() { @@ -36,12 +35,6 @@ public class TappableLabel: UILabel { textStorage = NSTextStorage(attributedString: attributedText) findLinksAndRange(attributeString: attributedText) - highlightedMentionBackgroundView.maxPadding = highlightedMentionBackgroundView - .calculateMaxPadding(for: attributedText) - highlightedMentionBackgroundView.frame = self.bounds.insetBy( - dx: -highlightedMentionBackgroundView.maxPadding, - dy: -highlightedMentionBackgroundView.maxPadding - ) } } @@ -84,19 +77,6 @@ public class TappableLabel: UILabel { // MARK: - Layout - public override func didMoveToSuperview() { - super.didMoveToSuperview() - - // Note: Because we want the 'highlight' content to appear behind the label we need - // to add the 'highlightedMentionBackgroundView' below it in the view hierarchy - // - // In order to try and avoid adding even more complexity to UI components which use - // this 'TappableLabel' we are going some view hierarchy manipulation and forcing - // these elements to maintain the same superview - highlightedMentionBackgroundView.removeFromSuperview() - superview?.insertSubview(highlightedMentionBackgroundView, belowSubview: self) - } - public override func layoutSubviews() { super.layoutSubviews() @@ -106,11 +86,6 @@ public class TappableLabel: UILabel { preferredMaxLayoutWidth = bounds.width invalidateIntrinsicContentSize() } - - highlightedMentionBackgroundView.frame = self.frame.insetBy( - dx: -highlightedMentionBackgroundView.maxPadding, - dy: -highlightedMentionBackgroundView.maxPadding - ) } public override var intrinsicContentSize: CGSize { diff --git a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift index 33e6eaa0ab..12134fa28a 100644 --- a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift +++ b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift @@ -9,10 +9,6 @@ public extension NSAttributedString.Key { .themeForegroundColor, .themeBackgroundColor, .themeStrokeColor, .themeUnderlineColor, .themeStrikethroughColor ] - internal static let keysToIgnoreValidation: Set = [ - .currentUserMentionBackgroundColor, .currentUserMentionBackgroundCornerRadius, .currentUserMentionBackgroundPadding - ] - static let themeForegroundColor = NSAttributedString.Key("org.getsession.themeForegroundColor") static let themeBackgroundColor = NSAttributedString.Key("org.getsession.themeBackgroundColor") static let themeStrokeColor = NSAttributedString.Key("org.getsession.themeStrokeColor") @@ -166,8 +162,7 @@ public class ThemedAttributedString: Equatable, Hashable { for (key, value) in attributes { guard key.originalKey == nil && - NSAttributedString.Key.themedKeys.contains(key) == false && - NSAttributedString.Key.keysToIgnoreValidation.contains(key) == false + NSAttributedString.Key.themedKeys.contains(key) == false else { continue } if value is ThemeValue { From a15f94ec35b1d41faeeec74d9074b71ea8cfadfa Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 3 Oct 2025 17:19:45 +1000 Subject: [PATCH 056/162] WIP Updated attachment processing refactoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Refactored "SignalAttachment" to be "PendingAttachment" and "ProcessedAttachment" • Added libWebP and SDWebImageWebPCoder for WebP encoding • Added an `icon` option to the ImageDataManager • Updated display picture editing to use `ImageDataManager.DataSource` where possible (avoid loading as data) • Updated the group creation and display picture update processes to be (mostly) async/await • Updated PhotoLibrary attachment processing to be async/await • Updated the main logic in AttachmentUploadJob to be async/await • Fixed an issue where link previews wouldn't render correctly --- Session.xcodeproj/project.pbxproj | 53 +- .../xcshareddata/swiftpm/Package.resolved | 37 +- Session/Closed Groups/NewClosedGroupVC.swift | 77 +- .../ConversationVC+Interaction.swift | 131 ++- .../Conversations/ConversationViewModel.swift | 8 +- .../Conversations/Input View/InputView.swift | 8 +- .../Content Views/LinkPreviewState.swift | 27 +- .../Content Views/LinkPreviewView.swift | 30 +- .../SwiftUI/LinkPreviewView_SwiftUI.swift | 55 +- .../Message Cells/VisibleMessageCell.swift | 5 +- .../Settings/ThreadSettingsViewModel.swift | 184 ++-- .../GIFs/GifPickerCell.swift | 11 +- .../GIFs/GifPickerViewController.swift | 16 +- .../ImagePickerController.swift | 32 +- .../MessageInfoScreen.swift | 1 + .../PhotoCapture.swift | 33 +- .../PhotoCaptureViewController.swift | 4 +- .../PhotoLibrary.swift | 219 ++-- .../SendMediaNavigationController.swift | 173 ++-- Session/Meta/Session+SNUIKit.swift | 5 +- .../Settings.bundle/ThirdPartyLicenses.plist | 89 +- Session/Settings/SettingsViewModel.swift | 105 +- Session/Shared/Types/NavigatableState.swift | 10 +- .../Utilities/ImageLoading+Convenience.swift | 5 +- .../UIContextualAction+Utilities.swift | 4 +- .../Crypto/Crypto+Attachments.swift | 104 +- .../_036_GroupsRebuildChanges.swift | 16 +- .../Database/Models/Attachment.swift | 57 +- .../Database/Models/LinkPreview.swift | 46 +- .../Jobs/AttachmentDownloadJob.swift | 2 +- .../Jobs/AttachmentUploadJob.swift | 286 +++--- .../Jobs/ReuploadUserDisplayPictureJob.swift | 226 ++-- .../LibSession+UserProfile.swift | 9 +- .../AttachmentUploader.swift | 49 +- .../Attachments/SignalAttachment.swift | 961 ------------------ .../Errors/AttachmentError.swift | 44 +- .../MessageSender+Groups.swift | 584 +++++------ .../Utilities/AsyncAccessible.swift | 28 - .../Utilities/AttachmentManager.swift | 743 +++++++++++++- .../Utilities/DisplayPictureError.swift | 4 + .../Utilities/DisplayPictureManager.swift | 231 ++--- .../Utilities/Profile+Updating.swift | 151 ++- .../ShareNavController.swift | 438 +++----- SessionShareExtension/ThreadPickerVC.swift | 88 +- .../ThreadPickerViewModel.swift | 6 + .../Components/Input View/InputTextView.swift | 10 +- .../Components/ProfilePictureView.swift | 8 +- SessionUIKit/Configuration.swift | 7 +- SessionUIKit/Types/ImageDataManager.swift | 66 +- SessionUIKit/Types/SUIKImageFormat.swift | 21 - SessionUtilitiesKit/Media/DataSource.swift | 279 ----- SessionUtilitiesKit/Media/ImageFormat.swift | 25 - SessionUtilitiesKit/Media/MediaUtils.swift | 394 +++++-- .../Media/UTType+Utilities.swift | 11 + SessionUtilitiesKit/Types/FileManager.swift | 12 +- .../Utilities/AVURLAsset+Utilities.swift | 26 +- ...AttachmentApprovalInputAccessoryView.swift | 4 +- .../AttachmentApprovalViewController.swift | 154 +-- .../AttachmentItemCollection.swift | 36 +- .../AttachmentPrepViewController.swift | 35 +- .../AttachmentTextToolbar.swift | 2 +- .../Image Editing/ImageEditorCanvasView.swift | 3 +- .../Image Editing/ImageEditorModel.swift | 12 +- .../MediaMessageView.swift | 112 +- ...ModalActivityIndicatorViewController.swift | 27 +- .../Shared Views/ApprovalRailCellView.swift | 42 +- SignalUtilitiesKit/Utilities/AppSetup.swift | 4 + 67 files changed, 3097 insertions(+), 3588 deletions(-) delete mode 100644 SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift delete mode 100644 SessionMessagingKit/Utilities/AsyncAccessible.swift delete mode 100644 SessionUIKit/Types/SUIKImageFormat.swift delete mode 100644 SessionUtilitiesKit/Media/DataSource.swift delete mode 100644 SessionUtilitiesKit/Media/ImageFormat.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 353b7490e0..daf4ad7fa4 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -528,7 +528,6 @@ FD2272D82C34EDE7004D8A6C /* SnodeAPIEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272D72C34EDE6004D8A6C /* SnodeAPIEndpoint.swift */; }; FD2272DD2C34EFFA004D8A6C /* AppSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272DC2C34EFFA004D8A6C /* AppSetup.swift */; }; FD2272E02C3502BE004D8A6C /* Setting+Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272DF2C3502BE004D8A6C /* Setting+Theme.swift */; }; - FD2272E62C351378004D8A6C /* SUIKImageFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272E52C351378004D8A6C /* SUIKImageFormat.swift */; }; FD2272EA2C351CA7004D8A6C /* Threading.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272E92C351CA7004D8A6C /* Threading.swift */; }; FD2272EC2C352155004D8A6C /* Feature.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272EB2C352155004D8A6C /* Feature.swift */; }; FD2272EE2C3521D6004D8A6C /* FeatureConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2272ED2C3521D6004D8A6C /* FeatureConfig.swift */; }; @@ -561,7 +560,6 @@ FD23EA6328ED0B260058676E /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23EA6028ED0B260058676E /* CombineExtensions.swift */; }; FD245C50285065C700B966DD /* VisibleMessage+Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */; }; FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5FB2554B0A000555489 /* MessageReceiver.swift */; }; - FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF224255B6D5D007E1867 /* SignalAttachment.swift */; }; FD245C53285065DB00B966DD /* ProximityMonitoringManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */; }; FD245C55285065E500B966DD /* OpenGroupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */; }; FD245C56285065EA00B966DD /* SNProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7822553AAF200C340D1 /* SNProto.swift */; }; @@ -1034,7 +1032,6 @@ FDE521942E050B1100061B8E /* DismissCallbackAVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521932E050B0800061B8E /* DismissCallbackAVPlayerViewController.swift */; }; FDE5219A2E08DBB800061B8E /* ImageLoading+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */; }; FDE5219C2E08E76C00061B8E /* SessionAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE5219B2E08E76600061B8E /* SessionAsyncImage.swift */; }; - FDE5219E2E0D0B9B00061B8E /* AsyncAccessible.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE5219D2E0D0B9800061B8E /* AsyncAccessible.swift */; }; FDE521A02E0D230000061B8E /* ObservationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE5219F2E0D22FD00061B8E /* ObservationManager.swift */; }; FDE521A22E0D23AB00061B8E /* ObservableKey+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE521A12E0D23A200061B8E /* ObservableKey+SessionMessagingKit.swift */; }; FDE521A62E0E6C8C00061B8E /* MockNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */; }; @@ -1063,8 +1060,6 @@ FDE754C02C9BAEF6002A2623 /* Array+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754BF2C9BAEF6002A2623 /* Array+Utilities.swift */; }; FDE754CC2C9BAF37002A2623 /* MediaUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754C72C9BAF36002A2623 /* MediaUtils.swift */; }; FDE754CD2C9BAF37002A2623 /* UTType+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754C82C9BAF36002A2623 /* UTType+Utilities.swift */; }; - FDE754CE2C9BAF37002A2623 /* ImageFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754C92C9BAF36002A2623 /* ImageFormat.swift */; }; - FDE754CF2C9BAF37002A2623 /* DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754CA2C9BAF37002A2623 /* DataSource.swift */; }; FDE754D22C9BAF53002A2623 /* JobDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754D12C9BAF53002A2623 /* JobDependencies.swift */; }; FDE754D42C9BAF6B002A2623 /* UICollectionView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754D32C9BAF6B002A2623 /* UICollectionView+ReusableView.swift */; }; FDE754DB2C9BAF8A002A2623 /* Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE754D52C9BAF89002A2623 /* Crypto.swift */; }; @@ -1099,6 +1094,7 @@ FDEF57712C44D2D300131302 /* GeoLite2-Country-Blocks-IPv4 in Resources */ = {isa = PBXBuildFile; fileRef = FDEF57702C44D2D300131302 /* GeoLite2-Country-Blocks-IPv4 */; }; FDEFDC6C2E8361E000EBCD81 /* HTTPHeader+FileServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDEFDC6B2E8361DB00EBCD81 /* HTTPHeader+FileServer.swift */; }; FDEFDC6E2E83A74300EBCD81 /* _045_LastProfileUpdateTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDEFDC6D2E83A74200EBCD81 /* _045_LastProfileUpdateTimestamp.swift */; }; + FDEFDC732E8B9F3300EBCD81 /* SDWebImageWebPCoder in Frameworks */ = {isa = PBXBuildFile; productRef = FDEFDC722E8B9F3300EBCD81 /* SDWebImageWebPCoder */; }; FDF01FAD2A9ECC4200CAF969 /* SingletonConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF01FAC2A9ECC4200CAF969 /* SingletonConfig.swift */; }; FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; }; FDF0B7422804EA4F004C14C5 /* _007_SMK_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7412804EA4F004C14C5 /* _007_SMK_SetupStandardJobs.swift */; }; @@ -1696,7 +1692,6 @@ C374EEF325DB31D40073A857 /* VoiceMessageRecordingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageRecordingView.swift; sourceTree = ""; }; C379DCF3256735770002D4EB /* VisibleMessage+Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+Attachment.swift"; sourceTree = ""; }; C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SNProtoEnvelope+Conversion.swift"; sourceTree = ""; }; - C38EF224255B6D5D007E1867 /* SignalAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SignalAttachment.swift; path = "SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift"; sourceTree = SOURCE_ROOT; }; C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIDevice+featureSupport.swift"; path = "SessionUtilitiesKit/General/UIDevice+featureSupport.swift"; sourceTree = SOURCE_ROOT; }; C38EF240255B6D67007E1867 /* UIView+OWS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIView+OWS.swift"; path = "SignalUtilitiesKit/Utilities/UIView+OWS.swift"; sourceTree = SOURCE_ROOT; }; C38EF241255B6D67007E1867 /* Collection+OWS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Collection+OWS.swift"; path = "SignalUtilitiesKit/Utilities/Collection+OWS.swift"; sourceTree = SOURCE_ROOT; }; @@ -1912,7 +1907,6 @@ FD2272D72C34EDE6004D8A6C /* SnodeAPIEndpoint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnodeAPIEndpoint.swift; sourceTree = ""; }; FD2272DC2C34EFFA004D8A6C /* AppSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSetup.swift; sourceTree = ""; }; FD2272DF2C3502BE004D8A6C /* Setting+Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Setting+Theme.swift"; sourceTree = ""; }; - FD2272E52C351378004D8A6C /* SUIKImageFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUIKImageFormat.swift; sourceTree = ""; }; FD2272E92C351CA7004D8A6C /* Threading.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Threading.swift; sourceTree = ""; }; FD2272EB2C352155004D8A6C /* Feature.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Feature.swift; sourceTree = ""; }; FD2272ED2C3521D6004D8A6C /* FeatureConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeatureConfig.swift; sourceTree = ""; }; @@ -2306,7 +2300,6 @@ FDE521932E050B0800061B8E /* DismissCallbackAVPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissCallbackAVPlayerViewController.swift; sourceTree = ""; }; FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImageLoading+Convenience.swift"; sourceTree = ""; }; FDE5219B2E08E76600061B8E /* SessionAsyncImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionAsyncImage.swift; sourceTree = ""; }; - FDE5219D2E0D0B9800061B8E /* AsyncAccessible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAccessible.swift; sourceTree = ""; }; FDE5219F2E0D22FD00061B8E /* ObservationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationManager.swift; sourceTree = ""; }; FDE521A12E0D23A200061B8E /* ObservableKey+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ObservableKey+SessionMessagingKit.swift"; sourceTree = ""; }; FDE6E99729F8E63A00F93C5D /* Accessibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Accessibility.swift; sourceTree = ""; }; @@ -2337,8 +2330,6 @@ FDE754BF2C9BAEF6002A2623 /* Array+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Array+Utilities.swift"; sourceTree = ""; }; FDE754C72C9BAF36002A2623 /* MediaUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaUtils.swift; sourceTree = ""; }; FDE754C82C9BAF36002A2623 /* UTType+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UTType+Utilities.swift"; sourceTree = ""; }; - FDE754C92C9BAF36002A2623 /* ImageFormat.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageFormat.swift; sourceTree = ""; }; - FDE754CA2C9BAF37002A2623 /* DataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataSource.swift; sourceTree = ""; }; FDE754D12C9BAF53002A2623 /* JobDependencies.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JobDependencies.swift; sourceTree = ""; }; FDE754D32C9BAF6B002A2623 /* UICollectionView+ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UICollectionView+ReusableView.swift"; sourceTree = ""; }; FDE754D52C9BAF89002A2623 /* Crypto.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Crypto.swift; sourceTree = ""; }; @@ -2530,6 +2521,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + FDEFDC732E8B9F3300EBCD81 /* SDWebImageWebPCoder in Frameworks */, FD9BDE012A5D24EA005F1EBC /* SessionUIKit.framework in Frameworks */, FD6673FA2D7021F800041530 /* SessionUtil in Frameworks */, FD2286732C38D43900BC06F7 /* DifferenceKit in Frameworks */, @@ -3035,8 +3027,6 @@ B8A582AF258C665E00AFD84C /* Media */ = { isa = PBXGroup; children = ( - FDE754CA2C9BAF37002A2623 /* DataSource.swift */, - FDE754C92C9BAF36002A2623 /* ImageFormat.swift */, FDE754C72C9BAF36002A2623 /* MediaUtils.swift */, FDE754C82C9BAF36002A2623 /* UTType+Utilities.swift */, ); @@ -3191,7 +3181,6 @@ isa = PBXGroup; children = ( FDF0B7562807F35E004C14C5 /* Errors */, - C3D9E3B52567685D0040E4F3 /* Attachments */, C32C5D22256DD496003C73A2 /* Link Previews */, C379DC6825672B5E0002D4EB /* Notifications */, C32C59F8256DB5A6003C73A2 /* Pollers */, @@ -3658,7 +3647,6 @@ 94B6BAF92E38454F00E718BB /* SessionProState.swift */, FD428B1E2B4B758B006D0888 /* AppReadiness.swift */, FDE5218D2E03A06700061B8E /* AttachmentManager.swift */, - FDE5219D2E0D0B9800061B8E /* AsyncAccessible.swift */, FD47E0B02AA6A05800A55E41 /* Authentication+SessionMessagingKit.swift */, FD859EF127BF6BA200510D0C /* Data+Utilities.swift */, C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, @@ -3837,14 +3825,6 @@ path = Utilities; sourceTree = ""; }; - C3D9E3B52567685D0040E4F3 /* Attachments */ = { - isa = PBXGroup; - children = ( - C38EF224255B6D5D007E1867 /* SignalAttachment.swift */, - ); - path = Attachments; - sourceTree = ""; - }; C3F0A58F255C8E3D007BE2A3 /* Meta */ = { isa = PBXGroup; children = ( @@ -4622,7 +4602,6 @@ FD71163128E2C42A00B47552 /* IconSize.swift */, FDB11A602DD5BDC900BEF49F /* ImageDataManager.swift */, 943C6D832B86B5F1004ACE64 /* Localization.swift */, - FD2272E52C351378004D8A6C /* SUIKImageFormat.swift */, ); path = Types; sourceTree = ""; @@ -5507,6 +5486,7 @@ FD6A39122C2A946A00762359 /* SwiftProtobuf */, FD2286722C38D43900BC06F7 /* DifferenceKit */, FD6673F92D7021F800041530 /* SessionUtil */, + FDEFDC722E8B9F3300EBCD81 /* SDWebImageWebPCoder */, ); productName = SessionMessagingKit; productReference = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; @@ -5771,6 +5751,7 @@ FD756BEE2D06686500BD7199 /* XCRemoteSwiftPackageReference "session-lucide" */, 946F5A712D5DA3AC00A5ADCE /* XCRemoteSwiftPackageReference "PunycodeSwift" */, FD6673F42D7021E700041530 /* XCRemoteSwiftPackageReference "libsession-util-spm" */, + FDEFDC712E8B9F3300EBCD81 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */, ); productRefGroup = D221A08A169C9E5E00537ABF /* Products */; projectDirPath = ""; @@ -6274,7 +6255,6 @@ FD37E9F628A5F106003AE748 /* Configuration.swift in Sources */, 94AAB1512E1F753500A6FA18 /* CyclicGradientView.swift in Sources */, FD8A5B1E2DBF4BBC004C689B /* ScreenLock+Errors.swift in Sources */, - FD2272E62C351378004D8A6C /* SUIKImageFormat.swift in Sources */, 7BF8D1FB2A70AF57005F1D6E /* SwiftUI+Theme.swift in Sources */, FDB11A612DD5BDCC00BEF49F /* ImageDataManager.swift in Sources */, FD6673FD2D77F54600041530 /* ScreenLockViewController.swift in Sources */, @@ -6557,7 +6537,6 @@ FD6673FF2D77F9C100041530 /* ScreenLock.swift in Sources */, FDB3DA8B2E24834000148F8D /* AVURLAsset+Utilities.swift in Sources */, FD848B9328420164000E298B /* UnicodeScalar+Utilities.swift in Sources */, - FDE754CE2C9BAF37002A2623 /* ImageFormat.swift in Sources */, FDB11A542DCD7A7F00BEF49F /* Task+Utilities.swift in Sources */, FDE7551A2C9BC169002A2623 /* UIApplicationState+Utilities.swift in Sources */, 94C58AC92D2E037200609195 /* Permissions.swift in Sources */, @@ -6585,7 +6564,6 @@ FD428B1B2B4B6098006D0888 /* Notifications+Lifecycle.swift in Sources */, 7B0EFDEE274F598600FFAAE7 /* TimestampUtils.swift in Sources */, FD2272D02C34EBD0004D8A6C /* FileManager.swift in Sources */, - FDE754CF2C9BAF37002A2623 /* DataSource.swift in Sources */, FD10AF122AF85D11007709E5 /* Feature+ServiceNetwork.swift in Sources */, B8F5F58325EC94A6003BF8D4 /* Collection+Utilities.swift in Sources */, FD17D7A127F40D2500122BE0 /* Storage.swift in Sources */, @@ -6639,7 +6617,6 @@ FD8ECF7B29340FFD00C0D1BB /* LibSession+SessionMessagingKit.swift in Sources */, FDD23AE62E458CAA0057E853 /* _023_SplitSnodeReceivedMessageInfo.swift in Sources */, 7B81682828B310D50069F315 /* _015_HomeQueryOptimisationIndexes.swift in Sources */, - FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */, FD2273002C352D8E004D8A6C /* LibSession+GroupKeys.swift in Sources */, FD70F25C2DC1F184003729B7 /* _040_MessageDeduplicationTable.swift in Sources */, FD2272FB2C352D8E004D8A6C /* LibSession+UserGroups.swift in Sources */, @@ -6671,7 +6648,6 @@ FD22727E2C32911C004D8A6C /* GarbageCollectionJob.swift in Sources */, FD09B7E7288670FD00ED0B66 /* Reaction.swift in Sources */, FD245C5A2850660100B966DD /* LinkPreviewDraft.swift in Sources */, - FDE5219E2E0D0B9B00061B8E /* AsyncAccessible.swift in Sources */, FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */, FD22726D2C32911C004D8A6C /* CheckForAppUpdatesJob.swift in Sources */, FD2273082C353109004D8A6C /* DisplayPictureManager.swift in Sources */, @@ -8370,7 +8346,7 @@ GCC_WARN_UNUSED_VALUE = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.6; - LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; + LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util_copy"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; MARKETING_VERSION = 2.14.4; ONLY_ACTIVE_ARCH = YES; @@ -8446,7 +8422,7 @@ GCC_WARN_UNUSED_VALUE = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.6; - LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; + LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util_copy"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; MARKETING_VERSION = 2.14.4; ONLY_ACTIVE_ARCH = NO; @@ -8931,7 +8907,7 @@ GCC_WARN_UNUSED_VALUE = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.6; - LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; + LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util_copy"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; MARKETING_VERSION = 2.14.4; ONLY_ACTIVE_ARCH = YES; @@ -9512,7 +9488,7 @@ GCC_WARN_UNUSED_VALUE = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.6; - LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; + LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util_copy"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; MARKETING_VERSION = 2.14.4; ONLY_ACTIVE_ARCH = NO; @@ -10517,6 +10493,14 @@ minimumVersion = 0.468.0; }; }; + FDEFDC712E8B9F3300EBCD81 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SDWebImage/SDWebImageWebPCoder.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.14.6; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -10663,6 +10647,11 @@ package = FD6A390E2C2A93CD00762359 /* XCRemoteSwiftPackageReference "WebRTC" */; productName = WebRTC; }; + FDEFDC722E8B9F3300EBCD81 /* SDWebImageWebPCoder */ = { + isa = XCSwiftPackageProductDependency; + package = FDEFDC712E8B9F3300EBCD81 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */; + productName = SDWebImageWebPCoder; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = D221A080169C9E5E00537ABF /* Project object */; diff --git a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 36a72ad9fd..1777b508fc 100644 --- a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "3976430cfdaea7445596ad6123334158bdc83e4997da535d15a15afc3c7aa091", + "originHash" : "659be7201ad78ce5b1fb117c3155ae4e9847a563ac63792741d83100ec19567d", "pins" : [ { "identity" : "cocoalumberjack", "kind" : "remoteSourceControl", "location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git", "state" : { - "revision" : "4b8714a7fb84d42393314ce897127b3939885ec3", - "version" : "3.8.5" + "revision" : "a9ed4b6f9bdedce7d77046f43adfb8ce1fd54114", + "version" : "3.9.0" } }, { @@ -55,6 +55,15 @@ "version" : "1.5.6" } }, + { + "identity" : "libwebp-xcode", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/libwebp-Xcode.git", + "state" : { + "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2", + "version" : "1.5.0" + } + }, { "identity" : "nimble", "kind" : "remoteSourceControl", @@ -91,6 +100,24 @@ "version" : "7.5.0" } }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage.git", + "state" : { + "revision" : "34cf2423a2c4088d06a3b08655603b5bc3eeeb3a", + "version" : "5.21.2" + } + }, + { + "identity" : "sdwebimagewebpcoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImageWebPCoder.git", + "state" : { + "revision" : "f534cfe830a7807ecc3d0332127a502426cfa067", + "version" : "0.14.6" + } + }, { "identity" : "session-grdb-swift", "kind" : "remoteSourceControl", @@ -105,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/session-foundation/session-lucide.git", "state" : { - "revision" : "af00ad53d714823e07f984aadd7af38bafaae69e", - "version" : "0.473.0" + "revision" : "43efda6bc6f116ac620810e8955796be6c4c0e1d", + "version" : "0.473.2" } }, { diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index c332d1b6a2..97f6e1eb93 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -419,50 +419,51 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate let selectedProfiles: [(String, Profile?)] = self.selectedProfileIds.map { id in (id, self.contacts.first { $0.profileId == id }?.profile) } - - ModalActivityIndicatorViewController.present(fromViewController: navigationController!) { [weak self, dependencies] activityIndicatorViewController in - MessageSender - .createGroup( + + let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController(onAppear: { _ in }) + navigationController?.present(indicator, animated: false) + + Task(priority: .userInitiated) { [weak self] in + guard let self = self else { return } + + do { + let thread: SessionThread = try await MessageSender.createGroup( name: name, description: nil, - displayPictureData: nil, + displayPicture: nil, members: selectedProfiles, using: dependencies ) - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .receive(on: DispatchQueue.main) - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .finished: break - case .failure: - self?.dismiss(animated: true, completion: nil) // Dismiss the loader - - let modal: ConfirmationModal = ConfirmationModal( - targetView: self?.view, - info: ConfirmationModal.Info( - title: "groupError".localized(), - body: .text("groupErrorCreate".localized()), - cancelTitle: "okay".localized(), - cancelStyle: .alert_text - ) - ) - self?.present(modal, animated: true) - } - }, - receiveValue: { thread in - /// When this is triggered via the "Recreate Group" action for Legacy Groups the screen will have been - /// pushed instead of presented and, as a result, we need to dismiss the `activityIndicatorViewController` - /// and want the transition to be animated in order to behave nicely - dependencies[singleton: .app].presentConversationCreatingIfNeeded( - for: thread.id, - variant: thread.variant, - action: .none, - dismissing: (self?.presentingViewController ?? activityIndicatorViewController), - animated: (self?.presentingViewController == nil) + + /// When this is triggered via the "Recreate Group" action for Legacy Groups the screen will have been + /// pushed instead of presented and, as a result, we need to dismiss the `activityIndicatorViewController` + /// and want the transition to be animated in order to behave nicely + await MainActor.run { [weak self, dependencies] in + dependencies[singleton: .app].presentConversationCreatingIfNeeded( + for: thread.id, + variant: thread.variant, + action: .none, + dismissing: (self?.presentingViewController ?? indicator), + animated: (self?.presentingViewController == nil) + ) + } + } + catch { + await MainActor.run { [weak self] in + self?.dismiss(animated: true, completion: nil) // Dismiss the loader + + let modal: ConfirmationModal = ConfirmationModal( + targetView: self?.view, + info: ConfirmationModal.Info( + title: "groupError".localized(), + body: .text("groupErrorCreate".localized()), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text ) - } - ) + ) + self?.present(modal, animated: true) + } + } } } } diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index ddf3b0f18f..0ba7d65056 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -274,7 +274,7 @@ extension ConversationVC: func sendMediaNav( _ sendMediaNavigationController: SendMediaNavigationController, - didApproveAttachments attachments: [SignalAttachment], + didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, messageText: String? @@ -304,7 +304,7 @@ extension ConversationVC: func attachmentApproval( _ attachmentApproval: AttachmentApprovalViewController, - didApproveAttachments attachments: [SignalAttachment], + didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, messageText: String? @@ -330,7 +330,7 @@ extension ConversationVC: snInputView.text = (newMessageText ?? "") } - func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) { + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: PendingAttachment) { } func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) { @@ -413,24 +413,25 @@ extension ConversationVC: } let fileName: String = (urlResourceValues.name ?? "attachment".localized()) - guard let dataSource = DataSourcePath(fileUrl: url, sourceFilename: urlResourceValues.name, shouldDeleteOnDeinit: false, using: dependencies) else { - DispatchQueue.main.async { [weak self] in - self?.viewModel.showToast(text: "attachmentsErrorLoad".localized()) - } - return - } - dataSource.sourceFilename = fileName + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .file(url), + utType: type, + sourceFilename: fileName, + using: dependencies + ) - // Although we want to be able to send higher quality attachments through the document picker - // it's more imporant that we ensure the sent format is one all clients can accept (e.g. *not* quicktime .mov) - guard !SignalAttachment.isInvalidVideo(dataSource: dataSource, type: type) else { - self?.showAttachmentApprovalDialogAfterProcessingVideo(at: url, with: fileName) + /// Although we want to be able to send higher quality attachments through the document picker + /// it's more imporant that we ensure the sent format is one all clients can accept (e.g. *not* quicktime .mov) + if + UTType.supportedVideoTypes.contains(pendingAttachment.utType) && + !UTType.supportedOutputVideoTypes.contains(pendingAttachment.utType) + { + self?.showAttachmentApprovalDialogAfterProcessingVideo(pendingAttachment) return } // "Document picker" attachments _SHOULD NOT_ be resized - let attachment = SignalAttachment.attachment(dataSource: dataSource, type: type, imageQuality: .original, using: dependencies) - self?.showAttachmentApprovalDialog(for: [ attachment ]) + self?.showAttachmentApprovalDialog(for: [ pendingAttachment ]) }, wasCancelled: { [weak self] _ in self?.showInputAccessoryView() @@ -483,17 +484,18 @@ extension ConversationVC: // MARK: - GifPickerViewControllerDelegate - func gifPickerDidSelect(attachment: SignalAttachment) { + func gifPickerDidSelect(attachment: PendingAttachment) { showAttachmentApprovalDialog(for: [ attachment ]) } - func showAttachmentApprovalDialog(for attachments: [SignalAttachment]) { + func showAttachmentApprovalDialog(for attachments: [PendingAttachment]) { guard let navController = AttachmentApprovalViewController.wrappedInNavController( threadId: self.viewModel.threadData.threadId, threadVariant: self.viewModel.threadData.threadVariant, attachments: attachments, approvalDelegate: self, disableLinkPreviewImageDownload: (self.viewModel.threadData.threadCanUpload != true), + didLoadLinkPreview: nil, using: self.viewModel.dependencies ) else { return } navController.modalPresentationStyle = .fullScreen @@ -501,36 +503,28 @@ extension ConversationVC: present(navController, animated: true, completion: nil) } - func showAttachmentApprovalDialogAfterProcessingVideo(at url: URL, with fileName: String) { - ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: true, message: nil) { [weak self, dependencies = viewModel.dependencies] modalActivityIndicator in - - guard let dataSource = DataSourcePath(fileUrl: url, sourceFilename: fileName, shouldDeleteOnDeinit: false, using: dependencies) else { - self?.showErrorAlert(for: SignalAttachment.empty(using: dependencies)) - return - } - dataSource.sourceFilename = fileName - - SignalAttachment - .compressVideoAsMp4( - dataSource: dataSource, - type: .mpeg4Movie, + func showAttachmentApprovalDialogAfterProcessingVideo(_ pendingAttachment: PendingAttachment) { + let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController( + canCancel: true + ) + present(indicator, animated: false) + + Task.detached(priority: .userInitiated) { [weak self, indicator, dependencies = viewModel.dependencies] in + do { + let convertedAttachment: PendingAttachment = try await pendingAttachment.compressAsMp4Video( using: dependencies ) - .attachmentPublisher - .sinkUntilComplete( - receiveValue: { [weak self] attachment in - guard !modalActivityIndicator.wasCancelled else { return } - - modalActivityIndicator.dismiss { - guard !attachment.hasError else { - self?.showErrorAlert(for: attachment) - return - } - - self?.showAttachmentApprovalDialog(for: [ attachment ]) - } - } - ) + guard await !indicator.wasCancelled else { return } + + await indicator.dismiss { + self?.showAttachmentApprovalDialog(for: [ convertedAttachment ]) + } + } + catch { + await indicator.dismiss { + self?.showErrorAlert(for: error) + } + } } } @@ -659,18 +653,13 @@ extension ConversationVC: func sendMessage( text: String, - attachments: [SignalAttachment] = [], + attachments: [PendingAttachment] = [], linkPreviewDraft: LinkPreviewDraft? = nil, quoteModel: QuotedReplyModel? = nil, hasPermissionToSendSeed: Bool = false ) { guard !showBlockedModalIfNeeded() else { return } - // Handle attachment errors if applicable - if let failedAttachment: SignalAttachment = attachments.first(where: { $0.hasError }) { - return showErrorAlert(for: failedAttachment) - } - let processedText: String = replaceMentions(in: text.trimmingCharacters(in: .whitespacesAndNewlines)) // If we have no content then do nothing @@ -926,18 +915,20 @@ extension ConversationVC: // MARK: --Attachments - func didPasteImageFromPasteboard(_ image: UIImage) { - guard let imageData = image.jpegData(compressionQuality: 1.0) else { return } + func didPasteImageDataFromPasteboard(_ imageData: Data) { + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .media(UUID().uuidString, imageData), + sourceFilename: nil, + using: viewModel.dependencies + ) - let dataSource = DataSourceValue(data: imageData, dataType: .jpeg, using: viewModel.dependencies) - let attachment = SignalAttachment.attachment(dataSource: dataSource, type: .jpeg, imageQuality: .medium, using: viewModel.dependencies) - guard let approvalVC = AttachmentApprovalViewController.wrappedInNavController( threadId: self.viewModel.threadData.threadId, threadVariant: self.viewModel.threadData.threadVariant, - attachments: [ attachment ], + attachments: [ pendingAttachment ], approvalDelegate: self, disableLinkPreviewImageDownload: (self.viewModel.threadData.threadCanUpload != true), + didLoadLinkPreview: nil, using: self.viewModel.dependencies ) else { return } approvalVC.modalPresentationStyle = .fullScreen @@ -2852,21 +2843,15 @@ extension ConversationVC: // Get data let fileName = ("messageVoice".localized() as NSString) .appendingPathExtension("m4a") // stringlint:ignore - let dataSourceOrNil = DataSourcePath(fileUrl: audioRecorder.url, sourceFilename: fileName, shouldDeleteOnDeinit: true, using: viewModel.dependencies) - self.audioRecorder = nil - - guard let dataSource = dataSourceOrNil else { - return Log.error(.conversation, "Couldn't load recorded data.") - } - - let attachment = SignalAttachment.voiceMessageAttachment(dataSource: dataSource, type: .mpeg4Audio, using: viewModel.dependencies) - - guard !attachment.hasError else { - return showErrorAlert(for: attachment) - } + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .voiceMessage(audioRecorder.url), + utType: .mpeg4Audio, + sourceFilename: fileName, + using: viewModel.dependencies + ) // Send attachment - sendMessage(text: "", attachments: [attachment]) + sendMessage(text: "", attachments: [pendingAttachment]) } func cancelVoiceMessageRecording() { @@ -2911,13 +2896,13 @@ extension ConversationVC: // MARK: - Convenience - func showErrorAlert(for attachment: SignalAttachment) { + @MainActor func showErrorAlert(for error: Error) { DispatchQueue.main.async { [weak self] in let modal: ConfirmationModal = ConfirmationModal( targetView: self?.view, info: ConfirmationModal.Info( title: "attachmentsErrorSending".localized(), - body: .text(attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage), + body: .text("\(error)"), cancelTitle: "okay".localized(), cancelStyle: .alert_text ) diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 804521bd4d..5ad831c0b7 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -719,7 +719,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold public func optimisticallyAppendOutgoingMessage( text: String?, sentTimestampMs: Int64, - attachments: [SignalAttachment]?, + attachments: [PendingAttachment]?, linkPreviewDraft: LinkPreviewDraft?, quoteModel: QuotedReplyModel? ) -> OptimisticMessageData { @@ -745,10 +745,12 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold isProMessage: dependencies[cache: .libSession].isSessionPro, using: dependencies ) - let optimisticAttachments: [Attachment]? = attachments - .map { AttachmentUploader.prepare(attachments: $0, using: dependencies) } + let optimisticAttachments: [Attachment]? = try? attachments.map { + try AttachmentUploader.prepare(attachments: $0, using: dependencies) + } let linkPreviewAttachment: Attachment? = linkPreviewDraft.map { draft in try? LinkPreview.generateAttachmentIfPossible( + urlString: draft.urlString, imageData: draft.jpegImageData, type: .jpeg, using: dependencies diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index b75531a7e0..bc6ac6722c 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -27,7 +27,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M private lazy var linkPreviewView: LinkPreviewView = { let maxWidth: CGFloat = (self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset) - return LinkPreviewView(maxWidth: maxWidth) { [weak self] in + return LinkPreviewView(maxWidth: maxWidth, using: dependencies) { [weak self] in self?.linkPreviewInfo = nil self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } } @@ -331,8 +331,8 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M characterLimitLabelTapGestureRecognizer.isEnabled = (numberOfCharactersLeft < Self.thresholdForCharacterLimit) } - func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage) { - delegate?.didPasteImageFromPasteboard(image) + func didPasteImageDataFromPasteboard(_ inputTextView: InputTextView, imageData: Data) { + delegate?.didPasteImageDataFromPasteboard(imageData) } // We want to show either a link preview or a quote draft, but never both at the same time. When trying to @@ -669,5 +669,5 @@ protocol InputViewDelegate: ExpandingAttachmentsButtonDelegate, VoiceMessageReco func handleCharacterLimitLabelTapped() func inputTextViewDidChangeContent(_ inputTextView: InputTextView) func handleMentionSelected(_ mentionInfo: MentionInfo, from view: MentionSelectionView) - func didPasteImageFromPasteboard(_ image: UIImage) + func didPasteImageDataFromPasteboard(_ imageData: Data) } diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift index 0a57b99575..7b62f3b844 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit @@ -9,7 +10,7 @@ protocol LinkPreviewState { var urlString: String? { get } var title: String? { get } var imageState: LinkPreview.ImageState { get } - var image: UIImage? { get } + var imageSource: ImageDataManager.DataSource? { get } } public extension LinkPreview { @@ -27,7 +28,7 @@ public extension LinkPreview { var urlString: String? { nil } var title: String? { nil } var imageState: LinkPreview.ImageState { .none } - var image: UIImage? { nil } + var imageSource: ImageDataManager.DataSource? { nil } } // MARK: DraftState @@ -48,14 +49,14 @@ public extension LinkPreview { return .none } - var image: UIImage? { + var imageSource: ImageDataManager.DataSource? { guard let jpegImageData = linkPreviewDraft.jpegImageData else { return nil } guard let image = UIImage(data: jpegImageData) else { Log.error("[LinkPreview] Could not load image: \(jpegImageData.count)") return nil } - return image + return .image(urlString ?? "Invalid_Link_Preview", image) } // MARK: - Type Specific @@ -101,19 +102,17 @@ public extension LinkPreview { } } - var image: UIImage? { + var imageSource: ImageDataManager.DataSource? { // Note: We don't check if the image is valid here because that can be confirmed // in 'imageState' and it's a little inefficient - guard imageAttachment?.isImage == true else { return nil } - guard let imageData: Data = try? imageAttachment?.readDataFromFile(using: dependencies) else { - return nil - } - guard let image = UIImage(data: imageData) else { - Log.error("[LinkPreview] Could not load image: \(imageAttachment?.downloadUrl ?? "unknown")") - return nil - } + guard + imageAttachment?.isImage == true, + let imageDownloadUrl: String = imageAttachment?.downloadUrl, + let path: String = try? dependencies[singleton: .attachmentManager] + .path(for: imageDownloadUrl) + else { return nil } - return image + return .url(URL(fileURLWithPath: path)) } // MARK: - Type Specific diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift index 7ec1765625..10ff9a5e55 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift @@ -10,6 +10,7 @@ final class LinkPreviewView: UIView { private static let loaderSize: CGFloat = 24 private static let cancelButtonSize: CGFloat = 45 + private let dependencies: Dependencies private let maxWidth: CGFloat private let onCancel: (() -> ())? @@ -23,7 +24,7 @@ final class LinkPreviewView: UIView { public var previewView: UIView { hStackView } private lazy var imageView: SessionImageView = { - let result: SessionImageView = SessionImageView() + let result: SessionImageView = SessionImageView(dataManager: dependencies[singleton: .imageDataManager]) result.contentMode = .scaleAspectFill return result @@ -88,7 +89,12 @@ final class LinkPreviewView: UIView { // MARK: - Initialization - init(maxWidth: CGFloat, onCancel: (() -> ())? = nil) { + init( + maxWidth: CGFloat, + using dependencies: Dependencies, + onCancel: (() -> ())? = nil + ) { + self.dependencies = dependencies self.maxWidth = maxWidth self.onCancel = onCancel @@ -153,10 +159,13 @@ final class LinkPreviewView: UIView { ) { cancelButton.removeFromSuperview() - var image: UIImage? = state.image - let stateHasImage: Bool = (image != nil) - if image == nil && (state is LinkPreview.DraftState || state is LinkPreview.SentState) { - image = UIImage(named: "Link")?.withRenderingMode(.alwaysTemplate) + var imageSource: ImageDataManager.DataSource? = state.imageSource + let stateHasImage: Bool = (imageSource != nil && imageSource?.contentExists == true) + if + (imageSource == nil || imageSource?.contentExists != true) && + (state is LinkPreview.DraftState || state is LinkPreview.SentState) + { + imageSource = .icon(.link, size: 32, renderingMode: .alwaysTemplate) } // Image view @@ -165,7 +174,10 @@ final class LinkPreviewView: UIView { imageViewContainerHeightConstraint.constant = imageViewContainerSize imageViewContainer.layer.cornerRadius = (state is LinkPreview.SentState ? 0 : 8) - imageView.image = image + if let source: ImageDataManager.DataSource = imageSource { + imageView.loadImage(source) + } + imageView.themeTintColor = (isOutgoing ? .messageBubble_outgoingText : .messageBubble_incomingText @@ -173,8 +185,8 @@ final class LinkPreviewView: UIView { imageView.contentMode = (stateHasImage ? .scaleAspectFill : .center) // Loader - loader.alpha = (image != nil ? 0 : 1) - if image != nil { loader.stopAnimating() } else { loader.startAnimating() } + loader.alpha = (imageSource != nil ? 0 : 1) + if imageSource != nil { loader.stopAnimating() } else { loader.startAnimating() } // Title titleLabel.text = state.title diff --git a/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift b/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift index 82ba8d086d..82419b5a63 100644 --- a/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift +++ b/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift @@ -1,11 +1,13 @@ // Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import SwiftUI +import Lucide import SessionUIKit import SessionMessagingKit public struct LinkPreviewView_SwiftUI: View { private var state: LinkPreviewState + private var dataManager: ImageDataManagerType private var isOutgoing: Bool private let maxWidth: CGFloat private var messageViewModel: MessageViewModel? @@ -18,6 +20,7 @@ public struct LinkPreviewView_SwiftUI: View { init( state: LinkPreviewState, + dataManager: ImageDataManagerType, isOutgoing: Bool, maxWidth: CGFloat = .infinity, messageViewModel: MessageViewModel? = nil, @@ -26,6 +29,7 @@ public struct LinkPreviewView_SwiftUI: View { onCancel: (() -> ())? = nil ) { self.state = state + self.dataManager = dataManager self.isOutgoing = isOutgoing self.maxWidth = maxWidth self.messageViewModel = messageViewModel @@ -48,25 +52,36 @@ public struct LinkPreviewView_SwiftUI: View { ) { // Link preview image let imageSize: CGFloat = state is LinkPreview.SentState ? 100 : 80 - if let linkPreviewImage: UIImage = state.image { - Image(uiImage: linkPreviewImage) - .resizable() - .scaledToFill() - .foregroundColor( - themeColor: isOutgoing ? - .messageBubble_outgoingText : - .messageBubble_incomingText - ) - .frame( - width: imageSize, - height: imageSize - ) - .cornerRadius(state is LinkPreview.SentState ? 0 : 8) - } else if - state is LinkPreview.DraftState || state is LinkPreview.SentState, - let defaultImage: UIImage = UIImage(named: "Link")?.withRenderingMode(.alwaysTemplate) - { - Image(uiImage: defaultImage) + if let linkPreviewImageSource: ImageDataManager.DataSource = state.imageSource { + SessionAsyncImage( + source: linkPreviewImageSource, + dataManager: dataManager, + content: { image in + image + .resizable() + .scaledToFill() + .foregroundColor( + themeColor: isOutgoing ? + .messageBubble_outgoingText : + .messageBubble_incomingText + ) + .frame( + width: imageSize, + height: imageSize + ) + .cornerRadius(state is LinkPreview.SentState ? 0 : 8) + }, + placeholder: { + ThemeColor(.alert_background) + .frame( + width: imageSize, + height: imageSize + ) + .cornerRadius(state is LinkPreview.SentState ? 0 : 8) + } + ) + } else if state is LinkPreview.DraftState || state is LinkPreview.SentState { + LucideIcon(.link, size: IconSize.medium.size) .foregroundColor( themeColor: isOutgoing ? .messageBubble_outgoingText : @@ -134,12 +149,14 @@ struct LinkPreview_SwiftUI_Previews: PreviewProvider { jpegImageData: UIImage(named: "AppIcon")?.jpegData(compressionQuality: 1) ) ), + dataManager: ImageDataManager(), isOutgoing: true ) .padding(.horizontal, Values.mediumSpacing) LinkPreviewView_SwiftUI( state: LinkPreview.LoadingState(), + dataManager: ImageDataManager(), isOutgoing: true ) .frame( diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 0dbde5e460..ad6ae28dd2 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -513,7 +513,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { if let linkPreview: LinkPreview = cellViewModel.linkPreview { switch linkPreview.variant { case .standard: - let linkPreviewView: LinkPreviewView = LinkPreviewView(maxWidth: maxWidth) + let linkPreviewView: LinkPreviewView = LinkPreviewView( + maxWidth: maxWidth, + using: dependencies + ) linkPreviewView.update( with: LinkPreview.SentState( linkPreview: linkPreview, diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 8ee7a40590..28398da8ff 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -1691,7 +1691,9 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob confirmTitle: "save".localized(), confirmEnabled: .afterChange { info in switch info.body { - case .image(let source, _, _, _, _, _, _): return (source?.imageData != nil) + case .image(let source, _, _, _, _, _, _): + return (source?.contentExists == true) + default: return false } }, @@ -1702,10 +1704,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob onConfirm: { [weak self] modal in switch modal.info.body { case .image(.some(let source), _, _, _, _, _, _): - guard let imageData: Data = source.imageData else { return } - self?.updateGroupDisplayPicture( - displayPictureUpdate: .groupUploadImageData(imageData), + displayPictureUpdate: .groupUploadImage(source), onUploadComplete: { [weak modal] in Task { @MainActor in modal?.close() } } @@ -1750,102 +1750,104 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob default: break } - Just(displayPictureUpdate) - .setFailureType(to: Error.self) - .flatMap { [weak self, dependencies] update -> AnyPublisher in + Task(priority: .userInitiated) { [weak self, threadId, dependencies] in + var targetUpdate: DisplayPictureManager.Update = displayPictureUpdate + var indicator: ModalActivityIndicatorViewController? + + do { switch displayPictureUpdate { - case .none, .currentUserRemove, .currentUserUploadImageData, .currentUserUpdateTo, - .contactRemove, .contactUpdateTo: - return Fail(error: AttachmentError.invalidStartState).eraseToAnyPublisher() - - case .groupRemove, .groupUpdateTo: - return Just(displayPictureUpdate) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() + case .none, .currentUserRemove, .currentUserUpdateTo, .contactRemove, + .contactUpdateTo: + throw AttachmentError.invalidStartState - case .groupUploadImageData(let data): + case .groupRemove, .groupUpdateTo: break + case .groupUploadImage(let source): /// Show a blocking loading indicator while uploading but not while updating or syncing the group configs - return dependencies[singleton: .displayPictureManager] - .prepareAndUploadDisplayPicture(imageData: data) - .showingBlockingLoading(in: self?.navigatableState) - .map { url, filePath, key -> DisplayPictureManager.Update in - .groupUpdateTo(url: url, key: key, filePath: filePath) - } - .mapError { $0 as Error } - .handleEvents( - receiveCompletion: { result in - switch result { - case .failure(let error): - let message: String = { - switch (displayPictureUpdate, error) { - case (.groupRemove, _): return "profileDisplayPictureRemoveError".localized() - case (_, DisplayPictureError.uploadMaxFileSizeExceeded): - return "profileDisplayPictureSizeError".localized() - - default: return "errorConnection".localized() - } - }() - - self?.transitionToScreen( - ConfirmationModal( - info: ConfirmationModal.Info( - title: "deleteAfterLegacyGroupsGroupUpdateErrorTitle".localized(), - body: .text(message), - cancelTitle: "okay".localized(), - cancelStyle: .alert_text, - dismissType: .single - ) - ), - transitionType: .present - ) - - case .finished: onUploadComplete() - } - } - ) - .eraseToAnyPublisher() + indicator = await MainActor.run { [weak self] in + let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController(onAppear: { _ in }) + self?.transitionToScreen(indicator, transitionType: .present) + return indicator + } + + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .media(source), + using: dependencies + ) + let preparedAttachment: PreparedAttachment = try dependencies[singleton: .displayPictureManager] + .prepareDisplayPicture(attachment: pendingAttachment) + let result = try await dependencies[singleton: .displayPictureManager] + .uploadDisplayPicture(attachment: preparedAttachment) + await MainActor.run { onUploadComplete() } + + targetUpdate = .groupUpdateTo( + url: result.downloadUrl, + key: result.encryptionKey + ) } } - .flatMapStorageReadPublisher(using: dependencies) { [threadId] db, displayPictureUpdate -> (DisplayPictureManager.Update, String?) in - ( - displayPictureUpdate, - try? ClosedGroup - .filter(id: threadId) - .select(.displayPictureUrl) - .asRequest(of: String.self) - .fetchOne(db) - ) - } - .flatMap { [threadId, dependencies] displayPictureUpdate, existingDownloadUrl -> AnyPublisher in - MessageSender - .updateGroup( - groupSessionId: threadId, - displayPictureUpdate: displayPictureUpdate, - using: dependencies + catch { + let message: String = { + switch (displayPictureUpdate, error) { + case (.groupRemove, _): return "profileDisplayPictureRemoveError".localized() + case (_, DisplayPictureError.uploadMaxFileSizeExceeded): + return "profileDisplayPictureSizeError".localized() + + default: return "errorConnection".localized() + } + }() + + await indicator?.dismiss { [weak self] in + self?.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "deleteAfterLegacyGroupsGroupUpdateErrorTitle".localized(), + body: .text(message), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text, + dismissType: .single + ) + ), + transitionType: .present ) - .map { _ in existingDownloadUrl } - .eraseToAnyPublisher() + } + return } - .handleEvents( - receiveOutput: { [dependencies] existingDownloadUrl in - /// Remove any cached avatar image value - if - let existingDownloadUrl: String = existingDownloadUrl, - let existingFilePath: String = try? dependencies[singleton: .displayPictureManager] - .path(for: existingDownloadUrl) - { - Task { - await dependencies[singleton: .imageDataManager].removeImage( - identifier: existingFilePath - ) - try? dependencies[singleton: .fileManager].removeItem(atPath: existingFilePath) - } + + let existingDownloadUrl: String? = try? await dependencies[singleton: .storage].readAsync { db in + try? ClosedGroup + .filter(id: threadId) + .select(.displayPictureUrl) + .asRequest(of: String.self) + .fetchOne(db) + } + + do { + try await MessageSender.updateGroup( + groupSessionId: threadId, + displayPictureUpdate: targetUpdate, + using: dependencies + ) + + /// Remove any cached avatar image value (only want to do so if the above update succeeded) + if + let existingDownloadUrl: String = existingDownloadUrl, + let existingFilePath: String = try? dependencies[singleton: .displayPictureManager] + .path(for: existingDownloadUrl) + { + Task { [dependencies] in + await dependencies[singleton: .imageDataManager].removeImage( + identifier: existingFilePath + ) + try? dependencies[singleton: .fileManager].removeItem(atPath: existingFilePath) } } - ) - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) - .receive(on: DispatchQueue.main, using: dependencies) - .sinkUntilComplete() + } + catch {} + + await MainActor.run { [indicator] in + indicator?.dismiss(completion: {}) + } + } } private func updateBlockedState( diff --git a/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift b/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift index 81bf19c1c7..1b03460d65 100644 --- a/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift +++ b/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift @@ -201,7 +201,16 @@ class GifPickerCell: UICollectionViewCell { clearViewState() return } - guard let dependencies: Dependencies = dependencies, MediaUtils.isValidImage(at: asset.filePath, type: .gif, using: dependencies) else { + guard + let dependencies: Dependencies = dependencies, + let metadata: MediaUtils.MediaMetadata = MediaUtils.MediaMetadata( + from: asset.filePath, + utType: .gif, + sourceFilename: nil, + using: dependencies + ), + metadata.isValidImage + else { Log.error(.giphy, "Cell received invalid asset.") clearViewState() return diff --git a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift index b1557c2166..facda4f414 100644 --- a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift +++ b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift @@ -357,7 +357,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main) .sink( - receiveCompletion: { [weak self, dependencies] result in + receiveCompletion: { [weak self] result in switch result { case .finished: break case .failure(let error): @@ -382,13 +382,17 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect Log.error(.giphy, "ViewController invalid asset description.") return } - - let dataSource = DataSourcePath(filePath: asset.filePath, sourceFilename: URL(fileURLWithPath: asset.filePath).pathExtension, shouldDeleteOnDeinit: false, using: dependencies) - let attachment = SignalAttachment.attachment(dataSource: dataSource, type: rendition.type, imageQuality: .medium, using: dependencies) + + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .media(URL(fileURLWithPath: asset.filePath)), + utType: rendition.type, + sourceFilename: URL(fileURLWithPath: asset.filePath).lastPathComponent, + using: dependencies + ) self?.dismiss(animated: true) { // Delegate presents view controllers, so it's important that *this* controller be dismissed before that occurs. - self?.delegate?.gifPickerDidSelect(attachment: attachment) + self?.delegate?.gifPickerDidSelect(attachment: pendingAttachment) } } ) @@ -573,5 +577,5 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect // MARK: - GifPickerViewControllerDelegate protocol GifPickerViewControllerDelegate: AnyObject { - func gifPickerDidSelect(attachment: SignalAttachment) + func gifPickerDidSelect(attachment: PendingAttachment) } diff --git a/Session/Media Viewing & Editing/ImagePickerController.swift b/Session/Media Viewing & Editing/ImagePickerController.swift index ae89e63281..83af9d22a3 100644 --- a/Session/Media Viewing & Editing/ImagePickerController.swift +++ b/Session/Media Viewing & Editing/ImagePickerController.swift @@ -14,7 +14,7 @@ protocol ImagePickerGridControllerDelegate: AnyObject { func imagePickerDidCancel(_ imagePicker: ImagePickerGridController) func imagePicker(_ imagePicker: ImagePickerGridController, isAssetSelected asset: PHAsset) -> Bool - func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPublisher: AnyPublisher) + func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, retrievalTask: Task) func imagePicker(_ imagePicker: ImagePickerGridController, didDeselectAsset asset: PHAsset) var isInBatchSelectMode: Bool { get } @@ -189,7 +189,19 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat delegate.imagePicker( self, didSelectAsset: asset, - attachmentPublisher: photoCollectionContents.outgoingAttachment(for: asset, using: dependencies) + retrievalTask: Task.detached { [weak photoCollectionContents, dependencies] in + guard let contents: PhotoCollectionContents = photoCollectionContents else { + throw CancellationError() + } + + return MediaLibraryAttachment( + asset: asset, + attachment: try await contents.pendingAttachment( + for: asset, + using: dependencies + ) + ) + } ) collectionView.selectItem(at: indexPath, animated: true, scrollPosition: []) case .deselect: @@ -468,7 +480,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat return true } - if (indexPathsForSelectedItems.count < SignalAttachment.maxAttachmentsAllowed) { + if (indexPathsForSelectedItems.count < AttachmentManager.maxAttachmentsAllowed) { return true } else { showTooManySelectedToast() @@ -491,7 +503,19 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat delegate.imagePicker( self, didSelectAsset: asset, - attachmentPublisher: photoCollectionContents.outgoingAttachment(for: asset, using: dependencies) + retrievalTask: Task.detached { [weak photoCollectionContents, dependencies] in + guard let contents: PhotoCollectionContents = photoCollectionContents else { + throw CancellationError() + } + + return MediaLibraryAttachment( + asset: asset, + attachment: try await contents.pendingAttachment( + for: asset, + using: dependencies + ) + ) + } ) firstSelectedIndexPath = nil diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 9456f8dc42..6104bbd0e6 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -473,6 +473,7 @@ struct MessageBubble: View { imageAttachment: messageViewModel.linkPreviewAttachment, using: dependencies ), + dataManager: dependencies[singleton: .imageDataManager], isOutgoing: (messageViewModel.variant == .standardOutgoing), maxWidth: maxWidth, messageViewModel: messageViewModel, diff --git a/Session/Media Viewing & Editing/PhotoCapture.swift b/Session/Media Viewing & Editing/PhotoCapture.swift index 30b38d6a73..69d6718be4 100644 --- a/Session/Media Viewing & Editing/PhotoCapture.swift +++ b/Session/Media Viewing & Editing/PhotoCapture.swift @@ -11,7 +11,7 @@ import SessionMessagingKit import SessionUtilitiesKit protocol PhotoCaptureDelegate: AnyObject { - func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessingAttachment attachment: SignalAttachment) + func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessingAttachment attachment: PendingAttachment) func photoCapture(_ photoCapture: PhotoCapture, processingDidError error: Error) func photoCaptureDidBeginVideo(_ photoCapture: PhotoCapture) @@ -423,10 +423,14 @@ extension PhotoCapture: CaptureOutputDelegate { delegate?.photoCapture(self, processingDidError: PhotoCaptureError.captureFailed) return } - - let dataSource = DataSourceValue(data: photoData, dataType: .jpeg, using: dependencies) - let attachment = SignalAttachment.attachment(dataSource: dataSource, type: .jpeg, imageQuality: .medium, using: dependencies) - delegate?.photoCapture(self, didFinishProcessingAttachment: attachment) + + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .media(UUID().uuidString, photoData), + utType: .jpeg, + sourceFilename: nil, + using: dependencies + ) + delegate?.photoCapture(self, didFinishProcessingAttachment: pendingAttachment) } // MARK: - Movie @@ -445,10 +449,21 @@ extension PhotoCapture: CaptureOutputDelegate { } Log.debug("[PhotoCapture] Ignoring error, since capture succeeded.") } - - let dataSource = DataSourcePath(fileUrl: outputFileURL, sourceFilename: nil, shouldDeleteOnDeinit: true, using: dependencies) - let attachment = SignalAttachment.attachment(dataSource: dataSource, type: .mpeg4Movie, using: dependencies) - delegate?.photoCapture(self, didFinishProcessingAttachment: attachment) + + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .media( + .videoUrl( + outputFileURL, + .mpeg4Movie, + nil, + dependencies[singleton: .attachmentManager] + ) + ), + utType: .mpeg4Movie, + sourceFilename: nil, + using: dependencies + ) + delegate?.photoCapture(self, didFinishProcessingAttachment: pendingAttachment) } /// The AVCaptureFileOutput can return an error even though recording succeeds. diff --git a/Session/Media Viewing & Editing/PhotoCaptureViewController.swift b/Session/Media Viewing & Editing/PhotoCaptureViewController.swift index af2a88f4ba..0cae8a2416 100644 --- a/Session/Media Viewing & Editing/PhotoCaptureViewController.swift +++ b/Session/Media Viewing & Editing/PhotoCaptureViewController.swift @@ -9,7 +9,7 @@ import SessionMessagingKit import SessionUtilitiesKit protocol PhotoCaptureViewControllerDelegate: AnyObject { - func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: SignalAttachment) + func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: PendingAttachment) func photoCaptureViewControllerDidCancel(_ photoCaptureViewController: PhotoCaptureViewController) } @@ -366,7 +366,7 @@ extension PhotoCaptureViewController: PhotoCaptureDelegate { // MARK: - Photo - func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessingAttachment attachment: SignalAttachment) { + func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessingAttachment attachment: PendingAttachment) { delegate?.photoCaptureViewController(self, didFinishProcessingAttachment: attachment) } diff --git a/Session/Media Viewing & Editing/PhotoLibrary.swift b/Session/Media Viewing & Editing/PhotoLibrary.swift index 16f3c4e715..08955d7969 100644 --- a/Session/Media Viewing & Editing/PhotoLibrary.swift +++ b/Session/Media Viewing & Editing/PhotoLibrary.swift @@ -206,133 +206,130 @@ class PhotoCollectionContents { } } - private func requestImageDataSource(for asset: PHAsset, using dependencies: Dependencies) -> AnyPublisher<(dataSource: (any DataSource), type: UTType), Error> { - return Deferred { - Future { [weak self] resolver in - let options: PHImageRequestOptions = PHImageRequestOptions() - options.isNetworkAccessAllowed = true - options.deliveryMode = .highQualityFormat + private func requestImageDataSource( + for asset: PHAsset, + using dependencies: Dependencies + ) async throws -> PendingAttachment { + let options: PHImageRequestOptions = PHImageRequestOptions() + options.isSynchronous = false + options.isNetworkAccessAllowed = true + options.deliveryMode = .highQualityFormat + + return try await withCheckedThrowingContinuation { [imageManager] continuation in + imageManager.requestImageDataAndOrientation(for: asset, options: options) { imageData, dataUTI, orientation, info in + if let error: Error = info?[PHImageErrorKey] as? Error { + return continuation.resume(throwing: error) + } - _ = self?.imageManager.requestImageData(for: asset, options: options) { imageData, dataUTI, orientation, info in - if let error: Error = info?[PHImageErrorKey] as? Error { - return resolver(.failure(error)) - } - - if (info?[PHImageCancelledKey] as? Bool) == true { - return resolver(.failure(PhotoLibraryError.assertionError(description: "Image request cancelled"))) - } - - // If we get a degraded image then we want to wait for the next callback (which will - // be the non-degraded version) - guard (info?[PHImageResultIsDegradedKey] as? Bool) != true else { - return - } - - guard let imageData = imageData else { - resolver(Result.failure(PhotoLibraryError.assertionError(description: "imageData was unexpectedly nil"))) - return - } - - guard let type: UTType = dataUTI.map({ UTType($0) }) else { - resolver(Result.failure(PhotoLibraryError.assertionError(description: "dataUTI was unexpectedly nil"))) - return - } - - guard let dataSource = DataSourceValue(data: imageData, dataType: type, using: dependencies) else { - resolver(Result.failure(PhotoLibraryError.assertionError(description: "dataSource was unexpectedly nil"))) - return - } - - resolver(Result.success((dataSource: dataSource, type: type))) + if (info?[PHImageCancelledKey] as? Bool) == true { + return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "Image request cancelled")) + } + + // If we get a degraded image then we want to wait for the next callback (which will + // be the non-degraded version) + guard (info?[PHImageResultIsDegradedKey] as? Bool) != true else { + return + } + + guard let imageData: Data = imageData else { + return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "imageData was unexpectedly nil")) + } + + guard let type: UTType = dataUTI.map({ UTType($0) }) else { + return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "dataUTI was unexpectedly nil")) + } + + guard let filePath: String = try? dependencies[singleton: .fileManager].write(dataToTemporaryFile: imageData) else { + return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "failed to write temporary file")) } + + continuation.resume( + returning: PendingAttachment( + source: .media(URL(fileURLWithPath: filePath)), + utType: type, + using: dependencies + ) + ) } } - .eraseToAnyPublisher() } - private func requestVideoDataSource(for asset: PHAsset, using dependencies: Dependencies) -> AnyPublisher<(dataSource: (any DataSource), type: UTType), Error> { - return Deferred { - Future { [weak self] resolver in - let options: PHVideoRequestOptions = PHVideoRequestOptions() - options.isNetworkAccessAllowed = true - options.deliveryMode = .highQualityFormat + private func requestVideoDataSource( + for asset: PHAsset, + using dependencies: Dependencies + ) async throws -> PendingAttachment { + let options: PHVideoRequestOptions = PHVideoRequestOptions() + options.isNetworkAccessAllowed = true + options.deliveryMode = .highQualityFormat + + return try await withCheckedThrowingContinuation { [imageManager] continuation in + imageManager.requestAVAsset(forVideo: asset, options: options) { avAsset, _, info in + if let error: Error = info?[PHImageErrorKey] as? Error { + return continuation.resume(throwing: error) + } - self?.imageManager.requestAVAsset(forVideo: asset, options: options) { avAsset, _, info in - - if let error: Error = info?[PHImageErrorKey] as? Error { - return resolver(.failure(error)) - } - - guard let avAsset: AVAsset = avAsset else { - return resolver(Result.failure(PhotoLibraryError.assertionError(description: "avAsset was unexpectedly nil"))) - } - - let compatiblePresets = AVAssetExportSession.exportPresets(compatibleWith: avAsset) - var bestExportPreset: String - - if compatiblePresets.contains(AVAssetExportPresetPassthrough) { - bestExportPreset = AVAssetExportPresetPassthrough - Log.debug("[PhotoLibrary] Using Passthrough export preset.") - } else { - bestExportPreset = AVAssetExportPresetHighestQuality - Log.debug("[PhotoLibrary] Passthrough not available. Falling back to HighestQuality export preset.") - } - - if (info?[PHImageCancelledKey] as? Bool) == true { - return resolver(.failure(PhotoLibraryError.assertionError(description: "Video request cancelled"))) - } + guard let avAsset: AVAsset = avAsset else { + return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "avAsset was unexpectedly nil")) + } + + let compatiblePresets = AVAssetExportSession.exportPresets(compatibleWith: avAsset) + var bestExportPreset: String + + if compatiblePresets.contains(AVAssetExportPresetPassthrough) { + bestExportPreset = AVAssetExportPresetPassthrough + Log.debug("[PhotoLibrary] Using Passthrough export preset.") + } else { + bestExportPreset = AVAssetExportPresetHighestQuality + Log.debug("[PhotoLibrary] Passthrough not available. Falling back to HighestQuality export preset.") + } + + if (info?[PHImageCancelledKey] as? Bool) == true { + return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "Video request cancelled")) + } + + guard let exportSession: AVAssetExportSession = AVAssetExportSession(asset: avAsset, presetName: bestExportPreset) else { + return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "exportSession was unexpectedly nil")) + } + + exportSession.outputFileType = AVFileType.mp4 + exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() + + let exportPath = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: "mp4") // stringlint:ignore + let exportURL = URL(fileURLWithPath: exportPath) + exportSession.outputURL = exportURL + + Log.debug("[PhotoLibrary] Starting video export") + exportSession.exportAsynchronously { [weak exportSession] in + Log.debug("[PhotoLibrary] Completed video export") - guard let exportSession: AVAssetExportSession = AVAssetExportSession(asset: avAsset, presetName: bestExportPreset) else { - resolver(Result.failure(PhotoLibraryError.assertionError(description: "exportSession was unexpectedly nil"))) - return + guard exportSession?.status == .completed else { + return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "Failed to build data source for exported video URL")) } - exportSession.outputFileType = AVFileType.mp4 - exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() - - let exportPath = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: "mp4") // stringlint:ignore - let exportURL = URL(fileURLWithPath: exportPath) - exportSession.outputURL = exportURL - - Log.debug("[PhotoLibrary] Starting video export") - exportSession.exportAsynchronously { [weak exportSession] in - Log.debug("[PhotoLibrary] Completed video export") - - guard - exportSession?.status == .completed, - let dataSource = DataSourcePath(fileUrl: exportURL, sourceFilename: nil, shouldDeleteOnDeinit: true, using: dependencies) - else { - resolver(Result.failure(PhotoLibraryError.assertionError(description: "Failed to build data source for exported video URL"))) - return - } - - resolver(Result.success((dataSource: dataSource, type: .mpeg4Movie))) - } + continuation.resume( + returning: PendingAttachment( + source: .media( + .videoUrl( + exportURL, + .mpeg4Movie, + nil, + dependencies[singleton: .attachmentManager] + ) + ), + utType: .mpeg4Movie, + using: dependencies + ) + ) } } } - .eraseToAnyPublisher() } - func outgoingAttachment(for asset: PHAsset, using dependencies: Dependencies) -> AnyPublisher { + func pendingAttachment(for asset: PHAsset, using dependencies: Dependencies) async throws -> PendingAttachment { switch asset.mediaType { - case .image: - return requestImageDataSource(for: asset, using: dependencies) - .map { (dataSource: DataSource, type: UTType) in - SignalAttachment.attachment(dataSource: dataSource, type: type, imageQuality: .medium, using: dependencies) - } - .eraseToAnyPublisher() - - case .video: - return requestVideoDataSource(for: asset, using: dependencies) - .map { (dataSource: DataSource, type: UTType) in - SignalAttachment.attachment(dataSource: dataSource, type: type, using: dependencies) - } - .eraseToAnyPublisher() - - default: - return Fail(error: PhotoLibraryError.unsupportedMediaType) - .eraseToAnyPublisher() + case .image: return try await requestImageDataSource(for: asset, using: dependencies) + case .video: return try await requestVideoDataSource(for: asset, using: dependencies) + default: throw PhotoLibraryError.unsupportedMediaType } } } diff --git a/Session/Media Viewing & Editing/SendMediaNavigationController.swift b/Session/Media Viewing & Editing/SendMediaNavigationController.swift index 5be73955ea..1fc48aeea3 100644 --- a/Session/Media Viewing & Editing/SendMediaNavigationController.swift +++ b/Session/Media Viewing & Editing/SendMediaNavigationController.swift @@ -21,6 +21,7 @@ class SendMediaNavigationController: UINavigationController { private let threadId: String private let threadVariant: SessionThread.Variant private var disposables: Set = Set() + private var loadMediaTask: Task? // MARK: - Initialization @@ -36,6 +37,10 @@ class SendMediaNavigationController: UINavigationController { fatalError("init(coder:) has not been implemented") } + deinit { + loadMediaTask?.cancel() + } + // MARK: - Overrides override func viewDidLoad() { @@ -204,7 +209,7 @@ class SendMediaNavigationController: UINavigationController { private lazy var attachmentDraftCollection = AttachmentDraftCollection.empty // Lazy to avoid https://bugs.swift.org/browse/SR-6657 - private var attachments: [SignalAttachment] { + private var attachments: [PendingAttachment] { return attachmentDraftCollection.attachmentDrafts.map { $0.attachment } } @@ -240,6 +245,7 @@ class SendMediaNavigationController: UINavigationController { threadVariant: self.threadVariant, attachments: self.attachments, disableLinkPreviewImageDownload: false, + didLoadLinkPreview: nil, using: dependencies ) else { return false } @@ -286,7 +292,7 @@ extension SendMediaNavigationController: UINavigationControllerDelegate { } extension SendMediaNavigationController: PhotoCaptureViewControllerDelegate { - func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: SignalAttachment) { + func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: PendingAttachment) { attachmentDraftCollection.append(.camera(attachment: attachment)) if isInBatchSelectMode { updateButtons(topViewController: photoCaptureViewController) @@ -331,72 +337,63 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate { func showApprovalAfterProcessingAnyMediaLibrarySelections() { let mediaLibrarySelections: [MediaLibrarySelection] = self.mediaLibrarySelections.orderedValues - - let backgroundBlock: (ModalActivityIndicatorViewController) -> Void = { [weak self, dependencies] modal in - guard let strongSelf = self else { return } - - Publishers - .MergeMany(mediaLibrarySelections.map { $0.publisher }) - .collect() - .sink( - receiveCompletion: { result in - switch result { - case .finished: break - case .failure(let error): - Log.error("[SendMediaNavigationController] Failed to prepare attachments. error: \(error)") - modal.dismiss { [weak self] in - let modal: ConfirmationModal = ConfirmationModal( - targetView: self?.view, - info: ConfirmationModal.Info( - title: "attachmentsErrorMediaSelection".localized(), - cancelTitle: "okay".localized(), - cancelStyle: .alert_text - ) - ) - self?.present(modal, animated: true) - } - } - }, - receiveValue: { attachments in - Log.debug("[SendMediaNavigationController] Built all attachments") - modal.dismiss { - self?.attachmentDraftCollection.selectedFromPicker(attachments: attachments) - - guard self?.pushApprovalViewController() == true else { - let modal: ConfirmationModal = ConfirmationModal( - info: ConfirmationModal.Info( - title: "attachmentsErrorMediaSelection".localized(), - cancelTitle: "okay".localized(), - cancelStyle: .alert_text - ) - ) - self?.present(modal, animated: true) - return - } - } + let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController() + self.present(indicator, animated: false) + + loadMediaTask?.cancel() + loadMediaTask = Task(priority: .userInitiated) { [weak self, indicator] in + do { + let attachments = try await withThrowingTaskGroup { group in + mediaLibrarySelections.forEach { selection in + group.addTask { try await selection.retrievalTask.value } } - ) - .store(in: &strongSelf.disposables) + + return try await group.reduce(into: []) { result, next in result.append(next) } + } + guard !Task.isCancelled else { return } + + Log.debug("[SendMediaNavigationController] Built all attachments") + indicator.dismiss { + self?.attachmentDraftCollection.selectedFromPicker(attachments: attachments) + + guard self?.pushApprovalViewController() == true else { + let modal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "attachmentsErrorMediaSelection".localized(), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text + ) + ) + self?.present(modal, animated: true) + return + } + } + } + catch { + Log.error("[SendMediaNavigationController] Failed to prepare attachments. error: \(error)") + indicator.dismiss { [weak self] in + let modal: ConfirmationModal = ConfirmationModal( + targetView: self?.view, + info: ConfirmationModal.Info( + title: "attachmentsErrorMediaSelection".localized(), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text + ) + ) + self?.present(modal, animated: true) + } + } } - - ModalActivityIndicatorViewController.present( - fromViewController: self, - canCancel: false, - onAppear: backgroundBlock - ) } func imagePicker(_ imagePicker: ImagePickerGridController, isAssetSelected asset: PHAsset) -> Bool { return mediaLibrarySelections.hasValue(forKey: asset) } - func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPublisher: AnyPublisher) { + func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, retrievalTask: Task) { guard !mediaLibrarySelections.hasValue(forKey: asset) else { return } - let libraryMedia = MediaLibrarySelection( - asset: asset, - signalAttachmentPublisher: attachmentPublisher - ) + let libraryMedia = MediaLibrarySelection(asset: asset, retrievalTask: retrievalTask) mediaLibrarySelections.append(key: asset, value: libraryMedia) updateButtons(topViewController: imagePicker) } @@ -409,7 +406,7 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate { } func imagePickerCanSelectAdditionalItems(_ imagePicker: ImagePickerGridController) -> Bool { - return attachmentDraftCollection.count <= SignalAttachment.maxAttachmentsAllowed + return attachmentDraftCollection.count <= AttachmentManager.maxAttachmentsAllowed } func imagePicker(_ imagePicker: ImagePickerGridController, failedToRetrieveAssetAt index: Int, forCount count: Int) { @@ -430,17 +427,16 @@ extension SendMediaNavigationController: AttachmentApprovalViewControllerDelegat sendMediaNavDelegate?.sendMediaNav(self, didChangeMessageText: newMessageText) } - func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) { + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: PendingAttachment) { guard let removedDraft = attachmentDraftCollection.attachmentDrafts.first(where: { $0.attachment == attachment}) else { Log.error("[SendMediaNavigationController] removedDraft was unexpectedly nil") return } switch removedDraft.source { - case .picker(attachment: let pickerAttachment): - mediaLibrarySelections.remove(key: pickerAttachment.asset) - case .camera(attachment: _): - break + case .camera(attachment: _): break + case .picker(attachment: let pickerAttachment): + mediaLibrarySelections.remove(key: pickerAttachment.asset) } attachmentDraftCollection.remove(attachment: attachment) @@ -448,7 +444,7 @@ extension SendMediaNavigationController: AttachmentApprovalViewControllerDelegat func attachmentApproval( _ attachmentApproval: AttachmentApprovalViewController, - didApproveAttachments attachments: [SignalAttachment], + didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, messageText: String? @@ -479,17 +475,15 @@ extension SendMediaNavigationController: AttachmentApprovalViewControllerDelegat } private enum AttachmentDraft { - case camera(attachment: SignalAttachment) + case camera(attachment: PendingAttachment) case picker(attachment: MediaLibraryAttachment) } private extension AttachmentDraft { - var attachment: SignalAttachment { + var attachment: PendingAttachment { switch self { - case .camera(let cameraAttachment): - return cameraAttachment - case .picker(let pickerAttachment): - return pickerAttachment.signalAttachment + case .camera(let cameraAttachment): return cameraAttachment + case .picker(let pickerAttachment): return pickerAttachment.attachment } } @@ -499,7 +493,7 @@ private extension AttachmentDraft { } private final class AttachmentDraftCollection { - lazy var attachmentDrafts = [AttachmentDraft]() // Lazy to avoid https://bugs.swift.org/browse/SR-6657 + lazy var attachmentDrafts: [AttachmentDraft] = [] static var empty: AttachmentDraftCollection { return AttachmentDraftCollection(attachmentDrafts: []) @@ -518,21 +512,17 @@ private final class AttachmentDraftCollection { var pickerAttachments: [MediaLibraryAttachment] { return attachmentDrafts.compactMap { attachmentDraft in switch attachmentDraft.source { - case .picker(let pickerAttachment): - return pickerAttachment - case .camera: - return nil + case .picker(let pickerAttachment): return pickerAttachment + case .camera: return nil } } } - var cameraAttachments: [SignalAttachment] { + var cameraAttachments: [PendingAttachment] { return attachmentDrafts.compactMap { attachmentDraft in switch attachmentDraft.source { - case .picker: - return nil - case .camera(let cameraAttachment): - return cameraAttachment + case .picker: return nil + case .camera(let cameraAttachment): return cameraAttachment } } } @@ -541,7 +531,7 @@ private final class AttachmentDraftCollection { attachmentDrafts.append(element) } - func remove(attachment: SignalAttachment) { + func remove(attachment: PendingAttachment) { attachmentDrafts.removeAll { $0.attachment == attachment } } @@ -550,7 +540,7 @@ private final class AttachmentDraftCollection { let oldPickerAttachments: Set = Set(self.pickerAttachments) for removedAttachment in oldPickerAttachments.subtracting(pickedAttachments) { - remove(attachment: removedAttachment.signalAttachment) + remove(attachment: removedAttachment.attachment) } // enumerate over new attachments to maintain order from picker @@ -565,29 +555,22 @@ private final class AttachmentDraftCollection { private struct MediaLibrarySelection: Hashable, Equatable { let asset: PHAsset - let signalAttachmentPublisher: AnyPublisher + let retrievalTask: Task func hash(into hasher: inout Hasher) { asset.hash(into: &hasher) } - var publisher: AnyPublisher { - let asset = self.asset - return signalAttachmentPublisher - .map { MediaLibraryAttachment(asset: asset, signalAttachment: $0) } - .eraseToAnyPublisher() - } - static func ==(lhs: MediaLibrarySelection, rhs: MediaLibrarySelection) -> Bool { return lhs.asset == rhs.asset } } -private struct MediaLibraryAttachment: Hashable, Equatable { +public struct MediaLibraryAttachment: Hashable, Equatable { let asset: PHAsset - let signalAttachment: SignalAttachment + let attachment: PendingAttachment - func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { asset.hash(into: &hasher) } @@ -794,7 +777,7 @@ private class DoneButton: UIView { protocol SendMediaNavDelegate: AnyObject { func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController?) - func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, messageText: String?) + func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, messageText: String?) func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) diff --git a/Session/Meta/Session+SNUIKit.swift b/Session/Meta/Session+SNUIKit.swift index 2c7a2aedfe..396c33a2cc 100644 --- a/Session/Meta/Session+SNUIKit.swift +++ b/Session/Meta/Session+SNUIKit.swift @@ -2,6 +2,7 @@ import UIKit import AVFoundation +import UniformTypeIdentifiers import SessionUIKit import SessionNetworkingKit import SessionUtilitiesKit @@ -88,10 +89,10 @@ internal struct SessionSNUIKitConfig: SNUIKit.ConfigType { return dependencies[feature: .showStringKeys] } - func asset(for path: String, mimeType: String, sourceFilename: String?) -> (asset: AVURLAsset, cleanup: () -> Void)? { + func asset(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, cleanup: () -> Void)? { return AVURLAsset.asset( for: path, - mimeType: mimeType, + utType: utType, sourceFilename: sourceFilename, using: dependencies ) diff --git a/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist b/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist index 3848270117..16af45f5d0 100644 --- a/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist +++ b/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist @@ -6,7 +6,7 @@ License BSD 3-Clause License -Copyright (c) 2010-2024, Deusty, LLC +Copyright (c) 2010-2025, Deusty, LLC All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: @@ -955,6 +955,42 @@ Public License instead of this License. But first, please read Title libsession-util-spm + + License + Copyright (c) 2010, Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + + Title + libwebp-Xcode - libwebp + License Apache License @@ -1630,6 +1666,57 @@ SOFTWARE. Title Quick - nimble + + License + Copyright (c) 2009-2020 Olivier Poitrey rs@dailymotion.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + + Title + SDWebImage + + + License + Copyright (c) 2018 Bogdan Poplauschi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + Title + SDWebImageWebPCoder + License Copyright (C) 2015-2025 Gwendal Roué diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 1071b8c059..feea7094c7 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -678,7 +678,9 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl confirmTitle: "save".localized(), confirmEnabled: .afterChange { info in switch info.body { - case .image(let source, _, _, _, _, _, _): return (source?.imageData != nil) + case .image(let source, _, _, _, _, _, _): + return (source?.contentExists == true) + default: return false } }, @@ -689,13 +691,12 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl onConfirm: { [weak self] modal in switch modal.info.body { case .image(.some(let source), _, _, _, _, _, _): - guard let imageData: Data = source.imageData else { return } - self?.updateProfile( - displayPictureUpdate: .currentUserUploadImageData( - data: imageData, - isReupload: false - ), + displayPictureUpdateGenerator: { [weak self] in + guard let self = self else { throw DisplayPictureError.uploadFailed } + + return try await uploadDisplayPicture(source: source) + }, onComplete: { [weak modal] in modal?.close() } ) @@ -704,7 +705,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl }, onCancel: { [weak self] modal in self?.updateProfile( - displayPictureUpdate: .currentUserRemove, + displayPictureUpdateGenerator: { .currentUserRemove }, onComplete: { [weak modal] in modal?.close() } ) } @@ -727,55 +728,65 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } } + fileprivate func uploadDisplayPicture(source: ImageDataManager.DataSource) async throws -> DisplayPictureManager.Update { + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .media(source), + using: dependencies + ) + let preparedAttachment: PreparedAttachment = try dependencies[singleton: .displayPictureManager] + .prepareDisplayPicture(attachment: pendingAttachment) + let result = try await dependencies[singleton: .displayPictureManager] + .uploadDisplayPicture(attachment: preparedAttachment) + + return .currentUserUpdateTo(url: result.downloadUrl, key: result.encryptionKey, isReupload: false) + } + @MainActor fileprivate func updateProfile( displayNameUpdate: Profile.DisplayNameUpdate = .none, - displayPictureUpdate: DisplayPictureManager.Update = .none, + displayPictureUpdateGenerator generator: @escaping () async throws -> DisplayPictureManager.Update = { .none }, onComplete: @escaping () -> () ) { - let viewController = ModalActivityIndicatorViewController(canCancel: false) { [weak self, dependencies] modalActivityIndicator in - Profile - .updateLocal( + let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController() + self.transitionToScreen(indicator, transitionType: .present) + + Task.detached(priority: .userInitiated) { [weak self, indicator, dependencies] in + var displayPictureUpdate: DisplayPictureManager.Update = .none + + do { + displayPictureUpdate = try await generator() + try await Profile.updateLocal( displayNameUpdate: displayNameUpdate, displayPictureUpdate: displayPictureUpdate, using: dependencies ) - .subscribe(on: DispatchQueue.global(qos: .default), using: dependencies) - .receive(on: DispatchQueue.main, using: dependencies) - .sinkUntilComplete( - receiveCompletion: { result in - modalActivityIndicator.dismiss { - switch result { - case .finished: onComplete() - case .failure(let error): - let message: String = { - switch (displayPictureUpdate, error) { - case (.currentUserRemove, _): return "profileDisplayPictureRemoveError".localized() - case (_, .uploadMaxFileSizeExceeded): - return "profileDisplayPictureSizeError".localized() - - default: return "errorConnection".localized() - } - }() - - self?.transitionToScreen( - ConfirmationModal( - info: ConfirmationModal.Info( - title: "profileErrorUpdate".localized(), - body: .text(message), - cancelTitle: "okay".localized(), - cancelStyle: .alert_text, - dismissType: .single - ) - ), - transitionType: .present - ) - } - } + } + catch { + let message: String = { + switch (displayPictureUpdate, error) { + case (.currentUserRemove, _): return "profileDisplayPictureRemoveError".localized() + case (_, DisplayPictureError.uploadMaxFileSizeExceeded): + return "profileDisplayPictureSizeError".localized() + + default: return "errorConnection".localized() } - ) + }() + + await indicator.dismiss { + self?.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "profileErrorUpdate".localized(), + body: .text(message), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text, + dismissType: .single + ) + ), + transitionType: .present + ) + } + } } - - self.transitionToScreen(viewController, transitionType: .present) } private func copySessionId(_ sessionId: String, button: SessionButton?) { diff --git a/Session/Shared/Types/NavigatableState.swift b/Session/Shared/Types/NavigatableState.swift index 89a7d3f895..97aed06d64 100644 --- a/Session/Shared/Types/NavigatableState.swift +++ b/Session/Shared/Types/NavigatableState.swift @@ -124,7 +124,7 @@ public extension Publisher { return Deferred { Future { promise in - Task { @MainActor in + DispatchQueue.main.async { promise(.success(ModalActivityIndicatorViewController(onAppear: { _ in }))) } } @@ -140,9 +140,11 @@ public extension Publisher { .flatMap { result -> AnyPublisher in Deferred { Future { resolver in - indicator.dismiss(completion: { - resolver(result) - }) + DispatchQueue.main.async { + indicator.dismiss(completion: { + resolver(result) + }) + } } }.eraseToAnyPublisher() } diff --git a/Session/Utilities/ImageLoading+Convenience.swift b/Session/Utilities/ImageLoading+Convenience.swift index 1b1ef57fd3..1b5598f047 100644 --- a/Session/Utilities/ImageLoading+Convenience.swift +++ b/Session/Utilities/ImageLoading+Convenience.swift @@ -2,6 +2,7 @@ import UIKit import SwiftUI +import UniformTypeIdentifiers import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit @@ -23,7 +24,7 @@ public extension ImageDataManager.DataSource { /// Videos need special handling so handle those specially return .videoUrl( URL(fileURLWithPath: path), - attachment.contentType, + (UTType(sessionMimeType: attachment.contentType) ?? .invalid), attachment.sourceFilename, dependencies[singleton: .attachmentManager] ) @@ -52,7 +53,7 @@ public extension ImageDataManager.DataSource { if attachment.isVideo { return .videoUrl( URL(fileURLWithPath: path), - attachment.contentType, + (UTType(sessionMimeType: attachment.contentType) ?? .invalid), attachment.sourceFilename, dependencies[singleton: .attachmentManager] ) diff --git a/Session/Utilities/UIContextualAction+Utilities.swift b/Session/Utilities/UIContextualAction+Utilities.swift index c0edbc3b20..e9c932b671 100644 --- a/Session/Utilities/UIContextualAction+Utilities.swift +++ b/Session/Utilities/UIContextualAction+Utilities.swift @@ -122,7 +122,7 @@ public extension UIContextualAction { case .clear: return UIContextualAction( title: "clear".localized(), - icon: Lucide.image(icon: .trash2, size: 24, color: .white), + icon: Lucide.image(icon: .trash2, size: 24), themeTintColor: .white, themeBackgroundColor: themeBackgroundColor, side: side, @@ -615,7 +615,7 @@ public extension UIContextualAction { case .delete: return UIContextualAction( title: "delete".localized(), - icon: Lucide.image(icon: .trash2, size: 24, color: .white), + icon: Lucide.image(icon: .trash2, size: 24), themeTintColor: .white, themeBackgroundColor: themeBackgroundColor, accessibility: Accessibility(identifier: "Delete button"), diff --git a/SessionMessagingKit/Crypto/Crypto+Attachments.swift b/SessionMessagingKit/Crypto/Crypto+Attachments.swift index ceb7cca191..f8f4a893a6 100644 --- a/SessionMessagingKit/Crypto/Crypto+Attachments.swift +++ b/SessionMessagingKit/Crypto/Crypto+Attachments.swift @@ -4,6 +4,7 @@ import Foundation import CommonCrypto +import SessionUtil import SessionNetworkingKit import SessionUtilitiesKit @@ -114,12 +115,113 @@ public extension Crypto.Generator { return (Data(encryptedPaddedData), outKey, Data(digest)) } } + + @available(*, deprecated, message: "This encryption method is deprecated and will be removed in a future release.") + static func legacyEncryptAttachment( + plaintext: Data + ) -> Crypto.Generator<(ciphertext: Data, encryptionKey: Data, digest: Data)> { + return Crypto.Generator( + id: "encryptAttachment", + args: [plaintext] + ) { dependencies in + // Due to paddedSize, we need to divide by two. + guard plaintext.count < (UInt.max / 2) else { + Log.error("[Crypto] Attachment data too long to encrypt.") + throw CryptoError.encryptionFailed + } + + guard + var iv: [UInt8] = dependencies[singleton: .crypto].generate(.randomBytes(aesCBCIvLength)), + var encryptionKey: [UInt8] = dependencies[singleton: .crypto].generate(.randomBytes(aesKeySize)), + var hmacKey: [UInt8] = dependencies[singleton: .crypto].generate(.randomBytes(hmac256KeyLength)) + else { + Log.error("[Crypto] Failed to generate random data.") + throw CryptoError.encryptionFailed + } + + // The concatenated key for storage + var outKey: Data = Data() + outKey.append(Data(encryptionKey)) + outKey.append(Data(hmacKey)) + + // Apply any padding + let desiredSize: Int = max(541, min(Int(Network.maxFileSize), Int(floor(pow(1.05, ceil(log(Double(plaintext.count)) / log(1.05))))))) + var paddedAttachmentData: [UInt8] = Array(plaintext) + if desiredSize > plaintext.count { + paddedAttachmentData.append(contentsOf: [UInt8](repeating: 0, count: desiredSize - plaintext.count)) + } + + var numBytesEncrypted: size_t = 0 + var bufferData: [UInt8] = Array(Data(count: paddedAttachmentData.count + kCCBlockSizeAES128)) + let cryptStatus: CCCryptorStatus = CCCrypt( + CCOperation(kCCEncrypt), + CCAlgorithm(kCCAlgorithmAES128), + CCOptions(kCCOptionPKCS7Padding), + &encryptionKey, encryptionKey.count, + &iv, + &paddedAttachmentData, paddedAttachmentData.count, + &bufferData, bufferData.count, + &numBytesEncrypted + ) + + guard cryptStatus == kCCSuccess else { + Log.error("[Crypto] Failed to encrypt attachment with status: \(cryptStatus).") + throw CryptoError.encryptionFailed + } + + guard cryptStatus == kCCSuccess else { + Log.error("[Crypto] Failed to encrypt attachment with status: \(cryptStatus).") + throw CryptoError.encryptionFailed + } + + guard bufferData.count >= numBytesEncrypted else { + Log.error("[Crypto] ciphertext has unexpected length: \(bufferData.count) < \(numBytesEncrypted).") + throw CryptoError.encryptionFailed + } + + let ciphertext: [UInt8] = Array(bufferData[0.. String { + static func generateFilename(utType: UTType, using dependencies: Dependencies) -> String { return dependencies[singleton: .crypto] .generate(.uuid()) .defaulting(to: UUID()) .uuidString - .appendingFileExtension(format.fileExtension) + .appendingFileExtension(utType.sessionFileExtension(sourceFilename: nil) ?? "jpg") + } +} + +private extension String { + func appendingFileExtension(_ fileExtension: String) -> String { + guard let result = (self as NSString).appendingPathExtension(fileExtension) else { + return self + } + return result } } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 8b109d98cc..af14989d63 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -10,7 +10,7 @@ import SessionUtilitiesKit import SessionNetworkingKit import SessionUIKit -public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct Attachment: Sendable, Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "attachment" } internal static let linkPreviewForeignKey = ForeignKey([Columns.id], to: [LinkPreview.Columns.attachmentId]) public static let interactionAttachments = hasOne(InteractionAttachment.self) @@ -42,12 +42,12 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR case caption } - public enum Variant: Int, Codable, DatabaseValueConvertible { + public enum Variant: Int, Sendable, Codable, DatabaseValueConvertible { case standard case voiceMessage } - public enum State: Int, Codable, DatabaseValueConvertible { + public enum State: Int, Sendable, Codable, DatabaseValueConvertible { case failedDownload case pendingDownload case downloading @@ -121,7 +121,7 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR /// Caption for the attachment @available(*, deprecated, message: "This field is no longer sent or rendered by the clients") - public let caption: String? + public let caption: String? = nil // MARK: - Initialization @@ -160,52 +160,6 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR self.encryptionKey = encryptionKey self.digest = digest } - - /// This initializer should only be used when converting from either a LinkPreview or a SignalAttachment to an Attachment (prior to upload) - public init?( - id: String = UUID().uuidString, - variant: Variant = .standard, - contentType: String, - dataSource: any DataSource, - sourceFilename: String? = nil, - using dependencies: Dependencies - ) { - guard - let uploadInfo: (url: String, path: String) = try? dependencies[singleton: .attachmentManager] - .uploadPathAndUrl(for: id), - case .success = Result(try dataSource.write(to: uploadInfo.path)) - else { return nil } - - let imageSize: CGSize? = MediaUtils.unrotatedSize( - for: uploadInfo.path, - type: UTType(sessionMimeType: contentType), - mimeType: contentType, - sourceFilename: sourceFilename, - using: dependencies - ) - let (isValid, duration): (Bool, TimeInterval?) = dependencies[singleton: .attachmentManager].determineValidityAndDuration( - contentType: contentType, - downloadUrl: uploadInfo.url, - sourceFilename: sourceFilename - ) - - self.id = id - self.serverId = nil - self.variant = variant - self.state = .uploading - self.contentType = contentType - self.byteCount = UInt(dataSource.dataLength) - self.creationTimestamp = nil - self.sourceFilename = sourceFilename - self.downloadUrl = uploadInfo.url /// This value will be replaced once the upload is successful - self.width = imageSize.map { UInt(floor($0.width)) } - self.height = imageSize.map { UInt(floor($0.height)) } - self.duration = duration - self.isVisualMedia = UTType.isVisualMedia(contentType) - self.isValid = isValid - self.encryptionKey = nil - self.digest = nil - } } // MARK: - CustomStringConvertible @@ -405,8 +359,7 @@ extension Attachment { return MediaUtils.unrotatedSize( for: path, - type: UTType(sessionMimeType: contentType), - mimeType: contentType, + utType: UTType(sessionMimeType: contentType), sourceFilename: sourceFilename, using: dependencies ) diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 0c8cada73f..8b7a776a86 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -20,6 +20,7 @@ public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, Persis /// We want to cache url previews to the nearest 100,000 seconds (~28 hours - simpler than 86,400) to ensure the user isn't shown a preview that is too stale internal static let timstampResolution: Double = 100000 + internal static let maxImageDimension: CGFloat = 600 public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -131,21 +132,25 @@ public extension LinkPreview { return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution) } - static func generateAttachmentIfPossible(imageData: Data?, type: UTType, using dependencies: Dependencies) throws -> Attachment? { + static func generateAttachmentIfPossible(urlString: String, imageData: Data?, type: UTType, using dependencies: Dependencies) throws -> Attachment? { guard let imageData: Data = imageData, !imageData.isEmpty else { return nil } - guard let fileExtension: String = type.sessionFileExtension(sourceFilename: nil) else { return nil } - guard let mimeType: String = type.preferredMIMEType else { return nil } - let filePath = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: fileExtension) - try imageData.write(to: NSURL.fileURL(withPath: filePath), options: .atomicWrite) - let dataSource: DataSourcePath = DataSourcePath( - filePath: filePath, - sourceFilename: nil, - shouldDeleteOnDeinit: true, + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .media(urlString, imageData), + utType: type, + using: dependencies + ) + let preparedAttachment: PreparedAttachment = try pendingAttachment.prepare( + transformations: [ + .compress, + .convertToStandardFormats, + .resize(maxDimension: LinkPreview.maxImageDimension), + .stripImageMetadata + ], using: dependencies ) - return Attachment(contentType: mimeType, dataSource: dataSource, using: dependencies) + return preparedAttachment.attachment } static func isValidLinkUrl(_ urlString: String) -> Bool { @@ -432,7 +437,7 @@ public extension LinkPreview { let imageUrl: String = contents.imageUrl, URL(string: imageUrl) != nil, let imageFileExtension: String = fileExtension(forImageUrl: imageUrl), - let imageMimeType: String = UTType(sessionFileExtension: imageFileExtension)?.preferredMIMEType + let imageUTType: UTType = UTType(sessionFileExtension: imageFileExtension) else { return Just(LinkPreviewDraft(urlString: linkUrlString, title: title)) .setFailureType(to: Error.self) @@ -440,7 +445,7 @@ public extension LinkPreview { } return LinkPreview - .downloadImage(url: imageUrl, imageMimeType: imageMimeType, using: dependencies) + .downloadImage(url: imageUrl, imageUTType: imageUTType, using: dependencies) .map { imageData -> LinkPreviewDraft in // We always recompress images to Jpeg LinkPreviewDraft(urlString: linkUrlString, title: title, jpegImageData: imageData) @@ -489,7 +494,7 @@ public extension LinkPreview { private static func downloadImage( url urlString: String, - imageMimeType: String, + imageUTType: UTType, using dependencies: Dependencies ) -> AnyPublisher { guard @@ -509,11 +514,9 @@ public extension LinkPreview { shouldIgnoreSignalProxy: true ) .tryMap { asset, _ -> Data in - let type: UTType? = UTType(sessionMimeType: imageMimeType) let imageSize = MediaUtils.unrotatedSize( for: asset.filePath, - type: type, - mimeType: imageMimeType, + utType: imageUTType, sourceFilename: nil, using: dependencies ) @@ -523,7 +526,16 @@ public extension LinkPreview { } // Loki: If it's a GIF then ensure its validity and don't download it as a JPG - if type == .gif && MediaUtils.isValidImage(at: asset.filePath, type: .gif, using: dependencies) { + if + imageUTType == .gif, + let metadata: MediaUtils.MediaMetadata = MediaUtils.MediaMetadata( + from: asset.filePath, + utType: .gif, + sourceFilename: nil, + using: dependencies + ), + metadata.isValidImage + { return try Data(contentsOf: URL(fileURLWithPath: asset.filePath)) } diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift index 210d629967..6fded8365d 100644 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift @@ -156,7 +156,7 @@ public enum AttachmentDownloadJob: JobExecutor { else { return data } // Open group attachments are unencrypted return try dependencies[singleton: .crypto].tryGenerate( - .decryptAttachment( + .legacyDecryptAttachment( ciphertext: data, key: key, digest: digest, diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index a3cd20c22a..0bac4afff2 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -34,175 +34,167 @@ public enum AttachmentUploadJob: JobExecutor { let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData) else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - dependencies[singleton: .storage] - .readPublisher { db -> Attachment in - guard let attachment: Attachment = try? Attachment.fetchOne(db, id: details.attachmentId) else { - throw JobRunnerError.missingRequiredDetails - } - - /// If the original interaction no longer exists then don't bother uploading the attachment (ie. the message was - /// deleted before it even got sent) - guard (try? Interaction.exists(db, id: interactionId)) == true else { - throw StorageError.objectNotFound + Task { + do { + let attachment: Attachment = try await dependencies[singleton: .storage].readAsync { db in + guard let attachment: Attachment = try? Attachment.fetchOne(db, id: details.attachmentId) else { + throw JobRunnerError.missingRequiredDetails + } + + /// If the original interaction no longer exists then don't bother uploading the attachment (ie. the message was + /// deleted before it even got sent) + guard (try? Interaction.exists(db, id: interactionId)) == true else { + throw StorageError.objectNotFound + } + + /// If the attachment is still pending download the hold off on running this job + guard attachment.state != .pendingDownload && attachment.state != .downloading else { + throw AttachmentError.uploadIsStillPendingDownload + } + + return attachment } + try Task.checkCancellation() - /// If the attachment is still pending download the hold off on running this job - guard attachment.state != .pendingDownload && attachment.state != .downloading else { - throw AttachmentError.uploadIsStillPendingDownload + let authMethod: AuthenticationMethod = try await dependencies[singleton: .storage].writeAsync { db in + /// If this upload is related to sending a message then trigger the `handleMessageWillSend` logic as if + /// this is a retry the logic wouldn't run until after the upload has completed resulting in a potentially incorrect + /// delivery status + let threadVariant: SessionThread.Variant = try SessionThread + .select(.variant) + .filter(id: threadId) + .asRequest(of: SessionThread.Variant.self) + .fetchOne(db, orThrow: StorageError.objectNotFound) + let authMethod: AuthenticationMethod = try Authentication.with( + db, + threadId: threadId, + threadVariant: threadVariant, + using: dependencies + ) + + guard + let sendJob: Job = try Job.fetchOne(db, id: details.messageSendJobId), + let sendJobDetails: Data = sendJob.details, + let details: MessageSendJob.Details = try? JSONDecoder(using: dependencies) + .decode(MessageSendJob.Details.self, from: sendJobDetails) + else { return authMethod } + + MessageSender.handleMessageWillSend( + db, + threadId: threadId, + message: details.message, + destination: details.destination, + interactionId: interactionId, + using: dependencies + ) + + return authMethod } + try Task.checkCancellation() - return attachment - } - .flatMapStorageWritePublisher(using: dependencies) { db, attachment -> (Attachment, AuthenticationMethod) in - /// If this upload is related to sending a message then trigger the `handleMessageWillSend` logic as if this is a retry the - /// logic wouldn't run until after the upload has completed resulting in a potentially incorrect delivery status - let threadVariant: SessionThread.Variant = try SessionThread - .select(.variant) - .filter(id: threadId) - .asRequest(of: SessionThread.Variant.self) - .fetchOne(db, orThrow: StorageError.objectNotFound) - let authMethod: AuthenticationMethod = try Authentication.with( - db, - threadId: threadId, - threadVariant: threadVariant, - using: dependencies - ) - - guard - let sendJob: Job = try Job.fetchOne(db, id: details.messageSendJobId), - let sendJobDetails: Data = sendJob.details, - let details: MessageSendJob.Details = try? JSONDecoder(using: dependencies) - .decode(MessageSendJob.Details.self, from: sendJobDetails) - else { return (attachment, authMethod) } - - MessageSender.handleMessageWillSend( - db, - threadId: threadId, - message: details.message, - destination: details.destination, - interactionId: interactionId, - using: dependencies - ) - - return (attachment, authMethod) - } - .subscribe(on: scheduler, using: dependencies) - .receive(on: scheduler, using: dependencies) - .tryMap { attachment, authMethod -> Network.PreparedRequest<(attachment: Attachment, fileId: String)> in - try AttachmentUploader.preparedUpload( + let request: Network.PreparedRequest<(attachment: Attachment, fileId: String)> = try AttachmentUploader.preparedUpload( attachment: attachment, logCategory: .cat, authMethod: authMethod, using: dependencies ) - } - .flatMapStorageWritePublisher(using: dependencies) { db, uploadRequest -> Network.PreparedRequest<(attachment: Attachment, fileId: String)> in + /// If we have a `cachedResponse` (ie. already uploaded) then don't change the attachment state to uploading /// as it's already been done - guard uploadRequest.cachedResponse == nil else { return uploadRequest } - - /// Update the attachment to the `uploading` state - _ = try? Attachment - .filter(id: details.attachmentId) - .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading)) - db.addAttachmentEvent( - id: details.attachmentId, - messageId: job.interactionId, - type: .updated(.state(.uploading)) - ) - - return uploadRequest - } - .flatMap { $0.send(using: dependencies) } - .map { _, value in value.attachment } - .handleEvents( - receiveCancel: { - /// If the stream gets cancelled then `receiveCompletion` won't get called, so we need to handle that - /// case and flag the upload as cancelled - dependencies[singleton: .storage].writeAsync { db in - try Attachment + if request.cachedResponse == nil { + /// Update the attachment to the `uploading` state + try? await dependencies[singleton: .storage].writeAsync { db in + _ = try? Attachment .filter(id: details.attachmentId) - .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading)) db.addAttachmentEvent( id: details.attachmentId, messageId: job.interactionId, - type: .updated(.state(.failedUpload)) + type: .updated(.state(.uploading)) ) } } - ) - .flatMapStorageWritePublisher(using: dependencies) { db, updatedAttachment in - let updatedAttachment: Attachment = try updatedAttachment.upserted(db) - db.addAttachmentEvent( - id: updatedAttachment.id, - messageId: job.interactionId, - type: .updated(.state(updatedAttachment.state)) - ) - return updatedAttachment + // FIXME: Make this async/await when the refactored networking is merged + let response: (attachment: Attachment, fileId: String) = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw AttachmentError.uploadFailed }() + try Task.checkCancellation() + + /// Save the updated attachment + try await dependencies[singleton: .storage].writeAsync { db in + try response.attachment.upsert(db) + db.addAttachmentEvent( + id: response.attachment.id, + messageId: job.interactionId, + type: .updated(.state(response.attachment.state)) + ) + } + + return scheduler.schedule { + success(job, false) + } + } + catch JobRunnerError.missingRequiredDetails { + return scheduler.schedule { + failure(job, JobRunnerError.missingRequiredDetails, true) + } } - .sinkUntilComplete( - receiveCompletion: { result in - switch (result, result.errorOrNull) { - case (.finished, _): success(job, false) - - case (_, let error as JobRunnerError) where error == .missingRequiredDetails: - failure(job, error, true) - - case (_, let error as StorageError) where error == .objectNotFound: - Log.info(.cat, "Failed due to missing interaction") - failure(job, error, true) - - case (_, let error as AttachmentError) where error == .uploadIsStillPendingDownload: - Log.info(.cat, "Deferred as attachment is still being downloaded") - return deferred(job) - - case (.failure(let error), _): - dependencies[singleton: .storage].writeAsync( - updates: { db in - /// Update the attachment state - try Attachment - .filter(id: details.attachmentId) - .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) - db.addAttachmentEvent( - id: details.attachmentId, - messageId: job.interactionId, - type: .updated(.state(.failedUpload)) - ) - - /// If this upload is related to sending a message then trigger the `handleFailedMessageSend` logic - /// as we want to ensure the message has the correct delivery status - guard - let sendJob: Job = try Job.fetchOne(db, id: details.messageSendJobId), - let sendJobDetails: Data = sendJob.details, - let details: MessageSendJob.Details = try? JSONDecoder(using: dependencies) - .decode(MessageSendJob.Details.self, from: sendJobDetails) - else { return false } - - MessageSender.handleFailedMessageSend( - db, - threadId: threadId, - message: details.message, - destination: nil, - error: .other(.cat, "Failed", error), - interactionId: interactionId, - using: dependencies - ) - return true - }, - completion: { result in - /// If we didn't log an error above then log it now - switch result { - case .failure, .success(true): break - case .success(false): Log.error(.cat, "Failed due to error: \(error)") - } - - failure(job, error, false) - } - ) + catch StorageError.objectNotFound { + return scheduler.schedule { + Log.info(.cat, "Failed due to missing interaction") + failure(job, StorageError.objectNotFound, true) + } + } + catch AttachmentError.uploadIsStillPendingDownload { + return scheduler.schedule { + Log.info(.cat, "Deferred as attachment is still being downloaded") + return deferred(job) + } + } + catch { + let triggeredSendFailed: Bool? = try? await dependencies[singleton: .storage].writeAsync { db in + /// Update the attachment state + try Attachment + .filter(id: details.attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) + db.addAttachmentEvent( + id: details.attachmentId, + messageId: job.interactionId, + type: .updated(.state(.failedUpload)) + ) + + /// If this upload is related to sending a message then trigger the `handleFailedMessageSend` logic + /// as we want to ensure the message has the correct delivery status + guard + let sendJob: Job = try Job.fetchOne(db, id: details.messageSendJobId), + let sendJobDetails: Data = sendJob.details, + let details: MessageSendJob.Details = try? JSONDecoder(using: dependencies) + .decode(MessageSendJob.Details.self, from: sendJobDetails) + else { return false } + + MessageSender.handleFailedMessageSend( + db, + threadId: threadId, + message: details.message, + destination: nil, + error: .other(.cat, "Failed", error), + interactionId: interactionId, + using: dependencies + ) + return true + } + + return scheduler.schedule { + if triggeredSendFailed == false { + Log.error(.cat, "Failed due to error: \(error)") } + + failure(job, error, false) } - ) + } + } } } diff --git a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift index 6246a773c9..c72dd835e9 100644 --- a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift +++ b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift @@ -36,17 +36,25 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { } Task { - // TODO: Wait until we've received a poll response before running the logic? - // TODO: Check whether the image needs to be reprocessed - // TODO: Try to extend the TTL - let lastAttempt: Date = ( - dependencies[defaults: .standard, key: .lastUserDisplayPictureRefresh] ?? - Date.distantPast - ) + /// Retrieve the users profile data + let profile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } + + /// If we don't have a display pic then no need to do anything + guard + let displayPictureUrl: URL = profile.displayPictureUrl.map({ URL(string: $0) }), + let displayPictureEncryptionKey: Data = profile.displayPictureEncryptionKey + else { + Log.info(.cat, "User has no display picture") + return scheduler.schedule { + success(job, false) + } + } - /// Only try to extend the TTL of the users display pic if enough time has passed since the last attempt - guard dependencies.dateNow.timeIntervalSince(lastAttempt) > maxExtendTTLFrequency else { + /// Only try to extend the TTL of the users display pic if enough time has passed since it was last updated + let lastUpdated: Date = Date(timeIntervalSince1970: profile.profileLastUpdated ?? 0) + + guard dependencies.dateNow.timeIntervalSince(lastUpdated) > maxExtendTTLFrequency else { /// Reset the `nextRunTimestamp` value just in case the last run failed so we don't get stuck in a loop endlessly /// deferring the job if let jobId: Int64 = job.id { @@ -63,141 +71,111 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { } } - /// Retrieve the users profile data - let profile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } - - /// If we don't have a display pic then no need to do anything - guard let displayPictureUrl: URL = profile.displayPictureUrl.map({ URL(string: $0) }) else { - Log.info(.cat, "User has no display picture") - return scheduler.schedule { - success(job, false) - } - } - - // let displayPictureUpdate: DisplayPictureManager.Update = profile.displayPictureUrl - // .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } - // .map { dependencies[singleton: .fileManager].contents(atPath: $0) } - // .map { .currentUserUploadImageData($0) } - // .defaulting(to: .none) - /// Try to extend the TTL of the existing profile pic first do { - let preparedRequest: Network.PreparedRequest = try Network.FileServer.preparedExtend( + let request: Network.PreparedRequest = try Network.FileServer.preparedExtend( url: displayPictureUrl, ttl: maxDisplayPictureTTL, serverPubkey: Network.FileServer.fileServerPublicKey, using: dependencies ) - var response: FileUploadResponse? - var requestError: Error? - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - preparedRequest + // FIXME: Make this async/await when the refactored networking is merged + let response: FileUploadResponse = try await request .send(using: dependencies) - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .finished: break /// The `receiveValue` closure will handle - case .failure(let error): - requestError = error - semaphore.signal() - } - }, - receiveValue: { _, fileUploadResponse in - response = fileUploadResponse - semaphore.signal() - } - ) - - /// Wait for the request to complete - semaphore.wait() + .values + .first(where: { _ in true })?.1 ?? { throw DisplayPictureError.uploadFailed }() - // TODO: If it's a `NotFound` error then we should do the standard reupload logic + /// Even though the data hasn't changed, we need to trigger `Profile.UpdateLocal` in order for the + /// `profileLastUpdated` value to be updated correctly + try await Profile.updateLocal( + displayPictureUpdate: .currentUserUpdateTo( + url: displayPictureUrl.absoluteString, + key: displayPictureEncryptionKey, + isReupload: true + ), + using: dependencies + ) + Log.info(.cat, "Existing profile expiration extended") + return scheduler.schedule { + success(job, false) + } + } catch NetworkError.notFound { /// If we get a `404` it means we couldn't extend the TTL of the file so need to re-upload - switch (response, requestError) { - case (_, NetworkError.notFound): break - case (_, .some(let error)): - return scheduler.schedule { - failure(job, error, false) - } - - case (.none, .none): break - /// An unknown error occured (we got no response and no error - shouldn't be possible) - return scheduler.schedule { - failure(job, DisplayPictureError.uploadFailed, false) - } - - case (.some, .none): - Log.info(.cat, "Existing profile expiration extended") - - return scheduler.schedule { - success(job, false) - } + } catch { + return scheduler.schedule { + failure(job, error, false) } + } + + /// Since we made it here it means that refreshing the TTL failed so we may need to reupload the display picture + do { + let pendingDisplayPicture: PendingAttachment = PendingAttachment( + source: .displayPicture(.url(displayPictureUrl)), + using: dependencies + ) - /// Determine whether we need to re-process the display picture before re-uploading it - var needsReprocessing: Bool = ((profile.profileLastUpdated ?? 0) == 0) - - if !needsReprocessing { - try? dependencies[singleton: .displayPictureManager].path(for: $0) - displayPictureUrl + guard + try profile.profileLastUpdated == 0 || + dependencies.dateNow.timeIntervalSince(lastUpdated) > maxReuploadFrequency || + dependencies[feature: .shortenFileTTL] || + pendingDisplayPicture.needsPreparationForAttachmentUpload( + transformations: [ + .convertToStandardFormats, + .resize(maxDimension: DisplayPictureManager.maxDimension) + ] + ) + else { + /// Reset the `nextRunTimestamp` value just in case the last run failed so we don't get stuck in a loop endlessly + /// deferring the job + if let jobId: Int64 = job.id { + dependencies[singleton: .storage].write { db in + try Job + .filter(id: jobId) + .updateAll(db, Job.Columns.nextRunTimestamp.set(to: 0)) + } + } + + return scheduler.schedule { + Log.info(.cat, "Deferred as not enough time has passed since the last update") + deferred(job) + } } + /// Prepare and upload the display picture + let preparedAttachment: PreparedAttachment = try dependencies[singleton: .displayPictureManager] + .prepareDisplayPicture( + attachment: pendingDisplayPicture, + transformations: [ + .convertToStandardFormats, + .resize(maxDimension: DisplayPictureManager.maxDimension), + .encrypt(legacy: true) // FIXME: Remove the `legacy` encryption option + ] + ) + let result = try await dependencies[singleton: .displayPictureManager] + .uploadDisplayPicture(attachment: preparedAttachment) + + /// Update the local state now that the display picture has finished uploading + try await Profile.updateLocal( + displayPictureUpdate: .currentUserUpdateTo( + url: result.downloadUrl, + key: result.encryptionKey, + isReupload: true + ), + using: dependencies + ) - //profile.pro - // TODO: If `shortenFileTTL` is set then reupload even if it's less than the 12 day timeout - // TODO: Update the timestamp on successful extend - // dependencies[defaults: .standard, key: .lastUserDisplayPictureReupload] = dependencies.dateNow + return scheduler.schedule { + Log.info(.cat, "Profile successfully updated") + success(job, false) + } } catch { - failure(job, error, false) + return scheduler.schedule { + failure(job, error, false) + } } - - // // Only re-upload the profile picture if enough time has passed since the last upload - // guard - // let lastAttempt: Date = dependencies[defaults: .standard, key: .lastProfilePictureReuploadAttempt], - // dependencies.dateNow.timeIntervalSince(lastProfilePictureUpload) > (14 * 24 * 60 * 60) - // else { - // // Reset the `nextRunTimestamp` value just in case the last run failed so we don't get stuck - // // in a loop endlessly deferring the job - // if let jobId: Int64 = job.id { - // dependencies[singleton: .storage].write { db in - // try Job - // .filter(id: jobId) - // .updateAll(db, Job.Columns.nextRunTimestamp.set(to: 0)) - // } - // } - // - // Log.info(.cat, "Deferred as not enough time has passed since the last update") - // return deferred(job) - // } - // - // /// **Note:** The `lastProfilePictureUpload` value is updated in `DisplayPictureManager` - // let profile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } - // let displayPictureUpdate: DisplayPictureManager.Update = profile.displayPictureUrl - // .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } - // .map { dependencies[singleton: .fileManager].contents(atPath: $0) } - // .map { .currentUserUploadImageData($0) } - // .defaulting(to: .none) - // - // Profile - // .updateLocal( - // displayPictureUpdate: displayPictureUpdate, - // using: dependencies - // ) - // .subscribe(on: scheduler, using: dependencies) - // .receive(on: scheduler, using: dependencies) - // .sinkUntilComplete( - // receiveCompletion: { result in - // switch result { - // case .failure(let error): failure(job, error, false) - // case .finished: - // Log.info(.cat, "Profile successfully updated") - // success(job, false) - // } - // } - // ) } } } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index 84f661266d..d4cd510b81 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -63,19 +63,16 @@ internal extension LibSessionCacheType { publicKey: userSessionId.hexString, displayNameUpdate: .currentUserUpdate(profileName), displayPictureUpdate: { - guard - let displayPictureUrl: String = displayPictureUrl, - let filePath: String = try? dependencies[singleton: .displayPictureManager] - .path(for: displayPictureUrl) - else { return .currentUserRemove } + guard let displayPictureUrl: String = displayPictureUrl else { return .currentUserRemove } return .currentUserUpdateTo( url: displayPictureUrl, key: displayPic.get(\.key), - filePath: filePath + isReupload: false ) }(), profileUpdateTimestamp: profileLastUpdateTimestamp, + suppressUserProfileConfigUpdate: true, using: dependencies ) diff --git a/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift b/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift index d90d1f9a7b..f816f9038d 100644 --- a/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift +++ b/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift @@ -135,30 +135,45 @@ public final class AttachmentUploader { ) } - // Get the raw attachment data - guard let rawData: Data = try? attachment.readDataFromFile(using: dependencies) else { - Log.error([cat].compactMap { $0 }, "Couldn't read attachment from disk.") - throw AttachmentError.noAttachment - } - // Encrypt the attachment if needed - var finalData: Data = rawData - var encryptionKey: Data? - var digest: Data? + let pendingAttachment: PendingAttachment = try PendingAttachment( + attachment: attachment, + using: dependencies + ) + let finalData: Data + let encryptionKey: Data? + let digest: Data? if destination.shouldEncrypt { - guard - let result: EncryptionData = dependencies[singleton: .crypto].generate( - .encryptAttachment(plaintext: rawData) - ) - else { + let preparedAttachment: PreparedAttachment = try pendingAttachment.prepare( + transformations: [ + .encrypt(legacy: true) // FIXME: Remove the `legacy` encryption option + ], + using: dependencies + ) + let maybeEncryptedData: Data? = dependencies[singleton: .fileManager] + .contents(atPath: preparedAttachment.temporaryFilePath) + + guard let encryptedData: Data = maybeEncryptedData else { Log.error([cat].compactMap { $0 }, "Couldn't encrypt attachment.") throw AttachmentError.encryptionFailed } + + + finalData = encryptedData + encryptionKey = preparedAttachment.attachment.encryptionKey + digest = preparedAttachment.attachment.digest + } + else { + // Get the raw attachment data + guard let rawData: Data = try? attachment.readDataFromFile(using: dependencies) else { + Log.error([cat].compactMap { $0 }, "Couldn't read attachment from disk.") + throw AttachmentError.noAttachment + } - finalData = result.ciphertext - encryptionKey = result.encryptionKey - digest = result.digest + finalData = rawData + encryptionKey = nil + digest = nil } // Ensure the file size is smaller than our upload limit diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift b/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift deleted file mode 100644 index 7ed8b82823..0000000000 --- a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift +++ /dev/null @@ -1,961 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// -// stringlint:disable - -import UIKit -import Combine -import MobileCoreServices -import AVFoundation -import UniformTypeIdentifiers -import SessionUtilitiesKit - -public enum SignalAttachmentError: Error { - case missingData - case fileSizeTooLarge - case invalidData - case couldNotParseImage - case couldNotConvertToJpeg - case couldNotConvertToMpeg4 - case couldNotRemoveMetadata - case invalidFileFormat - case couldNotResizeImage -} - -extension String { - public var filenameWithoutExtension: String { - return (self as NSString).deletingPathExtension - } - - public var fileExtension: String? { - return (self as NSString).pathExtension - } - - public func appendingFileExtension(_ fileExtension: String) -> String { - guard let result = (self as NSString).appendingPathExtension(fileExtension) else { - return self - } - return result - } -} - -extension SignalAttachmentError: LocalizedError { - public var errorDescription: String? { - switch self { - case .fileSizeTooLarge: - return "attachmentsErrorSize".localized() - case .invalidData, .missingData, .invalidFileFormat: - return "attachmentsErrorNotSupported".localized() - case .couldNotConvertToJpeg, .couldNotParseImage, .couldNotConvertToMpeg4, .couldNotResizeImage: - return "attachmentsErrorOpen".localized() - case .couldNotRemoveMetadata: - return "attachmentsImageErrorMetadata".localized() - } - } -} - -@objc -public enum TSImageQualityTier: UInt { - case original - case high - case mediumHigh - case medium - case mediumLow - case low -} - -@objc -public enum TSImageQuality: UInt { - case original - case medium - case compact - - func imageQualityTier() -> TSImageQualityTier { - switch self { - case .original: - return .original - case .medium: - return .mediumHigh - case .compact: - return .medium - } - } -} - -// Represents a possible attachment to upload. -// The attachment may be invalid. -// -// Signal attachments are subject to validation and -// in some cases, file format conversion. -// -// This class gathers that logic. It offers factory methods -// for attachments that do the necessary work. -// -// The return value for the factory methods will be nil if the input is nil. -// -// [SignalAttachment hasError] will be true for non-valid attachments. -// -// TODO: Perhaps do conversion off the main thread? -// FIXME: Would be nice to replace the `SignalAttachment` and use our internal types (eg. `ImageDataManager`) -public class SignalAttachment: Equatable { - - // MARK: Properties - - public let dataSource: (any DataSource) - public var linkPreviewDraft: LinkPreviewDraft? - - public var data: Data { return dataSource.data } - public var dataLength: UInt { return UInt(dataSource.dataLength) } - public var dataUrl: URL? { return dataSource.dataUrl } - public var sourceFilename: String? { return dataSource.sourceFilename?.filteredFilename } - public var isValidImage: Bool { return dataSource.isValidImage } - public var isValidVideo: Bool { return dataSource.isValidVideo } - public var imageSize: CGSize? { return dataSource.imageSize } - - // This flag should be set for text attachments that can be sent as text messages. - public var isConvertibleToTextMessage = false - - // This flag should be set for attachments that can be sent as contact shares. - public var isConvertibleToContactShare = false - - // Attachment types are identified using UTType. - public let dataType: UTType - - public var error: SignalAttachmentError? { - didSet { - assert(oldValue == nil) - } - } - - // To avoid redundant work of repeatedly compressing/uncompressing - // images, we cache the UIImage associated with this attachment if - // possible. - private var cachedImage: UIImage? - private var cachedVideoPreview: UIImage? - - private(set) public var isVoiceMessage = false - - // MARK: - - public static let maxAttachmentsAllowed: Int = 32 - - // MARK: Constructor - - // This method should not be called directly; use the factory - // methods instead. - private init(dataSource: (any DataSource), dataType: UTType) { - self.dataSource = dataSource - self.dataType = dataType - } - - // MARK: Methods - - public var hasError: Bool { return error != nil } - - public var errorName: String? { - guard let error = error else { - // This method should only be called if there is an error. - return nil - } - - return "\(error)" - } - - public var localizedErrorDescription: String? { - guard let error = self.error else { - // This method should only be called if there is an error. - return nil - } - guard let errorDescription = error.errorDescription else { - return nil - } - - return "\(errorDescription)" - } - - public class var missingDataErrorMessage: String { - guard let errorDescription = SignalAttachmentError.missingData.errorDescription else { - return "" - } - - return errorDescription - } - - public func text() -> String? { - guard let text = String(data: dataSource.data, encoding: .utf8) else { - return nil - } - - return text - } - - public func duration(using dependencies: Dependencies) -> TimeInterval? { - switch (isAudio, isVideo) { - case (true, _): - let audioPlayer: AVAudioPlayer? = try? AVAudioPlayer(data: dataSource.data) - - return (audioPlayer?.duration).map { $0 > 0 ? $0 : nil } - - case (_, true): - guard - let mimeType: String = dataType.sessionMimeType, - let url: URL = dataUrl, - let assetInfo: (asset: AVURLAsset, cleanup: () -> Void) = AVURLAsset.asset( - for: url.path, - mimeType: mimeType, - sourceFilename: sourceFilename, - using: dependencies - ) - else { return nil } - - // According to the CMTime docs "value/timescale = seconds" - let duration: TimeInterval = (TimeInterval(assetInfo.asset.duration.value) / TimeInterval(assetInfo.asset.duration.timescale)) - assetInfo.cleanup() - - return duration - - default: return nil - } - } - - // Returns the MIME type for this attachment or nil if no MIME type - // can be identified. - public var mimeType: String { - guard - let fileExtension: String = sourceFilename.map({ URL(fileURLWithPath: $0) })?.pathExtension, - !fileExtension.isEmpty, - let fileExtensionMimeType: String = UTType(sessionFileExtension: fileExtension)?.preferredMIMEType - else { return (dataType.preferredMIMEType ?? UTType.mimeTypeDefault) } - - // UTI types are an imperfect means of representing file type; - // file extensions are also imperfect but far more reliable and - // comprehensive so we always prefer to try to deduce MIME type - // from the file extension. - return fileExtensionMimeType - } - - // Use the filename if known. If not, e.g. if the attachment was copy/pasted, we'll generate a filename - // like: "signal-2017-04-24-095918.zip" - public var filenameOrDefault: String { - if let filename = sourceFilename { - return filename.filteredFilename - } else { - let kDefaultAttachmentName = "signal" - - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "YYYY-MM-dd-HHmmss" - let dateString = dateFormatter.string(from: Date()) - - let withoutExtension = "\(kDefaultAttachmentName)-\(dateString)" - if let fileExtension = self.fileExtension { - return "\(withoutExtension).\(fileExtension)" - } - - return withoutExtension - } - } - - // Returns the file extension for this attachment or nil if no file extension - // can be identified. - public var fileExtension: String? { - guard - let fileExtension: String = sourceFilename.map({ URL(fileURLWithPath: $0) })?.pathExtension, - !fileExtension.isEmpty - else { return dataType.sessionFileExtension(sourceFilename: sourceFilename) } - - return fileExtension.filteredFilename - } - - public var isImage: Bool { dataType.isImage || dataType.isAnimated } - public var isAnimatedImage: Bool { dataType.isAnimated } - public var isVideo: Bool { dataType.isVideo } - public var isAudio: Bool { dataType.isAudio } - - public var isText: Bool { - isConvertibleToTextMessage && - dataType.conforms(to: .text) - } - - public var isUrl: Bool { - dataType.conforms(to: .url) - } - - public class func pasteboardHasPossibleAttachment() -> Bool { - return UIPasteboard.general.numberOfItems > 0 - } - - public class func pasteboardHasText() -> Bool { - guard - UIPasteboard.general.numberOfItems > 0, - let pasteboardUTIdentifiers: [[String]] = UIPasteboard.general.types(forItemSet: IndexSet(integer: 0)), - let pasteboardUTTypes: Set = pasteboardUTIdentifiers.first.map({ Set($0.compactMap { UTType($0) }) }) - else { return false } - - // The pasteboard can be populated with multiple UTI types - // with different payloads. iMessage for example will copy - // an animated GIF to the pasteboard with the following UTI - // types: - // - // * "public.url-name" - // * "public.utf8-plain-text" - // * "com.compuserve.gif" - // - // We want to paste the animated GIF itself, not it's name. - // - // In general, our rule is to prefer non-text pasteboard - // contents, so we return true IFF there is a text UTI type - // and there is no non-text UTI type. - guard !pasteboardUTTypes.contains(where: { !$0.conforms(to: .text) }) else { return false } - - return pasteboardUTTypes.contains(where: { $0.conforms(to: .text) || $0.conforms(to: .url) }) - } - - // Returns an attachment from the pasteboard, or nil if no attachment - // can be found. - // - // NOTE: The attachment returned by this method may not be valid. - // Check the attachment's error property. - public class func attachmentFromPasteboard(using dependencies: Dependencies) -> SignalAttachment? { - guard - UIPasteboard.general.numberOfItems > 0, - let pasteboardUTIdentifiers: [[String]] = UIPasteboard.general.types(forItemSet: IndexSet(integer: 0)), - let pasteboardUTTypes: Set = pasteboardUTIdentifiers.first.map({ Set($0.compactMap { UTType($0) }) }) - else { return nil } - - for type in UTType.supportedInputImageTypes { - if pasteboardUTTypes.contains(type) { - guard let data: Data = dataForFirstPasteboardItem(type: type) else { return nil } - - // Pasted images _SHOULD _NOT_ be resized, if possible. - let dataSource = DataSourceValue(data: data, dataType: type, using: dependencies) - return attachment(dataSource: dataSource, type: type, imageQuality: .original, using: dependencies) - } - } - for type in UTType.supportedVideoTypes { - if pasteboardUTTypes.contains(type) { - guard let data = dataForFirstPasteboardItem(type: type) else { return nil } - - let dataSource = DataSourceValue(data: data, dataType: type, using: dependencies) - return videoAttachment(dataSource: dataSource, type: type, using: dependencies) - } - } - for type in UTType.supportedAudioTypes { - if pasteboardUTTypes.contains(type) { - guard let data = dataForFirstPasteboardItem(type: type) else { return nil } - - let dataSource = DataSourceValue(data: data, dataType: type, using: dependencies) - return audioAttachment(dataSource: dataSource, type: type, using: dependencies) - } - } - - let type: UTType = pasteboardUTTypes[pasteboardUTTypes.startIndex] - guard let data = dataForFirstPasteboardItem(type: type) else { return nil } - - let dataSource = DataSourceValue(data: data, dataType: type, using: dependencies) - return genericAttachment(dataSource: dataSource, type: type, using: dependencies) - } - - // This method should only be called for dataUTIs that - // are appropriate for the first pasteboard item. - private class func dataForFirstPasteboardItem(type: UTType) -> Data? { - guard - UIPasteboard.general.numberOfItems > 0, - let dataValues: [Data] = UIPasteboard.general.data( - forPasteboardType: type.identifier, - inItemSet: IndexSet(integer: 0) - ), - !dataValues.isEmpty - else { return nil } - - return dataValues[0] - } - - // MARK: Image Attachments - - // Factory method for an image attachment. - // - // NOTE: The attachment returned by this method may not be valid. - // Check the attachment's error property. - private class func imageAttachment(dataSource: (any DataSource)?, type: UTType, imageQuality: TSImageQuality, using dependencies: Dependencies) -> SignalAttachment { - assert(dataSource != nil) - guard var dataSource = dataSource else { - let attachment = SignalAttachment(dataSource: DataSourceValue.empty(using: dependencies), dataType: type) - attachment.error = .missingData - return attachment - } - - let attachment = SignalAttachment(dataSource: dataSource, dataType: type) - - guard UTType.supportedInputImageTypes.contains(type) else { - attachment.error = .invalidFileFormat - return attachment - } - - guard dataSource.dataLength > 0 else { - attachment.error = .invalidData - return attachment - } - - if UTType.supportedAnimatedImageTypes.contains(type) { - guard dataSource.dataLength <= SNUtilitiesKit.maxFileSize else { - attachment.error = .fileSizeTooLarge - return attachment - } - - // Never re-encode animated images (i.e. GIFs) as JPEGs. - return attachment - } else { - guard let image = UIImage(data: dataSource.data) else { - attachment.error = .couldNotParseImage - return attachment - } - attachment.cachedImage = image - - let isValidOutput = isValidOutputImage(image: image, dataSource: dataSource, type: type, imageQuality: imageQuality) - - if let sourceFilename = dataSource.sourceFilename, - let sourceFileExtension = sourceFilename.fileExtension, - ["heic", "heif"].contains(sourceFileExtension.lowercased()) { - - // If a .heic file actually contains jpeg data, update the extension to match. - // - // Here's how that can happen: - // In iOS11, the Photos.app records photos with HEIC UTIType, with the .HEIC extension. - // Since HEIC isn't a valid output format for Signal, we'll detect that and convert to JPEG, - // updating the extension as well. No problem. - // However the problem comes in when you edit an HEIC image in Photos.app - the image is saved - // in the Photos.app as a JPEG, but retains the (now incongruous) HEIC extension in the filename. - assert(type == .jpeg || !isValidOutput) - - let baseFilename = sourceFilename.filenameWithoutExtension - dataSource.sourceFilename = baseFilename.appendingFileExtension("jpg") - } - - if isValidOutput { - return removeImageMetadata(attachment: attachment, using: dependencies) - } else { - return compressImageAsJPEG(image: image, attachment: attachment, filename: dataSource.sourceFilename, imageQuality: imageQuality, using: dependencies) - } - } - } - - // If the proposed attachment already conforms to the - // file size and content size limits, don't recompress it. - private class func isValidOutputImage(image: UIImage?, dataSource: (any DataSource)?, type: UTType, imageQuality: TSImageQuality) -> Bool { - guard - image != nil, - let dataSource = dataSource, - UTType.supportedOutputImageTypes.contains(type) - else { return false } - - return ( - doesImageHaveAcceptableFileSize(dataSource: dataSource, imageQuality: imageQuality) && - dataSource.dataLength <= SNUtilitiesKit.maxFileSize - ) - } - - // Factory method for an image attachment. - // - // NOTE: The attachment returned by this method may nil or not be valid. - // Check the attachment's error property. - public class func imageAttachment(image: UIImage?, type: UTType, filename: String?, imageQuality: TSImageQuality, using dependencies: Dependencies) -> SignalAttachment { - guard let image: UIImage = image else { - let dataSource = DataSourceValue.empty(using: dependencies) - dataSource.sourceFilename = filename - let attachment = SignalAttachment(dataSource: dataSource, dataType: type) - attachment.error = .missingData - return attachment - } - - // Make a placeholder attachment on which to hang errors if necessary. - let dataSource = DataSourceValue.empty(using: dependencies) - dataSource.sourceFilename = filename - let attachment = SignalAttachment(dataSource: dataSource, dataType: type) - attachment.cachedImage = image - - return compressImageAsJPEG(image: image, attachment: attachment, filename: filename, imageQuality: imageQuality, using: dependencies) - } - - private class func compressImageAsJPEG(image: UIImage, attachment: SignalAttachment, filename: String?, imageQuality: TSImageQuality, using dependencies: Dependencies) -> SignalAttachment { - assert(attachment.error == nil) - - if imageQuality == .original && - attachment.dataLength < SNUtilitiesKit.maxFileSize && - UTType.supportedOutputImageTypes.contains(attachment.dataType) { - // We should avoid resizing images attached "as documents" if possible. - return attachment - } - - var imageUploadQuality = imageQuality.imageQualityTier() - - while true { - let maxSize = maxSizeForImage(image: image, imageUploadQuality: imageUploadQuality) - var dstImage: UIImage! = image - if image.size.width > maxSize || - image.size.height > maxSize { - guard let resizedImage = imageScaled(image, toMaxSize: maxSize) else { - attachment.error = .couldNotResizeImage - return attachment - } - dstImage = resizedImage - } - guard let jpgImageData = dstImage.jpegData(compressionQuality: jpegCompressionQuality(imageUploadQuality: imageUploadQuality)) else { - attachment.error = .couldNotConvertToJpeg - return attachment - } - - let dataSource = DataSourceValue(data: jpgImageData, fileExtension: "jpg", using: dependencies) - let baseFilename = filename?.filenameWithoutExtension - let jpgFilename = baseFilename?.appendingFileExtension("jpg") - dataSource.sourceFilename = jpgFilename - - if doesImageHaveAcceptableFileSize(dataSource: dataSource, imageQuality: imageQuality) && - dataSource.dataLength <= SNUtilitiesKit.maxFileSize { - let recompressedAttachment = SignalAttachment(dataSource: dataSource, dataType: .jpeg) - recompressedAttachment.cachedImage = dstImage - return recompressedAttachment - } - - // If the JPEG output is larger than the file size limit, - // continue to try again by progressively reducing the - // image upload quality. - switch imageUploadQuality { - case .original: - imageUploadQuality = .high - case .high: - imageUploadQuality = .mediumHigh - case .mediumHigh: - imageUploadQuality = .medium - case .medium: - imageUploadQuality = .mediumLow - case .mediumLow: - imageUploadQuality = .low - case .low: - attachment.error = .fileSizeTooLarge - return attachment - } - } - } - - // NOTE: For unknown reasons, resizing images with UIGraphicsBeginImageContext() - // crashes reliably in the share extension after screen lock's auth UI has been presented. - // Resizing using a CGContext seems to work fine. - private class func imageScaled(_ uiImage: UIImage, toMaxSize maxSize: CGFloat) -> UIImage? { - guard let cgImage = uiImage.cgImage else { - return nil - } - - // It's essential that we work consistently in "CG" coordinates (which are - // pixels and don't reflect orientation), not "UI" coordinates (which - // are points and do reflect orientation). - let scrSize = CGSize(width: cgImage.width, height: cgImage.height) - var maxSizeRect = CGRect.zero - maxSizeRect.size = CGSize(width: maxSize, height: maxSize) - let newSize = AVMakeRect(aspectRatio: scrSize, insideRect: maxSizeRect).size - - let colorSpace = CGColorSpaceCreateDeviceRGB() - let bitmapInfo: CGBitmapInfo = [ - CGBitmapInfo(rawValue: CGImageByteOrderInfo.orderDefault.rawValue), - CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)] - guard let context = CGContext.init(data: nil, - width: Int(newSize.width), - height: Int(newSize.height), - bitsPerComponent: 8, - bytesPerRow: 0, - space: colorSpace, - bitmapInfo: bitmapInfo.rawValue) else { - return nil - } - context.interpolationQuality = .high - - var drawRect = CGRect.zero - drawRect.size = newSize - context.draw(cgImage, in: drawRect) - - guard let newCGImage = context.makeImage() else { - return nil - } - return UIImage(cgImage: newCGImage, - scale: uiImage.scale, - orientation: uiImage.imageOrientation) - } - - private class func doesImageHaveAcceptableFileSize(dataSource: (any DataSource), imageQuality: TSImageQuality) -> Bool { - switch imageQuality { - case .original: - return true - case .medium: - return dataSource.dataLength < UInt(1024 * 1024) - case .compact: - return dataSource.dataLength < UInt(400 * 1024) - } - } - - private class func maxSizeForImage(image: UIImage, imageUploadQuality: TSImageQualityTier) -> CGFloat { - switch imageUploadQuality { - case .original: - return max(image.size.width, image.size.height) - case .high: - return 2048 - case .mediumHigh: - return 1536 - case .medium: - return 1024 - case .mediumLow: - return 768 - case .low: - return 512 - } - } - - private class func jpegCompressionQuality(imageUploadQuality: TSImageQualityTier) -> CGFloat { - switch imageUploadQuality { - case .original: - return 1 - case .high: - return 0.9 - case .mediumHigh: - return 0.8 - case .medium: - return 0.7 - case .mediumLow: - return 0.6 - case .low: - return 0.5 - } - } - - private class func removeImageMetadata(attachment: SignalAttachment, using dependencies: Dependencies) -> SignalAttachment { - guard let source = CGImageSourceCreateWithData(attachment.data as CFData, nil) else { - let attachment = SignalAttachment(dataSource: DataSourceValue.empty(using: dependencies), dataType: attachment.dataType) - attachment.error = .missingData - return attachment - } - - guard let type = CGImageSourceGetType(source) else { - let attachment = SignalAttachment(dataSource: DataSourceValue.empty(using: dependencies), dataType: attachment.dataType) - attachment.error = .invalidFileFormat - return attachment - } - - let count = CGImageSourceGetCount(source) - let mutableData = NSMutableData() - guard let destination = CGImageDestinationCreateWithData(mutableData as CFMutableData, type, count, nil) else { - attachment.error = .couldNotRemoveMetadata - return attachment - } - - let removeMetadataProperties: [String: AnyObject] = - [ - kCGImagePropertyExifDictionary as String: kCFNull, - kCGImagePropertyExifAuxDictionary as String: kCFNull, - kCGImagePropertyGPSDictionary as String: kCFNull, - kCGImagePropertyTIFFDictionary as String: kCFNull, - kCGImagePropertyJFIFDictionary as String: kCFNull, - kCGImagePropertyPNGDictionary as String: kCFNull, - kCGImagePropertyIPTCDictionary as String: kCFNull, - kCGImagePropertyMakerAppleDictionary as String: kCFNull - ] - - for index in 0...count-1 { - CGImageDestinationAddImageFromSource(destination, source, index, removeMetadataProperties as CFDictionary) - } - - if CGImageDestinationFinalize(destination) { - guard let dataSource = DataSourceValue(data: mutableData as Data, dataType: attachment.dataType, using: dependencies) else { - attachment.error = .couldNotRemoveMetadata - return attachment - } - - let strippedAttachment = SignalAttachment(dataSource: dataSource, dataType: attachment.dataType) - return strippedAttachment - - } else { - attachment.error = .couldNotRemoveMetadata - return attachment - } - } - - // MARK: Video Attachments - - // Factory method for video attachments. - // - // NOTE: The attachment returned by this method may not be valid. - // Check the attachment's error property. - private class func videoAttachment(dataSource: (any DataSource)?, type: UTType, using dependencies: Dependencies) -> SignalAttachment { - guard let dataSource = dataSource else { - let dataSource = DataSourceValue.empty(using: dependencies) - let attachment = SignalAttachment(dataSource: dataSource, dataType: type) - attachment.error = .missingData - return attachment - } - - return newAttachment( - dataSource: dataSource, - type: type, - validTypes: UTType.supportedVideoTypes, - maxFileSize: SNUtilitiesKit.maxFileSize, - using: dependencies - ) - } - - public class func copyToVideoTempDir(url fromUrl: URL, using dependencies: Dependencies) throws -> URL { - let baseDir = SignalAttachment.videoTempPath(using: dependencies) - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: baseDir.path) - let toUrl = baseDir.appendingPathComponent(fromUrl.lastPathComponent) - - try dependencies[singleton: .fileManager].copyItem(at: fromUrl, to: toUrl) - - return toUrl - } - - private class func videoTempPath(using dependencies: Dependencies) -> URL { - let videoDir = URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) - .appendingPathComponent("video") - try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: videoDir.path) - return videoDir - } - - public class func compressVideoAsMp4( - dataSource: (any DataSource), - type: UTType, - using dependencies: Dependencies - ) -> (AnyPublisher, AVAssetExportSession?) { - guard let url = dataSource.dataUrl else { - let attachment = SignalAttachment(dataSource: DataSourceValue.empty(using: dependencies), dataType: type) - attachment.error = .missingData - return ( - Just(attachment) - .setFailureType(to: Error.self) - .eraseToAnyPublisher(), - nil - ) - } - - let asset = AVAsset(url: url) - - guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else { - let attachment = SignalAttachment(dataSource: DataSourceValue.empty(using: dependencies), dataType: type) - attachment.error = .couldNotConvertToMpeg4 - return ( - Just(attachment) - .setFailureType(to: Error.self) - .eraseToAnyPublisher(), - nil - ) - } - - exportSession.shouldOptimizeForNetworkUse = true - exportSession.outputFileType = AVFileType.mp4 - exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() - - let exportURL = videoTempPath(using: dependencies) - .appendingPathComponent(UUID().uuidString).appendingPathExtension("mp4") - exportSession.outputURL = exportURL - - let publisher = Deferred { - Future { resolver in - exportSession.exportAsynchronously { - let baseFilename = dataSource.sourceFilename - let mp4Filename = baseFilename?.filenameWithoutExtension.appendingFileExtension("mp4") - - guard let dataSource = DataSourcePath(fileUrl: exportURL, sourceFilename: baseFilename, shouldDeleteOnDeinit: true, using: dependencies) else { - let attachment = SignalAttachment(dataSource: DataSourceValue.empty(using: dependencies), dataType: type) - attachment.error = .couldNotConvertToMpeg4 - resolver(Result.success(attachment)) - return - } - - dataSource.sourceFilename = mp4Filename - - let attachment = SignalAttachment(dataSource: dataSource, dataType: .mpeg4Movie) - resolver(Result.success(attachment)) - } - } - } - .eraseToAnyPublisher() - - return (publisher, exportSession) - } - - public struct VideoCompressionResult { - public let attachmentPublisher: AnyPublisher - public let exportSession: AVAssetExportSession? - - fileprivate init(attachmentPublisher: AnyPublisher, exportSession: AVAssetExportSession?) { - self.attachmentPublisher = attachmentPublisher - self.exportSession = exportSession - } - } - - public class func compressVideoAsMp4(dataSource: (any DataSource), type: UTType, using dependencies: Dependencies) -> VideoCompressionResult { - let (attachmentPublisher, exportSession) = compressVideoAsMp4(dataSource: dataSource, type: type, using: dependencies) - return VideoCompressionResult(attachmentPublisher: attachmentPublisher, exportSession: exportSession) - } - - public class func isInvalidVideo(dataSource: (any DataSource), type: UTType) -> Bool { - guard UTType.supportedVideoTypes.contains(type) else { - // not a video - return false - } - - guard isValidOutputVideo(dataSource: dataSource, type: type) else { - // found a video which needs to be converted - return true - } - - // It is a video, but it's not invalid - return false - } - - private class func isValidOutputVideo(dataSource: (any DataSource)?, type: UTType) -> Bool { - guard - let dataSource = dataSource, - UTType.supportedOutputVideoTypes.contains(type), - dataSource.dataLength <= SNUtilitiesKit.maxFileSize - else { return false } - - return false - } - - // MARK: Audio Attachments - - // Factory method for audio attachments. - // - // NOTE: The attachment returned by this method may not be valid. - // Check the attachment's error property. - private class func audioAttachment(dataSource: (any DataSource)?, type: UTType, using dependencies: Dependencies) -> SignalAttachment { - return newAttachment( - dataSource: dataSource, - type: type, - validTypes: UTType.supportedAudioTypes, - maxFileSize: SNUtilitiesKit.maxFileSize, - using: dependencies - ) - } - - // MARK: Generic Attachments - - // Factory method for generic attachments. - // - // NOTE: The attachment returned by this method may not be valid. - // Check the attachment's error property. - private class func genericAttachment(dataSource: (any DataSource)?, type: UTType, using dependencies: Dependencies) -> SignalAttachment { - return newAttachment( - dataSource: dataSource, - type: type, - validTypes: nil, - maxFileSize: SNUtilitiesKit.maxFileSize, - using: dependencies - ) - } - - // MARK: Voice Messages - - public class func voiceMessageAttachment(dataSource: (any DataSource)?, type: UTType, using dependencies: Dependencies) -> SignalAttachment { - let attachment = audioAttachment(dataSource: dataSource, type: type, using: dependencies) - attachment.isVoiceMessage = true - return attachment - } - - // MARK: Attachments - - // Factory method for non-image Attachments. - // - // NOTE: The attachment returned by this method may not be valid. - // Check the attachment's error property. - public class func attachment(dataSource: (any DataSource)?, type: UTType, using dependencies: Dependencies) -> SignalAttachment { - return attachment(dataSource: dataSource, type: type, imageQuality: .original, using: dependencies) - } - - // Factory method for attachments of any kind. - // - // NOTE: The attachment returned by this method may not be valid. - // Check the attachment's error property. - public class func attachment(dataSource: (any DataSource)?, type: UTType, imageQuality: TSImageQuality, using dependencies: Dependencies) -> SignalAttachment { - if UTType.supportedInputImageTypes.contains(type) { - return imageAttachment(dataSource: dataSource, type: type, imageQuality: imageQuality, using: dependencies) - } else if UTType.supportedVideoTypes.contains(type) { - return videoAttachment(dataSource: dataSource, type: type, using: dependencies) - } else if UTType.supportedAudioTypes.contains(type) { - return audioAttachment(dataSource: dataSource, type: type, using: dependencies) - } - - return genericAttachment(dataSource: dataSource, type: type, using: dependencies) - } - - public class func empty(using dependencies: Dependencies) -> SignalAttachment { - return SignalAttachment.attachment( - dataSource: DataSourceValue.empty(using: dependencies), - type: .content, - imageQuality: .original, - using: dependencies - ) - } - - // MARK: Helper Methods - - private class func newAttachment( - dataSource: (any DataSource)?, - type: UTType, - validTypes: Set?, - maxFileSize: UInt, - using dependencies: Dependencies - ) -> SignalAttachment { - guard let dataSource = dataSource else { - let attachment = SignalAttachment(dataSource: DataSourceValue.empty(using: dependencies), dataType: type) - attachment.error = .missingData - return attachment - } - - let attachment = SignalAttachment(dataSource: dataSource, dataType: type) - - if let validTypes: Set = validTypes { - guard validTypes.contains(type) else { - attachment.error = .invalidFileFormat - return attachment - } - } - - guard dataSource.dataLength > 0 else { - assert(dataSource.dataLength > 0) - attachment.error = .invalidData - return attachment - } - - guard dataSource.dataLength <= maxFileSize else { - attachment.error = .fileSizeTooLarge - return attachment - } - - // Attachment is valid - return attachment - } - - // MARK: - Equatable - - public static func == (lhs: SignalAttachment, rhs: SignalAttachment) -> Bool { - switch (lhs.dataSource, rhs.dataSource) { - case (let lhsDataSource as DataSourcePath, let rhsDataSource as DataSourcePath): - guard lhsDataSource == rhsDataSource else { return false } - break - - case (let lhsDataSource as DataSourceValue, let rhsDataSource as DataSourceValue): - guard lhsDataSource == rhsDataSource else { return false } - break - - default: return false - } - - return ( - lhs.dataType == rhs.dataType && - lhs.linkPreviewDraft == rhs.linkPreviewDraft && - lhs.isConvertibleToTextMessage == rhs.isConvertibleToTextMessage && - lhs.isConvertibleToContactShare == rhs.isConvertibleToContactShare && - lhs.cachedImage == rhs.cachedImage && - lhs.cachedVideoPreview == rhs.cachedVideoPreview && - lhs.isVoiceMessage == rhs.isVoiceMessage - ) - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift index 0a84310cfd..52cc00622b 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift @@ -3,23 +3,57 @@ // stringlint:disable import Foundation +import SessionUIKit -public enum AttachmentError: LocalizedError { +public enum AttachmentError: Error, CustomStringConvertible { case invalidStartState case noAttachment case notUploaded - case invalidData case encryptionFailed case uploadIsStillPendingDownload - - public var errorDescription: String? { + case uploadFailed + + case missingData + case fileSizeTooLarge + case invalidData + case couldNotParseImage + case couldNotConvertToJpeg + case couldNotConvertToMpeg4 + case couldNotRemoveMetadata + case invalidFileFormat + case couldNotResizeImage + case invalidAttachmentSource + case invalidPath + case writeFailed + + case invalidMediaSource + case invalidDimensions + case invalidImageData + + public var description: String { switch self { case .invalidStartState: return "Cannot upload an attachment in this state." case .noAttachment: return "No such attachment." case .notUploaded: return "Attachment not uploaded." - case .invalidData: return "Invalid attachment data." case .encryptionFailed: return "Couldn't encrypt file." case .uploadIsStillPendingDownload: return "Upload is still pending download." + case .uploadFailed: return "Upload failed." + case .invalidAttachmentSource: return "Invalid attachment source." + case .invalidPath: return "Failed to generate a valid path." + case .writeFailed: return "Failed to write to disk." + + case .invalidMediaSource: return "Invalid media source." + case .invalidDimensions: return "Invalid dimensions." + + case .fileSizeTooLarge: return "attachmentsErrorSize".localized() + case .invalidData, .missingData, .invalidFileFormat, .invalidImageData: + return "attachmentsErrorNotSupported".localized() + + case .couldNotConvertToJpeg, .couldNotParseImage, .couldNotConvertToMpeg4, .couldNotResizeImage: + return "attachmentsErrorOpen".localized() + + case .couldNotRemoveMetadata: + return "attachmentsImageErrorMetadata".localized() } } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index a0129669ac..39aaf0d5eb 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -3,249 +3,231 @@ import Foundation import Combine import GRDB +import SessionUIKit import SessionUtilitiesKit import SessionNetworkingKit extension MessageSender { private typealias PreparedGroupData = ( groupSessionId: SessionId, + identityKeyPair: KeyPair, groupState: [ConfigDump.Variant: LibSession.Config], thread: SessionThread, group: ClosedGroup, - members: [GroupMember], - preparedNotificationsSubscription: Network.PreparedRequest? + members: [GroupMember] ) public static func createGroup( name: String, description: String?, - displayPictureData: Data?, + displayPicture: ImageDataManager.DataSource?, members: [(String, Profile?)], using dependencies: Dependencies - ) -> AnyPublisher { + ) async throws -> SessionThread { let userSessionId: SessionId = dependencies[cache: .general].sessionId let sortedOtherMembers: [(String, Profile?)] = members .filter { id, _ in id != userSessionId.hexString } .sortedById(userSessionId: userSessionId) + var displayPictureInfo: DisplayPictureManager.UploadResult? - return Just(()) - .setFailureType(to: Error.self) - .flatMap { _ -> AnyPublisher in - guard let displayPictureData: Data = displayPictureData else { - return Just(nil) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - return dependencies[singleton: .displayPictureManager] - .prepareAndUploadDisplayPicture(imageData: displayPictureData, compression: true) - .mapError { error -> Error in error } - .map { Optional($0) } - .eraseToAnyPublisher() - } - .flatMap { (displayPictureInfo: DisplayPictureManager.UploadResult?) -> AnyPublisher in - dependencies[singleton: .storage].writePublisher { db -> PreparedGroupData in - /// Create and cache the libSession entries - let createdInfo: LibSession.CreatedGroupInfo = try LibSession.createGroup( - db, - name: name, - description: description, - displayPictureUrl: displayPictureInfo?.downloadUrl, - displayPictureEncryptionKey: displayPictureInfo?.encryptionKey, - members: members, - using: dependencies + if let source: ImageDataManager.DataSource = displayPicture { + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .media(source), + using: dependencies + ) + let preparedAttachment: PreparedAttachment = try dependencies[singleton: .displayPictureManager] + .prepareDisplayPicture(attachment: pendingAttachment) + displayPictureInfo = try await dependencies[singleton: .displayPictureManager] + .uploadDisplayPicture(attachment: preparedAttachment) + } + + let preparedGroupData: PreparedGroupData = try await dependencies[singleton: .storage].writeAsync { db in + /// Create and cache the libSession entries + let createdInfo: LibSession.CreatedGroupInfo = try LibSession.createGroup( + db, + name: name, + description: description, + displayPictureUrl: displayPictureInfo?.downloadUrl, + displayPictureEncryptionKey: displayPictureInfo?.encryptionKey, + members: members, + using: dependencies + ) + + /// Save the relevant objects to the database + let thread: SessionThread = try SessionThread.upsert( + db, + id: createdInfo.group.id, + variant: .group, + values: SessionThread.TargetValues( + creationDateTimestamp: .setTo(createdInfo.group.formationTimestamp), + shouldBeVisible: .setTo(true) + ), + using: dependencies + ) + try createdInfo.group.insert(db) + try createdInfo.members.forEach { try $0.insert(db) } + + /// Add a record of the initial invites going out (default to being read as we don't want the creator of the group + /// to see the "Unread Messages" banner above this control message) + _ = try? Interaction( + threadId: createdInfo.group.id, + threadVariant: .group, + authorId: userSessionId.hexString, + variant: .infoGroupMembersUpdated, + body: ClosedGroup.MessageInfo + .addedUsers( + hasCurrentUser: false, + names: sortedOtherMembers.map { id, profile in + profile?.displayName(for: .group) ?? + id.truncated() + }, + historyShared: false ) - - /// Save the relevant objects to the database - let thread: SessionThread = try SessionThread.upsert( - db, - id: createdInfo.group.id, - variant: .group, - values: SessionThread.TargetValues( - creationDateTimestamp: .setTo(createdInfo.group.formationTimestamp), - shouldBeVisible: .setTo(true) + .infoString(using: dependencies), + timestampMs: Int64(createdInfo.group.formationTimestamp * 1000), + wasRead: true, + using: dependencies + ).inserted(db) + + /// Schedule the "members added" control message to be sent after the config sync completes + try dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .messageSend, + behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, + threadId: createdInfo.group.id, + details: MessageSendJob.Details( + destination: .closedGroup(groupPublicKey: createdInfo.group.id), + message: GroupUpdateMemberChangeMessage( + changeType: .added, + memberSessionIds: sortedOtherMembers.map { id, _ in id }, + historyShared: false, + sentTimestampMs: UInt64(createdInfo.group.formationTimestamp * 1000), + authMethod: Authentication.groupAdmin( + groupSessionId: createdInfo.groupSessionId, + ed25519SecretKey: createdInfo.identityKeyPair.secretKey + ), + using: dependencies ), - using: dependencies + requiredConfigSyncVariant: .groupMembers ) - try createdInfo.group.insert(db) - try createdInfo.members.forEach { try $0.insert(db) } - - /// Add a record of the initial invites going out (default to being read as we don't want the creator of the group - /// to see the "Unread Messages" banner above this control message) - _ = try? Interaction( - threadId: createdInfo.group.id, - threadVariant: .group, - authorId: userSessionId.hexString, - variant: .infoGroupMembersUpdated, - body: ClosedGroup.MessageInfo - .addedUsers( - hasCurrentUser: false, - names: sortedOtherMembers.map { id, profile in - profile?.displayName(for: .group) ?? - id.truncated() - }, - historyShared: false + ), + canStartJob: false + ) + + return ( + createdInfo.groupSessionId, + createdInfo.identityKeyPair, + createdInfo.groupState, + thread, + createdInfo.group, + createdInfo.members + ) + } + + do { + // TODO: Refactor to async/await when supported + try await ConfigurationSyncJob.run( + swarmPublicKey: preparedGroupData.groupSessionId.hexString, + requireAllRequestsSucceed: true, + using: dependencies + ).values.first { _ in true } + } + catch { + /// Remove the config and database states + try await dependencies[singleton: .storage].writeAsync { db in + LibSession.removeGroupStateIfNeeded( + db, + groupSessionId: preparedGroupData.groupSessionId, + using: dependencies + ) + + _ = try? preparedGroupData.thread.delete(db) + _ = try? preparedGroupData.group.delete(db) + try? preparedGroupData.members.forEach { try $0.delete(db) } + _ = try? Job + .filter(Job.Columns.threadId == preparedGroupData.group.id) + .deleteAll(db) + } + throw error + } + + /// Save the successfully created group and add to the user config/ + try await dependencies[singleton: .storage].writeAsync { db in + try LibSession.saveCreatedGroup( + db, + group: preparedGroupData.group, + groupState: preparedGroupData.groupState, + using: dependencies + ) + } + + /// Start polling + dependencies + .mutate(cache: .groupPollers) { $0.getOrCreatePoller(for: preparedGroupData.thread.id) } + .startIfNeeded() + + /// Subscribe for push notifications (if PNs are enabled) + if let token: String = dependencies[defaults: .standard, key: .deviceToken] { + let request = try? Network.PushNotification + .preparedSubscribe( + token: Data(hex: token), + swarms: [( + preparedGroupData.groupSessionId, + Authentication.groupAdmin( + groupSessionId: preparedGroupData.groupSessionId, + ed25519SecretKey: preparedGroupData.identityKeyPair.secretKey + ) + )], + using: dependencies + ) + request + .send(using: dependencies) + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) + .sinkUntilComplete() + } + + /// Save jobs for sending group member invitations + try? await dependencies[singleton: .storage].writeAsync { db in + preparedGroupData.members + .filter { $0.profileId != userSessionId.hexString } + .compactMap { member -> (GroupMember, GroupInviteMemberJob.Details)? in + /// Generate authData for the removed member + guard + let memberAuthInfo: Authentication.Info = try? dependencies.mutate(cache: .libSession, { cache in + try dependencies[singleton: .crypto].tryGenerate( + .memberAuthData( + config: cache.config( + for: .groupKeys, + sessionId: preparedGroupData.groupSessionId + ), + groupSessionId: preparedGroupData.groupSessionId, + memberId: member.profileId + ) ) - .infoString(using: dependencies), - timestampMs: Int64(createdInfo.group.formationTimestamp * 1000), - wasRead: true, - using: dependencies - ).inserted(db) + }), + let jobDetails: GroupInviteMemberJob.Details = try? GroupInviteMemberJob.Details( + memberSessionIdHexString: member.profileId, + authInfo: memberAuthInfo + ) + else { return nil } - /// Schedule the "members added" control message to be sent after the config sync completes - try dependencies[singleton: .jobRunner].add( + return (member, jobDetails) + } + .forEach { member, jobDetails in + dependencies[singleton: .jobRunner].add( db, job: Job( - variant: .messageSend, - behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, - threadId: createdInfo.group.id, - details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: createdInfo.group.id), - message: GroupUpdateMemberChangeMessage( - changeType: .added, - memberSessionIds: sortedOtherMembers.map { id, _ in id }, - historyShared: false, - sentTimestampMs: UInt64(createdInfo.group.formationTimestamp * 1000), - authMethod: Authentication.groupAdmin( - groupSessionId: createdInfo.groupSessionId, - ed25519SecretKey: createdInfo.identityKeyPair.secretKey - ), - using: dependencies - ), - requiredConfigSyncVariant: .groupMembers - ) + variant: .groupInviteMember, + threadId: preparedGroupData.thread.id, + details: jobDetails ), - canStartJob: false - ) - - // Prepare the notification subscription - var preparedNotificationSubscription: Network.PreparedRequest? - - if let token: String = dependencies[defaults: .standard, key: .deviceToken] { - preparedNotificationSubscription = try? Network.PushNotification - .preparedSubscribe( - token: Data(hex: token), - swarms: [( - createdInfo.groupSessionId, - Authentication.groupAdmin( - groupSessionId: createdInfo.groupSessionId, - ed25519SecretKey: createdInfo.identityKeyPair.secretKey - ) - )], - using: dependencies - ) - } - - return ( - createdInfo.groupSessionId, - createdInfo.groupState, - thread, - createdInfo.group, - createdInfo.members, - preparedNotificationSubscription + canStartJob: true ) } - } - .flatMap { preparedGroupData -> AnyPublisher in - ConfigurationSyncJob - .run( - swarmPublicKey: preparedGroupData.groupSessionId.hexString, - requireAllRequestsSucceed: true, - using: dependencies - ) - .flatMap { _ in - dependencies[singleton: .storage].writePublisher { db in - // Save the successfully created group and add to the user config - try LibSession.saveCreatedGroup( - db, - group: preparedGroupData.group, - groupState: preparedGroupData.groupState, - using: dependencies - ) - - return preparedGroupData - } - } - .handleEvents( - receiveCompletion: { result in - switch result { - case .finished: break - case .failure: - // Remove the config and database states - dependencies[singleton: .storage].writeAsync { db in - LibSession.removeGroupStateIfNeeded( - db, - groupSessionId: preparedGroupData.groupSessionId, - using: dependencies - ) - - _ = try? preparedGroupData.thread.delete(db) - _ = try? preparedGroupData.group.delete(db) - try? preparedGroupData.members.forEach { try $0.delete(db) } - _ = try? Job - .filter(Job.Columns.threadId == preparedGroupData.group.id) - .deleteAll(db) - } - } - } - ) - .eraseToAnyPublisher() - } - .handleEvents( - receiveOutput: { groupSessionId, _, thread, group, groupMembers, preparedNotificationSubscription in - let userSessionId: SessionId = dependencies[cache: .general].sessionId - - // Start polling - dependencies - .mutate(cache: .groupPollers) { $0.getOrCreatePoller(for: thread.id) } - .startIfNeeded() - - // Subscribe for push notifications (if PNs are enabled) - preparedNotificationSubscription? - .send(using: dependencies) - .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies) - .sinkUntilComplete() - - dependencies[singleton: .storage].writeAsync { db in - // Save jobs for sending group member invitations - groupMembers - .filter { $0.profileId != userSessionId.hexString } - .compactMap { member -> (GroupMember, GroupInviteMemberJob.Details)? in - // Generate authData for the removed member - guard - let memberAuthInfo: Authentication.Info = try? dependencies.mutate(cache: .libSession, { cache in - try dependencies[singleton: .crypto].tryGenerate( - .memberAuthData( - config: cache.config(for: .groupKeys, sessionId: groupSessionId), - groupSessionId: groupSessionId, - memberId: member.profileId - ) - ) - }), - let jobDetails: GroupInviteMemberJob.Details = try? GroupInviteMemberJob.Details( - memberSessionIdHexString: member.profileId, - authInfo: memberAuthInfo - ) - else { return nil } - - return (member, jobDetails) - } - .forEach { member, jobDetails in - dependencies[singleton: .jobRunner].add( - db, - job: Job( - variant: .groupInviteMember, - threadId: thread.id, - details: jobDetails - ), - canStartJob: true - ) - } - } - } - ) - .map { _, _, thread, _, _, _ in thread } - .eraseToAnyPublisher() + } + + return preparedGroupData.thread } public static func updateGroup( @@ -357,102 +339,100 @@ extension MessageSender { groupSessionId: String, displayPictureUpdate: DisplayPictureManager.Update, using dependencies: Dependencies - ) -> AnyPublisher { + ) async throws { guard let sessionId: SessionId = try? SessionId(from: groupSessionId), sessionId.prefix == .group else { - return Fail(error: MessageSenderError.invalidClosedGroupUpdate).eraseToAnyPublisher() + throw MessageSenderError.invalidClosedGroupUpdate } - return dependencies[singleton: .storage] - .writePublisher { db in - guard - let groupIdentityPrivateKey: Data = try? ClosedGroup - .filter(id: sessionId.hexString) - .select(.groupIdentityPrivateKey) - .asRequest(of: Data.self) - .fetchOne(db) - else { throw MessageSenderError.invalidClosedGroupUpdate } - - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let changeTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - - /// Perform the config changes without triggering a config sync (we will trigger one manually as part of the process) - try dependencies.mutate(cache: .libSession) { cache in - try cache.withCustomBehaviour(.skipAutomaticConfigSync, for: sessionId) { - switch displayPictureUpdate { - case .groupRemove: - try ClosedGroup - .filter(id: groupSessionId) - .updateAllAndConfig( - db, - ClosedGroup.Columns.displayPictureUrl.set(to: nil), - ClosedGroup.Columns.displayPictureEncryptionKey.set(to: nil), - using: dependencies - ) - - case .groupUpdateTo(let url, let key, let fileName): - try ClosedGroup - .filter(id: groupSessionId) - .updateAllAndConfig( - db, - ClosedGroup.Columns.displayPictureUrl.set(to: url), - ClosedGroup.Columns.displayPictureEncryptionKey.set(to: key), - using: dependencies - ) - - default: throw MessageSenderError.invalidClosedGroupUpdate - } + try await dependencies[singleton: .storage].writeAsync { db in + guard + let groupIdentityPrivateKey: Data = try? ClosedGroup + .filter(id: sessionId.hexString) + .select(.groupIdentityPrivateKey) + .asRequest(of: Data.self) + .fetchOne(db) + else { throw MessageSenderError.invalidClosedGroupUpdate } + + let userSessionId: SessionId = dependencies[cache: .general].sessionId + let changeTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + + /// Perform the config changes without triggering a config sync (we will trigger one manually as part of the process) + try dependencies.mutate(cache: .libSession) { cache in + try cache.withCustomBehaviour(.skipAutomaticConfigSync, for: sessionId) { + switch displayPictureUpdate { + case .groupRemove: + try ClosedGroup + .filter(id: groupSessionId) + .updateAllAndConfig( + db, + ClosedGroup.Columns.displayPictureUrl.set(to: nil), + ClosedGroup.Columns.displayPictureEncryptionKey.set(to: nil), + using: dependencies + ) + + case .groupUpdateTo(let url, let key): + try ClosedGroup + .filter(id: groupSessionId) + .updateAllAndConfig( + db, + ClosedGroup.Columns.displayPictureUrl.set(to: url), + ClosedGroup.Columns.displayPictureEncryptionKey.set(to: key), + using: dependencies + ) + + default: throw MessageSenderError.invalidClosedGroupUpdate } } - - let disappearingConfig: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration.fetchOne(db, id: sessionId.hexString) - - /// Add a record of the change to the conversation - _ = try Interaction( - threadId: groupSessionId, - threadVariant: .group, - authorId: userSessionId.hexString, - variant: .infoGroupInfoUpdated, - body: ClosedGroup.MessageInfo - .updatedDisplayPicture - .infoString(using: dependencies), - timestampMs: changeTimestampMs, - expiresInSeconds: disappearingConfig?.expiresInSeconds(), - expiresStartedAtMs: disappearingConfig?.initialExpiresStartedAtMs( - sentTimestampMs: Double(changeTimestampMs) - ), - using: dependencies - ).inserted(db) - - /// Schedule the control message to be sent to the group after the config sync completes - try dependencies[singleton: .jobRunner].add( - db, - job: Job( - variant: .messageSend, - behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, - threadId: sessionId.hexString, - details: MessageSendJob.Details( - destination: .closedGroup(groupPublicKey: sessionId.hexString), - message: GroupUpdateInfoChangeMessage( - changeType: .avatar, - sentTimestampMs: UInt64(changeTimestampMs), - authMethod: Authentication.groupAdmin( - groupSessionId: sessionId, - ed25519SecretKey: Array(groupIdentityPrivateKey) - ), - using: dependencies - ).with(disappearingConfig), - requiredConfigSyncVariant: .groupInfo - ) - ), - canStartJob: false - ) } - .flatMap { _ -> AnyPublisher in - ConfigurationSyncJob - .run(swarmPublicKey: groupSessionId, using: dependencies) - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() + + let disappearingConfig: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration.fetchOne(db, id: sessionId.hexString) + + /// Add a record of the change to the conversation + _ = try Interaction( + threadId: groupSessionId, + threadVariant: .group, + authorId: userSessionId.hexString, + variant: .infoGroupInfoUpdated, + body: ClosedGroup.MessageInfo + .updatedDisplayPicture + .infoString(using: dependencies), + timestampMs: changeTimestampMs, + expiresInSeconds: disappearingConfig?.expiresInSeconds(), + expiresStartedAtMs: disappearingConfig?.initialExpiresStartedAtMs( + sentTimestampMs: Double(changeTimestampMs) + ), + using: dependencies + ).inserted(db) + + /// Schedule the control message to be sent to the group after the config sync completes + try dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .messageSend, + behaviour: .runOnceAfterConfigSyncIgnoringPermanentFailure, + threadId: sessionId.hexString, + details: MessageSendJob.Details( + destination: .closedGroup(groupPublicKey: sessionId.hexString), + message: GroupUpdateInfoChangeMessage( + changeType: .avatar, + sentTimestampMs: UInt64(changeTimestampMs), + authMethod: Authentication.groupAdmin( + groupSessionId: sessionId, + ed25519SecretKey: Array(groupIdentityPrivateKey) + ), + using: dependencies + ).with(disappearingConfig), + requiredConfigSyncVariant: .groupInfo + ) + ), + canStartJob: false + ) + } + + _ = try await ConfigurationSyncJob + .run(swarmPublicKey: groupSessionId, using: dependencies) + .values + .first { _ in true } } public static func updateGroup( diff --git a/SessionMessagingKit/Utilities/AsyncAccessible.swift b/SessionMessagingKit/Utilities/AsyncAccessible.swift deleted file mode 100644 index 18370d0e3f..0000000000 --- a/SessionMessagingKit/Utilities/AsyncAccessible.swift +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -private let unsafeSyncQueue: DispatchQueue = DispatchQueue(label: "com.session.unsafeSyncQueue") - -public protocol AsyncAccessible {} - -public extension AsyncAccessible { - - /// This function blocks the current thread and waits for the result of the closure, use async/await functionality directly where possible - /// as this approach could result in deadlocks - nonisolated func unsafeSync(_ closure: @escaping (Self) async -> T) -> T { - let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) - var result: T! /// Intentionally implicitly unwrapped as we will wait undefinitely for it to return otherwise - - /// Run the task on a specific queue, not the global pool to try to force any unsafe execution to run serially - unsafeSyncQueue.async { [self] in - Task { [self] in - result = await closure(self) - semaphore.signal() - } - } - semaphore.wait() - - return result - } -} diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index 394cb1fef0..ed23c6ff36 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -6,6 +6,7 @@ import AVFoundation import Combine import UniformTypeIdentifiers import GRDB +import SDWebImageWebPCoder import SessionUIKit import SessionNetworkingKit import SessionUtilitiesKit @@ -28,6 +29,8 @@ public extension Log.Category { // MARK: - AttachmentManager public final class AttachmentManager: Sendable, ThumbnailManager { + public static let maxAttachmentsAllowed: Int = 32 + private let dependencies: Dependencies // MARK: - Initalization @@ -72,12 +75,10 @@ public final class AttachmentManager: Sendable, ThumbnailManager { .path } - public func uploadPathAndUrl(for id: String) throws -> (url: String, path: String) { - let fakeLocalUrlPath: String = URL(fileURLWithPath: placeholderUrlPath()) - .appendingPathComponent(URL(fileURLWithPath: id).path) + public func pendingUploadFilePath(for id: String) throws -> String { + return URL(fileURLWithPath: placeholderUrlPath()) + .appendingPathComponent(id) .path - - return (fakeLocalUrlPath, try path(for: fakeLocalUrlPath)) } public func isPlaceholderUploadUrl(_ url: String?) -> Bool { @@ -189,64 +190,712 @@ public final class AttachmentManager: Sendable, ThumbnailManager { ) -> (isValid: Bool, duration: TimeInterval?) { guard let path: String = try? path(for: downloadUrl) else { return (false, nil) } + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .file(URL(fileURLWithPath: path)), + utType: UTType(sessionMimeType: contentType), + sourceFilename: sourceFilename, + using: dependencies + ) + // Process audio attachments - if UTType.isAudio(contentType) { - do { - let audioPlayer: AVAudioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path)) - - return ((audioPlayer.duration > 0), audioPlayer.duration) - } - catch { - switch (error as NSError).code { - case Int(kAudioFileInvalidFileError), Int(kAudioFileStreamError_InvalidFile): - // Ignore "invalid audio file" errors - return (false, nil) - - default: return (false, nil) - } - } + if pendingAttachment.utType.isAudio { + return (pendingAttachment.duration > 0, pendingAttachment.duration) } // Process image attachments - if UTType.isImage(contentType) || UTType.isAnimated(contentType) { - return ( - MediaUtils.isValidImage(at: path, type: UTType(sessionMimeType: contentType), using: dependencies), - nil - ) + if pendingAttachment.utType.isImage || pendingAttachment.utType.isAnimated { + return (pendingAttachment.isValidVisualMedia, nil) } // Process video attachments - if UTType.isVideo(contentType) { - let assetInfo: (asset: AVURLAsset, cleanup: () -> Void)? = AVURLAsset.asset( - for: path, - mimeType: contentType, - sourceFilename: sourceFilename, - using: dependencies + if pendingAttachment.utType.isVideo { + return (pendingAttachment.isValidVisualMedia, pendingAttachment.duration) + } + + // Any other attachment types are valid and have no duration + return (true, nil) + } +} + +// MARK: - PendingAttachment + +public struct PendingAttachment: Sendable, Equatable, Hashable { + public let source: DataSource + public let sourceFilename: String? + public let metadata: Metadata? + + public var utType: UTType { metadata?.utType ?? .invalid } + public var fileSize: UInt64 { metadata?.fileSize ?? 0 } + public var duration: TimeInterval { + switch metadata { + case .media(let mediaMetadata): return mediaMetadata.duration + case .file, .none: return 0 + } + } + + // MARK: Initialization + + public init( + source: DataSource, + utType: UTType? = nil, + sourceFilename: String? = nil, + using dependencies: Dependencies + ) { + self.source = source + self.sourceFilename = sourceFilename + self.metadata = PendingAttachment.metadata( + for: source, + utType: utType, + sourceFilename: sourceFilename, + using: dependencies + ) + } + + public init( + attachment: Attachment, + using dependencies: Dependencies + ) throws { + let filePath: String = try dependencies[singleton: .attachmentManager] + .path(for: attachment.downloadUrl) + let source: DataSource + + switch attachment.variant { + case .standard: source = .file(URL(fileURLWithPath: filePath)) + case .voiceMessage: source = .voiceMessage(URL(fileURLWithPath: filePath)) + } + + self.source = source + self.sourceFilename = attachment.sourceFilename + self.metadata = PendingAttachment.metadata( + for: source, + utType: UTType(attachment.contentType), + sourceFilename: attachment.sourceFilename, + using: dependencies + ) + } + + // MARK: - Internal Functions + + private static func metadata( + for dataSource: DataSource, + utType: UTType?, + sourceFilename: String?, + using dependencies: Dependencies + ) -> Metadata? { + let maybeFileSize: UInt64? = dataSource.fileSize(using: dependencies) + + switch (dataSource, dataSource.visualMediaSource) { + case (.file(let url), _), (.voiceMessage(let url), _): + guard + let utType: UTType = utType, + let fileSize: UInt64 = maybeFileSize + else { return nil } + + /// If the url is actually media then try to load `MediaMetadata`, falling back to the `FileMetadata` + guard + let metadata: MediaUtils.MediaMetadata = MediaUtils.MediaMetadata( + from: url.absoluteString, + utType: utType, + sourceFilename: sourceFilename, + using: dependencies + ) + else { return .file(FileMetadata(utType: utType, fileSize: fileSize)) } + + return .media(metadata) + + case (_, .image(_, .some(let image))): + guard let metadata: MediaUtils.MediaMetadata = MediaUtils.MediaMetadata(image: image) else { + return nil + } + + return .media(metadata) + + case (.displayPicture(let mediaSource), _), (.media(let mediaSource), _): + guard + let fileSize: UInt64 = maybeFileSize, + let source: CGImageSource = mediaSource.createImageSource(), + let metadata: MediaUtils.MediaMetadata = MediaUtils.MediaMetadata( + source: source, + fileSize: fileSize + ) + else { return nil } + + return .media(metadata) + + case (.text, _): + guard + let utType: UTType = utType, + let fileSize: UInt64 = maybeFileSize + else { return nil } + + return .file(FileMetadata(utType: utType, fileSize: fileSize)) + } + } +} + +// MARK: - PendingAttachment.DataSource + +public extension PendingAttachment { + enum DataSource: Sendable, Equatable, Hashable { + case displayPicture(ImageDataManager.DataSource) + case media(ImageDataManager.DataSource) + case file(URL) + case voiceMessage(URL) + case text(String) + + // MARK: - Convenience + + public static func media(_ url: URL) -> DataSource { + return .media(.url(url)) + } + + public static func media(_ identifier: String, _ data: Data) -> DataSource { + return .media(.data(identifier, data)) + } + + fileprivate var visualMediaSource: ImageDataManager.DataSource? { + switch self { + case .displayPicture(let source), .media(let source): return source + case .file, .voiceMessage, .text: return nil + } + } + + fileprivate var url: URL? { + switch (self, visualMediaSource) { + case (.file(let url), _), (.voiceMessage(let url), _), (_, .url(let url)), + (_, .videoUrl(let url, _, _, _)), (_, .urlThumbnail(let url, _, _)): + return url + + case (_, .none), (_, .data), (_, .image), (_, .placeholderIcon), (_, .asyncSource), (.displayPicture, _), (.media, _), (.text, _): + return nil + } + } + + fileprivate func fileSize(using dependencies: Dependencies) -> UInt64? { + switch (self, visualMediaSource) { + case (.file(let url), _), (.voiceMessage(let url), _), (_, .url(let url)): + guard let path: String = try? path(for: url, using: dependencies) else { return nil } + + return dependencies[singleton: .fileManager].fileSize(of: path) + + case (_, .data(_, let data)): return UInt64(data.count) + case (.text(let content), _): + return (content.data(using: .ascii)?.count).map { UInt64($0) } + + default: return nil + } + } + + private func path(for url: URL, using dependencies: Dependencies) throws -> String { + switch self { + case .displayPicture: + return try dependencies[singleton: .displayPictureManager].path(for: url.absoluteString) + + default: + return try dependencies[singleton: .attachmentManager].path(for: url.absoluteString) + } + } + } +} + +// MARK: - PendingAttachment.Metadata + +public extension PendingAttachment { + enum Metadata: Sendable, Equatable, Hashable { + case media(MediaUtils.MediaMetadata) + case file(FileMetadata) + + var utType: UTType { + switch self { + case .media(let metadata): return (metadata.utType ?? .invalid) + case .file(let metadata): return metadata.utType + } + } + + public var fileSize: UInt64 { + switch self { + case .media(let metadata): return metadata.fileSize + case .file(let metadata): return metadata.fileSize + } + } + + public var pixelSize: CGSize? { + switch self { + case .media(let metadata): return metadata.pixelSize + case .file: return nil + } + } + } + + struct FileMetadata: Sendable, Equatable, Hashable { + public let utType: UTType + public let fileSize: UInt64 + + init(utType: UTType, fileSize: UInt64) { + self.utType = utType + self.fileSize = fileSize + } + } +} + +// MARK: - PreparedAttachment + +public struct PreparedAttachment: Sendable, Equatable, Hashable { + public let attachment: Attachment + public let temporaryFilePath: String + + public init( + attachment: Attachment, + temporaryFilePath: String + ) { + self.attachment = attachment + self.temporaryFilePath = temporaryFilePath + } +} + +// MARK: - Conversion + +public extension PendingAttachment { + enum Transform: Sendable, Equatable, Hashable { + case compress + case convertToStandardFormats + case resize(maxDimension: CGFloat) + case stripImageMetadata + case encrypt(legacy: Bool) + + fileprivate enum Erased: Equatable { + case compress + case convertToStandardFormats + case resize + case stripImageMetadata + case encrypt + } + + fileprivate var erased: Erased { + switch self { + case .compress: return .compress + case .convertToStandardFormats: return .convertToStandardFormats + case .resize: return .resize + case .stripImageMetadata: return .stripImageMetadata + case .encrypt: return .encrypt + } + } + } + + func toText() -> String? { + /// Just to be safe ensure the file size isn't crazy large - since we have a character limit of 2,000 - 10,000 characters + /// (which is ~40Kb) a 100Kb limit should be sufficiend + guard (metadata?.fileSize ?? 0) < (1024 * 100) else { return nil } + + switch (source, source.visualMediaSource) { + case (.text(let text), _): return text + case (.file(let fileUrl), _): return try? String(contentsOf: fileUrl, encoding: .utf8) + case (_, .data(_, let data)): return String(data: data, encoding: .utf8) + case (.displayPicture, _), (.media, _), (.voiceMessage, _): return nil + } + } + + func compressAsMp4Video(using dependencies: Dependencies) async throws -> PendingAttachment { + guard + case .media(let mediaSource) = source, + case .url(let url) = mediaSource, + let exportSession: AVAssetExportSession = AVAssetExportSession( + asset: AVAsset(url: url), + presetName: AVAssetExportPresetMediumQuality ) + else { throw AttachmentError.invalidData } + + let exportPath: String = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: "mp4") + let exportUrl: URL = URL(fileURLWithPath: exportPath) + exportSession.shouldOptimizeForNetworkUse = true + exportSession.outputFileType = AVFileType.mp4 + exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() + exportSession.outputURL = exportUrl + + return await withCheckedContinuation { continuation in + exportSession.exportAsynchronously { + continuation.resume( + returning: PendingAttachment( + source: .media( + .videoUrl( + exportUrl, + .mpeg4Movie, + sourceFilename, + dependencies[singleton: .attachmentManager] + ) + ), + utType: .mpeg4Movie, + sourceFilename: sourceFilename, + using: dependencies + ) + ) + } + } + } + + // MARK: - Encryption and Preparation + + func needsPreparationForAttachmentUpload(transformations: Set) throws -> Bool { + switch source { + case .file: return try fileNeedsPreparation(transformations) + case .voiceMessage, .displayPicture, .media: return try mediaNeedsPreparation(transformations) + case .text: return true /// Need to write to a file in order to upload as an attachment + } + } + + private func fileNeedsPreparation(_ transformations: Set) throws -> Bool { + /// Check the type of `metadata` we have (as if the `file` was actually media then the `metadata` will be `media` + /// and as such we want to go down the `mediaNeedsPreparation` path) + switch self.metadata { + case .none: throw AttachmentError.invalidData + case .media: return try mediaNeedsPreparation(transformations) + case .file: break + } + + for transformation in transformations { + switch transformation { + case .encrypt: return true + case .compress, .convertToStandardFormats, .resize, .stripImageMetadata: + continue /// None of these are supported for general files + } + } + + /// None of the requested `transformations` were needed so the file doesn't need preparation + return false + } + + private func mediaNeedsPreparation(_ transformations: Set) throws -> Bool { + guard case .media(let mediaMatadata) = self.metadata else { + throw AttachmentError.invalidMediaSource + } + guard mediaMatadata.hasValidPixelSize else { throw AttachmentError.invalidDimensions } + + for transformation in transformations { + switch transformation { + case .encrypt: return true + case .compress: + /// We don't currently want to compress animated images + guard mediaMatadata.frameCount == 1 else { continue } + + return true /// Otherwise if we've been told to expliclty compress then we should do so + + case .convertToStandardFormats: + /// We don't currently want to convert animated images + guard mediaMatadata.frameCount == 1 else { continue } + + switch mediaMatadata.utType { + case .png: return true /// Want to convert to a `WebP` + default: continue + } + + case .resize(let maxDimension): + /// We don't currently want to resize animated images + guard mediaMatadata.frameCount == 1 else { continue } + + let maxImageDimension: CGFloat = max( + mediaMatadata.pixelSize.width, + mediaMatadata.pixelSize.height + ) + + if maxImageDimension > maxDimension { + return true + } + continue + + case .stripImageMetadata: + /// We don't currently strip metadata from animated images + guard mediaMatadata.frameCount == 1 else { continue } + + return mediaMatadata.hasUnsafeMetadata + } + } + + /// None of the requested `transformations` were needed so the file doesn't need preparation + return false + } + + func prepare(transformations: Set, using dependencies: Dependencies) throws -> PreparedAttachment { + let preparedData: Data + + switch source { + case .displayPicture: preparedData = try prepareImage(transformations) + case .media where utType.isImage || utType.isAnimated: + preparedData = try prepareImage(transformations) - guard - let asset: AVURLAsset = assetInfo?.asset, - MediaUtils.isVideoOfValidContentTypeAndSize( - path: path, - type: contentType, + // TODO: Custom video processing??? + case .media where utType.isVideo: fatalError() // TODO: Load and encrypt + + // TODO: Custom audio processing??? + case .voiceMessage: fatalError() // TODO: Load and encrypt + case .media where utType.isAudio: fatalError() // TODO: Load and encrypt + case .file, .media, .voiceMessage: fatalError() // TODO: Load and encrypt + case .text: fatalError() // TODO: Encode to file as ASCII? + } + + /// Generate the temporary path to use while the upload is pending + /// + /// **Note:** This is stored alongside other attachments rather that in the temporary directory because the + /// `AttachmentUploadJob` can exist between launches, but the temporary directory gets cleared on every launch) + let attachmentId: String = UUID().uuidString + let pendingUploadFilePath: String = try dependencies[singleton: .attachmentManager].pendingUploadFilePath(for: attachmentId) + + /// If we don't have the `encrypt` transform then we can just return the `preparedData` (which is unencrypted but should + /// have all other `Transform` changes applied + // FIXME: We should store attachments encrypted and decrypt them when we want to render/open them + guard case .encrypt(let legacyEncryption) = transformations.first(where: { $0.erased == .encrypt }) else { + let filePath: String = try dependencies[singleton: .fileManager] + .write(dataToTemporaryFile: preparedData) + + return PreparedAttachment( + attachment: try prepareAttachment( + id: attachmentId, + downloadUrl: pendingUploadFilePath, + byteCount: UInt(preparedData.count), + encryptionKey: nil, + digest: nil, using: dependencies ), - MediaUtils.isValidVideo(asset: asset) - else { - assetInfo?.cleanup() - return (false, nil) + temporaryFilePath: filePath + ) + } + + /// Encrypt the data using either the legacy or updated encryption + typealias EncryptionData = (ciphertext: Data, encryptionKey: Data, digest: Data) + let encryptedData: EncryptionData + + if legacyEncryption { + // TODO: For legacy encryption do we need to validate the file size here or can we do it earlier??? + + encryptedData = try dependencies[singleton: .crypto].tryGenerate( + .legacyEncryptAttachment(plaintext: preparedData) + ) + } + else { + // TODO: This + fatalError() + } + + let filePath: String = try dependencies[singleton: .fileManager] + .write(dataToTemporaryFile: encryptedData.ciphertext) + + return PreparedAttachment( + attachment: try prepareAttachment( + id: attachmentId, + downloadUrl: pendingUploadFilePath, + byteCount: UInt(preparedData.count), + encryptionKey: encryptedData.encryptionKey, + digest: encryptedData.digest, + using: dependencies + ), + temporaryFilePath: filePath + ) + } + + private func prepareImage(_ transformations: Set) throws -> Data { + guard + let targetSource: ImageDataManager.DataSource = visualMediaSource, + case .media(let mediaMatadata) = self.metadata + else { throw AttachmentError.invalidMediaSource } + + guard mediaMatadata.hasValidPixelSize else { + Log.error(.attachmentManager, "Source has invalid image dimensions.") + throw AttachmentError.invalidDimensions + } + + /// If it's animated then we don't want to do any processing (to performance intensive at this stage, and won't have as big of + /// an impact due to a smaller number of users actually using them) + guard mediaMatadata.frameCount == 1 else { + switch targetSource { + case .url(let url): return try Data(contentsOf: url, options: [.dataReadingMapped]) + case .data(_, let data): return data + + /// None of the other source options support animated images so just fail + default: throw AttachmentError.invalidData + } + } + + /// If we can't load the data into a `UIImage` then we can't process it + var image: UIImage + var needsReencoding: Bool = ( + transformations.contains(.compress) || + transformations.contains(.convertToStandardFormats) + ) + let originalImageData: Data? + + switch targetSource { + case .image(_, let directImage): + image = try directImage ?? { throw AttachmentError.invalidData }() + needsReencoding = true /// In-memory image always needs encoding + originalImageData = nil + + case .url(let url): + guard + let imageData: Data = try? Data(contentsOf: url, options: [.dataReadingMapped]), + let loadedImage = UIImage(data: imageData) + else { throw AttachmentError.invalidImageData } + + image = loadedImage + originalImageData = imageData + + case .data(_, let data): + guard let loadedImage = UIImage(data: data) else { + throw AttachmentError.invalidImageData + } + + image = loadedImage + originalImageData = data + + default: throw AttachmentError.invalidMediaSource + } + + /// If we have the `resize` and the resolution is too large then we need to scale it down + if case .resize(let targetSize) = transformations.first(where: { $0.erased == .resize }) { + let maxImageDimension: CGFloat = max(mediaMatadata.pixelSize.width, mediaMatadata.pixelSize.height) + + if maxImageDimension > targetSize { + Log.debug(.attachmentManager, "Resizing image to fit in max allows dimension.") + image = image.resized(toFillPixelSize: CGSize(width: targetSize, height: targetSize)) + needsReencoding = true /// We've resized the image so need to re-encode it + } + } + + /// If we don't need to re-encode then just check if we want to strip the metadata and return either the original or stripped + /// version of the data + if !needsReencoding { + guard let originalData: Data = originalImageData else { throw AttachmentError.invalidData } + + /// If we don't want to strip the metadata then just return the original data + guard transformations.contains(.stripImageMetadata) else { + return originalData } - let durationSeconds: TimeInterval = ( - // According to the CMTime docs "value/timescale = seconds" - TimeInterval(asset.duration.value) / TimeInterval(asset.duration.timescale) + /// Otherwise clear the metadata and return the updated data + let options: CFDictionary = [ + kCGImageSourceShouldCache: false, + kCGImageSourceShouldCacheImmediately: false + ] as CFDictionary + let outputData: NSMutableData = NSMutableData() + + guard + let source: CGImageSource = CGImageSourceCreateWithData(originalData as CFData, options), + let sourceType: String = CGImageSourceGetType(source) as? String, + let cgImage: CGImage = CGImageSourceCreateImageAtIndex(source, 0, nil), + let destination = CGImageDestinationCreateWithData(outputData as CFMutableData, sourceType as CFString, 1, nil) + else { throw AttachmentError.invalidData } + + CGImageDestinationAddImage(destination, cgImage, nil) + + guard CGImageDestinationFinalize(destination) else { + throw AttachmentError.couldNotResizeImage + } + + return outputData as Data + } + + /// Otherwise, perform the desired re-encoding, images with alpha should be converted to lossless `WebP` + if mediaMatadata.hasAlpha == true { + let maybeWebPData: Data? = SDImageWebPCoder.shared.encodedData( + with: image, + format: .webP, + options: [ + .encodeFirstFrameOnly: true, + .encodeWebPLossless: true, + .encodeCompressionQuality: 0.25 + ] ) - assetInfo?.cleanup() - return (true, durationSeconds) + guard let webPData: Data = maybeWebPData else { + throw AttachmentError.couldNotResizeImage + } + + return webPData } - // Any other attachment types are valid and have no duration - return (true, nil) + /// And opaque images should be converted to `JPEG` with the appropriate quality + let quality: CGFloat = (transformations.contains(.compress) ? 0.75 : 0.95) + + guard let data: Data = image.jpegData(compressionQuality: quality) else { + throw AttachmentError.couldNotResizeImage + } + + return data + } + + private func prepareAttachment( + id: String, + downloadUrl: String, + byteCount: UInt, + encryptionKey: Data?, + digest: Data?, + using dependencies: Dependencies + ) throws -> Attachment { + let contentType: String = { + guard + let fileExtension: String = sourceFilename.map({ URL(fileURLWithPath: $0) })?.pathExtension, + !fileExtension.isEmpty, + let fileExtensionMimeType: String = UTType(sessionFileExtension: fileExtension)?.preferredMIMEType + else { return (utType.preferredMIMEType ?? UTType.mimeTypeDefault) } + + /// UTTypes are an imperfect means of representing file type; file extensions are also imperfect but far more + /// reliable and comprehensive so we always prefer to try to deduce MIME type from the file extension + return fileExtensionMimeType + }() + let imageSize: CGSize? = { + switch metadata { + case .media(let mediaMetadata): return mediaMetadata.unrotatedSize + case .file, .none: return nil + } + }() + + return Attachment( + id: id, + serverId: nil, + variant: { + switch source { + case .voiceMessage: return .voiceMessage + default: return .standard + } + }(), + state: .uploading, + contentType: contentType, + byteCount: byteCount, + creationTimestamp: nil, + sourceFilename: sourceFilename, + downloadUrl: downloadUrl, + width: imageSize.map { UInt(floor($0.width)) }, + height: imageSize.map { UInt(floor($0.height)) }, + duration: duration, + isVisualMedia: utType.isVisualMedia, + isValid: isValidVisualMedia, + encryptionKey: encryptionKey, + digest: digest + ) + } +} + +// MARK: - Convenience + +public extension PendingAttachment { + var visualMediaSource: ImageDataManager.DataSource? { source.visualMediaSource } + + /// Returns the file extension for this attachment or nil if no file extension can be identified + var fileExtension: String? { + guard + let fileExtension: String = sourceFilename.map({ URL(fileURLWithPath: $0) })?.pathExtension, + !fileExtension.isEmpty + else { return utType.sessionFileExtension(sourceFilename: sourceFilename) } + + return fileExtension.filteredFilename + } + + var isValidVisualMedia: Bool { + guard utType.isImage || utType.isAnimated || utType.isVideo else { return false } + guard case .media(let mediaMetadata) = metadata else { return false } + + return ( + mediaMetadata.hasValidPixelSize && + mediaMetadata.hasValidFileSize && + mediaMetadata.hasValidDuration + ) } } diff --git a/SessionMessagingKit/Utilities/DisplayPictureError.swift b/SessionMessagingKit/Utilities/DisplayPictureError.swift index 82fa50c841..498ef6c3df 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureError.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureError.swift @@ -8,6 +8,7 @@ public enum DisplayPictureError: Error, Equatable, CustomStringConvertible { case imageTooLarge case writeFailed case loadFailed + case imageProcessingFailed case databaseChangesFailed case encryptionFailed case uploadFailed @@ -16,12 +17,14 @@ public enum DisplayPictureError: Error, Equatable, CustomStringConvertible { case invalidPath case alreadyDownloaded(URL?) case updateNoLongerValid + case notEncrypted public var description: String { switch self { case .imageTooLarge: return "Display picture too large." case .writeFailed: return "Display picture write failed." case .loadFailed: return "Display picture load failed." + case .imageProcessingFailed: return "Display picture processing failed." case .databaseChangesFailed: return "Failed to save display picture to database." case .encryptionFailed: return "Display picture encryption failed." case .uploadFailed: return "Display picture upload failed." @@ -30,6 +33,7 @@ public enum DisplayPictureError: Error, Equatable, CustomStringConvertible { case .invalidPath: return "Failed to generate a valid path." case .alreadyDownloaded: return "Display picture already downloaded." case .updateNoLongerValid: return "Display picture update no longer valid." + case .notEncrypted: return "Display picture not encrypted." } } } diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index d83f554566..191a6af197 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -1,4 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import UIKit import Combine @@ -31,15 +33,14 @@ public class DisplayPictureManager { case none case contactRemove - case contactUpdateTo(url: String, key: Data, filePath: String) + case contactUpdateTo(url: String, key: Data) case currentUserRemove - case currentUserUploadImageData(data: Data, isReupload: Bool) - case currentUserUpdateTo(url: String, key: Data, filePath: String) + case currentUserUpdateTo(url: String, key: Data, isReupload: Bool) case groupRemove - case groupUploadImageData(Data) - case groupUpdateTo(url: String, key: Data, filePath: String) + case groupUploadImage(ImageDataManager.DataSource) + case groupUpdateTo(url: String, key: Data) static func from(_ profile: VisibleMessage.VMProfile, fallback: Update, using dependencies: Dependencies) -> Update { return from(profile.profilePictureUrl, key: profile.profileKey, fallback: fallback, using: dependencies) @@ -52,16 +53,15 @@ public class DisplayPictureManager { static func from(_ url: String?, key: Data?, fallback: Update, using dependencies: Dependencies) -> Update { guard let url: String = url, - let key: Data = key, - let filePath: String = try? dependencies[singleton: .displayPictureManager].path(for: url) + let key: Data = key else { return fallback } - return .contactUpdateTo(url: url, key: key, filePath: filePath) + return .contactUpdateTo(url: url, key: key) } } public static let maxBytes: UInt = (5 * 1000 * 1000) - public static let maxDiameter: CGFloat = 640 + public static let maxDimension: CGFloat = 600 public static let aes256KeyByteLength: Int = 32 internal static let nonceLength: Int = 12 internal static let tagLength: Int = 16 @@ -101,7 +101,7 @@ public class DisplayPictureManager { public func sharedDataDisplayPictureDirPath() -> String { let path: String = URL(fileURLWithPath: dependencies[singleton: .fileManager].appSharedDataDirectoryPath) - .appendingPathComponent("DisplayPictures") // stringlint:ignore + .appendingPathComponent("DisplayPictures") .path try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: path) @@ -133,6 +133,13 @@ public class DisplayPictureManager { .path } + public func path(for source: ImageDataManager.DataSource) throws -> String { + switch source { + case .url(let url): return try path(for: url.absoluteString) + default: throw DisplayPictureError.invalidCall + } + } + public func resetStorage() { try? dependencies[singleton: .fileManager].removeItem( atPath: sharedDataDisplayPictureDirPath() @@ -182,150 +189,66 @@ public class DisplayPictureManager { // MARK: - Uploading - public func prepareAndUploadDisplayPicture(imageData: Data, compression: Bool) -> AnyPublisher { - return Just(()) - .setFailureType(to: DisplayPictureError.self) - .tryMap { [dependencies] _ -> (Network.PreparedRequest, String, Data) in - // If the profile avatar was updated or removed then encrypt with a new profile key - // to ensure that other users know that our profile picture was updated - let newEncryptionKey: Data - let finalImageData: Data - let fileExtension: String - let guessedFormat: ImageFormat = MediaUtils.guessedImageFormat(data: imageData) - - finalImageData = try { - switch guessedFormat { - case .gif, .webp: - // Animated images can't be resized so if the data is too large we should error - guard imageData.count <= DisplayPictureManager.maxBytes else { - // Our avatar dimensions are so small that it's incredibly unlikely we wouldn't - // be able to fit our profile photo (eg. generating pure noise at our resolution - // compresses to ~200k) - Log.error(.displayPictureManager, "Updating service with profile failed: \(DisplayPictureError.uploadMaxFileSizeExceeded).") - throw DisplayPictureError.uploadMaxFileSizeExceeded - } - - return imageData - - default: break - } - - // Process the image to ensure it meets our standards for size and compress it to - // standardise the formwat and remove any metadata - guard var image: UIImage = UIImage(data: imageData) else { - throw DisplayPictureError.invalidCall - } - - if image.size.width != DisplayPictureManager.maxDiameter || image.size.height != DisplayPictureManager.maxDiameter { - // To help ensure the user is being shown the same cropping of their avatar as - // everyone else will see, we want to be sure that the image was resized before this point. - Log.verbose(.displayPictureManager, "Avatar image should have been resized before trying to upload.") - image = image.resized(toFillPixelSize: CGSize(width: DisplayPictureManager.maxDiameter, height: DisplayPictureManager.maxDiameter)) - } - - guard let data: Data = image.jpegData(compressionQuality: 0.95) else { - Log.error(.displayPictureManager, "Updating service with profile failed.") - throw DisplayPictureError.writeFailed - } - - guard data.count <= DisplayPictureManager.maxBytes else { - // Our avatar dimensions are so small that it's incredibly unlikely we wouldn't - // be able to fit our profile photo (eg. generating pure noise at our resolution - // compresses to ~200k) - Log.verbose(.displayPictureManager, "Suprised to find profile avatar was too large. Was it scaled properly? image: \(image)") - Log.error(.displayPictureManager, "Updating service with profile failed.") - throw DisplayPictureError.uploadMaxFileSizeExceeded - } - - return data - }() - - newEncryptionKey = try dependencies[singleton: .crypto] - .tryGenerate(.randomBytes(DisplayPictureManager.aes256KeyByteLength)) - fileExtension = { - switch guessedFormat { - case .gif: return "gif" // stringlint:ignore - case .webp: return "webp" // stringlint:ignore - default: return "jpg" // stringlint:ignore - } - }() - - // If we have a new avatar image, we must first: - // - // * Write it to disk. - // * Encrypt it - // * Upload it to asset service - // * Send asset service info to Signal Service - Log.verbose(.displayPictureManager, "Updating local profile on service with new avatar.") - - let temporaryFilePath: String = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: fileExtension) - - // Write the avatar to disk - do { try finalImageData.write(to: URL(fileURLWithPath: temporaryFilePath), options: [.atomic]) } - catch { - Log.error(.displayPictureManager, "Updating service with profile failed.") - throw DisplayPictureError.writeFailed - } - - // Encrypt the avatar for upload - guard - let encryptedData: Data = dependencies[singleton: .crypto].generate( - .encryptedDataDisplayPicture(data: finalImageData, key: newEncryptionKey) - ) - else { - Log.error(.displayPictureManager, "Updating service with profile failed.") - throw DisplayPictureError.encryptionFailed - } - - // Upload the avatar to the FileServer - guard - let preparedUpload: Network.PreparedRequest = try? Network.preparedUpload( - data: encryptedData, - requestAndPathBuildTimeout: Network.fileUploadTimeout, - using: dependencies - ) - else { - Log.error(.displayPictureManager, "Updating service with profile failed.") - throw DisplayPictureError.uploadFailed - } - - return (preparedUpload, temporaryFilePath, newEncryptionKey) - } - .flatMap { [dependencies] preparedUpload, temporaryFilePath, newEncryptionKey -> AnyPublisher<(FileUploadResponse, String, Data), Error> in - preparedUpload.send(using: dependencies) - .map { _, response -> (FileUploadResponse, String, Data) in - (response, temporaryFilePath, newEncryptionKey) - } - .eraseToAnyPublisher() - } - .tryMap { [dependencies] fileUploadResponse, temporaryFilePath, newEncryptionKey -> (String, String, Data) in - let downloadUrl: String = Network.FileServer.downloadUrlString(for: fileUploadResponse.id) - let finalFilePath: String = try dependencies[singleton: .displayPictureManager].path(for: downloadUrl) - try dependencies[singleton: .fileManager].moveItem(atPath: temporaryFilePath, toPath: finalFilePath) - - return (downloadUrl, finalFilePath, newEncryptionKey) - } - .mapError { error in - Log.error(.displayPictureManager, "Updating service with profile failed with error: \(error).") - - switch error { - case NetworkError.maxFileSizeExceeded: return DisplayPictureError.uploadMaxFileSizeExceeded - case let displayPictureError as DisplayPictureError: return displayPictureError - default: return DisplayPictureError.uploadFailed - } - } - .map { [dependencies] downloadUrl, finalFilePath, newEncryptionKey -> UploadResult in - /// Load the data into the `imageDataManager` (assuming we will use it elsewhere in the UI) - Task(priority: .userInitiated) { - await dependencies[singleton: .imageDataManager].load( - .url(URL(fileURLWithPath: finalFilePath)) - ) - } - - Log.verbose(.displayPictureManager, "Successfully uploaded avatar image.") - return (downloadUrl, finalFilePath, newEncryptionKey) - } - .eraseToAnyPublisher() + public func prepareDisplayPicture( + attachment: PendingAttachment, + transformations: Set? = nil + ) throws -> PreparedAttachment { + /// If we weren't given custom transformations then use the default ones for display pictures + let finalTransfomations: Set = ( + transformations ?? + [ + .compress, + .convertToStandardFormats, + .resize(maxDimension: DisplayPictureManager.maxDimension), + .stripImageMetadata, + .encrypt(legacy: true) // FIXME: Remove the `legacy` encryption option + ] + ) + + return try attachment.prepare(transformations: finalTransfomations, using: dependencies) + } + + public func uploadDisplayPicture(attachment: PreparedAttachment) async throws -> UploadResult { + let uploadResponse: FileUploadResponse + + /// Ensure we have an encryption key for the `PreparedAttachment` we want to use as a display picture + guard let encryptionKey: Data = attachment.attachment.encryptionKey else { + throw DisplayPictureError.notEncrypted + } + + do { + /// Upload the data + let data: Data = try dependencies[singleton: .fileManager] + .contents(atPath: attachment.temporaryFilePath) ?? { throw AttachmentError.invalidData }() + let request: Network.PreparedRequest = try Network.preparedUpload( + data: data, + requestAndPathBuildTimeout: Network.fileUploadTimeout, + using: dependencies + ) + + // TODO: Refactor to use async/await when the networking refactor is merged + uploadResponse = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw DisplayPictureError.uploadFailed }() + } + catch NetworkError.maxFileSizeExceeded { throw DisplayPictureError.uploadMaxFileSizeExceeded } + catch { throw DisplayPictureError.uploadFailed } + + /// Generate the `downloadUrl` and move the temporary file to it's expected destination + let downloadUrl: String = Network.FileServer.downloadUrlString(for: uploadResponse.id) + let finalFilePath: String = try dependencies[singleton: .displayPictureManager].path(for: downloadUrl) + try dependencies[singleton: .fileManager].moveItem( + atPath: attachment.temporaryFilePath, + toPath: finalFilePath + ) + + /// Load the data into the `imageDataManager` (assuming we will use it elsewhere in the UI) + Task.detached(priority: .userInitiated) { [imageDataManager = dependencies[singleton: .imageDataManager]] in + await imageDataManager.load(.url(URL(fileURLWithPath: finalFilePath))) + } + + return (downloadUrl, finalFilePath, encryptionKey) } } diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index bb641eb9f8..8377831f71 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -34,95 +34,53 @@ public extension Profile { displayNameUpdate: DisplayNameUpdate = .none, displayPictureUpdate: DisplayPictureManager.Update = .none, using dependencies: Dependencies - ) -> AnyPublisher { - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let isRemovingAvatar: Bool = { - switch displayPictureUpdate { - case .currentUserRemove: return true - default: return false - } - }() - + ) async throws { + /// Perform any non-database related changes for the update switch displayPictureUpdate { - case .contactRemove, .contactUpdateTo, .groupRemove, .groupUpdateTo, .groupUploadImageData: - return Fail(error: DisplayPictureError.invalidCall) - .eraseToAnyPublisher() + case .contactRemove, .contactUpdateTo, .groupRemove, .groupUpdateTo, .groupUploadImage: + throw DisplayPictureError.invalidCall - case .none, .currentUserRemove, .currentUserUpdateTo: - return dependencies[singleton: .storage] - .writePublisher { db in - if isRemovingAvatar { - let existingProfileUrl: String? = try Profile - .filter(id: userSessionId.hexString) - .select(.displayPictureUrl) - .asRequest(of: String.self) - .fetchOne(db) - - /// Remove any cached avatar image data - if - let existingProfileUrl: String = existingProfileUrl, - let filePath: String = try? dependencies[singleton: .displayPictureManager] - .path(for: existingProfileUrl) - { - Task(priority: .low) { - await dependencies[singleton: .imageDataManager].removeImage( - identifier: filePath - ) - try? dependencies[singleton: .fileManager].removeItem(atPath: filePath) - } - } - - switch existingProfileUrl { - case .some: Log.verbose(.profile, "Updating local profile on service with cleared avatar.") - case .none: Log.verbose(.profile, "Updating local profile on service with no avatar.") - } - } - - let profileUpdateTimestampMs: Double = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - try Profile.updateIfNeeded( - db, - publicKey: userSessionId.hexString, - displayNameUpdate: displayNameUpdate, - displayPictureUpdate: displayPictureUpdate, - profileUpdateTimestamp: TimeInterval(profileUpdateTimestampMs / 1000), - using: dependencies - ) - Log.info(.profile, "Successfully updated user profile.") - } - .mapError { _ in DisplayPictureError.databaseChangesFailed } - .eraseToAnyPublisher() - - case .currentUserUploadImageData(let data, let isReupload): - return dependencies[singleton: .displayPictureManager] - .prepareAndUploadDisplayPicture(imageData: data, compression: !isReupload) - .mapError { $0 as Error } - .flatMapStorageWritePublisher(using: dependencies, updates: { db, result in - let profileUpdateTimestamp: TimeInterval = (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) - try Profile.updateIfNeeded( - db, - publicKey: userSessionId.hexString, - displayNameUpdate: displayNameUpdate, - displayPictureUpdate: .currentUserUpdateTo( - url: result.downloadUrl, - key: result.encryptionKey, - filePath: result.filePath - ), - profileUpdateTimestamp: profileUpdateTimestamp, - isReuploadCurrentUserProfilePicture: isReupload, - using: dependencies + case .none, .currentUserUpdateTo: break + case .currentUserRemove: + /// Remove any cached avatar image data + if + let existingProfileUrl: String = dependencies + .mutate(cache: .libSession, { $0.profile }) + .displayPictureUrl, + let filePath: String = try? dependencies[singleton: .displayPictureManager] + .path(for: existingProfileUrl) + { + Log.verbose(.profile, "Updating local profile on service with cleared avatar.") + Task(priority: .low) { + await dependencies[singleton: .imageDataManager].removeImage( + identifier: filePath ) - - dependencies[defaults: .standard, key: .lastUserDisplayPictureRefresh] = dependencies.dateNow - Log.info(.profile, "Successfully updated user profile.") - }) - .mapError { error in - switch error { - case let displayPictureError as DisplayPictureError: return displayPictureError - default: return DisplayPictureError.databaseChangesFailed - } + try? dependencies[singleton: .fileManager].removeItem(atPath: filePath) } - .eraseToAnyPublisher() + } + else { + Log.verbose(.profile, "Updating local profile on service with no avatar.") + } } + + /// Finally, update the `Profile` data in the database + do { + let userSessionId: SessionId = dependencies[cache: .general].sessionId + let profileUpdateTimestamp: TimeInterval = (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + + try await dependencies[singleton: .storage].writeAsync { db in + try Profile.updateIfNeeded( + db, + publicKey: userSessionId.hexString, + displayNameUpdate: displayNameUpdate, + displayPictureUpdate: displayPictureUpdate, + profileUpdateTimestamp: profileUpdateTimestamp, + using: dependencies + ) + } + Log.info(.profile, "Successfully updated user profile.") + } + catch { throw DisplayPictureError.databaseChangesFailed } } /// To try to maintain backwards compatibility with profile changes we want to continue to accept profile changes from old clients if @@ -161,7 +119,7 @@ public extension Profile { displayPictureUpdate: DisplayPictureManager.Update, blocksCommunityMessageRequests: Bool? = nil, profileUpdateTimestamp: TimeInterval?, - isReuploadCurrentUserProfilePicture: Bool = false, + suppressUserProfileConfigUpdate: Bool = false, using dependencies: Dependencies ) throws { let isCurrentUser = (publicKey == dependencies[cache: .general].sessionId.hexString) @@ -195,9 +153,7 @@ public extension Profile { // Profile picture & profile key switch (displayPictureUpdate, isCurrentUser) { case (.none, _): break - case (.currentUserUploadImageData, _), (.groupRemove, _), (.groupUpdateTo, _): - preconditionFailure("Invalid options for this function") - + case (.groupRemove, _), (.groupUpdateTo, _): throw DisplayPictureError.invalidCall case (.contactRemove, false), (.currentUserRemove, true): if profile.displayPictureEncryptionKey != nil { profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: nil)) @@ -208,11 +164,15 @@ public extension Profile { db.addProfileEvent(id: publicKey, change: .displayPictureUrl(nil)) } - case (.contactUpdateTo(let url, let key, let filePath), false), - (.currentUserUpdateTo(let url, let key, let filePath), true): + case (.contactUpdateTo(let url, let key), false), + (.currentUserUpdateTo(let url, let key, _), true): /// If we have already downloaded the image then no need to download it again (the database records will be updated /// once the download completes) - if !dependencies[singleton: .fileManager].fileExists(atPath: filePath) { + let fileExists: Bool = ((try? dependencies[singleton: .displayPictureManager] + .path(for: url)) + .map { dependencies[singleton: .fileManager].fileExists(atPath: $0) } ?? false) + + if !fileExists { dependencies[singleton: .jobRunner].add( db, job: Job( @@ -257,14 +217,19 @@ public extension Profile { /// We don't automatically update the current users profile data when changed in the database so need to manually /// trigger the update - if isCurrentUser, let updatedProfile = try? Profile.fetchOne(db, id: publicKey) { + if !suppressUserProfileConfigUpdate, isCurrentUser, let updatedProfile = try? Profile.fetchOne(db, id: publicKey) { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .userProfile, sessionId: dependencies[cache: .general].sessionId) { _ in try cache.updateProfile( displayName: updatedProfile.name, displayPictureUrl: updatedProfile.displayPictureUrl, displayPictureEncryptionKey: updatedProfile.displayPictureEncryptionKey, - isReuploadProfilePicture: isReuploadCurrentUserProfilePicture + isReuploadProfilePicture: { + switch displayPictureUpdate { + case .currentUserUpdateTo(_, _, let isReupload): return isReupload + default: return false + } + }() ) } } diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index b1f420316f..57b1a0982d 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -12,10 +12,11 @@ import SessionUtilitiesKit import SessionMessagingKit final class ShareNavController: UINavigationController { - public static var attachmentPrepPublisher: AnyPublisher<[SignalAttachment], Error>? + @MainActor public static var pendingAttachments: CurrentValueAsyncStream<[PendingAttachment]?> = CurrentValueAsyncStream(nil) /// The `ShareNavController` is initialized from a storyboard so we need to manually initialize this private let dependencies: Dependencies = Dependencies.createEmpty() + private var processPendingAttachmentsTask: Task? // MARK: - Error @@ -171,6 +172,7 @@ final class ShareNavController: UINavigationController { } deinit { + processPendingAttachmentsTask?.cancel() NotificationCenter.default.removeObserver(self) Log.flush() @@ -212,20 +214,24 @@ final class ShareNavController: UINavigationController { setViewControllers([ threadPickerVC ], animated: false) - let publisher = buildAttachments() - ModalActivityIndicatorViewController - .present( - fromViewController: self, - canCancel: false - ) { activityIndicator in - publisher - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .receive(on: DispatchQueue.main) - .sinkUntilComplete( - receiveCompletion: { _ in activityIndicator.dismiss { } } - ) + let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController() + present(indicator, animated: false) + + processPendingAttachmentsTask?.cancel() + processPendingAttachmentsTask = Task.detached(priority: .userInitiated) { [weak self, indicator] in + guard let self = self else { return } + + do { + let attachments: [PendingAttachment] = try await buildAttachments() + await ShareNavController.pendingAttachments.send(attachments) + await indicator.dismiss {} + } + catch { + await indicator.dismiss { [weak self] in + self?.shareViewFailed(error: error) + } } - ShareNavController.attachmentPrepPublisher = publisher + } } func shareViewWasCompleted(threadId: String?, interactionId: Int64?) { @@ -281,26 +287,6 @@ final class ShareNavController: UINavigationController { } // MARK: Attachment Prep - - private class func createDataSource(type: UTType, url: URL, customFileName: String?, using dependencies: Dependencies) -> (any DataSource)? { - switch (type, type.conforms(to: .text)) { - // Share URLs as text messages whose text content is the URL - case (.url, _): return DataSourceValue(text: url.absoluteString, using: dependencies) - - // Share text as oversize text messages. - // - // NOTE: SharingThreadPickerViewController will try to unpack them - // and send them as normal text messages if possible. - case (_, true): return DataSourcePath(fileUrl: url, sourceFilename: customFileName, shouldDeleteOnDeinit: false, using: dependencies) - - default: - guard let dataSource = DataSourcePath(fileUrl: url, sourceFilename: customFileName, shouldDeleteOnDeinit: false, using: dependencies) else { - return nil - } - - return dataSource - } - } private func extractItemProviders() throws -> [NSItemProvider]? { guard let inputItems = self.extensionContext?.inputItems else { @@ -369,19 +355,6 @@ final class ShareNavController: UINavigationController { return [] } - - private func selectItemProviders() -> AnyPublisher<[NSItemProvider], Error> { - do { - let result: [NSItemProvider] = try extractItemProviders() ?? { - throw ShareViewControllerError.assertionError(description: "no input item") - }() - - return Just(result) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - catch { return Fail(error: error).eraseToAnyPublisher() } - } // MARK: - LoadedItem @@ -390,26 +363,18 @@ final class ShareNavController: UINavigationController { let itemUrl: URL let type: UTType - var customFileName: String? - var isConvertibleToTextMessage = false - var isConvertibleToContactShare = false - - init(itemProvider: NSItemProvider, - itemUrl: URL, - type: UTType, - customFileName: String? = nil, - isConvertibleToTextMessage: Bool = false, - isConvertibleToContactShare: Bool = false) { + init( + itemProvider: NSItemProvider, + itemUrl: URL, + type: UTType + ) { self.itemProvider = itemProvider self.itemUrl = itemUrl self.type = type - self.customFileName = customFileName - self.isConvertibleToTextMessage = isConvertibleToTextMessage - self.isConvertibleToContactShare = isConvertibleToContactShare } } - private func loadItemProvider(itemProvider: NSItemProvider) -> AnyPublisher { + private func pendingAttachment(itemProvider: NSItemProvider) async throws -> PendingAttachment { Log.info("utiTypes for attachment: \(itemProvider.registeredTypeIdentifiers)") // We need to be very careful about which UTI type we use. @@ -422,264 +387,135 @@ final class ShareNavController: UINavigationController { // so in the case of file attachments we try to refine the attachment type // using the file extension. guard let srcType: UTType = itemProvider.type else { - let error = ShareViewControllerError.unsupportedMedia - return Fail(error: error) - .eraseToAnyPublisher() + throw ShareViewControllerError.unsupportedMedia } Log.debug("matched UTType: \(srcType.identifier)") - - return Deferred { [weak self, dependencies] in - Future { resolver in - let loadCompletion: NSItemProvider.CompletionHandler = { value, error in - guard self != nil else { return } - if let error: Error = error { - resolver(Result.failure(error)) - return - } - - guard let value = value else { - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "missing item provider")) + + let pendingAttachment: PendingAttachment = try await withCheckedThrowingContinuation { [itemProvider, dependencies] continuation in + itemProvider.loadItem(forTypeIdentifier: srcType.identifier, options: nil) { value, error in + if let error: Error = error { + return continuation.resume(throwing: error) + } + + switch value { + case .none: + return continuation.resume( + throwing: ShareViewControllerError.assertionError( + description: "missing item provider" + ) ) - return - } - - Log.debug("value type: \(type(of: value))") - - switch value { - case let data as Data: - let customFileName = "Contact.vcf" // stringlint:ignore - let customFileExtension: String? = srcType.sessionFileExtension(sourceFilename: nil) - - guard let tempFilePath = try? dependencies[singleton: .fileManager].write(data: data, toTemporaryFileWithExtension: customFileExtension) else { - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))")) - ) - return - } - let fileUrl = URL(fileURLWithPath: tempFilePath) - - resolver( - Result.success( - LoadedItem( - itemProvider: itemProvider, - itemUrl: fileUrl, - type: srcType, - customFileName: customFileName, - isConvertibleToContactShare: false - ) + + case let data as Data: + guard let tempFilePath = try? dependencies[singleton: .fileManager].write(dataToTemporaryFile: data) else { + return continuation.resume( + throwing: ShareViewControllerError.assertionError( + description: "Error writing item data" ) ) - - case let string as String: - Log.debug("string provider: \(string)") - guard let data = string.filteredForDisplay.data(using: String.Encoding.utf8) else { - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))")) - ) - return - } - guard let tempFilePath: String = try? dependencies[singleton: .fileManager].write(data: data, toTemporaryFileWithExtension: "txt") else { // stringlint:ignore - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))")) - ) - return - } - - let fileUrl = URL(fileURLWithPath: tempFilePath) - - let isConvertibleToTextMessage = !itemProvider.registeredTypeIdentifiers.contains(UTType.fileURL.identifier) - - if srcType.conforms(to: .text) { - resolver( - Result.success( - LoadedItem( - itemProvider: itemProvider, - itemUrl: fileUrl, - type: srcType, - isConvertibleToTextMessage: isConvertibleToTextMessage - ) - ) - ) - } - else { - resolver( - Result.success( - LoadedItem( - itemProvider: itemProvider, - itemUrl: fileUrl, - type: .text, - isConvertibleToTextMessage: isConvertibleToTextMessage - ) - ) + } + + return continuation.resume( + returning: PendingAttachment( + source: .file(URL(fileURLWithPath: tempFilePath)), + utType: srcType, + using: dependencies + ) + ) + + case let string as String: + Log.debug("string provider: \(string)") + return continuation.resume( + returning: PendingAttachment( + source: .text(string.filteredForDisplay), + utType: srcType, + using: dependencies + ) + ) + + case let url as URL: + /// If it's not a file URL then the user is sharing a website so we should handle it as text + guard url.isFileURL else { + return continuation.resume( + returning: PendingAttachment( + source: .text(url.absoluteString), + utType: srcType, + using: dependencies ) - } - - case let url as URL: - // If the share itself is a URL (e.g. a link from Safari), try to send this as a text message. - let isConvertibleToTextMessage = ( - itemProvider.registeredTypeIdentifiers.contains(UTType.url.identifier) && - !itemProvider.registeredTypeIdentifiers.contains(UTType.fileURL.identifier) ) + } + + /// Otherwise we should copy the content into a temporary directory so we don't need to worry about + /// weird system file security issues when trying to eventually share it + let tmpPath: String = dependencies[singleton: .fileManager] + .temporaryFilePath(fileExtension: url.pathExtension) + + do { + try dependencies[singleton: .fileManager].copyItem(at: url, to: URL(fileURLWithPath: tmpPath)) - if isConvertibleToTextMessage { - resolver( - Result.success( - LoadedItem( - itemProvider: itemProvider, - itemUrl: url, - type: .url, - isConvertibleToTextMessage: isConvertibleToTextMessage - ) - ) + return continuation.resume( + returning: PendingAttachment( + source: .file(URL(fileURLWithPath: tmpPath)), + utType: .url, + using: dependencies ) - } - else { - resolver( - Result.success( - LoadedItem( - itemProvider: itemProvider, - itemUrl: url, - type: srcType, - isConvertibleToTextMessage: isConvertibleToTextMessage - ) - ) - ) - } - - case let image as UIImage: - if let data = image.pngData() { - let tempFilePath: String = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: "png") // stringlint:ignore - do { - let url = NSURL.fileURL(withPath: tempFilePath) - try data.write(to: url) - - resolver( - Result.success( - LoadedItem( - itemProvider: itemProvider, - itemUrl: url, - type: srcType - ) - ) - ) - } - catch { - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "couldn't write UIImage: \(String(describing: error))")) - ) - } - } - else { - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "couldn't convert UIImage to PNG: \(String(describing: error))")) + ) + } + catch { + return continuation.resume( + throwing: ShareViewControllerError.assertionError( + description: "Failed to copy temporary file: \(error)" ) - } - - default: - // It's unavoidable that we may sometimes receives data types that we - // don't know how to handle. - resolver( - Result.failure(ShareViewControllerError.assertionError(description: "unexpected value: \(String(describing: value))")) ) - } + } + + case let image as UIImage: + return continuation.resume( + returning: PendingAttachment( + source: .media(.image(UUID().uuidString, image)), + utType: srcType, + using: dependencies + ) + ) + + default: + // It's unavoidable that we may sometimes receives data types that we + // don't know how to handle. + return continuation.resume( + throwing: ShareViewControllerError.assertionError( + description: "Unexpected value: \(String(describing: value))" + ) + ) } - - itemProvider.loadItem(forTypeIdentifier: srcType.identifier, options: nil, completionHandler: loadCompletion) } } - .eraseToAnyPublisher() + + /// If the attachment is a video that isn't in the `supportedVideoTypes` then we should try to convert it + guard + pendingAttachment.utType.isVideo && + !UTType.supportedVideoTypes.contains(pendingAttachment.utType) + else { return pendingAttachment } + + return try await pendingAttachment.compressAsMp4Video(using: dependencies) } - - private func buildAttachment(forLoadedItem loadedItem: LoadedItem) -> AnyPublisher { - let itemProvider = loadedItem.itemProvider - let itemUrl = loadedItem.itemUrl - - var url = itemUrl - do { - if isVideoNeedingRelocation(itemProvider: itemProvider, itemUrl: itemUrl) { - url = try SignalAttachment.copyToVideoTempDir(url: itemUrl, using: dependencies) - } - } catch { - let error = ShareViewControllerError.assertionError(description: "Could not copy video") - return Fail(error: error) - .eraseToAnyPublisher() - } - - Log.debug("building DataSource with url: \(url), UTType: \(loadedItem.type)") - guard let dataSource = ShareNavController.createDataSource(type: loadedItem.type, url: url, customFileName: loadedItem.customFileName, using: dependencies) else { - let error = ShareViewControllerError.assertionError(description: "Unable to read attachment data") - return Fail(error: error) - .eraseToAnyPublisher() - } + private func buildAttachments() async throws -> [PendingAttachment] { + let itemProviders: [NSItemProvider] = try extractItemProviders() ?? { + throw ShareViewControllerError.assertionError(description: "no input item") + }() + + var result: [PendingAttachment] = [] - // start with base utiType, but it might be something generic like "image" - var specificType: UTType = loadedItem.type - if loadedItem.type == .url { - // Use kUTTypeURL for URLs. - } else if loadedItem.type.conforms(to: .text) { - // Use kUTTypeText for text. - } else if url.pathExtension.count > 0 { - // Determine a more specific utiType based on file extension - if let fileExtensionType: UTType = UTType(sessionFileExtension: url.pathExtension) { - Log.debug("UTType based on extension: \(fileExtensionType.identifier)") - specificType = fileExtensionType - } - } + for itemProvider in itemProviders.prefix(AttachmentManager.maxAttachmentsAllowed) { + let attachment: PendingAttachment = try await pendingAttachment(itemProvider: itemProvider) - guard !SignalAttachment.isInvalidVideo(dataSource: dataSource, type: specificType) else { - // This can happen, e.g. when sharing a quicktime-video from iCloud drive. - let (publisher, _) = SignalAttachment.compressVideoAsMp4(dataSource: dataSource, type: specificType, using: dependencies) - return publisher + result.append(attachment) } - - let attachment = SignalAttachment.attachment(dataSource: dataSource, type: specificType, imageQuality: .medium, using: dependencies) - if loadedItem.isConvertibleToContactShare { - Log.debug("isConvertibleToContactShare") - attachment.isConvertibleToContactShare = true - } else if loadedItem.isConvertibleToTextMessage { - Log.debug("isConvertibleToTextMessage") - attachment.isConvertibleToTextMessage = true + + guard !result.isEmpty else { + throw ShareViewControllerError.assertionError(description: "no valid attachments") } - return Just(attachment) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - private func buildAttachments() -> AnyPublisher<[SignalAttachment], Error> { - return selectItemProviders() - .tryFlatMap { [weak self] itemProviders -> AnyPublisher<[SignalAttachment], Error> in - guard let strongSelf = self else { - throw ShareViewControllerError.assertionError(description: "expired") - } - - var loadPublishers = [AnyPublisher]() - - for itemProvider in itemProviders.prefix(SignalAttachment.maxAttachmentsAllowed) { - let loadPublisher = strongSelf.loadItemProvider(itemProvider: itemProvider) - .flatMap { loadedItem -> AnyPublisher in - return strongSelf.buildAttachment(forLoadedItem: loadedItem) - } - .eraseToAnyPublisher() - - loadPublishers.append(loadPublisher) - } - - return Publishers - .MergeMany(loadPublishers) - .collect() - .eraseToAnyPublisher() - } - .tryMap { signalAttachments -> [SignalAttachment] in - guard signalAttachments.count > 0 else { - throw ShareViewControllerError.assertionError(description: "no valid attachments") - } - - return signalAttachments - } - .shareReplay(1) - .eraseToAnyPublisher() + + return result } // Some host apps (e.g. iOS Photos.app) sometimes auto-converts some video formats (e.g. com.apple.quicktime-movie) @@ -808,10 +644,10 @@ private struct SAESNUIKitConfig: SNUIKit.ConfigType { return dependencies[feature: .showStringKeys] } - func asset(for path: String, mimeType: String, sourceFilename: String?) -> (asset: AVURLAsset, cleanup: () -> Void)? { + func asset(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, cleanup: () -> Void)? { return AVURLAsset.asset( for: path, - mimeType: mimeType, + utType: utType, sourceFilename: sourceFilename, using: dependencies ) diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 60295606b9..0f7dc4cb19 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -207,51 +207,62 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - ShareNavController.attachmentPrepPublisher? - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .receive(on: DispatchQueue.main) - .sinkUntilComplete( - receiveValue: { [weak self, dependencies = self.viewModel.dependencies] attachments in - guard - let strongSelf = self, - let approvalVC: UINavigationController = AttachmentApprovalViewController.wrappedInNavController( - threadId: strongSelf.viewModel.viewData[indexPath.row].threadId, - threadVariant: strongSelf.viewModel.viewData[indexPath.row].threadVariant, - attachments: attachments, - approvalDelegate: strongSelf, - disableLinkPreviewImageDownload: ( - strongSelf.viewModel.viewData[indexPath.row].threadCanUpload != true - ), - using: dependencies - ) - else { return } - - self?.navigationController?.present(approvalVC, animated: true, completion: nil) - } - ) + Task(priority: .userInitiated) { [weak self] in + let attachments: [PendingAttachment] = await ShareNavController.pendingAttachments.stream + .compactMap { $0 } + .first(where: { _ in true }) + .defaulting(to: []) + + guard + !attachments.isEmpty, + let self = self, + let approvalVC: UINavigationController = AttachmentApprovalViewController.wrappedInNavController( + threadId: self.viewModel.viewData[indexPath.row].threadId, + threadVariant: self.viewModel.viewData[indexPath.row].threadVariant, + attachments: attachments, + approvalDelegate: self, + disableLinkPreviewImageDownload: ( + self.viewModel.viewData[indexPath.row].threadCanUpload != true + ), + didLoadLinkPreview: { [weak self] linkPreview in + self?.viewModel.didLoadLinkPreview(linkPreview: linkPreview) + }, + using: self.viewModel.dependencies + ) + else { + self?.shareNavController?.shareViewFailed(error: AttachmentError.invalidData) + return + } + + navigationController?.present(approvalVC, animated: true, completion: nil) + } } func attachmentApproval( _ attachmentApproval: AttachmentApprovalViewController, - didApproveAttachments attachments: [SignalAttachment], + didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, messageText: String? ) { // Sharing a URL or plain text will populate the 'messageText' field so in those // cases we should ignore the attachments - let isSharingUrl: Bool = (attachments.count == 1 && attachments[0].isUrl) - let isSharingText: Bool = (attachments.count == 1 && attachments[0].isText) - let finalAttachments: [SignalAttachment] = (isSharingUrl || isSharingText ? [] : attachments) - let body: String? = ( - isSharingUrl && (messageText?.isEmpty == true || attachments[0].linkPreviewDraft == nil) ? - ( - (messageText?.isEmpty == true || (attachments[0].text() == messageText) ? - attachments[0].text() : - "\(attachments[0].text() ?? "")\n\n\(messageText ?? "")" // stringlint:ignore - ) - ) : - messageText + let isSharingUrl: Bool = (attachments.count == 1 && attachments[0].utType.conforms(to: .url)) + let isSharingText: Bool = (attachments.count == 1 && attachments[0].utType.isText) + let finalAttachments: [PendingAttachment] = (isSharingUrl || isSharingText ? [] : attachments) + let body: String? = { + guard isSharingUrl else { return messageText } + + let attachmentText: String? = attachments[0].toText() + + return (messageText?.isEmpty == true || attachmentText == messageText ? + attachmentText : + "\(attachmentText ?? "")\n\n\(messageText ?? "")" // stringlint:ignore + ) + }() + let linkPreviewDraft: LinkPreviewDraft? = (isSharingUrl ? + viewModel.linkPreviewDrafts.first(where: { $0.urlString == body }) : + nil ) let userSessionId: SessionId = viewModel.dependencies[cache: .general].sessionId let swarmPublicKey: String = { @@ -314,7 +325,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView expiresStartedAtMs: destinationDisappearingMessagesConfiguration?.initialExpiresStartedAtMs( sentTimestampMs: Double(sentTimestampMs) ), - linkPreviewUrl: (isSharingUrl ? attachments.first?.linkPreviewDraft?.urlString : nil), + linkPreviewUrl: linkPreviewDraft?.urlString, using: dependencies ).inserted(db) sharedInteractionId = interaction.id @@ -327,7 +338,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView // one then add it now if isSharingUrl, - let linkPreviewDraft: LinkPreviewDraft = attachments.first?.linkPreviewDraft, + let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft, (try? interaction.linkPreview.isEmpty(db)) == true { try LinkPreview( @@ -335,6 +346,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView title: linkPreviewDraft.title, attachmentId: LinkPreview .generateAttachmentIfPossible( + urlString: linkPreviewDraft.urlString, imageData: linkPreviewDraft.jpegImageData, type: .jpeg, using: dependencies @@ -454,7 +466,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageText newMessageText: String?) { } - func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) { + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: PendingAttachment) { } func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) { diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index ffc6799dd6..e8323b83f4 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -14,6 +14,8 @@ public class ThreadPickerViewModel { public let dependencies: Dependencies public let userMetadata: ExtensionHelper.UserMetadata? public let hasNonTextAttachment: Bool + // FIXME: Clean up to follow proper MVVM + @MainActor public private(set) var linkPreviewDrafts: [LinkPreviewDraft] = [] init( userMetadata: ExtensionHelper.UserMetadata?, @@ -97,6 +99,10 @@ public class ThreadPickerViewModel { // MARK: - Functions + @MainActor public func didLoadLinkPreview(linkPreview: LinkPreviewDraft) { + linkPreviewDrafts.append(linkPreview) + } + public func updateData(_ updatedData: [SessionThreadViewModel]) { self.viewData = updatedData } diff --git a/SessionUIKit/Components/Input View/InputTextView.swift b/SessionUIKit/Components/Input View/InputTextView.swift index 1f9fd8c204..4eac3cf018 100644 --- a/SessionUIKit/Components/Input View/InputTextView.swift +++ b/SessionUIKit/Components/Input View/InputTextView.swift @@ -59,8 +59,12 @@ public final class InputTextView: UITextView, UITextViewDelegate { } public override func paste(_ sender: Any?) { - if let image = UIPasteboard.general.image { - snDelegate?.didPasteImageFromPasteboard(self, image: image) + if + UIPasteboard.general.hasImages, + let firstItem: [String: Any] = UIPasteboard.general.items.first, + let itemData: Data = firstItem.values.first as? Data + { + snDelegate?.didPasteImageDataFromPasteboard(self, imageData: itemData) } super.paste(sender) } @@ -120,5 +124,5 @@ public final class InputTextView: UITextView, UITextViewDelegate { public protocol InputTextViewDelegate: AnyObject { func inputTextViewDidChangeSize(_ inputTextView: InputTextView) func inputTextViewDidChangeContent(_ inputTextView: InputTextView) - func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage) + func didPasteImageDataFromPasteboard(_ inputTextView: InputTextView, imageData: Data) } diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index f7da0d828c..73d06a2fcb 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -475,8 +475,8 @@ public final class ProfilePictureView: UIView { // Populate the main imageView switch (info.source, info.renderingMode) { - case (.some(let source), .some(let renderingMode)) where source.directImage != nil: - imageView.image = source.directImage?.withRenderingMode(renderingMode) + case (.image(_, let image), .some(let renderingMode)): + imageView.image = image?.withRenderingMode(renderingMode) case (.some(let source), _): imageView.loadImage(source) @@ -519,8 +519,8 @@ public final class ProfilePictureView: UIView { // Set the additional image content and reposition the image views correctly switch (additionalInfo.source, additionalInfo.renderingMode) { - case (.some(let source), .some(let renderingMode)) where source.directImage != nil: - additionalImageView.image = source.directImage?.withRenderingMode(renderingMode) + case (.image(_, let image), .some(let renderingMode)): + additionalImageView.image = image?.withRenderingMode(renderingMode) additionalImageContainerView.isHidden = false case (.some(let source), _): diff --git a/SessionUIKit/Configuration.swift b/SessionUIKit/Configuration.swift index 41e8c9826f..32ed35f0ee 100644 --- a/SessionUIKit/Configuration.swift +++ b/SessionUIKit/Configuration.swift @@ -2,6 +2,7 @@ import UIKit import AVFoundation +import UniformTypeIdentifiers public typealias ThemeSettings = (theme: Theme?, primaryColor: Theme.PrimaryColor?, matchSystemNightModeSetting: Bool?) @@ -17,7 +18,7 @@ public actor SNUIKit { func cacheContextualActionInfo(tableViewHash: Int, sideKey: String, actionIndex: Int, actionInfo: Any) func removeCachedContextualActionInfo(tableViewHash: Int, keys: [String]) func shouldShowStringKeys() -> Bool - func asset(for path: String, mimeType: String, sourceFilename: String?) -> (asset: AVURLAsset, cleanup: () -> Void)? + func asset(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, cleanup: () -> Void)? } @MainActor public static var mainWindow: UIWindow? = nil @@ -67,9 +68,9 @@ public actor SNUIKit { return config.shouldShowStringKeys() } - internal static func asset(for path: String, mimeType: String, sourceFilename: String?) -> (asset: AVURLAsset, cleanup: () -> Void)? { + internal static func asset(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, cleanup: () -> Void)? { guard let config: ConfigType = self.config else { return nil } - return config.asset(for: path, mimeType: mimeType, sourceFilename: sourceFilename) + return config.asset(for: path, utType: utType, sourceFilename: sourceFilename) } } diff --git a/SessionUIKit/Types/ImageDataManager.swift b/SessionUIKit/Types/ImageDataManager.swift index 9e0dd29674..7167dd0aea 100644 --- a/SessionUIKit/Types/ImageDataManager.swift +++ b/SessionUIKit/Types/ImageDataManager.swift @@ -1,6 +1,7 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Lucide import AVFoundation import ImageIO @@ -89,6 +90,13 @@ public actor ImageDataManager: ImageDataManagerType { private static func processSource(_ dataSource: DataSource) async -> ProcessedImageData? { switch dataSource { + case .icon(let icon, let size, let renderingMode): + guard let image: UIImage = Lucide.image(icon: icon, size: size) else { return nil } + + return ProcessedImageData( + type: .staticImage(image.withRenderingMode(renderingMode)) + ) + /// If we were given a direct `UIImage` value then use it case .image(_, let maybeImage): guard let image: UIImage = maybeImage else { return nil } @@ -98,7 +106,7 @@ public actor ImageDataManager: ImageDataManagerType { ) /// Custom handle `videoUrl` values since it requires thumbnail generation - case .videoUrl(let url, let mimeType, let sourceFilename, let thumbnailManager): + case .videoUrl(let url, let utType, let sourceFilename, let thumbnailManager): /// If we had already generated a thumbnail then use that if let existingThumbnail: UIImage = thumbnailManager.existingThumbnailImage(url: url, size: .large), @@ -119,7 +127,7 @@ public actor ImageDataManager: ImageDataManagerType { /// Otherwise we need to generate a new one let assetInfo: (asset: AVURLAsset, cleanup: () -> Void)? = SNUIKit.asset( for: url.path, - mimeType: mimeType, + utType: utType, sourceFilename: sourceFilename ) @@ -558,8 +566,9 @@ public extension ImageDataManager { enum DataSource: Sendable, Equatable, Hashable { case url(URL) case data(String, Data) + case icon(Lucide.Icon, size: CGFloat, renderingMode: UIImage.RenderingMode = .alwaysOriginal) case image(String, UIImage?) - case videoUrl(URL, String, String?, ThumbnailManager) + case videoUrl(URL, UTType, String?, ThumbnailManager) case urlThumbnail(URL, ImageDataManager.ThumbnailSize, ThumbnailManager) case placeholderIcon(seed: String, text: String, size: CGFloat) case asyncSource(String, @Sendable () async -> DataSource?) @@ -568,6 +577,9 @@ public extension ImageDataManager { switch self { case .url(let url): return url.absoluteString case .data(let identifier, _): return identifier + case .icon(let icon, let size, let renderingMode): + return "\(icon.rawValue)-\(Int(floor(size)))-\(renderingMode.rawValue)" + case .image(let identifier, _): return identifier case .videoUrl(let url, _, _, _): return url.absoluteString case .urlThumbnail(let url, let size, _): @@ -586,26 +598,19 @@ public extension ImageDataManager { } } - public var imageData: Data? { + public var contentExists: Bool { switch self { - case .url(let url): return try? Data(contentsOf: url, options: [.dataReadingMapped]) - case .data(_, let data): return data - case .image(_, let image): return image?.pngData() - case .videoUrl: return nil - case .urlThumbnail: return nil - case .placeholderIcon: return nil - case .asyncSource: return nil - } - } - - public var directImage: UIImage? { - switch self { - case .image(_, let image): return image - default: return nil + case .url(let url), .videoUrl(let url, _, _, _), .urlThumbnail(let url, _, _): + return FileManager.default.fileExists(atPath: url.path) + + case .data(_, let data): return !data.isEmpty + case .image(_, let image): return (image != nil) + case .icon, .placeholderIcon: return true + case .asyncSource: return true /// Need to assume it exists } } - fileprivate func createImageSource(options: [CFString: Any]? = nil) -> CGImageSource? { + public func createImageSource(options: [CFString: Any]? = nil) -> CGImageSource? { let finalOptions: CFDictionary = ( options ?? [ @@ -620,7 +625,7 @@ public extension ImageDataManager { case .urlThumbnail(let url, _, _): return CGImageSourceCreateWithURL(url as CFURL, finalOptions) // These cases have special handling which doesn't use `createImageSource` - case .image, .videoUrl, .placeholderIcon, .asyncSource: return nil + case .icon, .image, .videoUrl, .placeholderIcon, .asyncSource: return nil } } @@ -633,14 +638,21 @@ public extension ImageDataManager { lhsData == rhsData ) + case (.icon(let lhsIcon, let lhsSize, let lhsRenderingMode), .icon(let rhsIcon, let rhsSize, let rhsRenderingMode)): + return ( + lhsIcon == rhsIcon && + lhsSize == rhsSize && + lhsRenderingMode == rhsRenderingMode + ) + case (.image(let lhsIdentifier, _), .image(let rhsIdentifier, _)): /// `UIImage` is not _really_ equatable so we need to use a separate identifier to use instead return (lhsIdentifier == rhsIdentifier) - case (.videoUrl(let lhsUrl, let lhsMimeType, let lhsSourceFilename, _), .videoUrl(let rhsUrl, let rhsMimeType, let rhsSourceFilename, _)): + case (.videoUrl(let lhsUrl, let lhsUTType, let lhsSourceFilename, _), .videoUrl(let rhsUrl, let rhsUTType, let rhsSourceFilename, _)): return ( lhsUrl == rhsUrl && - lhsMimeType == rhsMimeType && + lhsUTType == rhsUTType && lhsSourceFilename == rhsSourceFilename ) @@ -671,13 +683,18 @@ public extension ImageDataManager { identifier.hash(into: &hasher) data.hash(into: &hasher) + case .icon(let icon, let size, let renderingMode): + icon.hash(into: &hasher) + size.hash(into: &hasher) + renderingMode.hash(into: &hasher) + case .image(let identifier, _): /// `UIImage` is not actually hashable so we need to provide a separate identifier to use instead identifier.hash(into: &hasher) - case .videoUrl(let url, let mimeType, let sourceFilename, _): + case .videoUrl(let url, let utType, let sourceFilename, _): url.hash(into: &hasher) - mimeType.hash(into: &hasher) + utType.hash(into: &hasher) sourceFilename.hash(into: &hasher) case .urlThumbnail(let url, let size, _): @@ -806,6 +823,7 @@ public extension ImageDataManager.DataSource { /// There are a number of types which have fixed sizes, in those cases we should return the target size rather than try to /// read it from data so we doncan avoid processing switch self { + case .icon(_, let size, _): return CGSize(width: size, height: size) case .image(_, let image): guard let image: UIImage = image else { break } diff --git a/SessionUIKit/Types/SUIKImageFormat.swift b/SessionUIKit/Types/SUIKImageFormat.swift deleted file mode 100644 index 39906bfb69..0000000000 --- a/SessionUIKit/Types/SUIKImageFormat.swift +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -/// This type should match the `ImageFormat` type in `SessionUtilitiesKit` -public enum SUIKImageFormat { - case unknown - case png - case gif - case tiff - case jpeg - case bmp - case webp - - var nullIfUnknown: SUIKImageFormat? { - switch self { - case .unknown: return nil - default: return self - } - } -} diff --git a/SessionUtilitiesKit/Media/DataSource.swift b/SessionUtilitiesKit/Media/DataSource.swift deleted file mode 100644 index 84fcddb595..0000000000 --- a/SessionUtilitiesKit/Media/DataSource.swift +++ /dev/null @@ -1,279 +0,0 @@ -// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import CoreGraphics -import ImageIO -import UniformTypeIdentifiers - -// MARK: - DataSource - -public protocol DataSource: Equatable { - var dependencies: Dependencies { get } - var data: Data { get } - var dataUrl: URL? { get } - - /// The file path for the data, if it already exists on disk. - /// - /// This method is safe to call as it will not do any expensive reads or writes. - /// - /// May return nil if the data does not (yet) reside on disk. - /// - /// Use `dataUrl` instead if you need to access the data; it will ensure the data is on disk and return a URL, barring an error. - var dataPathIfOnDisk: String? { get } - - var dataLength: Int { get } - var sourceFilename: String? { get set } - var fileExtension: String { get } - var mimeType: String? { get } - var shouldDeleteOnDeinit: Bool { get } - - // MARK: - Functions - - func write(to path: String) throws -} - -public extension DataSource { - var imageSize: CGSize? { - let type: UTType? = UTType(sessionFileExtension: fileExtension) - let options: CFDictionary = [ - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false - ] as CFDictionary - let maybeSource: CGImageSource? = { - switch self.dataPathIfOnDisk { - case .some(let path): return CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, options) - case .none: return CGImageSourceCreateWithData(data as CFData, options) - } - }() - - guard let source: CGImageSource = maybeSource else { return nil } - - return MediaUtils.MediaMetadata(source: source)?.pixelSize - } - - var isValidImage: Bool { - let type: UTType? = UTType(sessionFileExtension: fileExtension) - - switch self.dataPathIfOnDisk { - case .some(let path): return MediaUtils.isValidImage(at: path, type: type, using: dependencies) - case .none: return MediaUtils.isValidImage(data: data, type: type) - } - } - - var isValidVideo: Bool { - guard let dataUrl: URL = self.dataUrl else { return false } - - return MediaUtils.isValidVideo( - path: dataUrl.path, - mimeType: mimeType, - sourceFilename: sourceFilename, - using: dependencies - ) - } -} - -// MARK: - DataSourceValue - -public class DataSourceValue: DataSource { - public static func empty(using dependencies: Dependencies) -> DataSourceValue { - return DataSourceValue(data: Data(), fileExtension: UTType.fileExtensionText, using: dependencies) - } - - public let dependencies: Dependencies - public var data: Data - public var sourceFilename: String? - public var fileExtension: String - var cachedFilePath: String? - public var shouldDeleteOnDeinit: Bool - - public var dataUrl: URL? { dataPath.map { URL(fileURLWithPath: $0) } } - public var dataPathIfOnDisk: String? { cachedFilePath } - public var dataLength: Int { data.count } - public var mimeType: String? { UTType.sessionMimeType(for: fileExtension) } - - var dataPath: String? { - let fileExtension: String = self.fileExtension - - return DataSourceValue.synced(self) { [weak self, dependencies] in - guard let cachedFilePath: String = self?.cachedFilePath else { - let filePath: String = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: fileExtension) - - do { try self?.write(to: filePath) } - catch { return nil } - - self?.cachedFilePath = filePath - return filePath - } - - return cachedFilePath - } - } - - // MARK: - Initialization - - public init(data: Data, fileExtension: String, using dependencies: Dependencies) { - self.dependencies = dependencies - self.data = data - self.fileExtension = fileExtension - self.shouldDeleteOnDeinit = true - } - - convenience init?(data: Data?, fileExtension: String, using dependencies: Dependencies) { - guard let data: Data = data else { return nil } - - self.init(data: data, fileExtension: fileExtension, using: dependencies) - } - - public convenience init?(data: Data?, dataType: UTType, using dependencies: Dependencies) { - guard let fileExtension: String = dataType.sessionFileExtension(sourceFilename: nil) else { return nil } - - self.init(data: data, fileExtension: fileExtension, using: dependencies) - } - - public convenience init?(text: String?, using dependencies: Dependencies) { - guard - let text: String = text, - let data: Data = text.filteredForDisplay.data(using: .utf8) - else { return nil } - - self.init(data: data, fileExtension: UTType.fileExtensionText, using: dependencies) - } - - deinit { - guard - shouldDeleteOnDeinit, - let filePath: String = cachedFilePath - else { return } - - DispatchQueue.global(qos: .default).async { [dependencies] in - try? dependencies[singleton: .fileManager].removeItem(atPath: filePath) - } - } - - // MARK: - Functions - - @discardableResult private static func synced(_ lock: Any, closure: () -> T) -> T { - objc_sync_enter(lock) - let result: T = closure() - objc_sync_exit(lock) - return result - } - - public func write(to path: String) throws { - try data.write(to: URL(fileURLWithPath: path), options: .atomic) - } - - public static func == (lhs: DataSourceValue, rhs: DataSourceValue) -> Bool { - return ( - lhs.data == rhs.data && - lhs.sourceFilename == rhs.sourceFilename && - lhs.fileExtension == rhs.fileExtension && - lhs.shouldDeleteOnDeinit == rhs.shouldDeleteOnDeinit - ) - } -} - -// MARK: - DataSourcePath - -public class DataSourcePath: DataSource { - public let dependencies: Dependencies - public var filePath: String - public var sourceFilename: String? - public var fileExtension: String { URL(fileURLWithPath: filePath).pathExtension } - var cachedData: Data? - var cachedDataLength: Int? - public var shouldDeleteOnDeinit: Bool - - public var data: Data { - let filePath: String = self.filePath - - return DataSourcePath.synced(self) { [weak self] in - if let cachedData: Data = self?.cachedData { - return cachedData - } - - let data: Data = ((try? Data(contentsOf: URL(fileURLWithPath: filePath))) ?? Data()) - self?.cachedData = data - return data - } - } - - public var dataUrl: URL? { URL(fileURLWithPath: filePath) } - public var dataPathIfOnDisk: String? { filePath } - - public var dataLength: Int { - let filePath: String = self.filePath - - return DataSourcePath.synced(self) { [weak self, dependencies] in - if let cachedDataLength: Int = self?.cachedDataLength { - return cachedDataLength - } - - let attrs: [FileAttributeKey: Any]? = try? dependencies[singleton: .fileManager].attributesOfItem(atPath: filePath) - let length: Int = ((attrs?[FileAttributeKey.size] as? Int) ?? 0) - self?.cachedDataLength = length - return length - } - } - - public var mimeType: String? { UTType.sessionMimeType(for: URL(fileURLWithPath: filePath).pathExtension) } - - // MARK: - Initialization - - public init( - filePath: String, - sourceFilename: String?, - shouldDeleteOnDeinit: Bool, - using dependencies: Dependencies - ) { - self.dependencies = dependencies - self.filePath = filePath - self.sourceFilename = sourceFilename - self.shouldDeleteOnDeinit = shouldDeleteOnDeinit - } - - public convenience init?( - fileUrl: URL?, - sourceFilename: String?, - shouldDeleteOnDeinit: Bool, - using dependencies: Dependencies - ) { - guard let fileUrl: URL = fileUrl, fileUrl.isFileURL else { return nil } - - self.init( - filePath: fileUrl.path, - sourceFilename: (sourceFilename ?? fileUrl.lastPathComponent), - shouldDeleteOnDeinit: shouldDeleteOnDeinit, - using: dependencies - ) - } - - deinit { - guard shouldDeleteOnDeinit else { return } - - DispatchQueue.global(qos: .default).async { [filePath, dependencies] in - try? dependencies[singleton: .fileManager].removeItem(atPath: filePath) - } - } - - // MARK: - Functions - - @discardableResult private static func synced(_ lock: Any, closure: () -> T) -> T { - objc_sync_enter(lock) - let result: T = closure() - objc_sync_exit(lock) - return result - } - - public func write(to path: String) throws { - try dependencies[singleton: .fileManager].copyItem(atPath: filePath, toPath: path) - } - - public static func == (lhs: DataSourcePath, rhs: DataSourcePath) -> Bool { - return ( - lhs.filePath == rhs.filePath && - lhs.sourceFilename == rhs.sourceFilename && - lhs.shouldDeleteOnDeinit == rhs.shouldDeleteOnDeinit - ) - } -} diff --git a/SessionUtilitiesKit/Media/ImageFormat.swift b/SessionUtilitiesKit/Media/ImageFormat.swift deleted file mode 100644 index cac74ed4ee..0000000000 --- a/SessionUtilitiesKit/Media/ImageFormat.swift +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -public enum ImageFormat { - case unknown - case png - case gif - case tiff - case jpeg - case bmp - case webp - - // stringlint:ignore_contents - public var fileExtension: String { - switch self { - case .jpeg, .unknown: return "jpg" - case .png: return "png" - case .gif: return "gif" - case .tiff: return "tiff" - case .bmp: return "bmp" - case .webp: return "webp" - } - } -} diff --git a/SessionUtilitiesKit/Media/MediaUtils.swift b/SessionUtilitiesKit/Media/MediaUtils.swift index ae3dec2938..5cf6b666aa 100644 --- a/SessionUtilitiesKit/Media/MediaUtils.swift +++ b/SessionUtilitiesKit/Media/MediaUtils.swift @@ -1,4 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import UIKit import AVFoundation @@ -18,24 +20,119 @@ public enum MediaError: Error { // MARK: - MediaUtils public enum MediaUtils { - public struct MediaMetadata { + public static let unsafeMetadataKeys: Set = [ + kCGImagePropertyExifDictionary, /// Camera settings, dates + kCGImagePropertyGPSDictionary, /// Location data + kCGImagePropertyIPTCDictionary, /// Copyright, captions + kCGImagePropertyTIFFDictionary, /// Camera make/model, software + kCGImagePropertyMakerAppleDictionary, /// Apple device info + kCGImagePropertyExifAuxDictionary, /// Lens info, etc. + kCGImageProperty8BIMDictionary, /// Photoshop data + kCGImagePropertyDNGDictionary, /// RAW camera data + kCGImagePropertyCIFFDictionary, /// Canon RAW + kCGImagePropertyMakerCanonDictionary, + kCGImagePropertyMakerNikonDictionary, + kCGImagePropertyMakerMinoltaDictionary, + kCGImagePropertyMakerFujiDictionary, + kCGImagePropertyMakerOlympusDictionary, + kCGImagePropertyMakerPentaxDictionary + ] + public static let possiblySafeMetadataKeys: Set = [ + kCGImagePropertyPNGDictionary, + kCGImagePropertyGIFDictionary, + kCGImagePropertyJFIFDictionary, + kCGImagePropertyHEICSDictionary + ] + public static let safeMetadataKeys: Set = [ + kCGImagePropertyPixelWidth, + kCGImagePropertyPixelHeight, + kCGImagePropertyDepth, + kCGImagePropertyHasAlpha, + kCGImagePropertyColorModel, + kCGImagePropertyOrientation + ] + + public struct MediaMetadata: Sendable, Equatable, Hashable { + /// The pixel size of the media (or it's first frame) public let pixelSize: CGSize + + /// The size of the file this media is stored in + /// + /// **Note:** This value could be `0` if initialised with a `UIImage` (since the eventual file size would depend on the the + /// file type when written to disk) + public let fileSize: UInt64 + + /// The number of frames this media has (`1` for a static image) public let frameCount: Int + + /// The duration of the content (will be `0` for static images) + public let duration: TimeInterval + + /// A flag indicating whether the media may contain unsafe metadata + public let hasUnsafeMetadata: Bool + + /// The number of bits in each color sample of each pixel public let depthBytes: CGFloat? + + /// A flag indicating whether the media has transparent content public let hasAlpha: Bool? + + /// The color model of the image such as "RGB", "CMYK", "Gray", or "Lab" public let colorModel: String? + + /// The orientation of the media public let orientation: UIImage.Orientation? + /// The type of the media content + public let utType: UTType? + + /// A flag indicating whether the media has valid dimensions (this is primarily here to avoid a "GIF bomb" situation) public var hasValidPixelSize: Bool { - pixelSize.width > 0 && - pixelSize.width < CGFloat(SNUtilitiesKit.maxValidImageDimension) && - pixelSize.height > 0 && - pixelSize.height < CGFloat(SNUtilitiesKit.maxValidImageDimension) + /// If the content isn't visual media then it should have a `zero` size + guard utType?.isVisualMedia == true else { return (pixelSize == .zero) } + + /// Otherwise just ensure it's a sane size + return ( + pixelSize.width > 0 && + pixelSize.width < CGFloat(SNUtilitiesKit.maxValidImageDimension) && + pixelSize.height > 0 && + pixelSize.height < CGFloat(SNUtilitiesKit.maxValidImageDimension) + ) + } + + /// A flag indicating whether the media has a valid file size (ie. the max file size that an attachment can be) + public var hasValidFileSize: Bool { fileSize <= SNUtilitiesKit.maxFileSize } + + /// A flag indicating whether the media has a valid duration for it's type + public var hasValidDuration: Bool { + if utType?.isAudio == true || utType?.isVideo == true { + return (duration > 0) + } + + if utType?.isAnimated == true && frameCount > 1 { + return (duration > 0) + } + + /// Other types shouldn't have a duration + return (duration == 0) + } + + public var unrotatedSize: CGSize { + /// If the metadata doesn't have an orientation then don't rotate the size (WebP and videos shouldn't have orientations) + guard let orientation: UIImage.Orientation = orientation else { return pixelSize } + + switch orientation { + case .up, .upMirrored, .down, .downMirrored: return pixelSize + case .leftMirrored, .left, .rightMirrored, .right: + return CGSize(width: pixelSize.height, height: pixelSize.width) + + @unknown default: return pixelSize + } } // MARK: - Initialization - public init?(source: CGImageSource) { + public init?(source: CGImageSource, fileSize: UInt64) { let count: Int = CGImageSourceGetCount(source) guard @@ -46,7 +143,44 @@ public enum MediaUtils { else { return nil } self.pixelSize = CGSize(width: width, height: height) + self.fileSize = fileSize self.frameCount = count + self.duration = { + guard count > 1 else { return 0 } + + return (0.. = Set(properties.keys) + + /// If we have one of the unsafe metadata keys then no need to process further + guard allKeys.isDisjoint(with: unsafeMetadataKeys) else { + return true + } + + /// A number of the properties required for media decoding are included at both the top level and in child data so + /// we need to check if there are any "non-allowed" keys in the child data in order to make a decision + for key in possiblySafeMetadataKeys { + guard + let childProperties: [CFString: Any] = properties[key] as? [CFString: Any], + !childProperties.isEmpty + else { continue } + + let allChildKeys: Set = Set(childProperties.keys) + let unsafeKeys: Set = allChildKeys.subtracting(safeMetadataKeys) + + if !unsafeKeys.isEmpty { + return true + } + + continue + } + + /// If we get here then there is no unsafe metadata + return false + }() self.depthBytes = { /// The number of bits in each color sample of each pixel. The value of this key is a CFNumberRef guard let depthBits: UInt = properties[kCGImagePropertyDepth] as? UInt else { return nil } @@ -65,81 +199,149 @@ public enum MediaUtils { return UIImage.Orientation(cgOrientation) }() + self.utType = (CGImageSourceGetType(source) as? String).map { UTType($0) } } public init( pixelSize: CGSize, + fileSize: UInt64 = 0, + hasUnsafeMetadata: Bool, depthBytes: CGFloat? = nil, hasAlpha: Bool? = nil, colorModel: String? = nil, - orientation: UIImage.Orientation? = nil + orientation: UIImage.Orientation? = nil, + utType: UTType? = nil ) { self.pixelSize = pixelSize + self.fileSize = fileSize self.frameCount = 1 + self.duration = 0 + self.hasUnsafeMetadata = hasUnsafeMetadata self.depthBytes = depthBytes self.hasAlpha = hasAlpha self.colorModel = colorModel self.orientation = orientation + self.utType = utType + } + + public init?(image: UIImage) { + guard let cgImage = image.cgImage else { return nil } + + self.pixelSize = image.size + self.fileSize = 0 /// Unknown for `UIImage` in memory + self.frameCount = 1 + self.duration = 0 + self.hasUnsafeMetadata = false /// `UIImage` in memory has no file metadata + self.depthBytes = { + let bitsPerPixel = cgImage.bitsPerPixel + return ceil(CGFloat(bitsPerPixel) / 8.0) + }() + let hasAlphaChannel: Bool = { + switch cgImage.alphaInfo { + case .none, .noneSkipFirst, .noneSkipLast: return false + case .first, .last, .premultipliedFirst, .premultipliedLast, .alphaOnly: return true + @unknown default: return false + } + }() + self.hasAlpha = hasAlphaChannel + self.colorModel = { + switch cgImage.colorSpace?.model { + case .monochrome: return "Gray" + case .rgb: return "RGB" + case .cmyk: return "CMYK" + case .lab: return "Lab" + default: return nil + } + }() + self.orientation = image.imageOrientation + self.utType = nil /// An in-memory `UIImage` is just decoded pixels so doesn't have a `UTType` } public init?( from path: String, - type: UTType?, - mimeType: String?, + utType: UTType?, sourceFilename: String?, using dependencies: Dependencies ) { /// Videos don't have the same metadata as images so need custom handling - guard type?.isVideo != true else { + guard utType?.isVideo != true else { let assetInfo: (asset: AVURLAsset, cleanup: () -> Void)? = AVURLAsset.asset( for: path, - mimeType: mimeType, + utType: utType, sourceFilename: sourceFilename, using: dependencies ) + defer { assetInfo?.cleanup() } guard + let fileSize: UInt64 = dependencies[singleton: .fileManager].fileSize(of: path), let asset: AVURLAsset = assetInfo?.asset, - let track: AVAssetTrack = asset.tracks(withMediaType: .video).first + !asset.tracks(withMediaType: .video).isEmpty else { return nil } - let size: CGSize = track.naturalSize - let transformedSize: CGSize = size.applying(track.preferredTransform) - let videoSize: CGSize = CGSize( - width: abs(transformedSize.width), - height: abs(transformedSize.height) - ) + /// Get the maximum size of any video track in the file + var maxTrackSize: CGSize = .zero + + for track: AVAssetTrack in asset.tracks(withMediaType: .video) { + let trackSize: CGSize = track.naturalSize + let transformedSize: CGSize = trackSize.applying(track.preferredTransform) + maxTrackSize.width = max(maxTrackSize.width, abs(transformedSize.width)) + maxTrackSize.height = max(maxTrackSize.height, abs(transformedSize.height)) + } - guard videoSize.width > 0, videoSize.height > 0 else { return nil } + guard maxTrackSize.width > 0, maxTrackSize.height > 0 else { return nil } - self.pixelSize = videoSize + self.pixelSize = maxTrackSize + self.fileSize = fileSize self.frameCount = -1 /// Rather than try to extract the frames, or give it an "incorrect" value, make it explicitly invalid + self.duration = ( /// According to the CMTime docs "value/timescale = seconds" + TimeInterval(asset.duration.value) / TimeInterval(asset.duration.timescale) + ) + self.hasUnsafeMetadata = false /// Don't current support stripping this so just hard-code + self.depthBytes = nil + self.hasAlpha = false + self.colorModel = nil + self.orientation = nil + self.utType = utType + return + } + + /// Audio also needs custom handling + guard utType?.isAudio != true else { + guard let fileSize: UInt64 = dependencies[singleton: .fileManager].fileSize(of: path) else { + return nil + } + + self.pixelSize = .zero + self.fileSize = fileSize + self.frameCount = -1 + + do { self.duration = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path)).duration } + catch { return nil } + + self.hasUnsafeMetadata = false /// Don't current support stripping this so just hard-code self.depthBytes = nil self.hasAlpha = false self.colorModel = nil self.orientation = nil + self.utType = utType return } + /// Load the image source and use that initializer to extract the metadata + let options: CFDictionary = [ + kCGImageSourceShouldCache: false, + kCGImageSourceShouldCacheImmediately: false + ] as CFDictionary + guard - let imageSource = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil), - let metadata: MediaMetadata = MediaMetadata(source: imageSource) + let fileSize: UInt64 = dependencies[singleton: .fileManager].fileSize(of: path), + let imageSource = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, options), + let metadata: MediaMetadata = MediaMetadata(source: imageSource, fileSize: fileSize) else { return nil } self = metadata } - - // MARK: - Functions - - public func apply(orientation: UIImage.Orientation) -> CGSize { - switch orientation { - case .up, .upMirrored, .down, .downMirrored: return pixelSize - case .leftMirrored, .left, .rightMirrored, .right: - return CGSize(width: pixelSize.height, height: pixelSize.width) - - @unknown default: return pixelSize - } - } } public static func isVideoOfValidContentTypeAndSize(path: String, type: String?, using dependencies: Dependencies) -> Bool { @@ -168,16 +370,16 @@ public enum MediaUtils { maxTrackSize.height = max(maxTrackSize.height, trackSize.height) } - return MediaMetadata(pixelSize: maxTrackSize).hasValidPixelSize + return MediaMetadata(pixelSize: maxTrackSize, hasUnsafeMetadata: false).hasValidPixelSize } /// Use `isValidVideo(asset: AVURLAsset)` if the `AVURLAsset` needs to be generated elsewhere in the code, /// otherwise this will be inefficient as it can create a temporary file for the `AVURLAsset` on old iOS versions - public static func isValidVideo(path: String, mimeType: String?, sourceFilename: String?, using dependencies: Dependencies) -> Bool { + public static func isValidVideo(path: String, utType: UTType?, sourceFilename: String?, using dependencies: Dependencies) -> Bool { guard let assetInfo: (asset: AVURLAsset, cleanup: () -> Void) = AVURLAsset.asset( for: path, - mimeType: mimeType, + utType: utType, sourceFilename: sourceFilename, using: dependencies ) @@ -189,83 +391,83 @@ public enum MediaUtils { return result } - public static func isValidImage(data: Data, type: UTType? = nil) -> Bool { - let options: CFDictionary = [ - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false - ] as CFDictionary - - guard - data.count < SNUtilitiesKit.maxFileSize, - let type: UTType = type, - (type.isImage || type.isAnimated), - let source: CGImageSource = CGImageSourceCreateWithData(data as CFData, options), - let metadata: MediaMetadata = MediaMetadata(source: source) - else { return false } - - return metadata.hasValidPixelSize - } - - public static func isValidImage(at path: String, type: UTType? = nil, using dependencies: Dependencies) -> Bool { - let options: CFDictionary = [ - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false - ] as CFDictionary - - guard - let type: UTType = type, - let fileSize: UInt64 = dependencies[singleton: .fileManager].fileSize(of: path), - fileSize <= SNUtilitiesKit.maxFileSize, - (type.isImage || type.isAnimated), - let source: CGImageSource = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, options), - let metadata: MediaMetadata = MediaMetadata(source: source) - else { return false } - - return metadata.hasValidPixelSize - } - public static func unrotatedSize( for path: String, - type: UTType?, - mimeType: String?, + utType: UTType?, sourceFilename: String?, using dependencies: Dependencies ) -> CGSize { guard let metadata: MediaMetadata = MediaMetadata( from: path, - type: type, - mimeType: mimeType, + utType: utType, sourceFilename: sourceFilename, using: dependencies ) else { return .zero } - /// If the metadata doesn't ahve an orientation then don't rotate the size (WebP and videos shouldn't have orientations) - guard let orientation: UIImage.Orientation = metadata.orientation else { return metadata.pixelSize } - - return metadata.apply(orientation: orientation) + return metadata.unrotatedSize } - public static func guessedImageFormat(data: Data) -> ImageFormat { - let twoBytesLength: Int = 2 - - guard data.count > twoBytesLength else { return .unknown } + private static func getFrameDuration(from source: CGImageSource, at index: Int) -> TimeInterval { + guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [String: Any] else { + return 0.1 + } + + /// Try to process it as a GIF + if let gifProps = properties[kCGImagePropertyGIFDictionary as String] as? [String: Any] { + if + let unclampedDelayTime = gifProps[kCGImagePropertyGIFUnclampedDelayTime as String] as? Double, + unclampedDelayTime > 0 + { + return unclampedDelayTime + } + + if + let delayTime = gifProps[kCGImagePropertyGIFDelayTime as String] as? Double, + delayTime > 0 + { + return delayTime + } + } - var bytes: [UInt8] = [UInt8](repeating: 0, count: twoBytesLength) - data.copyBytes(to: &bytes, from: (data.startIndex.. 0 + { + return delayTime + } + } - switch (bytes[0], bytes[1]) { - case (0x47, 0x49): return .gif - case (0x89, 0x50): return .png - case (0xff, 0xd8): return .jpeg - case (0x42, 0x4d): return .bmp - case (0x4D, 0x4D): return .tiff // Motorola byte order TIFF - case (0x49, 0x49): return .tiff // Intel byte order TIFF - case (0x52, 0x49): return .webp // First two letters of WebP - - default: return .unknown + /// Try to process it as a WebP + if + let webpProps = properties[kCGImagePropertyWebPDictionary as String] as? [String: Any], + let delayTime = webpProps[kCGImagePropertyWebPDelayTime as String] as? Double, + delayTime > 0 + { + return delayTime } + + return 0.1 /// Fallback + } +} + +// MARK: - Convenience + +public extension MediaUtils.MediaMetadata { + var isValidImage: Bool { + guard + let utType: UTType = utType, + (utType.isImage || utType.isAnimated) + else { return false } + + return ( + hasValidPixelSize && + hasValidFileSize && + hasValidDuration + ) } } diff --git a/SessionUtilitiesKit/Media/UTType+Utilities.swift b/SessionUtilitiesKit/Media/UTType+Utilities.swift index 2e3673c710..9cab6f3258 100644 --- a/SessionUtilitiesKit/Media/UTType+Utilities.swift +++ b/SessionUtilitiesKit/Media/UTType+Utilities.swift @@ -158,6 +158,17 @@ public extension UTType { self = result } + init?(imageData: Data) { + guard + let imageSource: CGImageSource = CGImageSourceCreateWithData(imageData as CFData, nil), + let typeString: String = CGImageSourceGetType(imageSource) as? String, + let result: UTType = UTType(typeString) + else { return nil } + + self = result + } + + // MARK: - Convenience static func isAnimated(_ mimeType: String) -> Bool { diff --git a/SessionUtilitiesKit/Types/FileManager.swift b/SessionUtilitiesKit/Types/FileManager.swift index 6c49165f86..c2f4eec063 100644 --- a/SessionUtilitiesKit/Types/FileManager.swift +++ b/SessionUtilitiesKit/Types/FileManager.swift @@ -29,7 +29,7 @@ public protocol FileManagerType { func protectFileOrFolder(at path: String, fileProtectionType: FileProtectionType) throws func fileSize(of path: String) -> UInt64? func temporaryFilePath(fileExtension: String?) -> String - func write(data: Data, toTemporaryFileWithExtension fileExtension: String?) throws -> String? + func write(data: Data, toTemporaryFileWithExtension fileExtension: String?) throws -> String // MARK: - Forwarded NSFileManager @@ -75,6 +75,14 @@ public extension FileManagerType { try protectFileOrFolder(at: path, fileProtectionType: .completeUntilFirstUserAuthentication) } + func temporaryFilePath() -> String { + return temporaryFilePath(fileExtension: nil) + } + + func write(dataToTemporaryFile data: Data) throws -> String { + return try write(data: data, toTemporaryFileWithExtension: nil) + } + func enumerator(at url: URL, includingPropertiesForKeys keys: [URLResourceKey]?) -> FileManager.DirectoryEnumerator? { return enumerator(at: url, includingPropertiesForKeys: keys, options: [], errorHandler: nil) } @@ -257,7 +265,7 @@ public class SessionFileManager: FileManagerType { .path } - public func write(data: Data, toTemporaryFileWithExtension fileExtension: String?) throws -> String? { + public func write(data: Data, toTemporaryFileWithExtension fileExtension: String?) throws -> String { let tempFilePath: String = temporaryFilePath(fileExtension: fileExtension) try data.write(to: URL(fileURLWithPath: tempFilePath), options: .atomic) diff --git a/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift b/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift index 962970910e..27fe205613 100644 --- a/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift @@ -1,16 +1,23 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. import AVFoundation +import UniformTypeIdentifiers public extension AVURLAsset { - static func asset(for path: String, mimeType: String?, sourceFilename: String?, using dependencies: Dependencies) -> (asset: AVURLAsset, cleanup: () -> Void)? { + static func asset(for path: String, utType: UTType?, sourceFilename: String?, using dependencies: Dependencies) -> (asset: AVURLAsset, cleanup: () -> Void)? { if #available(iOS 17.0, *) { /// Since `mimeType` can be null we need to try to resolve it to a value let finalMimeType: String - switch (mimeType, sourceFilename) { + switch (utType, sourceFilename) { case (.none, .none): return nil - case (.some(let mimeType), _): finalMimeType = mimeType + case (.some(let utType), _): + guard let mimeType: String = utType.sessionMimeType else { + return nil + } + + finalMimeType = mimeType + case (.none, .some(let sourceFilename)): guard let type: UTType = UTType( @@ -34,7 +41,7 @@ public extension AVURLAsset { /// Since `mimeType` and/or `sourceFilename` can be null we need to try to resolve them both to values let finalExtension: String - switch (mimeType, sourceFilename) { + switch (utType, sourceFilename) { case (.none, .none): return nil case (.none, .some(let sourceFilename)): guard @@ -46,16 +53,15 @@ public extension AVURLAsset { finalExtension = fileExtension - case (.some(let mimeType), let sourceFilename): - guard - let fileExtension: String = UTType(sessionMimeType: mimeType)? - .sessionFileExtension(sourceFilename: sourceFilename) - else { return nil } + case (.some(let utType), let sourceFilename): + guard let fileExtension: String = utType.sessionFileExtension(sourceFilename: sourceFilename) else { + return nil + } finalExtension = fileExtension } - let tmpPath: String = URL(fileURLWithPath: NSTemporaryDirectory()) + let tmpPath: String = URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) .appendingPathComponent(URL(fileURLWithPath: path).lastPathComponent) .appendingPathExtension(finalExtension) .path diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift index 179a49d32d..fb189e32a6 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift @@ -22,7 +22,7 @@ class AttachmentApprovalInputAccessoryView: UIView { return attachmentTextToolbar.inputView?.isFirstResponder ?? false } - private var currentAttachmentItem: SignalAttachmentItem? + private var currentAttachmentItem: PendingAttachmentRailItem? let kGalleryRailViewHeight: CGFloat = 72 @@ -95,7 +95,7 @@ class AttachmentApprovalInputAccessoryView: UIView { } } - public func update(currentAttachmentItem: SignalAttachmentItem?, shouldHideControls: Bool) { + public func update(currentAttachmentItem: PendingAttachmentRailItem?, shouldHideControls: Bool) { self.currentAttachmentItem = currentAttachmentItem self.shouldHideControls = shouldHideControls diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index 0332840e9c..3cb0de8e0c 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -21,7 +21,7 @@ private extension Log.Category { public protocol AttachmentApprovalViewControllerDelegate: AnyObject { func attachmentApproval( _ attachmentApproval: AttachmentApprovalViewController, - didApproveAttachments attachments: [SignalAttachment], + didApproveAttachments attachments: [PendingAttachment], forThreadId threadId: String, threadVariant: SessionThread.Variant, messageText: String? @@ -36,7 +36,7 @@ public protocol AttachmentApprovalViewControllerDelegate: AnyObject { func attachmentApproval( _ attachmentApproval: AttachmentApprovalViewController, - didRemoveAttachment attachment: SignalAttachment + didRemoveAttachment attachment: PendingAttachment ) func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) @@ -75,16 +75,17 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC var isKeyboardVisible: Bool = false private let disableLinkPreviewImageDownload: Bool + private let didLoadLinkPreview: ((LinkPreviewDraft) -> Void)? public weak var approvalDelegate: AttachmentApprovalViewControllerDelegate? - let attachmentItemCollection: AttachmentItemCollection + let attachmentRailItemCollection: PendingAttachmentRailItemCollection - var attachmentItems: [SignalAttachmentItem] { - return attachmentItemCollection.attachmentItems + var attachmentItems: [PendingAttachmentRailItem] { + return attachmentRailItemCollection.attachmentItems } - var attachments: [SignalAttachment] { + var attachments: [PendingAttachment] { return attachmentItems.map { (attachmentItem) in autoreleasepool { return self.processedAttachment(forAttachmentItem: attachmentItem) @@ -100,7 +101,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC return pageViewControllers?.first } - var currentItem: SignalAttachmentItem? { + var currentItem: PendingAttachmentRailItem? { get { return currentPageViewController?.attachmentItem } set { setCurrentItem(newValue, direction: .forward, animated: false) } } @@ -140,8 +141,9 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC mode: Mode, threadId: String, threadVariant: SessionThread.Variant, - attachments: [SignalAttachment], + attachments: [PendingAttachment], disableLinkPreviewImageDownload: Bool, + didLoadLinkPreview: ((LinkPreviewDraft) -> Void)?, using dependencies: Dependencies ) { guard !attachments.isEmpty else { return nil } @@ -150,11 +152,14 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC self.mode = mode self.threadId = threadId self.threadVariant = threadVariant - let attachmentItems = attachments.map { SignalAttachmentItem(attachment: $0, using: dependencies)} + let attachmentItems = attachments.map { + PendingAttachmentRailItem(attachment: $0, using: dependencies) + } self.isAddMoreVisible = (mode == .sharedNavigation) self.disableLinkPreviewImageDownload = disableLinkPreviewImageDownload + self.didLoadLinkPreview = didLoadLinkPreview - self.attachmentItemCollection = AttachmentItemCollection(attachmentItems: attachmentItems, isAddMoreVisible: isAddMoreVisible) + self.attachmentRailItemCollection = PendingAttachmentRailItemCollection(attachmentItems: attachmentItems, isAddMoreVisible: isAddMoreVisible) super.init( transitionStyle: .scroll, @@ -181,9 +186,10 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC public class func wrappedInNavController( threadId: String, threadVariant: SessionThread.Variant, - attachments: [SignalAttachment], + attachments: [PendingAttachment], approvalDelegate: AttachmentApprovalViewControllerDelegate, disableLinkPreviewImageDownload: Bool, + didLoadLinkPreview: ((LinkPreviewDraft) -> Void)?, using dependencies: Dependencies ) -> UINavigationController? { guard let vc = AttachmentApprovalViewController( @@ -192,6 +198,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC threadVariant: threadVariant, attachments: attachments, disableLinkPreviewImageDownload: disableLinkPreviewImageDownload, + didLoadLinkPreview: didLoadLinkPreview, using: dependencies ) else { return nil } vc.approvalDelegate = approvalDelegate @@ -249,8 +256,8 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC // If the first item is just text, or is a URL and LinkPreviews are disabled // then just fill the 'message' box with it - if firstItem.attachment.isText || (firstItem.attachment.isUrl && LinkPreview.previewUrl(for: firstItem.attachment.text(), using: dependencies) == nil) { - bottomToolView.attachmentTextToolbar.text = firstItem.attachment.text() + if firstItem.attachment.utType.isText || (firstItem.attachment.utType.conforms(to: .url) && LinkPreview.previewUrl(for: firstItem.attachment.toText(), using: dependencies) == nil) { + bottomToolView.attachmentTextToolbar.text = firstItem.attachment.toText() } } @@ -289,7 +296,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC if pageViewControllers?.count == 1 { currentPageViewController = pageViewControllers?.first } - let currentAttachmentItem: SignalAttachmentItem? = currentPageViewController?.attachmentItem + let currentAttachmentItem: PendingAttachmentRailItem? = currentPageViewController?.attachmentItem let hasPresentedView = (self.presentedViewController != nil) let isToolbarFirstResponder = bottomToolView.hasFirstResponder @@ -339,12 +346,12 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC // MARK: - View Helpers - func remove(attachmentItem: SignalAttachmentItem) { + func remove(attachmentItem: PendingAttachmentRailItem) { if attachmentItem.isEqual(to: currentItem) { - if let nextItem = attachmentItemCollection.itemAfter(item: attachmentItem) { + if let nextItem = attachmentRailItemCollection.itemAfter(item: attachmentItem) { setCurrentItem(nextItem, direction: .forward, animated: true) } - else if let prevItem = attachmentItemCollection.itemBefore(item: attachmentItem) { + else if let prevItem = attachmentRailItemCollection.itemBefore(item: attachmentItem) { setCurrentItem(prevItem, direction: .reverse, animated: true) } else { @@ -353,7 +360,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } } - self.attachmentItemCollection.remove(item: attachmentItem) + self.attachmentRailItemCollection.remove(item: attachmentItem) self.approvalDelegate?.attachmentApproval(self, didRemoveAttachment: attachmentItem.attachment) self.updateMediaRail() } @@ -434,7 +441,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } } - private func buildPage(item: SignalAttachmentItem) -> AttachmentPrepViewController? { + private func buildPage(item: PendingAttachmentRailItem) -> AttachmentPrepViewController? { if let cachedPage = cachedPages[item.uniqueIdentifier] { Log.debug(.cat, "Cache hit.") return cachedPage @@ -444,6 +451,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC let viewController = AttachmentPrepViewController( attachmentItem: item, disableLinkPreviewImageDownload: disableLinkPreviewImageDownload, + didLoadLinkPreview: didLoadLinkPreview, using: dependencies ) viewController.prepDelegate = self @@ -452,8 +460,8 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC return viewController } - private func setCurrentItem(_ item: SignalAttachmentItem?, direction: UIPageViewController.NavigationDirection, animated isAnimated: Bool) { - guard let item: SignalAttachmentItem = item, let page = self.buildPage(item: item) else { + private func setCurrentItem(_ item: PendingAttachmentRailItem?, direction: UIPageViewController.NavigationDirection, animated isAnimated: Bool) { + guard let item: PendingAttachmentRailItem = item, let page = self.buildPage(item: item) else { Log.error(.cat, "Unexpectedly unable to build new page") return } @@ -475,7 +483,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC case is AddMoreRailItem: return GalleryRailCellView() - case is SignalAttachmentItem: + case is PendingAttachmentRailItem: let cell = ApprovalRailCellView() cell.approvalRailCellDelegate = self return cell @@ -487,8 +495,8 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } galleryRailView.configureCellViews( - album: (attachmentItemCollection.attachmentItems as [GalleryRailItem]) - .appending(attachmentItemCollection.isAddMoreVisible ? + album: (attachmentRailItemCollection.attachmentItems as [GalleryRailItem]) + .appending(attachmentRailItemCollection.isAddMoreVisible ? AddMoreRailItem() : nil ), @@ -500,7 +508,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC if isAddMoreVisible { galleryRailView.isHidden = false } - else if attachmentItemCollection.attachmentItems.count > 1 { + else if attachmentRailItemCollection.attachmentItems.count > 1 { galleryRailView.isHidden = false } else { @@ -509,13 +517,13 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } // For any attachments edited with the image editor, returns a - // new SignalAttachment that reflects those changes. Otherwise, + // new PendingAttachment that reflects those changes. Otherwise, // returns the original attachment. // // If any errors occurs in the export process, we fail over to // sending the original attachment. This seems better than trying // to involve the user in resolving the issue. - func processedAttachment(forAttachmentItem attachmentItem: SignalAttachmentItem) -> SignalAttachment { + func processedAttachment(forAttachmentItem attachmentItem: PendingAttachmentRailItem) -> PendingAttachment { guard let imageEditorModel = attachmentItem.imageEditorModel else { // Image was not edited. return attachmentItem.attachment @@ -530,9 +538,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } var dataType: UTType = .image let maybeDstData: Data? = { - let isLossy: Bool = ( - attachmentItem.attachment.mimeType.caseInsensitiveCompare(UTType.mimeTypeJpeg) == .orderedSame - ) + let isLossy: Bool = (attachmentItem.attachment.utType == .jpeg) if isLossy { dataType = .jpeg @@ -548,8 +554,9 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC Log.error(.cat, "Could not export for output.") return attachmentItem.attachment } - guard let dataSource = DataSourceValue(data: dstData, dataType: dataType, using: dependencies) else { - Log.error(.cat, "Could not prepare data source for output.") + + guard let filePath: String = try? dependencies[singleton: .fileManager].write(dataToTemporaryFile: dstData) else { + Log.error(.cat, "Could not save output to disk.") return attachmentItem.attachment } @@ -557,22 +564,21 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC var filename: String? = attachmentItem.attachment.sourceFilename if let sourceFilename = attachmentItem.attachment.sourceFilename { if let fileExtension: String = dataType.sessionFileExtension(sourceFilename: sourceFilename) { - filename = (sourceFilename as NSString).deletingPathExtension.appendingFileExtension(fileExtension) + filename = ((sourceFilename as NSString) + .deletingPathExtension as NSString) + .appendingPathExtension(fileExtension) } } - dataSource.sourceFilename = filename - - let dstAttachment = SignalAttachment.attachment(dataSource: dataSource, type: dataType, imageQuality: .medium, using: dependencies) - if let attachmentError = dstAttachment.error { - Log.error(.cat, "Could not prepare attachment for output: \(attachmentError).") - return attachmentItem.attachment - } - // Preserve caption text. - dstAttachment.captionText = attachmentItem.captionText - return dstAttachment + + return PendingAttachment( + source: .media(URL(fileURLWithPath: filePath)), + utType: dataType, + sourceFilename: filename, + using: dependencies + ) } - func attachmentItem(before currentItem: SignalAttachmentItem) -> SignalAttachmentItem? { + func attachmentItem(before currentItem: PendingAttachmentRailItem) -> PendingAttachmentRailItem? { guard let currentIndex = attachmentItems.firstIndex(of: currentItem) else { Log.error(.cat, "currentIndex was unexpectedly nil") return nil @@ -587,7 +593,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC return previousItem } - func attachmentItem(after currentItem: SignalAttachmentItem) -> SignalAttachmentItem? { + func attachmentItem(after currentItem: PendingAttachmentRailItem) -> PendingAttachmentRailItem? { guard let currentIndex = attachmentItems.firstIndex(of: currentItem) else { Log.error(.cat, "currentIndex was unexpectedly nil") return nil @@ -756,46 +762,42 @@ extension AttachmentApprovalViewController: AttachmentPrepViewControllerDelegate // MARK: GalleryRail -extension SignalAttachmentItem: GalleryRailItem { +extension PendingAttachmentRailItem: GalleryRailItem { func buildRailItemView(using dependencies: Dependencies) -> UIView { let imageView: SessionImageView = SessionImageView(dataManager: dependencies[singleton: .imageDataManager]) imageView.contentMode = .scaleAspectFill imageView.themeBackgroundColor = .backgroundSecondary - if let path: String = (attachment.dataSource.dataPathIfOnDisk ?? attachment.dataUrl?.absoluteString) { - let source: ImageDataManager.DataSource = { - /// Can't thumbnail animated images so just load the full file in this case - if attachment.isAnimatedImage { - return .url(URL(fileURLWithPath: path)) - } - - /// Videos have a custom method for generating their thumbnails so use that instead - if attachment.isVideo { - return .videoUrl( - URL(fileURLWithPath: path), - attachment.mimeType, - attachment.sourceFilename, - dependencies[singleton: .attachmentManager] - ) + switch attachment.source { + case .file, .voiceMessage, .text: break; + case .displayPicture(let dataSource), .media(let dataSource): + Task.detached(priority: .userInitiated) { [attachment, attachmentManager = dependencies[singleton: .attachmentManager]] in + /// Can't thumbnail animated images so just load the full file in this case + if attachment.utType.isAnimated { + return await imageView.loadImage(dataSource) + } + + /// Videos have a custom method for generating their thumbnails so use that instead + if attachment.utType.isVideo { + return await imageView.loadImage(dataSource) + } + + /// We only support generating a thumbnail for a file that is on disk, so if the source isn't a `url` then just + /// load it directly + guard case .url(let url) = dataSource else { + return await imageView.loadImage(dataSource) + } + + /// Otherwise, generate the thumbnail + await imageView.loadImage(.urlThumbnail(url, .small, attachmentManager)) } - - return .urlThumbnail( - URL(fileURLWithPath: path), - .small, - dependencies[singleton: .attachmentManager] - ) - }() - - Task(priority: .userInitiated) { - await imageView.loadImage(source) - } } return imageView } func isEqual(to other: GalleryRailItem?) -> Bool { - guard let otherAttachmentItem: SignalAttachmentItem = other as? SignalAttachmentItem else { return false } + guard let otherAttachmentItem: PendingAttachmentRailItem = other as? PendingAttachmentRailItem else { return false } return (self.attachment == otherAttachmentItem.attachment) } @@ -810,12 +812,12 @@ extension AttachmentApprovalViewController: GalleryRailViewDelegate { return } - guard let targetItem = imageRailItem as? SignalAttachmentItem else { + guard let targetItem = imageRailItem as? PendingAttachmentRailItem else { Log.error(.cat, "Unexpected imageRailItem: \(imageRailItem)") return } - guard let currentItem: SignalAttachmentItem = currentItem, let currentIndex = attachmentItems.firstIndex(of: currentItem) else { + guard let currentItem: PendingAttachmentRailItem = currentItem, let currentIndex = attachmentItems.firstIndex(of: currentItem) else { Log.error(.cat, "currentIndex was unexpectedly nil") return } @@ -834,7 +836,7 @@ extension AttachmentApprovalViewController: GalleryRailViewDelegate { // MARK: - extension AttachmentApprovalViewController: ApprovalRailCellViewDelegate { - func approvalRailCellView(_ approvalRailCellView: ApprovalRailCellView, didRemoveItem attachmentItem: SignalAttachmentItem) { + func approvalRailCellView(_ approvalRailCellView: ApprovalRailCellView, didRemoveItem attachmentItem: PendingAttachmentRailItem) { remove(attachmentItem: attachmentItem) } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift index 893163ebcf..616ee47a91 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift @@ -1,6 +1,7 @@ // Copyright (c) 2019 Open Whisper Systems. All rights reserved. import UIKit +import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit @@ -24,54 +25,55 @@ class AddMoreRailItem: GalleryRailItem { } } -class SignalAttachmentItem: Equatable { +class PendingAttachmentRailItem: Equatable { - enum SignalAttachmentItemError: Error { + enum PendingAttachmentRailItemError: Error { case noThumbnail } let uniqueIdentifier: UUID = UUID() - let attachment: SignalAttachment + let attachment: PendingAttachment // This might be nil if the attachment is not a valid image. var imageEditorModel: ImageEditorModel? - init(attachment: SignalAttachment, using dependencies: Dependencies) { + init(attachment: PendingAttachment, using dependencies: Dependencies) { self.attachment = attachment // Try and make a ImageEditorModel. // This will only apply for valid images. - if ImageEditorModel.isFeatureEnabled, - let dataUrl: URL = attachment.dataUrl, - dataUrl.isFileURL { - let path = dataUrl.path + if + ImageEditorModel.isFeatureEnabled, + case .media(let mediaSource) = attachment.source, + case .url(let url) = mediaSource + { do { - imageEditorModel = try ImageEditorModel(srcImagePath: path, using: dependencies) + imageEditorModel = try ImageEditorModel(srcImagePath: url.absoluteString, using: dependencies) } catch { // Usually not an error; this usually indicates invalid input. - Log.warn("[SignalAttachmentItem] Could not create image editor: \(error)") + Log.warn("[PendingAttachmentRailItem] Could not create image editor: \(error)") } } } // MARK: Equatable - static func == (lhs: SignalAttachmentItem, rhs: SignalAttachmentItem) -> Bool { + static func == (lhs: PendingAttachmentRailItem, rhs: PendingAttachmentRailItem) -> Bool { return lhs.attachment == rhs.attachment } } // MARK: - -class AttachmentItemCollection { - private(set) var attachmentItems: [SignalAttachmentItem] +class PendingAttachmentRailItemCollection { + private(set) var attachmentItems: [PendingAttachmentRailItem] let isAddMoreVisible: Bool - init(attachmentItems: [SignalAttachmentItem], isAddMoreVisible: Bool) { + init(attachmentItems: [PendingAttachmentRailItem], isAddMoreVisible: Bool) { self.attachmentItems = attachmentItems self.isAddMoreVisible = isAddMoreVisible } - func itemAfter(item: SignalAttachmentItem) -> SignalAttachmentItem? { + func itemAfter(item: PendingAttachmentRailItem) -> PendingAttachmentRailItem? { guard let currentIndex = attachmentItems.firstIndex(of: item) else { Log.error("[AttachmentItemCollection] itemAfter currentIndex was unexpectedly nil.") return nil @@ -82,7 +84,7 @@ class AttachmentItemCollection { return attachmentItems[safe: nextIndex] } - func itemBefore(item: SignalAttachmentItem) -> SignalAttachmentItem? { + func itemBefore(item: PendingAttachmentRailItem) -> PendingAttachmentRailItem? { guard let currentIndex = attachmentItems.firstIndex(of: item) else { Log.error("[AttachmentItemCollection] itemBefore currentIndex was unexpectedly nil.") return nil @@ -93,7 +95,7 @@ class AttachmentItemCollection { return attachmentItems[safe: prevIndex] } - func remove(item: SignalAttachmentItem) { + func remove(item: PendingAttachmentRailItem) { attachmentItems = attachmentItems.filter { $0 != item } } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift index 0844c1ee20..6665d2354a 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift @@ -28,9 +28,10 @@ public class AttachmentPrepViewController: OWSViewController { private let dependencies: Dependencies weak var prepDelegate: AttachmentPrepViewControllerDelegate? - let attachmentItem: SignalAttachmentItem - var attachment: SignalAttachment { return attachmentItem.attachment } + let attachmentItem: PendingAttachmentRailItem + var attachment: PendingAttachment { return attachmentItem.attachment } private let disableLinkPreviewImageDownload: Bool + private let didLoadLinkPreview: ((LinkPreviewDraft) -> Void)? // MARK: - UI @@ -63,6 +64,7 @@ public class AttachmentPrepViewController: OWSViewController { attachment: attachment, mode: .attachmentApproval, disableLinkPreviewImageDownload: disableLinkPreviewImageDownload, + didLoadLinkPreview: didLoadLinkPreview, using: dependencies ) view.translatesAutoresizingMaskIntoConstraints = false @@ -101,19 +103,17 @@ public class AttachmentPrepViewController: OWSViewController { // MARK: - Initializers init( - attachmentItem: SignalAttachmentItem, + attachmentItem: PendingAttachmentRailItem, disableLinkPreviewImageDownload: Bool, + didLoadLinkPreview: ((LinkPreviewDraft) -> Void)?, using dependencies: Dependencies ) { self.dependencies = dependencies self.attachmentItem = attachmentItem self.disableLinkPreviewImageDownload = disableLinkPreviewImageDownload + self.didLoadLinkPreview = didLoadLinkPreview super.init(nibName: nil, bundle: nil) - - if attachment.hasError { - Log.error("[AttachmentPrepViewController] \(attachment.error.debugDescription)") - } } public required init?(coder aDecoder: NSCoder) { @@ -136,13 +136,13 @@ public class AttachmentPrepViewController: OWSViewController { let tapGesture = UITapGestureRecognizer(target: self, action: #selector(screenTapped)) mediaMessageView.addGestureRecognizer(tapGesture) - if attachment.isImage, let editorView: ImageEditorView = imageEditorView { + if attachment.utType.isImage, let editorView: ImageEditorView = imageEditorView { view.addSubview(editorView) imageEditorUpdateNavigationBar() } - if attachment.isVideo || attachment.isAudio { + if attachment.utType.isVideo || attachment.utType.isAudio { contentContainerView.addSubview(playButton) } @@ -200,8 +200,8 @@ public class AttachmentPrepViewController: OWSViewController { mediaMessageView.heightAnchor.constraint(equalTo: view.heightAnchor) ]) - if attachment.isImage, let editorView: ImageEditorView = imageEditorView { - let size: CGSize = (attachment.imageSize ?? CGSize.zero) + if attachment.utType.isImage, let editorView: ImageEditorView = imageEditorView { + let size: CGSize = (attachment.metadata?.pixelSize ?? CGSize.zero) let isPortrait: Bool = (size.height > size.width) NSLayoutConstraint.activate([ @@ -217,7 +217,7 @@ public class AttachmentPrepViewController: OWSViewController { ]) } - if attachment.isVideo || attachment.isAudio { + if attachment.utType.isVideo || attachment.utType.isAudio { let playButtonSize: CGFloat = Values.scaleFromIPhone5(70) NSLayoutConstraint.activate([ @@ -249,10 +249,13 @@ public class AttachmentPrepViewController: OWSViewController { } @objc public func playButtonTapped() { - guard let fileUrl: URL = attachment.dataUrl else { return Log.error(.media, "Missing video file") } + guard + case .media(let mediaSource) = self.attachment.source, + case .url(let fileUrl) = mediaSource + else { return Log.error(.media, "Missing video file") } - /// The `attachment` here is a `SignalAttachment` which is pointing to a file outside of the app (which would have a - /// proper file extension) so no need to create a temporary copy of the video, or clean it up by using our custom + /// The `attachment` here is a `PendingAttachment` which is pointing to a temporary file which has a proper file + /// extension) so no need to create a temporary copy of the video, or clean it up by using our custom /// `DismissCallbackAVPlayerViewController` callback logic let player: AVPlayer = AVPlayer(url: fileUrl) let viewController: AVPlayerViewController = AVPlayerViewController() @@ -266,7 +269,7 @@ public class AttachmentPrepViewController: OWSViewController { // MARK: - Helpers var isZoomable: Bool { - return attachment.isImage || attachment.isVideo + return attachment.utType.isImage || attachment.utType.isVideo } func zoomOut(animated: Bool) { diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift index 26204358e0..9cc5412d19 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift @@ -214,7 +214,7 @@ extension AttachmentTextToolbar: InputTextViewDelegate { delegate?.attachmentTextToolbarDidChange(self) } - func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage) { + func didPasteImageDataFromPasteboard(_ inputTextView: InputTextView, imageData: Data) { } } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift index 3fd75252e0..de9e0de6e0 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift @@ -608,8 +608,7 @@ public class ImageEditorCanvasView: UIView { let viewSize = dstSizePixels let hasAlpha: Bool = (MediaUtils.MediaMetadata( from: model.srcImagePath, - type: nil, - mimeType: nil, + utType: nil, sourceFilename: nil, using: dependencies )?.hasAlpha == true) diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift index 1e6e22dfc1..dd484b3e77 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift @@ -61,19 +61,18 @@ public class ImageEditorModel { let srcFileName = (srcImagePath as NSString).lastPathComponent let srcFileExtension = (srcFileName as NSString).pathExtension - guard let type: UTType = UTType(sessionFileExtension: srcFileExtension) else { + guard let utType: UTType = UTType(sessionFileExtension: srcFileExtension) else { Log.error("[ImageEditorModel] Couldn't determine UTType for file.") throw ImageEditorError.invalidInput } - guard type.isImage && !type.isAnimated else { - Log.error("[ImageEditorModel] Invalid MIME type: \(type.preferredMIMEType ?? "unknown").") + guard utType.isImage && !utType.isAnimated else { + Log.error("[ImageEditorModel] Invalid MIME type: \(utType.preferredMIMEType ?? "unknown").") throw ImageEditorError.invalidInput } let srcImageSizePixels = MediaUtils.unrotatedSize( for: srcImagePath, - type: type, - mimeType: nil, + utType: utType, sourceFilename: srcFileName, using: dependencies ) @@ -310,8 +309,7 @@ public class ImageEditorModel { } let hasAlpha: Bool = (MediaUtils.MediaMetadata( from: imagePath, - type: nil, - mimeType: nil, + utType: nil, sourceFilename: nil, using: dependencies )?.hasAlpha == true) diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift index 4feb892afb..91cc271a2f 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift @@ -20,9 +20,9 @@ public class MediaMessageView: UIView { private let dependencies: Dependencies private var disposables: Set = Set() public let mode: Mode - public let attachment: SignalAttachment + public let attachment: PendingAttachment private let disableLinkPreviewImageDownload: Bool - private lazy var duration: TimeInterval? = attachment.duration(using: dependencies) + private let didLoadLinkPreview: ((LinkPreviewDraft) -> Void)? private var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)? // MARK: Initializers @@ -35,20 +35,24 @@ public class MediaMessageView: UIView { // Currently we only use one mode (AttachmentApproval), so we could simplify this class, but it's kind // of nice that it's written in a flexible way in case we'd want to use it elsewhere again in the future. public required init( - attachment: SignalAttachment, + attachment: PendingAttachment, mode: MediaMessageView.Mode, disableLinkPreviewImageDownload: Bool, + didLoadLinkPreview: ((LinkPreviewDraft) -> Void)?, using dependencies: Dependencies ) { - if attachment.hasError { Log.error("[MediaMessageView] \(attachment.error.debugDescription)") } - self.dependencies = dependencies self.attachment = attachment self.mode = mode self.disableLinkPreviewImageDownload = disableLinkPreviewImageDownload + self.didLoadLinkPreview = didLoadLinkPreview // Set the linkPreviewUrl if it's a url - if attachment.isUrl, let linkPreviewURL: String = LinkPreview.previewUrl(for: attachment.text(), using: dependencies) { + if + attachment.utType.conforms(to: .url), + let attachmentText: String = attachment.toText(), + let linkPreviewURL: String = LinkPreview.previewUrl(for: attachmentText, using: dependencies) + { self.linkPreviewInfo = (url: linkPreviewURL, draft: nil) } @@ -109,53 +113,12 @@ public class MediaMessageView: UIView { view.themeTintColor = .textPrimary // Override the image to the correct one - if attachment.isImage || attachment.isAnimatedImage { - let maybeSource: ImageDataManager.DataSource? = { - guard attachment.isValidImage else { return nil } - - return ( - attachment.dataSource.dataPathIfOnDisk.map { .url(URL(fileURLWithPath: $0)) } ?? - attachment.dataSource.dataUrl.map { .url($0) } - ) - }() - - if let source: ImageDataManager.DataSource = maybeSource { - view.layer.minificationFilter = .trilinear - view.layer.magnificationFilter = .trilinear - view.loadImage(source) - } - else { - view.contentMode = .scaleAspectFit - view.image = UIImage(named: "FileLarge")?.withRenderingMode(.alwaysTemplate) - view.themeTintColor = .textPrimary - } - } - else if attachment.isVideo { - let maybeSource: ImageDataManager.DataSource? = { - guard attachment.isValidVideo else { return nil } - - return attachment.dataSource.dataUrl.map { url in - .videoUrl( - url, - attachment.mimeType, - attachment.sourceFilename, - dependencies[singleton: .attachmentManager] - ) - } - }() - - if let source: ImageDataManager.DataSource = maybeSource { - view.layer.minificationFilter = .trilinear - view.layer.magnificationFilter = .trilinear - view.loadImage(source) - } - else { - view.contentMode = .scaleAspectFit - view.image = UIImage(named: "FileLarge")?.withRenderingMode(.alwaysTemplate) - view.themeTintColor = .textPrimary - } + if attachment.isValidVisualMedia, let source: ImageDataManager.DataSource = attachment.visualMediaSource { + view.layer.minificationFilter = .trilinear + view.layer.magnificationFilter = .trilinear + view.loadImage(source) } - else if attachment.isUrl { + else if attachment.utType.conforms(to: .url) { view.clipsToBounds = true view.image = UIImage(named: "Link")?.withRenderingMode(.alwaysTemplate) view.themeTintColor = .messageBubble_outgoingText @@ -179,7 +142,7 @@ public class MediaMessageView: UIView { let stackView: UIStackView = UIStackView() stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .vertical - stackView.alignment = (attachment.isUrl && linkPreviewInfo?.url != nil ? .leading : .center) + stackView.alignment = (attachment.utType.conforms(to: .url) && linkPreviewInfo?.url != nil ? .leading : .center) stackView.distribution = .fill switch mode { @@ -211,7 +174,7 @@ public class MediaMessageView: UIView { } // Content - if attachment.isUrl { + if attachment.utType.conforms(to: .url) { // If we have no link preview info at this point then assume link previews are disabled if let linkPreviewURL: String = linkPreviewInfo?.url { label.font = .boldSystemFont(ofSize: Values.smallFontSize) @@ -225,7 +188,7 @@ public class MediaMessageView: UIView { } } // Title for everything except these types - else if !attachment.isImage && !attachment.isAnimatedImage && !attachment.isVideo { + else if !attachment.isValidVisualMedia { if let fileName: String = attachment.sourceFilename?.trimmingCharacters(in: .whitespacesAndNewlines), fileName.count > 0 { label.text = fileName } @@ -263,7 +226,7 @@ public class MediaMessageView: UIView { } // Content - if attachment.isUrl { + if attachment.utType.conforms(to: .url) { // We only load Link Previews for HTTPS urls so append an explanation for not if let linkPreviewURL: String = linkPreviewInfo?.url { let httpsScheme: String = "https" // stringlint:ignore @@ -288,10 +251,11 @@ public class MediaMessageView: UIView { } } // Subtitle for everything else except these types - else if !attachment.isImage && !attachment.isAnimatedImage && !attachment.isVideo { + else if !attachment.isValidVisualMedia { // Format string for file size label in call interstitial view. // Embeds: {{file size as 'N mb' or 'N kb'}}. - let fileSize: UInt = attachment.dataLength + let fileSize: UInt = UInt(attachment.fileSize) + let duration: TimeInterval? = (attachment.duration > 0 ? attachment.duration : nil) label.text = duration .map { "\(Format.fileSize(fileSize)), \(Format.duration($0))" } .defaulting(to: Format.fileSize(fileSize)) @@ -308,7 +272,7 @@ public class MediaMessageView: UIView { private func setupViews(using dependencies: Dependencies) { // Plain text will just be put in the 'message' input so do nothing - guard !attachment.isText else { return } + guard !attachment.utType.isText else { return } // Setup the view hierarchy addSubview(stackView) @@ -322,18 +286,17 @@ public class MediaMessageView: UIView { titleStackView.addArrangedSubview(subtitleLabel) imageView.alpha = 1 - imageView.set(.width, to: .width, of: stackView) imageView.addSubview(fileTypeImageView) // Type-specific configurations - if attachment.isAudio { + if attachment.utType.isAudio { // Hide the 'audioPlayPauseButton' if the 'audioPlayer' failed to get created fileTypeImageView.image = UIImage(named: "table_ic_notification_sound")? .withRenderingMode(.alwaysTemplate) fileTypeImageView.themeTintColor = .textPrimary fileTypeImageView.isHidden = false } - else if attachment.isUrl { + else if attachment.utType.conforms(to: .url) { imageView.alpha = 0 // Not 'isHidden' because we want it to take up space in the UIStackView loadingView.isHidden = false @@ -349,15 +312,18 @@ public class MediaMessageView: UIView { ) } } + else { + imageView.set(.width, to: .width, of: stackView) + } } private func setupLayout() { // Plain text will just be put in the 'message' input so do nothing - guard !attachment.isText else { return } + guard !attachment.utType.isText else { return } // Sizing calculations let clampedRatio: CGFloat = { - if attachment.isUrl { + if attachment.utType.conforms(to: .url) { return 1 } @@ -369,17 +335,17 @@ public class MediaMessageView: UIView { }() let maybeImageSize: CGFloat? = { - if attachment.isImage || attachment.isAnimatedImage { - guard attachment.isValidImage else { return nil } + if attachment.utType.isImage || attachment.utType.isAnimated { + guard attachment.isValidVisualMedia else { return nil } // If we don't have a valid image then use the 'generic' case } - else if attachment.isValidVideo { - guard attachment.isValidVideo else { return nil } + else if attachment.utType.isVideo { + guard attachment.isValidVisualMedia else { return nil } // If we don't have a valid image then use the 'generic' case } - else if attachment.isUrl { + else if attachment.utType.conforms(to: .url) { return 80 } @@ -402,7 +368,8 @@ public class MediaMessageView: UIView { (maybeImageSize != nil ? stackView.widthAnchor.constraint( equalTo: widthAnchor, - constant: (attachment.isUrl ? -(32 * 2) : 0) // Inset stackView for urls + // Inset stackView for urls + constant: (attachment.utType.conforms(to: .url) ? -(32 * 2) : 0) ) : stackView.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor) ), @@ -442,7 +409,7 @@ public class MediaMessageView: UIView { } // No inset for the text for URLs but there is for all other layouts - if !attachment.isUrl { + if !attachment.utType.conforms(to: .url) { NSLayoutConstraint.activate([ titleLabel.widthAnchor.constraint(equalTo: stackView.widthAnchor, constant: -(32 * 2)), subtitleLabel.widthAnchor.constraint(equalTo: stackView.widthAnchor, constant: -(32 * 2)) @@ -489,8 +456,7 @@ public class MediaMessageView: UIView { } }, receiveValue: { [weak self] draft in - // TODO: Look at refactoring this behaviour to consolidate attachment mutations - self?.attachment.linkPreviewDraft = draft + self?.didLoadLinkPreview?(draft) self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft) // Update the UI diff --git a/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift b/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift index 7b80e15e5b..acebd9da69 100644 --- a/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift +++ b/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift @@ -12,7 +12,7 @@ import SessionUtilitiesKit public class ModalActivityIndicatorViewController: OWSViewController { let canCancel: Bool let message: String? - private let onAppear: (ModalActivityIndicatorViewController) -> Void + private let onAppear: ((ModalActivityIndicatorViewController) -> Void)? private var hasAppeared: Bool = false public var wasCancelled: Bool = false @@ -87,7 +87,7 @@ public class ModalActivityIndicatorViewController: OWSViewController { @MainActor public required init( canCancel: Bool = false, message: String? = nil, - onAppear: @escaping (ModalActivityIndicatorViewController) -> Void + onAppear: ((ModalActivityIndicatorViewController) -> Void)? = nil ) { self.canCancel = canCancel self.message = message @@ -114,14 +114,7 @@ public class ModalActivityIndicatorViewController: OWSViewController { ) } - public func dismiss(completion: @escaping () -> Void) { - guard Thread.isMainThread else { - DispatchQueue.main.async { [weak self] in - self?.dismiss(completion: completion) - } - return - } - + @MainActor public func dismiss(completion: @escaping @MainActor () -> Void) { if !wasDimissed { // Only dismiss once. self.dismiss(animated: false, completion: completion) @@ -129,9 +122,7 @@ public class ModalActivityIndicatorViewController: OWSViewController { } else { // If already dismissed, wait a beat then call completion. - DispatchQueue.main.async { - completion() - } + completion() } } @@ -183,7 +174,7 @@ public class ModalActivityIndicatorViewController: OWSViewController { self.hasAppeared = true DispatchQueue.global().async { - self.onAppear(self) + self.onAppear?(self) } } } @@ -232,9 +223,11 @@ public extension Publisher { .flatMap { result -> AnyPublisher in Deferred { Future { resolver in - indicator.dismiss(completion: { - resolver(result) - }) + DispatchQueue.main.async { + indicator.dismiss(completion: { + resolver(result) + }) + } } }.eraseToAnyPublisher() } diff --git a/SignalUtilitiesKit/Shared Views/ApprovalRailCellView.swift b/SignalUtilitiesKit/Shared Views/ApprovalRailCellView.swift index 30fd976c21..69dc4d3ac9 100644 --- a/SignalUtilitiesKit/Shared Views/ApprovalRailCellView.swift +++ b/SignalUtilitiesKit/Shared Views/ApprovalRailCellView.swift @@ -5,7 +5,7 @@ import SessionUIKit import SessionUtilitiesKit protocol ApprovalRailCellViewDelegate: AnyObject { - func approvalRailCellView(_ approvalRailCellView: ApprovalRailCellView, didRemoveItem attachmentItem: SignalAttachmentItem) + func approvalRailCellView(_ approvalRailCellView: ApprovalRailCellView, didRemoveItem attachmentItem: PendingAttachmentRailItem) func canRemoveApprovalRailCellView(_ approvalRailCellView: ApprovalRailCellView) -> Bool } @@ -17,14 +17,14 @@ public class ApprovalRailCellView: GalleryRailCellView { lazy var deleteButton: UIButton = { let button = OWSButton { [weak self] in - guard let strongSelf = self else { return } + guard let self = self else { return } - guard let attachmentItem = strongSelf.item as? SignalAttachmentItem else { + guard let attachmentItem = item as? PendingAttachmentRailItem else { Log.error("[ApprovalRailCellView] attachmentItem was unexpectedly nil") return } - strongSelf.approvalRailCellDelegate?.approvalRailCellView(strongSelf, didRemoveItem: attachmentItem) + self.approvalRailCellDelegate?.approvalRailCellView(self, didRemoveItem: attachmentItem) } button.setImage(UIImage(named: "x-24")?.withRenderingMode(.alwaysTemplate), for: .normal) @@ -39,18 +39,6 @@ public class ApprovalRailCellView: GalleryRailCellView { return button }() - lazy var captionIndicator: UIView = { - let image = UIImage(named: "image_editor_caption")?.withRenderingMode(.alwaysTemplate) - let imageView = UIImageView(image: image) - imageView.themeTintColor = .white - imageView.themeShadowColor = .black - imageView.layer.shadowRadius = 2 - imageView.layer.shadowOpacity = 0.66 - imageView.layer.shadowOffset = .zero - - return imageView - }() - override func setIsSelected(_ isSelected: Bool) { super.setIsSelected(isSelected) @@ -66,26 +54,4 @@ public class ApprovalRailCellView: GalleryRailCellView { deleteButton.removeFromSuperview() } } - - override func configure(item: GalleryRailItem, delegate: GalleryRailCellViewDelegate, using dependencies: Dependencies) { - super.configure(item: item, delegate: delegate, using: dependencies) - - var hasCaption = false - if let attachmentItem = item as? SignalAttachmentItem { - if let captionText = attachmentItem.captionText { - hasCaption = captionText.count > 0 - } - } else { - Log.error("[ApprovalRailCellView] Invalid item") - } - - if hasCaption { - addSubview(captionIndicator) - - captionIndicator.pin(.top, to: .top, of: self, withInset: cellBorderWidth) - captionIndicator.pin(.leading, to: .leading, of: self, withInset: cellBorderWidth + 4) - } else { - captionIndicator.removeFromSuperview() - } - } } diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index 1a3f2f7558..a79cf88c3f 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -2,6 +2,7 @@ import Foundation import GRDB +import SDWebImageWebPCoder import SessionUIKit import SessionNetworkingKit import SessionMessagingKit @@ -31,6 +32,9 @@ public enum AppSetup { at: NSTemporaryDirectory(), fileProtectionType: .completeUntilFirstUserAuthentication ) + + // Need to register the WebP coder for encoding purposes + SDImageCodersManager.shared.addCoder(SDImageWebPCoder.shared) SessionEnvironment.shared = SessionEnvironment( audioSession: OWSAudioSession(), From b08bef049c1d17fa7a9e4fecc00127d11dcbfd93 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Fri, 3 Oct 2025 15:59:03 +0800 Subject: [PATCH 057/162] Added option to select a minimum version for depecation warning banner --- Session/Home/HomeViewModel.swift | 16 +++--- .../DeveloperSettingsViewModel.swift | 49 ++++++++++++++++++- SessionUtilitiesKit/General/Feature.swift | 5 ++ 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 7b863f7eab..d42aaeee05 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -37,9 +37,9 @@ public class HomeViewModel: NavigatableStateHolder { // Reusable OS version check for initial and updated state check // Check if the current device is running a version LESS THAN iOS 16.0 - private static var isOSVersionDeprecated: Bool { - guard #unavailable(iOS 16.0) else { return false } - return true + private static func isOSVersionDeprecated(using dependencies: Dependencies) -> Bool { + let systemVersion = ProcessInfo.processInfo.operatingSystemVersion + return systemVersion.majorVersion < dependencies[feature: .versionDeprecationMinimum] } public let dependencies: Dependencies @@ -61,7 +61,7 @@ public class HomeViewModel: NavigatableStateHolder { .loadInitialAppReviewPromptState(using: dependencies), appWasInstalledPriorToAppReviewRelease: AppReviewPromptModel .checkIfAppWasInstalledPriorToAppReviewRelease(using: dependencies), - showVersionSupportBanner: Self.isOSVersionDeprecated && dependencies[feature: .versionDeprecationWarning] + showVersionSupportBanner: Self.isOSVersionDeprecated(using: dependencies) && dependencies[feature: .versionDeprecationWarning] ) /// Bind the state @@ -134,7 +134,8 @@ public class HomeViewModel: NavigatableStateHolder { .userDefault(.hasPressedDonateButton), .userDefault(.hasChangedTheme), .updateScreen(HomeViewModel.self), - .feature(.versionDeprecationWarning) + .feature(.versionDeprecationWarning), + .feature(.versionDeprecationMinimum) ] itemCache.values.forEach { threadViewModel in @@ -413,7 +414,10 @@ public class HomeViewModel: NavigatableStateHolder { forceOffline = updatedValue } else if event.key == .feature(.versionDeprecationWarning), let updatedValue = event.value as? Bool { - showVersionSupportBanner = isOSVersionDeprecated && updatedValue + showVersionSupportBanner = isOSVersionDeprecated(using: dependencies) && updatedValue + } + else if event.key == .feature(.versionDeprecationMinimum) { + showVersionSupportBanner = isOSVersionDeprecated(using: dependencies) && dependencies[feature: .versionDeprecationWarning] } } diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift index 41d23b7fad..9ddb4a27ed 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift @@ -82,6 +82,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case resetAppReviewPrompt case simulateAppReviewLimit case versionDeprecationWarning + case versionDeprecationMinimum case defaultLogLevel case advancedLogging @@ -124,6 +125,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .resetAppReviewPrompt: return "resetAppReviewPrompt" case .simulateAppReviewLimit: return "simulateAppReviewLimit" case .versionDeprecationWarning: return "versionDeprecationWarning" + case .versionDeprecationMinimum: return "versionDeprecationMinimum" case .defaultLogLevel: return "defaultLogLevel" case .advancedLogging: return "advancedLogging" @@ -168,6 +170,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .resetAppReviewPrompt: result.append(.resetAppReviewPrompt); fallthrough case .simulateAppReviewLimit: result.append(.simulateAppReviewLimit); fallthrough case .versionDeprecationWarning: result.append(.versionDeprecationWarning); fallthrough + case .versionDeprecationMinimum: result.append(.versionDeprecationMinimum); fallthrough case .defaultLogLevel: result.append(.defaultLogLevel); fallthrough case .advancedLogging: result.append(.advancedLogging); fallthrough @@ -222,6 +225,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, let updateSimulateAppReviewLimit: Bool let versionDeprecationWarning: Bool + let versionDeprecationMinimum: Int } let title: String = "Developer Settings" @@ -266,7 +270,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, updateSimulateAppReviewLimit: dependencies[feature: .simulateAppReviewLimit], - versionDeprecationWarning: dependencies[feature: .versionDeprecationWarning] + versionDeprecationWarning: dependencies[feature: .versionDeprecationWarning], + versionDeprecationMinimum: dependencies[feature: .versionDeprecationMinimum] ) } .compactMapWithPrevious { [weak self] prev, current -> [SectionModel]? in self?.content(prev, current) } @@ -465,6 +470,35 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, to: !current.versionDeprecationWarning ) } + ), + SessionCell.Info( + id: .versionDeprecationMinimum, + title: "Version Deprecation Minimum Version", + subtitle: """ + The minimum version allowed before showing version deprecation warning. + """, + trailingAccessory: .dropDown { "iOS \(current.versionDeprecationMinimum)" }, + onTap: { [weak self, dependencies] in + self?.transitionToScreen( + SessionTableViewController( + viewModel: SessionListViewModel( + title: "Minimum iOS Version", + options: [ + WarningVersion(version: 16), + WarningVersion(version: 17), + WarningVersion(version: 18) + ], + behaviour: .autoDismiss( + initialSelection: WarningVersion(version: current.versionDeprecationMinimum), + onOptionSelected: { [weak self] selected in + dependencies.set(feature: .versionDeprecationMinimum, to: selected.version) + } + ), + using: dependencies + ) + ) + ) + } ) ] ) @@ -840,7 +874,11 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, guard dependencies.hasSet(feature: .versionDeprecationWarning) else { return } updateFlag(for: .versionDeprecationWarning, to: nil) - + case .versionDeprecationMinimum: + guard dependencies.hasSet(feature: .versionDeprecationMinimum) else { return } + + dependencies.set(feature: .versionDeprecationMinimum, to: nil) + case .communityPollLimit: guard dependencies.hasSet(feature: .communityPollLimit) else { return } @@ -1799,6 +1837,13 @@ final class PollLimitInputView: UIView, UITextFieldDelegate, SessionCell.Accesso } } +// MARK: - WarningVersion +struct WarningVersion: Listable { + var version: Int + + var id: String { "\(version)" } + var title: String { "iOS \(version)" } +} // MARK: - Listable Conformance diff --git a/SessionUtilitiesKit/General/Feature.swift b/SessionUtilitiesKit/General/Feature.swift index f42c7537ea..f5c3b988f4 100644 --- a/SessionUtilitiesKit/General/Feature.swift +++ b/SessionUtilitiesKit/General/Feature.swift @@ -101,6 +101,11 @@ public extension FeatureStorage { static let versionDeprecationWarning: FeatureConfig = Dependencies.create( identifier: "versionDeprecationWarning" ) + + static let versionDeprecationMinimum: FeatureConfig = Dependencies.create( + identifier: "versionDeprecationMinimum", + defaultOption: 16 + ) } // MARK: - FeatureOption From 7eda20389378546a5110be36ff60b767a83e29a4 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 6 Oct 2025 12:14:21 +1100 Subject: [PATCH 058/162] refactor on session pro badge image cache --- Session.xcodeproj/project.pbxproj | 8 ++--- .../Settings/ThreadSettingsViewModel.swift | 2 +- Session/Settings/SettingsViewModel.swift | 8 +++-- .../Views/SessionProBadge+Utilities.swift | 34 +++++++++++++++++-- SessionUIKit/Components/SessionProBadge.swift | 21 ------------ .../Components/SwiftUI/ProCTAModal.swift | 4 +-- SessionUtilitiesKit/General/General.swift | 32 +++++++++++++++++ 7 files changed, 75 insertions(+), 34 deletions(-) rename SessionUIKit/Utilities/String+SessionProBadge.swift => Session/Shared/Views/SessionProBadge+Utilities.swift (54%) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index d9349a3313..27bc26d57a 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -193,7 +193,6 @@ 947D7FE92D51837200E8E413 /* Text+CopyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947D7FE62D51837200E8E413 /* Text+CopyButton.swift */; }; 9499E6032DDD9BF900091434 /* ExpandableLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9499E6022DDD9BEE00091434 /* ExpandableLabel.swift */; }; 9499E68B2DF92F4E00091434 /* ThreadNotificationSettingsViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9499E68A2DF92F3B00091434 /* ThreadNotificationSettingsViewModelSpec.swift */; }; - 949D91222E822D5A0074F595 /* String+SessionProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 949D91212E822D520074F595 /* String+SessionProBadge.swift */; }; 94A6B9DB2DD6BF7C00DB4B44 /* Constants+Apple.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A6B9DA2DD6BF6E00DB4B44 /* Constants+Apple.swift */; }; 94AAB14B2E1E198200A6FA18 /* Modal+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94AAB14A2E1E197800A6FA18 /* Modal+SwiftUI.swift */; }; 94AAB14D2E1F39B500A6FA18 /* ProCTAModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94AAB14C2E1F39AB00A6FA18 /* ProCTAModal.swift */; }; @@ -226,6 +225,7 @@ 94CD96452E1BAC0F0097754D /* HigherCharLimitCTA.webp in Resources */ = {isa = PBXBuildFile; fileRef = 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */; }; 94D716802E8F6363008294EE /* HighlightMentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */; }; 94D716822E8FA1A0008294EE /* AttributedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716812E8FA19D008294EE /* AttributedLabel.swift */; }; + 94D716862E933958008294EE /* SessionProBadge+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716852E93394B008294EE /* SessionProBadge+Utilities.swift */; }; 94E9BC0D2C7BFBDA006984EA /* Localization+Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */; }; A11CD70D17FA230600A2D1B1 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; A163E8AB16F3F6AA0094D68B /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A163E8AA16F3F6A90094D68B /* Security.framework */; }; @@ -1589,7 +1589,6 @@ 947D7FE62D51837200E8E413 /* Text+CopyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Text+CopyButton.swift"; sourceTree = ""; }; 9499E6022DDD9BEE00091434 /* ExpandableLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandableLabel.swift; sourceTree = ""; }; 9499E68A2DF92F3B00091434 /* ThreadNotificationSettingsViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadNotificationSettingsViewModelSpec.swift; sourceTree = ""; }; - 949D91212E822D520074F595 /* String+SessionProBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+SessionProBadge.swift"; sourceTree = ""; }; 94A6B9DA2DD6BF6E00DB4B44 /* Constants+Apple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Constants+Apple.swift"; sourceTree = ""; }; 94AAB14A2E1E197800A6FA18 /* Modal+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Modal+SwiftUI.swift"; sourceTree = ""; }; 94AAB14C2E1F39AB00A6FA18 /* ProCTAModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProCTAModal.swift; sourceTree = ""; }; @@ -1617,6 +1616,7 @@ 94CD963E2E1BABE90097754D /* HigherCharLimitCTA.webp */ = {isa = PBXFileReference; lastKnownFileType = file; path = HigherCharLimitCTA.webp; sourceTree = ""; }; 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightMentionView.swift; sourceTree = ""; }; 94D716812E8FA19D008294EE /* AttributedLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedLabel.swift; sourceTree = ""; }; + 94D716852E93394B008294EE /* SessionProBadge+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProBadge+Utilities.swift"; sourceTree = ""; }; 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Localization+Style.swift"; sourceTree = ""; }; A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; A163E8AA16F3F6A90094D68B /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; @@ -3365,7 +3365,6 @@ isa = PBXGroup; children = ( 94B6BB012E3AE85800E718BB /* QRCode.swift */, - 949D91212E822D520074F595 /* String+SessionProBadge.swift */, 94CD962F2E1B88430097754D /* CGRect+Utilities.swift */, FD848B9728422F1A000E298B /* Date+Utilities.swift */, FD8A5B282DC060DD004C689B /* Double+Utilities.swift */, @@ -4664,6 +4663,7 @@ FD71164028E2C83000B47552 /* Views */ = { isa = PBXGroup; children = ( + 94D716852E93394B008294EE /* SessionProBadge+Utilities.swift */, 942256A02C23F90700C0FDBF /* CustomTopTabBar.swift */, FD7115EF28C5D7DE00B47552 /* SessionHeaderView.swift */, FD37EA0A28AB12E2003AE748 /* SessionCell.swift */, @@ -6318,7 +6318,6 @@ FD8A5B1E2DBF4BBC004C689B /* ScreenLock+Errors.swift in Sources */, 942BA9412E4487F7007C4595 /* LightBox.swift in Sources */, 942BA9C12E4EA5CB007C4595 /* SessionLabelWithProBadge.swift in Sources */, - 949D91222E822D5A0074F595 /* String+SessionProBadge.swift in Sources */, FD2272E62C351378004D8A6C /* SUIKImageFormat.swift in Sources */, 7BF8D1FB2A70AF57005F1D6E /* SwiftUI+Theme.swift in Sources */, FDB11A612DD5BDCC00BEF49F /* ImageDataManager.swift in Sources */, @@ -7096,6 +7095,7 @@ 4521C3C01F59F3BA00B4C582 /* TextFieldHelper.swift in Sources */, FD860CBA2D66BF2A00BBE29C /* AppIconGridView.swift in Sources */, FD37E9D128A1F2EB003AE748 /* ThemeSelectionView.swift in Sources */, + 94D716862E933958008294EE /* SessionProBadge+Utilities.swift in Sources */, FD39352C28F382920084DADA /* VersionFooterView.swift in Sources */, FD12A83F2AD63BDF00EEBA0D /* Navigatable.swift in Sources */, 7B9F71D22852EEE2006DFE7B /* Emoji+SkinTones.swift in Sources */, diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 0ac95a1262..4fca374e7a 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -304,7 +304,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi alignment: .center, trailingImage: ( (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }) ? - ("ProBadge", SessionProBadge(size: .medium).toImage()) : + ("ProBadge", SessionProBadge(size: .medium).toImage(using: dependencies)) : nil ) ), diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 276b0ae9f9..1593671a3f 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -321,7 +321,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl font: .titleLarge, alignment: .center, trailingImage: (state.isSessionPro ? - ("ProBadge", SessionProBadge(size: .medium).toImage()) : + ("ProBadge", SessionProBadge(size: .medium).toImage(using: viewModel.dependencies)) : nil ) ), @@ -714,7 +714,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl at: .leading, font: .systemFont(ofSize: Values.smallFontSize), textColor: .textSecondary, - proBadgeSize: .small + proBadgeSize: .small, + using: dependencies ): "proAnimatedDisplayPicturesNonProModalDescription" .localized() @@ -722,7 +723,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl at: .trailing, font: .systemFont(ofSize: Values.smallFontSize), textColor: .textSecondary, - proBadgeSize: .small + proBadgeSize: .small, + using: dependencies ) }(), accessibility: Accessibility( diff --git a/SessionUIKit/Utilities/String+SessionProBadge.swift b/Session/Shared/Views/SessionProBadge+Utilities.swift similarity index 54% rename from SessionUIKit/Utilities/String+SessionProBadge.swift rename to Session/Shared/Views/SessionProBadge+Utilities.swift index 15f36582cc..f158565e19 100644 --- a/SessionUIKit/Utilities/String+SessionProBadge.swift +++ b/Session/Shared/Views/SessionProBadge+Utilities.swift @@ -1,6 +1,35 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. import UIKit +import SessionUIKit +import SessionUtilitiesKit + +public extension SessionProBadge.Size{ + // stringlint:ignore_contents + var cacheKey: String { + switch self { + case .mini: return "SessionProBadge.Mini" + case .small: return "SessionProBadge.Small" + case .medium: return "SessionProBadge.Medium" + case .large: return "SessionProBadge.Large" + } + } +} + +public extension SessionProBadge { + func toImage(using dependencies: Dependencies) -> UIImage { + let themePrimaryColor = dependencies.mutate(cache: .libSession) { libSession -> Theme.PrimaryColor? in libSession.get(.themePrimaryColor)} + let cacheKey: String = self.size.cacheKey + ".\(themePrimaryColor.defaulting(to: .defaultPrimaryColor))" + + if let cachedImage = dependencies[cache: .generalUI].get(for: cacheKey) { + return cachedImage + } + + let renderedImage = self.toImage(isOpaque: self.isOpaque, scale: UIScreen.main.scale) + dependencies.mutate(cache: .generalUI) { $0.cache(renderedImage, for: cacheKey) } + return renderedImage + } +} public extension String { enum SessionProBadgePosition { @@ -12,9 +41,10 @@ public extension String { font: UIFont, textColor: ThemeValue = .textPrimary, proBadgeSize: SessionProBadge.Size, - spacing: String = " " + spacing: String = " ", + using dependencies: Dependencies ) -> NSMutableAttributedString { - let image: UIImage = SessionProBadge(size: proBadgeSize).toImage() + let image: UIImage = SessionProBadge(size: proBadgeSize).toImage(using: dependencies) let base = NSMutableAttributedString() let attachment = NSTextAttachment() attachment.image = image diff --git a/SessionUIKit/Components/SessionProBadge.swift b/SessionUIKit/Components/SessionProBadge.swift index 0aaf9bdf51..47bd9141d8 100644 --- a/SessionUIKit/Components/SessionProBadge.swift +++ b/SessionUIKit/Components/SessionProBadge.swift @@ -3,8 +3,6 @@ import UIKit public class SessionProBadge: UIView { - static let cache: NSCache = NSCache() - public enum Size { case mini, small, medium, large @@ -48,16 +46,6 @@ public class SessionProBadge: UIView { case .large: return 40 } } - - // stringlint:ignore_contents - var cacheKey: NSString { - switch self { - case .mini: return "SessionProBadge.Mini" - case .small: return "SessionProBadge.Small" - case .medium: return "SessionProBadge.Medium" - case .large: return "SessionProBadge.Large" - } - } } public var size: Size { @@ -111,12 +99,6 @@ public class SessionProBadge: UIView { self.layer.cornerRadius = self.size.cornerRadius widthConstraint = self.set(.width, to: self.size.width) heightConstraint = self.set(.height, to: self.size.height) - } - - public func toImage() -> UIImage { - if let cachedImage = SessionProBadge.cache.object(forKey: self.size.cacheKey) { - return cachedImage - } self.proImageView.frame = CGRect( x: (size.width - size.proFontWidth) / 2, @@ -124,8 +106,5 @@ public class SessionProBadge: UIView { width: size.proFontWidth, height: size.proFontHeight ) - let renderedImage = self.toImage(isOpaque: self.isOpaque, scale: UIScreen.main.scale) - SessionProBadge.cache.setObject(renderedImage, forKey: self.size.cacheKey) - return renderedImage } } diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index 278d090900..165a04b5be 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -272,9 +272,7 @@ public struct ProCTAModal: View { case .groupLimit(_, let isSessionProActivated) = variant, isSessionProActivated { - let proBadgeImage: UIImage = SessionProBadge(size: .small).toImage() - - (Text(variant.subtitle) + Text(" \(Image(uiImage: proBadgeImage))").baselineOffset(-2)) + Text(variant.subtitle) .font(.Body.largeRegular) .foregroundColor(themeColor: .textSecondary) .multilineTextAlignment(.center) diff --git a/SessionUtilitiesKit/General/General.swift b/SessionUtilitiesKit/General/General.swift index 1c7f764c4e..cef2c6f3fb 100644 --- a/SessionUtilitiesKit/General/General.swift +++ b/SessionUtilitiesKit/General/General.swift @@ -14,6 +14,15 @@ public extension Cache { ) } +public extension Cache { + static let generalUI: CacheConfig = Dependencies.create( + identifier: "generalUI", + createInstance: { dependencies in General.UICache() }, + mutableInstance: { $0 }, + immutableInstance: { $0 } + ) +} + // MARK: - General.Cache public enum General { @@ -58,6 +67,18 @@ public enum General { self.ed25519SecretKey = ed25519SecretKey } } + + public class UICache: GeneralUICacheType { + private let cache: NSCache = NSCache() + + public func cache(_ image: UIImage, for key: String) { + cache.setObject(image, forKey: key as NSString) + } + + public func get(for key: String) -> UIImage? { + return cache.object(forKey: key as NSString) + } + } } // MARK: - GeneralCacheType @@ -82,3 +103,14 @@ public protocol GeneralCacheType: ImmutableGeneralCacheType, MutableCacheType { func setSecretKey(ed25519SecretKey: [UInt8]) } + +// MARK: Cache.GeneralUI + +/// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way +public protocol ImmutableGeneralUICacheType: ImmutableCacheType { + func get(for key: String) -> UIImage? +} + +public protocol GeneralUICacheType: ImmutableGeneralUICacheType, MutableCacheType { + func cache(_ image: UIImage, for key: String) +} From 1cc029df3b2977c184de3faa24c7b5f313fb12dd Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 6 Oct 2025 15:26:04 +1100 Subject: [PATCH 059/162] feat: cache for @You image rendering --- Session.xcodeproj/project.pbxproj | 4 + .../Views/SessionProBadge+Utilities.swift | 2 +- .../MentionUtilities+Attributes.swift | 98 +++++++++++++++++++ .../MentionUtilities+DisplayName.swift | 3 +- .../Components/HighlightMentionView.swift | 14 +-- SessionUIKit/Utilities/MentionUtilities.swift | 82 +--------------- 6 files changed, 112 insertions(+), 91 deletions(-) create mode 100644 Session/Utilities/MentionUtilities+Attributes.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 27bc26d57a..d3ee4be193 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -226,6 +226,7 @@ 94D716802E8F6363008294EE /* HighlightMentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */; }; 94D716822E8FA1A0008294EE /* AttributedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716812E8FA19D008294EE /* AttributedLabel.swift */; }; 94D716862E933958008294EE /* SessionProBadge+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716852E93394B008294EE /* SessionProBadge+Utilities.swift */; }; + 94D716912E9379BA008294EE /* MentionUtilities+Attributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D716902E9379A4008294EE /* MentionUtilities+Attributes.swift */; }; 94E9BC0D2C7BFBDA006984EA /* Localization+Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */; }; A11CD70D17FA230600A2D1B1 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; A163E8AB16F3F6AA0094D68B /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A163E8AA16F3F6A90094D68B /* Security.framework */; }; @@ -1617,6 +1618,7 @@ 94D7167F2E8F6362008294EE /* HighlightMentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightMentionView.swift; sourceTree = ""; }; 94D716812E8FA19D008294EE /* AttributedLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedLabel.swift; sourceTree = ""; }; 94D716852E93394B008294EE /* SessionProBadge+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionProBadge+Utilities.swift"; sourceTree = ""; }; + 94D716902E9379A4008294EE /* MentionUtilities+Attributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MentionUtilities+Attributes.swift"; sourceTree = ""; }; 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Localization+Style.swift"; sourceTree = ""; }; A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; A163E8AA16F3F6A90094D68B /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; @@ -2739,6 +2741,7 @@ 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */, FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */, FDE521932E050B0800061B8E /* DismissCallbackAVPlayerViewController.swift */, + 94D716902E9379A4008294EE /* MentionUtilities+Attributes.swift */, FD981BD42DC978AC00564172 /* MentionUtilities+DisplayName.swift */, 45C0DC1A1E68FE9000E04C47 /* UIApplication+OWS.swift */, 45C0DC1D1E69011F00E04C47 /* UIStoryboard+OWS.swift */, @@ -7136,6 +7139,7 @@ FED288F82E4C3BE100C31171 /* AppReviewPromptModel.swift in Sources */, FDE754B12C9B96B4002A2623 /* TurnServerInfo.swift in Sources */, 7BD687D12A5D0D1200D8E455 /* MessageInfoScreen.swift in Sources */, + 94D716912E9379BA008294EE /* MentionUtilities+Attributes.swift in Sources */, B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */, FD71162E28E168C700B47552 /* SettingsViewModel.swift in Sources */, ); diff --git a/Session/Shared/Views/SessionProBadge+Utilities.swift b/Session/Shared/Views/SessionProBadge+Utilities.swift index f158565e19..2f6cc8fbfd 100644 --- a/Session/Shared/Views/SessionProBadge+Utilities.swift +++ b/Session/Shared/Views/SessionProBadge+Utilities.swift @@ -19,7 +19,7 @@ public extension SessionProBadge.Size{ public extension SessionProBadge { func toImage(using dependencies: Dependencies) -> UIImage { let themePrimaryColor = dependencies.mutate(cache: .libSession) { libSession -> Theme.PrimaryColor? in libSession.get(.themePrimaryColor)} - let cacheKey: String = self.size.cacheKey + ".\(themePrimaryColor.defaulting(to: .defaultPrimaryColor))" + let cacheKey: String = self.size.cacheKey + ".\(themePrimaryColor.defaulting(to: .defaultPrimaryColor))" // stringlint:ignore if let cachedImage = dependencies[cache: .generalUI].get(for: cacheKey) { return cachedImage diff --git a/Session/Utilities/MentionUtilities+Attributes.swift b/Session/Utilities/MentionUtilities+Attributes.swift new file mode 100644 index 0000000000..6a0f9a4f1b --- /dev/null +++ b/Session/Utilities/MentionUtilities+Attributes.swift @@ -0,0 +1,98 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SessionUtilitiesKit + + +public extension MentionUtilities { + static func highlightMentions( + in string: String, + currentUserSessionIds: Set, + location: MentionLocation, + textColor: ThemeValue, + attributes: [NSAttributedString.Key: Any], + displayNameRetriever: (String, Bool) -> String?, + using dependencies: Dependencies + ) -> ThemedAttributedString { + let (string, mentions) = getMentions( + in: string, + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: displayNameRetriever + ) + + let sizeDiff: CGFloat = (Values.smallFontSize / Values.mediumFontSize) + let result = ThemedAttributedString(string: string, attributes: attributes) + let mentionFont = UIFont.boldSystemFont(ofSize: Values.smallFontSize) + // Iterate in reverse so index ranges remain valid while replacing + for mention in mentions.sorted(by: { $0.range.location > $1.range.location }) { + if mention.isCurrentUser && location == .incomingMessage { + // Build the rendered chip image + let image = HighlightMentionView( + mentionText: (result.string as NSString).substring(with: mention.range), + font: mentionFont, + themeTextColor: .dynamicForInterfaceStyle(light: textColor, dark: .black), + themeBackgroundColor: .primary, + backgroundCornerRadius: (8 * sizeDiff), + backgroundPadding: (3 * sizeDiff) + ).toImage(using: dependencies) + + let attachment = NSTextAttachment() + let offsetY = (mentionFont.capHeight - image.size.height) / 2 + attachment.image = image + attachment.bounds = CGRect( + x: 0, + y: offsetY, + width: image.size.width, + height: image.size.height + ) + + let attachmentString = NSMutableAttributedString(attachment: attachment) + + // Replace the mention text with the image attachment + result.replaceCharacters(in: mention.range, with: attachmentString) + + let insertIndex = mention.range.location + attachmentString.length + if insertIndex < result.length { + result.addAttribute(.kern, value: (3 * sizeDiff), range: NSRange(location: insertIndex, length: 1)) + } + continue + } + + result.addAttribute(.font, value: mentionFont, range: mention.range) + + var targetColor: ThemeValue = textColor + switch location { + case .incomingMessage: + targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .primary) + case .outgoingMessage: + targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) + case .outgoingQuote: + targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) + case .incomingQuote: + targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .primary) + case .quoteDraft, .styleFree: + targetColor = .dynamicForInterfaceStyle(light: textColor, dark: textColor) + } + + result.addAttribute(.themeForegroundColor, value: targetColor, range: mention.range) + } + + return result + } +} + +public extension HighlightMentionView { + func toImage(using dependencies: Dependencies) -> UIImage { + let themePrimaryColor = dependencies.mutate(cache: .libSession) { libSession -> Theme.PrimaryColor? in libSession.get(.themePrimaryColor)} + let cacheKey: String = "Mention.CurrentUser.\(themePrimaryColor.defaulting(to: .defaultPrimaryColor))" // stringlint:ignore + + if let cachedImage = dependencies[cache: .generalUI].get(for: cacheKey) { + return cachedImage + } + + let renderedImage = self.toImage(isOpaque: self.isOpaque, scale: UIScreen.main.scale) + dependencies.mutate(cache: .generalUI) { $0.cache(renderedImage, for: cacheKey) } + return renderedImage + } +} diff --git a/Session/Utilities/MentionUtilities+DisplayName.swift b/Session/Utilities/MentionUtilities+DisplayName.swift index a2059109dc..18adfe2c99 100644 --- a/Session/Utilities/MentionUtilities+DisplayName.swift +++ b/Session/Utilities/MentionUtilities+DisplayName.swift @@ -49,7 +49,8 @@ public extension MentionUtilities { threadVariant: threadVariant, using: dependencies ) - } + }, + using: dependencies ) } } diff --git a/SessionUIKit/Components/HighlightMentionView.swift b/SessionUIKit/Components/HighlightMentionView.swift index 82dbf00f30..1b41fc4a10 100644 --- a/SessionUIKit/Components/HighlightMentionView.swift +++ b/SessionUIKit/Components/HighlightMentionView.swift @@ -31,13 +31,7 @@ public class HighlightMentionView: UIView { self.themeBackgroundColor = themeBackgroundColor self.label.pin(to: self, withInset: backgroundPadding) self.layer.cornerRadius = backgroundCornerRadius - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - public func toImage() -> UIImage { + let maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: label.font.lineHeight) let size = self.label.sizeThatFits(maxSize) self.label.frame = CGRect( @@ -51,7 +45,9 @@ public class HighlightMentionView: UIView { width: size.width + 2 * self.backgroundPadding, height: size.height + 2 * self.backgroundPadding ) - let renderedImage = self.toImage(isOpaque: self.isOpaque, scale: UIScreen.main.scale) - return renderedImage + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } } diff --git a/SessionUIKit/Utilities/MentionUtilities.swift b/SessionUIKit/Utilities/MentionUtilities.swift index 4fdfee7631..844df5a58d 100644 --- a/SessionUIKit/Utilities/MentionUtilities.swift +++ b/SessionUIKit/Utilities/MentionUtilities.swift @@ -66,91 +66,13 @@ public enum MentionUtilities { currentUserSessionIds: Set, displayNameRetriever: (String, Bool) -> String? ) -> String { - /// **Note:** We are returning the string here so the 'textColor' and 'primaryColor' values are irrelevant - return highlightMentions( + let (string, _) = getMentions( in: string, currentUserSessionIds: currentUserSessionIds, - location: .styleFree, - textColor: .black, - attributes: [:], displayNameRetriever: displayNameRetriever ) - .string - .deformatted() - } - - public static func highlightMentions( - in string: String, - currentUserSessionIds: Set, - location: MentionLocation, - textColor: ThemeValue, - attributes: [NSAttributedString.Key: Any], - displayNameRetriever: (String, Bool) -> String? - ) -> ThemedAttributedString { - let (string, mentions) = getMentions( - in: string, - currentUserSessionIds: currentUserSessionIds, - displayNameRetriever: displayNameRetriever - ) - - let sizeDiff: CGFloat = (Values.smallFontSize / Values.mediumFontSize) - let result = ThemedAttributedString(string: string, attributes: attributes) - let mentionFont = UIFont.boldSystemFont(ofSize: Values.smallFontSize) - // Iterate in reverse so index ranges remain valid while replacing - for mention in mentions.sorted(by: { $0.range.location > $1.range.location }) { - if mention.isCurrentUser && location == .incomingMessage { - // Build the rendered chip image - let image = HighlightMentionView( - mentionText: (result.string as NSString).substring(with: mention.range), - font: mentionFont, - themeTextColor: .dynamicForInterfaceStyle(light: textColor, dark: .black), - themeBackgroundColor: .primary, - backgroundCornerRadius: (8 * sizeDiff), - backgroundPadding: (3 * sizeDiff) - ).toImage() - - let attachment = NSTextAttachment() - let offsetY = (mentionFont.capHeight - image.size.height) / 2 - attachment.image = image - attachment.bounds = CGRect( - x: 0, - y: offsetY, - width: image.size.width, - height: image.size.height - ) - - let attachmentString = NSMutableAttributedString(attachment: attachment) - - // Replace the mention text with the image attachment - result.replaceCharacters(in: mention.range, with: attachmentString) - - let insertIndex = mention.range.location + attachmentString.length - if insertIndex < result.length { - result.addAttribute(.kern, value: (3 * sizeDiff), range: NSRange(location: insertIndex, length: 1)) - } - continue - } - - result.addAttribute(.font, value: mentionFont, range: mention.range) - - var targetColor: ThemeValue = textColor - switch location { - case .incomingMessage: - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .primary) - case .outgoingMessage: - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) - case .outgoingQuote: - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .black) - case .incomingQuote: - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: .primary) - case .quoteDraft, .styleFree: - targetColor = .dynamicForInterfaceStyle(light: textColor, dark: textColor) - } - - result.addAttribute(.themeForegroundColor, value: targetColor, range: mention.range) - } - return result + return string } } From 5f720af9d183b8741a4c1c220b2a8a213f8ab1f7 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 6 Oct 2025 16:49:33 +1100 Subject: [PATCH 060/162] New deterministic encryption/decryption, upload/download refactoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added deterministic attachment encryption/decryption • Refactored the logic in the AttachmentUploadJob to be more reusable • Refactored the upload logic in the AttachmentUploadJob to not require the database (so the Share Extension can more easily use it when that database relocation work continues) • Refactored the AttachmentDownloadJob to use async/await internally • Refactored the DisplayPictureDownloadJob to be (partially) async/await internally • Fixed a few issues with uploading attachments --- Session.xcodeproj/project.pbxproj | 8 - .../ConversationVC+Interaction.swift | 6 +- .../Conversations/ConversationViewModel.swift | 2 +- .../Content Views/MediaView.swift | 2 +- .../Settings/ThreadSettingsViewModel.swift | 2 +- Session/Settings/SettingsViewModel.swift | 4 +- .../Crypto/Crypto+Attachments.swift | 238 ++++---- .../Database/Models/Attachment.swift | 62 +-- .../Jobs/AttachmentDownloadJob.swift | 405 +++++++------- .../Jobs/AttachmentUploadJob.swift | 507 ++++++++++++++++-- .../Jobs/DisplayPictureDownloadJob.swift | 250 +++++---- .../Jobs/ReuploadUserDisplayPictureJob.swift | 4 +- .../AttachmentUploader.swift | 246 --------- .../Errors/AttachmentError.swift | 16 + .../Utilities/AttachmentManager.swift | 200 +++++-- .../Utilities/DisplayPictureError.swift | 39 -- .../Utilities/DisplayPictureManager.swift | 20 +- .../Utilities/Profile+Updating.swift | 6 +- .../MessageSenderGroupsSpec.swift | 2 +- SessionShareExtension/ThreadPickerVC.swift | 47 +- SessionUtilitiesKit/Crypto/Crypto.swift | 6 + SessionUtilitiesKit/Media/MediaUtils.swift | 4 +- SessionUtilitiesKit/Types/FileManager.swift | 10 +- .../AttachmentItemCollection.swift | 7 +- .../Image Editing/ImageEditorCanvasView.swift | 73 ++- .../Image Editing/ImageEditorModel.swift | 45 +- _SharedTestUtilities/MockFileManager.swift | 1 - 27 files changed, 1193 insertions(+), 1019 deletions(-) delete mode 100644 SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift delete mode 100644 SessionMessagingKit/Utilities/DisplayPictureError.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index daf4ad7fa4..e7a93cde61 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -676,7 +676,6 @@ FD49E2492B05C1D500FFBBB5 /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */; }; FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200D283492210034334B /* InsetLockableTableView.swift */; }; FD4BB22B2D63F20700D0DC3D /* MigrationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4BB22A2D63F20600D0DC3D /* MigrationHelper.swift */; }; - FD4C4E9C2B02E2A300C72199 /* DisplayPictureError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4C4E9B2B02E2A300C72199 /* DisplayPictureError.swift */; }; FD4C53AF2CC1D62E003B10F4 /* _035_ReworkRecipientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4C53AE2CC1D61E003B10F4 /* _035_ReworkRecipientState.swift */; }; FD52090328B4680F006098F6 /* RadioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090228B4680F006098F6 /* RadioButton.swift */; }; FD52090528B4915F006098F6 /* PrivacySettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD52090428B4915F006098F6 /* PrivacySettingsViewModel.swift */; }; @@ -854,7 +853,6 @@ FD778B6429B189FF001BAC6B /* _028_GenerateInitialUserConfigDumps.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD778B6329B189FF001BAC6B /* _028_GenerateInitialUserConfigDumps.swift */; }; FD78E9EE2DD6D32500D55B50 /* ImageDataManager+Singleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A622DD5BDDD00BEF49F /* ImageDataManager+Singleton.swift */; }; FD78E9F22DDA9EA200D55B50 /* MockImageDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78E9F12DDA9E9B00D55B50 /* MockImageDataManager.swift */; }; - FD78E9F42DDABA4F00D55B50 /* AttachmentUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78E9F32DDABA4200D55B50 /* AttachmentUploader.swift */; }; FD78E9F62DDD43AD00D55B50 /* Mutation.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78E9F52DDD43AB00D55B50 /* Mutation.swift */; }; FD78E9FA2DDD74D200D55B50 /* _042_MoveSettingsToLibSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78E9F72DDD742100D55B50 /* _042_MoveSettingsToLibSession.swift */; }; FD78E9FD2DDD97F200D55B50 /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD78E9FC2DDD97F000D55B50 /* Setting.swift */; }; @@ -2014,7 +2012,6 @@ FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychain.swift; sourceTree = ""; }; FD4B200D283492210034334B /* InsetLockableTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsetLockableTableView.swift; sourceTree = ""; }; FD4BB22A2D63F20600D0DC3D /* MigrationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationHelper.swift; sourceTree = ""; }; - FD4C4E9B2B02E2A300C72199 /* DisplayPictureError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DisplayPictureError.swift; sourceTree = ""; }; FD4C53AE2CC1D61E003B10F4 /* _035_ReworkRecipientState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _035_ReworkRecipientState.swift; sourceTree = ""; }; FD52090228B4680F006098F6 /* RadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButton.swift; sourceTree = ""; }; FD52090428B4915F006098F6 /* PrivacySettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettingsViewModel.swift; sourceTree = ""; }; @@ -2120,7 +2117,6 @@ FD7728972849E8110018502F /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; FD778B6329B189FF001BAC6B /* _028_GenerateInitialUserConfigDumps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _028_GenerateInitialUserConfigDumps.swift; sourceTree = ""; }; FD78E9F12DDA9E9B00D55B50 /* MockImageDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockImageDataManager.swift; sourceTree = ""; }; - FD78E9F32DDABA4200D55B50 /* AttachmentUploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploader.swift; sourceTree = ""; }; FD78E9F52DDD43AB00D55B50 /* Mutation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mutation.swift; sourceTree = ""; }; FD78E9F72DDD742100D55B50 /* _042_MoveSettingsToLibSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _042_MoveSettingsToLibSession.swift; sourceTree = ""; }; FD78E9FC2DDD97F000D55B50 /* Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; }; @@ -3187,7 +3183,6 @@ C32C5B1B256DC160003C73A2 /* Quotes */, C32C5995256DAF85003C73A2 /* Typing Indicators */, FD7728A1284F0DF50018502F /* Message Handling */, - FD78E9F32DDABA4200D55B50 /* AttachmentUploader.swift */, B8D0A25825E367AC00C1835E /* Notification+MessageReceiver.swift */, C300A5F12554B09800555489 /* MessageSender.swift */, FDF0B75B2807F41D004C14C5 /* MessageSender+Convenience.swift */, @@ -3650,7 +3645,6 @@ FD47E0B02AA6A05800A55E41 /* Authentication+SessionMessagingKit.swift */, FD859EF127BF6BA200510D0C /* Data+Utilities.swift */, C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, - FD4C4E9B2B02E2A300C72199 /* DisplayPictureError.swift */, FD2273072C353109004D8A6C /* DisplayPictureManager.swift */, FD981BC52DC3310800564172 /* ExtensionHelper.swift */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, @@ -6629,7 +6623,6 @@ FD245C5F2850662200B966DD /* OWSWindowManager.m in Sources */, FDF40CDE2897A1BC006A0CC4 /* _011_RemoveLegacyYDB.swift in Sources */, FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */, - FD78E9F42DDABA4F00D55B50 /* AttachmentUploader.swift in Sources */, FDE754A32C9A8FD1002A2623 /* SwarmPoller.swift in Sources */, 7B81682C28B72F480069F315 /* PendingChange.swift in Sources */, FD5C7309285007920029977D /* BlindedIdLookup.swift in Sources */, @@ -6699,7 +6692,6 @@ FDEFDC6E2E83A74300EBCD81 /* _045_LastProfileUpdateTimestamp.swift in Sources */, FDD23AE82E458DD40057E853 /* _002_SUK_SetupStandardJobs.swift in Sources */, FD72BDA12BE368C800CF6CF6 /* UIWindowLevel+Utilities.swift in Sources */, - FD4C4E9C2B02E2A300C72199 /* DisplayPictureError.swift in Sources */, FDD23AE22E457CE50057E853 /* _008_SNK_YDBToGRDBMigration.swift in Sources */, FD5C7307284F103B0029977D /* MessageReceiver+MessageRequests.swift in Sources */, C3A71D0B2558989C0043A11F /* MessageWrapper.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 0ba7d65056..4996d5caf8 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -787,11 +787,11 @@ extension ConversationVC: ).insert(db) } - // Process any attachments - try AttachmentUploader.process( + // Link any attachments to their interaction + try AttachmentUploadJob.link( db, attachments: optimisticData.attachmentData, - for: insertedInteraction.id + toInteractionWithId: insertedInteraction.id ) // If we are sending a blinded message then we need to update the blinded profile diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 5ad831c0b7..8d0993aaeb 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -746,7 +746,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold using: dependencies ) let optimisticAttachments: [Attachment]? = try? attachments.map { - try AttachmentUploader.prepare(attachments: $0, using: dependencies) + try AttachmentUploadJob.preparePriorToUpload(attachments: $0, using: dependencies) } let linkPreviewAttachment: Attachment? = linkPreviewDraft.map { draft in try? LinkPreview.generateAttachmentIfPossible( diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index 09e9b1e7b0..2de57e6322 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -178,7 +178,7 @@ public class MediaView: UIView { addSubview(loadingIndicator) loadingIndicator.pin(.leading, to: .leading, of: self) - loadingIndicator.pin(.trailing, to: .trailing, of: self) + loadingIndicator.pin(.trailing, to: .trailing, of: self).setting(priority: .defaultHigh) loadingIndicator.pin(.bottom, to: .bottom, of: self) /// Load in image data if possible diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 28398da8ff..28b4e9418d 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -1789,7 +1789,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob let message: String = { switch (displayPictureUpdate, error) { case (.groupRemove, _): return "profileDisplayPictureRemoveError".localized() - case (_, DisplayPictureError.uploadMaxFileSizeExceeded): + case (_, AttachmentError.fileSizeTooLarge): return "profileDisplayPictureSizeError".localized() default: return "errorConnection".localized() diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index feea7094c7..96549d2bbd 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -693,7 +693,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl case .image(.some(let source), _, _, _, _, _, _): self?.updateProfile( displayPictureUpdateGenerator: { [weak self] in - guard let self = self else { throw DisplayPictureError.uploadFailed } + guard let self = self else { throw AttachmentError.uploadFailed } return try await uploadDisplayPicture(source: source) }, @@ -764,7 +764,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl let message: String = { switch (displayPictureUpdate, error) { case (.currentUserRemove, _): return "profileDisplayPictureRemoveError".localized() - case (_, DisplayPictureError.uploadMaxFileSizeExceeded): + case (_, AttachmentError.fileSizeTooLarge): return "profileDisplayPictureSizeError".localized() default: return "errorConnection".localized() diff --git a/SessionMessagingKit/Crypto/Crypto+Attachments.swift b/SessionMessagingKit/Crypto/Crypto+Attachments.swift index f8f4a893a6..76a84bb845 100644 --- a/SessionMessagingKit/Crypto/Crypto+Attachments.swift +++ b/SessionMessagingKit/Crypto/Crypto+Attachments.swift @@ -10,109 +10,70 @@ import SessionUtilitiesKit // MARK: - Encryption +public extension Crypto { + enum AttachmentDomain: Sendable, Equatable, Hashable { + case attachment + case profilePicture + + fileprivate var libSessionValue: ATTACHMENT_DOMAIN { + switch self { + case .attachment: return ATTACHMENT_DOMAIN_ATTACHMENT + case .profilePicture: return ATTACHMENT_DOMAIN_PROFILE_PIC + } + } + } +} + public extension Crypto.Generator { private static var hmac256KeyLength: Int { 32 } private static var hmac256OutputLength: Int { 32 } private static var aesCBCIvLength: Int { 16 } private static var aesKeySize: Int { 32 } + static func expectedEncryptedAttachmentSize(plaintext: Data) -> Crypto.Generator { + return Crypto.Generator( + id: "expectedEncryptedAttachmentSize", + args: [plaintext] + ) { dependencies in + return session_attachment_encrypted_size(plaintext.count) + } + } + static func encryptAttachment( - plaintext: Data - ) -> Crypto.Generator<(ciphertext: Data, encryptionKey: Data, digest: Data)> { + plaintext: Data, + domain: Crypto.AttachmentDomain + ) -> Crypto.Generator<(ciphertext: Data, encryptionKey: Data)> { return Crypto.Generator( id: "encryptAttachment", args: [plaintext] ) { dependencies in - // Due to paddedSize, we need to divide by two. - guard plaintext.count < (UInt.max / 2) else { - Log.error("[Crypto] Attachment data too long to encrypt.") + guard !dependencies[cache: .general].ed25519Seed.isEmpty else { + Log.error(.crypto, "Invalid seed.") throw CryptoError.encryptionFailed } + let cPlaintext: [UInt8] = Array(plaintext) + let encryptedSize: Int = session_attachment_encrypted_size(cPlaintext.count) + var cEncryptionKey: [UInt8] = [UInt8](repeating: 0, count: 32) + var cEncryptedData: [UInt8] = [UInt8](repeating: 0, count: encryptedSize) + var cError: [CChar] = [CChar](repeating: 0, count: 256) + guard - var iv: [UInt8] = dependencies[singleton: .crypto].generate(.randomBytes(aesCBCIvLength)), - var encryptionKey: [UInt8] = dependencies[singleton: .crypto].generate(.randomBytes(aesKeySize)), - var hmacKey: [UInt8] = dependencies[singleton: .crypto].generate(.randomBytes(hmac256KeyLength)) + session_attachment_encrypt( + dependencies[cache: .general].ed25519Seed, + cPlaintext, + cPlaintext.count, + domain.libSessionValue, + &cEncryptionKey, + &cEncryptedData, + &cError + ) else { - Log.error("[Crypto] Failed to generate random data.") - throw CryptoError.encryptionFailed - } - - // The concatenated key for storage - var outKey: Data = Data() - outKey.append(Data(encryptionKey)) - outKey.append(Data(hmacKey)) - - // Apply any padding - let desiredSize: Int = max(541, min(Int(Network.maxFileSize), Int(floor(pow(1.05, ceil(log(Double(plaintext.count)) / log(1.05))))))) - var paddedAttachmentData: [UInt8] = Array(plaintext) - if desiredSize > plaintext.count { - paddedAttachmentData.append(contentsOf: [UInt8](repeating: 0, count: desiredSize - plaintext.count)) - } - - var numBytesEncrypted: size_t = 0 - var bufferData: [UInt8] = Array(Data(count: paddedAttachmentData.count + kCCBlockSizeAES128)) - let cryptStatus: CCCryptorStatus = CCCrypt( - CCOperation(kCCEncrypt), - CCAlgorithm(kCCAlgorithmAES128), - CCOptions(kCCOptionPKCS7Padding), - &encryptionKey, encryptionKey.count, - &iv, - &paddedAttachmentData, paddedAttachmentData.count, - &bufferData, bufferData.count, - &numBytesEncrypted - ) - - guard cryptStatus == kCCSuccess else { - Log.error("[Crypto] Failed to encrypt attachment with status: \(cryptStatus).") - throw CryptoError.encryptionFailed - } - - guard cryptStatus == kCCSuccess else { - Log.error("[Crypto] Failed to encrypt attachment with status: \(cryptStatus).") - throw CryptoError.encryptionFailed - } - - guard bufferData.count >= numBytesEncrypted else { - Log.error("[Crypto] ciphertext has unexpected length: \(bufferData.count) < \(numBytesEncrypted).") - throw CryptoError.encryptionFailed - } - - let ciphertext: [UInt8] = Array(bufferData[0.. Crypto.Generator<(ciphertext: Data, encryptionKey: Data, digest: Data)> { return Crypto.Generator( - id: "encryptAttachment", + id: "legacyEncryptAttachment", args: [plaintext] ) { dependencies in // Due to paddedSize, we need to divide by two. - guard plaintext.count < (UInt.max / 2) else { - Log.error("[Crypto] Attachment data too long to encrypt.") - throw CryptoError.encryptionFailed - } - guard + plaintext.count < (UInt.max / 2), var iv: [UInt8] = dependencies[singleton: .crypto].generate(.randomBytes(aesCBCIvLength)), var encryptionKey: [UInt8] = dependencies[singleton: .crypto].generate(.randomBytes(aesKeySize)), var hmacKey: [UInt8] = dependencies[singleton: .crypto].generate(.randomBytes(hmac256KeyLength)) - else { - Log.error("[Crypto] Failed to generate random data.") - throw CryptoError.encryptionFailed - } + else { throw AttachmentError.legacyEncryptionFailed } // The concatenated key for storage var outKey: Data = Data() @@ -164,33 +118,19 @@ public extension Crypto.Generator { &numBytesEncrypted ) - guard cryptStatus == kCCSuccess else { - Log.error("[Crypto] Failed to encrypt attachment with status: \(cryptStatus).") - throw CryptoError.encryptionFailed - } - - guard cryptStatus == kCCSuccess else { - Log.error("[Crypto] Failed to encrypt attachment with status: \(cryptStatus).") - throw CryptoError.encryptionFailed - } - - guard bufferData.count >= numBytesEncrypted else { - Log.error("[Crypto] ciphertext has unexpected length: \(bufferData.count) < \(numBytesEncrypted).") - throw CryptoError.encryptionFailed - } + guard + cryptStatus == kCCSuccess, + bufferData.count >= numBytesEncrypted + else { throw AttachmentError.legacyEncryptionFailed } let ciphertext: [UInt8] = Array(bufferData[0.. Crypto.Generator { + return Crypto.Generator( + id: "decryptAttachment", + args: [ciphertext, key] + ) { dependencies in + let cCiphertext: [UInt8] = Array(ciphertext) + let expectedDecryptedSize: Int = session_attachment_decrypted_max_size(cCiphertext.count) + let cDecryptionKey: [UInt8] = Array(key) + var cDecryptedData: [UInt8] = [UInt8](repeating: 0, count: expectedDecryptedSize) + var cDecryptedSize: Int = 0 + var cError: [CChar] = [CChar](repeating: 0, count: 256) + + guard + session_attachment_decrypt( + cCiphertext, + cCiphertext.count, + cDecryptionKey, + &cDecryptedData, + &cDecryptedSize, + &cError + ) + else { + Log.error(.crypto, "Attachment decryption failed due to error: \(String(cString: cError))") + throw CryptoError.decryptionFailed + } + + return Data(cDecryptedData) + } + } + static func legacyDecryptAttachment( ciphertext: Data, key: Data, @@ -228,12 +200,11 @@ public extension Crypto.Generator { unpaddedSize: UInt ) -> Crypto.Generator { return Crypto.Generator( - id: "decryptAttachment", + id: "legacyDecryptAttachment", args: [ciphertext, key, digest, unpaddedSize] ) { guard ciphertext.count >= aesCBCIvLength + hmac256OutputLength else { - Log.error("[Crypto] Attachment shorter than crypto overhead."); - throw CryptoError.decryptionFailed + throw AttachmentError.legacyDecryptionFailed } // key: 32 byte AES key || 32 byte Hmac-SHA256 key. @@ -270,10 +241,7 @@ public extension Crypto.Generator { return (isEqual == 0) }() - guard isHmacEqual else { - Log.error("[Crypto] Bad HMAC on decrypting payload.") - throw CryptoError.decryptionFailed - } + guard isHmacEqual else { throw AttachmentError.legacyDecryptionFailed } // Verify digest of: iv || encrypted data || hmac dataToAuth += generatedHmac @@ -292,10 +260,7 @@ public extension Crypto.Generator { return (isEqual == 0) }() - guard isDigestEqual else { - Log.error("[Crypto] Bad digest on decrypting payload.") - throw CryptoError.decryptionFailed - } + guard isDigestEqual else { throw AttachmentError.legacyDecryptionFailed } var numBytesDecrypted: size_t = 0 var bufferData: [UInt8] = Array(Data(count: ciphertext.count + kCCBlockSizeAES128)) @@ -310,14 +275,10 @@ public extension Crypto.Generator { &numBytesDecrypted ) - guard cryptStatus == kCCSuccess else { - Log.error("[Crypto] Failed to decrypt attachment with status: \(cryptStatus).") - throw CryptoError.decryptionFailed - } - guard bufferData.count >= numBytesDecrypted else { - Log.error("[Crypto] Attachment paddedPlaintext has unexpected length: \(bufferData.count) < \(numBytesDecrypted).") - throw CryptoError.decryptionFailed - } + guard + cryptStatus == kCCSuccess, + bufferData.count >= numBytesDecrypted + else { throw AttachmentError.legacyDecryptionFailed } let paddedPlaintext: [UInt8] = Array(bufferData[0.. Attachment { - /// If the `downloadUrl` previously had a value and we are updating it then we need to move the file from it's current location - /// to the hash that would be generated for the new location - /// - /// We default `finalDownloadUrl` to the current `downloadUrl` just in case moving the file fails (in which case we don't - /// want to update it or we won't be able to resolve the stored file), but if we don't currently have a `downloadUrl` then we can - /// just use the new one - var finalDownloadUrl: String? = (self.downloadUrl ?? downloadUrl) - - if - let newUrl: String = downloadUrl, - let oldUrl: String = self.downloadUrl, - newUrl != oldUrl - { - if - let oldPath: String = try? dependencies[singleton: .attachmentManager].path(for: oldUrl), - let newPath: String = try? dependencies[singleton: .attachmentManager].path(for: newUrl) - { - do { - try dependencies[singleton: .fileManager].moveItem(atPath: oldPath, toPath: newPath) - finalDownloadUrl = newUrl - } - catch {} - } - } + ) throws -> Attachment { + guard let downloadUrl: String = self.downloadUrl else { throw AttachmentError.invalidPath } let (isValid, duration): (Bool, TimeInterval?) = { switch (self.state, state) { case (_, .downloaded): return dependencies[singleton: .attachmentManager].determineValidityAndDuration( contentType: contentType, - downloadUrl: finalDownloadUrl, + downloadUrl: downloadUrl, sourceFilename: sourceFilename ) - // Assume the data is already correct for "uploading" attachments (and don't override it) + /// Assume the data is already correct for "uploading" attachments (and don't override it) case (.uploading, _), (.uploaded, _), (.failedUpload, _): return (self.isValid, self.duration) case (_, .failedDownload): return (false, nil) default: return (self.isValid, self.duration) } }() - // Regenerate this just in case we added support since the attachment was inserted into - // the database (eg. manually downloaded in a later update) + + /// Regenerate this just in case we added support since the attachment was inserted into the database (eg. manually + /// downloaded in a later update) let isVisualMedia: Bool = UTType.isVisualMedia(contentType) let attachmentResolution: CGSize? = { if let width: UInt = self.width, let height: UInt = self.height, width > 0, height > 0 { @@ -354,7 +328,7 @@ extension Attachment { isVisualMedia, state == .downloaded, let path: String = try? dependencies[singleton: .attachmentManager] - .path(for: finalDownloadUrl) + .path(for: downloadUrl) else { return nil } return MediaUtils.unrotatedSize( @@ -366,26 +340,22 @@ extension Attachment { }() return Attachment( - id: self.id, - serverId: (serverId ?? self.serverId), + id: id, + serverId: serverId, variant: variant, state: (state ?? self.state), contentType: contentType, byteCount: byteCount, creationTimestamp: (creationTimestamp ?? self.creationTimestamp), sourceFilename: sourceFilename, - downloadUrl: finalDownloadUrl, + downloadUrl: downloadUrl, width: attachmentResolution.map { UInt($0.width) }, height: attachmentResolution.map { UInt($0.height) }, duration: duration, - isVisualMedia: ( - // Regenerate this just in case we added support since the attachment was inserted into - // the database (eg. manually downloaded in a later update) - UTType.isVisualMedia(contentType) - ), + isVisualMedia: isVisualMedia, isValid: isValid, - encryptionKey: (encryptionKey ?? self.encryptionKey), - digest: (digest ?? self.digest) + encryptionKey: encryptionKey, + digest: digest ) } } @@ -451,8 +421,10 @@ extension Attachment { 0 ) - if let encryptionKey: Data = encryptionKey, let digest: Data = digest { + if let encryptionKey: Data = encryptionKey { builder.setKey(encryptionKey) + } + if let digest: Data = digest { builder.setDigest(digest) } diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift index 6fded8365d..c19fb3adfb 100644 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift @@ -25,239 +25,240 @@ public enum AttachmentDownloadJob: JobExecutor { let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData) else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - dependencies[singleton: .storage] - .writePublisher { db -> Attachment in - guard let attachment: Attachment = try? Attachment.fetchOne(db, id: details.attachmentId) else { - throw JobRunnerError.missingRequiredDetails - } - - // Due to the complex nature of jobs and how attachments can be reused it's possible for - // 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 { - throw AttachmentDownloadError.alreadyDownloaded - } - - // 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 = dependencies[singleton: .jobRunner] - .jobInfoFor(state: .running, variant: .attachmentDownload) - .filter { key, _ in key != job.id } - .values - .compactMap { info -> String? in - guard let data: Data = info.detailsData else { return nil } - - return (try? JSONDecoder(using: dependencies).decode(Details.self, from: data))? - .attachmentId - } - .asSet() + Task { + do { + /// Validate and retrieve the attachment state + let attachment: Attachment = try await dependencies[singleton: .storage].writeAsync { db -> Attachment in + guard let attachment: Attachment = try? Attachment.fetchOne(db, id: details.attachmentId) else { + throw JobRunnerError.missingRequiredDetails + } + + /// Due to the complex nature of jobs and how attachments can be reused it's possible for an + /// `AttachmentDownloadJob` to get created for an attachment which has already been downloaded or + /// uploaded so in those cases just succeed immediately + guard attachment.state != .downloaded && attachment.state != .uploaded else { + throw AttachmentDownloadError.alreadyDownloaded + } - // 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) { - _ = try Attachment - .filter(id: attachment.id) - .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedDownload)) - db.addAttachmentEvent( - id: attachment.id, - messageId: job.interactionId, - type: .updated(.state(.failedDownload)) + /// 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 = dependencies[singleton: .jobRunner] + .jobInfoFor(state: .running, variant: .attachmentDownload) + .filter { key, _ in key != job.id } + .values + .compactMap { info -> String? in + guard let data: Data = info.detailsData else { return nil } + + return (try? JSONDecoder(using: dependencies).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) { + _ = try Attachment + .filter(id: attachment.id) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedDownload)) + db.addAttachmentEvent( + id: attachment.id, + messageId: job.interactionId, + type: .updated(.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 + throw JobRunnerError.possibleDuplicateJob( + permanentFailure: otherCurrentJobAttachmentIds.contains(attachment.id) ) } - // 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 - throw JobRunnerError.possibleDuplicateJob( - permanentFailure: otherCurrentJobAttachmentIds.contains(attachment.id) + /// Update to the 'downloading' state (no need to update the 'attachment' instance) + try Attachment + .filter(id: attachment.id) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.downloading)) + db.addAttachmentEvent( + id: attachment.id, + messageId: job.interactionId, + type: .updated(.state(.downloading)) ) + + return attachment } + try Task.checkCancellation() - // Update to the 'downloading' state (no need to update the 'attachment' instance) - try Attachment - .filter(id: attachment.id) - .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.downloading)) - db.addAttachmentEvent( - id: attachment.id, - messageId: job.interactionId, - type: .updated(.state(.downloading)) - ) - - return attachment - } - .tryMap { attachment -> (attachment: Attachment, temporaryFileUrl: URL, downloadUrl: URL) in guard let downloadUrl: URL = attachment.downloadUrl.map({ URL(string: $0) }) else { throw AttachmentDownloadError.invalidUrl } - let temporaryFileUrl: URL = URL( - fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectoryAccessibleAfterFirstAuth + UUID().uuidString - ) + /// Download the attachment data + let maybeAuthMethod: AuthenticationMethod? = try await dependencies[singleton: .storage].readAsync { db in + try? Authentication.with( + db, + threadId: threadId, + threadVariant: .community, + using: dependencies + ) + } + let request: Network.PreparedRequest - return (attachment, temporaryFileUrl, downloadUrl) - } - .flatMapStorageReadPublisher(using: dependencies, value: { db, info -> Network.PreparedRequest<(data: Data, attachment: Attachment, temporaryFileUrl: URL)> in - let maybeRoomToken: String? = try OpenGroup - .select(.roomToken) - .filter(id: threadId) - .asRequest(of: String.self) - .fetchOne(db) + switch maybeAuthMethod { + case let authMethod as Authentication.community: + request = try Network.SOGS.preparedDownload( + url: downloadUrl, + roomToken: authMethod.roomToken, + authMethod: authMethod, + using: dependencies + ) + + default: + request = try Network.preparedDownload( + url: downloadUrl, + using: dependencies + ) + } - switch maybeRoomToken { - case .some(let roomToken): - return try Network.SOGS - .preparedDownload( - url: info.downloadUrl, - roomToken: roomToken, - authMethod: try Authentication.with( - db, - threadId: threadId, - threadVariant: .community, - using: dependencies - ), - using: dependencies + // FIXME: Make this async/await when the refactored networking is merged + let response: Data = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw AttachmentError.downloadFailed }() + try Task.checkCancellation() + + /// Store the encrypted data temporarily + let temporaryFilePath: String = dependencies[singleton: .fileManager].temporaryFilePath() + try response.write(to: URL(fileURLWithPath: temporaryFilePath), options: .atomic) + defer { + /// Remove the temporary file regardless of the outcome (it'll get recreated if we try again) + try? dependencies[singleton: .fileManager].removeItem(atPath: temporaryFilePath) + } + + /// Decrypt the data if needed + let plaintext: Data + + switch (attachment.encryptionKey, attachment.digest) { + case (.some(let key), .some(let digest)) where !key.isEmpty && !digest.isEmpty: + plaintext = try dependencies[singleton: .crypto].tryGenerate( + .legacyDecryptAttachment( + ciphertext: response, + key: key, + digest: digest, + unpaddedSize: attachment.byteCount ) - .map { _, data in (data, info.attachment, info.temporaryFileUrl) } + ) - case .none: - return try Network - .preparedDownload( - url: info.downloadUrl, - using: dependencies + case (.some(let key), _) where !key.isEmpty: + plaintext = try dependencies[singleton: .crypto].tryGenerate( + .decryptAttachment( + ciphertext: response, + key: key ) - .map { _, data in (data, info.attachment, info.temporaryFileUrl) } + ) + + default: plaintext = response } - }) - .flatMap { downloadRequest in - downloadRequest.send(using: dependencies).map { _, response in - (response.attachment, response.temporaryFileUrl, response.data) + try Task.checkCancellation() + + /// Write the decrypted data to disk + guard try attachment.write(data: plaintext, using: dependencies) else { + throw AttachmentDownloadError.failedToSaveFile } - } - .subscribe(on: scheduler, using: dependencies) - .receive(on: scheduler, using: dependencies) - .tryMap { attachment, temporaryFileUrl, data -> Attachment in - // Store the encrypted data temporarily - try data.write(to: temporaryFileUrl, options: .atomic) + try Task.checkCancellation() - // Decrypt the data - let plaintext: Data = try { - guard - let key: Data = attachment.encryptionKey, - let digest: Data = attachment.digest, - key.count > 0, - digest.count > 0 - else { return data } // Open group attachments are unencrypted - - return try dependencies[singleton: .crypto].tryGenerate( - .legacyDecryptAttachment( - ciphertext: data, - key: key, - digest: digest, - unpaddedSize: attachment.byteCount + /// Update the attachment state + /// + /// **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 + try await dependencies[singleton: .storage].writeAsync { db in + try attachment + .with( + state: .downloaded, + creationTimestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000), + using: dependencies ) + .upserted(db) + + db.addAttachmentEvent( + id: attachment.id, + messageId: job.interactionId, + type: .updated(.state(.downloaded)) ) - }() + } - // Write the data to disk - guard try attachment.write(data: plaintext, using: dependencies) else { - throw AttachmentDownloadError.failedToSaveFile + return scheduler.schedule { + success(job, false) + } + } + catch AttachmentDownloadError.alreadyDownloaded { + return scheduler.schedule { + success(job, false) + } + } + catch JobRunnerError.missingRequiredDetails { + return scheduler.schedule { + failure(job, JobRunnerError.missingRequiredDetails, true) } + } + catch JobRunnerError.possibleDuplicateJob(let permanentFailure) { + return scheduler.schedule { + failure(job, JobRunnerError.possibleDuplicateJob(permanentFailure: permanentFailure), permanentFailure) + } + } + catch { + let targetState: Attachment.State + let permanentFailure: Bool - // Remove the temporary file - try? dependencies[singleton: .fileManager].removeItem(atPath: temporaryFileUrl.path) + switch error { + /// 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 NetworkError.notFound: + targetState = .invalid + permanentFailure = true + + /// 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 + case NetworkError.badRequest, NetworkError.unauthorised, + SnodeAPIError.signatureVerificationFailed: + 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: + targetState = .failedDownload + permanentFailure = false + } - return attachment - } - .flatMapStorageWritePublisher(using: dependencies) { db, attachment in - /// Update the attachment state + /// 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 - let updatedAttachment: Attachment = try attachment - .with( - state: .downloaded, - creationTimestamp: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000), - using: dependencies + try? await dependencies[singleton: .storage].writeAsync { db in + _ = try Attachment + .filter(id: details.attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: targetState)) + db.addAttachmentEvent( + id: details.attachmentId, + messageId: job.interactionId, + type: .updated(.state(targetState)) ) - .upserted(db) - db.addAttachmentEvent( - id: attachment.id, - messageId: job.interactionId, - type: .updated(.state(.downloaded)) - ) + } - return updatedAttachment - } - .sinkUntilComplete( - receiveCompletion: { result in - switch (result, result.errorOrNull, result.errorOrNull as? JobRunnerError) { - case (.finished, _, _): success(job, false) - case (_, let error as AttachmentDownloadError, _) where error == .alreadyDownloaded: - success(job, false) - - case (_, _, .missingRequiredDetails): - failure(job, JobRunnerError.missingRequiredDetails, true) - - case (_, _, .possibleDuplicateJob(let permanentFailure)): - failure(job, JobRunnerError.possibleDuplicateJob(permanentFailure: permanentFailure), permanentFailure) - - case (.failure(let error), _, _): - let targetState: Attachment.State - let permanentFailure: Bool - - switch error { - /// 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 NetworkError.notFound: - targetState = .invalid - permanentFailure = true - - /// 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 - case NetworkError.badRequest, NetworkError.unauthorised, - SnodeAPIError.signatureVerificationFailed: - 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: - 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 - dependencies[singleton: .storage].writeAsync( - updates: { db in - _ = try Attachment - .filter(id: details.attachmentId) - .updateAll(db, Attachment.Columns.state.set(to: targetState)) - db.addAttachmentEvent( - id: details.attachmentId, - messageId: job.interactionId, - type: .updated(.state(targetState)) - ) - }, - completion: { _ in - /// Trigger the failure and provide the `permanentFailure` value defined above - failure(job, error, permanentFailure) - } - ) - } + /// Trigger the failure and provide the `permanentFailure` value defined above + return scheduler.schedule { + failure(job, error, permanentFailure) } - ) + } + } } } diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index 0bac4afff2..27af612ffa 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import UniformTypeIdentifiers import Combine import GRDB import SessionNetworkingKit @@ -56,82 +57,32 @@ public enum AttachmentUploadJob: JobExecutor { } try Task.checkCancellation() - let authMethod: AuthenticationMethod = try await dependencies[singleton: .storage].writeAsync { db in - /// If this upload is related to sending a message then trigger the `handleMessageWillSend` logic as if - /// this is a retry the logic wouldn't run until after the upload has completed resulting in a potentially incorrect - /// delivery status + let authMethod: AuthenticationMethod = try await dependencies[singleton: .storage].readAsync { db in let threadVariant: SessionThread.Variant = try SessionThread .select(.variant) .filter(id: threadId) .asRequest(of: SessionThread.Variant.self) .fetchOne(db, orThrow: StorageError.objectNotFound) - let authMethod: AuthenticationMethod = try Authentication.with( + return try Authentication.with( db, threadId: threadId, threadVariant: threadVariant, using: dependencies ) - - guard - let sendJob: Job = try Job.fetchOne(db, id: details.messageSendJobId), - let sendJobDetails: Data = sendJob.details, - let details: MessageSendJob.Details = try? JSONDecoder(using: dependencies) - .decode(MessageSendJob.Details.self, from: sendJobDetails) - else { return authMethod } - - MessageSender.handleMessageWillSend( - db, - threadId: threadId, - message: details.message, - destination: details.destination, - interactionId: interactionId, - using: dependencies - ) - - return authMethod } try Task.checkCancellation() - let request: Network.PreparedRequest<(attachment: Attachment, fileId: String)> = try AttachmentUploader.preparedUpload( + try await upload( attachment: attachment, - logCategory: .cat, + threadId: threadId, + interactionId: interactionId, + messageSendJobId: details.messageSendJobId, authMethod: authMethod, + onEvent: standardEventHandling(using: dependencies), using: dependencies ) - - /// If we have a `cachedResponse` (ie. already uploaded) then don't change the attachment state to uploading - /// as it's already been done - if request.cachedResponse == nil { - /// Update the attachment to the `uploading` state - try? await dependencies[singleton: .storage].writeAsync { db in - _ = try? Attachment - .filter(id: details.attachmentId) - .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading)) - db.addAttachmentEvent( - id: details.attachmentId, - messageId: job.interactionId, - type: .updated(.state(.uploading)) - ) - } - } - - // FIXME: Make this async/await when the refactored networking is merged - let response: (attachment: Attachment, fileId: String) = try await request - .send(using: dependencies) - .values - .first(where: { _ in true })?.1 ?? { throw AttachmentError.uploadFailed }() try Task.checkCancellation() - /// Save the updated attachment - try await dependencies[singleton: .storage].writeAsync { db in - try response.attachment.upsert(db) - db.addAttachmentEvent( - id: response.attachment.id, - messageId: job.interactionId, - type: .updated(.state(response.attachment.state)) - ) - } - return scheduler.schedule { success(job, false) } @@ -154,7 +105,7 @@ public enum AttachmentUploadJob: JobExecutor { } } catch { - let triggeredSendFailed: Bool? = try? await dependencies[singleton: .storage].writeAsync { db in + let triggeredMessageSendFailure: Bool? = try? await dependencies[singleton: .storage].writeAsync { db in /// Update the attachment state try Attachment .filter(id: details.attachmentId) @@ -187,7 +138,7 @@ public enum AttachmentUploadJob: JobExecutor { } return scheduler.schedule { - if triggeredSendFailed == false { + if triggeredMessageSendFailure == false { Log.error(.cat, "Failed due to error: \(error)") } @@ -218,3 +169,441 @@ extension AttachmentUploadJob { } } } + +// MARK: - Uploading + +public extension AttachmentUploadJob { + typealias PreparedUpload = ( + request: Network.PreparedRequest, + attachment: PreparedAttachment + ) + + enum Event { + case willUpload(Attachment, threadId: String, interactionId: Int64?, messageSendJobId: Int64?) + case success(Attachment, interactionId: Int64?) + } + + static func preparePriorToUpload( + attachments: [PendingAttachment], + using dependencies: Dependencies + ) throws -> [Attachment] { + return try attachments.compactMap { pendingAttachment in + /// Strip any metadata from the attachment + let preparedAttachment: PreparedAttachment = try pendingAttachment.prepare( + transformations: [ + .stripImageMetadata + ], + using: dependencies + ) + + /// The attachment will have been stored in a temporary location during preparation so we need to move it to the + /// "pending upload" file path (which will be relocated to the deterministic final path after upload) + try dependencies[singleton: .fileManager].moveItem( + atPath: preparedAttachment.temporaryFilePath, + toPath: preparedAttachment.pendingUploadFilePath + ) + + return preparedAttachment.attachment + } + } + + static func link( + _ db: ObservingDatabase, + attachments: [Attachment]?, + toInteractionWithId interactionId: Int64? + ) throws { + guard + let attachments: [Attachment] = attachments, + let interactionId: Int64 = interactionId + else { return } + + try attachments + .enumerated() + .forEach { index, attachment in + let interactionAttachment: InteractionAttachment = InteractionAttachment( + albumIndex: index, + interactionId: interactionId, + attachmentId: attachment.id + ) + + try attachment.insert(db) + try interactionAttachment.insert(db) + } + } + + @discardableResult + static func upload( + attachment: Attachment, + threadId: String, + interactionId: Int64?, + messageSendJobId: Int64?, + authMethod: AuthenticationMethod, + onEvent: ((Event) async throws -> Void)?, + using dependencies: Dependencies + ) async throws -> Attachment { + let shouldEncrypt: Bool = { + switch authMethod { + case is Authentication.community: return false + default: return true + } + }() + + /// This can occur if an `AttachmentUploadJob` was explicitly created for a message dependant on the attachment being + /// uploaded (in this case the attachment has already been uploaded so just succeed) + if + attachment.state == .uploaded, + Network.FileServer.fileId(for: attachment.downloadUrl) != nil + { + return attachment + } + + /// 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 currently encrypted + /// + /// **Note:** The most common cases for this will be for `LinkPreviews` + if + attachment.state == .downloaded, + Network.FileServer.fileId(for: attachment.downloadUrl) != nil, + ( + !shouldEncrypt || + attachment.encryptionKey != nil + ) + { + return attachment + } + + /// If we have gotten here then we need to upload + try await onEvent?(.willUpload(attachment, threadId: threadId, interactionId: interactionId, messageSendJobId: messageSendJobId)) + try Task.checkCancellation() + + /// Encrypt the attachment if needed + let pendingAttachment: PendingAttachment = try PendingAttachment( + attachment: attachment, + using: dependencies + ) + let preparedAttachment: PreparedAttachment = try pendingAttachment.prepare( + transformations: Set([ + // FIXME: Remove the `legacy` encryption option + (shouldEncrypt ? .encrypt(legacy: true, domain: .attachment) : nil) + ].compactMap { $0 }), + using: dependencies + ) + let maybePreparedData: Data? = dependencies[singleton: .fileManager] + .contents(atPath: preparedAttachment.temporaryFilePath) + try Task.checkCancellation() + + guard let preparedData: Data = maybePreparedData else { + Log.error(.cat, "Couldn't retrieve prepared attachment data.") + throw AttachmentError.invalidData + } + + /// Ensure the file size is smaller than our upload limit + Log.info(.cat, "File size: \(preparedData.count) bytes.") + guard preparedData.count <= Network.maxFileSize else { + throw NetworkError.maxFileSizeExceeded + } + + let request: Network.PreparedRequest + + /// Return the request and the prepared attachment + switch authMethod { + case let communityAuth as Authentication.community: + request = try Network.SOGS.preparedUpload( + data: preparedData, + roomToken: communityAuth.roomToken, + authMethod: communityAuth, + using: dependencies + ) + + default: + // TODO: Handle custom URLs + request = try Network.preparedUpload(data: preparedData, using: dependencies) + } + + // FIXME: Make this async/await when the refactored networking is merged + let response: FileUploadResponse = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw AttachmentError.uploadFailed }() + try Task.checkCancellation() + + /// If the `downloadUrl` previously had a value and we are updating it then we need to move the file from it's current location + /// to the hash that would be generated for the new location + let finalDownloadUrl: String = { + let isPlaceholderUploadUrl: Bool = dependencies[singleton: .attachmentManager] + .isPlaceholderUploadUrl(preparedAttachment.attachment.downloadUrl) + + switch (preparedAttachment.attachment.downloadUrl, isPlaceholderUploadUrl, authMethod) { + case (.some(let downloadUrl), false, _): return downloadUrl + case (_, _, let community as Authentication.community): + return Network.SOGS.downloadUrlString( + for: response.id, + server: community.server, + roomToken: community.roomToken + ) + + default: + // TODO: Handle Custom URLs + return Network.FileServer.downloadUrlString(for: response.id) + } + }() + + if + let oldUrl: String = preparedAttachment.attachment.downloadUrl, + finalDownloadUrl != oldUrl, + let oldPath: String = try? dependencies[singleton: .attachmentManager].path(for: oldUrl), + let newPath: String = try? dependencies[singleton: .attachmentManager].path(for: finalDownloadUrl) + { + try dependencies[singleton: .fileManager].moveItem(atPath: oldPath, toPath: newPath) + } + + /// Generate the final uploaded attachment data and trigger the success callback + let uploadedAttachment: Attachment = Attachment( + id: preparedAttachment.attachment.id, + serverId: response.id, + variant: preparedAttachment.attachment.variant, + state: .uploaded, + contentType: preparedAttachment.attachment.contentType, + byteCount: preparedAttachment.attachment.byteCount, + creationTimestamp: ( + preparedAttachment.attachment.creationTimestamp ?? + (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + ), + sourceFilename: preparedAttachment.attachment.sourceFilename, + downloadUrl: finalDownloadUrl, + width: preparedAttachment.attachment.width, + height: preparedAttachment.attachment.height, + duration: preparedAttachment.attachment.duration, + isVisualMedia: preparedAttachment.attachment.isVisualMedia, + isValid: preparedAttachment.attachment.isValid, + encryptionKey: preparedAttachment.attachment.encryptionKey, + digest: preparedAttachment.attachment.digest + ) + try await onEvent?(.success(uploadedAttachment, interactionId: interactionId)) + try Task.checkCancellation() + + return uploadedAttachment + } + + @available(*, deprecated, message: "Replace with an async/await call to `upload`") + static func preparedUpload( + attachment: Attachment, + authMethod: AuthenticationMethod, + using dependencies: Dependencies + ) throws -> (request: Network.PreparedRequest, attachment: PreparedAttachment) { + let endpoint: (any EndpointType) = { + switch authMethod { + case let community as Authentication.community: + return Network.SOGS.Endpoint.roomFile(community.roomToken) + + default: return Network.FileServer.Endpoint.file + } + }() + let shouldEncrypt: Bool = { + switch authMethod { + case is Authentication.community: return false + default: return true + } + }() + + /// This can occur if an `AttachmentUploadJob` was explicitly created for a message dependant on the attachment being + /// uploaded (in this case the attachment has already been uploaded so just succeed) + if + attachment.state == .uploaded, + let fileId: String = Network.FileServer.fileId(for: attachment.downloadUrl) + { + return ( + try Network.PreparedRequest.cached( + FileUploadResponse(id: fileId, uploaded: nil, expires: nil), + endpoint: endpoint, + using: dependencies + ), + PreparedAttachment( + attachment: attachment, + temporaryFilePath: "", + pendingUploadFilePath: "" + ) + ) + } + + /// 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 then encrypt it + /// + /// **Note:** The most common cases for this will be for `LinkPreviews` + if + attachment.state == .downloaded, + let fileId: String = Network.FileServer.fileId(for: attachment.downloadUrl), + ( + !shouldEncrypt || ( + attachment.encryptionKey != nil && + attachment.digest != nil + ) + ) + { + return ( + try Network.PreparedRequest.cached( + FileUploadResponse(id: fileId, uploaded: nil, expires: nil), + endpoint: endpoint, + using: dependencies + ), + PreparedAttachment( + attachment: attachment, + temporaryFilePath: "", + pendingUploadFilePath: "" + ) + ) + } + + /// Encrypt the attachment if needed + let pendingAttachment: PendingAttachment = try PendingAttachment( + attachment: attachment, + using: dependencies + ) + let preparedAttachment: PreparedAttachment = try pendingAttachment.prepare( + transformations: Set([ + // FIXME: Remove the `legacy` encryption option + (shouldEncrypt ? .encrypt(legacy: true, domain: .attachment) : nil) + ].compactMap { $0 }), + using: dependencies + ) + let maybePreparedData: Data? = dependencies[singleton: .fileManager] + .contents(atPath: preparedAttachment.temporaryFilePath) + + guard let preparedData: Data = maybePreparedData else { + Log.error(.cat, "Couldn't retrieve prepared attachment data.") + throw AttachmentError.invalidData + } + + /// Ensure the file size is smaller than our upload limit + Log.info(.cat, "File size: \(preparedData.count) bytes.") + guard preparedData.count <= Network.maxFileSize else { throw NetworkError.maxFileSizeExceeded } + + /// Return the request and the prepared attachment + switch authMethod { + case let communityAuth as Authentication.community: + return ( + try Network.SOGS.preparedUpload( + data: preparedData, + roomToken: communityAuth.roomToken, + authMethod: communityAuth, + using: dependencies + ), + preparedAttachment + ) + + default: + return ( + try Network.preparedUpload(data: preparedData, using: dependencies), + preparedAttachment + ) + } + } + + @available(*, deprecated, message: "Replace with an async/await call to `upload`") + static func processUploadResponse( + preparedAttachment: PreparedAttachment, + authMethod: AuthenticationMethod, + response: FileUploadResponse, + using dependencies: Dependencies + ) throws -> Attachment { + /// If the `downloadUrl` previously had a value and we are updating it then we need to move the file from it's current location + /// to the hash that would be generated for the new location + let finalDownloadUrl: String = { + let isPlaceholderUploadUrl: Bool = dependencies[singleton: .attachmentManager] + .isPlaceholderUploadUrl(preparedAttachment.attachment.downloadUrl) + + switch (preparedAttachment.attachment.downloadUrl, isPlaceholderUploadUrl, authMethod) { + case (.some(let downloadUrl), false, _): return downloadUrl + case (_, _, let community as Authentication.community): + return Network.SOGS.downloadUrlString( + for: response.id, + server: community.server, + roomToken: community.roomToken + ) + + default: + return Network.FileServer.downloadUrlString(for: response.id) + } + }() + + if + let oldUrl: String = preparedAttachment.attachment.downloadUrl, + finalDownloadUrl != oldUrl, + let oldPath: String = try? dependencies[singleton: .attachmentManager].path(for: oldUrl), + let newPath: String = try? dependencies[singleton: .attachmentManager].path(for: finalDownloadUrl) + { + try dependencies[singleton: .fileManager].moveItem(atPath: oldPath, toPath: newPath) + } + + return Attachment( + id: preparedAttachment.attachment.id, + serverId: response.id, + variant: preparedAttachment.attachment.variant, + state: .uploaded, + contentType: preparedAttachment.attachment.contentType, + byteCount: preparedAttachment.attachment.byteCount, + creationTimestamp: ( + preparedAttachment.attachment.creationTimestamp ?? + (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + ), + sourceFilename: preparedAttachment.attachment.sourceFilename, + downloadUrl: finalDownloadUrl, + width: preparedAttachment.attachment.width, + height: preparedAttachment.attachment.height, + duration: preparedAttachment.attachment.duration, + isVisualMedia: preparedAttachment.attachment.isVisualMedia, + isValid: preparedAttachment.attachment.isValid, + encryptionKey: preparedAttachment.attachment.encryptionKey, + digest: preparedAttachment.attachment.digest + ) + } + + /// This function performs the standard database actions when various upload events occur + /// + /// Returns `true` if the event resulted in a `MessageSendJob` being updated + static func standardEventHandling(using dependencies: Dependencies) -> ((Event) async throws -> Void) { + return { event in + try await dependencies[singleton: .storage].writeAsync { db in + switch event { + case .willUpload(let attachment, let threadId, let interactionId, let messageSendJobId): + _ = try? Attachment + .filter(id: attachment.id) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading)) + db.addAttachmentEvent( + id: attachment.id, + messageId: interactionId, + type: .updated(.state(.uploading)) + ) + + /// If this upload is related to sending a message then trigger the `handleMessageWillSend` logic as if + /// this is a retry the logic wouldn't run until after the upload has completed resulting in a potentially incorrect + /// delivery status + guard + let sendJob: Job = try Job.fetchOne(db, id: messageSendJobId), + let sendJobDetails: Data = sendJob.details, + let details: MessageSendJob.Details = try? JSONDecoder(using: dependencies) + .decode(MessageSendJob.Details.self, from: sendJobDetails) + else { return } + + MessageSender.handleMessageWillSend( + db, + threadId: threadId, + message: details.message, + destination: details.destination, + interactionId: interactionId, + using: dependencies + ) + + case .success(let updatedAttachment, let interactionId): + try updatedAttachment.upsert(db) + + db.addAttachmentEvent( + id: updatedAttachment.id, + messageId: interactionId, + type: .updated(.state(updatedAttachment.state)) + ) + } + } + } + } +} diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index fd2b544796..bf6e8ad2ff 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -34,147 +34,159 @@ public enum DisplayPictureDownloadJob: JobExecutor { let details: Details = try? JSONDecoder(using: dependencies).decode(Details.self, from: detailsData) else { return failure(job, JobRunnerError.missingRequiredDetails, true) } - dependencies[singleton: .storage] - .readPublisher { db -> Network.PreparedRequest in - switch details.target { - case .profile(_, let url, _), .group(_, let url, _): - guard - let fileId: String = Network.FileServer.fileId(for: url), - let downloadUrl: URL = URL(string: Network.FileServer.downloadUrlString(for: url, fileId: fileId)) - else { throw NetworkError.invalidURL } - - return try Network.preparedDownload( - url: downloadUrl, - using: dependencies - ) - - case .community(let fileId, let roomToken, let server, let skipAuthentication): - guard - let info: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo - .fetchOne(db, id: OpenGroup.idFor(roomToken: roomToken, server: server)) - else { throw JobRunnerError.missingRequiredDetails } - - return try Network.SOGS.preparedDownload( - fileId: fileId, - roomToken: roomToken, - authMethod: Authentication.community(info: info), - skipAuthentication: skipAuthentication, - using: dependencies - ) + Task { + do { + let request: Network.PreparedRequest = try await dependencies[singleton: .storage].readAsync { db in + switch details.target { + case .profile(_, let url, _), .group(_, let url, _): + // TODO: Support custom URLs + guard + let fileId: String = Network.FileServer.fileId(for: url), + let downloadUrl: URL = URL(string: Network.FileServer.downloadUrlString(for: url, fileId: fileId)) + else { throw NetworkError.invalidURL } + + return try Network.preparedDownload( + url: downloadUrl, + using: dependencies + ) + + case .community(let fileId, let roomToken, let server, let skipAuthentication): + guard + let info: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo + .fetchOne(db, id: OpenGroup.idFor(roomToken: roomToken, server: server)) + else { throw JobRunnerError.missingRequiredDetails } + + return try Network.SOGS.preparedDownload( + fileId: fileId, + roomToken: roomToken, + authMethod: Authentication.community(info: info), + skipAuthentication: skipAuthentication, + using: dependencies + ) + } } - } - .tryMap { (preparedDownload: Network.PreparedRequest) -> Network.PreparedRequest<(Data, String, URL?)> in - let downloadUrl: URL? = try? preparedDownload.generateUrl() + try Task.checkCancellation() - guard - let filePath: String = try? dependencies[singleton: .displayPictureManager].path( - for: (downloadUrl?.absoluteString ?? preparedDownload.path) - ) - else { throw DisplayPictureError.invalidPath } + let downloadUrl: URL? = try? request.generateUrl() + let filePath: String = try dependencies[singleton: .displayPictureManager] + .path(for: (downloadUrl?.absoluteString ?? request.path)) guard !dependencies[singleton: .fileManager].fileExists(atPath: filePath) else { - throw DisplayPictureError.alreadyDownloaded(downloadUrl) + throw AttachmentError.alreadyDownloaded(downloadUrl) } - return preparedDownload.map { _, data in - (data, filePath, downloadUrl) - } - } - .flatMap { $0.send(using: dependencies) } - .map { _, result in result } - .subscribe(on: scheduler, using: dependencies) - .receive(on: scheduler, using: dependencies) - .flatMapStorageReadPublisher(using: dependencies) { (db: ObservingDatabase, result: (Data, String, URL?)) -> (Data, String, URL?) in + // FIXME: Make this async/await when the refactored networking is merged + let response: Data = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw AttachmentError.downloadFailed }() + try Task.checkCancellation() + /// Check to make sure this download is still a valid update - guard details.isValidUpdate(db, using: dependencies) else { - throw DisplayPictureError.updateNoLongerValid + try await dependencies[singleton: .storage].readAsync { db in + try details.ensureValidUpdate(db, using: dependencies) } - return result - } - .tryMap { (data: Data, filePath: String, downloadUrl: URL?) -> URL? in + /// Get the decrypted data guard let decryptedData: Data = { switch details.target { - case .community: return data // Community data is unencrypted + case .community: return response /// Community data is unencrypted case .profile(_, _, let encryptionKey), .group(_, _, let encryptionKey): return dependencies[singleton: .crypto].generate( - .decryptedDataDisplayPicture(data: data, key: encryptionKey) + .decryptedDataDisplayPicture(data: response, key: encryptionKey) ) } }() - else { throw DisplayPictureError.writeFailed } + else { throw AttachmentError.writeFailed } + /// Ensure it's a valid image guard UIImage(data: decryptedData) != nil, dependencies[singleton: .fileManager].createFile( atPath: filePath, contents: decryptedData ) - else { throw DisplayPictureError.loadFailed } + else { throw AttachmentError.invalidData } /// Kick off a task to load the image into the cache (assuming we want to render it soon) - Task(priority: .userInitiated) { + Task.detached(priority: .userInitiated) { await dependencies[singleton: .imageDataManager].load( .url(URL(fileURLWithPath: filePath)) ) } - return downloadUrl - } - .flatMapStorageWritePublisher(using: dependencies) { (db: ObservingDatabase, downloadUrl: URL?) in /// Store the updated information in the database (this will generally result in the UI refreshing as it'll observe /// the `downloadUrl` changing) - try writeChanges( - db, - details: details, - downloadUrl: downloadUrl, - using: dependencies - ) + try await dependencies[singleton: .storage].writeAsync { db in + try writeChanges( + db, + details: details, + downloadUrl: downloadUrl, + using: dependencies + ) + } + + return scheduler.schedule { + success(job, false) + } } - .sinkUntilComplete( - receiveCompletion: { result in - switch (result, result.errorOrNull, result.errorOrNull as? DisplayPictureError) { - case (.finished, _, _): success(job, false) - case (_, _, .updateNoLongerValid): success(job, false) - case (_, _, .alreadyDownloaded(let downloadUrl)): - /// If the file already exists then write the changes to the database - dependencies[singleton: .storage].writeAsync( - updates: { db in - try writeChanges( - db, - details: details, - downloadUrl: downloadUrl, - using: dependencies - ) - }, - completion: { result in - switch result { - case .success: success(job, false) - case .failure(let error): failure(job, error, true) - } - } - ) - - case (_, let error as JobRunnerError, _) where error == .missingRequiredDetails: - failure(job, error, true) - - case (_, _, .invalidPath): - Log.error(.cat, "Failed to generate display picture file path for \(details.target)") - failure(job, DisplayPictureError.invalidPath, true) - - case (_, _, .writeFailed): - Log.error(.cat, "Failed to decrypt display picture for \(details.target)") - failure(job, DisplayPictureError.writeFailed, true) - - case (_, _, .loadFailed): - Log.error(.cat, "Failed to load display picture for \(details.target)") - failure(job, DisplayPictureError.loadFailed, true) - - case (.failure(let error), _, _): failure(job, error, true) + catch AttachmentError.downloadNoLongerValid { + return scheduler.schedule { + success(job, false) + } + } + catch AttachmentError.alreadyDownloaded(let downloadUrl) { + /// If the file already exists then write the changes to the database + do { + try await dependencies[singleton: .storage].writeAsync { db in + try writeChanges( + db, + details: details, + downloadUrl: downloadUrl, + using: dependencies + ) + } + + return scheduler.schedule { + success(job, false) } } - ) + catch { + return scheduler.schedule { + failure(job, error, true) + } + } + } + catch JobRunnerError.missingRequiredDetails { + return scheduler.schedule { + failure(job, JobRunnerError.missingRequiredDetails, true) + } + } + catch AttachmentError.invalidPath { + return scheduler.schedule { + Log.error(.cat, "Failed to generate display picture file path for \(details.target)") + failure(job, AttachmentError.invalidPath, true) + } + } + catch AttachmentError.writeFailed { + return scheduler.schedule { + Log.error(.cat, "Failed to decrypt display picture for \(details.target)") + failure(job, AttachmentError.writeFailed, true) + } + } + catch AttachmentError.invalidData { + return scheduler.schedule { + Log.error(.cat, "Failed to load display picture for \(details.target)") + failure(job, AttachmentError.invalidData, true) + } + } + catch { + return scheduler.schedule { + failure(job, error, true) + } + } + } } private static func writeChanges( @@ -337,10 +349,12 @@ extension DisplayPictureDownloadJob { // MARK: - Functions - fileprivate func isValidUpdate(_ db: ObservingDatabase, using dependencies: Dependencies) -> Bool { + fileprivate func ensureValidUpdate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { switch self.target { case .profile(let id, let url, let encryptionKey): - guard let latestProfile: Profile = try? Profile.fetchOne(db, id: id) else { return false } + guard let latestProfile: Profile = try? Profile.fetchOne(db, id: id) else { + throw AttachmentError.downloadNoLongerValid + } /// If the data matches what is stored in the database then we should be fine to consider it valid (it may be that /// we are re-downloading a profile due to some invalid state) @@ -349,10 +363,12 @@ extension DisplayPictureDownloadJob { url == latestProfile.displayPictureUrl ) - return ( + guard Profile.shouldUpdateProfile(timestamp, profile: latestProfile, using: dependencies) || - dataMatches - ) + dataMatches + else { throw AttachmentError.downloadNoLongerValid } + + break case .group(let id, let url,_): /// Groups now rely on a `GroupInfo` config message which has a proper `seqNo` so we don't need any @@ -361,10 +377,11 @@ extension DisplayPictureDownloadJob { guard let latestDisplayPictureUrl: String = dependencies.mutate(cache: .libSession, { cache in cache.displayPictureUrl(threadId: id, threadVariant: .group) - }) - else { return false } + }), + url == latestDisplayPictureUrl + else { throw AttachmentError.downloadNoLongerValid } - return (url == latestDisplayPictureUrl) + break case .community(let imageId, let roomToken, let server, _): guard @@ -372,10 +389,11 @@ extension DisplayPictureDownloadJob { .select(.imageId) .filter(id: OpenGroup.idFor(roomToken: roomToken, server: server)) .asRequest(of: String.self) - .fetchOne(db) - else { return false } + .fetchOne(db), + imageId == latestImageId + else { throw AttachmentError.downloadNoLongerValid } - return (imageId == latestImageId) + break } } } diff --git a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift index c72dd835e9..01f0436c1f 100644 --- a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift +++ b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift @@ -84,7 +84,7 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { let response: FileUploadResponse = try await request .send(using: dependencies) .values - .first(where: { _ in true })?.1 ?? { throw DisplayPictureError.uploadFailed }() + .first(where: { _ in true })?.1 ?? { throw AttachmentError.uploadFailed }() /// Even though the data hasn't changed, we need to trigger `Profile.UpdateLocal` in order for the /// `profileLastUpdated` value to be updated correctly @@ -150,7 +150,7 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { transformations: [ .convertToStandardFormats, .resize(maxDimension: DisplayPictureManager.maxDimension), - .encrypt(legacy: true) // FIXME: Remove the `legacy` encryption option + .encrypt(legacy: true, domain: .profilePicture) // FIXME: Remove the `legacy` encryption option ] ) let result = try await dependencies[singleton: .displayPictureManager] diff --git a/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift b/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift deleted file mode 100644 index f816f9038d..0000000000 --- a/SessionMessagingKit/Sending & Receiving/AttachmentUploader.swift +++ /dev/null @@ -1,246 +0,0 @@ -// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine -import GRDB -import SessionNetworkingKit -import SessionUtilitiesKit - -// MARK: - AttachmentUploader - -public final class AttachmentUploader { - private enum Destination { - case fileServer - case community(roomToken: String, server: String) - - var shouldEncrypt: Bool { - switch self { - case .fileServer: return true - case .community: return false - } - } - } - - public static func prepare(attachments: [SignalAttachment], using dependencies: Dependencies) -> [Attachment] { - return attachments.compactMap { signalAttachment in - Attachment( - variant: (signalAttachment.isVoiceMessage ? - .voiceMessage : - .standard - ), - contentType: signalAttachment.mimeType, - dataSource: signalAttachment.dataSource, - sourceFilename: signalAttachment.sourceFilename, - using: dependencies - ) - } - } - - public static func process( - _ db: ObservingDatabase, - attachments: [Attachment]?, - for interactionId: Int64? - ) throws { - guard - let attachments: [Attachment] = attachments, - let interactionId: Int64 = interactionId - else { return } - - try attachments - .enumerated() - .forEach { index, attachment in - let interactionAttachment: InteractionAttachment = InteractionAttachment( - albumIndex: index, - interactionId: interactionId, - attachmentId: attachment.id - ) - - try attachment.insert(db) - try interactionAttachment.insert(db) - } - } - - public static func preparedUpload( - attachment: Attachment, - logCategory cat: Log.Category?, - authMethod: AuthenticationMethod, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest<(attachment: Attachment, fileId: String)> { - typealias UploadInfo = ( - attachment: Attachment, - preparedRequest: Network.PreparedRequest, - encryptionKey: Data?, - digest: Data? - ) - typealias EncryptionData = (ciphertext: Data, encryptionKey: Data, digest: Data) - - // Generate the correct upload info based on the state of the attachment - let destination: AttachmentUploader.Destination = { - switch authMethod { - case let auth as Authentication.community: - return .community(roomToken: auth.roomToken, server: auth.server) - - default: return .fileServer - } - }() - let uploadInfo: UploadInfo = try { - let endpoint: (any EndpointType) = { - switch destination { - case .fileServer: return Network.FileServer.Endpoint.file - case .community(let roomToken, _): return Network.SOGS.Endpoint.roomFile(roomToken) - } - }() - - // This can occur if an AttachmentUploadJob was explicitly created for a message - // dependant on the attachment being uploaded (in this case the attachment has - // already been uploaded so just succeed) - if attachment.state == .uploaded, let fileId: String = Network.FileServer.fileId(for: attachment.downloadUrl) { - return ( - attachment, - try Network.PreparedRequest.cached( - FileUploadResponse(id: fileId, uploaded: nil, expires: nil), - endpoint: endpoint, - using: dependencies - ), - attachment.encryptionKey, - attachment.digest - ) - } - - // 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 then encrypt it - // - // Note: The most common cases for this will be for LinkPreviews or Quotes - if - attachment.state == .downloaded, - let fileId: String = Network.FileServer.fileId(for: attachment.downloadUrl), - ( - !destination.shouldEncrypt || ( - attachment.encryptionKey != nil && - attachment.digest != nil - ) - ) - { - return ( - attachment, - try Network.PreparedRequest.cached( - FileUploadResponse(id: fileId, uploaded: nil, expires: nil), - endpoint: endpoint, - using: dependencies - ), - attachment.encryptionKey, - attachment.digest - ) - } - - // Encrypt the attachment if needed - let pendingAttachment: PendingAttachment = try PendingAttachment( - attachment: attachment, - using: dependencies - ) - let finalData: Data - let encryptionKey: Data? - let digest: Data? - - if destination.shouldEncrypt { - let preparedAttachment: PreparedAttachment = try pendingAttachment.prepare( - transformations: [ - .encrypt(legacy: true) // FIXME: Remove the `legacy` encryption option - ], - using: dependencies - ) - let maybeEncryptedData: Data? = dependencies[singleton: .fileManager] - .contents(atPath: preparedAttachment.temporaryFilePath) - - guard let encryptedData: Data = maybeEncryptedData else { - Log.error([cat].compactMap { $0 }, "Couldn't encrypt attachment.") - throw AttachmentError.encryptionFailed - } - - - finalData = encryptedData - encryptionKey = preparedAttachment.attachment.encryptionKey - digest = preparedAttachment.attachment.digest - } - else { - // Get the raw attachment data - guard let rawData: Data = try? attachment.readDataFromFile(using: dependencies) else { - Log.error([cat].compactMap { $0 }, "Couldn't read attachment from disk.") - throw AttachmentError.noAttachment - } - - finalData = rawData - encryptionKey = nil - digest = nil - } - - // Ensure the file size is smaller than our upload limit - Log.info([cat].compactMap { $0 }, "File size: \(finalData.count) bytes.") - guard finalData.count <= Network.maxFileSize else { throw NetworkError.maxFileSizeExceeded } - - // Generate the request - switch destination { - case .fileServer: - return ( - attachment, - try Network.preparedUpload(data: finalData, using: dependencies), - encryptionKey, - digest - ) - - case .community(let roomToken, _): - return ( - attachment, - try Network.SOGS.preparedUpload( - data: finalData, - roomToken: roomToken, - authMethod: authMethod, - using: dependencies - ), - encryptionKey, - digest - ) - } - }() - - return uploadInfo.preparedRequest.map { _, response in - /// Generate the updated attachment info - /// - /// **Note:** We **MUST** use the `.with` function here to ensure the `isValid` flag is - /// updated correctly - let updatedAttachment: Attachment = uploadInfo.attachment - .with( - serverId: response.id, - state: .uploaded, - creationTimestamp: ( - uploadInfo.attachment.creationTimestamp ?? - (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) - ), - downloadUrl: { - let isPlaceholderUploadUrl: Bool = dependencies[singleton: .attachmentManager] - .isPlaceholderUploadUrl(uploadInfo.attachment.downloadUrl) - - switch (uploadInfo.attachment.downloadUrl, isPlaceholderUploadUrl, destination) { - case (.some(let downloadUrl), false, _): return downloadUrl - case (_, _, .fileServer): - return Network.FileServer.downloadUrlString(for: response.id) - - case (_, _, .community(let roomToken, let server)): - return Network.SOGS.downloadUrlString( - for: response.id, - server: server, - roomToken: roomToken - ) - } - }(), - encryptionKey: uploadInfo.encryptionKey, - digest: uploadInfo.digest, - using: dependencies - ) - - return (updatedAttachment, response.id) - } - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift index 52cc00622b..0bd56eaf12 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift @@ -10,8 +10,12 @@ public enum AttachmentError: Error, CustomStringConvertible { case noAttachment case notUploaded case encryptionFailed + case legacyEncryptionFailed + case legacyDecryptionFailed + case notEncrypted case uploadIsStillPendingDownload case uploadFailed + case downloadFailed case missingData case fileSizeTooLarge @@ -25,9 +29,13 @@ public enum AttachmentError: Error, CustomStringConvertible { case invalidAttachmentSource case invalidPath case writeFailed + case alreadyDownloaded(URL?) + case downloadNoLongerValid + case databaseChangesFailed case invalidMediaSource case invalidDimensions + case invalidDuration case invalidImageData public var description: String { @@ -36,14 +44,22 @@ public enum AttachmentError: Error, CustomStringConvertible { case .noAttachment: return "No such attachment." case .notUploaded: return "Attachment not uploaded." case .encryptionFailed: return "Couldn't encrypt file." + case .legacyEncryptionFailed: return "Couldn't encrypt file (legacy)." + case .legacyDecryptionFailed: return "Couldn't decrypt file (legacy)." + case .notEncrypted: return "File not encrypted." case .uploadIsStillPendingDownload: return "Upload is still pending download." case .uploadFailed: return "Upload failed." + case .downloadFailed: return "Download failed." case .invalidAttachmentSource: return "Invalid attachment source." case .invalidPath: return "Failed to generate a valid path." case .writeFailed: return "Failed to write to disk." + case .alreadyDownloaded: return "File already downloaded." + case .downloadNoLongerValid: return "Download is no longer valid." + case .databaseChangesFailed: return "Database changes failed." case .invalidMediaSource: return "Invalid media source." case .invalidDimensions: return "Invalid dimensions." + case .invalidDuration: return "Invalid duration." case .fileSizeTooLarge: return "attachmentsErrorSize".localized() case .invalidData, .missingData, .invalidFileFormat, .invalidImageData: diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index ed23c6ff36..5f5e12c500 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -7,6 +7,7 @@ import Combine import UniformTypeIdentifiers import GRDB import SDWebImageWebPCoder +import SessionUtil import SessionUIKit import SessionNetworkingKit import SessionUtilitiesKit @@ -39,7 +40,7 @@ public final class AttachmentManager: Sendable, ThumbnailManager { self.dependencies = dependencies } - // MARK: - General + // MARK: - File Paths public func sharedDataAttachmentsDirPath() -> String { let path: String = URL(fileURLWithPath: SessionFileManager.nonInjectedAppSharedDataDirectoryPath) @@ -50,7 +51,14 @@ public final class AttachmentManager: Sendable, ThumbnailManager { return path } - // MARK: - File Paths + private func placeholderUrlPath() -> String { + let path: String = URL(fileURLWithPath: sharedDataAttachmentsDirPath()) + .appendingPathComponent("uploadPlaceholderUrl") // stringlint:ignore + .path + try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: path) + + return path + } /// **Note:** Generally the url we get won't have an extension and we don't want to make assumptions until we have the actual /// image data so generate a name for the file and then determine the extension separately @@ -58,8 +66,12 @@ public final class AttachmentManager: Sendable, ThumbnailManager { guard let urlString: String = urlString, !urlString.isEmpty - else { throw DisplayPictureError.invalidCall } + else { throw AttachmentError.invalidPath } + + /// If the provided url is a placeholder url then it _is_ a valid path, so we should just return it directly + guard !isPlaceholderUploadUrl(urlString) else { return urlString } + /// Otherwise we need to generate the deterministic file path based on the url provided let urlHash = try dependencies[singleton: .crypto] .tryGenerate(.hash(message: Array(urlString.utf8))) .toHexString() @@ -69,20 +81,19 @@ public final class AttachmentManager: Sendable, ThumbnailManager { .path } - private func placeholderUrlPath() -> String { - return URL(fileURLWithPath: sharedDataAttachmentsDirPath()) - .appendingPathComponent("uploadPlaceholderUrl") // stringlint:ignore - .path - } - - public func pendingUploadFilePath(for id: String) throws -> String { + public func pendingUploadPath(for id: String) throws -> String { return URL(fileURLWithPath: placeholderUrlPath()) .appendingPathComponent(id) .path } - public func isPlaceholderUploadUrl(_ url: String?) -> Bool { - return (url?.hasPrefix(placeholderUrlPath()) == true) + public func isPlaceholderUploadUrl(_ urlString: String?) -> Bool { + guard + let urlString: String = urlString, + let url: URL = URL(string: urlString) + else { return false } + + return url.path.hasPrefix(placeholderUrlPath()) } public func temporaryPathForOpening(downloadUrl: String?, mimeType: String?, sourceFilename: String?) throws -> String { @@ -161,7 +172,7 @@ public final class AttachmentManager: Sendable, ThumbnailManager { // MARK: - ThumbnailManager private func thumbnailUrl(for url: URL, size: ImageDataManager.ThumbnailSize) throws -> URL { - guard !url.lastPathComponent.isEmpty else { throw DisplayPictureError.invalidCall } + guard !url.lastPathComponent.isEmpty else { throw AttachmentError.invalidPath } /// Thumbnails are written to the caches directory, so that iOS can remove them if necessary return URL(fileURLWithPath: SessionFileManager.cachesDirectoryPath) @@ -223,6 +234,7 @@ public struct PendingAttachment: Sendable, Equatable, Hashable { public let source: DataSource public let sourceFilename: String? public let metadata: Metadata? + private let existingAttachmentId: String? public var utType: UTType { metadata?.utType ?? .invalid } public var fileSize: UInt64 { metadata?.fileSize ?? 0 } @@ -249,6 +261,7 @@ public struct PendingAttachment: Sendable, Equatable, Hashable { sourceFilename: sourceFilename, using: dependencies ) + self.existingAttachmentId = nil } public init( @@ -268,10 +281,11 @@ public struct PendingAttachment: Sendable, Equatable, Hashable { self.sourceFilename = attachment.sourceFilename self.metadata = PendingAttachment.metadata( for: source, - utType: UTType(attachment.contentType), + utType: UTType(sessionMimeType: attachment.contentType), sourceFilename: attachment.sourceFilename, using: dependencies ) + self.existingAttachmentId = attachment.id } // MARK: - Internal Functions @@ -294,7 +308,7 @@ public struct PendingAttachment: Sendable, Equatable, Hashable { /// If the url is actually media then try to load `MediaMetadata`, falling back to the `FileMetadata` guard let metadata: MediaUtils.MediaMetadata = MediaUtils.MediaMetadata( - from: url.absoluteString, + from: url.path, utType: utType, sourceFilename: sourceFilename, using: dependencies @@ -374,9 +388,7 @@ public extension PendingAttachment { fileprivate func fileSize(using dependencies: Dependencies) -> UInt64? { switch (self, visualMediaSource) { case (.file(let url), _), (.voiceMessage(let url), _), (_, .url(let url)): - guard let path: String = try? path(for: url, using: dependencies) else { return nil } - - return dependencies[singleton: .fileManager].fileSize(of: path) + return dependencies[singleton: .fileManager].fileSize(of: url.path) case (_, .data(_, let data)): return UInt64(data.count) case (.text(let content), _): @@ -385,16 +397,6 @@ public extension PendingAttachment { default: return nil } } - - private func path(for url: URL, using dependencies: Dependencies) throws -> String { - switch self { - case .displayPicture: - return try dependencies[singleton: .displayPictureManager].path(for: url.absoluteString) - - default: - return try dependencies[singleton: .attachmentManager].path(for: url.absoluteString) - } - } } } @@ -443,17 +445,20 @@ public extension PendingAttachment { public struct PreparedAttachment: Sendable, Equatable, Hashable { public let attachment: Attachment public let temporaryFilePath: String + public let pendingUploadFilePath: String public init( attachment: Attachment, - temporaryFilePath: String + temporaryFilePath: String, + pendingUploadFilePath: String ) { self.attachment = attachment self.temporaryFilePath = temporaryFilePath + self.pendingUploadFilePath = pendingUploadFilePath } } -// MARK: - Conversion +// MARK: - Transforms public extension PendingAttachment { enum Transform: Sendable, Equatable, Hashable { @@ -461,7 +466,7 @@ public extension PendingAttachment { case convertToStandardFormats case resize(maxDimension: CGFloat) case stripImageMetadata - case encrypt(legacy: Bool) + case encrypt(legacy: Bool, domain: Crypto.AttachmentDomain) fileprivate enum Erased: Equatable { case compress @@ -615,36 +620,34 @@ public extension PendingAttachment { } func prepare(transformations: Set, using dependencies: Dependencies) throws -> PreparedAttachment { + /// Perform any source-specific transformations and load the attachment data into memory let preparedData: Data switch source { case .displayPicture: preparedData = try prepareImage(transformations) - case .media where utType.isImage || utType.isAnimated: - preparedData = try prepareImage(transformations) - - // TODO: Custom video processing??? - case .media where utType.isVideo: fatalError() // TODO: Load and encrypt - - // TODO: Custom audio processing??? - case .voiceMessage: fatalError() // TODO: Load and encrypt - case .media where utType.isAudio: fatalError() // TODO: Load and encrypt - case .file, .media, .voiceMessage: fatalError() // TODO: Load and encrypt - case .text: fatalError() // TODO: Encode to file as ASCII? + case .media where utType.isImage: preparedData = try prepareImage(transformations) + case .media where utType.isAnimated: preparedData = try prepareImage(transformations) + case .media where utType.isVideo: preparedData = try prepareVideo(transformations) + case .media where utType.isAudio: preparedData = try prepareAudio(transformations) + case .voiceMessage: preparedData = try prepareAudio(transformations) + case .text: preparedData = try prepareText(transformations) + case .file, .media: preparedData = try prepareGeneral(transformations) } /// Generate the temporary path to use while the upload is pending /// /// **Note:** This is stored alongside other attachments rather that in the temporary directory because the /// `AttachmentUploadJob` can exist between launches, but the temporary directory gets cleared on every launch) - let attachmentId: String = UUID().uuidString - let pendingUploadFilePath: String = try dependencies[singleton: .attachmentManager].pendingUploadFilePath(for: attachmentId) + let attachmentId: String = (existingAttachmentId ?? UUID().uuidString) + let pendingUploadFilePath: String = try dependencies[singleton: .attachmentManager].pendingUploadPath(for: attachmentId) /// If we don't have the `encrypt` transform then we can just return the `preparedData` (which is unencrypted but should /// have all other `Transform` changes applied // FIXME: We should store attachments encrypted and decrypt them when we want to render/open them - guard case .encrypt(let legacyEncryption) = transformations.first(where: { $0.erased == .encrypt }) else { - let filePath: String = try dependencies[singleton: .fileManager] - .write(dataToTemporaryFile: preparedData) + guard case .encrypt(let legacyEncryption, let encryptionDomain) = transformations.first(where: { $0.erased == .encrypt }) else { + let filePath: String = try dependencies[singleton: .fileManager].write( + dataToTemporaryFile: preparedData + ) return PreparedAttachment( attachment: try prepareAttachment( @@ -655,7 +658,8 @@ public extension PendingAttachment { digest: nil, using: dependencies ), - temporaryFilePath: filePath + temporaryFilePath: filePath, + pendingUploadFilePath: pendingUploadFilePath ) } @@ -664,15 +668,30 @@ public extension PendingAttachment { let encryptedData: EncryptionData if legacyEncryption { - // TODO: For legacy encryption do we need to validate the file size here or can we do it earlier??? - encryptedData = try dependencies[singleton: .crypto].tryGenerate( .legacyEncryptAttachment(plaintext: preparedData) ) + + /// May as well throw here if we know the attachment is too large to send + guard encryptedData.ciphertext.count <= Network.maxFileSize else { + throw AttachmentError.fileSizeTooLarge + } } else { - // TODO: This - fatalError() + let encryptedSize: Int = try dependencies[singleton: .crypto].tryGenerate( + .expectedEncryptedAttachmentSize(plaintext: preparedData) + ) + + /// May as well throw here if we know the attachment is too large to send + guard UInt(encryptedSize) <= Network.maxFileSize else { + throw AttachmentError.fileSizeTooLarge + } + + let result = try dependencies[singleton: .crypto].tryGenerate( + .encryptAttachment(plaintext: preparedData, domain: encryptionDomain) + ) + + encryptedData = (result.ciphertext, result.encryptionKey, Data()) } let filePath: String = try dependencies[singleton: .fileManager] @@ -687,7 +706,8 @@ public extension PendingAttachment { digest: encryptedData.digest, using: dependencies ), - temporaryFilePath: filePath + temporaryFilePath: filePath, + pendingUploadFilePath: pendingUploadFilePath ) } @@ -706,7 +726,7 @@ public extension PendingAttachment { /// an impact due to a smaller number of users actually using them) guard mediaMatadata.frameCount == 1 else { switch targetSource { - case .url(let url): return try Data(contentsOf: url, options: [.dataReadingMapped]) + case .url(let url): return try Data(contentsOf: url, options: []) case .data(_, let data): return data /// None of the other source options support animated images so just fail @@ -730,7 +750,7 @@ public extension PendingAttachment { case .url(let url): guard - let imageData: Data = try? Data(contentsOf: url, options: [.dataReadingMapped]), + let imageData: Data = try? Data(contentsOf: url, options: []), let loadedImage = UIImage(data: imageData) else { throw AttachmentError.invalidImageData } @@ -821,6 +841,76 @@ public extension PendingAttachment { return data } + private func prepareVideo(_ transformations: Set) throws -> Data { + guard + let targetSource: ImageDataManager.DataSource = visualMediaSource, + case .media(let mediaMatadata) = self.metadata + else { throw AttachmentError.invalidMediaSource } + + guard mediaMatadata.hasValidPixelSize else { + Log.error(.attachmentManager, "Source has invalid image dimensions.") + throw AttachmentError.invalidDimensions + } + guard mediaMatadata.hasValidDuration else { + Log.error(.attachmentManager, "Source has invalid duration.") + throw AttachmentError.invalidDuration + } + + switch targetSource { + case .data(_, let data): return data + case .url(let url), .videoUrl(let url, _, _, _): + return try Data(contentsOf: url, options: []) + + default: throw AttachmentError.invalidMediaSource + } + } + + private func prepareAudio(_ transformations: Set) throws -> Data { + guard case .media(let mediaMatadata) = self.metadata else { + throw AttachmentError.invalidMediaSource + } + + guard mediaMatadata.hasValidDuration else { + Log.error(.attachmentManager, "Source has invalid duration.") + throw AttachmentError.invalidDuration + } + + switch source { + case .voiceMessage(let url): return try Data(contentsOf: url, options: []) + case .media(let mediaSource) where utType.isAudio: + switch mediaSource { + case .url(let url): return try Data(contentsOf: url, options: []) + case .data(_, let data): return data + default: throw AttachmentError.invalidMediaSource + } + + default: throw AttachmentError.invalidMediaSource + } + } + + private func prepareText(_ transformations: Set) throws -> Data { + guard + case .text(let text) = source, + let data: Data = text.data(using: .ascii) + else { throw AttachmentError.invalidData } + + return data + } + + private func prepareGeneral(_ transformations: Set) throws -> Data { + switch source { + case .file(let url): return try Data(contentsOf: url, options: []) + case .media(let mediaSource): + switch mediaSource { + case .url(let url): return try Data(contentsOf: url, options: []) + case .data(_, let data): return data + default: throw AttachmentError.invalidData + } + + default: throw AttachmentError.invalidData + } + } + private func prepareAttachment( id: String, downloadUrl: String, diff --git a/SessionMessagingKit/Utilities/DisplayPictureError.swift b/SessionMessagingKit/Utilities/DisplayPictureError.swift deleted file mode 100644 index 498ef6c3df..0000000000 --- a/SessionMessagingKit/Utilities/DisplayPictureError.swift +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable - -import Foundation - -public enum DisplayPictureError: Error, Equatable, CustomStringConvertible { - case imageTooLarge - case writeFailed - case loadFailed - case imageProcessingFailed - case databaseChangesFailed - case encryptionFailed - case uploadFailed - case uploadMaxFileSizeExceeded - case invalidCall - case invalidPath - case alreadyDownloaded(URL?) - case updateNoLongerValid - case notEncrypted - - public var description: String { - switch self { - case .imageTooLarge: return "Display picture too large." - case .writeFailed: return "Display picture write failed." - case .loadFailed: return "Display picture load failed." - case .imageProcessingFailed: return "Display picture processing failed." - case .databaseChangesFailed: return "Failed to save display picture to database." - case .encryptionFailed: return "Display picture encryption failed." - case .uploadFailed: return "Display picture upload failed." - case .uploadMaxFileSizeExceeded: return "Maximum file size exceeded." - case .invalidCall: return "Attempted to remove display picture using the wrong method." - case .invalidPath: return "Failed to generate a valid path." - case .alreadyDownloaded: return "Display picture already downloaded." - case .updateNoLongerValid: return "Display picture update no longer valid." - case .notEncrypted: return "Display picture not encrypted." - } - } -} diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index 191a6af197..0571f6e77a 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -116,7 +116,7 @@ public class DisplayPictureManager { guard let urlString: String = urlString, !urlString.isEmpty - else { throw DisplayPictureError.invalidCall } + else { throw AttachmentError.invalidPath } let urlHash = try { guard let cachedHash: String = cache.object(forKey: urlString as NSString) as? String else { @@ -133,13 +133,6 @@ public class DisplayPictureManager { .path } - public func path(for source: ImageDataManager.DataSource) throws -> String { - switch source { - case .url(let url): return try path(for: url.absoluteString) - default: throw DisplayPictureError.invalidCall - } - } - public func resetStorage() { try? dependencies[singleton: .fileManager].removeItem( atPath: sharedDataDisplayPictureDirPath() @@ -198,10 +191,9 @@ public class DisplayPictureManager { transformations ?? [ .compress, - .convertToStandardFormats, .resize(maxDimension: DisplayPictureManager.maxDimension), .stripImageMetadata, - .encrypt(legacy: true) // FIXME: Remove the `legacy` encryption option + .encrypt(legacy: true, domain: .profilePicture) // FIXME: Remove the `legacy` encryption option ] ) @@ -213,7 +205,7 @@ public class DisplayPictureManager { /// Ensure we have an encryption key for the `PreparedAttachment` we want to use as a display picture guard let encryptionKey: Data = attachment.attachment.encryptionKey else { - throw DisplayPictureError.notEncrypted + throw AttachmentError.notEncrypted } do { @@ -230,10 +222,10 @@ public class DisplayPictureManager { uploadResponse = try await request .send(using: dependencies) .values - .first(where: { _ in true })?.1 ?? { throw DisplayPictureError.uploadFailed }() + .first(where: { _ in true })?.1 ?? { throw AttachmentError.uploadFailed }() } - catch NetworkError.maxFileSizeExceeded { throw DisplayPictureError.uploadMaxFileSizeExceeded } - catch { throw DisplayPictureError.uploadFailed } + catch NetworkError.maxFileSizeExceeded { throw AttachmentError.fileSizeTooLarge } + catch { throw AttachmentError.uploadFailed } /// Generate the `downloadUrl` and move the temporary file to it's expected destination let downloadUrl: String = Network.FileServer.downloadUrlString(for: uploadResponse.id) diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index 8377831f71..e4244e640b 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -38,7 +38,7 @@ public extension Profile { /// Perform any non-database related changes for the update switch displayPictureUpdate { case .contactRemove, .contactUpdateTo, .groupRemove, .groupUpdateTo, .groupUploadImage: - throw DisplayPictureError.invalidCall + throw AttachmentError.invalidStartState case .none, .currentUserUpdateTo: break case .currentUserRemove: @@ -80,7 +80,7 @@ public extension Profile { } Log.info(.profile, "Successfully updated user profile.") } - catch { throw DisplayPictureError.databaseChangesFailed } + catch { throw AttachmentError.databaseChangesFailed } } /// To try to maintain backwards compatibility with profile changes we want to continue to accept profile changes from old clients if @@ -153,7 +153,7 @@ public extension Profile { // Profile picture & profile key switch (displayPictureUpdate, isCurrentUser) { case (.none, _): break - case (.groupRemove, _), (.groupUpdateTo, _): throw DisplayPictureError.invalidCall + case (.groupRemove, _), (.groupUpdateTo, _): throw AttachmentError.invalidStartState case (.contactRemove, false), (.currentUserRemove, true): if profile.displayPictureEncryptionKey != nil { profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: nil)) diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift index 7d3a59a5d7..e67e26d9e0 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift @@ -690,7 +690,7 @@ class MessageSenderGroupsSpec: QuickSpec { .mapError { error.setting(to: $0) } .sinkAndStore(in: &disposables) - expect(error).to(matchError(DisplayPictureError.uploadFailed)) + expect(error).to(matchError(AttachmentError.uploadFailed)) } } diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 0f7dc4cb19..f99faeabb8 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -291,7 +291,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView .send(using: dependencies) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMapStorageWritePublisher(using: dependencies) { db, _ -> (Message, Message.Destination, Int64?, AuthenticationMethod, [Network.PreparedRequest<(attachment: Attachment, fileId: String)>]) in + .flatMapStorageWritePublisher(using: dependencies) { db, _ -> (Message, Message.Destination, Int64?, AuthenticationMethod, [AttachmentUploadJob.PreparedUpload]) in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { throw MessageSenderError.noThread } @@ -357,14 +357,14 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView ).insert(db) } - // Process any attachments - try AttachmentUploader.process( + // Link any attachments to their interaction + try AttachmentUploadJob.link( db, - attachments: AttachmentUploader.prepare( + attachments: try AttachmentUploadJob.preparePriorToUpload( attachments: finalAttachments, using: dependencies ), - for: interactionId + toInteractionWithId: interactionId ) // Using the same logic as the `MessageSendJob` retrieve @@ -376,13 +376,12 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView ) let attachmentState: MessageSendJob.AttachmentState = try MessageSendJob .fetchAttachmentState(db, interactionId: interactionId) - let preparedUploads: [Network.PreparedRequest<(attachment: Attachment, fileId: String)>] = try Attachment + let preparedUploads: [AttachmentUploadJob.PreparedUpload] = try Attachment .filter(ids: attachmentState.allAttachmentIds) .fetchAll(db) .map { attachment in - try AttachmentUploader.preparedUpload( + try AttachmentUploadJob.preparedUpload( attachment: attachment, - logCategory: nil, authMethod: authMethod, using: dependencies ) @@ -396,7 +395,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView return (visibleMessage, destination, interaction.id, authMethod, preparedUploads) } - .flatMap { (message: Message, destination: Message.Destination, interactionId: Int64?, authMethod: AuthenticationMethod, preparedUploads: [Network.PreparedRequest<(attachment: Attachment, fileId: String)>]) -> AnyPublisher<(Message, Message.Destination, Int64?, AuthenticationMethod, [(Attachment, String)]), Error> in + .flatMap { (message: Message, destination: Message.Destination, interactionId: Int64?, authMethod: AuthenticationMethod, preparedUploads: [AttachmentUploadJob.PreparedUpload]) -> AnyPublisher<(Message, Message.Destination, Int64?, AuthenticationMethod, [(PreparedAttachment, FileUploadResponse)]), Error> in guard !preparedUploads.isEmpty else { return Just((message, destination, interactionId, authMethod, [])) .setFailureType(to: Error.self) @@ -404,26 +403,44 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView } return Publishers - .MergeMany(preparedUploads.map { $0.send(using: dependencies) }) + .MergeMany( + preparedUploads.map { request, preparedAttachment in + request.send(using: dependencies).map { _, response in + (preparedAttachment, response) + } + } + ) .collect() - .map { results in (message, destination, interactionId, authMethod, results.map { _, value in value }) } + .map { results in (message, destination, interactionId, authMethod, results) } .eraseToAnyPublisher() } - .tryFlatMap { message, destination, interactionId, authMethod, attachments -> AnyPublisher<(Message, [Attachment]), Error> in - try MessageSender + .tryFlatMap { message, destination, interactionId, authMethod, uploadResults -> AnyPublisher<(Message, [Attachment]), Error> in + let updatedAttachments: [(attachment: Attachment, fileId: String)] = try uploadResults.map { attachment, response in + ( + try AttachmentUploadJob.processUploadResponse( + preparedAttachment: attachment, + authMethod: authMethod, + response: response, + using: dependencies + ), + response.id + ) + } + + return try MessageSender .preparedSend( message: message, to: destination, namespace: destination.defaultNamespace, interactionId: interactionId, - attachments: attachments, + attachments: updatedAttachments, authMethod: authMethod, onEvent: MessageSender.standardEventHandling(using: dependencies), using: dependencies ) .send(using: dependencies) .map { _, message in - (message, attachments.map { attachment, _ in attachment }) + (message, updatedAttachments.map { attachment, _ in attachment }) } .eraseToAnyPublisher() } diff --git a/SessionUtilitiesKit/Crypto/Crypto.swift b/SessionUtilitiesKit/Crypto/Crypto.swift index 8ab0c151f8..60c37d0f56 100644 --- a/SessionUtilitiesKit/Crypto/Crypto.swift +++ b/SessionUtilitiesKit/Crypto/Crypto.swift @@ -4,6 +4,12 @@ import Foundation +// MARK: - Log.Category + +public extension Log.Category { + static let crypto: Log.Category = .create("Crypto", defaultLevel: .info) +} + // MARK: - Singleton public extension Singleton { diff --git a/SessionUtilitiesKit/Media/MediaUtils.swift b/SessionUtilitiesKit/Media/MediaUtils.swift index 5cf6b666aa..9b02ca8975 100644 --- a/SessionUtilitiesKit/Media/MediaUtils.swift +++ b/SessionUtilitiesKit/Media/MediaUtils.swift @@ -49,7 +49,9 @@ public enum MediaUtils { kCGImagePropertyDepth, kCGImagePropertyHasAlpha, kCGImagePropertyColorModel, - kCGImagePropertyOrientation + kCGImagePropertyOrientation, + kCGImagePropertyGIFDelayTime, + kCGImagePropertyGIFUnclampedDelayTime ] public struct MediaMetadata: Sendable, Equatable, Hashable { diff --git a/SessionUtilitiesKit/Types/FileManager.swift b/SessionUtilitiesKit/Types/FileManager.swift index c2f4eec063..2614daba36 100644 --- a/SessionUtilitiesKit/Types/FileManager.swift +++ b/SessionUtilitiesKit/Types/FileManager.swift @@ -19,7 +19,6 @@ public protocol FileManagerType { var temporaryDirectory: String { get } var documentsDirectoryPath: String { get } var appSharedDataDirectoryPath: String { get } - var temporaryDirectoryAccessibleAfterFirstAuth: String { get } /// **Note:** We need to call this method on launch _and_ every time the app becomes active, /// since file protection may prevent it from succeeding in the background. @@ -155,13 +154,6 @@ public class SessionFileManager: FileManagerType { .defaulting(to: "") } - public var temporaryDirectoryAccessibleAfterFirstAuth: String { - let dirPath: String = NSTemporaryDirectory() - try? ensureDirectoryExists(at: dirPath, fileProtectionType: .completeUntilFirstUserAuthentication) - - return dirPath - } - // MARK: - Initialization init(using dependencies: Dependencies) { @@ -375,7 +367,7 @@ public class SessionFileManager: FileManagerType { withItemAt: URL(fileURLWithPath: newItemPath), backupItemName: backupItemName, options: options - )?.absoluteString + )?.path } public func replaceItemAt(_ originalItemURL: URL, withItemAt newItemURL: URL, backupItemName: String?, options: FileManager.ItemReplacementOptions) throws -> URL? { diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift index 616ee47a91..054e776bef 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift @@ -43,12 +43,13 @@ class PendingAttachmentRailItem: Equatable { // Try and make a ImageEditorModel. // This will only apply for valid images. if - ImageEditorModel.isFeatureEnabled, + ImageEditorModel.isFeatureEnabled && + attachment.utType.isImage, case .media(let mediaSource) = attachment.source, - case .url(let url) = mediaSource + case .url = mediaSource { do { - imageEditorModel = try ImageEditorModel(srcImagePath: url.absoluteString, using: dependencies) + imageEditorModel = try ImageEditorModel(attachment: attachment, using: dependencies) } catch { // Usually not an error; this usually indicates invalid input. Log.warn("[PendingAttachmentRailItem] Could not create image editor: \(error)") diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift index de9e0de6e0..6c8464aa73 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift @@ -138,27 +138,52 @@ public class ImageEditorCanvasView: UIView { } public class func loadSrcImage(model: ImageEditorModel) -> UIImage? { - let srcImageData: Data - do { - let srcImagePath = model.srcImagePath - let srcImageUrl = URL(fileURLWithPath: srcImagePath) - srcImageData = try Data(contentsOf: srcImageUrl) - } catch { - Log.error("[ImageEditorCanvasView] Couldn't parse srcImageUrl") - return nil - } - // We use this constructor so that we can specify the scale. - // - // UIImage(contentsOfFile:) will sometimes use device scale. - guard let srcImage = UIImage(data: srcImageData, scale: 1.0) else { - Log.error("[ImageEditorCanvasView] Couldn't load background image.") - return nil + switch model.src { + case .url(let url): + // We use this constructor so that we can specify the scale. + // + // UIImage(contentsOfFile:) will sometimes use device scale. + guard + let data: Data = try? Data(contentsOf: url), + let srcImage: UIImage = UIImage(data: data, scale: 1.0) + else { + Log.error("[ImageEditorCanvasView] Couldn't load source image.") + return nil + } + + // We normalize the image orientation here for the sake + // of code simplicity. We could modify the image layer's + // transform to handle the normalization, which would + // have perf benefits. + return srcImage.normalizedImage() + + case .data(_, let data): + // We use this constructor so that we can specify the scale. + // + // UIImage(contentsOfFile:) will sometimes use device scale. + guard let srcImage: UIImage = UIImage(data: data, scale: 1.0) else { + Log.error("[ImageEditorCanvasView] Couldn't load source image.") + return nil + } + + // We normalize the image orientation here for the sake + // of code simplicity. We could modify the image layer's + // transform to handle the normalization, which would + // have perf benefits. + return srcImage.normalizedImage() + + case .image(_, let maybeImage): + guard let image: UIImage = maybeImage else { + Log.error("[ImageEditorCanvasView] Invalid source provided") + return nil + } + + return image.normalizedImage() + + default: + Log.error("[ImageEditorCanvasView] Invalid source provided") + return nil } - // We normalize the image orientation here for the sake - // of code simplicity. We could modify the image layer's - // transform to handle the normalization, which would - // have perf benefits. - return srcImage.normalizedImage() } // MARK: - Content @@ -606,12 +631,6 @@ public class ImageEditorCanvasView: UIView { let dstSizePixels = transform.outputSizePixels let dstScale: CGFloat = 1.0 // The size is specified in pixels, not in points. let viewSize = dstSizePixels - let hasAlpha: Bool = (MediaUtils.MediaMetadata( - from: model.srcImagePath, - utType: nil, - sourceFilename: nil, - using: dependencies - )?.hasAlpha == true) // We use an UIImageView + UIView.renderAsImage() instead of a CGGraphicsContext // Because CALayer.renderInContext() doesn't honor CALayer properties like frame, @@ -662,7 +681,7 @@ public class ImageEditorCanvasView: UIView { CATransaction.commit() - let image = view.toImage(isOpaque: !hasAlpha, scale: dstScale) + let image = view.toImage(isOpaque: (model.srcMetadata.hasAlpha != true), scale: dstScale) return image } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift index dd484b3e77..510b8b15a9 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift @@ -2,6 +2,8 @@ import UIKit import UniformTypeIdentifiers +import SessionUIKit +import SessionMessagingKit import SessionUtilitiesKit // Used to represent undo/redo operations. @@ -43,7 +45,8 @@ public class ImageEditorModel { } private let dependencies: Dependencies - public let srcImagePath: String + public let src: ImageDataManager.DataSource + public let srcMetadata: MediaUtils.MediaMetadata public let srcImageSizePixels: CGSize private var contents: ImageEditorContents private var transform: ImageEditorTransform @@ -54,33 +57,31 @@ public class ImageEditorModel { // // * They are invalid. // * We can't determine their size / aspect-ratio. - public required init(srcImagePath: String, using dependencies: Dependencies) throws { + public required init(attachment: PendingAttachment, using dependencies: Dependencies) throws { self.dependencies = dependencies - self.srcImagePath = srcImagePath - - let srcFileName = (srcImagePath as NSString).lastPathComponent - let srcFileExtension = (srcFileName as NSString).pathExtension - guard let utType: UTType = UTType(sessionFileExtension: srcFileExtension) else { - Log.error("[ImageEditorModel] Couldn't determine UTType for file.") + guard + let source: ImageDataManager.DataSource = attachment.visualMediaSource, + case .media(let metadata) = attachment.metadata + else { + Log.error("[ImageEditorModel] Couldn't extract media data.") throw ImageEditorError.invalidInput } - guard utType.isImage && !utType.isAnimated else { - Log.error("[ImageEditorModel] Invalid MIME type: \(utType.preferredMIMEType ?? "unknown").") + guard attachment.utType.isImage && !attachment.utType.isAnimated else { + Log.error("[ImageEditorModel] Invalid MIME type: \(attachment.utType.preferredMIMEType ?? "unknown").") throw ImageEditorError.invalidInput } - let srcImageSizePixels = MediaUtils.unrotatedSize( - for: srcImagePath, - utType: utType, - sourceFilename: srcFileName, - using: dependencies - ) - guard srcImageSizePixels.width > 0, srcImageSizePixels.height > 0 else { + let unrotatedSize: CGSize = metadata.unrotatedSize + + guard unrotatedSize.width > 0, unrotatedSize.height > 0 else { Log.error("[ImageEditorModel] Couldn't determine image size.") throw ImageEditorError.invalidInput } - self.srcImageSizePixels = srcImageSizePixels + + self.src = source + self.srcMetadata = metadata + self.srcImageSizePixels = unrotatedSize self.contents = ImageEditorContents() self.transform = ImageEditorTransform.defaultTransform(srcImageSizePixels: srcImageSizePixels) @@ -232,14 +233,6 @@ public class ImageEditorModel { private var temporaryFilePaths = [String]() - public func temporaryFilePath(withFileExtension fileExtension: String) -> String { - Log.assertOnMainThread() - - let filePath = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: fileExtension) - temporaryFilePaths.append(filePath) - return filePath - } - deinit { Log.assertOnMainThread() diff --git a/_SharedTestUtilities/MockFileManager.swift b/_SharedTestUtilities/MockFileManager.swift index 473fad7b93..e71a944ff5 100644 --- a/_SharedTestUtilities/MockFileManager.swift +++ b/_SharedTestUtilities/MockFileManager.swift @@ -7,7 +7,6 @@ class MockFileManager: Mock, FileManagerType { var temporaryDirectory: String { mock() } var documentsDirectoryPath: String { mock() } var appSharedDataDirectoryPath: String { mock() } - var temporaryDirectoryAccessibleAfterFirstAuth: String { mock() } func clearOldTemporaryDirectories() { mockNoReturn() } From 07c75d8b9b2b11b3bdf85343323fab350b868174 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 6 Oct 2025 17:14:52 +1100 Subject: [PATCH 061/162] fix pro badge or mention in attributed string won't change the color when theme color changed --- .../Settings/ThreadSettingsViewModel.swift | 2 +- Session/Settings/SettingsViewModel.swift | 2 +- .../Shared/Types/SessionCell+Styling.swift | 4 +- Session/Shared/Views/SessionCell.swift | 2 +- .../Views/SessionProBadge+Utilities.swift | 25 ++++------ .../Utilities/SessionProState.swift | 2 +- .../Modals & Toast/ConfirmationModal.swift | 4 +- .../Themes/ThemedAttributedString.swift | 47 ++++++++++++------- .../Utilities/UILabel+Utilities.swift | 18 ++----- 9 files changed, 51 insertions(+), 55 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 4fca374e7a..2b0445d9a9 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -304,7 +304,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi alignment: .center, trailingImage: ( (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }) ? - ("ProBadge", SessionProBadge(size: .medium).toImage(using: dependencies)) : + ("ProBadge", { [dependencies] in SessionProBadge(size: .medium).toImage(using: dependencies) }) : nil ) ), diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 1593671a3f..64880d7a68 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -321,7 +321,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl font: .titleLarge, alignment: .center, trailingImage: (state.isSessionPro ? - ("ProBadge", SessionProBadge(size: .medium).toImage(using: viewModel.dependencies)) : + ("ProBadge", { SessionProBadge(size: .medium).toImage(using: viewModel.dependencies) }) : nil ) ), diff --git a/Session/Shared/Types/SessionCell+Styling.swift b/Session/Shared/Types/SessionCell+Styling.swift index 3a45d9dd53..6eb1048924 100644 --- a/Session/Shared/Types/SessionCell+Styling.swift +++ b/Session/Shared/Types/SessionCell+Styling.swift @@ -18,7 +18,7 @@ public extension SessionCell { let editingPlaceholder: String? let interaction: Interaction let accessibility: Accessibility? - let trailingImage: (id: String, image: UIImage)? + let trailingImage: (id: String, imageGenerator: (() -> UIImage))? let extraViewGenerator: (() -> UIView)? private let fontStyle: FontStyle @@ -31,7 +31,7 @@ public extension SessionCell { editingPlaceholder: String? = nil, interaction: Interaction = .none, accessibility: Accessibility? = nil, - trailingImage: (id: String, image: UIImage)? = nil, + trailingImage: (id: String, imageGenerator: (() -> UIImage))? = nil, extraViewGenerator: (() -> UIView)? = nil ) { self.text = text diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index 4b1592df03..30c0403eac 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -527,7 +527,7 @@ public class SessionCell: UITableViewCell { titleLabel.accessibilityIdentifier = info.title?.accessibility?.identifier titleLabel.accessibilityLabel = info.title?.accessibility?.label titleLabel.isHidden = (info.title == nil) - titleLabel.attachTrailing(info.title?.trailingImage?.image) + titleLabel.attachTrailing(info.title?.trailingImage?.imageGenerator) subtitleLabel.isUserInteractionEnabled = (info.subtitle?.interaction == .copy) subtitleLabel.font = info.subtitle?.font subtitleLabel.themeTextColor = info.styling.subtitleTintColor diff --git a/Session/Shared/Views/SessionProBadge+Utilities.swift b/Session/Shared/Views/SessionProBadge+Utilities.swift index 2f6cc8fbfd..0c5651c35e 100644 --- a/Session/Shared/Views/SessionProBadge+Utilities.swift +++ b/Session/Shared/Views/SessionProBadge+Utilities.swift @@ -43,26 +43,17 @@ public extension String { proBadgeSize: SessionProBadge.Size, spacing: String = " ", using dependencies: Dependencies - ) -> NSMutableAttributedString { - let image: UIImage = SessionProBadge(size: proBadgeSize).toImage(using: dependencies) - let base = NSMutableAttributedString() - let attachment = NSTextAttachment() - attachment.image = image - - // Vertical alignment tweak to align to baseline - let cap = font.capHeight - let dy = (cap - image.size.height) / 2 - attachment.bounds = CGRect(x: 0, y: dy, width: image.size.width, height: image.size.height) - + ) -> ThemedAttributedString { + let base = ThemedAttributedString() switch postion { case .leading: - base.append(NSAttributedString(attachment: attachment)) - base.append(NSAttributedString(string: spacing)) - base.append(NSAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) + base.append(ThemedAttributedString(imageAttachmentGenerator: { SessionProBadge(size: proBadgeSize).toImage(using: dependencies) })) + base.append(ThemedAttributedString(string: spacing)) + base.append(ThemedAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) case .trailing: - base.append(NSAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) - base.append(NSAttributedString(string: spacing)) - base.append(NSAttributedString(attachment: attachment)) + base.append(ThemedAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) + base.append(ThemedAttributedString(string: spacing)) + base.append(ThemedAttributedString(imageAttachmentGenerator: { SessionProBadge(size: proBadgeSize).toImage(using: dependencies) })) } return base diff --git a/SessionMessagingKit/Utilities/SessionProState.swift b/SessionMessagingKit/Utilities/SessionProState.swift index 17e0ea1b7e..2c58ab1d59 100644 --- a/SessionMessagingKit/Utilities/SessionProState.swift +++ b/SessionMessagingKit/Utilities/SessionProState.swift @@ -42,7 +42,7 @@ public class SessionProState: SessionProManagerType { afterClosed: (() -> Void)?, presenting: ((UIViewController) -> Void)? ) -> Bool { - guard dependencies[feature: .sessionProEnabled] && (!isSessionProSubject.value) else { + guard dependencies[feature: .sessionProEnabled] && (!dependencies[feature: .mockCurrentUserSessionPro]) else { return false } beforePresented?() diff --git a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift index b22ff1e77a..7b423b0040 100644 --- a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift +++ b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift @@ -537,7 +537,7 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { mainStackView.spacing = 0 contentStackView.spacing = Values.verySmallSpacing proDescriptionLabelContainer.isHidden = (description == nil) - proDescriptionLabel.attributedText = description + proDescriptionLabel.themeAttributedText = description imageViewContainer.isHidden = false profileView.clipsToBounds = (style == .circular) profileView.setDataManager(dataManager) @@ -1052,7 +1052,7 @@ public extension ConfirmationModal.Info { placeholder: ImageDataManager.DataSource?, icon: ProfilePictureView.ProfileIcon = .none, style: ImageStyle, - description: NSAttributedString?, + description: ThemedAttributedString?, accessibility: Accessibility?, dataManager: ImageDataManagerType, onProBageTapped: (() -> Void)?, diff --git a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift index 12134fa28a..675cab5e2e 100644 --- a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift +++ b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift @@ -30,37 +30,50 @@ public extension NSAttributedString.Key { // MARK: - ThemedAttributedString public class ThemedAttributedString: Equatable, Hashable { - internal let value: NSMutableAttributedString + internal var value: NSMutableAttributedString { + if let image = imageAttachmentGenerator?() { + return NSMutableAttributedString(attachment: NSTextAttachment(image: image)) + } + return attributedString + } public var string: String { value.string } public var length: Int { value.length } + internal var imageAttachmentGenerator: (() -> UIImage?)? + internal var attributedString: NSMutableAttributedString public init() { - self.value = NSMutableAttributedString() + self.attributedString = NSMutableAttributedString() } public init(attributedString: ThemedAttributedString) { - self.value = attributedString.value + self.attributedString = attributedString.attributedString + self.imageAttachmentGenerator = attributedString.imageAttachmentGenerator } public init(attributedString: NSAttributedString) { #if DEBUG ThemedAttributedString.validateAttributes(attributedString) #endif - self.value = NSMutableAttributedString(attributedString: attributedString) + self.attributedString = NSMutableAttributedString(attributedString: attributedString) } public init(string: String, attributes: [NSAttributedString.Key: Any] = [:]) { #if DEBUG ThemedAttributedString.validateAttributes(attributes) #endif - self.value = NSMutableAttributedString(string: string, attributes: attributes) + self.attributedString = NSMutableAttributedString(string: string, attributes: attributes) } public init(attachment: NSTextAttachment, attributes: [NSAttributedString.Key: Any] = [:]) { #if DEBUG ThemedAttributedString.validateAttributes(attributes) #endif - self.value = NSMutableAttributedString(attachment: attachment) + self.attributedString = NSMutableAttributedString(attachment: attachment) + } + + public init(imageAttachmentGenerator: @escaping (() -> UIImage?)) { + self.attributedString = NSMutableAttributedString() + self.imageAttachmentGenerator = imageAttachmentGenerator } required init?(coder: NSCoder) { @@ -85,7 +98,7 @@ public class ThemedAttributedString: Equatable, Hashable { #if DEBUG ThemedAttributedString.validateAttributes(attributes ?? [:]) #endif - value.append(NSAttributedString(string: string, attributes: attributes)) + self.attributedString.append(NSAttributedString(string: string, attributes: attributes)) return self } @@ -93,23 +106,23 @@ public class ThemedAttributedString: Equatable, Hashable { #if DEBUG ThemedAttributedString.validateAttributes(attributedString) #endif - value.append(attributedString) + self.attributedString.append(attributedString) } public func append(_ attributedString: ThemedAttributedString) { - value.append(attributedString.value) + self.attributedString.append(attributedString.value) } public func appending(_ attributedString: NSAttributedString) -> ThemedAttributedString { #if DEBUG ThemedAttributedString.validateAttributes(attributedString) #endif - value.append(attributedString) + self.attributedString.append(attributedString) return self } public func appending(_ attributedString: ThemedAttributedString) -> ThemedAttributedString { - value.append(attributedString.value) + self.attributedString.append(attributedString.value) return self } @@ -118,7 +131,7 @@ public class ThemedAttributedString: Equatable, Hashable { ThemedAttributedString.validateAttributes([name: value]) #endif let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - value.addAttribute(name, value: attrValue, range: targetRange) + self.attributedString.addAttribute(name, value: attrValue, range: targetRange) } public func addingAttribute(_ name: NSAttributedString.Key, value attrValue: Any, range: NSRange? = nil) -> ThemedAttributedString { @@ -126,7 +139,7 @@ public class ThemedAttributedString: Equatable, Hashable { ThemedAttributedString.validateAttributes([name: value]) #endif let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - value.addAttribute(name, value: attrValue, range: targetRange) + self.attributedString.addAttribute(name, value: attrValue, range: targetRange) return self } @@ -135,7 +148,7 @@ public class ThemedAttributedString: Equatable, Hashable { ThemedAttributedString.validateAttributes(attrs) #endif let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - value.addAttributes(attrs, range: targetRange) + self.attributedString.addAttributes(attrs, range: targetRange) } public func addingAttributes(_ attrs: [NSAttributedString.Key: Any], range: NSRange? = nil) -> ThemedAttributedString { @@ -143,16 +156,16 @@ public class ThemedAttributedString: Equatable, Hashable { ThemedAttributedString.validateAttributes(attrs) #endif let targetRange: NSRange = (range ?? NSRange(location: 0, length: self.length)) - value.addAttributes(attrs, range: targetRange) + self.attributedString.addAttributes(attrs, range: targetRange) return self } public func boundingRect(with size: CGSize, options: NSStringDrawingOptions = [], context: NSStringDrawingContext?) -> CGRect { - return value.boundingRect(with: size, options: options, context: context) + return self.attributedString.boundingRect(with: size, options: options, context: context) } public func replaceCharacters(in range: NSRange, with attributedString: NSAttributedString) { - value.replaceCharacters(in: range, with: attributedString) + self.attributedString.replaceCharacters(in: range, with: attributedString) } // MARK: - Convenience diff --git a/SessionUIKit/Utilities/UILabel+Utilities.swift b/SessionUIKit/Utilities/UILabel+Utilities.swift index 1ab1098384..e0782d8e04 100644 --- a/SessionUIKit/Utilities/UILabel+Utilities.swift +++ b/SessionUIKit/Utilities/UILabel+Utilities.swift @@ -4,28 +4,20 @@ import UIKit public extension UILabel { /// Appends a rendered snapshot of `view` as an inline image attachment. - func attachTrailing(_ image: UIImage?, spacing: String = " ") { - guard let image = image, image.size != .zero else { return } + func attachTrailing(_ imageGenerator: (() -> UIImage?)?, spacing: String = " ") { + guard let imageGenerator else { return } - let base = NSMutableAttributedString() + let base = ThemedAttributedString() if let existing = attributedText, existing.length > 0 { base.append(existing) } else if let t = text { base.append(NSAttributedString(string: t, attributes: [.font: font as Any, .foregroundColor: textColor as Any])) } - let attachment = NSTextAttachment() - attachment.image = image - - // Vertical alignment tweak to align to baseline - let cap = font?.capHeight ?? 0 - let dy = (cap - image.size.height) / 2 - attachment.bounds = CGRect(x: 0, y: dy, width: image.size.width, height: image.size.height) - base.append(NSAttributedString(string: spacing)) - base.append(NSAttributedString(attachment: attachment)) + base.append(ThemedAttributedString(imageAttachmentGenerator: imageGenerator)) - attributedText = base + themeAttributedText = base numberOfLines = 0 lineBreakMode = .byWordWrapping } From 25f99ea7f1318571019013111bd6cf50bae17274 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 7 Oct 2025 09:31:26 +1100 Subject: [PATCH 062/162] Added code to filter out Giphy results which won't be rendered --- .../GIFs/GifPickerViewController.swift | 29 ++- .../GIFs/GiphyAPI.swift | 221 +++++++++++------- 2 files changed, 169 insertions(+), 81 deletions(-) diff --git a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift index facda4f414..aebf965847 100644 --- a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift +++ b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift @@ -498,8 +498,19 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect receiveValue: { [weak self] imageInfos in Log.debug(.giphy, "ViewController showing trending") - if imageInfos.count > 0 { - self?.imageInfos = imageInfos + // Filter out invalid images before displaying + let validImageInfos = imageInfos.filter { imageInfo in + let isValid: Bool = imageInfo.isValid() + + if !isValid { + Log.debug(.giphy, "Filtering out invalid GIF: \(imageInfo.giphyId)") + } + + return isValid + } + + if validImageInfos.count > 0 { + self?.imageInfos = validImageInfos self?.viewMode = .results } else { @@ -536,7 +547,19 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect }, receiveValue: { [weak self] imageInfos in Log.verbose(.giphy, "ViewController search complete") - self?.imageInfos = imageInfos + + // Filter out invalid images before displaying + let validImageInfos = imageInfos.filter { imageInfo in + let isValid: Bool = imageInfo.isValid() + + if !isValid { + Log.debug(.giphy, "Filtering out invalid GIF: \(imageInfo.giphyId)") + } + + return isValid + } + + self?.imageInfos = validImageInfos if imageInfos.count > 0 { self?.viewMode = .results diff --git a/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift b/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift index eb2cd33430..e991b16622 100644 --- a/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift +++ b/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift @@ -95,9 +95,15 @@ class GiphyRendition: ProxiedContentAssetDescription { return name.hasSuffix("_still") } + /// A frame-reduced version of the animation public var isDownsampled: Bool { return name.hasSuffix("_downsampled") } + + /// A scaled down version of the animation + public var isDownsized: Bool { + return name.hasPrefix("downsized_") || name == "downsized" + } public func log() { Log.verbose(.giphy, "\t \(format), \(name), \(width), \(height), \(fileSize)") @@ -124,15 +130,16 @@ class GiphyImageInfo: NSObject { // source of truth for the aspect ratio of the image. let originalRendition: GiphyRendition - init(giphyId: String, - renditions: [GiphyRendition], - originalRendition: GiphyRendition) { + init( + giphyId: String, + renditions: [GiphyRendition], + originalRendition: GiphyRendition + ) { self.giphyId = giphyId self.renditions = renditions self.originalRendition = originalRendition } - // TODO: We may need to tweak these constants. let kMaxDimension = UInt(618) let kMinPreviewDimension = UInt(60) let kMinSendingDimension = UInt(101) @@ -149,36 +156,88 @@ class GiphyImageInfo: NSObject { rendition.log() } } - + + public func isValid() -> Bool { + return ( + pickStillRendition() != nil && + pickPreviewRendition() != nil && + pickSendingRendition() != nil + ) + } + public func pickStillRendition() -> GiphyRendition? { // Stills are just temporary placeholders, so use the smallest still possible. - return pickRendition(renditionType: .stillPreview, pickingStrategy: .smallerIsBetter, maxFileSize: kPreferedPreviewFileSize) + let originalRendition: GiphyRendition? = pickRendition( + renditionType: .stillPreview, + pickingStrategy: .smallerIsBetter, + maxFileSize: kPreferedPreviewFileSize, + allowDownsized: false + ) + + if let rendition: GiphyRendition = originalRendition { + return rendition + } + + return pickRendition( + renditionType: .stillPreview, + pickingStrategy: .smallerIsBetter, + maxFileSize: kPreferedPreviewFileSize, + allowDownsized: false + ) } public func pickPreviewRendition() -> GiphyRendition? { - // Try to pick a small file... - if let rendition = pickRendition(renditionType: .animatedLowQuality, pickingStrategy: .largerIsBetter, maxFileSize: kPreferedPreviewFileSize) { - return rendition - } - // ...but gradually relax the file restriction... - if let rendition = pickRendition(renditionType: .animatedLowQuality, pickingStrategy: .smallerIsBetter, maxFileSize: kPreferedPreviewFileSize * 2) { - return rendition + // Try to pick a small file, then gradually relax the limit until we find an animation + let options: [(size: UInt, strategy: PickingStrategy, allowDownsized: Bool)] = [ + (kPreferedPreviewFileSize, .largerIsBetter, false), + (kPreferedPreviewFileSize * 2, .smallerIsBetter, false), + (kPreferedPreviewFileSize * 3, .smallerIsBetter, false), + (kPreferedPreviewFileSize, .largerIsBetter, true), + (kPreferedPreviewFileSize * 2, .smallerIsBetter, true), + (kPreferedPreviewFileSize * 3, .smallerIsBetter, true), + ] + + for (size, strategy, allowDownsized) in options { + let maybeRendition: GiphyRendition? = pickRendition( + renditionType: .animatedLowQuality, + pickingStrategy: strategy, + maxFileSize: size, + allowDownsized: allowDownsized + ) + + if let rendition: GiphyRendition = maybeRendition { + return rendition + } } - // ...and relax even more until we find an animated rendition. - return pickRendition(renditionType: .animatedLowQuality, pickingStrategy: .smallerIsBetter, maxFileSize: kPreferedPreviewFileSize * 3) + + return nil } public func pickSendingRendition() -> GiphyRendition? { - // Try to pick a small file... - if let rendition = pickRendition(renditionType: .animatedHighQuality, pickingStrategy: .largerIsBetter, maxFileSize: kPreferedSendingFileSize) { - return rendition - } - // ...but gradually relax the file restriction... - if let rendition = pickRendition(renditionType: .animatedHighQuality, pickingStrategy: .smallerIsBetter, maxFileSize: kPreferedSendingFileSize * 2) { - return rendition + // Try to pick a small file, then gradually relax the limit until we find an animation + let options: [(size: UInt, strategy: PickingStrategy, allowDownsized: Bool)] = [ + (kPreferedPreviewFileSize, .largerIsBetter, false), + (kPreferedPreviewFileSize * 2, .smallerIsBetter, false), + (kPreferedPreviewFileSize * 3, .smallerIsBetter, false), + (kPreferedPreviewFileSize, .largerIsBetter, true), + (kPreferedPreviewFileSize * 2, .smallerIsBetter, true), + (kPreferedPreviewFileSize * 3, .smallerIsBetter, true), + ] + + for (size, strategy, allowDownsized) in options { + let maybeRendition: GiphyRendition? = pickRendition( + renditionType: .animatedLowQuality, + pickingStrategy: strategy, + maxFileSize: size, + allowDownsized: allowDownsized + ) + + if let rendition: GiphyRendition = maybeRendition { + return rendition + } } - // ...and relax even more until we find an animated rendition. - return pickRendition(renditionType: .animatedHighQuality, pickingStrategy: .smallerIsBetter, maxFileSize: kPreferedSendingFileSize * 3) + + return nil } enum RenditionType { @@ -189,100 +248,106 @@ class GiphyImageInfo: NSObject { // // * We want to avoid incomplete renditions. // * We want to pick a rendition of "just good enough" quality. - private func pickRendition(renditionType: RenditionType, pickingStrategy: PickingStrategy, maxFileSize: UInt) -> GiphyRendition? { + private func pickRendition( + renditionType: RenditionType, + pickingStrategy: PickingStrategy, + maxFileSize: UInt, + allowDownsized: Bool + ) -> GiphyRendition? { var bestRendition: GiphyRendition? for rendition in renditions { + // Check if we should skip downsized renditions + if !allowDownsized && rendition.isDownsized { + continue + } + switch renditionType { - case .stillPreview: - // Accept GIF or JPEG stills. In practice we'll - // usually select a JPEG since they'll be smaller. - guard [.gif, .jpg].contains(rendition.format) else { - continue - } - // Only consider still renditions. - guard rendition.isStill else { - continue - } - // Accept still renditions without a valid file size. Note that fileSize - // will be zero for renditions without a valid file size, so they will pass - // the maxFileSize test. - // - // Don't worry about max content size; still images are tiny in comparison - // with animated renditions. - guard rendition.width >= kMinPreviewDimension && - rendition.height >= kMinPreviewDimension && - rendition.fileSize <= maxFileSize - else { - continue - } + case .stillPreview: + // Accept GIF or JPEG stills. In practice we'll + // usually select a JPEG since they'll be smaller. + guard [.gif, .jpg].contains(rendition.format) else { continue } + + // Only consider still renditions. + guard rendition.isStill else { continue } + + // Accept still renditions without a valid file size. Note that fileSize + // will be zero for renditions without a valid file size, so they will pass + // the maxFileSize test. + // + // Don't worry about max content size; still images are tiny in comparison + // with animated renditions. + guard + rendition.width >= kMinPreviewDimension && + rendition.height >= kMinPreviewDimension && + rendition.fileSize <= maxFileSize + else { continue } + case .animatedLowQuality: // Only use GIFs for animated renditions. - guard rendition.format == .gif else { - continue - } + guard rendition.format == .gif else { continue } + // Ignore stills. - guard !rendition.isStill else { - continue - } + guard !rendition.isStill else { continue } + // Ignore "downsampled" renditions which skip frames, etc. - guard !rendition.isDownsampled else { - continue - } - guard rendition.width >= kMinPreviewDimension && + guard !rendition.isDownsampled else { continue } + + guard + rendition.width >= kMinPreviewDimension && rendition.width <= kMaxDimension && rendition.height >= kMinPreviewDimension && rendition.height <= kMaxDimension && rendition.fileSize > 0 && rendition.fileSize <= maxFileSize - else { - continue - } + else { continue } + case .animatedHighQuality: // Only use GIFs for animated renditions. - guard rendition.format == .gif else { - continue - } + guard rendition.format == .gif else { continue } + // Ignore stills. - guard !rendition.isStill else { - continue - } + guard !rendition.isStill else { continue } + // Ignore "downsampled" renditions which skip frames, etc. - guard !rendition.isDownsampled else { - continue - } - guard rendition.width >= kMinSendingDimension && + guard !rendition.isDownsampled else { continue } + + guard + rendition.width >= kMinSendingDimension && rendition.width <= kMaxDimension && rendition.height >= kMinSendingDimension && rendition.height <= kMaxDimension && rendition.fileSize > 0 && rendition.fileSize <= maxFileSize - else { - continue - } + else { continue } } if let currentBestRendition = bestRendition { - if rendition.width == currentBestRendition.width && + if + rendition.width == currentBestRendition.width && rendition.fileSize > 0 && currentBestRendition.fileSize > 0 && - rendition.fileSize < currentBestRendition.fileSize { + rendition.fileSize < currentBestRendition.fileSize + { // If two renditions have the same content size, prefer // the rendition with the smaller file size, e.g. // prefer JPEG over GIF for stills. bestRendition = rendition - } else if pickingStrategy == .smallerIsBetter { + } + else if pickingStrategy == .smallerIsBetter { // "Smaller is better" if rendition.width < currentBestRendition.width { bestRendition = rendition } - } else { + } + else { // "Larger is better" if rendition.width > currentBestRendition.width { bestRendition = rendition } } - } else { + } + else { bestRendition = rendition } } From da0ad25ec8eeae84b448f1d40d09f00598813219 Mon Sep 17 00:00:00 2001 From: mpretty-cyro <15862619+mpretty-cyro@users.noreply.github.com> Date: Tue, 7 Oct 2025 03:13:05 +0000 Subject: [PATCH 063/162] [Automated] Update translations from Crowdin --- .../Meta/Translations/Localizable.xcstrings | 304 ++++++++++++++---- 1 file changed, 246 insertions(+), 58 deletions(-) diff --git a/Session/Meta/Translations/Localizable.xcstrings b/Session/Meta/Translations/Localizable.xcstrings index 929defed6a..80ae275348 100644 --- a/Session/Meta/Translations/Localizable.xcstrings +++ b/Session/Meta/Translations/Localizable.xcstrings @@ -80465,6 +80465,50 @@ } } }, + "cameraAccessDeniedMessage" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{app_name} needs access to your camera to enable video calls, but this permission has been denied. You can’t update your camera permissions during a call.

Would you like to end the call now and enable camera access, or would you like to be reminded after the call?" + } + } + } + }, + "cameraAccessInstructions" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "To allow camera access, open settings and turn on the Camera permission." + } + } + } + }, + "cameraAccessReminderMessage" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "During your last call, you tried to use video but couldn’t because camera access was previously denied. To allow camera access, open settings and turn on the Camera permission." + } + } + } + }, + "cameraAccessRequired" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera Access Required" + } + } + } + }, "cameraErrorNotFound" : { "extractionState" : "manual", "localizations" : { @@ -83895,13 +83939,24 @@ } } }, + "cancelProPlan" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel {pro} Plan" + } + } + } + }, "cancelProPlatform" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Cancel your plan on the {platform} website, using the {platform_account} you signed up for Pro with." + "value" : "Cancel your plan on the {platform} website, using the {platform_account} you signed up for {pro} with." } } } @@ -83912,7 +83967,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Cancel your plan on the {platform_store} website, using the {platform_account} you signed up for Pro with." + "value" : "Cancel your plan on the {platform_store} website, using the {platform_account} you signed up for {pro} with." } } } @@ -84558,22 +84613,32 @@ "checkingProStatusDescription" : { "extractionState" : "manual", "localizations" : { - "az" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "{pro} məlumatlarınız yoxlanılır. Bu səhifədəki bəzi məlumatlar, yoxlama tamamlanana qədər qeyri-dəqiq ola bilər." + "value" : "Checking your {pro} details. Some information on this page may be unavailable until this check is complete." } - }, - "cs" : { + } + } + }, + "checkingProStatusEllipsis" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Kontrolují se vaše údaje {pro}. Některé informace na této stránce mohou být nepřesné, dokud kontrola nebude dokončena." + "value" : "Checking {pro} Status..." } - }, + } + } + }, + "checkingProStatusRenew" : { + "extractionState" : "manual", + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Checking your {pro} details. Some information on this page may be inaccurate until this check is complete." + "value" : "Checking your {pro} details. You cannot renew until this check is complete." } } } @@ -137849,6 +137914,72 @@ } } }, + "deleteAttachments" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Selected Attachment" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Selected Attachments" + } + } + } + } + } + } + } + } + }, + "deleteAttachmentsDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Are you sure you want to delete the selected attachment? The message associated with the attachment will also be deleted." + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Are you sure you want to delete the selected attachments? The message associated with the attachments will also be deleted." + } + } + } + } + } + } + } + } + }, "deleteContactDescription" : { "extractionState" : "manual", "localizations" : { @@ -188998,6 +189129,17 @@ } } }, + "enableCameraAccess" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable Camera Access?" + } + } + } + }, "enableNotifications" : { "extractionState" : "manual", "localizations" : { @@ -189063,6 +189205,17 @@ } } }, + "endCallToEnable" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "End Call to Enable" + } + } + } + }, "enjoyingSession" : { "extractionState" : "manual", "localizations" : { @@ -189659,22 +189812,10 @@ "errorCheckingProStatus" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} statusunu yoxlama xətası." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chyba kontroly stavu {pro}." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Error checking {pro} status." + "value" : "Error checking {pro} status" } } } @@ -327617,6 +327758,17 @@ } } }, + "onDeviceCancelDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, cancel your plan via the {app_pro} settings." + } + } + } + }, "onDeviceDescription" : { "extractionState" : "manual", "localizations" : { @@ -330544,6 +330696,17 @@ } } }, + "onLinkedDevice" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On a linked device" + } + } + } + }, "onPlatformStoreWebsite" : { "extractionState" : "manual", "localizations" : { @@ -332025,6 +332188,17 @@ } } }, + "openSettings" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Settings" + } + } + } + }, "openSurvey" : { "extractionState" : "manual", "localizations" : { @@ -356728,6 +356902,28 @@ } } }, + "proCancellationShortDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Canceling your {app_pro} plan will prevent your plan from automatically renewing before your {pro} plan expires.

Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your plan expires." + } + } + } + }, + "proCancelSorry" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "We’re sorry to see you cancel {pro}. Here's what you need to know before canceling your {app_pro} plan." + } + } + } + }, "processingRefundRequest" : { "extractionState" : "manual", "localizations" : { @@ -364997,7 +365193,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Because you originally signed up for {app_pro} via the {platform_store}, you'll need to use the same {platform_account} to request a refund." + "value" : "Because you originally signed up for {app_pro} via the {platform_store}, you'll need to use your {platform_account} to request a refund." } } } @@ -365113,7 +365309,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store}. Because you are using {app_name} Desktop, you're not able to renew your plan here.

{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store}. {pro} Roadmap {icon}" + "value" : "Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you are using {app_name} Desktop, you're not able to renew your plan here.

{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon}" } } } @@ -365124,7 +365320,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Renew your plan in the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store}." + "value" : "Renew your plan in the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store_other}." } } } @@ -365686,7 +365882,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store}. Because you installed {app_name} using the {buildVariant}, you're not able to renew your plan here.

{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store}. {pro} Roadmap {icon}" + "value" : "Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you're not able to renew your plan here.

{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon}" } } } @@ -366229,6 +366425,17 @@ } } }, + "proStatusRenewError" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to connect to the network to load your current plan. Renewing your plan via {app_name} will be disabled until connectivity is restored.

Please check your network connection and retry." + } + } + } + }, "proSupportDescription" : { "extractionState" : "manual", "localizations" : { @@ -383325,16 +383532,10 @@ "renewingPro" : { "extractionState" : "manual", "localizations" : { - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Obnovení Pro" - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Renewing Pro" + "value" : "Renewing {pro}" } } } @@ -427479,48 +427680,35 @@ } } }, - "viaStoreWebsite" : { + "viaPlatformWebsiteDescription" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Via the {platform} website" + "value" : "Change your plan using the {platform_account} you used to sign up with, via the {platform} website ." } } } }, - "viaStoreWebsiteDescription" : { + "viaStoreWebsite" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Qeydiyyatdan keçərkən istifadə etdiyiniz {platform_account} hesabı ilə {platform_store} veb saytı üzərindən planınızı dəyişdirin." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Změňte svůj tarif pomocí {platform_account}, se kterým jste se zaregistrovali, prostřednictvím webu {platform_store}." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Change your plan using the {platform_account} you used to sign up with, via the {platform_store} website." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Modifiez votre abonnement en utilisant le compte {platform_account} avec lequel vous vous êtes inscrit, via le site web de {platform_store}." + "value" : "Via the {platform} website" } - }, - "nl" : { + } + } + }, + "viaStoreWebsiteDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Wijzig je abonnement met het {platform_account} waarmee je je hebt aangemeld, via de {platform_store} website." + "value" : "Change your plan using the {platform_account} you used to sign up with, via the {platform_store} website ." } } } From b6c4d9a93560d6c255c049f7ddba7800d63a78f4 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 7 Oct 2025 16:50:42 +1100 Subject: [PATCH 064/162] Fixed a number of issues found during testing, cleaned up code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Started adding dev settings to enable deterministic encryption and set a custom file server for testing • Fixed a bug where the display picture upload would never dismiss the loader • Fixed a bug where we were using the wrong legacy encryption for the display picture --- Session.xcodeproj/project.pbxproj | 12 + .../ConversationVC+Interaction.swift | 12 +- .../Settings/ThreadSettingsViewModel.swift | 8 +- Session/Home/HomeViewModel.swift | 4 +- .../AllMediaViewController.swift | 4 +- .../CropScaleImageViewController.swift | 13 +- .../MediaDetailViewController.swift | 2 +- .../MediaPageViewController.swift | 2 +- ...DeveloperSettingsFileServerViewModel.swift | 456 ++++++++++++++++++ .../DeveloperSettingsViewModel.swift | 45 +- Session/Settings/ImagePickerHandler.swift | 5 + Session/Settings/SettingsViewModel.swift | 10 +- .../Crypto/Crypto+Attachments.swift | 61 ++- .../Crypto/Crypto+SessionMessagingKit.swift | 60 --- .../Database/Models/Profile.swift | 17 +- .../Jobs/AttachmentUploadJob.swift | 48 +- .../Jobs/DisplayPictureDownloadJob.swift | 31 +- .../Jobs/ReuploadUserDisplayPictureJob.swift | 7 +- .../Config Handling/LibSession+Contacts.swift | 2 +- .../LibSession+GroupMembers.swift | 2 +- .../LibSession+SharedGroup.swift | 2 +- .../LibSession+SessionMessagingKit.swift | 5 +- .../Errors/AttachmentError.swift | 2 +- .../MessageSender+Groups.swift | 2 +- .../Utilities/AttachmentManager.swift | 165 ++++--- .../Utilities/DisplayPictureManager.swift | 38 +- .../Utilities/Profile+Updating.swift | 38 +- .../LibSession/LibSessionGroupInfoSpec.swift | 4 +- .../LibSession/LibSessionUtilSpec.swift | 4 +- .../MessageReceiverGroupsSpec.swift | 8 +- .../MessageSenderGroupsSpec.swift | 4 +- .../FileServer/FileServer.swift | 81 ++++ .../FileServer/FileServerAPI.swift | 1 + SessionNetworkingKit/Types/Destination.swift | 3 +- .../Utilities/URL+Utilities.swift | 26 + .../ShareNavController.swift | 4 +- SessionShareExtension/ThreadPickerVC.swift | 11 +- SessionUtilitiesKit/General/Feature.swift | 4 + SessionUtilitiesKit/Types/FileManager.swift | 50 +- SessionUtilitiesKit/Types/Update.swift | 15 + .../AttachmentApprovalViewController.swift | 2 +- ...ModalActivityIndicatorViewController.swift | 4 +- _SharedTestUtilities/MockFileManager.swift | 4 + 43 files changed, 977 insertions(+), 301 deletions(-) create mode 100644 Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift create mode 100644 SessionNetworkingKit/Utilities/URL+Utilities.swift create mode 100644 SessionUtilitiesKit/Types/Update.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index e7a93cde61..992afbdc35 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -1023,6 +1023,9 @@ FDDF074429C3E3D000E5E8B5 /* FetchRequest+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */; }; FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */; }; FDE125232A837E4E002DA685 /* MainAppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE125222A837E4E002DA685 /* MainAppContext.swift */; }; + FDE287532E94C5CB00442E03 /* Update.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287522E94C5C900442E03 /* Update.swift */; }; + FDE287552E94CFDB00442E03 /* URL+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287542E94CFD400442E03 /* URL+Utilities.swift */; }; + FDE287572E94D7B800442E03 /* DeveloperSettingsFileServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287562E94D7B200442E03 /* DeveloperSettingsFileServerViewModel.swift */; }; FDE33BBC2D5C124900E56F42 /* DispatchTimeInterval+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE33BBB2D5C124300E56F42 /* DispatchTimeInterval+Utilities.swift */; }; FDE33BBE2D5C3AF100E56F42 /* _037_GroupsExpiredFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE33BBD2D5C3AE800E56F42 /* _037_GroupsExpiredFlag.swift */; }; FDE519F72AB7CDC700450C53 /* Result+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE519F62AB7CDC700450C53 /* Result+Utilities.swift */; }; @@ -2289,6 +2292,9 @@ FDDF074329C3E3D000E5E8B5 /* FetchRequest+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FetchRequest+Utilities.swift"; sourceTree = ""; }; FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerSpec.swift; sourceTree = ""; }; FDE125222A837E4E002DA685 /* MainAppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainAppContext.swift; sourceTree = ""; }; + FDE287522E94C5C900442E03 /* Update.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update.swift; sourceTree = ""; }; + FDE287542E94CFD400442E03 /* URL+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Utilities.swift"; sourceTree = ""; }; + FDE287562E94D7B200442E03 /* DeveloperSettingsFileServerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsFileServerViewModel.swift; sourceTree = ""; }; FDE33BBB2D5C124300E56F42 /* DispatchTimeInterval+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchTimeInterval+Utilities.swift"; sourceTree = ""; }; FDE33BBD2D5C3AE800E56F42 /* _037_GroupsExpiredFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _037_GroupsExpiredFlag.swift; sourceTree = ""; }; FDE519F62AB7CDC700450C53 /* Result+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Result+Utilities.swift"; sourceTree = ""; }; @@ -3706,6 +3712,7 @@ FD2272BD2C34B710004D8A6C /* Publisher+Utilities.swift */, FD83DCDC2A739D350065FFAE /* RetryWithDependencies.swift */, C3C2A5D22553860900C340D1 /* String+Trimming.swift */, + FDE287542E94CFD400442E03 /* URL+Utilities.swift */, FD2272A82C33E337004D8A6C /* URLResponse+Utilities.swift */, ); path = Utilities; @@ -4161,6 +4168,7 @@ FD78EA032DDEC3C000D55B50 /* MultiTaskManager.swift */, FD6F5B5F2E657A32009A8D01 /* StreamLifecycleManager.swift */, FD2272E92C351CA7004D8A6C /* Threading.swift */, + FDE287522E94C5C900442E03 /* Update.swift */, FDAA16752AC28A3B00DDBF77 /* UserDefaultsType.swift */, ); path = Types; @@ -5050,6 +5058,7 @@ children = ( FDC1BD672CFE6EEA002CDC71 /* DeveloperSettingsViewModel.swift */, FD860CBD2D6E7DA000BBE29C /* DeveloperSettingsViewModel+Testing.swift */, + FDE287562E94D7B200442E03 /* DeveloperSettingsFileServerViewModel.swift */, FDE71B0A2E7935250023F5F9 /* DeveloperSettingsGroupsViewModel.swift */, FDE71B0C2E793B1F0023F5F9 /* DeveloperSettingsProViewModel.swift */, ); @@ -6430,6 +6439,7 @@ FDF848D229405C5B007DCAE5 /* LegacyGetMessagesRequest.swift in Sources */, FDF848E529405D6E007DCAE5 /* SnodeAPIError.swift in Sources */, FD6B928C2E779DCC004463B5 /* FileServer.swift in Sources */, + FDE287552E94CFDB00442E03 /* URL+Utilities.swift in Sources */, FDF848D529405C5B007DCAE5 /* DeleteAllMessagesResponse.swift in Sources */, FD2272B22C33E337004D8A6C /* PreparedRequest.swift in Sources */, FDF848BF29405C5A007DCAE5 /* SnodeResponse.swift in Sources */, @@ -6548,6 +6558,7 @@ FDE754E02C9BAF8A002A2623 /* Hex.swift in Sources */, FDB11A5B2DD1901000BEF49F /* CurrentValueAsyncStream.swift in Sources */, FDB3DA862E1E1F0E00148F8D /* TaskCancellation.swift in Sources */, + FDE287532E94C5CB00442E03 /* Update.swift in Sources */, FDE754CC2C9BAF37002A2623 /* MediaUtils.swift in Sources */, FDE754DE2C9BAF8A002A2623 /* Crypto+SessionUtilitiesKit.swift in Sources */, FDF2220F281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift in Sources */, @@ -6853,6 +6864,7 @@ FD3FAB5F2AE9BC2200DC5421 /* EditGroupViewModel.swift in Sources */, 7BA37AFB2AEB64CA002438F8 /* DisappearingMessageTimerView.swift in Sources */, FD12A8492AD63C4700EEBA0D /* SessionNavItem.swift in Sources */, + FDE287572E94D7B800442E03 /* DeveloperSettingsFileServerViewModel.swift in Sources */, FD37EA0528AA00C1003AE748 /* NotificationSettingsViewModel.swift in Sources */, C328255225CA64470062D0A7 /* ContextMenuVC+ActionView.swift in Sources */, C3548F0824456AB6009433A8 /* UIView+Wrapping.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 4996d5caf8..b824e0f46a 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -511,7 +511,7 @@ extension ConversationVC: Task.detached(priority: .userInitiated) { [weak self, indicator, dependencies = viewModel.dependencies] in do { - let convertedAttachment: PendingAttachment = try await pendingAttachment.compressAsMp4Video( + let convertedAttachment: PendingAttachment = try await pendingAttachment.toMp4Video( using: dependencies ) guard await !indicator.wasCancelled else { return } @@ -1327,7 +1327,7 @@ extension ConversationVC: try? AVAudioSession.sharedInstance().setCategory(.playback) let viewController: DismissCallbackAVPlayerViewController = DismissCallbackAVPlayerViewController { [dependencies = viewModel.dependencies] in /// Sanity check to make sure we don't unintentionally remove a proper attachment file - guard path.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) else { + guard dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(path) else { return } @@ -1387,7 +1387,7 @@ extension ConversationVC: try? AVAudioSession.sharedInstance().setCategory(.playback) let viewController: DismissCallbackAVPlayerViewController = DismissCallbackAVPlayerViewController { [dependencies = viewModel.dependencies] in /// Sanity check to make sure we don't unintentionally remove a proper attachment file - guard path.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) else { + guard dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(path) else { return } @@ -2479,7 +2479,7 @@ extension ConversationVC: didPickDocumentsAt: { [weak self, dependencies = viewModel.dependencies] _, _ in validAttachments.forEach { attachment, path in /// Sanity check to make sure we don't unintentionally remove a proper attachment file - guard path.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) else { + guard dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(path) else { return } @@ -2538,7 +2538,7 @@ extension ConversationVC: completionHandler: { [weak self, dependencies] _, _ in validAttachments.forEach { attachment, path in /// Sanity check to make sure we don't unintentionally remove a proper attachment file - guard path.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) else { + guard dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(path) else { return } @@ -2924,7 +2924,7 @@ extension ConversationVC: UIDocumentInteractionControllerDelegate { /// Now that we are finished with it we want to remove the temporary file (just to be safe ensure that it starts with the /// `temporaryDirectory` so we don't accidentally delete a proper file if logic elsewhere changes) - if temporaryFileUrl.path.starts(with: viewModel.dependencies[singleton: .fileManager].temporaryDirectory) { + if viewModel.dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(temporaryFileUrl.path) { try? viewModel.dependencies[singleton: .fileManager].removeItem(atPath: temporaryFileUrl.path) } } diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 28b4e9418d..95e46114d3 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -1750,7 +1750,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob default: break } - Task(priority: .userInitiated) { [weak self, threadId, dependencies] in + Task.detached(priority: .userInitiated) { [weak self, threadId, dependencies] in var targetUpdate: DisplayPictureManager.Update = displayPictureUpdate var indicator: ModalActivityIndicatorViewController? @@ -1776,7 +1776,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob let preparedAttachment: PreparedAttachment = try dependencies[singleton: .displayPictureManager] .prepareDisplayPicture(attachment: pendingAttachment) let result = try await dependencies[singleton: .displayPictureManager] - .uploadDisplayPicture(attachment: preparedAttachment) + .uploadDisplayPicture(preparedAttachment: preparedAttachment) await MainActor.run { onUploadComplete() } targetUpdate = .groupUpdateTo( @@ -1844,9 +1844,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob } catch {} - await MainActor.run { [indicator] in - indicator?.dismiss(completion: {}) - } + await indicator?.dismiss() } } diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 98d29d990e..91d51434ab 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -373,8 +373,8 @@ public class HomeViewModel: NavigatableStateHolder { switch eventValue.change { case .name(let name): userProfile = userProfile.with(name: name) - case .nickname(let nickname): userProfile = userProfile.with(nickname: nickname) - case .displayPictureUrl(let url): userProfile = userProfile.with(displayPictureUrl: url) + case .nickname(let nickname): userProfile = userProfile.with(nickname: .set(to: nickname)) + case .displayPictureUrl(let url): userProfile = userProfile.with(displayPictureUrl: .set(to: url)) } } groupedOtherEvents?[.setting]?.forEach { event in diff --git a/Session/Media Viewing & Editing/AllMediaViewController.swift b/Session/Media Viewing & Editing/AllMediaViewController.swift index 008c21c169..1b6f32d42c 100644 --- a/Session/Media Viewing & Editing/AllMediaViewController.swift +++ b/Session/Media Viewing & Editing/AllMediaViewController.swift @@ -164,7 +164,7 @@ extension AllMediaViewController: UIDocumentInteractionControllerDelegate { /// Now that we are finished with it we want to remove the temporary file (just to be safe ensure that it starts with the /// `temporaryDirectory` so we don't accidentally delete a proper file if logic elsewhere changes) - if temporaryFileUrl.path.starts(with: dependencies[singleton: .fileManager].temporaryDirectory) { + if dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(temporaryFileUrl.path) { try? dependencies[singleton: .fileManager].removeItem(atPath: temporaryFileUrl.path) } } @@ -189,7 +189,7 @@ extension AllMediaViewController: DocumentTileViewControllerDelegate { navigationController?.present(shareVC, animated: true) { [dependencies] in /// Now that we are finished with it we want to remove the temporary file (just to be safe ensure that it starts with the /// `temporaryDirectory` so we don't accidentally delete a proper file if logic elsewhere changes) - if temporaryFileUrl.path.starts(with: dependencies[singleton: .fileManager].temporaryDirectory) { + if dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(temporaryFileUrl.path) { try? dependencies[singleton: .fileManager].removeItem(atPath: temporaryFileUrl.path) } } diff --git a/Session/Media Viewing & Editing/CropScaleImageViewController.swift b/Session/Media Viewing & Editing/CropScaleImageViewController.swift index 47cea9863f..c7509ea75d 100644 --- a/Session/Media Viewing & Editing/CropScaleImageViewController.swift +++ b/Session/Media Viewing & Editing/CropScaleImageViewController.swift @@ -39,11 +39,7 @@ import SessionUtilitiesKit var imageLayer: CALayer! // In width/height. - // - // TODO: We could make this a parameter. - var dstSizePixels: CGSize { - return CGSize(width: 640, height: 640) - } + let dstSizePixels: CGSize var dstAspectRatio: CGFloat { return dstSizePixels.width / dstSizePixels.height } @@ -78,9 +74,14 @@ import SessionUtilitiesKit fatalError("init(coder:) has not been implemented") } - @objc required init(srcImage: UIImage, successCompletion : @escaping (CGRect, Data) -> Void) { + @objc required init( + srcImage: UIImage, + dstSizePixels: CGSize, + successCompletion: @escaping (CGRect, Data) -> Void + ) { // normalized() can be slightly expensive but in practice this is fine. self.srcImage = srcImage.normalizedImage() + self.dstSizePixels = dstSizePixels self.successCompletion = successCompletion super.init(nibName: nil, bundle: nil) diff --git a/Session/Media Viewing & Editing/MediaDetailViewController.swift b/Session/Media Viewing & Editing/MediaDetailViewController.swift index 692baabe0c..b507ab3e1a 100644 --- a/Session/Media Viewing & Editing/MediaDetailViewController.swift +++ b/Session/Media Viewing & Editing/MediaDetailViewController.swift @@ -245,7 +245,7 @@ class MediaDetailViewController: OWSViewController, UIScrollViewDelegate { let player: AVPlayer = AVPlayer(url: videoUrl) let viewController: DismissCallbackAVPlayerViewController = DismissCallbackAVPlayerViewController { [dependencies] in /// Sanity check to make sure we don't unintentionally remove a proper attachment file - guard path.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) else { + guard dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(path) else { return } diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index f32a1f7642..aa77cf2c8c 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -498,7 +498,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou } /// Sanity check to make sure we don't unintentionally remove a proper attachment file - if path.hasPrefix(dependencies[singleton: .fileManager].temporaryDirectory) { + if dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(path) { try? dependencies[singleton: .fileManager].removeItem(atPath: path) } diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift new file mode 100644 index 0000000000..f1d5bbfc32 --- /dev/null +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift @@ -0,0 +1,456 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation +import Combine +import GRDB +import DifferenceKit +import SessionUIKit +import SessionNetworkingKit +import SessionMessagingKit +import SessionUtilitiesKit + +class DeveloperSettingsFileServerViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource { + public let dependencies: Dependencies + public let navigatableState: NavigatableState = NavigatableState() + public let state: TableDataState = TableDataState() + public let observableState: ObservableTableSourceState = ObservableTableSourceState() + + private var updatedCustomServerUrl: String? + private var updatedCustomServerPubkey: String? + + /// This value is the current state of the view + @MainActor @Published private(set) var internalState: State + private var observationTask: Task? + + // MARK: - Initialization + + @MainActor init(using dependencies: Dependencies) { + self.dependencies = dependencies + self.internalState = State.initialState(using: dependencies) + + /// Bind the state + self.observationTask = ObservationBuilder + .initialValue(self.internalState) + .debounce(for: .never) + .using(dependencies: dependencies) + .query(DeveloperSettingsFileServerViewModel.queryState) + .assign { [weak self] updatedState in + guard let self = self else { return } + + // FIXME: To slightly reduce the size of the changes this new observation mechanism is currently wired into the old SessionTableViewController observation mechanism, we should refactor it so everything uses the new mechanism + let oldState: State = self.internalState + self.internalState = updatedState + self.pendingTableDataSubject.send(updatedState.sections(viewModel: self, previousState: oldState)) + } + } + + // MARK: - Config + + public enum Section: SessionTableSection { + case general + + var title: String? { + switch self { + case .general: return nil + } + } + + var style: SessionTableSectionStyle { + switch self { + case .general: return .padding + } + } + } + + public enum TableItem: Hashable, Differentiable, CaseIterable { + case shortenFileTTL + case deterministicAttachmentEncryption + case customFileServerUrl + case customFileServerPubkey + + // MARK: - Conformance + + public typealias DifferenceIdentifier = String + + public var differenceIdentifier: String { + switch self { + case .shortenFileTTL: return "shortenFileTTL" + case .deterministicAttachmentEncryption: return "deterministicAttachmentEncryption" + case .customFileServerUrl: return "customFileServerUrl" + case .customFileServerPubkey: return "customFileServerPubkey" + } + } + + public func isContentEqual(to source: TableItem) -> Bool { + self.differenceIdentifier == source.differenceIdentifier + } + + public static var allCases: [TableItem] { + var result: [TableItem] = [] + switch TableItem.shortenFileTTL { + case .shortenFileTTL: result.append(.shortenFileTTL); fallthrough + case .deterministicAttachmentEncryption: result.append(.deterministicAttachmentEncryption); fallthrough + case .customFileServerUrl: result.append(.customFileServerUrl); fallthrough + case .customFileServerPubkey: result.append(.customFileServerPubkey) + } + + return result + } + } + + // MARK: - Content + + public struct State: Equatable, ObservableKeyProvider { + struct Info: Equatable, Hashable { + let shortenFileTTL: Bool + let deterministicAttachmentEncryption: Bool + let customFileServer: Network.FileServer.Custom + + public func with( + shortenFileTTL: Bool? = nil, + deterministicAttachmentEncryption: Bool? = nil, + customFileServer: Network.FileServer.Custom? = nil + ) -> Info { + return Info( + shortenFileTTL: (shortenFileTTL ?? self.shortenFileTTL), + deterministicAttachmentEncryption: (deterministicAttachmentEncryption ?? self.deterministicAttachmentEncryption), + customFileServer: (customFileServer ?? self.customFileServer) + ) + } + } + + let initialState: Info + let pendingState: Info + + @MainActor public func sections(viewModel: DeveloperSettingsFileServerViewModel, previousState: State) -> [SectionModel] { + DeveloperSettingsFileServerViewModel.sections( + state: self, + previousState: previousState, + viewModel: viewModel + ) + } + + public let observedKeys: Set = [ + .updateScreen(DeveloperSettingsFileServerViewModel.self), + .feature(.shortenFileTTL), + .feature(.deterministicAttachmentEncryption), + .feature(.customFileServer) + ] + + static func initialState(using dependencies: Dependencies) -> State { + let initialInfo: Info = Info( + shortenFileTTL: dependencies[feature: .shortenFileTTL], + deterministicAttachmentEncryption: dependencies[feature: .deterministicAttachmentEncryption], + customFileServer: dependencies[feature: .customFileServer] + ) + + return State( + initialState: initialInfo, + pendingState: initialInfo + ) + } + } + + let title: String = "Developer File Server Settings" + + lazy var footerButtonInfo: AnyPublisher = $internalState + .map { [weak self] state -> SessionButton.Info? in + return SessionButton.Info( + style: .bordered, + title: "set".localized(), + isEnabled: { + guard state.initialState != state.pendingState else { return false } + + return ( + state.pendingState.customFileServer.isEmpty || + state.pendingState.customFileServer.isValid + ) + }(), + accessibility: Accessibility( + identifier: "Set button", + label: "Set button" + ), + minWidth: 110, + onTap: { [weak self] in + Task { [weak self] in + await self?.saveChanges() + } + } + ) + } + .eraseToAnyPublisher() + + @Sendable private static func queryState( + previousState: State, + events: [ObservedEvent], + isInitialQuery: Bool, + using dependencies: Dependencies + ) async -> State { + return State( + initialState: previousState.initialState, + pendingState: (events.first?.value as? State.Info ?? previousState.pendingState) + ) + } + + private static func sections( + state: State, + previousState: State, + viewModel: DeveloperSettingsFileServerViewModel + ) -> [SectionModel] { + let general: SectionModel = SectionModel( + model: .general, + elements: [ + SessionCell.Info( + id: .shortenFileTTL, + title: "Shorten File TTL", + subtitle: "Set the TTL for files in the cache to 1 minute", + trailingAccessory: .toggle( + state.pendingState.shortenFileTTL, + oldValue: previousState.pendingState.shortenFileTTL + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsFileServerViewModel.self), + value: state.pendingState.with( + shortenFileTTL: !state.pendingState.shortenFileTTL + ) + ) + } + ), + SessionCell.Info( + id: .deterministicAttachmentEncryption, + title: "Deterministic Attachment Encryption", + subtitle: """ + Controls whether the new deterministic encryption should be used for attachment and display pictures + + Warning: Old clients won't be able to decrypt attachments sent while this is enabled + """, + trailingAccessory: .toggle( + state.pendingState.deterministicAttachmentEncryption, + oldValue: previousState.pendingState.deterministicAttachmentEncryption + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsFileServerViewModel.self), + value: state.pendingState.with( + deterministicAttachmentEncryption: !state.pendingState.deterministicAttachmentEncryption + ) + ) + } + ), + SessionCell.Info( + id: .customFileServerUrl, + title: "Custom File Server URL", + subtitle: """ + The URL to use instead of the default File Server for uploading files + + Current: \(state.pendingState.customFileServer.url.isEmpty ? "Default" : state.pendingState.customFileServer.url) + """, + trailingAccessory: .icon(.squarePen), + onTap: { [weak viewModel] in + viewModel?.showServerUrlModal(pendingState: state.pendingState) + } + ), + SessionCell.Info( + id: .customFileServerPubkey, + title: "Custom File Server Public Key", + subtitle: """ + The public key to use for the above custom File Server + + Current: \(state.pendingState.customFileServer.pubkey.isEmpty ? "Default" : state.pendingState.customFileServer.pubkey) + """, + trailingAccessory: .icon(.squarePen), + onTap: { [weak viewModel] in + viewModel?.showServerPubkeyModal(pendingState: state.pendingState) + } + ) + ] + ) + + return [general] + } + + // MARK: - Internal Functions + + private func showServerUrlModal(pendingState: State.Info) { + self.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Custom File Server URL", + body: .input( + explanation: ThemedAttributedString( + string: "The url for the custom file server." + ), + info: ConfirmationModal.Info.Body.InputInfo( + placeholder: "Enter URL", + initialValue: pendingState.customFileServer.url, + inputChecker: { text in + guard URL(string: text) != nil else { + return "Value must be a valid url." + } + + return nil + } + ), + onChange: { [weak self] value in + self?.updatedCustomServerPubkey = value + } + ), + confirmTitle: "save".localized(), + confirmEnabled: .afterChange { [weak self] _ in + guard let value: String = self?.updatedCustomServerUrl else { + return false + } + + return (URL(string: value) != nil) + }, + cancelStyle: .alert_text, + dismissOnConfirm: false, + onConfirm: { [weak self, dependencies] modal in + guard + let value: String = self?.updatedCustomServerPubkey, + URL(string: value) != nil + else { + modal.updateContent( + withError: "Value must be a valid url." + ) + return + } + + modal.dismiss(animated: true) + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsFileServerViewModel.self), + value: pendingState.with( + customFileServer: pendingState.customFileServer.with( + url: value + ) + ) + ) + } + ) + ), + transitionType: .present + ) + } + + private func showServerPubkeyModal(pendingState: State.Info) { + self.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Custom File Server Pubkey", + body: .input( + explanation: ThemedAttributedString( + string: """ + The public key for the custom file server. + + This is 64 character hexadecimal value. + """ + ), + info: ConfirmationModal.Info.Body.InputInfo( + placeholder: "Enter Pubkey", + initialValue: pendingState.customFileServer.pubkey, + inputChecker: { text in + guard text.count <= 64 else { + return "Value must be a 64 character hexadecimal string." + } + + return nil + } + ), + onChange: { [weak self] value in + self?.updatedCustomServerPubkey = value + } + ), + confirmTitle: "save".localized(), + confirmEnabled: .afterChange { [weak self] _ in + guard let value: String = self?.updatedCustomServerPubkey else { + return false + } + + return ( + Hex.isValid(value) && + value.trimmingCharacters(in: .whitespacesAndNewlines).count == 64 + ) + }, + cancelStyle: .alert_text, + dismissOnConfirm: false, + onConfirm: { [weak self, dependencies] modal in + guard + let value: String = self?.updatedCustomServerPubkey, + Hex.isValid(value), + value.trimmingCharacters(in: .whitespacesAndNewlines).count == 64 + else { + modal.updateContent( + withError: "Value must be a 64 character hexadecimal string." + ) + return + } + + modal.dismiss(animated: true) + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsFileServerViewModel.self), + value: pendingState.with( + customFileServer: pendingState.customFileServer.with( + pubkey: value + ) + ) + ) + } + ) + ), + transitionType: .present + ) + } + + // MARK: - Reverting + + public static func disableDeveloperMode(using dependencies: Dependencies) { + let features: [FeatureConfig] = [ + .shortenFileTTL, + .deterministicAttachmentEncryption + ] + + features.forEach { feature in + guard dependencies.hasSet(feature: feature) else { return } + + dependencies.set(feature: feature, to: nil) + } + + if dependencies.hasSet(feature: .customFileServer) { + dependencies.set(feature: .customFileServer, to: nil) + } + } + + // MARK: - Saving + + @MainActor private func saveChanges(hasConfirmed: Bool = false) async { + guard internalState.initialState != internalState.pendingState else { return } + + if internalState.initialState.shortenFileTTL != internalState.pendingState.shortenFileTTL { + dependencies.set(feature: .shortenFileTTL, to: internalState.pendingState.shortenFileTTL) + } + + if internalState.initialState.deterministicAttachmentEncryption != internalState.pendingState.deterministicAttachmentEncryption { + dependencies.set( + feature: .deterministicAttachmentEncryption, + to: internalState.pendingState.deterministicAttachmentEncryption + ) + } + + if internalState.initialState.customFileServer != internalState.pendingState.customFileServer { + dependencies.set( + feature: .customFileServer, + to: internalState.pendingState.customFileServer + ) + } + + /// Changes have been saved so we can dismiss the screen + self.dismissScreen() + } +} diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift index 462ca0244e..320867b2f7 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift @@ -74,7 +74,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case proConfig case groupConfig - case shortenFileTTL + case fileServerConfig case animationsEnabled case showStringKeys case truncatePubkeysInLogs @@ -116,7 +116,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .proConfig: return "proConfig" case .groupConfig: return "groupConfig" - case .shortenFileTTL: return "shortenFileTTL" + case .fileServerConfig: return "fileServerConfig" case .animationsEnabled: return "animationsEnabled" case .showStringKeys: return "showStringKeys" case .truncatePubkeysInLogs: return "truncatePubkeysInLogs" @@ -160,7 +160,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .proConfig: result.append(.proConfig); fallthrough case .groupConfig: result.append(.groupConfig); fallthrough - case .shortenFileTTL: result.append(.shortenFileTTL); fallthrough + case .fileServerConfig: result.append(.fileServerConfig); fallthrough case .animationsEnabled: result.append(.animationsEnabled); fallthrough case .showStringKeys: result.append(.showStringKeys); fallthrough case .truncatePubkeysInLogs: result.append(.truncatePubkeysInLogs); fallthrough @@ -201,7 +201,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, let developerMode: Bool let versionBlindedID: String? - let shortenFileTTL: Bool let animationsEnabled: Bool let showStringKeys: Bool let truncatePubkeysInLogs: Bool @@ -245,7 +244,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, cache.get(.developerModeEnabled) }, versionBlindedID: versionBlindedID, - shortenFileTTL: dependencies[feature: .shortenFileTTL], animationsEnabled: dependencies[feature: .animationsEnabled], showStringKeys: dependencies[feature: .showStringKeys], truncatePubkeysInLogs: dependencies[feature: .truncatePubkeysInLogs], @@ -340,17 +338,21 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, model: .general, elements: [ SessionCell.Info( - id: .shortenFileTTL, - title: "Shorten File TTL", - subtitle: "Set the TTL for files in the cache to 1 minute", - trailingAccessory: .toggle( - current.shortenFileTTL, - oldValue: previous?.shortenFileTTL - ), - onTap: { [weak self] in - self?.updateFlag( - for: .shortenFileTTL, - to: !current.shortenFileTTL + id: .fileServerConfig, + title: "File Server Configuration", + subtitle: """ + Configure settings related to the File Server. + + File TTL: \(dependencies[feature: .shortenFileTTL] ? "60 Seconds" : "14 Days") + Deterministic Encryption: \(dependencies[feature: .deterministicAttachmentEncryption] ? "Enabled" : "Disabled") + File Server: \(dependencies[feature: .customFileServer].isValid ? dependencies[feature: .customFileServer].url : Network.FileServer.fileServer) + """, + trailingAccessory: .icon(.chevronRight), + onTap: { [weak self, dependencies] in + self?.transitionToScreen( + SessionTableViewController( + viewModel: DeveloperSettingsFileServerViewModel(using: dependencies) + ) ) } ), @@ -787,10 +789,10 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, .importDatabase, .advancedLogging, .resetAppReviewPrompt: break /// These are actions rather than values stored as "features" so no need to do anything - case .shortenFileTTL: - guard dependencies.hasSet(feature: .shortenFileTTL) else { return } - - updateFlag(for: .shortenFileTTL, to: nil) + case .groupConfig: DeveloperSettingsGroupsViewModel.disableDeveloperMode(using: dependencies) + case .proConfig: DeveloperSettingsProViewModel.disableDeveloperMode(using: dependencies) + case .fileServerConfig: + DeveloperSettingsFileServerViewModel.disableDeveloperMode(using: dependencies) case .animationsEnabled: guard dependencies.hasSet(feature: .animationsEnabled) else { return } @@ -841,9 +843,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, dependencies.set(feature: .communityPollLimit, to: nil) forceRefresh(type: .databaseQuery) - case .groupConfig: DeveloperSettingsGroupsViewModel.disableDeveloperMode(using: dependencies) - case .proConfig: DeveloperSettingsProViewModel.disableDeveloperMode(using: dependencies) - case .forceSlowDatabaseQueries: guard dependencies.hasSet(feature: .forceSlowDatabaseQueries) else { return } diff --git a/Session/Settings/ImagePickerHandler.swift b/Session/Settings/ImagePickerHandler.swift index 24811c715f..94d379d03f 100644 --- a/Session/Settings/ImagePickerHandler.swift +++ b/Session/Settings/ImagePickerHandler.swift @@ -2,6 +2,7 @@ import UIKit import UniformTypeIdentifiers +import SessionMessagingKit import SessionUtilitiesKit class ImagePickerHandler: NSObject, UIImagePickerControllerDelegate & UINavigationControllerDelegate { @@ -44,6 +45,10 @@ class ImagePickerHandler: NSObject, UIImagePickerControllerDelegate & UINavigati else { let viewController: CropScaleImageViewController = CropScaleImageViewController( srcImage: rawAvatar, + dstSizePixels: CGSize( + width: DisplayPictureManager.maxDimension, + height: DisplayPictureManager.maxDimension + ), successCompletion: { cropFrame, resultImageData in let croppedImagePath: String = imageUrl .deletingLastPathComponent() diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 96549d2bbd..96a2a13057 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -211,8 +211,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl groupedEvents?[.profile]?.forEach { event in switch (event.value as? ProfileEvent)?.change { case .name(let name): profile = profile.with(name: name) - case .nickname(let nickname): profile = profile.with(nickname: nickname) - case .displayPictureUrl(let url): profile = profile.with(displayPictureUrl: url) + case .nickname(let nickname): profile = profile.with(nickname: .set(to: nickname)) + case .displayPictureUrl(let url): profile = profile.with(displayPictureUrl: .set(to: url)) default: break } } @@ -736,7 +736,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl let preparedAttachment: PreparedAttachment = try dependencies[singleton: .displayPictureManager] .prepareDisplayPicture(attachment: pendingAttachment) let result = try await dependencies[singleton: .displayPictureManager] - .uploadDisplayPicture(attachment: preparedAttachment) + .uploadDisplayPicture(preparedAttachment: preparedAttachment) return .currentUserUpdateTo(url: result.downloadUrl, key: result.encryptionKey, isReupload: false) } @@ -759,6 +759,10 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl displayPictureUpdate: displayPictureUpdate, using: dependencies ) + + await indicator.dismiss { + onComplete() + } } catch { let message: String = { diff --git a/SessionMessagingKit/Crypto/Crypto+Attachments.swift b/SessionMessagingKit/Crypto/Crypto+Attachments.swift index 76a84bb845..fc601b4190 100644 --- a/SessionMessagingKit/Crypto/Crypto+Attachments.swift +++ b/SessionMessagingKit/Crypto/Crypto+Attachments.swift @@ -3,6 +3,7 @@ // stringlint:disable import Foundation +import CryptoKit import CommonCrypto import SessionUtil import SessionNetworkingKit @@ -78,11 +79,11 @@ public extension Crypto.Generator { } @available(*, deprecated, message: "This encryption method is deprecated and will be removed in a future release.") - static func legacyEncryptAttachment( + static func legacyEncryptedAttachment( plaintext: Data ) -> Crypto.Generator<(ciphertext: Data, encryptionKey: Data, digest: Data)> { return Crypto.Generator( - id: "legacyEncryptAttachment", + id: "legacyEncryptedAttachment", args: [plaintext] ) { dependencies in // Due to paddedSize, we need to divide by two. @@ -155,6 +156,33 @@ public extension Crypto.Generator { return (Data(encryptedPaddedData), outKey, Data(digest)) } } + + @available(*, deprecated, message: "This encryption method is deprecated and will be removed in a future release.") + static func legacyEncryptedDisplayPicture( + data: Data, + key: Data + ) -> Crypto.Generator { + return Crypto.Generator( + id: "legacyEncryptedDisplayPicture", + args: [data, key] + ) { dependencies in + // The key structure is: nonce || ciphertext || authTag + guard + key.count == DisplayPictureManager.encryptionKeySize, + let nonceData: Data = dependencies[singleton: .crypto] + .generate(.randomBytes(DisplayPictureManager.nonceLength)), + let nonce: AES.GCM.Nonce = try? AES.GCM.Nonce(data: nonceData), + let sealedData: AES.GCM.SealedBox = try? AES.GCM.seal( + data, + using: SymmetricKey(data: key), + nonce: nonce + ), + let encryptedContent: Data = sealedData.combined + else { throw CryptoError.failedToGenerateOutput } + + return encryptedContent + } + } } // MARK: - Decryption @@ -315,4 +343,33 @@ public extension Crypto.Generator { return Data(paddedPlaintext[0.. Crypto.Generator { + return Crypto.Generator( + id: "legacyDecryptedDisplayPicture", + args: [data, key] + ) { dependencies in + guard key.count == DisplayPictureManager.encryptionKeySize else { + throw CryptoError.failedToGenerateOutput + } + + // The key structure is: nonce || ciphertext || authTag + let cipherTextLength: Int = (data.count - (DisplayPictureManager.nonceLength + DisplayPictureManager.tagLength)) + + guard + cipherTextLength > 0, + let sealedData: AES.GCM.SealedBox = try? AES.GCM.SealedBox( + nonce: AES.GCM.Nonce(data: data.subdata(in: 0.. Crypto.Generator { - return Crypto.Generator( - id: "encryptedDataDisplayPicture", - args: [data, key] - ) { dependencies in - // The key structure is: nonce || ciphertext || authTag - guard - key.count == DisplayPictureManager.aes256KeyByteLength, - let nonceData: Data = dependencies[singleton: .crypto] - .generate(.randomBytes(DisplayPictureManager.nonceLength)), - let nonce: AES.GCM.Nonce = try? AES.GCM.Nonce(data: nonceData), - let sealedData: AES.GCM.SealedBox = try? AES.GCM.seal( - data, - using: SymmetricKey(data: key), - nonce: nonce - ), - let encryptedContent: Data = sealedData.combined - else { throw CryptoError.failedToGenerateOutput } - - return encryptedContent - } - } - - static func decryptedDataDisplayPicture( - data: Data, - key: Data - ) -> Crypto.Generator { - return Crypto.Generator( - id: "decryptedDataDisplayPicture", - args: [data, key] - ) { dependencies in - guard key.count == DisplayPictureManager.aes256KeyByteLength else { - throw CryptoError.failedToGenerateOutput - } - - // The key structure is: nonce || ciphertext || authTag - let cipherTextLength: Int = (data.count - (DisplayPictureManager.nonceLength + DisplayPictureManager.tagLength)) - - guard - cipherTextLength > 0, - let sealedData: AES.GCM.SealedBox = try? AES.GCM.SealedBox( - nonce: AES.GCM.Nonce(data: data.subdata(in: 0.. = .useExisting, + displayPictureUrl: Update = .useExisting, + displayPictureEncryptionKey: Update = .useExisting, + profileLastUpdated: Update = .useExisting, + blocksCommunityMessageRequests: Update = .useExisting ) -> Profile { return Profile( id: id, name: (name ?? self.name), - nickname: (nickname ?? self.nickname), - displayPictureUrl: (displayPictureUrl ?? self.displayPictureUrl), - displayPictureEncryptionKey: displayPictureEncryptionKey, - profileLastUpdated: profileLastUpdated, - blocksCommunityMessageRequests: blocksCommunityMessageRequests + nickname: nickname.or(self.nickname), + displayPictureUrl: displayPictureUrl.or(self.displayPictureUrl), + displayPictureEncryptionKey: displayPictureEncryptionKey.or(self.displayPictureEncryptionKey), + profileLastUpdated: profileLastUpdated.or(self.profileLastUpdated), + blocksCommunityMessageRequests: blocksCommunityMessageRequests.or(self.blocksCommunityMessageRequests) ) } } diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index 27af612ffa..4fafe5c3ce 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -175,7 +175,8 @@ extension AttachmentUploadJob { public extension AttachmentUploadJob { typealias PreparedUpload = ( request: Network.PreparedRequest, - attachment: PreparedAttachment + attachment: Attachment, + preparedAttachment: PreparedAttachment ) enum Event { @@ -188,21 +189,15 @@ public extension AttachmentUploadJob { using dependencies: Dependencies ) throws -> [Attachment] { return try attachments.compactMap { pendingAttachment in - /// Strip any metadata from the attachment + /// Strip any metadata from the attachment and store at a "Pending Upload" file path let preparedAttachment: PreparedAttachment = try pendingAttachment.prepare( transformations: [ .stripImageMetadata ], + storeAtPendingAttachmentUploadPath: true, using: dependencies ) - /// The attachment will have been stored in a temporary location during preparation so we need to move it to the - /// "pending upload" file path (which will be relocated to the deterministic final path after upload) - try dependencies[singleton: .fileManager].moveItem( - atPath: preparedAttachment.temporaryFilePath, - toPath: preparedAttachment.pendingUploadFilePath - ) - return preparedAttachment.attachment } } @@ -289,7 +284,7 @@ public extension AttachmentUploadJob { using: dependencies ) let maybePreparedData: Data? = dependencies[singleton: .fileManager] - .contents(atPath: preparedAttachment.temporaryFilePath) + .contents(atPath: preparedAttachment.filePath) try Task.checkCancellation() guard let preparedData: Data = maybePreparedData else { @@ -316,7 +311,6 @@ public extension AttachmentUploadJob { ) default: - // TODO: Handle custom URLs request = try Network.preparedUpload(data: preparedData, using: dependencies) } @@ -329,11 +323,15 @@ public extension AttachmentUploadJob { /// If the `downloadUrl` previously had a value and we are updating it then we need to move the file from it's current location /// to the hash that would be generated for the new location + /// + /// **Note:** Attachments are currently stored unencrypted so we need to move the original `attachment` file to the + /// `finalFilePath` rather than the encrypted one + // FIXME: Should probably store display pictures encrypted and decrypt on load let finalDownloadUrl: String = { let isPlaceholderUploadUrl: Bool = dependencies[singleton: .attachmentManager] - .isPlaceholderUploadUrl(preparedAttachment.attachment.downloadUrl) + .isPlaceholderUploadUrl(attachment.downloadUrl) - switch (preparedAttachment.attachment.downloadUrl, isPlaceholderUploadUrl, authMethod) { + switch (attachment.downloadUrl, isPlaceholderUploadUrl, authMethod) { case (.some(let downloadUrl), false, _): return downloadUrl case (_, _, let community as Authentication.community): return Network.SOGS.downloadUrlString( @@ -343,13 +341,12 @@ public extension AttachmentUploadJob { ) default: - // TODO: Handle Custom URLs return Network.FileServer.downloadUrlString(for: response.id) } }() if - let oldUrl: String = preparedAttachment.attachment.downloadUrl, + let oldUrl: String = attachment.downloadUrl, finalDownloadUrl != oldUrl, let oldPath: String = try? dependencies[singleton: .attachmentManager].path(for: oldUrl), let newPath: String = try? dependencies[singleton: .attachmentManager].path(for: finalDownloadUrl) @@ -390,7 +387,7 @@ public extension AttachmentUploadJob { attachment: Attachment, authMethod: AuthenticationMethod, using dependencies: Dependencies - ) throws -> (request: Network.PreparedRequest, attachment: PreparedAttachment) { + ) throws -> (request: Network.PreparedRequest, attachment: Attachment, preparedAttachment: PreparedAttachment) { let endpoint: (any EndpointType) = { switch authMethod { case let community as Authentication.community: @@ -418,10 +415,10 @@ public extension AttachmentUploadJob { endpoint: endpoint, using: dependencies ), + attachment, PreparedAttachment( attachment: attachment, - temporaryFilePath: "", - pendingUploadFilePath: "" + filePath: "" ) ) } @@ -446,10 +443,10 @@ public extension AttachmentUploadJob { endpoint: endpoint, using: dependencies ), + attachment, PreparedAttachment( attachment: attachment, - temporaryFilePath: "", - pendingUploadFilePath: "" + filePath: "" ) ) } @@ -467,7 +464,7 @@ public extension AttachmentUploadJob { using: dependencies ) let maybePreparedData: Data? = dependencies[singleton: .fileManager] - .contents(atPath: preparedAttachment.temporaryFilePath) + .contents(atPath: preparedAttachment.filePath) guard let preparedData: Data = maybePreparedData else { Log.error(.cat, "Couldn't retrieve prepared attachment data.") @@ -488,12 +485,14 @@ public extension AttachmentUploadJob { authMethod: communityAuth, using: dependencies ), + attachment, preparedAttachment ) default: return ( try Network.preparedUpload(data: preparedData, using: dependencies), + attachment, preparedAttachment ) } @@ -501,6 +500,7 @@ public extension AttachmentUploadJob { @available(*, deprecated, message: "Replace with an async/await call to `upload`") static func processUploadResponse( + originalAttachment: Attachment, preparedAttachment: PreparedAttachment, authMethod: AuthenticationMethod, response: FileUploadResponse, @@ -510,9 +510,9 @@ public extension AttachmentUploadJob { /// to the hash that would be generated for the new location let finalDownloadUrl: String = { let isPlaceholderUploadUrl: Bool = dependencies[singleton: .attachmentManager] - .isPlaceholderUploadUrl(preparedAttachment.attachment.downloadUrl) + .isPlaceholderUploadUrl(originalAttachment.downloadUrl) - switch (preparedAttachment.attachment.downloadUrl, isPlaceholderUploadUrl, authMethod) { + switch (originalAttachment.downloadUrl, isPlaceholderUploadUrl, authMethod) { case (.some(let downloadUrl), false, _): return downloadUrl case (_, _, let community as Authentication.community): return Network.SOGS.downloadUrlString( @@ -527,7 +527,7 @@ public extension AttachmentUploadJob { }() if - let oldUrl: String = preparedAttachment.attachment.downloadUrl, + let oldUrl: String = originalAttachment.downloadUrl, finalDownloadUrl != oldUrl, let oldPath: String = try? dependencies[singleton: .attachmentManager].path(for: oldUrl), let newPath: String = try? dependencies[singleton: .attachmentManager].path(for: finalDownloadUrl) diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index bf6e8ad2ff..7649670d2c 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -39,11 +39,9 @@ public enum DisplayPictureDownloadJob: JobExecutor { let request: Network.PreparedRequest = try await dependencies[singleton: .storage].readAsync { db in switch details.target { case .profile(_, let url, _), .group(_, let url, _): - // TODO: Support custom URLs - guard - let fileId: String = Network.FileServer.fileId(for: url), - let downloadUrl: URL = URL(string: Network.FileServer.downloadUrlString(for: url, fileId: fileId)) - else { throw NetworkError.invalidURL } + guard let downloadUrl: URL = URL(string: url) else { + throw NetworkError.invalidURL + } return try Network.preparedDownload( url: downloadUrl, @@ -67,9 +65,9 @@ public enum DisplayPictureDownloadJob: JobExecutor { } try Task.checkCancellation() - let downloadUrl: URL? = try? request.generateUrl() + let downloadUrl: String = ((try? request.generateUrl())?.absoluteString ?? request.path) let filePath: String = try dependencies[singleton: .displayPictureManager] - .path(for: (downloadUrl?.absoluteString ?? request.path)) + .path(for: downloadUrl) guard !dependencies[singleton: .fileManager].fileExists(atPath: filePath) else { throw AttachmentError.alreadyDownloaded(downloadUrl) @@ -90,11 +88,16 @@ public enum DisplayPictureDownloadJob: JobExecutor { /// Get the decrypted data guard let decryptedData: Data = { - switch details.target { - case .community: return response /// Community data is unencrypted - case .profile(_, _, let encryptionKey), .group(_, _, let encryptionKey): + switch (details.target, Network.FileServer.usesDeterministicEncryption(downloadUrl)) { + case (.community, _): return response /// Community data is unencrypted + case (.profile(_, _, let encryptionKey), false), (.group(_, _, let encryptionKey), false): + return dependencies[singleton: .crypto].generate( + .legacyDecryptedDisplayPicture(data: response, key: encryptionKey) + ) + + case (.profile(_, _, let encryptionKey), true), (.group(_, _, let encryptionKey), true): return dependencies[singleton: .crypto].generate( - .decryptedDataDisplayPicture(data: response, key: encryptionKey) + .decryptAttachment(ciphertext: response, key: encryptionKey) ) } }() @@ -192,7 +195,7 @@ public enum DisplayPictureDownloadJob: JobExecutor { private static func writeChanges( _ db: ObservingDatabase, details: Details, - downloadUrl: URL?, + downloadUrl: String?, using dependencies: Dependencies ) throws { switch details.target { @@ -230,7 +233,7 @@ public enum DisplayPictureDownloadJob: JobExecutor { ) db.addConversationEvent( id: OpenGroup.idFor(roomToken: roomToken, server: server), - type: .updated(.displayPictureUrl(downloadUrl?.absoluteString)) + type: .updated(.displayPictureUrl(downloadUrl)) ) } } @@ -250,7 +253,7 @@ extension DisplayPictureDownloadJob { return ( !url.isEmpty && Network.FileServer.fileId(for: url) != nil && - encryptionKey.count == DisplayPictureManager.aes256KeyByteLength + encryptionKey.count == DisplayPictureManager.encryptionKeySize ) case .community(let imageId, _, _, _): return !imageId.isEmpty diff --git a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift index 01f0436c1f..1fcf73b11e 100644 --- a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift +++ b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift @@ -112,7 +112,7 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { /// Since we made it here it means that refreshing the TTL failed so we may need to reupload the display picture do { let pendingDisplayPicture: PendingAttachment = PendingAttachment( - source: .displayPicture(.url(displayPictureUrl)), + source: .media(.url(displayPictureUrl)), using: dependencies ) @@ -149,12 +149,11 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { attachment: pendingDisplayPicture, transformations: [ .convertToStandardFormats, - .resize(maxDimension: DisplayPictureManager.maxDimension), - .encrypt(legacy: true, domain: .profilePicture) // FIXME: Remove the `legacy` encryption option + .resize(maxDimension: DisplayPictureManager.maxDimension) ] ) let result = try await dependencies[singleton: .displayPictureManager] - .uploadDisplayPicture(attachment: preparedAttachment) + .uploadDisplayPicture(preparedAttachment: preparedAttachment) /// Update the local state now that the display picture has finished uploading try await Profile.updateLocal( diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index f61612f7b1..df05fc1d6a 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -347,7 +347,7 @@ public extension LibSession { let updatedProfile: Profile = info.profile, dependencies[singleton: .appContext].isMainApp && ( oldAvatarUrl != (info.displayPictureUrl ?? "") || - oldAvatarKey != (info.displayPictureEncryptionKey ?? Data(repeating: 0, count: DisplayPictureManager.aes256KeyByteLength)) + oldAvatarKey != (info.displayPictureEncryptionKey ?? Data()) ) { dependencies[singleton: .displayPictureManager].scheduleDownload( diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift index dd81620304..6a500b6bd0 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift @@ -215,7 +215,7 @@ internal extension LibSession { let picUrl: String = profile?.displayPictureUrl, let picKey: Data = profile?.displayPictureEncryptionKey, !picUrl.isEmpty, - picKey.count == DisplayPictureManager.aes256KeyByteLength + picKey.count == DisplayPictureManager.encryptionKeySize { member.set(\.profile_pic.url, to: picUrl) member.set(\.profile_pic.key, to: picKey) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift index 64a1f5260a..ac14439634 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift @@ -128,7 +128,7 @@ internal extension LibSession { let picUrl: String = memberInfo.profile?.displayPictureUrl, let picKey: Data = memberInfo.profile?.displayPictureEncryptionKey, !picUrl.isEmpty, - picKey.count == DisplayPictureManager.aes256KeyByteLength + picKey.count == DisplayPictureManager.encryptionKeySize { member.set(\.profile_pic.url, to: picUrl) member.set(\.profile_pic.key, to: picKey) diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index de193f1adb..0b2a7a8ec8 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -18,9 +18,12 @@ public extension Cache { ) } - // MARK: - Convenience +public extension LibSession { + static var attachmentEncryptionKeySize: Int { ATTACHMENT_ENCRYPT_KEY_SIZE } +} + public extension LibSession { static func parseCommunity(url: String) -> (room: String, server: String, publicKey: String)? { var cBaseUrl: [CChar] = [CChar](repeating: 0, count: COMMUNITY_BASE_URL_MAX_LENGTH) diff --git a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift index 0bd56eaf12..9bbcc1a250 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift @@ -29,7 +29,7 @@ public enum AttachmentError: Error, CustomStringConvertible { case invalidAttachmentSource case invalidPath case writeFailed - case alreadyDownloaded(URL?) + case alreadyDownloaded(String?) case downloadNoLongerValid case databaseChangesFailed diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index 39aaf0d5eb..914e8268a3 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -38,7 +38,7 @@ extension MessageSender { let preparedAttachment: PreparedAttachment = try dependencies[singleton: .displayPictureManager] .prepareDisplayPicture(attachment: pendingAttachment) displayPictureInfo = try await dependencies[singleton: .displayPictureManager] - .uploadDisplayPicture(attachment: preparedAttachment) + .uploadDisplayPicture(preparedAttachment: preparedAttachment) } let preparedGroupData: PreparedGroupData = try await dependencies[singleton: .storage].writeAsync { db in diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index 5f5e12c500..4d41c54724 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -68,8 +68,12 @@ public final class AttachmentManager: Sendable, ThumbnailManager { !urlString.isEmpty else { throw AttachmentError.invalidPath } - /// If the provided url is a placeholder url then it _is_ a valid path, so we should just return it directly - guard !isPlaceholderUploadUrl(urlString) else { return urlString } + /// If the provided url is a placeholder url or located in the temporary directory then it _is_ a valid path, so we should just return + /// it directly instead of generating a hash + guard + !isPlaceholderUploadUrl(urlString) && + !dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(urlString) + else { return urlString } /// Otherwise we need to generate the deterministic file path based on the url provided let urlHash = try dependencies[singleton: .crypto] @@ -324,7 +328,7 @@ public struct PendingAttachment: Sendable, Equatable, Hashable { return .media(metadata) - case (.displayPicture(let mediaSource), _), (.media(let mediaSource), _): + case (.media(let mediaSource), _): guard let fileSize: UInt64 = maybeFileSize, let source: CGImageSource = mediaSource.createImageSource(), @@ -351,7 +355,6 @@ public struct PendingAttachment: Sendable, Equatable, Hashable { public extension PendingAttachment { enum DataSource: Sendable, Equatable, Hashable { - case displayPicture(ImageDataManager.DataSource) case media(ImageDataManager.DataSource) case file(URL) case voiceMessage(URL) @@ -369,7 +372,7 @@ public extension PendingAttachment { fileprivate var visualMediaSource: ImageDataManager.DataSource? { switch self { - case .displayPicture(let source), .media(let source): return source + case .media(let source): return source case .file, .voiceMessage, .text: return nil } } @@ -380,7 +383,7 @@ public extension PendingAttachment { (_, .videoUrl(let url, _, _, _)), (_, .urlThumbnail(let url, _, _)): return url - case (_, .none), (_, .data), (_, .image), (_, .placeholderIcon), (_, .asyncSource), (.displayPicture, _), (.media, _), (.text, _): + case (_, .none), (_, .data), (_, .image), (_, .placeholderIcon), (_, .asyncSource), (.media, _), (.text, _): return nil } } @@ -444,17 +447,14 @@ public extension PendingAttachment { public struct PreparedAttachment: Sendable, Equatable, Hashable { public let attachment: Attachment - public let temporaryFilePath: String - public let pendingUploadFilePath: String + public let filePath: String public init( attachment: Attachment, - temporaryFilePath: String, - pendingUploadFilePath: String + filePath: String ) { self.attachment = attachment - self.temporaryFilePath = temporaryFilePath - self.pendingUploadFilePath = pendingUploadFilePath + self.filePath = filePath } } @@ -487,63 +487,12 @@ public extension PendingAttachment { } } - func toText() -> String? { - /// Just to be safe ensure the file size isn't crazy large - since we have a character limit of 2,000 - 10,000 characters - /// (which is ~40Kb) a 100Kb limit should be sufficiend - guard (metadata?.fileSize ?? 0) < (1024 * 100) else { return nil } - - switch (source, source.visualMediaSource) { - case (.text(let text), _): return text - case (.file(let fileUrl), _): return try? String(contentsOf: fileUrl, encoding: .utf8) - case (_, .data(_, let data)): return String(data: data, encoding: .utf8) - case (.displayPicture, _), (.media, _), (.voiceMessage, _): return nil - } - } - - func compressAsMp4Video(using dependencies: Dependencies) async throws -> PendingAttachment { - guard - case .media(let mediaSource) = source, - case .url(let url) = mediaSource, - let exportSession: AVAssetExportSession = AVAssetExportSession( - asset: AVAsset(url: url), - presetName: AVAssetExportPresetMediumQuality - ) - else { throw AttachmentError.invalidData } - - let exportPath: String = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: "mp4") - let exportUrl: URL = URL(fileURLWithPath: exportPath) - exportSession.shouldOptimizeForNetworkUse = true - exportSession.outputFileType = AVFileType.mp4 - exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() - exportSession.outputURL = exportUrl - - return await withCheckedContinuation { continuation in - exportSession.exportAsynchronously { - continuation.resume( - returning: PendingAttachment( - source: .media( - .videoUrl( - exportUrl, - .mpeg4Movie, - sourceFilename, - dependencies[singleton: .attachmentManager] - ) - ), - utType: .mpeg4Movie, - sourceFilename: sourceFilename, - using: dependencies - ) - ) - } - } - } - // MARK: - Encryption and Preparation func needsPreparationForAttachmentUpload(transformations: Set) throws -> Bool { switch source { case .file: return try fileNeedsPreparation(transformations) - case .voiceMessage, .displayPicture, .media: return try mediaNeedsPreparation(transformations) + case .voiceMessage, .media: return try mediaNeedsPreparation(transformations) case .text: return true /// Need to write to a file in order to upload as an attachment } } @@ -619,12 +568,15 @@ public extension PendingAttachment { return false } - func prepare(transformations: Set, using dependencies: Dependencies) throws -> PreparedAttachment { + func prepare( + transformations: Set, + storeAtPendingAttachmentUploadPath: Bool = false, + using dependencies: Dependencies + ) throws -> PreparedAttachment { /// Perform any source-specific transformations and load the attachment data into memory let preparedData: Data switch source { - case .displayPicture: preparedData = try prepareImage(transformations) case .media where utType.isImage: preparedData = try prepareImage(transformations) case .media where utType.isAnimated: preparedData = try prepareImage(transformations) case .media where utType.isVideo: preparedData = try prepareVideo(transformations) @@ -636,30 +588,30 @@ public extension PendingAttachment { /// Generate the temporary path to use while the upload is pending /// - /// **Note:** This is stored alongside other attachments rather that in the temporary directory because the + /// **Note:** This is stored alongside other attachments rather than in the temporary directory because the /// `AttachmentUploadJob` can exist between launches, but the temporary directory gets cleared on every launch) let attachmentId: String = (existingAttachmentId ?? UUID().uuidString) - let pendingUploadFilePath: String = try dependencies[singleton: .attachmentManager].pendingUploadPath(for: attachmentId) + let filePath: String = try (storeAtPendingAttachmentUploadPath ? + dependencies[singleton: .attachmentManager].pendingUploadPath(for: attachmentId) : + dependencies[singleton: .fileManager].temporaryFilePath() + ) /// If we don't have the `encrypt` transform then we can just return the `preparedData` (which is unencrypted but should /// have all other `Transform` changes applied // FIXME: We should store attachments encrypted and decrypt them when we want to render/open them guard case .encrypt(let legacyEncryption, let encryptionDomain) = transformations.first(where: { $0.erased == .encrypt }) else { - let filePath: String = try dependencies[singleton: .fileManager].write( - dataToTemporaryFile: preparedData - ) + try dependencies[singleton: .fileManager].write(data: preparedData, toPath: filePath) return PreparedAttachment( attachment: try prepareAttachment( id: attachmentId, - downloadUrl: pendingUploadFilePath, + downloadUrl: filePath, byteCount: UInt(preparedData.count), encryptionKey: nil, digest: nil, using: dependencies ), - temporaryFilePath: filePath, - pendingUploadFilePath: pendingUploadFilePath + filePath: filePath ) } @@ -694,20 +646,21 @@ public extension PendingAttachment { encryptedData = (result.ciphertext, result.encryptionKey, Data()) } - let filePath: String = try dependencies[singleton: .fileManager] - .write(dataToTemporaryFile: encryptedData.ciphertext) + try dependencies[singleton: .fileManager].write( + data: encryptedData.ciphertext, + toPath: filePath + ) return PreparedAttachment( attachment: try prepareAttachment( id: attachmentId, - downloadUrl: pendingUploadFilePath, + downloadUrl: filePath, byteCount: UInt(preparedData.count), encryptionKey: encryptedData.encryptionKey, digest: encryptedData.digest, using: dependencies ), - temporaryFilePath: filePath, - pendingUploadFilePath: pendingUploadFilePath + filePath: filePath ) } @@ -987,5 +940,59 @@ public extension PendingAttachment { mediaMetadata.hasValidFileSize && mediaMetadata.hasValidDuration ) + +// MARK: - Type Conversions + +public extension PendingAttachment { + func toText() -> String? { + /// Just to be safe ensure the file size isn't crazy large - since we have a character limit of 2,000 - 10,000 characters + /// (which is ~40Kb) a 100Kb limit should be sufficiend + guard (metadata?.fileSize ?? 0) < (1024 * 100) else { return nil } + + switch (source, source.visualMediaSource) { + case (.text(let text), _): return text + case (.file(let fileUrl), _): return try? String(contentsOf: fileUrl, encoding: .utf8) + case (_, .data(_, let data)): return String(data: data, encoding: .utf8) + case (.media, _), (.voiceMessage, _): return nil + } + } + + func toMp4Video(using dependencies: Dependencies) async throws -> PendingAttachment { + guard + case .media(let mediaSource) = source, + case .url(let url) = mediaSource, + let exportSession: AVAssetExportSession = AVAssetExportSession( + asset: AVAsset(url: url), + presetName: AVAssetExportPresetMediumQuality + ) + else { throw AttachmentError.invalidData } + + let exportPath: String = dependencies[singleton: .fileManager] + .temporaryFilePath(fileExtension: "mp4") // stringlint:disable + let exportUrl: URL = URL(fileURLWithPath: exportPath) + exportSession.shouldOptimizeForNetworkUse = true + exportSession.outputFileType = AVFileType.mp4 + exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() + exportSession.outputURL = exportUrl + + return await withCheckedContinuation { continuation in + exportSession.exportAsynchronously { + continuation.resume( + returning: PendingAttachment( + source: .media( + .videoUrl( + exportUrl, + .mpeg4Movie, + sourceFilename, + dependencies[singleton: .attachmentManager] + ) + ), + utType: .mpeg4Movie, + sourceFilename: sourceFilename, + using: dependencies + ) + ) + } + } } } diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index 0571f6e77a..8f7c31f602 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -62,7 +62,7 @@ public class DisplayPictureManager { public static let maxBytes: UInt = (5 * 1000 * 1000) public static let maxDimension: CGFloat = 600 - public static let aes256KeyByteLength: Int = 32 + public static var encryptionKeySize: Int { LibSession.attachmentEncryptionKeySize } internal static let nonceLength: Int = 12 internal static let tagLength: Int = 16 @@ -118,6 +118,12 @@ public class DisplayPictureManager { !urlString.isEmpty else { throw AttachmentError.invalidPath } + /// If the provided url is located in the temporary directory then it _is_ a valid path, so we should just return it directly instead + /// of generating a hash + guard !dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(urlString) else { + return urlString + } + let urlHash = try { guard let cachedHash: String = cache.object(forKey: urlString as NSString) as? String else { return try dependencies[singleton: .crypto] @@ -192,16 +198,30 @@ public class DisplayPictureManager { [ .compress, .resize(maxDimension: DisplayPictureManager.maxDimension), - .stripImageMetadata, - .encrypt(legacy: true, domain: .profilePicture) // FIXME: Remove the `legacy` encryption option + .stripImageMetadata ] ) - return try attachment.prepare(transformations: finalTransfomations, using: dependencies) + let preparedAttachment: PreparedAttachment = try attachment.prepare( + transformations: finalTransfomations, + using: dependencies + ) + + return preparedAttachment } - public func uploadDisplayPicture(attachment: PreparedAttachment) async throws -> UploadResult { + public func uploadDisplayPicture(preparedAttachment: PreparedAttachment) async throws -> UploadResult { let uploadResponse: FileUploadResponse + let pendingAttachment: PendingAttachment = try PendingAttachment( + attachment: preparedAttachment.attachment, + using: dependencies + ) + let attachment: PreparedAttachment = try pendingAttachment.prepare( + transformations: [ + .encrypt(legacy: true, domain: .profilePicture) // FIXME: Remove the `legacy` encryption option + ], + using: dependencies + ) /// Ensure we have an encryption key for the `PreparedAttachment` we want to use as a display picture guard let encryptionKey: Data = attachment.attachment.encryptionKey else { @@ -211,7 +231,7 @@ public class DisplayPictureManager { do { /// Upload the data let data: Data = try dependencies[singleton: .fileManager] - .contents(atPath: attachment.temporaryFilePath) ?? { throw AttachmentError.invalidData }() + .contents(atPath: attachment.filePath) ?? { throw AttachmentError.invalidData }() let request: Network.PreparedRequest = try Network.preparedUpload( data: data, requestAndPathBuildTimeout: Network.fileUploadTimeout, @@ -228,10 +248,14 @@ public class DisplayPictureManager { catch { throw AttachmentError.uploadFailed } /// Generate the `downloadUrl` and move the temporary file to it's expected destination + /// + /// **Note:** Display pictures are currently stored unencrypted so we need to move the original `preparedAttachment` + /// file to the `finalFilePath` rather than the encrypted one + // FIXME: Should probably store display pictures encrypted and decrypt on load let downloadUrl: String = Network.FileServer.downloadUrlString(for: uploadResponse.id) let finalFilePath: String = try dependencies[singleton: .displayPictureManager].path(for: downloadUrl) try dependencies[singleton: .fileManager].moveItem( - atPath: attachment.temporaryFilePath, + atPath: preparedAttachment.filePath, toPath: finalFilePath ) diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index e4244e640b..17bdc075b8 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -123,7 +123,11 @@ public extension Profile { using dependencies: Dependencies ) throws { let isCurrentUser = (publicKey == dependencies[cache: .general].sessionId.hexString) - let profile: Profile = Profile.fetchOrCreate(db, id: publicKey) + let profile: Profile = (isCurrentUser ? + dependencies.mutate(cache: .libSession) { $0.profile } : + Profile.fetchOrCreate(db, id: publicKey) + ) + var updatedProfile: Profile = profile var profileChanges: [ConfigColumnAssignment] = [] guard shouldUpdateProfile(profileUpdateTimestamp, profile: profile, using: dependencies) else { @@ -137,6 +141,7 @@ public extension Profile { guard let name: String = name, !name.isEmpty, name != profile.name else { break } if profile.name != name { + updatedProfile = updatedProfile.with(name: name) profileChanges.append(Profile.Columns.name.set(to: name)) db.addProfileEvent(id: publicKey, change: .name(name)) } @@ -147,6 +152,7 @@ public extension Profile { // Blocks community message requests flag if let blocksCommunityMessageRequests: Bool = blocksCommunityMessageRequests { + updatedProfile = updatedProfile.with(blocksCommunityMessageRequests: .set(to: blocksCommunityMessageRequests)) profileChanges.append(Profile.Columns.blocksCommunityMessageRequests.set(to: blocksCommunityMessageRequests)) } @@ -156,10 +162,12 @@ public extension Profile { case (.groupRemove, _), (.groupUpdateTo, _): throw AttachmentError.invalidStartState case (.contactRemove, false), (.currentUserRemove, true): if profile.displayPictureEncryptionKey != nil { + updatedProfile = updatedProfile.with(displayPictureEncryptionKey: .set(to: nil)) profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: nil)) } if profile.displayPictureUrl != nil { + updatedProfile = updatedProfile.with(displayPictureUrl: .set(to: nil)) profileChanges.append(Profile.Columns.displayPictureUrl.set(to: nil)) db.addProfileEvent(id: publicKey, change: .displayPictureUrl(nil)) } @@ -188,11 +196,13 @@ public extension Profile { } else { if url != profile.displayPictureUrl { + updatedProfile = updatedProfile.with(displayPictureUrl: .set(to: url)) profileChanges.append(Profile.Columns.displayPictureUrl.set(to: url)) db.addProfileEvent(id: publicKey, change: .displayPictureUrl(url)) } - if key != profile.displayPictureEncryptionKey && key.count == DisplayPictureManager.aes256KeyByteLength { + if key != profile.displayPictureEncryptionKey && key.count == DisplayPictureManager.encryptionKeySize { + updatedProfile = updatedProfile.with(displayPictureEncryptionKey: .set(to: key)) profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: key)) } } @@ -203,21 +213,25 @@ public extension Profile { /// Persist any changes if !profileChanges.isEmpty { + updatedProfile = updatedProfile.with(profileLastUpdated: .set(to: profileUpdateTimestamp)) profileChanges.append(Profile.Columns.profileLastUpdated.set(to: profileUpdateTimestamp)) - try profile.upsert(db) - - try Profile - .filter(id: publicKey) - .updateAllAndConfig( - db, - profileChanges, - using: dependencies - ) + /// The current users profile is sourced from `libSession` everywhere so no need to update the database + if !isCurrentUser { + try updatedProfile.upsert(db) + + try Profile + .filter(id: publicKey) + .updateAllAndConfig( + db, + profileChanges, + using: dependencies + ) + } /// We don't automatically update the current users profile data when changed in the database so need to manually /// trigger the update - if !suppressUserProfileConfigUpdate, isCurrentUser, let updatedProfile = try? Profile.fetchOne(db, id: publicKey) { + if !suppressUserProfileConfigUpdate, isCurrentUser { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .userProfile, sessionId: dependencies[cache: .general].sessionId) { _ in try cache.updateProfile( diff --git a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift index 209274b6fd..7b20940ec9 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift @@ -280,7 +280,7 @@ class LibSessionGroupInfoSpec: QuickSpec { createGroupOutput.groupState[.groupInfo]?.conf.map { var displayPic: user_profile_pic = user_profile_pic() displayPic.set(\.url, to: "https://www.oxen.io/file/1234") - displayPic.set(\.key, to: Data(repeating: 1, count: DisplayPictureManager.aes256KeyByteLength)) + displayPic.set(\.key, to: Data(repeating: 1, count: DisplayPictureManager.encryptionKeySize)) groups_info_set_pic($0, displayPic) } @@ -309,7 +309,7 @@ class LibSessionGroupInfoSpec: QuickSpec { url: "https://www.oxen.io/file/1234", encryptionKey: Data( repeating: 1, - count: DisplayPictureManager.aes256KeyByteLength + count: DisplayPictureManager.encryptionKeySize ) ), timestamp: 1234567891 diff --git a/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift index 84c3252952..37f922543b 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift @@ -595,7 +595,7 @@ fileprivate extension LibSessionUtilSpec { case .profile_pic: contact.set(\.profile_pic.url, to: rand.nextBytes(count: LibSession.sizeMaxProfileUrlBytes).toHexString()) - contact.set(\.profile_pic.key, to: rand.nextBytes(count: DisplayPictureManager.aes256KeyByteLength)) + contact.set(\.profile_pic.key, to: rand.nextBytes(count: DisplayPictureManager.encryptionKeySize)) } } @@ -2695,7 +2695,7 @@ fileprivate extension LibSessionUtilSpec { case .profile_pic: member.set(\.profile_pic.url, to: rand.nextBytes(count: LibSession.sizeMaxProfileUrlBytes).toHexString()) - member.set(\.profile_pic.key, to: Data(rand.nextBytes(count: DisplayPictureManager.aes256KeyByteLength))) + member.set(\.profile_pic.key, to: Data(rand.nextBytes(count: DisplayPictureManager.encryptionKeySize))) } } diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift index de9094033a..62604acaae 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift @@ -402,7 +402,7 @@ class MessageReceiverGroupsSpec: QuickSpec { inviteMessage.profile = VisibleMessage.VMProfile( displayName: "TestName", - profileKey: Data((0.. = Dependencies.create( + identifier: "customFileServer" + ) +} + +public extension Network.FileServer { + struct Custom: Sendable, Equatable, Codable, FeatureOption { + public typealias RawValue = String + + private struct Values: Equatable, Codable { + public let url: String + public let pubkey: String + } + + public static let defaultOption: Custom = Custom( + url: "", + pubkey: "" + ) + + public let title: String = "Custom File Server" + public let subtitle: String? = nil + private let values: Values + + public var url: String { values.url } + public var pubkey: String { values.pubkey } + public var isEmpty: Bool { + values.url.isEmpty && + values.pubkey.isEmpty + } + public var isValid: Bool { + let pubkeyValid: Bool = ( + Hex.isValid(values.pubkey) && + values.pubkey.count == 64 + ) + + return (pubkeyValid && URL(string: url) != nil) + } + + /// This is needed to conform to `FeatureOption` so it can be saved to `UserDefaults` + public var rawValue: String { + (try? JSONEncoder().encode(values)).map { String(data: $0, encoding: .utf8) } ?? "" + } + + // MARK: - Initialization + + public init(url: String, pubkey: String) { + self.values = Values(url: url, pubkey: pubkey) + } + + public init?(rawValue: String) { + guard + let data: Data = rawValue.data(using: .utf8), + let decodedValues: Values = try? JSONDecoder().decode(Values.self, from: data) + else { return nil } + + self.values = decodedValues + } + + // MARK: - Functions + + public func with( + url: String? = nil, + pubkey: String? = nil + ) -> Custom { + return Custom( + url: (url ?? self.values.url), + pubkey: (pubkey ?? self.values.pubkey) + ) + } + + // MARK: - Equality + + public static func == (lhs: Custom, rhs: Custom) -> Bool { + return (lhs.values == rhs.values) + } + } +} diff --git a/SessionNetworkingKit/FileServer/FileServerAPI.swift b/SessionNetworkingKit/FileServer/FileServerAPI.swift index 40e4747f9f..d94c006588 100644 --- a/SessionNetworkingKit/FileServer/FileServerAPI.swift +++ b/SessionNetworkingKit/FileServer/FileServerAPI.swift @@ -46,6 +46,7 @@ public extension Network.FileServer { endpoint: .directUrl(url), destination: .serverDownload( url: url, + queryParameters: url.queryParameters, x25519PublicKey: serverPubkey, fileName: nil ) diff --git a/SessionNetworkingKit/Types/Destination.swift b/SessionNetworkingKit/Types/Destination.swift index 6c79d3a555..f694ef1a58 100644 --- a/SessionNetworkingKit/Types/Destination.swift +++ b/SessionNetworkingKit/Types/Destination.swift @@ -40,7 +40,7 @@ public extension Network { method: HTTPMethod, url: URL, server: String?, - queryParameters: [HTTPQueryParam: String] = [:], + queryParameters: [HTTPQueryParam: String], headers: [HTTPHeader: String], x25519PublicKey: String ) throws { @@ -169,6 +169,7 @@ public extension Network { method: .get, url: url, server: nil, + queryParameters: queryParameters, headers: headers, x25519PublicKey: x25519PublicKey )) diff --git a/SessionNetworkingKit/Utilities/URL+Utilities.swift b/SessionNetworkingKit/Utilities/URL+Utilities.swift new file mode 100644 index 0000000000..cf8d30bad1 --- /dev/null +++ b/SessionNetworkingKit/Utilities/URL+Utilities.swift @@ -0,0 +1,26 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension URL { + var queryParameters: [HTTPQueryParam: String] { + guard + let components: URLComponents = URLComponents(url: self, resolvingAgainstBaseURL: false), + let queryItems: [URLQueryItem] = components.queryItems + else { return [:] } + + return queryItems.reduce(into: [:]) { result, next in + result[next.name] = next.value + } + } + + var fragmentParameters: [String: String] { + guard let fragment = self.fragment else { return [:] } + + // Parse fragment as if it were a query string + var components: URLComponents = URLComponents() + components.query = fragment + + return (components.queryItems?.reduce(into: [:]) { $0[$1.name] = $1.value } ?? [:]) + } +} diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index 57b1a0982d..5b56ec8693 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -224,7 +224,7 @@ final class ShareNavController: UINavigationController { do { let attachments: [PendingAttachment] = try await buildAttachments() await ShareNavController.pendingAttachments.send(attachments) - await indicator.dismiss {} + await indicator.dismiss() } catch { await indicator.dismiss { [weak self] in @@ -495,7 +495,7 @@ final class ShareNavController: UINavigationController { !UTType.supportedVideoTypes.contains(pendingAttachment.utType) else { return pendingAttachment } - return try await pendingAttachment.compressAsMp4Video(using: dependencies) + return try await pendingAttachment.toMp4Video(using: dependencies) } private func buildAttachments() async throws -> [PendingAttachment] { diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index f99faeabb8..a4447c4553 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -395,7 +395,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView return (visibleMessage, destination, interaction.id, authMethod, preparedUploads) } - .flatMap { (message: Message, destination: Message.Destination, interactionId: Int64?, authMethod: AuthenticationMethod, preparedUploads: [AttachmentUploadJob.PreparedUpload]) -> AnyPublisher<(Message, Message.Destination, Int64?, AuthenticationMethod, [(PreparedAttachment, FileUploadResponse)]), Error> in + .flatMap { (message: Message, destination: Message.Destination, interactionId: Int64?, authMethod: AuthenticationMethod, preparedUploads: [AttachmentUploadJob.PreparedUpload]) -> AnyPublisher<(Message, Message.Destination, Int64?, AuthenticationMethod, [(Attachment, PreparedAttachment, FileUploadResponse)]), Error> in guard !preparedUploads.isEmpty else { return Just((message, destination, interactionId, authMethod, [])) .setFailureType(to: Error.self) @@ -404,9 +404,9 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView return Publishers .MergeMany( - preparedUploads.map { request, preparedAttachment in + preparedUploads.map { request, attachment, preparedAttachment in request.send(using: dependencies).map { _, response in - (preparedAttachment, response) + (attachment, preparedAttachment, response) } } ) @@ -415,10 +415,11 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView .eraseToAnyPublisher() } .tryFlatMap { message, destination, interactionId, authMethod, uploadResults -> AnyPublisher<(Message, [Attachment]), Error> in - let updatedAttachments: [(attachment: Attachment, fileId: String)] = try uploadResults.map { attachment, response in + let updatedAttachments: [(attachment: Attachment, fileId: String)] = try uploadResults.map { attachment, preparedAttachment, response in ( try AttachmentUploadJob.processUploadResponse( - preparedAttachment: attachment, + originalAttachment: attachment, + preparedAttachment: preparedAttachment, authMethod: authMethod, response: response, using: dependencies diff --git a/SessionUtilitiesKit/General/Feature.swift b/SessionUtilitiesKit/General/Feature.swift index 5b63dc8220..9b3ee3d5ee 100644 --- a/SessionUtilitiesKit/General/Feature.swift +++ b/SessionUtilitiesKit/General/Feature.swift @@ -98,6 +98,10 @@ public extension FeatureStorage { identifier: "shortenFileTTL" ) + static let deterministicAttachmentEncryption: FeatureConfig = Dependencies.create( + identifier: "deterministicAttachmentEncryption" + ) + static let simulateAppReviewLimit: FeatureConfig = Dependencies.create( identifier: "simulateAppReviewLimit" ) diff --git a/SessionUtilitiesKit/Types/FileManager.swift b/SessionUtilitiesKit/Types/FileManager.swift index 2614daba36..09fc34c11c 100644 --- a/SessionUtilitiesKit/Types/FileManager.swift +++ b/SessionUtilitiesKit/Types/FileManager.swift @@ -27,8 +27,11 @@ public protocol FileManagerType { func ensureDirectoryExists(at path: String, fileProtectionType: FileProtectionType) throws func protectFileOrFolder(at path: String, fileProtectionType: FileProtectionType) throws func fileSize(of path: String) -> UInt64? + + func isLocatedInTemporaryDirectory(_ path: String) -> Bool func temporaryFilePath(fileExtension: String?) -> String func write(data: Data, toTemporaryFileWithExtension fileExtension: String?) throws -> String + func write(data: Data, toPath path: String) throws // MARK: - Forwarded NSFileManager @@ -140,6 +143,8 @@ public extension SessionFileManager { // MARK: - SessionFileManager public class SessionFileManager: FileManagerType { + private static let temporaryDirectoryPrefix: String = "sesh_temp_" + private let dependencies: Dependencies private let fileManager: FileManager = .default public var temporaryDirectory: String @@ -159,8 +164,8 @@ public class SessionFileManager: FileManagerType { init(using dependencies: Dependencies) { self.dependencies = dependencies - // Create a new temp directory for this instance - let dirName: String = "ows_temp_\(UUID().uuidString)" + /// Create a new temp directory for this instance + let dirName: String = "\(SessionFileManager.temporaryDirectoryPrefix)\(UUID().uuidString)" self.temporaryDirectory = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent(dirName) .path @@ -170,31 +175,32 @@ public class SessionFileManager: FileManagerType { // MARK: - Functions public func clearOldTemporaryDirectories() { - // We use the lowest priority queue for this, and wait N seconds - // to avoid interfering with app startup. + /// We use the lowest priority queue for this, and wait N seconds to avoid interfering with app startup DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + .seconds(3), using: dependencies) { [temporaryDirectory, fileManager, dependencies] in - // Abort if app not active + /// Abort if app not active guard dependencies[singleton: .appContext].isAppForegroundAndActive else { return } - // Ignore the "current" temp directory. + /// Ignore the "current" temp directory let thresholdDate: Date = dependencies[singleton: .appContext].appLaunchTime let currentTempDirName: String = URL(fileURLWithPath: temporaryDirectory).lastPathComponent let dirPath: String = NSTemporaryDirectory() - guard let fileNames: [String] = try? fileManager.contentsOfDirectory(atPath: dirPath) else { return } + guard let fileNames: [String] = try? fileManager.contentsOfDirectory(atPath: dirPath) else { + return + } fileNames.forEach { fileName in guard fileName != currentTempDirName else { return } - // Delete files with either: - // - // a) "ows_temp" name prefix. - // b) modified time before app launch time. + /// Delete files with either: + /// + /// a) `temporaryDirectoryPrefix` name prefix. + /// b) modified time before app launch time. let filePath: String = URL(fileURLWithPath: dirPath).appendingPathComponent(fileName).path - if !fileName.hasPrefix("ows_temp") { - // It's fine if we can't get the attributes (the file may have been deleted since we found it), - // also don't delete files which were created in the last N minutes + if !fileName.hasPrefix(SessionFileManager.temporaryDirectoryPrefix) { + /// It's fine if we can't get the attributes (the file may have been deleted since we found it), also don't delete + /// files which were created in the last N minutes guard let attributes: [FileAttributeKey: Any] = try? fileManager.attributesOfItem(atPath: filePath), let modificationDate: Date = attributes[.modificationDate] as? Date, @@ -202,8 +208,7 @@ public class SessionFileManager: FileManagerType { else { return } } - // This can happen if the app launches before the phone is unlocked. - // Clean up will occur when app becomes active. + /// This can happen if the app launches before the phone is unlocked, clean up will occur when app becomes active try? fileManager.removeItem(atPath: filePath) } } @@ -245,6 +250,14 @@ public class SessionFileManager: FileManagerType { return (attributes[.size] as? UInt64) } + public func isLocatedInTemporaryDirectory(_ path: String) -> Bool { + let prefix: String = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(SessionFileManager.temporaryDirectoryPrefix) + .path + + return path.hasPrefix(prefix) + } + public func temporaryFilePath(fileExtension: String?) -> String { var tempFileName: String = UUID().uuidString @@ -266,6 +279,11 @@ public class SessionFileManager: FileManagerType { return tempFilePath } + public func write(data: Data, toPath path: String) throws { + try data.write(to: URL(fileURLWithPath: path), options: .atomic) + try protectFileOrFolder(at: path) + } + // MARK: - Forwarded NSFileManager public var currentDirectoryPath: String { fileManager.currentDirectoryPath } diff --git a/SessionUtilitiesKit/Types/Update.swift b/SessionUtilitiesKit/Types/Update.swift new file mode 100644 index 0000000000..1a243a5d9d --- /dev/null +++ b/SessionUtilitiesKit/Types/Update.swift @@ -0,0 +1,15 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum Update { + case set(to: T) + case useExisting + + public func or(_ existing: T) -> T { + switch self { + case .set(let value): return value + case .useExisting: return existing + } + } +} diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index 3cb0de8e0c..9432b8e889 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -770,7 +770,7 @@ extension PendingAttachmentRailItem: GalleryRailItem { switch attachment.source { case .file, .voiceMessage, .text: break; - case .displayPicture(let dataSource), .media(let dataSource): + case .media(let dataSource): Task.detached(priority: .userInitiated) { [attachment, attachmentManager = dependencies[singleton: .attachmentManager]] in /// Can't thumbnail animated images so just load the full file in this case if attachment.utType.isAnimated { diff --git a/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift b/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift index acebd9da69..6bdf1b2086 100644 --- a/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift +++ b/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift @@ -114,7 +114,7 @@ public class ModalActivityIndicatorViewController: OWSViewController { ) } - @MainActor public func dismiss(completion: @escaping @MainActor () -> Void) { + @MainActor public func dismiss(completion: (@MainActor () -> Void)? = nil) { if !wasDimissed { // Only dismiss once. self.dismiss(animated: false, completion: completion) @@ -122,7 +122,7 @@ public class ModalActivityIndicatorViewController: OWSViewController { } else { // If already dismissed, wait a beat then call completion. - completion() + completion?() } } diff --git a/_SharedTestUtilities/MockFileManager.swift b/_SharedTestUtilities/MockFileManager.swift index e71a944ff5..d4702736be 100644 --- a/_SharedTestUtilities/MockFileManager.swift +++ b/_SharedTestUtilities/MockFileManager.swift @@ -31,6 +31,10 @@ class MockFileManager: Mock, FileManagerType { return try mockThrowing(args: [data, fileExtension]) } + func write(data: Data, toPath path: String) throws { + try mockThrowingNoReturn(args: [data, path]) + } + // MARK: - Forwarded NSFileManager var currentDirectoryPath: String { mock() } From 83dc62fe9cbff674a43dfd38c07397631b23b172 Mon Sep 17 00:00:00 2001 From: mpretty-cyro <15862619+mpretty-cyro@users.noreply.github.com> Date: Wed, 8 Oct 2025 04:47:54 +0000 Subject: [PATCH 065/162] [Automated] Update translations from Crowdin --- Session/Meta/Translations/Localizable.xcstrings | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Session/Meta/Translations/Localizable.xcstrings b/Session/Meta/Translations/Localizable.xcstrings index 80ae275348..300dd1804a 100644 --- a/Session/Meta/Translations/Localizable.xcstrings +++ b/Session/Meta/Translations/Localizable.xcstrings @@ -382465,6 +382465,17 @@ } } }, + "remindMeLater" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remind Me Later" + } + } + } + }, "remove" : { "extractionState" : "manual", "localizations" : { From 1b6071a6c778690d148c5d606b4e1b9e4ab44ca6 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Tue, 16 Sep 2025 09:38:29 +0800 Subject: [PATCH 066/162] Removed camera permission request on call start Fix missing text parameter on settings privacy dialog --- Session/Calls/CallVC.swift | 46 +++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index 85b9f8c668..539b4fecc4 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -18,6 +18,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel var latestKnownAudioOutputDeviceName: String? var durationTimer: Timer? var shouldRestartCamera = true + var didFinishPreparingCamera = false weak var conversationVC: ConversationVC? = nil lazy var cameraManager: CameraManager = { @@ -464,9 +465,8 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel setUpViewHierarchy() setUpProfilePictureImage() - if shouldRestartCamera { cameraManager.prepare() } - _ = call.videoCapturer // Force the lazy var to instantiate + titleLabel.text = self.call.contactName if self.call.hasConnected { callDurationLabel.isHidden = false @@ -721,6 +721,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel // MARK: - Video and Audio @objc private func operateCamera() { + if (call.isVideoEnabled) { floatingViewContainer.isHidden = true cameraManager.stop() @@ -730,28 +731,31 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel call.isVideoEnabled = false } else { - guard Permissions.requestCameraPermissionIfNeeded(using: dependencies) else { - let confirmationModal: ConfirmationModal = ConfirmationModal( - info: ConfirmationModal.Info( - title: "permissionsRequired".localized(), - body: .text("permissionsCameraAccessRequiredCallsIos".localized()), - showCondition: .disabled, - confirmTitle: "sessionSettings".localized(), - onConfirm: { _ in - UIApplication.shared.openSystemSettings() - } - ) - ) - - self.navigationController?.present(confirmationModal, animated: true, completion: nil) - return + Permissions.requestCameraPermissionIfNeeded(presentingViewController: self, using: dependencies) { isAuthorized in + DispatchQueue.main.async { [weak self] in + guard isAuthorized else { return } + + var previewDelay = 0.0 + + // Check if camera has already been prepared should only be called once + // this was removed from viewDidLoad so camera permission will only be requested when needed + if self?.shouldRestartCamera == true && self?.didFinishPreparingCamera == false { + self?.cameraManager.prepare() + self?.didFinishPreparingCamera = true + previewDelay = 0.5 + } + + // Added a small delay in presenting preview due to camera manager preparations + DispatchQueue.main.asyncAfter(deadline: .now() + previewDelay) { [weak self] in + let previewVC = VideoPreviewVC() + previewVC.delegate = self + self?.present(previewVC, animated: true, completion: nil) + } + } } - let previewVC = VideoPreviewVC() - previewVC.delegate = self - present(previewVC, animated: true, completion: nil) } } - + func cameraDidConfirmTurningOn() { floatingViewContainer.isHidden = false let localVideoView: LocalVideoView = self.floatingViewVideoSource == .local ? self.floatingLocalVideoView : self.fullScreenLocalVideoView From fc7f6c8713f36996faab946f139db4fe84491b82 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Tue, 16 Sep 2025 13:37:15 +0800 Subject: [PATCH 067/162] Clean up permission and preview handling --- Session/Calls/CallVC.swift | 38 +++++++++---------- .../Settings/PrivacySettingsViewModel.swift | 4 +- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index 539b4fecc4..00e66546b4 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -18,7 +18,6 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel var latestKnownAudioOutputDeviceName: String? var durationTimer: Timer? var shouldRestartCamera = true - var didFinishPreparingCamera = false weak var conversationVC: ConversationVC? = nil lazy var cameraManager: CameraManager = { @@ -731,26 +730,19 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel call.isVideoEnabled = false } else { - Permissions.requestCameraPermissionIfNeeded(presentingViewController: self, using: dependencies) { isAuthorized in - DispatchQueue.main.async { [weak self] in - guard isAuthorized else { return } - - var previewDelay = 0.0 - - // Check if camera has already been prepared should only be called once - // this was removed from viewDidLoad so camera permission will only be requested when needed - if self?.shouldRestartCamera == true && self?.didFinishPreparingCamera == false { - self?.cameraManager.prepare() - self?.didFinishPreparingCamera = true - previewDelay = 0.5 - } - - // Added a small delay in presenting preview due to camera manager preparations - DispatchQueue.main.asyncAfter(deadline: .now() + previewDelay) { [weak self] in - let previewVC = VideoPreviewVC() - previewVC.delegate = self - self?.present(previewVC, animated: true, completion: nil) - } + + // Added delay of preview due to permission dialog alert dismissal on allow. + // It causes issue on `VideoPreviewVC` presentation animation, + // If camera permission is already allowed no animation delay is needed + var previewDelay = Permissions.camera == .undetermined ? 0.5 : 0 + + Permissions.requestCameraPermissionIfNeeded(presentingViewController: self, using: dependencies) { [weak self] isAuthorized in + guard isAuthorized else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + previewDelay) { [weak self] in + let previewVC = VideoPreviewVC() + previewVC.delegate = self + self?.present(previewVC, animated: true, completion: nil) } } } @@ -758,10 +750,14 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel func cameraDidConfirmTurningOn() { floatingViewContainer.isHidden = false + let localVideoView: LocalVideoView = self.floatingViewVideoSource == .local ? self.floatingLocalVideoView : self.fullScreenLocalVideoView localVideoView.alpha = 1 + + // Camera preparation cameraManager.prepare() cameraManager.start() + videoButton.themeTintColor = .backgroundSecondary videoButton.themeBackgroundColor = .textPrimary switchCameraButton.isEnabled = true diff --git a/Session/Settings/PrivacySettingsViewModel.swift b/Session/Settings/PrivacySettingsViewModel.swift index 66eaf0e948..0764aa3b77 100644 --- a/Session/Settings/PrivacySettingsViewModel.swift +++ b/Session/Settings/PrivacySettingsViewModel.swift @@ -232,9 +232,7 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav ), confirmationInfo: ConfirmationModal.Info( title: "callsVoiceAndVideoBeta".localized(), - body: .text("callsVoiceAndVideoModalDescription" - .put(key: "session_foundation", value: Constants.session_foundation) - .localized()), + body: .text("callsVoiceAndVideoModalDescription".localized()), showCondition: .disabled, confirmTitle: "theContinue".localized(), confirmStyle: .danger, From 9dc19d25c86beb06cfbc9ac37008c25bba646965 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Wed, 8 Oct 2025 11:27:12 +0800 Subject: [PATCH 068/162] Additional camera permission instructions and behavior --- Session/Calls/CallVC.swift | 85 ++++++++++++++----- Session/Home/HomeViewModel.swift | 36 ++++++-- Session/Shared/ScreenLockWindow.swift | 3 + Session/Utilities/Permissions.swift | 65 +++++++++++++- .../Types/UserDefaultsType.swift | 3 + 5 files changed, 163 insertions(+), 29 deletions(-) diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index 00e66546b4..6269e8babd 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -33,6 +33,8 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel var floatingViewVideoSource: FloatingViewVideoSource = .local + private var willEndToEnableCameraPermission: Bool = false + // MARK: - UI Components private lazy var floatingLocalVideoView: LocalVideoView = { @@ -659,12 +661,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel } Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { [weak self] _ in - DispatchQueue.main.async { - self?.dismiss(animated: true, completion: { - self?.conversationVC?.becomeFirstResponder() - self?.conversationVC?.showInputAccessoryView() - }) - } + self?.shouldHandleCallDismiss() } } @@ -687,12 +684,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel } Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { [weak self] _ in - DispatchQueue.main.async { - self?.dismiss(animated: true, completion: { - self?.conversationVC?.becomeFirstResponder() - self?.conversationVC?.showInputAccessoryView() - }) - } + self?.shouldHandleCallDismiss() } } } @@ -734,15 +726,55 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel // Added delay of preview due to permission dialog alert dismissal on allow. // It causes issue on `VideoPreviewVC` presentation animation, // If camera permission is already allowed no animation delay is needed - var previewDelay = Permissions.camera == .undetermined ? 0.5 : 0 + let previewDelay = Permissions.camera == .undetermined ? 0.5 : 0 - Permissions.requestCameraPermissionIfNeeded(presentingViewController: self, using: dependencies) { [weak self] isAuthorized in - guard isAuthorized else { return } + Permissions.requestCameraPermissionIfNeeded( + useCustomDeniedAlert: true, + using: dependencies + ) { [weak self, dependencies] isAuthorized in - DispatchQueue.main.asyncAfter(deadline: .now() + previewDelay) { [weak self] in - let previewVC = VideoPreviewVC() - previewVC.delegate = self - self?.present(previewVC, animated: true, completion: nil) + let status = Permissions.camera + + switch (isAuthorized, status) { + case (false, .denied): + guard let presentingViewController: UIViewController = (self?.navigationController ?? dependencies[singleton: .appContext].frontMostViewController) + else { return } + + DispatchQueue.main.async { + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "cameraAccessRequired".localized(), + body: .attributedText( + "cameraAccessDeniedMessage" + .put(key: "app_name", value: Constants.app_name) + .localizedFormatted(), + scrollMode: .never + ), + confirmTitle: "endCallToEnable".localized(), + confirmStyle: .danger, + cancelTitle: "Remind Me Later", // stringlint:ignore + cancelStyle: .alert_text, + onConfirm: { _ in + self?.willEndToEnableCameraPermission = true + + self?.endCall() + }, + onCancel: { modal in + dependencies[defaults: .standard, key: .shouldRemindGrantingCameraPermissionForCalls] = true + modal.dismiss(animated: true) + } + ) + ) + presentingViewController.present(confirmationModal, animated: true, completion: nil) + } + case (true, _): + DispatchQueue.main.asyncAfter(deadline: .now() + previewDelay) { [weak self] in + let previewVC = VideoPreviewVC() + previewVC.delegate = self + self?.present(previewVC, animated: true, completion: nil) + } + break + default: break } } } @@ -874,6 +906,21 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel } } + private func shouldHandleCallDismiss() { + DispatchQueue.main.async { [weak self, dependencies] in + self?.dismiss(animated: true, completion: { + self?.conversationVC?.becomeFirstResponder() + self?.conversationVC?.showInputAccessoryView() + + if self?.willEndToEnableCameraPermission == true { + Permissions.showEnableCameraAccessInstructions(using: dependencies) + } else { + Permissions.remindCameraAccessRequirement(using: dependencies) + } + }) + } + } + // MARK: - AVRoutePickerViewDelegate func routePickerViewWillBeginPresentingRoutes(_ routePickerView: AVRoutePickerView) { diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 98d29d990e..97431f5180 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -553,15 +553,18 @@ public class HomeViewModel: NavigatableStateHolder { // MARK: - Handle App review @MainActor func viewDidAppear() { - guard state.pendingAppReviewPromptState != nil else { return } - - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self, dependencies] in - guard let updatedState: AppReviewPromptState = self?.state.pendingAppReviewPromptState else { return } - - dependencies[defaults: .standard, key: .didActionAppReviewPrompt] = false - - self?.handlePromptChangeState(updatedState) + if state.pendingAppReviewPromptState != nil { + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self, dependencies] in + guard let updatedState: AppReviewPromptState = self?.state.pendingAppReviewPromptState else { return } + + dependencies[defaults: .standard, key: .didActionAppReviewPrompt] = false + + self?.handlePromptChangeState(updatedState) + } } + + // Camera reminder + willShowCameraPermissionReminder() } func scheduleAppReviewRetry() { @@ -706,6 +709,20 @@ public class HomeViewModel: NavigatableStateHolder { } } + // Camera permission + func willShowCameraPermissionReminder() { + guard + dependencies[singleton: .screenLock].checkIfScreenIsUnlocked(), // Show camera access reminder when app has been unlocked + !dependencies[defaults: .appGroup, key: .isCallOngoing] // Checks if there is still an ongoing call + else { + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [dependencies] in + Permissions.remindCameraAccessRequirement(using: dependencies) + } + } + @MainActor @objc func didReturnFromBackground() { // Observe changes to app state retry and flags when app goes to bg to fg @@ -718,6 +735,9 @@ public class HomeViewModel: NavigatableStateHolder { self?.handlePromptChangeState(updatedState) } } + + // Camera reminder + willShowCameraPermissionReminder() } // MARK: - Functions diff --git a/Session/Shared/ScreenLockWindow.swift b/Session/Shared/ScreenLockWindow.swift index 0a2c4e9a9e..b2d25211fb 100644 --- a/Session/Shared/ScreenLockWindow.swift +++ b/Session/Shared/ScreenLockWindow.swift @@ -127,6 +127,9 @@ public class ScreenLockWindow { } } + /// Checks if app has been unlocked + public func checkIfScreenIsUnlocked() -> Bool { !isScreenLockLocked } + // MARK: - Functions private func determineDesiredUIState() -> ScreenLockViewController.State { diff --git a/Session/Utilities/Permissions.swift b/Session/Utilities/Permissions.swift index 96fc52c505..5d1d6490e5 100644 --- a/Session/Utilities/Permissions.swift +++ b/Session/Utilities/Permissions.swift @@ -12,6 +12,7 @@ import Network extension Permissions { @MainActor @discardableResult public static func requestCameraPermissionIfNeeded( presentingViewController: UIViewController? = nil, + useCustomDeniedAlert: Bool = false, using dependencies: Dependencies, onAuthorized: ((Bool) -> Void)? = nil ) -> Bool { @@ -22,8 +23,12 @@ extension Permissions { case .denied, .restricted: guard - let presentingViewController: UIViewController = (presentingViewController ?? dependencies[singleton: .appContext].frontMostViewController) - else { return false } + let presentingViewController: UIViewController = (presentingViewController ?? dependencies[singleton: .appContext].frontMostViewController), + useCustomDeniedAlert == false + else { + onAuthorized?(false) + return false + } let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( @@ -337,5 +342,61 @@ extension Permissions { ) } } + + // MARK: - Custom camera permission request dialog + public static func remindCameraAccessRequirement(using dependencies: Dependencies) { + /* + Only show when the folliwing conditions are true + - Remind me later is tapped when trying to enable camera on calls + - Not in background state + - Camera permission is not yet allowed + */ + guard + dependencies[defaults: .standard, key: .shouldRemindGrantingCameraPermissionForCalls], + !dependencies[singleton: .appContext].isInBackground, + Permissions.camera == .denied + else { + return + } + + DispatchQueue.main.async { [dependencies] in + guard let controller = dependencies[singleton: .appContext].frontMostViewController else { + return + } + + dependencies[defaults: .standard, key: .shouldRemindGrantingCameraPermissionForCalls] = false + + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "enableCameraAccess".localized(), + body: .text( + "cameraAccessReminderMessage".localized(), + scrollMode: .never + ), + confirmTitle: "openSettings".localized(), + onConfirm: { _ in UIApplication.shared.openSystemSettings() } + ) + ) + controller.present(confirmationModal, animated: true, completion: nil) + } + } + + public static func showEnableCameraAccessInstructions(using dependencies: Dependencies) { + DispatchQueue.main.async { + guard let controller = dependencies[singleton: .appContext].frontMostViewController + else { return } + + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "enableCameraAccess".localized(), + body: .text("cameraAccessInstructions" + .localized()), + confirmTitle: "openSettings".localized(), + onConfirm: { _ in UIApplication.shared.openSystemSettings() } + ) + ) + controller.present(confirmationModal, animated: true, completion: nil) + } + } } diff --git a/SessionUtilitiesKit/Types/UserDefaultsType.swift b/SessionUtilitiesKit/Types/UserDefaultsType.swift index 968b66c2e4..9fbf991285 100644 --- a/SessionUtilitiesKit/Types/UserDefaultsType.swift +++ b/SessionUtilitiesKit/Types/UserDefaultsType.swift @@ -180,6 +180,9 @@ public extension UserDefaults.BoolKey { /// Idicates whether app review prompt was ignored or no iteraction was done to dismiss it (closed app) static let didActionAppReviewPrompt: UserDefaults.BoolKey = "didActionAppReviewPrompt" + + /// Indicates wheter the user should be reminded to grant camera permission for calls + static let shouldRemindGrantingCameraPermissionForCalls: UserDefaults.BoolKey = "shouldRemindGrantingCameraPermissionForCalls" } public extension UserDefaults.DateKey { From 6d3361728c008b60bf481f361b43d26a978f2e89 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Wed, 8 Oct 2025 13:21:31 +0800 Subject: [PATCH 069/162] Added missing localized string and cleaned up code --- Session/Calls/CallVC.swift | 16 ++++++---------- Session/Utilities/Permissions.swift | 2 +- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index 6269e8babd..8f17a810fc 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -33,8 +33,6 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel var floatingViewVideoSource: FloatingViewVideoSource = .local - private var willEndToEnableCameraPermission: Bool = false - // MARK: - UI Components private lazy var floatingLocalVideoView: LocalVideoView = { @@ -676,7 +674,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel } } - @objc private func endCall() { + @objc private func endCall(presentCameraRequestDialog: Bool = false) { dependencies[singleton: .callManager].endCall(call) { [weak self, dependencies] error in if let _ = error { self?.call.endSessionCall() @@ -684,7 +682,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel } Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { [weak self] _ in - self?.shouldHandleCallDismiss() + self?.shouldHandleCallDismiss(presentCameraRequestDialog) } } } @@ -752,12 +750,10 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel ), confirmTitle: "endCallToEnable".localized(), confirmStyle: .danger, - cancelTitle: "Remind Me Later", // stringlint:ignore + cancelTitle: "remindMeLater".localized(), cancelStyle: .alert_text, onConfirm: { _ in - self?.willEndToEnableCameraPermission = true - - self?.endCall() + self?.endCall(presentCameraRequestDialog: true) }, onCancel: { modal in dependencies[defaults: .standard, key: .shouldRemindGrantingCameraPermissionForCalls] = true @@ -906,13 +902,13 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel } } - private func shouldHandleCallDismiss() { + private func shouldHandleCallDismiss(_ presentCameraRequestDialog: Bool = false) { DispatchQueue.main.async { [weak self, dependencies] in self?.dismiss(animated: true, completion: { self?.conversationVC?.becomeFirstResponder() self?.conversationVC?.showInputAccessoryView() - if self?.willEndToEnableCameraPermission == true { + if presentCameraRequestDialog == true { Permissions.showEnableCameraAccessInstructions(using: dependencies) } else { Permissions.remindCameraAccessRequirement(using: dependencies) diff --git a/Session/Utilities/Permissions.swift b/Session/Utilities/Permissions.swift index 5d1d6490e5..7443b67958 100644 --- a/Session/Utilities/Permissions.swift +++ b/Session/Utilities/Permissions.swift @@ -24,7 +24,7 @@ extension Permissions { case .denied, .restricted: guard let presentingViewController: UIViewController = (presentingViewController ?? dependencies[singleton: .appContext].frontMostViewController), - useCustomDeniedAlert == false + !useCustomDeniedAlert else { onAuthorized?(false) return false From 592eb348abf94ae890cd9994b69271c111a35650 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 9 Oct 2025 10:23:13 +1100 Subject: [PATCH 070/162] Fixed a number of bugs, some code cleanup, resolved some TODOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Wired up the dev settings • Update logic to use an ed25519 key for the FileServer instead of an x25519 one • Updated the ReuploadUserDisplayPictureJob to wait for the first poll to succeed before running (to reduce config conflict issues) • Fixed a bug with the FileServer extend API call --- Session.xcodeproj/project.pbxproj | 42 ++++-- .../PhotoLibrary.swift | 10 +- Session/Meta/Session+SNUIKit.swift | 18 ++- ...DeveloperSettingsFileServerViewModel.swift | 17 +-- .../DeveloperSettingsViewModel.swift | 2 +- .../Migrations/_043_RenameAttachments.swift | 5 +- .../Jobs/AttachmentDownloadJob.swift | 2 +- .../Jobs/AttachmentUploadJob.swift | 34 +++-- .../Jobs/DisplayPictureDownloadJob.swift | 20 ++- SessionMessagingKit/Jobs/MessageSendJob.swift | 13 +- .../Jobs/ReuploadUserDisplayPictureJob.swift | 14 +- .../Errors/AttachmentError.swift | 4 +- .../Pollers/CommunityPoller.swift | 26 +++- .../Pollers/GroupPoller.swift | 47 +++---- .../Pollers/PollerType.swift | 3 +- .../Pollers/SwarmPoller.swift | 29 +++-- .../Utilities/AttachmentManager.swift | 63 ++++++--- .../Utilities/DisplayPictureManager.swift | 39 +++--- .../MessageReceiverGroupsSpec.swift | 2 +- .../_TestUtilities/MockCommunityPoller.swift | 2 +- .../_TestUtilities/MockPoller.swift | 2 +- .../_TestUtilities/MockSwarmPoller.swift | 2 +- .../FileServer/FileServer.swift | 122 +++++++++--------- .../FileServer/FileServerAPI.swift | 53 +++----- .../Models/ExtendExpirationResponse.swift | 17 +++ .../Types/HTTPFragmentParam+FileServer.swift | 8 ++ .../LibSession/LibSession+Networking.swift | 5 +- .../Types/Request+PushNotificationAPI.swift | 2 + SessionNetworkingKit/SOGS/SOGS.swift | 2 + SessionNetworkingKit/SOGS/SOGSAPI.swift | 3 + .../SOGS/Types/Request+SOGS.swift | 2 + .../SessionNetwork/SessionNetworkAPI.swift | 2 + SessionNetworkingKit/Types/Destination.swift | 43 +++++- .../Types/HTTPFragmentParam.swift | 22 ++++ .../Types/HTTPQueryParam.swift | 18 ++- .../Types/PreparedRequest.swift | 10 +- .../Utilities/URL+Utilities.swift | 20 ++- .../Types/BatchRequestSpec.swift | 8 ++ .../Types/DestinationSpec.swift | 51 ++++++-- .../ShareNavController.swift | 18 ++- SessionShareExtension/ThreadPickerVC.swift | 2 +- SessionUIKit/Configuration.swift | 6 +- SessionUIKit/Types/ImageDataManager.swift | 38 ++---- SessionUtilitiesKit/Crypto/CryptoError.swift | 1 + SessionUtilitiesKit/Media/MediaUtils.swift | 66 +++------- .../Media/UTType+Utilities.swift | 8 +- SessionUtilitiesKit/Types/StringCache.swift | 47 +++++++ .../Utilities/AVURLAsset+Utilities.swift | 13 ++ .../Utilities/AsyncStream+Utilities.swift | 13 ++ .../Utilities/UIImage+Utilities.swift | 2 +- .../AttachmentItemCollection.swift | 3 +- .../AttachmentPrepViewController.swift | 6 +- .../Image Editing/ImageEditorModel.swift | 2 +- SignalUtilitiesKit/Utilities/ImageCache.swift | 57 -------- .../Utilities/OWSSignalAddress.swift | 33 ----- .../Utilities/ReverseDispatchQueue.swift | 74 ----------- _SharedTestUtilities/Async+Utilities.swift | 16 +++ 57 files changed, 678 insertions(+), 511 deletions(-) create mode 100644 SessionNetworkingKit/FileServer/Models/ExtendExpirationResponse.swift create mode 100644 SessionNetworkingKit/FileServer/Types/HTTPFragmentParam+FileServer.swift create mode 100644 SessionNetworkingKit/Types/HTTPFragmentParam.swift create mode 100644 SessionUtilitiesKit/Types/StringCache.swift create mode 100644 SessionUtilitiesKit/Utilities/AsyncStream+Utilities.swift delete mode 100644 SignalUtilitiesKit/Utilities/ImageCache.swift delete mode 100644 SignalUtilitiesKit/Utilities/OWSSignalAddress.swift delete mode 100644 SignalUtilitiesKit/Utilities/ReverseDispatchQueue.swift create mode 100644 _SharedTestUtilities/Async+Utilities.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 992afbdc35..07a019713a 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -309,8 +309,6 @@ C33FD9C2255A54EF00E217F9 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; }; C33FD9C4255A54EF00E217F9 /* SessionNetworkingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A59F255385C100C340D1 /* SessionNetworkingKit.framework */; }; C33FD9C5255A54EF00E217F9 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; - C33FDC58255A582000E217F9 /* ReverseDispatchQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */; }; - C33FDD8D255A582000E217F9 /* OWSSignalAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */; }; C3402FE52559036600EA6424 /* SessionUIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C331FF1B2558F9D300070591 /* SessionUIKit.framework */; }; C34C8F7423A7830B00D82669 /* SpaceMono-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = C34C8F7323A7830A00D82669 /* SpaceMono-Bold.ttf */; }; C3548F0824456AB6009433A8 /* UIView+Wrapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */; }; @@ -326,7 +324,6 @@ C38EF24D255B6D67007E1867 /* UIView+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF240255B6D67007E1867 /* UIView+OWS.swift */; }; C38EF24E255B6D67007E1867 /* Collection+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF241255B6D67007E1867 /* Collection+OWS.swift */; }; C38EF2B3255B6D9C007E1867 /* UIViewController+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */; }; - C38EF32E255B6DBF007E1867 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF304255B6DBE007E1867 /* ImageCache.swift */; }; C38EF363255B6DCC007E1867 /* ModalActivityIndicatorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF349255B6DC7007E1867 /* ModalActivityIndicatorViewController.swift */; }; C38EF372255B6DCC007E1867 /* MediaMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF358255B6DCC007E1867 /* MediaMessageView.swift */; }; C38EF385255B6DD2007E1867 /* AttachmentTextToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF37C255B6DCF007E1867 /* AttachmentTextToolbar.swift */; }; @@ -1026,6 +1023,15 @@ FDE287532E94C5CB00442E03 /* Update.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287522E94C5C900442E03 /* Update.swift */; }; FDE287552E94CFDB00442E03 /* URL+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287542E94CFD400442E03 /* URL+Utilities.swift */; }; FDE287572E94D7B800442E03 /* DeveloperSettingsFileServerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287562E94D7B200442E03 /* DeveloperSettingsFileServerViewModel.swift */; }; + FDE287592E95BBAF00442E03 /* HTTPFragmentParam.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287582E95BBA900442E03 /* HTTPFragmentParam.swift */; }; + FDE2875B2E95BC3300442E03 /* HTTPFragmentParam+FileServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE2875A2E95BC2A00442E03 /* HTTPFragmentParam+FileServer.swift */; }; + FDE2875D2E95CD3500442E03 /* StringCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE2875C2E95CD3300442E03 /* StringCache.swift */; }; + FDE2875F2E96061E00442E03 /* ExtendExpirationResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE2875E2E96061A00442E03 /* ExtendExpirationResponse.swift */; }; + FDE287612E970D5C00442E03 /* Async+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287602E970D5900442E03 /* Async+Utilities.swift */; }; + FDE287622E970D5C00442E03 /* Async+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287602E970D5900442E03 /* Async+Utilities.swift */; }; + FDE287632E970D5C00442E03 /* Async+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287602E970D5900442E03 /* Async+Utilities.swift */; }; + FDE287642E970D5C00442E03 /* Async+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287602E970D5900442E03 /* Async+Utilities.swift */; }; + FDE287662E970D9E00442E03 /* AsyncStream+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE287652E970D9A00442E03 /* AsyncStream+Utilities.swift */; }; FDE33BBC2D5C124900E56F42 /* DispatchTimeInterval+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE33BBB2D5C124300E56F42 /* DispatchTimeInterval+Utilities.swift */; }; FDE33BBE2D5C3AF100E56F42 /* _037_GroupsExpiredFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE33BBD2D5C3AE800E56F42 /* _037_GroupsExpiredFlag.swift */; }; FDE519F72AB7CDC700450C53 /* Result+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE519F62AB7CDC700450C53 /* Result+Utilities.swift */; }; @@ -1676,12 +1682,10 @@ C33FD9AE255A548A00E217F9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; C33FDA7A255A57FB00E217F9 /* NSRegularExpression+SSK.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSRegularExpression+SSK.swift"; sourceTree = ""; }; C33FDA87255A57FC00E217F9 /* TypingIndicators.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicators.swift; sourceTree = ""; }; - C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReverseDispatchQueue.swift; sourceTree = ""; }; C33FDAFD255A580600E217F9 /* LRUCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LRUCache.swift; sourceTree = ""; }; C33FDB3A255A580B00E217F9 /* PollerType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PollerType.swift; sourceTree = ""; }; C33FDB3F255A580C00E217F9 /* String+SSK.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+SSK.swift"; sourceTree = ""; }; C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinkPreviewDraft.swift; sourceTree = ""; }; - C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSSignalAddress.swift; sourceTree = ""; }; C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushNotificationAPI.swift; sourceTree = ""; }; C34C8F7323A7830A00D82669 /* SpaceMono-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SpaceMono-Bold.ttf"; sourceTree = ""; }; C352A30825574D8400338F3E /* Message+Destination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+Destination.swift"; sourceTree = ""; }; @@ -1705,7 +1709,6 @@ C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSAudioPlayer.h; path = SessionMessagingKit/Utilities/OWSAudioPlayer.h; sourceTree = SOURCE_ROOT; }; C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSAudioPlayer.m; path = SessionMessagingKit/Utilities/OWSAudioPlayer.m; sourceTree = SOURCE_ROOT; }; C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSWindowManager.h; path = SessionMessagingKit/Utilities/OWSWindowManager.h; sourceTree = SOURCE_ROOT; }; - C38EF304255B6DBE007E1867 /* ImageCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ImageCache.swift; path = SignalUtilitiesKit/Utilities/ImageCache.swift; sourceTree = SOURCE_ROOT; }; C38EF306255B6DBE007E1867 /* OWSWindowManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSWindowManager.m; path = SessionMessagingKit/Utilities/OWSWindowManager.m; sourceTree = SOURCE_ROOT; }; C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = DeviceSleepManager.swift; path = SessionMessagingKit/Utilities/DeviceSleepManager.swift; sourceTree = SOURCE_ROOT; }; C38EF349255B6DC7007E1867 /* ModalActivityIndicatorViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ModalActivityIndicatorViewController.swift; path = "SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift"; sourceTree = SOURCE_ROOT; }; @@ -2295,6 +2298,12 @@ FDE287522E94C5C900442E03 /* Update.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update.swift; sourceTree = ""; }; FDE287542E94CFD400442E03 /* URL+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Utilities.swift"; sourceTree = ""; }; FDE287562E94D7B200442E03 /* DeveloperSettingsFileServerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsFileServerViewModel.swift; sourceTree = ""; }; + FDE287582E95BBA900442E03 /* HTTPFragmentParam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPFragmentParam.swift; sourceTree = ""; }; + FDE2875A2E95BC2A00442E03 /* HTTPFragmentParam+FileServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPFragmentParam+FileServer.swift"; sourceTree = ""; }; + FDE2875C2E95CD3300442E03 /* StringCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringCache.swift; sourceTree = ""; }; + FDE2875E2E96061A00442E03 /* ExtendExpirationResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendExpirationResponse.swift; sourceTree = ""; }; + FDE287602E970D5900442E03 /* Async+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Async+Utilities.swift"; sourceTree = ""; }; + FDE287652E970D9A00442E03 /* AsyncStream+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AsyncStream+Utilities.swift"; sourceTree = ""; }; FDE33BBB2D5C124300E56F42 /* DispatchTimeInterval+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchTimeInterval+Utilities.swift"; sourceTree = ""; }; FDE33BBD2D5C3AE800E56F42 /* _037_GroupsExpiredFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _037_GroupsExpiredFlag.swift; sourceTree = ""; }; FDE519F62AB7CDC700450C53 /* Result+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Result+Utilities.swift"; sourceTree = ""; }; @@ -3816,9 +3825,6 @@ C38EF240255B6D67007E1867 /* UIView+OWS.swift */, FD71161D28D9772700B47552 /* UIViewController+OWS.swift */, C38EF2B1255B6D9C007E1867 /* UIViewController+Utilities.swift */, - C33FDBD3255A581800E217F9 /* OWSSignalAddress.swift */, - C38EF304255B6DBE007E1867 /* ImageCache.swift */, - C33FDA9E255A57FF00E217F9 /* ReverseDispatchQueue.swift */, C38EF241255B6D67007E1867 /* Collection+OWS.swift */, C38EF3AE255B6DE5007E1867 /* OrderedDictionary.swift */, FDB3487D2BE856C800B716C2 /* UIBezierPath+Utilities.swift */, @@ -3961,6 +3967,7 @@ 94C58AC82D2E036E00609195 /* Permissions.swift */, FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */, FD78EA052DDEC8F100D55B50 /* AsyncSequence+Utilities.swift */, + FDE287652E970D9A00442E03 /* AsyncStream+Utilities.swift */, FD7443452D07CA9F00862443 /* CGFloat+Utilities.swift */, FD7443462D07CA9F00862443 /* CGPoint+Utilities.swift */, FD7443472D07CA9F00862443 /* CGRect+Utilities.swift */, @@ -4167,6 +4174,7 @@ FD6A38F02C2A66B100762359 /* KeychainStorage.swift */, FD78EA032DDEC3C000D55B50 /* MultiTaskManager.swift */, FD6F5B5F2E657A32009A8D01 /* StreamLifecycleManager.swift */, + FDE2875C2E95CD3300442E03 /* StringCache.swift */, FD2272E92C351CA7004D8A6C /* Threading.swift */, FDE287522E94C5C900442E03 /* Update.swift */, FDAA16752AC28A3B00DDBF77 /* UserDefaultsType.swift */, @@ -4400,6 +4408,7 @@ isa = PBXGroup; children = ( FDC4383727B3863200C60D73 /* AppVersionResponse.swift */, + FDE2875E2E96061A00442E03 /* ExtendExpirationResponse.swift */, ); path = Models; sourceTree = ""; @@ -4763,6 +4772,7 @@ FD83B9BC27CF2215005E1583 /* _SharedTestUtilities */ = { isa = PBXGroup; children = ( + FDE287602E970D5900442E03 /* Async+Utilities.swift */, FD0150472CA243CB005B08A1 /* Mock.swift */, FD0969F82A69FFE700C5C365 /* Mocked.swift */, FD481A932CAE0ADD00ECC4CF /* MockAppContext.swift */, @@ -5143,6 +5153,7 @@ FDEFDC6A2E8361D400EBCD81 /* Types */ = { isa = PBXGroup; children = ( + FDE2875A2E95BC2A00442E03 /* HTTPFragmentParam+FileServer.swift */, FDEFDC6B2E8361DB00EBCD81 /* HTTPHeader+FileServer.swift */, ); path = Types; @@ -5182,6 +5193,7 @@ FD2272A62C33E337004D8A6C /* HTTPHeader.swift */, FD2272A72C33E337004D8A6C /* HTTPMethod.swift */, FD22729A2C33E336004D8A6C /* HTTPQueryParam.swift */, + FDE287582E95BBA900442E03 /* HTTPFragmentParam.swift */, FD2272982C33E336004D8A6C /* IPv4.swift */, FD22729B2C33E336004D8A6C /* JSON.swift */, FD2272992C33E336004D8A6C /* Network.swift */, @@ -6312,12 +6324,10 @@ C38EF3C2255B6DE7007E1867 /* ImageEditorPaletteView.swift in Sources */, C38EF363255B6DCC007E1867 /* ModalActivityIndicatorViewController.swift in Sources */, C38EF3C1255B6DE7007E1867 /* ImageEditorBrushViewController.swift in Sources */, - C33FDD8D255A582000E217F9 /* OWSSignalAddress.swift in Sources */, C38EF388255B6DD2007E1867 /* AttachmentApprovalViewController.swift in Sources */, C38EF38C255B6DD2007E1867 /* ApprovalRailCellView.swift in Sources */, C38EF3C7255B6DE7007E1867 /* ImageEditorCanvasView.swift in Sources */, C38EF400255B6DF7007E1867 /* GalleryRailView.swift in Sources */, - C38EF32E255B6DBF007E1867 /* ImageCache.swift in Sources */, C38EF3BA255B6DE7007E1867 /* ImageEditorItem.swift in Sources */, C38EF24E255B6D67007E1867 /* Collection+OWS.swift in Sources */, FD2272DD2C34EFFA004D8A6C /* AppSetup.swift in Sources */, @@ -6334,7 +6344,6 @@ C38EF3BB255B6DE7007E1867 /* ImageEditorStrokeItem.swift in Sources */, C38EF3C0255B6DE7007E1867 /* ImageEditorCropViewController.swift in Sources */, C38EF3BD255B6DE7007E1867 /* ImageEditorTransform.swift in Sources */, - C33FDC58255A582000E217F9 /* ReverseDispatchQueue.swift in Sources */, C38EF3F9255B6DF7007E1867 /* OWSLayerView.swift in Sources */, C38EF3B9255B6DE7007E1867 /* ImageEditorPinchGestureRecognizer.swift in Sources */, FDB3487E2BE856C800B716C2 /* UIBezierPath+Utilities.swift in Sources */, @@ -6379,6 +6388,7 @@ FD6B92992E77A06E004463B5 /* Token.swift in Sources */, FDB5DAF32A96DD4F002C8721 /* PreparedRequest+Sending.swift in Sources */, FDF848C629405C5B007DCAE5 /* DeleteAllMessagesRequest.swift in Sources */, + FDE2875F2E96061E00442E03 /* ExtendExpirationResponse.swift in Sources */, FDF848D429405C5B007DCAE5 /* DeleteAllBeforeResponse.swift in Sources */, FD6B92AF2E77AA03004463B5 /* HTTPQueryParam+SOGS.swift in Sources */, FD6B92B02E77AA03004463B5 /* Request+SOGS.swift in Sources */, @@ -6437,6 +6447,7 @@ FD2272AF2C33E337004D8A6C /* JSON.swift in Sources */, FD2272D62C34ED6A004D8A6C /* RetryWithDependencies.swift in Sources */, FDF848D229405C5B007DCAE5 /* LegacyGetMessagesRequest.swift in Sources */, + FDE287592E95BBAF00442E03 /* HTTPFragmentParam.swift in Sources */, FDF848E529405D6E007DCAE5 /* SnodeAPIError.swift in Sources */, FD6B928C2E779DCC004463B5 /* FileServer.swift in Sources */, FDE287552E94CFDB00442E03 /* URL+Utilities.swift in Sources */, @@ -6455,6 +6466,7 @@ FD2272BA2C33E337004D8A6C /* HTTPHeader.swift in Sources */, FDF848CD29405C5B007DCAE5 /* GetNetworkTimestampResponse.swift in Sources */, FDF848DA29405C5B007DCAE5 /* GetMessagesResponse.swift in Sources */, + FDE2875B2E95BC3300442E03 /* HTTPFragmentParam+FileServer.swift in Sources */, FD6B92C62E77AD0F004463B5 /* Crypto+FileServer.swift in Sources */, FD2286682C37DA3B00BC06F7 /* LibSession+Networking.swift in Sources */, FD2272A92C33E337004D8A6C /* ContentProxy.swift in Sources */, @@ -6487,6 +6499,7 @@ FD2272C82C34EB0A004D8A6C /* Job.swift in Sources */, FD2272C72C34EAF5004D8A6C /* ColumnExpressible.swift in Sources */, FDB3486E2BE8457F00B716C2 /* BackgroundTaskManager.swift in Sources */, + FDE287662E970D9E00442E03 /* AsyncStream+Utilities.swift in Sources */, 7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */, FD428B192B4B576F006D0888 /* AppContext.swift in Sources */, FD2272D42C34ECE1004D8A6C /* BencodeEncoder.swift in Sources */, @@ -6494,6 +6507,7 @@ FD559DF52A7368CB00C7C62A /* DispatchQueue+Utilities.swift in Sources */, FDE754DC2C9BAF8A002A2623 /* CryptoError.swift in Sources */, FDFF9FDF2A787F57005E0628 /* JSONEncoder+Utilities.swift in Sources */, + FDE2875D2E95CD3500442E03 /* StringCache.swift in Sources */, FDE754CD2C9BAF37002A2623 /* UTType+Utilities.swift in Sources */, FD42ECD62E3308B5002D03EA /* ObservableKey+SessionUtilitiesKit.swift in Sources */, FDE754D22C9BAF53002A2623 /* JobDependencies.swift in Sources */, @@ -7100,6 +7114,7 @@ 9499E68B2DF92F4E00091434 /* ThreadNotificationSettingsViewModelSpec.swift in Sources */, FD01504B2CA243CB005B08A1 /* Mock.swift in Sources */, FD0969FA2A6A00B000C5C365 /* Mocked.swift in Sources */, + FDE287632E970D5C00442E03 /* Async+Utilities.swift in Sources */, FD481A9A2CB4CAE500ECC4CF /* CommonSMKMockExtensions.swift in Sources */, FD3FAB6C2AF1B28B00DC5421 /* MockFileManager.swift in Sources */, FD23EA6128ED0B260058676E /* CombineExtensions.swift in Sources */, @@ -7146,6 +7161,7 @@ FD23CE262A676B5B0000B97C /* DependenciesSpec.swift in Sources */, FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */, FD0969FB2A6A00B100C5C365 /* Mocked.swift in Sources */, + FDE287622E970D5C00442E03 /* Async+Utilities.swift in Sources */, FD0150292CA23DB7005B08A1 /* GRDBExtensions.swift in Sources */, FDD23AEF2E459EC90057E853 /* _012_AddJobPriority.swift in Sources */, FD481A942CAE0AE000ECC4CF /* MockAppContext.swift in Sources */, @@ -7173,6 +7189,7 @@ FD6B92D32E77B270004463B5 /* UpdateMessageRequestSpec.swift in Sources */, FDB5DB092A981F8D002C8721 /* MockCrypto.swift in Sources */, FDAA167B2AC28E2F00DDBF77 /* SnodeRequestSpec.swift in Sources */, + FDE287642E970D5C00442E03 /* Async+Utilities.swift in Sources */, FD6B92D02E77B23B004463B5 /* CapabilitiesResponse.swift in Sources */, FD6B92D42E77B2C7004463B5 /* SOGSAPISpec.swift in Sources */, FD65318C2AA025C500DFEEAA /* TestDependencies.swift in Sources */, @@ -7256,6 +7273,7 @@ FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */, FD3C906727E416AF00CD579F /* BlindedIdLookupSpec.swift in Sources */, FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */, + FDE287612E970D5C00442E03 /* Async+Utilities.swift in Sources */, FD3FAB6D2AF1B28B00DC5421 /* MockFileManager.swift in Sources */, FD78E9F22DDA9EA200D55B50 /* MockImageDataManager.swift in Sources */, ); diff --git a/Session/Media Viewing & Editing/PhotoLibrary.swift b/Session/Media Viewing & Editing/PhotoLibrary.swift index 08955d7969..3f2aac3eb7 100644 --- a/Session/Media Viewing & Editing/PhotoLibrary.swift +++ b/Session/Media Viewing & Editing/PhotoLibrary.swift @@ -268,6 +268,10 @@ class PhotoCollectionContents { return continuation.resume(throwing: error) } + if (info?[PHImageCancelledKey] as? Bool) == true { + return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "Video request cancelled")) + } + guard let avAsset: AVAsset = avAsset else { return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "avAsset was unexpectedly nil")) } @@ -283,10 +287,8 @@ class PhotoCollectionContents { Log.debug("[PhotoLibrary] Passthrough not available. Falling back to HighestQuality export preset.") } - if (info?[PHImageCancelledKey] as? Bool) == true { - return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "Video request cancelled")) - } - + /// Apple likes to use special formats for media so in order to maintain compatibility with other clients we want to + /// convert the selected video into an `mp4` guard let exportSession: AVAssetExportSession = AVAssetExportSession(asset: avAsset, presetName: bestExportPreset) else { return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "exportSession was unexpectedly nil")) } diff --git a/Session/Meta/Session+SNUIKit.swift b/Session/Meta/Session+SNUIKit.swift index 396c33a2cc..f27d4efc03 100644 --- a/Session/Meta/Session+SNUIKit.swift +++ b/Session/Meta/Session+SNUIKit.swift @@ -89,12 +89,16 @@ internal struct SessionSNUIKitConfig: SNUIKit.ConfigType { return dependencies[feature: .showStringKeys] } - func asset(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, cleanup: () -> Void)? { - return AVURLAsset.asset( - for: path, - utType: utType, - sourceFilename: sourceFilename, - using: dependencies - ) + func assetInfo(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, isValidVideo: Bool, cleanup: () -> Void)? { + guard + let result: (asset: AVURLAsset, cleanup: () -> Void) = AVURLAsset.asset( + for: path, + utType: utType, + sourceFilename: sourceFilename, + using: dependencies + ) + else { return nil } + + return (result.asset, MediaUtils.isValidVideo(asset: result.asset), result.cleanup) } } diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift index f1d5bbfc32..42f2c354eb 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift @@ -290,33 +290,34 @@ class DeveloperSettingsFileServerViewModel: SessionTableViewModel, NavigatableSt initialValue: pendingState.customFileServer.url, inputChecker: { text in guard URL(string: text) != nil else { - return "Value must be a valid url." + return "Value must be a valid url (with HTTP or HTTPS)." } return nil } ), onChange: { [weak self] value in - self?.updatedCustomServerPubkey = value + self?.updatedCustomServerUrl = value.lowercased() } ), confirmTitle: "save".localized(), confirmEnabled: .afterChange { [weak self] _ in - guard let value: String = self?.updatedCustomServerUrl else { - return false - } + guard + let value: String = self?.updatedCustomServerUrl, + let url: URL = URL(string: value) + else { return false } - return (URL(string: value) != nil) + return (url.scheme != nil && url.host != nil) }, cancelStyle: .alert_text, dismissOnConfirm: false, onConfirm: { [weak self, dependencies] modal in guard - let value: String = self?.updatedCustomServerPubkey, + let value: String = self?.updatedCustomServerUrl, URL(string: value) != nil else { modal.updateContent( - withError: "Value must be a valid url." + withError: "Value must be a valid url (with HTTP or HTTPS)." ) return } diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift index 320867b2f7..8bc0f8a1c0 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift @@ -345,7 +345,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, File TTL: \(dependencies[feature: .shortenFileTTL] ? "60 Seconds" : "14 Days") Deterministic Encryption: \(dependencies[feature: .deterministicAttachmentEncryption] ? "Enabled" : "Disabled") - File Server: \(dependencies[feature: .customFileServer].isValid ? dependencies[feature: .customFileServer].url : Network.FileServer.fileServer) + File Server: \(Network.FileServer.server(using: dependencies)) """, trailingAccessory: .icon(.chevronRight), onTap: { [weak self, dependencies] in diff --git a/SessionMessagingKit/Database/Migrations/_043_RenameAttachments.swift b/SessionMessagingKit/Database/Migrations/_043_RenameAttachments.swift index ff94b962c2..1c3e216833 100644 --- a/SessionMessagingKit/Database/Migrations/_043_RenameAttachments.swift +++ b/SessionMessagingKit/Database/Migrations/_043_RenameAttachments.swift @@ -150,7 +150,10 @@ enum _043_RenameAttachments: Migration { return urlString } - return Network.FileServer.downloadUrlString(for: "invalid-legacy-file-\(index)") + return Network.FileServer.downloadUrlString( + for: "invalid-legacy-file-\(index)", + using: dependencies + ) }() guard diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift index c19fb3adfb..fc21a83822 100644 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift @@ -119,7 +119,7 @@ public enum AttachmentDownloadJob: JobExecutor { ) default: - request = try Network.preparedDownload( + request = try Network.FileServer.preparedDownload( url: downloadUrl, using: dependencies ) diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index 4fafe5c3ce..75354f3473 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -247,7 +247,8 @@ public extension AttachmentUploadJob { /// uploaded (in this case the attachment has already been uploaded so just succeed) if attachment.state == .uploaded, - Network.FileServer.fileId(for: attachment.downloadUrl) != nil + Network.FileServer.fileId(for: attachment.downloadUrl) != nil, + !dependencies[singleton: .attachmentManager].isPlaceholderUploadUrl(attachment.downloadUrl) { return attachment } @@ -259,6 +260,7 @@ public extension AttachmentUploadJob { if attachment.state == .downloaded, Network.FileServer.fileId(for: attachment.downloadUrl) != nil, + !dependencies[singleton: .attachmentManager].isPlaceholderUploadUrl(attachment.downloadUrl), ( !shouldEncrypt || attachment.encryptionKey != nil @@ -278,8 +280,7 @@ public extension AttachmentUploadJob { ) let preparedAttachment: PreparedAttachment = try pendingAttachment.prepare( transformations: Set([ - // FIXME: Remove the `legacy` encryption option - (shouldEncrypt ? .encrypt(legacy: true, domain: .attachment) : nil) + (shouldEncrypt ? .encrypt(domain: .attachment) : nil) ].compactMap { $0 }), using: dependencies ) @@ -311,7 +312,10 @@ public extension AttachmentUploadJob { ) default: - request = try Network.preparedUpload(data: preparedData, using: dependencies) + request = try Network.FileServer.preparedUpload( + data: preparedData, + using: dependencies + ) } // FIXME: Make this async/await when the refactored networking is merged @@ -341,7 +345,10 @@ public extension AttachmentUploadJob { ) default: - return Network.FileServer.downloadUrlString(for: response.id) + return Network.FileServer.downloadUrlString( + for: response.id, + using: dependencies + ) } }() @@ -407,7 +414,8 @@ public extension AttachmentUploadJob { /// uploaded (in this case the attachment has already been uploaded so just succeed) if attachment.state == .uploaded, - let fileId: String = Network.FileServer.fileId(for: attachment.downloadUrl) + let fileId: String = Network.FileServer.fileId(for: attachment.downloadUrl), + !dependencies[singleton: .attachmentManager].isPlaceholderUploadUrl(attachment.downloadUrl) { return ( try Network.PreparedRequest.cached( @@ -430,6 +438,7 @@ public extension AttachmentUploadJob { if attachment.state == .downloaded, let fileId: String = Network.FileServer.fileId(for: attachment.downloadUrl), + !dependencies[singleton: .attachmentManager].isPlaceholderUploadUrl(attachment.downloadUrl), ( !shouldEncrypt || ( attachment.encryptionKey != nil && @@ -458,8 +467,7 @@ public extension AttachmentUploadJob { ) let preparedAttachment: PreparedAttachment = try pendingAttachment.prepare( transformations: Set([ - // FIXME: Remove the `legacy` encryption option - (shouldEncrypt ? .encrypt(legacy: true, domain: .attachment) : nil) + (shouldEncrypt ? .encrypt(domain: .attachment) : nil) ].compactMap { $0 }), using: dependencies ) @@ -491,7 +499,10 @@ public extension AttachmentUploadJob { default: return ( - try Network.preparedUpload(data: preparedData, using: dependencies), + try Network.FileServer.preparedUpload( + data: preparedData, + using: dependencies + ), attachment, preparedAttachment ) @@ -522,7 +533,10 @@ public extension AttachmentUploadJob { ) default: - return Network.FileServer.downloadUrlString(for: response.id) + return Network.FileServer.downloadUrlString( + for: response.id, + using: dependencies + ) } }() diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index 7649670d2c..f45b35553b 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -43,7 +43,7 @@ public enum DisplayPictureDownloadJob: JobExecutor { throw NetworkError.invalidURL } - return try Network.preparedDownload( + return try Network.FileServer.preparedDownload( url: downloadUrl, using: dependencies ) @@ -65,6 +65,11 @@ public enum DisplayPictureDownloadJob: JobExecutor { } try Task.checkCancellation() + /// Check to make sure this download is a valid update before starting to download + try await dependencies[singleton: .storage].readAsync { db in + try details.ensureValidUpdate(db, using: dependencies) + } + let downloadUrl: String = ((try? request.generateUrl())?.absoluteString ?? request.path) let filePath: String = try dependencies[singleton: .displayPictureManager] .path(for: downloadUrl) @@ -88,7 +93,7 @@ public enum DisplayPictureDownloadJob: JobExecutor { /// Get the decrypted data guard let decryptedData: Data = { - switch (details.target, Network.FileServer.usesDeterministicEncryption(downloadUrl)) { + switch (details.target, details.target.usesDeterministicEncryption) { case (.community, _): return response /// Community data is unencrypted case (.profile(_, _, let encryptionKey), false), (.group(_, _, let encryptionKey), false): return dependencies[singleton: .crypto].generate( @@ -249,14 +254,21 @@ extension DisplayPictureDownloadJob { var isValid: Bool { switch self { + case .community(let imageId, _, _, _): return !imageId.isEmpty case .profile(_, let url, let encryptionKey), .group(_, let url, let encryptionKey): return ( !url.isEmpty && Network.FileServer.fileId(for: url) != nil && encryptionKey.count == DisplayPictureManager.encryptionKeySize ) - - case .community(let imageId, _, _, _): return !imageId.isEmpty + } + } + + var usesDeterministicEncryption: Bool { + switch self { + case .community: return false + case .profile(_, let url, _), .group(_, let url, _): + return Network.FileServer.usesDeterministicEncryption(url) } } diff --git a/SessionMessagingKit/Jobs/MessageSendJob.swift b/SessionMessagingKit/Jobs/MessageSendJob.swift index bbb2b1c7fe..5ec6e54d81 100644 --- a/SessionMessagingKit/Jobs/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/MessageSendJob.swift @@ -85,7 +85,13 @@ public enum MessageSendJob: JobExecutor { /// Retrieve the current attachment state let attachmentState: AttachmentState = dependencies[singleton: .storage] - .read { db in try MessageSendJob.fetchAttachmentState(db, interactionId: interactionId) } + .read { db in + try MessageSendJob.fetchAttachmentState( + db, + interactionId: interactionId, + using: dependencies + ) + } .defaulting(to: AttachmentState(error: MessageSenderError.invalidMessage)) /// If we got an error when trying to retrieve the attachment state then this job is actually invalid so it @@ -301,7 +307,8 @@ public extension MessageSendJob { static func fetchAttachmentState( _ db: ObservingDatabase, - interactionId: Int64 + interactionId: Int64, + using dependencies: Dependencies ) throws -> AttachmentState { // If the original interaction no longer exists then don't bother sending the message (ie. the // message was deleted before it even got sent) @@ -352,6 +359,8 @@ public extension MessageSendJob { .compactMap { info in guard let attachment: Attachment = attachments[info.attachmentId], + !dependencies[singleton: .attachmentManager] + .isPlaceholderUploadUrl(attachment.downloadUrl), let fileId: String = Network.FileServer.fileId(for: info.downloadUrl) else { return nil } diff --git a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift index 1fcf73b11e..1d0a98fbb7 100644 --- a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift +++ b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift @@ -36,6 +36,15 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { } Task { + guard + await dependencies[singleton: .currentUserPoller].successfulPollCount + .first(where: { $0 > 0 }) != nil + else { + Log.info(.cat, "Deferred due to never receiving an initial poll response") + return scheduler.schedule { + deferred(job) + } + } /// Retrieve the users profile data let profile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } @@ -73,15 +82,14 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { /// Try to extend the TTL of the existing profile pic first do { - let request: Network.PreparedRequest = try Network.FileServer.preparedExtend( + let request: Network.PreparedRequest = try Network.FileServer.preparedExtend( url: displayPictureUrl, ttl: maxDisplayPictureTTL, - serverPubkey: Network.FileServer.fileServerPublicKey, using: dependencies ) // FIXME: Make this async/await when the refactored networking is merged - let response: FileUploadResponse = try await request + _ = try await request .send(using: dependencies) .values .first(where: { _ in true })?.1 ?? { throw AttachmentError.uploadFailed }() diff --git a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift index 9bbcc1a250..dece2a14b7 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift @@ -23,6 +23,7 @@ public enum AttachmentError: Error, CustomStringConvertible { case couldNotParseImage case couldNotConvertToJpeg case couldNotConvertToMpeg4 + case couldNotConvertToWebP case couldNotRemoveMetadata case invalidFileFormat case couldNotResizeImage @@ -65,7 +66,8 @@ public enum AttachmentError: Error, CustomStringConvertible { case .invalidData, .missingData, .invalidFileFormat, .invalidImageData: return "attachmentsErrorNotSupported".localized() - case .couldNotConvertToJpeg, .couldNotParseImage, .couldNotConvertToMpeg4, .couldNotResizeImage: + case .couldNotConvertToJpeg, .couldNotParseImage, .couldNotConvertToMpeg4, + .couldNotConvertToWebP, .couldNotResizeImage: return "attachmentsErrorOpen".localized() case .couldNotRemoveMetadata: diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift index 40477f59f3..e322563198 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CommunityPoller.swift @@ -23,7 +23,7 @@ public protocol CommunityPollerType { typealias PollResponse = (info: ResponseInfoType, data: Network.BatchResponseMap) var isPolling: Bool { get } - var receivedPollResponse: AnyPublisher { get } + nonisolated var receivedPollResponse: AsyncStream { get } func startIfNeeded() func stop() @@ -54,9 +54,8 @@ public final class CommunityPoller: CommunityPollerType & PollerType { public let pollerName: String public let pollerDestination: PollerDestination public let logStartAndStopCalls: Bool - public var receivedPollResponse: AnyPublisher { - receivedPollResponseSubject.eraseToAnyPublisher() - } + nonisolated public var receivedPollResponse: AsyncStream { responseStream.stream } + nonisolated public var successfulPollCount: AsyncStream { pollCountStream.stream } public var isPolling: Bool = false public var pollCount: Int = 0 @@ -65,7 +64,8 @@ public final class CommunityPoller: CommunityPollerType & PollerType { public var cancellable: AnyCancellable? private let shouldStoreMessages: Bool - private let receivedPollResponseSubject: PassthroughSubject = PassthroughSubject() + nonisolated private let responseStream: CancellationAwareAsyncStream = CancellationAwareAsyncStream() + nonisolated private let pollCountStream: CurrentValueAsyncStream = CurrentValueAsyncStream(0) // MARK: - Initialization @@ -90,6 +90,13 @@ public final class CommunityPoller: CommunityPollerType & PollerType { self.logStartAndStopCalls = logStartAndStopCalls } + deinit { + // Send completion events to the observables + Task { [stream = responseStream] in + await stream.finishCurrentStreams() + } + } + // MARK: - Abstract Methods public func nextPollDelay() -> AnyPublisher { @@ -119,7 +126,7 @@ public final class CommunityPoller: CommunityPollerType & PollerType { } return .continuePolling } - //[pollerName, pollerDestination, failureCount, dependencies] + func handleError(_ error: Error) throws -> AnyPublisher { /// Log the error first Log.error(.poller, "\(pollerName) failed to update capabilities due to error: \(error).") @@ -328,7 +335,12 @@ public final class CommunityPoller: CommunityPollerType & PollerType { } .handleEvents( receiveOutput: { [weak self, dependencies] _ in - self?.pollCount += 1 + let updatedPollCount: Int = ((self?.pollCount ?? 0) + 1) + self?.pollCount = updatedPollCount + + Task { [weak self] in + await self?.pollCountStream.send(updatedPollCount) + } dependencies.mutate(cache: .openGroupManager) { cache in cache.setLastSuccessfulCommunityPollTimestamp( diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift index 7e816d4a4f..6a47a74fbf 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift @@ -50,34 +50,37 @@ public final class GroupPoller: SwarmPoller { /// If the keys generation is greated than `0` then it means we have a valid config so shouldn't continue guard numKeys == 0 else { return } - dependencies[singleton: .storage] - .readPublisher { [pollerDestination] db in + Task.detached { [weak self, pollerDestination, dependencies] in + guard let self = self else { return } + + let isExpired: Bool? = try await dependencies[singleton: .storage].readAsync { [pollerDestination] db in try ClosedGroup .filter(id: pollerDestination.target) .select(.expired) .asRequest(of: Bool.self) .fetchOne(db) } - .filter { ($0 != true) } - .flatMap { [receivedPollResponse] _ in receivedPollResponse } - .first() - .map { $0.filter { $0.isConfigMessage } } - .filter { !$0.contains(where: { $0.namespace == Network.SnodeAPI.Namespace.configGroupKeys }) } - .sinkUntilComplete( - receiveValue: { [pollerDestination, pollerName, dependencies] configMessages in - Log.error(.poller, "\(pollerName) received no config messages in it's first poll, flagging as expired.") - - dependencies[singleton: .storage].writeAsync { db in - try ClosedGroup - .filter(id: pollerDestination.target) - .updateAllAndConfig( - db, - ClosedGroup.Columns.expired.set(to: true), - using: dependencies - ) - } - } - ) + + /// If we haven't set the `expired` value then we should check the first poll response to see if it's missing the + /// `GroupKeys` config message + guard + isExpired != true, + let response: PollResponse = await receivedPollResponse.first(), + !response.contains(where: { $0.namespace == .configGroupKeys }) + else { return } + + /// There isn't `GroupKeys` config so flag the group as `expired` + Log.error(.poller, "\(pollerName) received no config messages in it's first poll, flagging as expired.") + try await dependencies[singleton: .storage].writeAsync { db in + try ClosedGroup + .filter(id: pollerDestination.target) + .updateAllAndConfig( + db, + ClosedGroup.Columns.expired.set(to: true), + using: dependencies + ) + } + } } // MARK: - Abstract Methods diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift b/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift index c3e16e5fc5..6856571469 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/PollerType.swift @@ -51,7 +51,8 @@ public protocol PollerType: AnyObject { var pollerName: String { get } var pollerDestination: PollerDestination { get } var logStartAndStopCalls: Bool { get } - var receivedPollResponse: AnyPublisher { get } + nonisolated var receivedPollResponse: AsyncStream { get } + nonisolated var successfulPollCount: AsyncStream { get } var isPolling: Bool { get set } var pollCount: Int { get set } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift index ab0851838b..ba5c1bd1f6 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift @@ -11,7 +11,7 @@ import SessionUtilitiesKit public protocol SwarmPollerType { typealias PollResponse = [ProcessedMessage] - var receivedPollResponse: AnyPublisher { get } + nonisolated var receivedPollResponse: AsyncStream { get } func startIfNeeded() func stop() @@ -31,9 +31,8 @@ public class SwarmPoller: SwarmPollerType & PollerType { public let pollerDestination: PollerDestination @ThreadSafeObject public var pollerDrainBehaviour: SwarmDrainBehaviour public let logStartAndStopCalls: Bool - public var receivedPollResponse: AnyPublisher { - receivedPollResponseSubject.eraseToAnyPublisher() - } + nonisolated public var receivedPollResponse: AsyncStream { responseStream.stream } + nonisolated public var successfulPollCount: AsyncStream { pollCountStream.stream } public var isPolling: Bool = false public var pollCount: Int = 0 @@ -44,7 +43,8 @@ public class SwarmPoller: SwarmPollerType & PollerType { private let namespaces: [Network.SnodeAPI.Namespace] private let customAuthMethod: AuthenticationMethod? private let shouldStoreMessages: Bool - private let receivedPollResponseSubject: PassthroughSubject = PassthroughSubject() + nonisolated private let responseStream: CancellationAwareAsyncStream = CancellationAwareAsyncStream() + nonisolated private let pollCountStream: CurrentValueAsyncStream = CurrentValueAsyncStream(0) // MARK: - Initialization @@ -72,6 +72,13 @@ public class SwarmPoller: SwarmPollerType & PollerType { self.logStartAndStopCalls = logStartAndStopCalls } + deinit { + // Send completion events to the observables + Task { [stream = responseStream] in + await stream.finishCurrentStreams() + } + } + // MARK: - Abstract Methods /// Calculate the delay which should occur before the next poll @@ -218,8 +225,14 @@ public class SwarmPoller: SwarmPollerType & PollerType { } .handleEvents( receiveOutput: { [weak self] (pollResult: PollResult) in + let updatedPollCount: Int = ((self?.pollCount ?? 0) + 1) + self?.pollCount = updatedPollCount + /// Notify any observers that we got a result - self?.receivedPollResponseSubject.send(pollResult.response) + Task { [weak self] in + await self?.responseStream.send(pollResult.response) + await self?.pollCountStream.send(updatedPollCount) + } } ) .eraseToAnyPublisher() @@ -387,8 +400,8 @@ public class SwarmPoller: SwarmPollerType & PollerType { } /// Make sure to add any synchronously processed messages to the `finalProcessedMessages` as otherwise - /// they wouldn't be emitted by the `receivedPollResponseSubject`, also need to add the count to - /// `messageCount` to ensure it's not incorrect + /// they wouldn't be emitted by `receivedPollResponse`, also need to add the count to `messageCount` to + /// ensure it's not incorrect finalProcessedMessages += processedMessages messageCount += processedMessages.count return nil diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index 4d41c54724..86aaee7af7 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -33,6 +33,9 @@ public final class AttachmentManager: Sendable, ThumbnailManager { public static let maxAttachmentsAllowed: Int = 32 private let dependencies: Dependencies + private let cache: StringCache = StringCache( + totalCostLimit: 5 * 1024 * 1024 /// Max 5MB of url to hash data (approx. 20,000 records) + ) // MARK: - Initalization @@ -76,9 +79,21 @@ public final class AttachmentManager: Sendable, ThumbnailManager { else { return urlString } /// Otherwise we need to generate the deterministic file path based on the url provided - let urlHash = try dependencies[singleton: .crypto] - .tryGenerate(.hash(message: Array(urlString.utf8))) - .toHexString() + /// + /// **Note:** Now that download urls could contain fragments (or query params I guess) that could result in inconsistent paths + /// with old attachments so just to be safe we should strip them before generating the `urlHash` + let urlNoQueryOrFragment: String = urlString + .components(separatedBy: "?")[0] + .components(separatedBy: "#")[0] + let urlHash = try { + guard let cachedHash: String = cache.object(forKey: urlNoQueryOrFragment) else { + return try dependencies[singleton: .crypto] + .tryGenerate(.hash(message: Array(urlNoQueryOrFragment.utf8))) + .toHexString() + } + + return cachedHash + }() return URL(fileURLWithPath: sharedDataAttachmentsDirPath()) .appendingPathComponent(urlHash) @@ -466,7 +481,7 @@ public extension PendingAttachment { case convertToStandardFormats case resize(maxDimension: CGFloat) case stripImageMetadata - case encrypt(legacy: Bool, domain: Crypto.AttachmentDomain) + case encrypt(domain: Crypto.AttachmentDomain) fileprivate enum Erased: Equatable { case compress @@ -577,8 +592,8 @@ public extension PendingAttachment { let preparedData: Data switch source { - case .media where utType.isImage: preparedData = try prepareImage(transformations) case .media where utType.isAnimated: preparedData = try prepareImage(transformations) + case .media where utType.isImage: preparedData = try prepareImage(transformations) case .media where utType.isVideo: preparedData = try prepareVideo(transformations) case .media where utType.isAudio: preparedData = try prepareAudio(transformations) case .voiceMessage: preparedData = try prepareAudio(transformations) @@ -599,7 +614,7 @@ public extension PendingAttachment { /// If we don't have the `encrypt` transform then we can just return the `preparedData` (which is unencrypted but should /// have all other `Transform` changes applied // FIXME: We should store attachments encrypted and decrypt them when we want to render/open them - guard case .encrypt(let legacyEncryption, let encryptionDomain) = transformations.first(where: { $0.erased == .encrypt }) else { + guard case .encrypt(let encryptionDomain) = transformations.first(where: { $0.erased == .encrypt }) else { try dependencies[singleton: .fileManager].write(data: preparedData, toPath: filePath) return PreparedAttachment( @@ -619,17 +634,7 @@ public extension PendingAttachment { typealias EncryptionData = (ciphertext: Data, encryptionKey: Data, digest: Data) let encryptedData: EncryptionData - if legacyEncryption { - encryptedData = try dependencies[singleton: .crypto].tryGenerate( - .legacyEncryptAttachment(plaintext: preparedData) - ) - - /// May as well throw here if we know the attachment is too large to send - guard encryptedData.ciphertext.count <= Network.maxFileSize else { - throw AttachmentError.fileSizeTooLarge - } - } - else { + if dependencies[feature: .deterministicAttachmentEncryption] { let encryptedSize: Int = try dependencies[singleton: .crypto].tryGenerate( .expectedEncryptedAttachmentSize(plaintext: preparedData) ) @@ -645,6 +650,27 @@ public extension PendingAttachment { encryptedData = (result.ciphertext, result.encryptionKey, Data()) } + else { + switch encryptionDomain { + case .attachment: + encryptedData = try dependencies[singleton: .crypto].tryGenerate( + .legacyEncryptedAttachment(plaintext: preparedData) + ) + + case .profilePicture: + let encryptionKey: Data = try dependencies[singleton: .crypto] + .tryGenerate(.randomBytes(DisplayPictureManager.encryptionKeySize)) + let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate( + .legacyEncryptedDisplayPicture(data: preparedData, key: encryptionKey) + ) + encryptedData = (ciphertext, encryptionKey, Data()) + } + + /// May as well throw here if we know the attachment is too large to send + guard encryptedData.ciphertext.count <= Network.maxFileSize else { + throw AttachmentError.fileSizeTooLarge + } + } try dependencies[singleton: .fileManager].write( data: encryptedData.ciphertext, @@ -937,9 +963,10 @@ public extension PendingAttachment { return ( mediaMetadata.hasValidPixelSize && - mediaMetadata.hasValidFileSize && mediaMetadata.hasValidDuration ) + } +} // MARK: - Type Conversions diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index 8f7c31f602..3ab06c61c8 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -67,20 +67,12 @@ public class DisplayPictureManager { internal static let tagLength: Int = 16 private let dependencies: Dependencies + private let cache: StringCache = StringCache( + totalCostLimit: 5 * 1024 * 1024 /// Max 5MB of url to hash data (approx. 20,000 records) + ) private let scheduleDownloads: PassthroughSubject<(), Never> = PassthroughSubject() private var scheduleDownloadsCancellable: AnyCancellable? - /// `NSCache` has more nuanced memory management systems than just listening for `didReceiveMemoryWarningNotification` - /// and can clear out values gradually, it can also remove items based on their "cost" so is better suited than our custom `LRUCache` - /// - /// Additionally `NSCache` is thread safe so we don't need to do any custom `ThreadSafeObject` work to interact with it - private var cache: NSCache = { - let result: NSCache = NSCache() - result.totalCostLimit = 5 * 1024 * 1024 /// Max 5MB of url to hash data (approx. 20,000 records) - - return result - }() - // MARK: - Initalization init(using dependencies: Dependencies) { @@ -124,10 +116,17 @@ public class DisplayPictureManager { return urlString } + /// Otherwise we need to generate the deterministic file path based on the url provided + /// + /// **Note:** Now that download urls could contain fragments (or query params I guess) that could result in inconsistent paths + /// with old attachments so just to be safe we should strip them before generating the `urlHash` + let urlNoQueryOrFragment: String = urlString + .components(separatedBy: "?")[0] + .components(separatedBy: "#")[0] let urlHash = try { - guard let cachedHash: String = cache.object(forKey: urlString as NSString) as? String else { + guard let cachedHash: String = cache.object(forKey: urlNoQueryOrFragment) else { return try dependencies[singleton: .crypto] - .tryGenerate(.hash(message: Array(urlString.utf8))) + .tryGenerate(.hash(message: Array(urlNoQueryOrFragment.utf8))) .toHexString() } @@ -218,11 +217,16 @@ public class DisplayPictureManager { ) let attachment: PreparedAttachment = try pendingAttachment.prepare( transformations: [ - .encrypt(legacy: true, domain: .profilePicture) // FIXME: Remove the `legacy` encryption option + .encrypt(domain: .profilePicture) ], using: dependencies ) + /// Clean up the file after the upload completes + defer { + try? dependencies[singleton: .fileManager].removeItem(atPath: attachment.filePath) + } + /// Ensure we have an encryption key for the `PreparedAttachment` we want to use as a display picture guard let encryptionKey: Data = attachment.attachment.encryptionKey else { throw AttachmentError.notEncrypted @@ -232,7 +236,7 @@ public class DisplayPictureManager { /// Upload the data let data: Data = try dependencies[singleton: .fileManager] .contents(atPath: attachment.filePath) ?? { throw AttachmentError.invalidData }() - let request: Network.PreparedRequest = try Network.preparedUpload( + let request: Network.PreparedRequest = try Network.FileServer.preparedUpload( data: data, requestAndPathBuildTimeout: Network.fileUploadTimeout, using: dependencies @@ -252,7 +256,10 @@ public class DisplayPictureManager { /// **Note:** Display pictures are currently stored unencrypted so we need to move the original `preparedAttachment` /// file to the `finalFilePath` rather than the encrypted one // FIXME: Should probably store display pictures encrypted and decrypt on load - let downloadUrl: String = Network.FileServer.downloadUrlString(for: uploadResponse.id) + let downloadUrl: String = Network.FileServer.downloadUrlString( + for: uploadResponse.id, + using: dependencies + ) let finalFilePath: String = try dependencies[singleton: .displayPictureManager].path(for: downloadUrl) try dependencies[singleton: .fileManager].moveItem( atPath: preparedAttachment.filePath, diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift index 62604acaae..afde49aad2 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift @@ -209,7 +209,7 @@ class MessageReceiverGroupsSpec: QuickSpec { @TestState var mockSwarmPoller: MockSwarmPoller! = MockSwarmPoller( initialSetup: { cache in cache.when { $0.startIfNeeded() }.thenReturn(()) - cache.when { $0.receivedPollResponse }.thenReturn(Just([]).eraseToAnyPublisher()) + cache.when { $0.receivedPollResponse }.thenReturn(.singleValue(value: [])) } ) @TestState(cache: .groupPollers, in: dependencies) var mockGroupPollersCache: MockGroupPollerCache! = MockGroupPollerCache( diff --git a/SessionMessagingKitTests/_TestUtilities/MockCommunityPoller.swift b/SessionMessagingKitTests/_TestUtilities/MockCommunityPoller.swift index f13ea85853..adb8e04be0 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockCommunityPoller.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockCommunityPoller.swift @@ -7,7 +7,7 @@ import Combine class MockCommunityPoller: Mock, CommunityPollerType { var isPolling: Bool { mock() } - var receivedPollResponse: AnyPublisher { mock() } + nonisolated var receivedPollResponse: AsyncStream { mock() } func startIfNeeded() { mockNoReturn() } func stop() { mockNoReturn() } diff --git a/SessionMessagingKitTests/_TestUtilities/MockPoller.swift b/SessionMessagingKitTests/_TestUtilities/MockPoller.swift index cfc5968bd2..be924bf820 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockPoller.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockPoller.swift @@ -16,7 +16,7 @@ class MockPoller: Mock, PollerType { var pollerName: String { mock() } var pollerDestination: PollerDestination { mock() } var logStartAndStopCalls: Bool { mock() } - var receivedPollResponse: AnyPublisher { mock() } + nonisolated var receivedPollResponse: AsyncStream { mock() } var isPolling: Bool { get { mock() } set { mockNoReturn(args: [newValue]) } diff --git a/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift b/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift index 05af305032..ca1f1925f5 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift @@ -12,7 +12,7 @@ class MockSwarmPoller: Mock, SwarmPollerType & Pol var pollerName: String { mock() } var pollerDestination: PollerDestination { mock() } var logStartAndStopCalls: Bool { mock() } - var receivedPollResponse: AnyPublisher { mock() } + nonisolated var receivedPollResponse: AsyncStream { mock() } var isPolling: Bool { get { mock() } set { mockNoReturn(args: [newValue]) } diff --git a/SessionNetworkingKit/FileServer/FileServer.swift b/SessionNetworkingKit/FileServer/FileServer.swift index 6e5c7fc841..aa2241252f 100644 --- a/SessionNetworkingKit/FileServer/FileServer.swift +++ b/SessionNetworkingKit/FileServer/FileServer.swift @@ -7,34 +7,71 @@ import SessionUtilitiesKit public extension Network { enum FileServer { - internal static let fileServer = "http://filev2.getsession.org" - public static let fileServerPublicKey = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59" - internal static let legacyFileServer = "http://88.99.175.227" - internal static let legacyFileServerPublicKey = "7cb31905b55cd5580c686911debf672577b3fb0bff81df4ce2d5c4cb3a7aaa69" - - static func fileServerPubkey(url: String? = nil) -> String { - switch url?.contains(legacyFileServer) { - case true: return legacyFileServerPublicKey - default: return fileServerPublicKey + public static let defaultServer = "http://filev2.getsession.org" + internal static let defaultEdPublicKey = "b8eef9821445ae16e2e97ef8aa6fe782fd11ad5253cd6723b281341dba22e371" + + public static func server(using dependencies: Dependencies) -> String { + guard dependencies[feature: .customFileServer].isValid else { + return defaultServer + } + + return dependencies[feature: .customFileServer].url + } + + internal static func edPublicKey(using dependencies: Dependencies) -> String { + guard dependencies[feature: .customFileServer].isValid else { + return defaultEdPublicKey } + + return dependencies[feature: .customFileServer].pubkey } - static func isFileServerUrl(url: URL) -> Bool { - return ( - url.absoluteString.starts(with: fileServer) || - url.absoluteString.starts(with: legacyFileServer) + internal static func x25519PublicKey(using dependencies: Dependencies) throws -> String { + let edPublicKey: String = edPublicKey(using: dependencies) + let x25519Pubkey: [UInt8] = try dependencies[singleton: .crypto].tryGenerate( + .x25519(ed25519Pubkey: Array(Data(hex: edPublicKey))) ) + + return x25519Pubkey.toHexString() } - public static func downloadUrlString(for url: String, fileId: String) -> String { - switch url.contains(legacyFileServer) { - case true: return "\(fileServer)/\(Endpoint.fileIndividual(fileId).path)" - default: return downloadUrlString(for: fileId) + internal static func x25519PublicKey(for url: URL, using dependencies: Dependencies) throws -> String { + let edPublicKey: String = (url.fragmentParameters[.publicKey] ?? defaultEdPublicKey) + + guard Hex.isValid(edPublicKey) && edPublicKey.count == 64 else { + throw CryptoError.invalidPublicKey } + + let x25519Pubkey: [UInt8] = try dependencies[singleton: .crypto].tryGenerate( + .x25519(ed25519Pubkey: Array(Data(hex: edPublicKey))) + ) + + return x25519Pubkey.toHexString() } - public static func downloadUrlString(for fileId: String) -> String { - return "\(fileServer)/\(Endpoint.fileIndividual(fileId).path)" + public static func downloadUrlString( + for fileId: String, + using dependencies: Dependencies + ) -> String { + var fragments: [HTTPFragmentParam: String] = [:] + let edPublicKey: String = edPublicKey(using: dependencies) + + if dependencies[feature: .deterministicAttachmentEncryption] { + fragments[.deterministicEncryption] = "" /// No value needed + } + + if edPublicKey != defaultEdPublicKey { + fragments[.publicKey] = edPublicKey + } + + let baseUrl: String = [ + server(using: dependencies), + Endpoint.fileIndividual(fileId).path + ].joined(separator: "/") + + return [baseUrl, HTTPFragmentParam.string(for: fragments)] + .filter { !$0.isEmpty } + .joined(separator: "#") } public static func fileId(for downloadUrl: String?) -> String? { @@ -46,47 +83,12 @@ public extension Network { .map { String($0) } } } - } - - static func preparedUpload( - data: Data, - requestAndPathBuildTimeout: TimeInterval? = nil, - using dependencies: Dependencies - ) throws -> PreparedRequest { - return try PreparedRequest( - request: Request( - endpoint: FileServer.Endpoint.file, - destination: .serverUpload( - server: FileServer.fileServer, - x25519PublicKey: FileServer.fileServerPublicKey, - fileName: nil - ), - body: data - ), - responseType: FileUploadResponse.self, - requestTimeout: Network.fileUploadTimeout, - requestAndPathBuildTimeout: requestAndPathBuildTimeout, - using: dependencies - ) - } - - static func preparedDownload( - url: URL, - using dependencies: Dependencies - ) throws -> PreparedRequest { - return try PreparedRequest( - request: Request( - endpoint: FileServer.Endpoint.directUrl(url), - destination: .serverDownload( - url: url, - x25519PublicKey: FileServer.fileServerPublicKey, - fileName: nil - ) - ), - responseType: Data.self, - requestTimeout: Network.fileUploadTimeout, - using: dependencies - ) + + public static func usesDeterministicEncryption(_ downloadUrl: String?) -> Bool { + return (downloadUrl + .map { URL(string: $0) }? + .fragmentParameters[.deterministicEncryption] != nil) + } } } diff --git a/SessionNetworkingKit/FileServer/FileServerAPI.swift b/SessionNetworkingKit/FileServer/FileServerAPI.swift index d94c006588..80418daeca 100644 --- a/SessionNetworkingKit/FileServer/FileServerAPI.swift +++ b/SessionNetworkingKit/FileServer/FileServerAPI.swift @@ -1,4 +1,6 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation import SessionUtilitiesKit @@ -15,16 +17,16 @@ public extension Network.FileServer { var headers: [HTTPHeader: String] = [:] if dependencies[feature: .shortenFileTTL] { - headers = [.fileCustomTTL : "60"] + headers = [.fileCustomTTL: "60"] } return try Network.PreparedRequest( request: Request( endpoint: .file, destination: .serverUpload( - server: FileServer.fileServer, + server: FileServer.server(using: dependencies), headers: headers, - x25519PublicKey: FileServer.fileServerPublicKey, + x25519PublicKey: FileServer.x25519PublicKey(using: dependencies), fileName: nil ), body: data @@ -38,16 +40,16 @@ public extension Network.FileServer { static func preparedDownload( url: URL, - serverPubkey: String, using dependencies: Dependencies ) throws -> Network.PreparedRequest { + let strippedUrl: URL = try url.strippingQueryAndFragment ?? { throw NetworkError.invalidURL }() + return try Network.PreparedRequest( request: Request( - endpoint: .directUrl(url), + endpoint: .directUrl(strippedUrl), destination: .serverDownload( - url: url, - queryParameters: url.queryParameters, - x25519PublicKey: serverPubkey, + url: strippedUrl, + x25519PublicKey: FileServer.x25519PublicKey(for: url, using: dependencies), fileName: nil ) ), @@ -57,43 +59,24 @@ public extension Network.FileServer { ) } - static func preparedExtend( - fileId: String, - ttl: TimeInterval, - using dependencies: Dependencies - ) throws -> Network.PreparedRequest { - return try Network.PreparedRequest( - request: Request( - endpoint: .extend(fileId), - destination: .server( - method: .post, - server: FileServer.fileServer, - headers: [.fileCustomTTL: "\(ttl)"], - x25519PublicKey: FileServer.fileServerPublicKey - ) - ), - responseType: FileUploadResponse.self, - using: dependencies - ) - } - static func preparedExtend( url: URL, ttl: TimeInterval, - serverPubkey: String, using dependencies: Dependencies - ) throws -> Network.PreparedRequest { + ) throws -> Network.PreparedRequest { + let strippedUrl: URL = try url.strippingQueryAndFragment ?? { throw NetworkError.invalidURL }() + return try Network.PreparedRequest( request: Request( - endpoint: .extendUrl(url), + endpoint: .extendUrl(strippedUrl), destination: .server( method: .post, - url: url, - headers: [.fileCustomTTL: "\(ttl)"], - x25519PublicKey: serverPubkey + url: strippedUrl, + headers: [.fileCustomTTL: "\(Int(floor(ttl)))"], + x25519PublicKey: FileServer.x25519PublicKey(for: url, using: dependencies) ) ), - responseType: FileUploadResponse.self, + responseType: ExtendExpirationResponse.self, using: dependencies ) } diff --git a/SessionNetworkingKit/FileServer/Models/ExtendExpirationResponse.swift b/SessionNetworkingKit/FileServer/Models/ExtendExpirationResponse.swift new file mode 100644 index 0000000000..76f11cd75f --- /dev/null +++ b/SessionNetworkingKit/FileServer/Models/ExtendExpirationResponse.swift @@ -0,0 +1,17 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension Network.FileServer { + struct ExtendExpirationResponse: Codable { + public let size: Int + public let uploaded: TimeInterval + public let expires: TimeInterval + + public init(size: Int, uploaded: TimeInterval, expires: TimeInterval) { + self.size = size + self.uploaded = uploaded + self.expires = expires + } + } +} diff --git a/SessionNetworkingKit/FileServer/Types/HTTPFragmentParam+FileServer.swift b/SessionNetworkingKit/FileServer/Types/HTTPFragmentParam+FileServer.swift new file mode 100644 index 0000000000..2c35f4348d --- /dev/null +++ b/SessionNetworkingKit/FileServer/Types/HTTPFragmentParam+FileServer.swift @@ -0,0 +1,8 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension HTTPFragmentParam { + static let publicKey: HTTPFragmentParam = "p" + static let deterministicEncryption: HTTPFragmentParam = "d" +} diff --git a/SessionNetworkingKit/LibSession/LibSession+Networking.swift b/SessionNetworkingKit/LibSession/LibSession+Networking.swift index b62547b0fb..7b23befdfb 100644 --- a/SessionNetworkingKit/LibSession/LibSession+Networking.swift +++ b/SessionNetworkingKit/LibSession/LibSession+Networking.swift @@ -582,9 +582,10 @@ private extension Network.Destination.ServerInfo { } let targetScheme: String = (self.scheme ?? "https") - let pathWithParams: String = Network.Destination.generatePathWithParams( + let pathWithParams: String = Network.Destination.generatePathWithParamsAndFragments( endpoint: endpoint, - queryParameters: queryParameters + queryParameters: queryParameters, + fragmentParameters: fragmentParameters, ) let port: UInt16 = UInt16(self.port ?? (targetScheme == "https" ? 443 : 80)) let headerKeys: [String] = headers.map { $0.key } diff --git a/SessionNetworkingKit/PushNotification/Types/Request+PushNotificationAPI.swift b/SessionNetworkingKit/PushNotification/Types/Request+PushNotificationAPI.swift index 480c90bf65..ad4abeabc2 100644 --- a/SessionNetworkingKit/PushNotification/Types/Request+PushNotificationAPI.swift +++ b/SessionNetworkingKit/PushNotification/Types/Request+PushNotificationAPI.swift @@ -8,6 +8,7 @@ public extension Request where Endpoint == Network.PushNotification.Endpoint { method: HTTPMethod, endpoint: Endpoint, queryParameters: [HTTPQueryParam: String] = [:], + fragmentParameters: [HTTPFragmentParam: String] = [:], headers: [HTTPHeader: String] = [:], body: T? = nil ) throws { @@ -17,6 +18,7 @@ public extension Request where Endpoint == Network.PushNotification.Endpoint { method: method, server: Network.PushNotification.server, queryParameters: queryParameters, + fragmentParameters: fragmentParameters, headers: headers, x25519PublicKey: Network.PushNotification.serverPublicKey ), diff --git a/SessionNetworkingKit/SOGS/SOGS.swift b/SessionNetworkingKit/SOGS/SOGS.swift index 0c5e5ce75e..80299ad743 100644 --- a/SessionNetworkingKit/SOGS/SOGS.swift +++ b/SessionNetworkingKit/SOGS/SOGS.swift @@ -1,4 +1,6 @@ // Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation diff --git a/SessionNetworkingKit/SOGS/SOGSAPI.swift b/SessionNetworkingKit/SOGS/SOGSAPI.swift index 642fe362c4..4700b36d80 100644 --- a/SessionNetworkingKit/SOGS/SOGSAPI.swift +++ b/SessionNetworkingKit/SOGS/SOGSAPI.swift @@ -1393,6 +1393,7 @@ public extension Network.SOGS { method: info.method, server: info.server, queryParameters: info.queryParameters, + fragmentParameters: info.fragmentParameters, headers: info.headers.updated(with: signatureHeaders), x25519PublicKey: info.x25519PublicKey ) @@ -1404,6 +1405,7 @@ public extension Network.SOGS { method: info.method, server: info.server, queryParameters: info.queryParameters, + fragmentParameters: info.fragmentParameters, headers: info.headers.updated(with: signatureHeaders), x25519PublicKey: info.x25519PublicKey ), @@ -1416,6 +1418,7 @@ public extension Network.SOGS { method: info.method, server: info.server, queryParameters: info.queryParameters, + fragmentParameters: info.fragmentParameters, headers: info.headers.updated(with: signatureHeaders), x25519PublicKey: info.x25519PublicKey ) diff --git a/SessionNetworkingKit/SOGS/Types/Request+SOGS.swift b/SessionNetworkingKit/SOGS/Types/Request+SOGS.swift index 5373174584..607c494570 100644 --- a/SessionNetworkingKit/SOGS/Types/Request+SOGS.swift +++ b/SessionNetworkingKit/SOGS/Types/Request+SOGS.swift @@ -8,6 +8,7 @@ public extension Request where Endpoint == Network.SOGS.Endpoint { method: HTTPMethod = .get, endpoint: Endpoint, queryParameters: [HTTPQueryParam: String] = [:], + fragmentParameters: [HTTPFragmentParam: String] = [:], headers: [HTTPHeader: String] = [:], body: T? = nil, authMethod: AuthenticationMethod @@ -22,6 +23,7 @@ public extension Request where Endpoint == Network.SOGS.Endpoint { method: method, server: server, queryParameters: queryParameters, + fragmentParameters: fragmentParameters, headers: headers, x25519PublicKey: publicKey ), diff --git a/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift b/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift index 8c24c622d2..962d1bd99f 100644 --- a/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift +++ b/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift @@ -23,6 +23,7 @@ public extension Network.SessionNetwork { method: .get, server: Network.SessionNetwork.networkAPIServer, queryParameters: [:], + fragmentParameters: [:], x25519PublicKey: Network.SessionNetwork.networkAPIServerPublicKey ) ), @@ -110,6 +111,7 @@ public extension Network.SessionNetwork { method: info.method, server: info.server, queryParameters: info.queryParameters, + fragmentParameters: info.fragmentParameters, headers: info.headers.updated( with: try signatureHeaders( url: url, diff --git a/SessionNetworkingKit/Types/Destination.swift b/SessionNetworkingKit/Types/Destination.swift index f694ef1a58..3251852657 100644 --- a/SessionNetworkingKit/Types/Destination.swift +++ b/SessionNetworkingKit/Types/Destination.swift @@ -11,6 +11,7 @@ public extension Network { public let method: HTTPMethod public let server: String public let queryParameters: [HTTPQueryParam: String] + public let fragmentParameters: [HTTPFragmentParam: String] public let headers: [HTTPHeader: String] public let x25519PublicKey: String @@ -26,12 +27,14 @@ public extension Network { method: HTTPMethod, server: String, queryParameters: [HTTPQueryParam: String], + fragmentParameters: [HTTPFragmentParam: String], headers: [HTTPHeader: String], x25519PublicKey: String ) { self.method = method self.server = server self.queryParameters = queryParameters + self.fragmentParameters = fragmentParameters self.headers = headers self.x25519PublicKey = x25519PublicKey } @@ -41,6 +44,7 @@ public extension Network { url: URL, server: String?, queryParameters: [HTTPQueryParam: String], + fragmentParameters: [HTTPFragmentParam: String], headers: [HTTPHeader: String], x25519PublicKey: String ) throws { @@ -54,6 +58,7 @@ public extension Network { throw NetworkError.invalidURL }() self.queryParameters = queryParameters + self.fragmentParameters = fragmentParameters self.headers = headers self.x25519PublicKey = x25519PublicKey } @@ -106,10 +111,20 @@ public extension Network { } } + public var fragmentParameters: [HTTPFragmentParam: String] { + switch self { + case .server(let info), .serverUpload(let info, _), .serverDownload(let info): + return info.fragmentParameters + + default: return [:] + } + } + public static func server( method: HTTPMethod = .get, server: String, queryParameters: [HTTPQueryParam: String] = [:], + fragmentParameters: [HTTPFragmentParam: String] = [:], headers: [HTTPHeader: String] = [:], x25519PublicKey: String ) throws -> Destination { @@ -117,6 +132,7 @@ public extension Network { method: method, server: server, queryParameters: queryParameters, + fragmentParameters: fragmentParameters, headers: headers, x25519PublicKey: x25519PublicKey )) @@ -126,6 +142,7 @@ public extension Network { method: HTTPMethod = .get, url: URL, queryParameters: [HTTPQueryParam: String] = [:], + fragmentParameters: [HTTPFragmentParam: String] = [:], headers: [HTTPHeader: String] = [:], x25519PublicKey: String ) throws -> Destination { @@ -134,6 +151,7 @@ public extension Network { url: url, server: nil, queryParameters: queryParameters, + fragmentParameters: fragmentParameters, headers: headers, x25519PublicKey: x25519PublicKey )) @@ -142,6 +160,7 @@ public extension Network { public static func serverUpload( server: String, queryParameters: [HTTPQueryParam: String] = [:], + fragmentParameters: [HTTPFragmentParam: String] = [:], headers: [HTTPHeader: String] = [:], x25519PublicKey: String, fileName: String? @@ -151,6 +170,7 @@ public extension Network { method: .post, server: server, queryParameters: queryParameters, + fragmentParameters: fragmentParameters, headers: headers, x25519PublicKey: x25519PublicKey ), @@ -161,6 +181,7 @@ public extension Network { public static func serverDownload( url: URL, queryParameters: [HTTPQueryParam: String] = [:], + fragmentParameters: [HTTPFragmentParam: String] = [:], headers: [HTTPHeader: String] = [:], x25519PublicKey: String, fileName: String? @@ -170,6 +191,7 @@ public extension Network { url: url, server: nil, queryParameters: queryParameters, + fragmentParameters: fragmentParameters, headers: headers, x25519PublicKey: x25519PublicKey )) @@ -196,16 +218,25 @@ public extension Network { // MARK: - Convenience - internal static func generatePathWithParams(endpoint: E, queryParameters: [HTTPQueryParam: String]) -> String { - return [ + internal static func generatePathWithParamsAndFragments( + endpoint: E, + queryParameters: [HTTPQueryParam: String], + fragmentParameters: [HTTPFragmentParam: String] + ) -> String { + let pathWithParams: String = [ "/\(endpoint.path)", - queryParameters - .map { key, value in "\(key)=\(value)" } - .joined(separator: "&") + HTTPQueryParam.string(for: queryParameters) ] - .compactMap { $0 } .filter { !$0.isEmpty } .joined(separator: "?") + + + return [ + pathWithParams, + HTTPFragmentParam.string(for: fragmentParameters) + ] + .filter { !$0.isEmpty } + .joined(separator: "#") } // MARK: - Equatable diff --git a/SessionNetworkingKit/Types/HTTPFragmentParam.swift b/SessionNetworkingKit/Types/HTTPFragmentParam.swift new file mode 100644 index 0000000000..aa960e2a6f --- /dev/null +++ b/SessionNetworkingKit/Types/HTTPFragmentParam.swift @@ -0,0 +1,22 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public struct HTTPFragmentParam: RawRepresentable, ExpressibleByStringLiteral, Hashable { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.init(rawValue) } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } +} + +public extension HTTPFragmentParam { + static func string(for fragments: [HTTPFragmentParam: String]) -> String { + /// The clients are set up to handle keys with no values so exclude them since they would just waste characters + return fragments + .map { key, value in "\(key.rawValue)\(!value.isEmpty ? "=\(value)" : "")" } + .joined(separator: "&") + } +} diff --git a/SessionNetworkingKit/Types/HTTPQueryParam.swift b/SessionNetworkingKit/Types/HTTPQueryParam.swift index a766bf1edc..77853d002c 100644 --- a/SessionNetworkingKit/Types/HTTPQueryParam.swift +++ b/SessionNetworkingKit/Types/HTTPQueryParam.swift @@ -2,4 +2,20 @@ import Foundation -public typealias HTTPQueryParam = String +public struct HTTPQueryParam: RawRepresentable, ExpressibleByStringLiteral, Hashable { + public let rawValue: String + + public init(_ rawValue: String) { self.rawValue = rawValue } + public init?(rawValue: String) { self.init(rawValue) } + public init(stringLiteral value: String) { self.init(value) } + public init(unicodeScalarLiteral value: String) { self.init(value) } + public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } +} + +public extension HTTPQueryParam { + static func string(for parameters: [HTTPQueryParam: String]) -> String { + return parameters + .map { key, value in "\(key.rawValue)=\(value)" } + .joined(separator: "&") + } +} diff --git a/SessionNetworkingKit/Types/PreparedRequest.swift b/SessionNetworkingKit/Types/PreparedRequest.swift index 1e94d94b8e..0a5e005b33 100644 --- a/SessionNetworkingKit/Types/PreparedRequest.swift +++ b/SessionNetworkingKit/Types/PreparedRequest.swift @@ -230,9 +230,10 @@ public extension Network { self.method = request.destination.method self.endpoint = request.endpoint self.endpointName = E.name - self.path = Destination.generatePathWithParams( + self.path = Destination.generatePathWithParamsAndFragments( endpoint: endpoint, - queryParameters: request.destination.queryParameters + queryParameters: request.destination.queryParameters, + fragmentParameters: request.destination.fragmentParameters ) self.headers = request.destination.headers @@ -340,9 +341,10 @@ public extension Network { public func generateUrl() throws -> URL { switch destination { case .server(let info), .serverUpload(let info, _), .serverDownload(let info): - let pathWithParams: String = Destination.generatePathWithParams( + let pathWithParams: String = Destination.generatePathWithParamsAndFragments( endpoint: endpoint, - queryParameters: info.queryParameters + queryParameters: info.queryParameters, + fragmentParameters: info.fragmentParameters ) guard let url: URL = URL(string: "\(info.server)\(pathWithParams)") else { diff --git a/SessionNetworkingKit/Utilities/URL+Utilities.swift b/SessionNetworkingKit/Utilities/URL+Utilities.swift index cf8d30bad1..2baca71ef4 100644 --- a/SessionNetworkingKit/Utilities/URL+Utilities.swift +++ b/SessionNetworkingKit/Utilities/URL+Utilities.swift @@ -3,6 +3,16 @@ import Foundation public extension URL { + var strippingQueryAndFragment: URL? { + guard var components: URLComponents = URLComponents(url: self, resolvingAgainstBaseURL: false) else { + return nil + } + components.queryItems = nil + components.fragment = nil + + return components.url + } + var queryParameters: [HTTPQueryParam: String] { guard let components: URLComponents = URLComponents(url: self, resolvingAgainstBaseURL: false), @@ -10,17 +20,21 @@ public extension URL { else { return [:] } return queryItems.reduce(into: [:]) { result, next in - result[next.name] = next.value + result[HTTPQueryParam(next.name)] = (next.value ?? "") } } - var fragmentParameters: [String: String] { + var fragmentParameters: [HTTPFragmentParam: String] { guard let fragment = self.fragment else { return [:] } // Parse fragment as if it were a query string var components: URLComponents = URLComponents() components.query = fragment - return (components.queryItems?.reduce(into: [:]) { $0[$1.name] = $1.value } ?? [:]) + guard let queryItems: [URLQueryItem] = components.queryItems else { return [:] } + + return queryItems.reduce(into: [:]) { result, next in + result[HTTPFragmentParam(next.name)] = (next.value ?? "") + } } } diff --git a/SessionNetworkingKitTests/Types/BatchRequestSpec.swift b/SessionNetworkingKitTests/Types/BatchRequestSpec.swift index 05554a2be1..e7cd9a9c75 100644 --- a/SessionNetworkingKitTests/Types/BatchRequestSpec.swift +++ b/SessionNetworkingKitTests/Types/BatchRequestSpec.swift @@ -27,6 +27,7 @@ class BatchRequestSpec: QuickSpec { destination: try! .server( server: "testServer", queryParameters: [:], + fragmentParameters: [:], headers: [ "TestCustomHeader": "TestCustom", HTTPHeader.testHeader: "Test" @@ -63,6 +64,7 @@ class BatchRequestSpec: QuickSpec { destination: try! .server( server: "testServer", queryParameters: [:], + fragmentParameters: [:], headers: [ "TestCustomHeader": "TestCustom", HTTPHeader.testHeader: "Test" @@ -102,6 +104,7 @@ class BatchRequestSpec: QuickSpec { destination: try! .server( server: "testServer", queryParameters: [:], + fragmentParameters: [:], headers: [:], x25519PublicKey: "05\(TestConstants.publicKey)" ), @@ -132,6 +135,7 @@ class BatchRequestSpec: QuickSpec { destination: try! .server( server: "testServer", queryParameters: [:], + fragmentParameters: [:], headers: [:], x25519PublicKey: "05\(TestConstants.publicKey)" ), @@ -162,6 +166,7 @@ class BatchRequestSpec: QuickSpec { destination: try! .server( server: "testServer", queryParameters: [:], + fragmentParameters: [:], headers: [:], x25519PublicKey: "05\(TestConstants.publicKey)" ), @@ -196,6 +201,7 @@ class BatchRequestSpec: QuickSpec { destination: try! .server( server: "testServer", queryParameters: [:], + fragmentParameters: [:], headers: [:], x25519PublicKey: "05\(TestConstants.publicKey)" ), @@ -227,6 +233,7 @@ class BatchRequestSpec: QuickSpec { destination: try! .server( server: "testServer", queryParameters: [:], + fragmentParameters: [:], headers: [:], x25519PublicKey: "05\(TestConstants.publicKey)" ), @@ -258,6 +265,7 @@ class BatchRequestSpec: QuickSpec { destination: try! .server( server: "testServer", queryParameters: [:], + fragmentParameters: [:], headers: [:], x25519PublicKey: "05\(TestConstants.publicKey)" ), diff --git a/SessionNetworkingKitTests/Types/DestinationSpec.swift b/SessionNetworkingKitTests/Types/DestinationSpec.swift index e226a25f21..2c228cbdb5 100644 --- a/SessionNetworkingKitTests/Types/DestinationSpec.swift +++ b/SessionNetworkingKitTests/Types/DestinationSpec.swift @@ -15,19 +15,21 @@ class DestinationSpec: QuickSpec { context("when generating a path") { // MARK: ---- adds a leading forward slash to the endpoint path it("adds a leading forward slash to the endpoint path") { - let result: String = Network.Destination.generatePathsAndParams( + let result: String = Network.Destination.generatePathWithParamsAndFragments( endpoint: TestEndpoint.test1, - queryParameters: [:] + queryParameters: [:], + fragmentParameters: [:] ) expect(result).to(equal("/test1")) } - // MARK: ---- creates a valid URL with no query parameters - it("creates a valid URL with no query parameters") { - let result: String = Network.Destination.generatePathsAndParams( + // MARK: ---- creates a valid URL with no query parameters or fragments + it("creates a valid URL with no query parameters or fragments") { + let result: String = Network.Destination.generatePathWithParamsAndFragments( endpoint: TestEndpoint.test1, - queryParameters: [:] + queryParameters: [:], + fragmentParameters: [:] ) expect(result).to(equal("/test1")) @@ -35,15 +37,44 @@ class DestinationSpec: QuickSpec { // MARK: ---- creates a valid URL when query parameters are provided it("creates a valid URL when query parameters are provided") { - let result: String = Network.Destination.generatePathsAndParams( + let result: String = Network.Destination.generatePathWithParamsAndFragments( endpoint: TestEndpoint.test1, queryParameters: [ .testParam: "123" - ] + ], + fragmentParameters: [:] ) expect(result).to(equal("/test1?testParam=123")) } + + // MARK: ---- creates a valid URL when fragment parameters are provided + it("creates a valid URL when fragment parameters are provided") { + let result: String = Network.Destination.generatePathWithParamsAndFragments( + endpoint: TestEndpoint.test1, + queryParameters: [:], + fragmentParameters: [ + .testFrag: "456" + ] + ) + + expect(result).to(equal("/test1#testFrag=456")) + } + + // MARK: ---- creates a valid URL when both query and fragment parameters are provided + it("creates a valid URL when both query and fragment parameters are provided") { + let result: String = Network.Destination.generatePathWithParamsAndFragments( + endpoint: TestEndpoint.test1, + queryParameters: [ + .testParam: "123" + ], + fragmentParameters: [ + .testFrag: "456" + ] + ) + + expect(result).to(equal("/test1?testParam=123#testFrag=456")) + } } // MARK: -- for a server @@ -69,6 +100,10 @@ fileprivate extension HTTPQueryParam { static let testParam: HTTPQueryParam = "testParam" } +fileprivate extension HTTPFragmentParam { + static let testFrag: HTTPFragmentParam = "testFrag" +} + fileprivate enum TestEndpoint: EndpointType { case test1 case testParams(String, Int) diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index 5b56ec8693..32e13bcd49 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -644,12 +644,16 @@ private struct SAESNUIKitConfig: SNUIKit.ConfigType { return dependencies[feature: .showStringKeys] } - func asset(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, cleanup: () -> Void)? { - return AVURLAsset.asset( - for: path, - utType: utType, - sourceFilename: sourceFilename, - using: dependencies - ) + func assetInfo(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, isValidVideo: Bool, cleanup: () -> Void)? { + guard + let result: (asset: AVURLAsset, cleanup: () -> Void) = AVURLAsset.asset( + for: path, + utType: utType, + sourceFilename: sourceFilename, + using: dependencies + ) + else { return nil } + + return (result.asset, MediaUtils.isValidVideo(asset: result.asset), result.cleanup) } } diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index a4447c4553..f7b1772884 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -375,7 +375,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView using: dependencies ) let attachmentState: MessageSendJob.AttachmentState = try MessageSendJob - .fetchAttachmentState(db, interactionId: interactionId) + .fetchAttachmentState(db, interactionId: interactionId, using: dependencies) let preparedUploads: [AttachmentUploadJob.PreparedUpload] = try Attachment .filter(ids: attachmentState.allAttachmentIds) .fetchAll(db) diff --git a/SessionUIKit/Configuration.swift b/SessionUIKit/Configuration.swift index 32ed35f0ee..856a8bbfa7 100644 --- a/SessionUIKit/Configuration.swift +++ b/SessionUIKit/Configuration.swift @@ -18,7 +18,7 @@ public actor SNUIKit { func cacheContextualActionInfo(tableViewHash: Int, sideKey: String, actionIndex: Int, actionInfo: Any) func removeCachedContextualActionInfo(tableViewHash: Int, keys: [String]) func shouldShowStringKeys() -> Bool - func asset(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, cleanup: () -> Void)? + func assetInfo(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, isValidVideo: Bool, cleanup: () -> Void)? } @MainActor public static var mainWindow: UIWindow? = nil @@ -68,9 +68,9 @@ public actor SNUIKit { return config.shouldShowStringKeys() } - internal static func asset(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, cleanup: () -> Void)? { + internal static func assetInfo(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, isValidVideo: Bool, cleanup: () -> Void)? { guard let config: ConfigType = self.config else { return nil } - return config.asset(for: path, utType: utType, sourceFilename: sourceFilename) + return config.assetInfo(for: path, utType: utType, sourceFilename: sourceFilename) } } diff --git a/SessionUIKit/Types/ImageDataManager.swift b/SessionUIKit/Types/ImageDataManager.swift index 7167dd0aea..aef3cff584 100644 --- a/SessionUIKit/Types/ImageDataManager.swift +++ b/SessionUIKit/Types/ImageDataManager.swift @@ -125,19 +125,19 @@ public actor ImageDataManager: ImageDataManagerType { } /// Otherwise we need to generate a new one - let assetInfo: (asset: AVURLAsset, cleanup: () -> Void)? = SNUIKit.asset( - for: url.path, - utType: utType, - sourceFilename: sourceFilename - ) - guard - let asset: AVURLAsset = assetInfo?.asset, - asset.isValidVideo + let assetInfo: (asset: AVURLAsset, isValidVideo: Bool, cleanup: () -> Void) = SNUIKit.assetInfo( + for: url.path, + utType: utType, + sourceFilename: sourceFilename + ) else { return nil } + defer { assetInfo.cleanup() } + + guard assetInfo.isValidVideo else { return nil } let time: CMTime = CMTimeMake(value: 1, timescale: 60) - let generator: AVAssetImageGenerator = AVAssetImageGenerator(asset: asset) + let generator: AVAssetImageGenerator = AVAssetImageGenerator(asset: assetInfo.asset) generator.appliesPreferredTrackTransform = true guard @@ -152,7 +152,6 @@ public actor ImageDataManager: ImageDataManagerType { let processedData: ProcessedImageData = ProcessedImageData( type: .staticImage(decodedImage) ) - assetInfo?.cleanup() /// Since we generated a new thumbnail we should save it to disk saveThumbnailToDisk( @@ -795,25 +794,6 @@ public extension ImageDataManager { /// Needed for `actor` usage (ie. assume safe access) extension UIImage: @unchecked Sendable {} -extension AVAsset { - var isValidVideo: Bool { - var maxTrackSize = CGSize.zero - - for track: AVAssetTrack in tracks(withMediaType: .video) { - let trackSize: CGSize = track.naturalSize - maxTrackSize.width = max(maxTrackSize.width, trackSize.width) - maxTrackSize.height = max(maxTrackSize.height, trackSize.height) - } - - return ( - maxTrackSize.width >= 1 && - maxTrackSize.height >= 1 && - maxTrackSize.width < (3 * 1024) && - maxTrackSize.height < (3 * 1024) - ) - } -} - public extension ImageDataManager.DataSource { /// We need to ensure that the image size is "reasonable", otherwise trying to load it could cause out-of-memory crashes static let maxValidDimension: Int = 1 << 18 // 262,144 pixels diff --git a/SessionUtilitiesKit/Crypto/CryptoError.swift b/SessionUtilitiesKit/Crypto/CryptoError.swift index 9ed1bfe208..c5746dbfe3 100644 --- a/SessionUtilitiesKit/Crypto/CryptoError.swift +++ b/SessionUtilitiesKit/Crypto/CryptoError.swift @@ -4,6 +4,7 @@ import Foundation public enum CryptoError: Error { case invalidSeed + case invalidPublicKey case keyGenerationFailed case randomGenerationFailed case signatureGenerationFailed diff --git a/SessionUtilitiesKit/Media/MediaUtils.swift b/SessionUtilitiesKit/Media/MediaUtils.swift index 9b02ca8975..dfc2a9ce9d 100644 --- a/SessionUtilitiesKit/Media/MediaUtils.swift +++ b/SessionUtilitiesKit/Media/MediaUtils.swift @@ -67,6 +67,9 @@ public enum MediaUtils { /// The number of frames this media has (`1` for a static image) public let frameCount: Int + /// The duration of each frame (this will be an empty array for anything other than animated images) + public let frameDurations: [TimeInterval] + /// The duration of the content (will be `0` for static images) public let duration: TimeInterval @@ -102,9 +105,6 @@ public enum MediaUtils { ) } - /// A flag indicating whether the media has a valid file size (ie. the max file size that an attachment can be) - public var hasValidFileSize: Bool { fileSize <= SNUtilitiesKit.maxFileSize } - /// A flag indicating whether the media has a valid duration for it's type public var hasValidDuration: Bool { if utType?.isAudio == true || utType?.isVideo == true { @@ -147,13 +147,12 @@ public enum MediaUtils { self.pixelSize = CGSize(width: width, height: height) self.fileSize = fileSize self.frameCount = count - self.duration = { - guard count > 1 else { return 0 } - - return (0.. 1 else { return [] } + + return (0.. = Set(properties.keys) @@ -217,6 +216,7 @@ public enum MediaUtils { self.pixelSize = pixelSize self.fileSize = fileSize self.frameCount = 1 + self.frameDurations = [] self.duration = 0 self.hasUnsafeMetadata = hasUnsafeMetadata self.depthBytes = depthBytes @@ -232,6 +232,7 @@ public enum MediaUtils { self.pixelSize = image.size self.fileSize = 0 /// Unknown for `UIImage` in memory self.frameCount = 1 + self.frameDurations = [] self.duration = 0 self.hasUnsafeMetadata = false /// `UIImage` in memory has no file metadata self.depthBytes = { @@ -282,20 +283,14 @@ public enum MediaUtils { else { return nil } /// Get the maximum size of any video track in the file - var maxTrackSize: CGSize = .zero - - for track: AVAssetTrack in asset.tracks(withMediaType: .video) { - let trackSize: CGSize = track.naturalSize - let transformedSize: CGSize = trackSize.applying(track.preferredTransform) - maxTrackSize.width = max(maxTrackSize.width, abs(transformedSize.width)) - maxTrackSize.height = max(maxTrackSize.height, abs(transformedSize.height)) - } + var maxTrackSize: CGSize = asset.maxVideoTrackSize guard maxTrackSize.width > 0, maxTrackSize.height > 0 else { return nil } self.pixelSize = maxTrackSize self.fileSize = fileSize self.frameCount = -1 /// Rather than try to extract the frames, or give it an "incorrect" value, make it explicitly invalid + self.frameDurations = [] self.duration = ( /// According to the CMTime docs "value/timescale = seconds" TimeInterval(asset.duration.value) / TimeInterval(asset.duration.timescale) ) @@ -317,6 +312,7 @@ public enum MediaUtils { self.pixelSize = .zero self.fileSize = fileSize self.frameCount = -1 + self.frameDurations = [] do { self.duration = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path)).duration } catch { return nil } @@ -346,33 +342,11 @@ public enum MediaUtils { } } - public static func isVideoOfValidContentTypeAndSize(path: String, type: String?, using dependencies: Dependencies) -> Bool { - guard dependencies[singleton: .fileManager].fileExists(atPath: path) else { - Log.error(.media, "Media file missing.") - return false - } - guard let type: String = type, UTType.isVideo(type) else { - Log.error(.media, "Media file has invalid content type.") - return false - } - - guard let fileSize: UInt64 = dependencies[singleton: .fileManager].fileSize(of: path) else { - Log.error(.media, "Media file has unknown length.") - return false - } - return UInt(fileSize) <= SNUtilitiesKit.maxFileSize - } - public static func isValidVideo(asset: AVURLAsset) -> Bool { - var maxTrackSize = CGSize.zero - - for track: AVAssetTrack in asset.tracks(withMediaType: .video) { - let trackSize: CGSize = track.naturalSize - maxTrackSize.width = max(maxTrackSize.width, trackSize.width) - maxTrackSize.height = max(maxTrackSize.height, trackSize.height) - } - - return MediaMetadata(pixelSize: maxTrackSize, hasUnsafeMetadata: false).hasValidPixelSize + return MediaMetadata( + pixelSize: asset.maxVideoTrackSize, + hasUnsafeMetadata: false + ).hasValidPixelSize } /// Use `isValidVideo(asset: AVURLAsset)` if the `AVURLAsset` needs to be generated elsewhere in the code, @@ -465,11 +439,7 @@ public extension MediaUtils.MediaMetadata { (utType.isImage || utType.isAnimated) else { return false } - return ( - hasValidPixelSize && - hasValidFileSize && - hasValidDuration - ) + return (hasValidPixelSize && hasValidDuration) } } diff --git a/SessionUtilitiesKit/Media/UTType+Utilities.swift b/SessionUtilitiesKit/Media/UTType+Utilities.swift index 9cab6f3258..1093ebaeb1 100644 --- a/SessionUtilitiesKit/Media/UTType+Utilities.swift +++ b/SessionUtilitiesKit/Media/UTType+Utilities.swift @@ -100,10 +100,10 @@ public extension UTType { ].compactMap { $0 }.asSet() var isAnimated: Bool { UTType.supportedAnimatedImageTypes.contains(self) } - var isImage: Bool { UTType.supportedImageTypes.contains(self) } - var isVideo: Bool { UTType.supportedVideoTypes.contains(self) } - var isAudio: Bool { UTType.supportedAudioTypes.contains(self) } - var isText: Bool { UTType.supportedTextTypes.contains(self) } + var isImage: Bool { conforms(to: .image) } + var isVideo: Bool { conforms(to: .video) } + var isAudio: Bool { conforms(to: .audio) } + var isText: Bool { conforms(to: .text) } var isMicrosoftDoc: Bool { UTType.supportedMicrosoftDocTypes.contains(self) } var isVisualMedia: Bool { isImage || isVideo || isAnimated } var sessionMimeType: String? { diff --git a/SessionUtilitiesKit/Types/StringCache.swift b/SessionUtilitiesKit/Types/StringCache.swift new file mode 100644 index 0000000000..eddc62a92a --- /dev/null +++ b/SessionUtilitiesKit/Types/StringCache.swift @@ -0,0 +1,47 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public final class StringCache: @unchecked Sendable { + /// `NSCache` has more nuanced memory management systems than just listening for `didReceiveMemoryWarningNotification` + /// and can clear out values gradually, it can also remove items based on their "cost" so is better suited than our custom `LRUCache` + /// + /// Additionally `NSCache` is thread safe so we don't need to do any custom `ThreadSafeObject` work to interact with it + private let cache: NSCache = NSCache() + + public init( + name: String? = nil, + countLimit: Int? = nil, + totalCostLimit: Int? = nil + ) { + if let name: String = name { + cache.name = name + } + + if let countLimit: Int = countLimit { + cache.countLimit = countLimit + } + + if let totalCostLimit: Int = totalCostLimit { + cache.totalCostLimit = totalCostLimit + } + } + + // MARK: - Functions + + public func object(forKey key: String) -> String? { + return cache.object(forKey: key as NSString) as? String + } + + public func setObject(_ value: String, forKey key: String, cost: Int = 0) { + cache.setObject(value as NSString, forKey: key as NSString, cost: cost) + } + + public func removeObject(forKey key: String) { + cache.removeObject(forKey: key as NSString) + } + + public func removeAllObjects() { + cache.removeAllObjects() + } +} diff --git a/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift b/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift index 27fe205613..d8e590f566 100644 --- a/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift @@ -4,6 +4,19 @@ import AVFoundation import UniformTypeIdentifiers public extension AVURLAsset { + var maxVideoTrackSize: CGSize { + var result: CGSize = .zero + + for track: AVAssetTrack in tracks(withMediaType: .video) { + let trackSize: CGSize = track.naturalSize + let transformedSize: CGSize = trackSize.applying(track.preferredTransform) + result.width = max(result.width, abs(transformedSize.width)) + result.height = max(result.height, abs(transformedSize.height)) + } + + return result + } + static func asset(for path: String, utType: UTType?, sourceFilename: String?, using dependencies: Dependencies) -> (asset: AVURLAsset, cleanup: () -> Void)? { if #available(iOS 17.0, *) { /// Since `mimeType` can be null we need to try to resolve it to a value diff --git a/SessionUtilitiesKit/Utilities/AsyncStream+Utilities.swift b/SessionUtilitiesKit/Utilities/AsyncStream+Utilities.swift new file mode 100644 index 0000000000..812463b7a5 --- /dev/null +++ b/SessionUtilitiesKit/Utilities/AsyncStream+Utilities.swift @@ -0,0 +1,13 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension AsyncStream { + func first() async -> Element? { + return await first(where: { _ in true }) + } + + func first(defaultValue: Element) async -> Element { + return (await first(where: { _ in true }) ?? defaultValue) + } +} diff --git a/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift b/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift index 25307752e4..8f7a54b178 100644 --- a/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift @@ -32,7 +32,7 @@ public extension UIImage { // Get the size in pixels, not points let srcSize: CGSize = CGSize(width: normalizedRef.width, height: normalizedRef.height) let widthRatio: CGFloat = (srcSize.width / srcSize.height) - let heightRatio: CGFloat = (srcSize.height / srcSize.height) + let heightRatio: CGFloat = (srcSize.height / srcSize.width) let drawRect: CGRect = { guard widthRatio <= heightRatio else { let targetWidth: CGFloat = (dstSize.height * srcSize.width / srcSize.height) diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift index 054e776bef..3279e7dcda 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift @@ -44,7 +44,8 @@ class PendingAttachmentRailItem: Equatable { // This will only apply for valid images. if ImageEditorModel.isFeatureEnabled && - attachment.utType.isImage, + attachment.utType.isImage && + attachment.duration == 0, case .media(let mediaSource) = attachment.source, case .url = mediaSource { diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift index 6665d2354a..a129d6e9a5 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift @@ -136,7 +136,7 @@ public class AttachmentPrepViewController: OWSViewController { let tapGesture = UITapGestureRecognizer(target: self, action: #selector(screenTapped)) mediaMessageView.addGestureRecognizer(tapGesture) - if attachment.utType.isImage, let editorView: ImageEditorView = imageEditorView { + if attachment.utType.isImage && attachment.duration == 0, let editorView: ImageEditorView = imageEditorView { view.addSubview(editorView) imageEditorUpdateNavigationBar() @@ -200,7 +200,7 @@ public class AttachmentPrepViewController: OWSViewController { mediaMessageView.heightAnchor.constraint(equalTo: view.heightAnchor) ]) - if attachment.utType.isImage, let editorView: ImageEditorView = imageEditorView { + if attachment.utType.isImage && attachment.duration == 0, let editorView: ImageEditorView = imageEditorView { let size: CGSize = (attachment.metadata?.pixelSize ?? CGSize.zero) let isPortrait: Bool = (size.height > size.width) @@ -269,7 +269,7 @@ public class AttachmentPrepViewController: OWSViewController { // MARK: - Helpers var isZoomable: Bool { - return attachment.utType.isImage || attachment.utType.isVideo + return attachment.utType.isImage || attachment.utType.isAnimated || attachment.utType.isVideo } func zoomOut(animated: Bool) { diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift index 510b8b15a9..45bac1a7d4 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift @@ -67,7 +67,7 @@ public class ImageEditorModel { Log.error("[ImageEditorModel] Couldn't extract media data.") throw ImageEditorError.invalidInput } - guard attachment.utType.isImage && !attachment.utType.isAnimated else { + guard attachment.utType.isImage && attachment.duration == 0 else { Log.error("[ImageEditorModel] Invalid MIME type: \(attachment.utType.preferredMIMEType ?? "unknown").") throw ImageEditorError.invalidInput } diff --git a/SignalUtilitiesKit/Utilities/ImageCache.swift b/SignalUtilitiesKit/Utilities/ImageCache.swift deleted file mode 100644 index 2a8520717c..0000000000 --- a/SignalUtilitiesKit/Utilities/ImageCache.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. -// - -import Foundation -import UIKit - -class ImageCacheRecord: NSObject { - var variations: [CGFloat: UIImage] - init(variations: [CGFloat: UIImage]) { - self.variations = variations - } -} - -/** - * A two dimensional hash, allowing you to store variations under a single key. - * This is useful because we generate multiple diameters of an image, but when we - * want to clear out the images for a key we want to clear out *all* variations. - */ -@objc -public class ImageCache: NSObject { - - let backingCache: NSCache - - public override init() { - self.backingCache = NSCache() - } - - @objc - public func image(forKey key: AnyObject, diameter: CGFloat) -> UIImage? { - guard let record = backingCache.object(forKey: key) else { - return nil - } - return record.variations[diameter] - } - - @objc - public func setImage(_ image: UIImage, forKey key: AnyObject, diameter: CGFloat) { - if let existingRecord = backingCache.object(forKey: key) { - existingRecord.variations[diameter] = image - backingCache.setObject(existingRecord, forKey: key) - } else { - let newRecord = ImageCacheRecord(variations: [diameter: image]) - backingCache.setObject(newRecord, forKey: key) - } - } - - @objc - public func removeAllImages() { - backingCache.removeAllObjects() - } - - @objc - public func removeAllImages(forKey key: AnyObject) { - backingCache.removeObject(forKey: key) - } -} diff --git a/SignalUtilitiesKit/Utilities/OWSSignalAddress.swift b/SignalUtilitiesKit/Utilities/OWSSignalAddress.swift deleted file mode 100644 index ce04d60137..0000000000 --- a/SignalUtilitiesKit/Utilities/OWSSignalAddress.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -import Foundation - -public enum OWSSignalAddressError: Error { - case assertionError(description: String) -} - -@objc -public class OWSSignalAddress: NSObject { - @objc - public let recipientId: String - - @objc - public let deviceId: UInt - - // MARK: Initializers - - @objc public init(recipientId: String, deviceId: UInt) throws { - guard recipientId.count > 0 else { - throw OWSSignalAddressError.assertionError(description: "Invalid recipient id: \(deviceId)") - } - - guard deviceId > 0 else { - throw OWSSignalAddressError.assertionError(description: "Invalid device id: \(deviceId)") - } - - self.recipientId = recipientId - self.deviceId = deviceId - } -} diff --git a/SignalUtilitiesKit/Utilities/ReverseDispatchQueue.swift b/SignalUtilitiesKit/Utilities/ReverseDispatchQueue.swift deleted file mode 100644 index f42281ec49..0000000000 --- a/SignalUtilitiesKit/Utilities/ReverseDispatchQueue.swift +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. - -import Foundation -import SessionUtilitiesKit - -// This is intended to be a drop-in replacement for DispatchQueue -// that processes its queue in reverse order. -@objc -public class ReverseDispatchQueue: NSObject { - - private static let isVerbose: Bool = false - - private let label: String - private let serialQueue: DispatchQueue - - // TODO: We could allow creation with various QOS. - @objc - public required init(label: String) { - self.label = label - serialQueue = DispatchQueue(label: label) - - super.init() - } - - public typealias WorkBlock = () -> Void - - private class Item { - let workBlock: WorkBlock - let index: UInt64 - - required init(workBlock : @escaping WorkBlock, index: UInt64) { - self.workBlock = workBlock - self.index = index - } - } - - // These properties should only be accessed on serialQueue. - private var items = [Item]() - private var indexCounter: UInt64 = 0 - - @objc - public func async(workBlock : @escaping WorkBlock) { - serialQueue.async { - self.indexCounter = self.indexCounter + 1 - let index = self.indexCounter - let item = Item(workBlock: workBlock, index: index ) - self.items.append(item) - - if ReverseDispatchQueue.isVerbose { - Log.verbose("[ReverseDispatchQueue] Enqueued[\(self.label)]: \(item.index)") - } - - self.process() - } - } - - private func process() { - serialQueue.async { - // Note that we popLast() so that we process - // the queue in the _reverse_ order from - // which it was enqueued. - guard let item = self.items.popLast() else { - // No enqueued work to do. - return - } - if ReverseDispatchQueue.isVerbose { - Log.verbose("[ReverseDispatchQueue] Processing[\(self.label)]: \(item.index)") - } - item.workBlock() - - self.process() - } - } -} diff --git a/_SharedTestUtilities/Async+Utilities.swift b/_SharedTestUtilities/Async+Utilities.swift new file mode 100644 index 0000000000..d686ec3898 --- /dev/null +++ b/_SharedTestUtilities/Async+Utilities.swift @@ -0,0 +1,16 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension AsyncStream { + static func singleValue(value: Element) -> AsyncStream { + var hasEmittedValue: Bool = false + + return AsyncStream(unfolding: { + guard !hasEmittedValue else { return nil } + + hasEmittedValue = true + return value + }) + } +} From a1db1c027203b8986b609fd5868563b6d4449ca1 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 9 Oct 2025 10:23:41 +1100 Subject: [PATCH 071/162] Fixed a few bugs which were noticed while testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Fixed an issue with the CI build script • Fixed an incorrect search icon on the home screen • Fixed an issue where the "Join Community" button didn't have a disabled state --- Scripts/build_ci.sh | 4 ++-- Session/Home/HomeVC.swift | 3 ++- Session/Open Groups/JoinOpenGroupVC.swift | 17 ++++++++++++----- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/Scripts/build_ci.sh b/Scripts/build_ci.sh index 9ff7932d3d..0034642f7b 100755 --- a/Scripts/build_ci.sh +++ b/Scripts/build_ci.sh @@ -56,8 +56,8 @@ if [[ "$MODE" == "test" ]]; then xcresultparser --output-format cli --no-test-result --coverage ./build/artifacts/testResults.xcresult parser_output=$(xcresultparser --output-format cli --no-test-result ./build/artifacts/testResults.xcresult) - build_errors_count=$(echo "$parser_output" | grep "Number of errors" | awk '{print $NF}') - failed_tests_count=$(echo "$parser_output" | grep "Number of failed tests" | awk '{print $NF}') + build_errors_count=$(echo "$parser_output" | grep "Number of errors" | awk '{print $NF}' | grep -o '[0-9]*' || echo "0") + failed_tests_count=$(echo "$parser_output" | grep "Number of failed tests" | awk '{print $NF}' | grep -o '[0-9]*' || echo "0") if [ "${build_errors_count:-0}" -gt 0 ] || [ "${failed_tests_count:-0}" -gt 0 ]; then echo "" diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index ecab5c8318..a80167b8ea 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -4,6 +4,7 @@ import UIKit import Combine import GRDB import DifferenceKit +import Lucide import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit @@ -547,7 +548,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi navigationItem.leftBarButtonItem = leftBarButtonItem // Right bar button item - search button - let rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .search, target: self, action: #selector(showSearchUI)) + let rightBarButtonItem = UIBarButtonItem(image: Lucide.image(icon: .search, size: 24), style: .plain, target: self, action: #selector(showSearchUI)) rightBarButtonItem.accessibilityLabel = "Search button" rightBarButtonItem.isAccessibilityElement = true navigationItem.rightBarButtonItem = rightBarButtonItem diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 0c5fa3c2cc..ee1f586ee2 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -339,7 +339,9 @@ private final class EnterURLVC: UIViewController, UIGestureRecognizerDelegate, O private var keyboardTransitionSnapshot2: UIView? private lazy var urlTextView: SNTextView = { - let result: SNTextView = SNTextView(placeholder: "communityEnterUrl".localized()) + let result: SNTextView = SNTextView(placeholder: "communityEnterUrl".localized()) { [weak self] text in + self?.joinButton.isEnabled = !text.isEmpty + } result.keyboardType = .URL result.autocapitalizationType = .none result.autocorrectionType = .no @@ -347,6 +349,15 @@ private final class EnterURLVC: UIViewController, UIGestureRecognizerDelegate, O return result }() + private lazy var joinButton: UIButton = { + let result: SessionButton = SessionButton(style: .bordered, size: .large) + result.setTitle("join".localized(), for: UIControl.State.normal) + result.addTarget(self, action: #selector(joinOpenGroup), for: .touchUpInside) + result.isEnabled = false + + return result + }() + private lazy var suggestionGridTitleLabel: UILabel = { let result: UILabel = UILabel() result.setContentHuggingPriority(.required, for: .vertical) @@ -393,10 +404,6 @@ private final class EnterURLVC: UIViewController, UIGestureRecognizerDelegate, O view.themeBackgroundColor = .clear // Next button - let joinButton = SessionButton(style: .bordered, size: .large) - joinButton.setTitle("join".localized(), for: UIControl.State.normal) - joinButton.addTarget(self, action: #selector(joinOpenGroup), for: UIControl.Event.touchUpInside) - let joinButtonContainer = UIView( wrapping: joinButton, withInsets: UIEdgeInsets(top: 0, leading: 80, bottom: 0, trailing: 80), From 284b1aa4805b7ef6e250272e20f331eaae133572 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Fri, 10 Oct 2025 08:07:49 +0800 Subject: [PATCH 072/162] Fix end call camera instruction not showing --- Session/Calls/CallVC.swift | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index 8f17a810fc..812f2b5dd1 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -33,6 +33,11 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel var floatingViewVideoSource: FloatingViewVideoSource = .local + // Use a local bool flag (set to true) instead of passing it from `endCall`. + // This prevents a race condition between `handleEndCallMessage` and `endCall`, + // since `handleEndCallMessage`, triggered by `call.hasEndedDidChange`, may fire first. + private var shouldShowCameraPermissionInstructions = false + // MARK: - UI Components private lazy var floatingLocalVideoView: LocalVideoView = { @@ -674,7 +679,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel } } - @objc private func endCall(presentCameraRequestDialog: Bool = false) { + @objc private func endCall() { dependencies[singleton: .callManager].endCall(call) { [weak self, dependencies] error in if let _ = error { self?.call.endSessionCall() @@ -682,7 +687,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel } Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { [weak self] _ in - self?.shouldHandleCallDismiss(presentCameraRequestDialog) + self?.shouldHandleCallDismiss() } } } @@ -753,7 +758,9 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel cancelTitle: "remindMeLater".localized(), cancelStyle: .alert_text, onConfirm: { _ in - self?.endCall(presentCameraRequestDialog: true) + self?.shouldShowCameraPermissionInstructions = true + + self?.endCall() }, onCancel: { modal in dependencies[defaults: .standard, key: .shouldRemindGrantingCameraPermissionForCalls] = true @@ -907,8 +914,8 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel self?.dismiss(animated: true, completion: { self?.conversationVC?.becomeFirstResponder() self?.conversationVC?.showInputAccessoryView() - - if presentCameraRequestDialog == true { + + if self?.shouldShowCameraPermissionInstructions == true { Permissions.showEnableCameraAccessInstructions(using: dependencies) } else { Permissions.remindCameraAccessRequirement(using: dependencies) @@ -919,11 +926,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel // MARK: - AVRoutePickerViewDelegate - func routePickerViewWillBeginPresentingRoutes(_ routePickerView: AVRoutePickerView) { - - } + func routePickerViewWillBeginPresentingRoutes(_ routePickerView: AVRoutePickerView) {} - func routePickerViewDidEndPresentingRoutes(_ routePickerView: AVRoutePickerView) { - - } + func routePickerViewDidEndPresentingRoutes(_ routePickerView: AVRoutePickerView) {} } From 99ea4adf3c3b17c8eb4109fa601c2c23de99168b Mon Sep 17 00:00:00 2001 From: mikoldin Date: Fri, 10 Oct 2025 09:37:39 +0800 Subject: [PATCH 073/162] Added missing emoji in welcome to session copy --- Session/Home/HomeVC.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index cf9e8ed989..6fe2207689 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -251,7 +251,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi welcomeLabel.font = .systemFont(ofSize: Values.smallFontSize) welcomeLabel.text = "onboardingBubbleWelcomeToSession" .put(key: "app_name", value: Constants.app_name) - .put(key: "emoji", value: "") + .put(key: "emoji", value: "👋") .localized() welcomeLabel.themeTextColor = .sessionButton_text welcomeLabel.textAlignment = .center From 7e2a8e63e53968c6bfda34928a38bccd2413c83e Mon Sep 17 00:00:00 2001 From: mpretty-cyro <15862619+mpretty-cyro@users.noreply.github.com> Date: Fri, 10 Oct 2025 02:49:26 +0000 Subject: [PATCH 074/162] [Automated] Update translations from Crowdin --- Session/Meta/Translations/Localizable.xcstrings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Session/Meta/Translations/Localizable.xcstrings b/Session/Meta/Translations/Localizable.xcstrings index 300dd1804a..b9f96f2706 100644 --- a/Session/Meta/Translations/Localizable.xcstrings +++ b/Session/Meta/Translations/Localizable.xcstrings @@ -189804,7 +189804,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Enter the password you use to unlock Session \r\non startup, not your Recovery Password" + "value" : "Enter the password you use to unlock Session \\non startup, not your Recovery Password" } } } From 81c6d9d087b1aeb6a35af1827906e6f1ba09489c Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 10 Oct 2025 15:05:58 +1100 Subject: [PATCH 075/162] Cleaned up and finalised code changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added logic to convert between desired file types • Added logic to convert display pictures to desired formats (with timeouts when initially setting) • Refactored the ThreadPickerVC message sending to be (partially) async/await • Removed some duplicate code • Resolved remaining TODOs • Fixed unit test compilation issues • Fixed some error handling --- Session.xcodeproj/project.pbxproj | 4 - Session/Closed Groups/NewClosedGroupVC.swift | 3 +- .../ConversationVC+Interaction.swift | 416 ++++---- .../Conversations/ConversationViewModel.swift | 17 +- .../Settings/ThreadSettingsViewModel.swift | 33 +- .../CropScaleImageViewController.swift | 510 +++------- .../OWSImagePickerController.swift | 15 - .../PhotoLibrary.swift | 105 +- ...DeveloperSettingsFileServerViewModel.swift | 42 +- Session/Settings/ImagePickerHandler.swift | 93 +- Session/Settings/SettingsViewModel.swift | 35 +- .../Crypto/Crypto+Attachments.swift | 32 +- .../Database/Models/LinkPreview.swift | 10 +- .../Jobs/AttachmentUploadJob.swift | 215 +--- .../Jobs/ReuploadUserDisplayPictureJob.swift | 19 +- .../Errors/AttachmentError.swift | 7 +- .../MessageSender+Groups.swift | 5 +- .../Utilities/AttachmentManager.swift | 943 +++++++++++++----- .../Utilities/DisplayPictureManager.swift | 128 ++- .../Jobs/DisplayPictureDownloadJobSpec.swift | 57 +- .../Jobs/MessageSendJobSpec.swift | 25 +- ...RetrieveDefaultOpenGroupRoomsJobSpec.swift | 73 +- .../LibSession/LibSessionGroupInfoSpec.swift | 23 +- .../LibSessionGroupMembersSpec.swift | 10 +- .../Open Groups/OpenGroupManagerSpec.swift | 45 +- .../MessageReceiverGroupsSpec.swift | 38 +- .../MessageSenderGroupsSpec.swift | 368 ++++--- .../Pollers/CommunityPollerSpec.swift | 10 +- .../_TestUtilities/MockPoller.swift | 1 + .../_TestUtilities/MockSwarmPoller.swift | 1 + .../FileServer/FileServer.swift | 16 +- .../Types/HTTPFragmentParam.swift | 2 +- .../Types/HTTPQueryParam.swift | 2 +- .../SOGS/SOGSAPISpec.swift | 70 +- .../Types/DestinationSpec.swift | 37 +- .../Types/PreparedRequestSendingSpec.swift | 20 +- .../Types/RequestSpec.swift | 5 +- .../CommonSSKMockExtensions.swift | 2 +- .../_TestUtilities/MockNetwork.swift | 34 +- .../ShareNavController.swift | 92 +- SessionShareExtension/ThreadPickerVC.swift | 231 +++-- SessionTests/Onboarding/OnboardingSpec.swift | 15 +- .../Modals & Toast/ConfirmationModal.swift | 39 +- .../Components/ProfilePictureView.swift | 100 +- SessionUtilitiesKit/Media/MediaUtils.swift | 5 +- .../Utilities/UIImage+Utilities.swift | 229 ++++- _SharedTestUtilities/MockFileManager.swift | 7 +- 47 files changed, 2542 insertions(+), 1647 deletions(-) delete mode 100644 Session/Media Viewing & Editing/OWSImagePickerController.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 07a019713a..dc4a092042 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -15,7 +15,6 @@ 3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34969559219B605E00DCFE74 /* ImagePickerController.swift */; }; 3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3496955B219B605E00DCFE74 /* PhotoLibrary.swift */; }; 3496956021A2FC8100DCFE74 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3496955F21A2FC8100DCFE74 /* CloudKit.framework */; }; - 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */; }; 34A8B3512190A40E00218A25 /* MediaAlbumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A8B3502190A40E00218A25 /* MediaAlbumView.swift */; }; 34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */; }; 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BECE2D1F7ABCE000D7438D /* GifPickerViewController.swift */; }; @@ -1388,7 +1387,6 @@ 34969559219B605E00DCFE74 /* ImagePickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePickerController.swift; sourceTree = ""; }; 3496955B219B605E00DCFE74 /* PhotoLibrary.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoLibrary.swift; sourceTree = ""; }; 3496955F21A2FC8100DCFE74 /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; }; - 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSImagePickerController.swift; sourceTree = ""; }; 34A8B3502190A40E00218A25 /* MediaAlbumView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaAlbumView.swift; sourceTree = ""; }; 34B0796E1FD07B1E00E248C2 /* SignalShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SignalShareExtension.entitlements; sourceTree = ""; }; 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypingIndicatorView.swift; sourceTree = ""; }; @@ -3538,7 +3536,6 @@ 7BBBDC452875600700747E59 /* DocumentTitleViewController.swift */, 346B66301F4E29B200E5122F /* CropScaleImageViewController.swift */, 34969559219B605E00DCFE74 /* ImagePickerController.swift */, - 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */, FD71160328C95B5600B47552 /* PhotoCollectionPickerViewModel.swift */, 3496955B219B605E00DCFE74 /* PhotoLibrary.swift */, 4CA485BA2232339F004B9E7D /* PhotoCaptureViewController.swift */, @@ -7032,7 +7029,6 @@ B8569AC325CB5D2900DBA3DB /* ConversationVC+Interaction.swift in Sources */, FD981BD52DC978B400564172 /* MentionUtilities+DisplayName.swift in Sources */, 3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */, - 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */, FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */, FD12A8472AD63C3400EEBA0D /* PagedObservationSource.swift in Sources */, FDC1BD682CFE6EEB002CDC71 /* DeveloperSettingsViewModel.swift in Sources */, diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index 97f6e1eb93..37faac7b3e 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -420,7 +420,7 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate (id, self.contacts.first { $0.profileId == id }?.profile) } - let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController(onAppear: { _ in }) + let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController() navigationController?.present(indicator, animated: false) Task(priority: .userInitiated) { [weak self] in @@ -431,6 +431,7 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate name: name, description: nil, displayPicture: nil, + displayPictureCropRect: nil, members: selectedProfiles, using: dependencies ) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index b824e0f46a..a407e00c2f 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -206,7 +206,7 @@ extension ConversationVC: // MARK: - Blocking - @discardableResult func showBlockedModalIfNeeded() -> Bool { + @MainActor @discardableResult func showBlockedModalIfNeeded() -> Bool { guard self.viewModel.threadData.threadVariant == .contact && self.viewModel.threadData.threadIsBlocked == true @@ -426,7 +426,63 @@ extension ConversationVC: UTType.supportedVideoTypes.contains(pendingAttachment.utType) && !UTType.supportedOutputVideoTypes.contains(pendingAttachment.utType) { - self?.showAttachmentApprovalDialogAfterProcessingVideo(pendingAttachment) + let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController( + canCancel: true + ) + self?.present(indicator, animated: false) + + Task.detached(priority: .userInitiated) { [weak self, indicator, dependencies] in + do { + let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( + operations: [.convert(to: .mp4)], + using: dependencies + ) + guard await !indicator.wasCancelled else { return } + + let convertedAttachment: PendingAttachment = PendingAttachment( + source: .media( + .videoUrl( + URL(fileURLWithPath: preparedAttachment.filePath), + .mpeg4Movie, + pendingAttachment.sourceFilename, + dependencies[singleton: .attachmentManager] + ) + ), + utType: .mpeg4Movie, + sourceFilename: pendingAttachment.sourceFilename, + using: dependencies + ) + try convertedAttachment.ensureExpectedEncryptedSize( + domain: .attachment, + maxFileSize: Network.maxFileSize, + using: dependencies + ) + + await indicator.dismiss { + self?.showAttachmentApprovalDialog(for: [ convertedAttachment ]) + } + } + catch { + await indicator.dismiss { + self?.showErrorAlert(for: error) + } + } + } + return + } + + /// Validate the expected attachment size before proceeding + do { + try pendingAttachment.ensureExpectedEncryptedSize( + domain: .attachment, + maxFileSize: Network.maxFileSize, + using: dependencies + ) + } + catch { + DispatchQueue.main.async { [weak self] in + self?.showErrorAlert(for: error) + } return } @@ -502,31 +558,6 @@ extension ConversationVC: present(navController, animated: true, completion: nil) } - - func showAttachmentApprovalDialogAfterProcessingVideo(_ pendingAttachment: PendingAttachment) { - let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController( - canCancel: true - ) - present(indicator, animated: false) - - Task.detached(priority: .userInitiated) { [weak self, indicator, dependencies = viewModel.dependencies] in - do { - let convertedAttachment: PendingAttachment = try await pendingAttachment.toMp4Video( - using: dependencies - ) - guard await !indicator.wasCancelled else { return } - - await indicator.dismiss { - self?.showAttachmentApprovalDialog(for: [ convertedAttachment ]) - } - } - catch { - await indicator.dismiss { - self?.showErrorAlert(for: error) - } - } - } - } // MARK: - InputViewDelegate @@ -651,7 +682,7 @@ extension ConversationVC: present(confirmationModal, animated: true, completion: nil) } - func sendMessage( + @MainActor func sendMessage( text: String, attachments: [PendingAttachment] = [], linkPreviewDraft: LinkPreviewDraft? = nil, @@ -660,6 +691,18 @@ extension ConversationVC: ) { guard !showBlockedModalIfNeeded() else { return } + /// Validate the expected attachment size before proceeding + do { + try attachments.forEach { attachment in + try attachment.ensureExpectedEncryptedSize( + domain: .attachment, + maxFileSize: Network.maxFileSize, + using: viewModel.dependencies + ) + } + } + catch { return showErrorAlert(for: error) } + let processedText: String = replaceMentions(in: text.trimmingCharacters(in: .whitespacesAndNewlines)) // If we have no content then do nothing @@ -690,37 +733,36 @@ extension ConversationVC: } // Clearing this out immediately to make this appear more snappy - DispatchQueue.main.async { [weak self] in - self?.snInputView.text = "" - self?.snInputView.quoteDraftInfo = nil - - self?.resetMentions() - self?.scrollToBottom(isAnimated: false) - } + snInputView.text = "" + snInputView.quoteDraftInfo = nil + resetMentions() + scrollToBottom(isAnimated: false) + // Optimistically insert the outgoing message (this will trigger a UI update) self.viewModel.sentMessageBeforeUpdate = true let sentTimestampMs: Int64 = viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - let optimisticData: ConversationViewModel.OptimisticMessageData = self.viewModel.optimisticallyAppendOutgoingMessage( - text: processedText, - sentTimestampMs: sentTimestampMs, - attachments: attachments, - linkPreviewDraft: linkPreviewDraft, - quoteModel: quoteModel - ) - // If this was a message request then approve it - approveMessageRequestIfNeeded( - for: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, - displayName: self.viewModel.threadData.displayName, - isDraft: (self.viewModel.threadData.threadIsDraft == true), - timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting - ).sinkUntilComplete( - receiveCompletion: { [weak self] _ in - self?.sendMessage(optimisticData: optimisticData) - } - ) + Task.detached(priority: .userInitiated) { [weak self] in + guard let self = self else { return } + + let optimisticData: ConversationViewModel.OptimisticMessageData = await viewModel.optimisticallyAppendOutgoingMessage( + text: processedText, + sentTimestampMs: sentTimestampMs, + attachments: attachments, + linkPreviewDraft: linkPreviewDraft, + quoteModel: quoteModel + ) + await approveMessageRequestIfNeeded( + for: self.viewModel.threadData.threadId, + threadVariant: self.viewModel.threadData.threadVariant, + displayName: self.viewModel.threadData.displayName, + isDraft: (self.viewModel.threadData.threadIsDraft == true), + timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting + ) + + await sendMessage(optimisticData: optimisticData) + } } private func sendMessage(optimisticData: ConversationViewModel.OptimisticMessageData) { @@ -2939,22 +2981,20 @@ extension ConversationVC { displayName: String, isDraft: Bool, timestampMs: Int64 - ) -> AnyPublisher { - let updateNavigationBackStack: () -> Void = { - // Remove the 'SessionTableViewController' from the nav hierarchy if present - DispatchQueue.main.async { [weak self] in - if - let viewControllers: [UIViewController] = self?.navigationController?.viewControllers, - let messageRequestsIndex = viewControllers - .firstIndex(where: { viewCon -> Bool in - (viewCon as? SessionViewModelAccessible)?.viewModelType == MessageRequestsViewModel.self - }), - messageRequestsIndex > 0 - { - var newViewControllers = viewControllers - newViewControllers.remove(at: messageRequestsIndex) - self?.navigationController?.viewControllers = newViewControllers - } + ) async { + let updateNavigationBackStack: @MainActor () -> Void = { [weak self] in + /// Remove the `SessionTableViewController` from the nav hierarchy if present + if + let viewControllers: [UIViewController] = self?.navigationController?.viewControllers, + let messageRequestsIndex = viewControllers + .firstIndex(where: { viewCon -> Bool in + (viewCon as? SessionViewModelAccessible)?.viewModelType == MessageRequestsViewModel.self + }), + messageRequestsIndex > 0 + { + var newViewControllers = viewControllers + newViewControllers.remove(at: messageRequestsIndex) + self?.navigationController?.viewControllers = newViewControllers } } @@ -2962,145 +3002,145 @@ extension ConversationVC { case .contact: /// If the contact doesn't exist then we should create it so we can store the `isApproved` state (it'll be updated /// with correct profile info if they accept the message request so this shouldn't cause weird behaviours) + let maybeContact: Contact? = try? await viewModel.dependencies[singleton: .storage].readAsync { [dependencies = viewModel.dependencies] db in + Contact.fetchOrCreate(db, id: threadId, using: dependencies) + } + guard - let contact: Contact = viewModel.dependencies[singleton: .storage].read({ [dependencies = viewModel.dependencies] db in - Contact.fetchOrCreate(db, id: threadId, using: dependencies) - }), + let contact: Contact = maybeContact, !contact.isApproved - else { return Just(()).eraseToAnyPublisher() } + else { return } - return viewModel.dependencies[singleton: .storage] - .writePublisher { [dependencies = viewModel.dependencies] db in - /// If this isn't a draft thread (ie. sending a message request) then send a `messageRequestResponse` - /// back to the sender (this allows the sender to know that they have been approved and can now use this - /// contact in closed groups) - if !isDraft { - _ = try? Interaction( - threadId: threadId, - threadVariant: threadVariant, - authorId: dependencies[cache: .general].sessionId.hexString, - variant: .infoMessageRequestAccepted, - body: "messageRequestYouHaveAccepted" - .put(key: "name", value: displayName) - .localized(), - timestampMs: timestampMs, - using: dependencies - ).inserted(db) - - try MessageSender.send( - db, - message: MessageRequestResponse( - isApproved: true, - sentTimestampMs: UInt64(timestampMs) - ), - interactionId: nil, - threadId: threadId, - threadVariant: threadVariant, - using: dependencies - ) - } + try? await viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in + /// If this isn't a draft thread (ie. sending a message request) then send a `messageRequestResponse` + /// back to the sender (this allows the sender to know that they have been approved and can now use this + /// contact in closed groups) + if !isDraft { + _ = try? Interaction( + threadId: threadId, + threadVariant: threadVariant, + authorId: dependencies[cache: .general].sessionId.hexString, + variant: .infoMessageRequestAccepted, + body: "messageRequestYouHaveAccepted" + .put(key: "name", value: displayName) + .localized(), + timestampMs: timestampMs, + using: dependencies + ).inserted(db) - // Default 'didApproveMe' to true for the person approving the message request - let updatedDidApproveMe: Bool = (contact.didApproveMe || !isDraft) - try contact.upsert(db) - try Contact - .filter(id: contact.id) - .updateAllAndConfig( - db, - Contact.Columns.isApproved.set(to: true), - Contact.Columns.didApproveMe.set(to: updatedDidApproveMe), - using: dependencies - ) - db.addContactEvent(id: contact.id, change: .isApproved(true)) - db.addContactEvent(id: contact.id, change: .didApproveMe(updatedDidApproveMe)) - db.addEvent(contact.id, forKey: .messageRequestAccepted) + try MessageSender.send( + db, + message: MessageRequestResponse( + isApproved: true, + sentTimestampMs: UInt64(timestampMs) + ), + interactionId: nil, + threadId: threadId, + threadVariant: threadVariant, + using: dependencies + ) } - .map { _ in () } - .catch { _ in Just(()).eraseToAnyPublisher() } - .handleEvents( - receiveOutput: { _ in - // Update the UI - updateNavigationBackStack() - } - ) - .eraseToAnyPublisher() + + // Default 'didApproveMe' to true for the person approving the message request + let updatedDidApproveMe: Bool = (contact.didApproveMe || !isDraft) + try contact.upsert(db) + try Contact + .filter(id: contact.id) + .updateAllAndConfig( + db, + Contact.Columns.isApproved.set(to: true), + Contact.Columns.didApproveMe.set(to: updatedDidApproveMe), + using: dependencies + ) + db.addContactEvent(id: contact.id, change: .isApproved(true)) + db.addContactEvent(id: contact.id, change: .didApproveMe(updatedDidApproveMe)) + db.addEvent(contact.id, forKey: .messageRequestAccepted) + } + + // Update the UI + await MainActor.run { + updateNavigationBackStack() + } + return case .group: // If the group is not in the invited state then don't bother doing anything + let maybeGroup: ClosedGroup? = try? await viewModel.dependencies[singleton: .storage].readAsync { db in + try ClosedGroup.fetchOne(db, id: threadId) + } + guard - let group: ClosedGroup = viewModel.dependencies[singleton: .storage].read({ db in - try ClosedGroup.fetchOne(db, id: threadId) - }), + let group: ClosedGroup = maybeGroup, group.invited == true - else { return Just(()).eraseToAnyPublisher() } + else { return } - return viewModel.dependencies[singleton: .storage] - .writePublisher { [dependencies = viewModel.dependencies] db in - /// Remove any existing `infoGroupInfoInvited` interactions from the group (don't want to have a - /// duplicate one from inside the group history) - try Interaction.deleteWhere( - db, - .filter(Interaction.Columns.threadId == group.id), - .filter(Interaction.Columns.variant == Interaction.Variant.infoGroupInfoInvited) - ) - - /// Optimistically insert a `standard` member for the current user in this group (it'll be update to the correct - /// one once we receive the first `GROUP_MEMBERS` config message but adding it here means the `canWrite` - /// state of the group will continue to be `true` while we wait on the initial poll to get back) - try GroupMember( - groupId: group.id, - profileId: dependencies[cache: .general].sessionId.hexString, - role: .standard, - roleStatus: .accepted, - isHidden: false - ).upsert(db) - - /// If this isn't a draft thread (ie. sending a message request) and the user is not an admin then schedule - /// sending a `GroupUpdateInviteResponseMessage` to the group (this allows other members to - /// know that the user has joined the group) - if !isDraft && group.groupIdentityPrivateKey == nil { - try MessageSender.send( - db, - message: GroupUpdateInviteResponseMessage( - isApproved: true, - sentTimestampMs: UInt64(timestampMs) - ), - interactionId: nil, - threadId: threadId, - threadVariant: threadVariant, - using: dependencies - ) - } - - /// Actually trigger the approval - try ClosedGroup.approveGroupIfNeeded( + try? await viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in + /// Remove any existing `infoGroupInfoInvited` interactions from the group (don't want to have a + /// duplicate one from inside the group history) + try Interaction.deleteWhere( + db, + .filter(Interaction.Columns.threadId == group.id), + .filter(Interaction.Columns.variant == Interaction.Variant.infoGroupInfoInvited) + ) + + /// Optimistically insert a `standard` member for the current user in this group (it'll be update to the correct + /// one once we receive the first `GROUP_MEMBERS` config message but adding it here means the `canWrite` + /// state of the group will continue to be `true` while we wait on the initial poll to get back) + try GroupMember( + groupId: group.id, + profileId: dependencies[cache: .general].sessionId.hexString, + role: .standard, + roleStatus: .accepted, + isHidden: false + ).upsert(db) + + /// If this isn't a draft thread (ie. sending a message request) and the user is not an admin then schedule + /// sending a `GroupUpdateInviteResponseMessage` to the group (this allows other members to + /// know that the user has joined the group) + if !isDraft && group.groupIdentityPrivateKey == nil { + try MessageSender.send( db, - group: group, + message: GroupUpdateInviteResponseMessage( + isApproved: true, + sentTimestampMs: UInt64(timestampMs) + ), + interactionId: nil, + threadId: threadId, + threadVariant: threadVariant, using: dependencies ) } - .map { _ in () } - .catch { _ in Just(()).eraseToAnyPublisher() } - .handleEvents( - receiveOutput: { _ in - // Update the UI - updateNavigationBackStack() - } + + /// Actually trigger the approval + try ClosedGroup.approveGroupIfNeeded( + db, + group: group, + using: dependencies ) - .eraseToAnyPublisher() + } + + // Update the UI + await MainActor.run { + updateNavigationBackStack() + } + return - default: return Just(()).eraseToAnyPublisher() + default: break } } func acceptMessageRequest() { - approveMessageRequestIfNeeded( - for: self.viewModel.threadData.threadId, - threadVariant: self.viewModel.threadData.threadVariant, - displayName: self.viewModel.threadData.displayName, - isDraft: (self.viewModel.threadData.threadIsDraft == true), - timestampMs: viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ).sinkUntilComplete() + Task.detached(priority: .userInitiated) { [weak self] in + guard let self = self else { return } + + await approveMessageRequestIfNeeded( + for: self.viewModel.threadData.threadId, + threadVariant: self.viewModel.threadData.threadVariant, + displayName: self.viewModel.threadData.displayName, + isDraft: (self.viewModel.threadData.threadIsDraft == true), + timestampMs: viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ) + } } func declineMessageRequest() { diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 8d0993aaeb..e1d9aabab7 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -722,7 +722,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold attachments: [PendingAttachment]?, linkPreviewDraft: LinkPreviewDraft?, quoteModel: QuotedReplyModel? - ) -> OptimisticMessageData { + ) async -> OptimisticMessageData { // Generate the optimistic data let optimisticMessageId: UUID = UUID() let threadData: SessionThreadViewModel = self.internalThreadData @@ -745,11 +745,18 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold isProMessage: dependencies[cache: .libSession].isSessionPro, using: dependencies ) - let optimisticAttachments: [Attachment]? = try? attachments.map { - try AttachmentUploadJob.preparePriorToUpload(attachments: $0, using: dependencies) + var optimisticAttachments: [Attachment]? + var linkPreviewAttachment: Attachment? + + if let pendingAttachments: [PendingAttachment] = attachments { + optimisticAttachments = try? await AttachmentUploadJob.preparePriorToUpload( + attachments: pendingAttachments, + using: dependencies + ) } - let linkPreviewAttachment: Attachment? = linkPreviewDraft.map { draft in - try? LinkPreview.generateAttachmentIfPossible( + + if let draft: LinkPreviewDraft = linkPreviewDraft { + linkPreviewAttachment = try? await LinkPreview.generateAttachmentIfPossible( urlString: draft.urlString, imageData: draft.jpegImageData, type: .jpeg, diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 95e46114d3..7d38f7aec5 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import PhotosUI import Combine import Lucide import GRDB @@ -25,9 +26,10 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob private var onDisplayPictureSelected: ((ConfirmationModal.ValueUpdate) -> Void)? private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler( onTransition: { [weak self] in self?.transitionToScreen($0, transitionType: $1) }, - onImageDataPicked: { [weak self] identifier, resultImageData in - self?.onDisplayPictureSelected?(.image(identifier: identifier, data: resultImageData)) - } + onImagePicked: { [weak self] source, cropRect in + self?.onDisplayPictureSelected?(.image(source: source, cropRect: cropRect)) + }, + using: dependencies ) // MARK: - Initialization @@ -1703,9 +1705,12 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob dismissOnConfirm: false, onConfirm: { [weak self] modal in switch modal.info.body { - case .image(.some(let source), _, _, _, _, _, _): + case .image(.some(let source), _, _, let style, _, _, _): self?.updateGroupDisplayPicture( - displayPictureUpdate: .groupUploadImage(source), + displayPictureUpdate: .groupUploadImage( + source: source, + cropRect: style.cropRect + ), onUploadComplete: { [weak modal] in Task { @MainActor in modal?.close() } } @@ -1731,9 +1736,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob @MainActor private func showPhotoLibraryForAvatar() { Permissions.requestLibraryPermissionIfNeeded(isSavingMedia: false, using: dependencies) { [weak self] in DispatchQueue.main.async { - let picker: UIImagePickerController = UIImagePickerController() - picker.sourceType = .photoLibrary - picker.mediaTypes = [ "public.image" ] // stringlint:disable + var configuration: PHPickerConfiguration = PHPickerConfiguration() + configuration.selectionLimit = 1 + configuration.filter = .any(of: [.images, .livePhotos]) + + let picker: PHPickerViewController = PHPickerViewController(configuration: configuration) picker.delegate = self?.imagePickerHandler self?.transitionToScreen(picker, transitionType: .present) @@ -1761,7 +1768,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob throw AttachmentError.invalidStartState case .groupRemove, .groupUpdateTo: break - case .groupUploadImage(let source): + case .groupUploadImage(let source, let cropRect): /// Show a blocking loading indicator while uploading but not while updating or syncing the group configs indicator = await MainActor.run { [weak self] in let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController(onAppear: { _ in }) @@ -1773,8 +1780,12 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob source: .media(source), using: dependencies ) - let preparedAttachment: PreparedAttachment = try dependencies[singleton: .displayPictureManager] - .prepareDisplayPicture(attachment: pendingAttachment) + let preparedAttachment: PreparedAttachment = try await dependencies[singleton: .displayPictureManager] + .prepareDisplayPicture( + attachment: pendingAttachment, + fallbackIfConversionTakesTooLong: true, + cropRect: cropRect + ) let result = try await dependencies[singleton: .displayPictureManager] .uploadDisplayPicture(preparedAttachment: preparedAttachment) await MainActor.run { onUploadComplete() } diff --git a/Session/Media Viewing & Editing/CropScaleImageViewController.swift b/Session/Media Viewing & Editing/CropScaleImageViewController.swift index c7509ea75d..9def02c883 100644 --- a/Session/Media Viewing & Editing/CropScaleImageViewController.swift +++ b/Session/Media Viewing & Editing/CropScaleImageViewController.swift @@ -1,8 +1,9 @@ // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -import Foundation +import UIKit import MediaPlayer import SessionUIKit +import SessionUIKit import SignalUtilitiesKit import SessionUtilitiesKit @@ -25,18 +26,14 @@ import SessionUtilitiesKit // region b) the rectangle at which the src image should be rendered // given a dst view or output context that will yield the // appropriate cropping. -@objc class CropScaleImageViewController: OWSViewController { +class CropScaleImageViewController: OWSViewController, UIScrollViewDelegate { // MARK: Properties - let srcImage: UIImage - - let successCompletion: ((CGRect, Data) -> Void) - - var imageView: UIView! + private let dataManager: ImageDataManagerType + let source: ImageDataManager.DataSource - // We use a CALayer to render the image for performance reasons. - var imageLayer: CALayer! + let successCompletion: ((ImageDataManager.DataSource, CGRect) -> Void) // In width/height. let dstSizePixels: CGSize @@ -46,26 +43,30 @@ import SessionUtilitiesKit // The size of the src image in points. var srcImageSizePoints: CGSize = CGSize.zero - // The size of the default crop region, which is the - // largest crop region with the correct dst aspect ratio - // that fits in the src image's aspect ratio, - // in src image point coordinates. - var srcDefaultCropSizePoints: CGSize = CGSize.zero - - // N = Scaled, zoomed in. - let kMaxImageScale: CGFloat = 4.0 - // 1.0 = Unscaled, cropped to fill crop rect. - let kMinImageScale: CGFloat = 1.0 - // This represents the current scaling of the src image. - var imageScale: CGFloat = 1.0 - - // This represents the current translation from the - // upper-left corner of the src image to the upper-left - // corner of the crop region in src image point coordinates. - var srcTranslation: CGPoint = CGPoint.zero // space between the cropping circle and the outside edge of the view let maskMargin = CGFloat(20) + + // MARK: - UI + + private lazy var scrollView: UIScrollView = { + let result: UIScrollView = UIScrollView() + result.delegate = self + result.minimumZoomScale = 1 + result.maximumZoomScale = 5 + result.showsHorizontalScrollIndicator = false + result.showsVerticalScrollIndicator = false +// result.clipsToBounds = false + + return result + }() + + private lazy var imageView: SessionImageView = { + let result: SessionImageView = SessionImageView(dataManager: dataManager) + result.loadImage(source) + + return result + }() // MARK: Initializers @@ -74,65 +75,20 @@ import SessionUtilitiesKit fatalError("init(coder:) has not been implemented") } - @objc required init( - srcImage: UIImage, + init( + source: ImageDataManager.DataSource, dstSizePixels: CGSize, - successCompletion: @escaping (CGRect, Data) -> Void + dataManager: ImageDataManagerType, + successCompletion: @escaping (ImageDataManager.DataSource, CGRect) -> Void ) { - // normalized() can be slightly expensive but in practice this is fine. - self.srcImage = srcImage.normalizedImage() + self.dataManager = dataManager + self.source = source self.dstSizePixels = dstSizePixels self.successCompletion = successCompletion + super.init(nibName: nil, bundle: nil) - configureCropAndScale() - } - - // MARK: Cropping and Scaling - - private func configureCropAndScale() { - // We use a "unit" view size (long dimension of length 1, short dimension reflects - // the dst aspect ratio) since we want to be able to perform this logic before we - // know the actual size of the cropped image view. - let unitSquareHeight: CGFloat = (dstAspectRatio >= 1.0 ? 1.0 : 1.0 / dstAspectRatio) - let unitSquareWidth: CGFloat = (dstAspectRatio >= 1.0 ? dstAspectRatio * unitSquareHeight : 1.0) - let unitSquareSize = CGSize(width: unitSquareWidth, height: unitSquareHeight) - - srcImageSizePoints = srcImage.size - guard - (srcImageSizePoints.width > 0 && srcImageSizePoints.height > 0) else { - return - } - - // Default - - // The "default" (no scaling, no translation) crop frame, expressed in - // srcImage's coordinate system. - srcDefaultCropSizePoints = defaultCropSizePoints(dstSizePoints: unitSquareSize) - assert(srcImageSizePoints.width >= srcDefaultCropSizePoints.width) - assert(srcImageSizePoints.height >= srcDefaultCropSizePoints.height) - - // By default, center the crop region in the src image. - srcTranslation = CGPoint(x: (srcImageSizePoints.width - srcDefaultCropSizePoints.width) * 0.5, - y: (srcImageSizePoints.height - srcDefaultCropSizePoints.height) * 0.5) - } - - // Given a dst size, find the size of the largest crop region - // that fits in the src image. - private func defaultCropSizePoints(dstSizePoints: CGSize) -> (CGSize) { - assert(srcImageSizePoints.width > 0) - assert(srcImageSizePoints.height > 0) - - let imageAspectRatio = srcImageSizePoints.width / srcImageSizePoints.height - let dstAspectRatio = dstSizePoints.width / dstSizePoints.height - - var dstCropSizePoints = CGSize.zero - if imageAspectRatio > dstAspectRatio { - dstCropSizePoints = CGSize(width: dstSizePoints.width / dstSizePoints.height * srcImageSizePoints.height, height: srcImageSizePoints.height) - } else { - dstCropSizePoints = CGSize(width: srcImageSizePoints.width, height: dstSizePoints.height / dstSizePoints.width * srcImageSizePoints.width) - } - return dstCropSizePoints + srcImageSizePoints = (source.sizeFromMetadata ?? .zero) } // MARK: View Lifecycle @@ -142,10 +98,19 @@ import SessionUtilitiesKit createViews() } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + if scrollView.minimumZoomScale == 1.0 && scrollView.bounds.width > 0 { + configureScrollView() + } + } // MARK: - Create Views private func createViews() { + title = "attachmentsMoveAndScale".localized() view.themeBackgroundColor = .backgroundPrimary let contentView = UIView() @@ -153,19 +118,22 @@ import SessionUtilitiesKit self.view.addSubview(contentView) contentView.pin(to: self.view) - let titleLabel: UILabel = UILabel() - titleLabel.font = .boldSystemFont(ofSize: Values.veryLargeFontSize) - titleLabel.text = "attachmentsMoveAndScale".localized() - titleLabel.themeTextColor = .textPrimary - titleLabel.textAlignment = .center - contentView.addSubview(titleLabel) - titleLabel.set(.width, to: .width, of: contentView) - - let titleLabelMargin = Values.scaleFromIPhone5(16) - titleLabel.pin(.top, to: .top, of: titleLabel.safeAreaLayoutGuide, withInset: titleLabelMargin) + contentView.addSubview(scrollView) + scrollView.pin(.top, to: .top, of: contentView, withInset: (Values.massiveSpacing + Values.smallSpacing)) + scrollView.pin(.leading, to: .leading, of: contentView) + scrollView.pin(.trailing, to: .trailing, of: contentView) + + imageView.frame = CGRect(origin: .zero, size: srcImageSizePoints) + scrollView.addSubview(imageView) + scrollView.contentSize = srcImageSizePoints + + let buttonRowBackground: UIView = UIView() + buttonRowBackground.themeBackgroundColor = .backgroundPrimary + contentView.addSubview(buttonRowBackground) let buttonRow: UIView = createButtonRow() contentView.addSubview(buttonRow) + buttonRow.pin(.top, to: .bottom, of: scrollView) buttonRow.pin(.leading, to: .leading, of: contentView) buttonRow.pin(.trailing, to: .trailing, of: contentView) buttonRow.pin(.bottom, to: .bottom, of: contentView) @@ -177,34 +145,16 @@ import SessionUtilitiesKit (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? Values.mediumSpacing) ) ) - - let imageView = OWSLayerView(frame: CGRect.zero, layoutCallback: { [weak self] _ in - guard let strongSelf = self else { return } - strongSelf.updateImageLayout() - }) - imageView.clipsToBounds = true - self.imageView = imageView - contentView.addSubview(imageView) - imageView.pin(.top, to: .top, of: contentView, withInset: (Values.massiveSpacing + Values.smallSpacing)) - imageView.pin(.leading, to: .leading, of: contentView) - imageView.pin(.trailing, to: .trailing, of: contentView) - imageView.pin(.bottom, to: .top, of: buttonRow) - - let imageLayer = CALayer() - self.imageLayer = imageLayer - imageLayer.contents = srcImage.cgImage - imageView.layer.addSublayer(imageLayer) - + buttonRowBackground.pin(to: buttonRow) + let maskingView = BezierPathView() contentView.addSubview(maskingView) maskingView.configureShapeLayer = { [weak self] layer, bounds in - guard let strongSelf = self else { - return - } + guard let self = self else { return } + let path = UIBezierPath(rect: bounds) - - let circleRect = strongSelf.cropFrame(forBounds: bounds) + let circleRect = cropFrame(forBounds: bounds) let radius = circleRect.size.width * 0.5 let circlePath = UIBezierPath(roundedRect: circleRect, cornerRadius: radius) @@ -220,10 +170,46 @@ import SessionUtilitiesKit maskingView.pin(.leading, to: .leading, of: contentView) maskingView.pin(.trailing, to: .trailing, of: contentView) maskingView.pin(.bottom, to: .top, of: buttonRow) - - contentView.isUserInteractionEnabled = true - contentView.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(sender:)))) - contentView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(handlePan(sender:)))) + } + + private func configureScrollView() { + guard srcImageSizePoints.width > 0 && srcImageSizePoints.height > 0 else { return } + + let scrollViewBounds = scrollView.bounds + guard scrollViewBounds.width > 0 && scrollViewBounds.height > 0 else { return } + + // Get the crop circle size + let cropCircleSize = min(scrollViewBounds.width, scrollViewBounds.height) - (maskMargin * 2) + + // Calculate the scale to fit the image to fill the crop circle + let widthScale = cropCircleSize / srcImageSizePoints.width + let heightScale = cropCircleSize / srcImageSizePoints.height + let minScale = max(widthScale, heightScale) // Fill, not fit + let maxScale = minScale * 5.0 + + scrollView.minimumZoomScale = minScale + scrollView.maximumZoomScale = maxScale + + // Start at minimum scale (fills the circle) + scrollView.zoomScale = minScale + + // Center the content + centerScrollViewContents() + } + + private func centerScrollViewContents() { + let scrollViewSize = scrollView.bounds.size + let imageViewSize = imageView.frame.size + + let horizontalInset = max(0, (scrollViewSize.width - imageViewSize.width) / 2) + let verticalInset = max(0, (scrollViewSize.height - imageViewSize.height) / 2) + + scrollView.contentInset = UIEdgeInsets( + top: verticalInset, + left: horizontalInset, + bottom: verticalInset, + right: horizontalInset + ) } // Given the current bounds for the image view, return the frame of the @@ -234,214 +220,13 @@ import SessionUtilitiesKit let circleRect = CGRect(x: bounds.size.width * 0.5 - radius, y: bounds.size.height * 0.5 - radius, width: radius * 2, height: radius * 2) return circleRect } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - updateImageLayout() - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - self.view.layoutSubviews() - updateImageLayout() - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - updateImageLayout() - } - - // Given a src image size and a dst view size, this finds the bounds - // of the largest rectangular crop region with the correct dst aspect - // ratio that fits in the src image's aspect ratio, in src image point - // coordinates. - private func defaultCropFramePoints(imageSizePoints: CGSize, viewSizePoints: CGSize) -> (CGRect) { - let imageAspectRatio = imageSizePoints.width / imageSizePoints.height - let viewAspectRatio = viewSizePoints.width / viewSizePoints.height - - var defaultCropSizePoints = CGSize.zero - if imageAspectRatio > viewAspectRatio { - defaultCropSizePoints = CGSize(width: viewSizePoints.width / viewSizePoints.height * imageSizePoints.height, height: imageSizePoints.height) - } else { - defaultCropSizePoints = CGSize(width: imageSizePoints.width, height: viewSizePoints.height / viewSizePoints.width * imageSizePoints.width) - } - - let defaultCropOriginPoints = CGPoint(x: (imageSizePoints.width - defaultCropSizePoints.width) * 0.5, - y: (imageSizePoints.height - defaultCropSizePoints.height) * 0.5) - assert(defaultCropOriginPoints.x >= 0) - assert(defaultCropOriginPoints.y >= 0) - assert(defaultCropOriginPoints.x <= imageSizePoints.width - defaultCropSizePoints.width) - assert(defaultCropOriginPoints.y <= imageSizePoints.height - defaultCropSizePoints.height) - return CGRect(origin: defaultCropOriginPoints, size: defaultCropSizePoints) - } - - // Updates the image view _AND_ normalizes the current scale/translate state. - private func updateImageLayout() { - guard let imageView = self.imageView else { - return - } - guard srcImageSizePoints.width > 0 && srcImageSizePoints.height > 0 else { - return - } - guard srcDefaultCropSizePoints.width > 0 && srcDefaultCropSizePoints.height > 0 else { - return - } - - // The size of the image view (should be full screen). - let imageViewSizePoints = imageView.frame.size - guard - (imageViewSizePoints.width > 0 && imageViewSizePoints.height > 0) else { - return - } - // The frame of the crop circle within the image view. - let cropFrame = self.cropFrame(forBounds: CGRect(origin: CGPoint.zero, size: imageViewSizePoints)) - - // Normalize the scaling property. - imageScale = max(kMinImageScale, min(kMaxImageScale, imageScale)) - - let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) - - let minSrcTranslationPoints = CGPoint.zero - - // Prevent panning outside of image area. - let maxSrcTranslationPoints = CGPoint(x: srcImageSizePoints.width - srcCropSizePoints.width, - y: srcImageSizePoints.height - srcCropSizePoints.height - ) - - // Normalize the translation property - srcTranslation = CGPoint(x: max(minSrcTranslationPoints.x, min(maxSrcTranslationPoints.x, srcTranslation.x)), - y: max(minSrcTranslationPoints.y, min(maxSrcTranslationPoints.y, srcTranslation.y))) - - // The frame of the image layer in crop frame coordinates. - let rawImageLayerFrame = imageRenderRect(forDstSize: cropFrame.size) - // The frame of the image layer in image view coordinates. - let imageLayerFrame = CGRect(x: rawImageLayerFrame.origin.x + cropFrame.origin.x, - y: rawImageLayerFrame.origin.y + cropFrame.origin.y, - width: rawImageLayerFrame.size.width, - height: rawImageLayerFrame.size.height) - - // Disable implicit animations for snappier panning/zooming. - CATransaction.begin() - CATransaction.setDisableActions(true) - - imageLayer.frame = imageLayerFrame - - CATransaction.commit() - } - - // Give the size of a given view or image context into which we - // will render the source image, return the frame (in that - // view/context's coordinate system) to render the source image. - // - // Gathering this logic in a single function ensures that the - // output will be WYSIWYG with the view state. - private func imageRenderRect(forDstSize dstSize: CGSize) -> CGRect { - - let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) - - let srcToViewRatio = dstSize.width / srcCropSizePoints.width - - return CGRect(origin: CGPoint(x: srcTranslation.x * -srcToViewRatio, - y: srcTranslation.y * -srcToViewRatio), - size: CGSize(width: srcImageSizePoints.width * +srcToViewRatio, - height: srcImageSizePoints.height * +srcToViewRatio - )) - } - - var srcTranslationAtPinchStart: CGPoint = CGPoint.zero - var imageScaleAtPinchStart: CGFloat = 0 - var lastPinchLocation: CGPoint = CGPoint.zero - var lastPinchScale: CGFloat = 1.0 - - @objc func handlePinch(sender: UIPinchGestureRecognizer) { - switch sender.state { - case .possible: break - case .began: - srcTranslationAtPinchStart = srcTranslation - imageScaleAtPinchStart = imageScale - - lastPinchLocation = - sender.location(in: sender.view) - lastPinchScale = sender.scale - - case .changed, .ended: - if sender.numberOfTouches > 1 { - let location = - sender.location(in: sender.view) - let scaleDiff = sender.scale / lastPinchScale - - // Update scaling. - let srcCropSizeBeforeScalePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) - imageScale = max(kMinImageScale, min(kMaxImageScale, imageScale * scaleDiff)) - let srcCropSizeAfterScalePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) - // Since the translation state reflects the "upper left" corner of the crop region, we need to - // adjust the translation when scaling to preserve the "center" of the crop region. - srcTranslation.x += (srcCropSizeBeforeScalePoints.width - srcCropSizeAfterScalePoints.width) * 0.5 - srcTranslation.y += (srcCropSizeBeforeScalePoints.height - srcCropSizeAfterScalePoints.height) * 0.5 - - // Update translation. - let viewSizePoints = imageView.frame.size - let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) - - let viewToSrcRatio = srcCropSizePoints.width / viewSizePoints.width - - let gestureTranslation = CGPoint(x: location.x - lastPinchLocation.x, - y: location.y - lastPinchLocation.y) - - srcTranslation = CGPoint(x: srcTranslation.x + gestureTranslation.x * -viewToSrcRatio, - y: srcTranslation.y + gestureTranslation.y * -viewToSrcRatio) - - lastPinchLocation = location - lastPinchScale = sender.scale - } - - case .cancelled, .failed: - srcTranslation = srcTranslationAtPinchStart - imageScale = imageScaleAtPinchStart - - @unknown default: break - } - - updateImageLayout() + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return imageView } - var srcTranslationAtPanStart: CGPoint = CGPoint.zero - - @objc func handlePan(sender: UIPanGestureRecognizer) { - switch sender.state { - case .possible: break - case .began: - srcTranslationAtPanStart = srcTranslation - - case .changed, .ended: - let viewSizePoints = imageView.frame.size - let srcCropSizePoints = CGSize(width: srcDefaultCropSizePoints.width / imageScale, - height: srcDefaultCropSizePoints.height / imageScale) - - let viewToSrcRatio = srcCropSizePoints.width / viewSizePoints.width - - let gestureTranslation = - sender.translation(in: sender.view) - - // Update translation. - srcTranslation = CGPoint(x: srcTranslationAtPanStart.x + gestureTranslation.x * -viewToSrcRatio, - y: srcTranslationAtPanStart.y + gestureTranslation.y * -viewToSrcRatio) - - case .cancelled, .failed: - srcTranslation = srcTranslationAtPanStart - - @unknown default: break - } - - updateImageLayout() + func scrollViewDidZoom(_ scrollView: UIScrollView) { + centerScrollViewContents() } private func createButtonRow() -> UIView { @@ -479,48 +264,59 @@ import SessionUtilitiesKit // MARK: - Event Handlers - @objc func cancelPressed(sender: UIButton) { + @objc func cancelPressed() { dismiss(animated: true, completion: nil) } - @objc func donePressed(sender: UIButton) { - let successCompletion = self.successCompletion - let dstSizePixels = self.dstSizePixels + @objc func donePressed() { dismiss(animated: true, completion: { [weak self] in - guard - let dstImageData: Data = self?.generateDstImageData(), - let imageViewFrame: CGRect = self?.imageRenderRect(forDstSize: dstSizePixels) - else { return } + guard let self = self else { return } - successCompletion(imageViewFrame, dstImageData) + self.successCompletion(self.source, self.calculateCropRect()) }) } - - // MARK: - Output - - func generateDstImage() -> UIImage? { - let hasAlpha = false - let dstScale: CGFloat = 1.0 // The size is specified in pixels, not in points. - UIGraphicsBeginImageContextWithOptions(dstSizePixels, !hasAlpha, dstScale) - - guard let context = UIGraphicsGetCurrentContext() else { - Log.error("[CropScaleImageViewController] Could not generate dst image.") - return nil - } - context.interpolationQuality = .high - - let imageViewFrame = imageRenderRect(forDstSize: dstSizePixels) - srcImage.draw(in: imageViewFrame) - - guard let scaledImage = UIGraphicsGetImageFromCurrentImageContext() else { - Log.error("[CropScaleImageViewController] Could not generate dst image.") - return nil - } - UIGraphicsEndImageContext() - return scaledImage - } - func generateDstImageData() -> Data? { - return generateDstImage().map { $0.pngData() } + // MARK: - Internal Functions + + private func calculateCropRect() -> CGRect { + let scrollViewBounds = scrollView.bounds + let cropCircleFrame = cropFrame(forBounds: scrollViewBounds) + + // Convert crop circle frame to image coordinates + let zoomScale = scrollView.zoomScale + let contentOffset = scrollView.contentOffset + let contentInset = scrollView.contentInset + + // Crop circle center in scroll view coordinates + let cropCenterX = cropCircleFrame.midX + let cropCenterY = cropCircleFrame.midY + + // Convert to content coordinates + let contentX = (cropCenterX + contentOffset.x - contentInset.left) / zoomScale + let contentY = (cropCenterY + contentOffset.y - contentInset.top) / zoomScale + + // Crop size in image coordinates + let cropSize = cropCircleFrame.width / zoomScale + + // Convert to normalized coordinates (0-1) + let normalizedX = (contentX - cropSize / 2) / srcImageSizePoints.width + let normalizedY = (contentY - cropSize / 2) / srcImageSizePoints.height + let normalizedWidth = cropSize / srcImageSizePoints.width + let normalizedHeight = cropSize / srcImageSizePoints.height + + // Clamp to valid range [0, 1] and ensure width/height don't exceed bounds + let clampedX = max(0, min(1 - normalizedWidth, normalizedX)) + let clampedY = max(0, min(1 - normalizedHeight, normalizedY)) + let clampedWidth = min(1.0, normalizedWidth, 1.0 - clampedX) + let clampedHeight = min(1.0, normalizedHeight, 1.0 - clampedY) + + return CGRect( + x: clampedX, + y: clampedY, + width: clampedWidth, + height: clampedHeight + ) } } +// TODO: Fix modal on Dean's calls PR +// TODO: Create the libSession PR to re-enable the profile_updated stuff, also merge in the attachment encryption stuff diff --git a/Session/Media Viewing & Editing/OWSImagePickerController.swift b/Session/Media Viewing & Editing/OWSImagePickerController.swift deleted file mode 100644 index b00fb5ed0c..0000000000 --- a/Session/Media Viewing & Editing/OWSImagePickerController.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import UIKit -import SessionUtilitiesKit - -@objc class OWSImagePickerController: UIImagePickerController { - - // MARK: Orientation - - override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { - return (UIDevice.current.isIPad ? .all : .portrait) - } -} diff --git a/Session/Media Viewing & Editing/PhotoLibrary.swift b/Session/Media Viewing & Editing/PhotoLibrary.swift index 3f2aac3eb7..8769bce2e3 100644 --- a/Session/Media Viewing & Editing/PhotoLibrary.swift +++ b/Session/Media Viewing & Editing/PhotoLibrary.swift @@ -215,7 +215,7 @@ class PhotoCollectionContents { options.isNetworkAccessAllowed = true options.deliveryMode = .highQualityFormat - return try await withCheckedThrowingContinuation { [imageManager] continuation in + let pendingAttachment: PendingAttachment = try await withCheckedThrowingContinuation { [imageManager] continuation in imageManager.requestImageDataAndOrientation(for: asset, options: options) { imageData, dataUTI, orientation, info in if let error: Error = info?[PHImageErrorKey] as? Error { return continuation.resume(throwing: error) @@ -252,6 +252,36 @@ class PhotoCollectionContents { ) } } + + /// Apple likes to use special formats for media so in order to maintain compatibility with other clients we want to + /// convert the selected image into a `WebP` if it's not one of the supported output types + guard UTType.supportedOutputImageTypes.contains(pendingAttachment.utType) else { + /// Since we need to convert the file we should clean up the temporary one we created earlier (the conversion will create + /// a new one) + defer { + switch pendingAttachment.source { + case .file(let url), .media(.url(let url)): + if dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(url.path) { + try? dependencies[singleton: .fileManager].removeItem(atPath: url.path) + } + default: break + } + } + + let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( + operations: [.convert(to: .webPLossy)], + using: dependencies + ) + + return PendingAttachment( + source: .media(.url(URL(fileURLWithPath: preparedAttachment.filePath))), + utType: .webP, + sourceFilename: pendingAttachment.sourceFilename, + using: dependencies + ) + } + + return pendingAttachment } private func requestVideoDataSource( @@ -277,51 +307,38 @@ class PhotoCollectionContents { } let compatiblePresets = AVAssetExportSession.exportPresets(compatibleWith: avAsset) - var bestExportPreset: String - - if compatiblePresets.contains(AVAssetExportPresetPassthrough) { - bestExportPreset = AVAssetExportPresetPassthrough - Log.debug("[PhotoLibrary] Using Passthrough export preset.") - } else { - bestExportPreset = AVAssetExportPresetHighestQuality - Log.debug("[PhotoLibrary] Passthrough not available. Falling back to HighestQuality export preset.") - } - - /// Apple likes to use special formats for media so in order to maintain compatibility with other clients we want to - /// convert the selected video into an `mp4` - guard let exportSession: AVAssetExportSession = AVAssetExportSession(asset: avAsset, presetName: bestExportPreset) else { - return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "exportSession was unexpectedly nil")) - } - - exportSession.outputFileType = AVFileType.mp4 - exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() - - let exportPath = dependencies[singleton: .fileManager].temporaryFilePath(fileExtension: "mp4") // stringlint:ignore - let exportURL = URL(fileURLWithPath: exportPath) - exportSession.outputURL = exportURL + let bestExportPreset: String = (compatiblePresets.contains(AVAssetExportPresetPassthrough) ? + AVAssetExportPresetPassthrough : + AVAssetExportPresetHighestQuality + ) + let exportPath: String = dependencies[singleton: .fileManager].temporaryFilePath() - Log.debug("[PhotoLibrary] Starting video export") - exportSession.exportAsynchronously { [weak exportSession] in - Log.debug("[PhotoLibrary] Completed video export") - - guard exportSession?.status == .completed else { - return continuation.resume(throwing: PhotoLibraryError.assertionError(description: "Failed to build data source for exported video URL")) - } - - continuation.resume( - returning: PendingAttachment( - source: .media( - .videoUrl( - exportURL, - .mpeg4Movie, - nil, - dependencies[singleton: .attachmentManager] - ) - ), - utType: .mpeg4Movie, - using: dependencies + Task { + do { + /// Apple likes to use special formats for media so in order to maintain compatibility with other clients we want to + /// convert the selected video into an `mp4` + try await PendingAttachment.convertToMpeg4( + asset: avAsset, + presetName: bestExportPreset, + filePath: exportPath ) - ) + + continuation.resume( + returning: PendingAttachment( + source: .media( + .videoUrl( + URL(fileURLWithPath: exportPath), + .mpeg4Movie, + nil, + dependencies[singleton: .attachmentManager] + ) + ), + utType: .mpeg4Movie, + using: dependencies + ) + ) + } + catch { continuation.resume(throwing: error) } } } } diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift index 42f2c354eb..5e9efbab02 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift @@ -259,7 +259,7 @@ class DeveloperSettingsFileServerViewModel: SessionTableViewModel, NavigatableSt id: .customFileServerPubkey, title: "Custom File Server Public Key", subtitle: """ - The public key to use for the above custom File Server + The public key to use for the above custom File Server (if empty then the pubkey for the default file server will be used) Current: \(state.pendingState.customFileServer.pubkey.isEmpty ? "Default" : state.pendingState.customFileServer.pubkey) """, @@ -309,7 +309,12 @@ class DeveloperSettingsFileServerViewModel: SessionTableViewModel, NavigatableSt return (url.scheme != nil && url.host != nil) }, - cancelStyle: .alert_text, + cancelTitle: (pendingState.customFileServer.url.isEmpty ? + "cancel".localized() : + "remove".localized() + ), + cancelStyle: (pendingState.customFileServer.url.isEmpty ? .alert_text : .danger), + hasCloseButton: !pendingState.customFileServer.url.isEmpty, dismissOnConfirm: false, onConfirm: { [weak self, dependencies] modal in guard @@ -332,6 +337,19 @@ class DeveloperSettingsFileServerViewModel: SessionTableViewModel, NavigatableSt ) ) ) + }, + onCancel: { [dependencies] modal in + modal.dismiss(animated: true) + + guard !pendingState.customFileServer.url.isEmpty else { return } + + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsFileServerViewModel.self), + value: pendingState.with( + customFileServer: pendingState.customFileServer.with(url: "") + ) + ) } ) ), @@ -378,7 +396,12 @@ class DeveloperSettingsFileServerViewModel: SessionTableViewModel, NavigatableSt value.trimmingCharacters(in: .whitespacesAndNewlines).count == 64 ) }, - cancelStyle: .alert_text, + cancelTitle: (pendingState.customFileServer.pubkey.isEmpty ? + "cancel".localized() : + "remove".localized() + ), + cancelStyle: (pendingState.customFileServer.pubkey.isEmpty ? .alert_text : .danger), + hasCloseButton: !pendingState.customFileServer.pubkey.isEmpty, dismissOnConfirm: false, onConfirm: { [weak self, dependencies] modal in guard @@ -402,6 +425,19 @@ class DeveloperSettingsFileServerViewModel: SessionTableViewModel, NavigatableSt ) ) ) + }, + onCancel: { [dependencies] modal in + modal.dismiss(animated: true) + + guard !pendingState.customFileServer.pubkey.isEmpty else { return } + + dependencies.notifyAsync( + priority: .immediate, + key: .updateScreen(DeveloperSettingsFileServerViewModel.self), + value: pendingState.with( + customFileServer: pendingState.customFileServer.with(pubkey: "") + ) + ) } ) ), diff --git a/Session/Settings/ImagePickerHandler.swift b/Session/Settings/ImagePickerHandler.swift index 94d379d03f..d2324040e6 100644 --- a/Session/Settings/ImagePickerHandler.swift +++ b/Session/Settings/ImagePickerHandler.swift @@ -1,76 +1,77 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import PhotosUI import UniformTypeIdentifiers +import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit -class ImagePickerHandler: NSObject, UIImagePickerControllerDelegate & UINavigationControllerDelegate { +class ImagePickerHandler: PHPickerViewControllerDelegate { + private let dependencies: Dependencies private let onTransition: (UIViewController, TransitionType) -> Void - private let onImageDataPicked: (String, Data) -> Void + private let onImagePicked: (ImageDataManager.DataSource, CGRect?) -> Void // MARK: - Initialization init( onTransition: @escaping (UIViewController, TransitionType) -> Void, - onImageDataPicked: @escaping (String, Data) -> Void + onImagePicked: @escaping (ImageDataManager.DataSource, CGRect?) -> Void, + using dependencies: Dependencies ) { + self.dependencies = dependencies self.onTransition = onTransition - self.onImageDataPicked = onImageDataPicked + self.onImagePicked = onImagePicked } // MARK: - UIImagePickerControllerDelegate - func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { - picker.dismiss(animated: true) - } - - func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { guard - let imageUrl: URL = info[.imageURL] as? URL, - let rawAvatar: UIImage = info[.originalImage] as? UIImage + let result: PHPickerResult = results.first, + let typeIdentifier: String = result.itemProvider.registeredTypeIdentifiers.first else { - picker.presentingViewController?.dismiss(animated: true) + picker.dismiss(animated: true) return } - picker.presentingViewController?.dismiss(animated: true) { [weak self] in - // Check if the user selected an animated image (if so then don't crop, just - // set the avatar directly - guard - let resourceValues: URLResourceValues = (try? imageUrl.resourceValues(forKeys: [.typeIdentifierKey])), - let type: Any = resourceValues.allValues.first?.value, - let typeString: String = type as? String, - UTType.isAnimated(typeString) - else { - let viewController: CropScaleImageViewController = CropScaleImageViewController( - srcImage: rawAvatar, - dstSizePixels: CGSize( - width: DisplayPictureManager.maxDimension, - height: DisplayPictureManager.maxDimension - ), - successCompletion: { cropFrame, resultImageData in - let croppedImagePath: String = imageUrl - .deletingLastPathComponent() - .appendingPathComponent([ - "\(Int(round(cropFrame.minX)))", - "\(Int(round(cropFrame.minY)))", - "\(Int(round(cropFrame.width)))", - "\(Int(round(cropFrame.height)))", - imageUrl.lastPathComponent - ].joined(separator: "-")) // stringlint:ignore - .path + picker.dismiss(animated: true) { [weak self] in + result.itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in + guard let self = self else { return } + guard let url: URL = url else { + print("Error loading file: \(error?.localizedDescription ?? "unknown")") + return + } + + do { + let onImagePicked: (ImageDataManager.DataSource, CGRect?) -> Void = self.onImagePicked + let filePath: String = self.dependencies[singleton: .fileManager].temporaryFilePath() + try self.dependencies[singleton: .fileManager].copyItem( + atPath: url.path, + toPath: filePath + ) + // TODO: Need to remove file when we are done + DispatchQueue.main.async { [weak self, dataManager = self.dependencies[singleton: .imageDataManager]] in + let viewController: CropScaleImageViewController = CropScaleImageViewController( + source: .url(URL(fileURLWithPath: filePath)), + dstSizePixels: CGSize( + width: DisplayPictureManager.maxDimension, + height: DisplayPictureManager.maxDimension + ), + dataManager: dataManager, + successCompletion: onImagePicked + ) - self?.onImageDataPicked(croppedImagePath, resultImageData) + self?.onTransition( + StyledNavigationController(rootViewController: viewController), + .present + ) } - ) - self?.onTransition(viewController, .present) - return + } + catch { + print("Error copying file: \(error)") + } } - - guard let imageData: Data = try? Data(contentsOf: URL(fileURLWithPath: imageUrl.path)) else { return } - - self?.onImageDataPicked(imageUrl.path, imageData) } } } diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 96a2a13057..73a064da87 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import PhotosUI import Combine import Lucide import GRDB @@ -20,9 +21,10 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl private var onDisplayPictureSelected: ((ConfirmationModal.ValueUpdate) -> Void)? private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler( onTransition: { [weak self] in self?.transitionToScreen($0, transitionType: $1) }, - onImageDataPicked: { [weak self] identifier, resultImageData in - self?.onDisplayPictureSelected?(.image(identifier: identifier, data: resultImageData)) - } + onImagePicked: { [weak self] source, cropRect in + self?.onDisplayPictureSelected?(.image(source: source, cropRect: cropRect)) + }, + using: dependencies ) /// This value is the current state of the view @@ -690,12 +692,15 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl dismissOnConfirm: false, onConfirm: { [weak self] modal in switch modal.info.body { - case .image(.some(let source), _, _, _, _, _, _): + case .image(.some(let source), _, _, let style, _, _, _): self?.updateProfile( displayPictureUpdateGenerator: { [weak self] in guard let self = self else { throw AttachmentError.uploadFailed } - return try await uploadDisplayPicture(source: source) + return try await uploadDisplayPicture( + source: source, + cropRect: style.cropRect + ) }, onComplete: { [weak modal] in modal?.close() } ) @@ -718,9 +723,11 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl @MainActor private func showPhotoLibraryForAvatar() { Permissions.requestLibraryPermissionIfNeeded(isSavingMedia: false, using: dependencies) { [weak self] in DispatchQueue.main.async { - let picker: UIImagePickerController = UIImagePickerController() - picker.sourceType = .photoLibrary - picker.mediaTypes = [ "public.image" ] // stringlint:ignore + var configuration: PHPickerConfiguration = PHPickerConfiguration() + configuration.selectionLimit = 1 + configuration.filter = .any(of: [.images, .livePhotos]) + + let picker: PHPickerViewController = PHPickerViewController(configuration: configuration) picker.delegate = self?.imagePickerHandler self?.transitionToScreen(picker, transitionType: .present) @@ -728,13 +735,19 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } } - fileprivate func uploadDisplayPicture(source: ImageDataManager.DataSource) async throws -> DisplayPictureManager.Update { + fileprivate func uploadDisplayPicture( + source: ImageDataManager.DataSource, + cropRect: CGRect? + ) async throws -> DisplayPictureManager.Update { let pendingAttachment: PendingAttachment = PendingAttachment( source: .media(source), using: dependencies ) - let preparedAttachment: PreparedAttachment = try dependencies[singleton: .displayPictureManager] - .prepareDisplayPicture(attachment: pendingAttachment) + let preparedAttachment: PreparedAttachment = try await dependencies[singleton: .displayPictureManager].prepareDisplayPicture( + attachment: pendingAttachment, + fallbackIfConversionTakesTooLong: true, + cropRect: cropRect + ) let result = try await dependencies[singleton: .displayPictureManager] .uploadDisplayPicture(preparedAttachment: preparedAttachment) diff --git a/SessionMessagingKit/Crypto/Crypto+Attachments.swift b/SessionMessagingKit/Crypto/Crypto+Attachments.swift index fc601b4190..76ecf208d1 100644 --- a/SessionMessagingKit/Crypto/Crypto+Attachments.swift +++ b/SessionMessagingKit/Crypto/Crypto+Attachments.swift @@ -31,12 +31,12 @@ public extension Crypto.Generator { private static var aesCBCIvLength: Int { 16 } private static var aesKeySize: Int { 32 } - static func expectedEncryptedAttachmentSize(plaintext: Data) -> Crypto.Generator { + static func expectedEncryptedAttachmentSize(plaintextSize: Int) -> Crypto.Generator { return Crypto.Generator( id: "expectedEncryptedAttachmentSize", - args: [plaintext] + args: [plaintextSize] ) { dependencies in - return session_attachment_encrypted_size(plaintext.count) + return session_attachment_encrypted_size(plaintextSize) } } @@ -78,6 +78,18 @@ public extension Crypto.Generator { } } + @available(*, deprecated, message: "This encryption method is deprecated and will be removed in a future release.") + static func legacyExpectedEncryptedAttachmentSize( + plaintextSize: Int + ) -> Crypto.Generator { + return Crypto.Generator( + id: "legacyExpectedEncryptedAttachmentSize", + args: [plaintextSize] + ) { dependencies in + return max(541, Int(floor(pow(1.05, ceil(log(Double(plaintextSize)) / log(1.05)))))) + } + } + @available(*, deprecated, message: "This encryption method is deprecated and will be removed in a future release.") static func legacyEncryptedAttachment( plaintext: Data @@ -156,7 +168,19 @@ public extension Crypto.Generator { return (Data(encryptedPaddedData), outKey, Data(digest)) } } - + + @available(*, deprecated, message: "This encryption method is deprecated and will be removed in a future release.") + static func legacyEncryptedDisplayPictureSize( + plaintextSize: Int + ) -> Crypto.Generator { + return Crypto.Generator( + id: "legacyEncryptedDisplayPictureSize", + args: [plaintextSize] + ) { dependencies in + return (plaintextSize + DisplayPictureManager.nonceLength + DisplayPictureManager.tagLength) + } + } + @available(*, deprecated, message: "This encryption method is deprecated and will be removed in a future release.") static func legacyEncryptedDisplayPicture( data: Data, diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 8b7a776a86..1a77b45a4e 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -132,7 +132,7 @@ public extension LinkPreview { return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution) } - static func generateAttachmentIfPossible(urlString: String, imageData: Data?, type: UTType, using dependencies: Dependencies) throws -> Attachment? { + static func generateAttachmentIfPossible(urlString: String, imageData: Data?, type: UTType, using dependencies: Dependencies) async throws -> Attachment? { guard let imageData: Data = imageData, !imageData.isEmpty else { return nil } let pendingAttachment: PendingAttachment = PendingAttachment( @@ -140,11 +140,9 @@ public extension LinkPreview { utType: type, using: dependencies ) - let preparedAttachment: PreparedAttachment = try pendingAttachment.prepare( - transformations: [ - .compress, - .convertToStandardFormats, - .resize(maxDimension: LinkPreview.maxImageDimension), + let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( + operations: [ + .convert(to: .webPLossy(maxDimension: LinkPreview.maxImageDimension)), .stripImageMetadata ], using: dependencies diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index 75354f3473..c02c11f647 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -187,19 +187,23 @@ public extension AttachmentUploadJob { static func preparePriorToUpload( attachments: [PendingAttachment], using dependencies: Dependencies - ) throws -> [Attachment] { - return try attachments.compactMap { pendingAttachment in + ) async throws -> [Attachment] { + var result: [Attachment] = [] + + for pendingAttachment in attachments { /// Strip any metadata from the attachment and store at a "Pending Upload" file path - let preparedAttachment: PreparedAttachment = try pendingAttachment.prepare( - transformations: [ + let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( + operations: [ .stripImageMetadata ], storeAtPendingAttachmentUploadPath: true, using: dependencies ) - return preparedAttachment.attachment + result.append(preparedAttachment.attachment) } + + return result } static func link( @@ -235,7 +239,7 @@ public extension AttachmentUploadJob { authMethod: AuthenticationMethod, onEvent: ((Event) async throws -> Void)?, using dependencies: Dependencies - ) async throws -> Attachment { + ) async throws -> (attachment: Attachment, response: FileUploadResponse) { let shouldEncrypt: Bool = { switch authMethod { case is Authentication.community: return false @@ -247,10 +251,10 @@ public extension AttachmentUploadJob { /// uploaded (in this case the attachment has already been uploaded so just succeed) if attachment.state == .uploaded, - Network.FileServer.fileId(for: attachment.downloadUrl) != nil, + let fileId: String = Network.FileServer.fileId(for: attachment.downloadUrl), !dependencies[singleton: .attachmentManager].isPlaceholderUploadUrl(attachment.downloadUrl) { - return attachment + return (attachment, FileUploadResponse(id: fileId, uploaded: nil, expires: nil)) } /// If the attachment is a downloaded attachment, check if it came from the server and if so just succeed immediately (no use @@ -259,14 +263,14 @@ public extension AttachmentUploadJob { /// **Note:** The most common cases for this will be for `LinkPreviews` if attachment.state == .downloaded, - Network.FileServer.fileId(for: attachment.downloadUrl) != nil, + let fileId: String = Network.FileServer.fileId(for: attachment.downloadUrl), !dependencies[singleton: .attachmentManager].isPlaceholderUploadUrl(attachment.downloadUrl), ( !shouldEncrypt || attachment.encryptionKey != nil ) { - return attachment + return (attachment, FileUploadResponse(id: fileId, uploaded: nil, expires: nil)) } /// If we have gotten here then we need to upload @@ -278,10 +282,8 @@ public extension AttachmentUploadJob { attachment: attachment, using: dependencies ) - let preparedAttachment: PreparedAttachment = try pendingAttachment.prepare( - transformations: Set([ - (shouldEncrypt ? .encrypt(domain: .attachment) : nil) - ].compactMap { $0 }), + let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( + operations: (shouldEncrypt ? [.encrypt(domain: .attachment)] : []), using: dependencies ) let maybePreparedData: Data? = dependencies[singleton: .fileManager] @@ -386,190 +388,7 @@ public extension AttachmentUploadJob { try await onEvent?(.success(uploadedAttachment, interactionId: interactionId)) try Task.checkCancellation() - return uploadedAttachment - } - - @available(*, deprecated, message: "Replace with an async/await call to `upload`") - static func preparedUpload( - attachment: Attachment, - authMethod: AuthenticationMethod, - using dependencies: Dependencies - ) throws -> (request: Network.PreparedRequest, attachment: Attachment, preparedAttachment: PreparedAttachment) { - let endpoint: (any EndpointType) = { - switch authMethod { - case let community as Authentication.community: - return Network.SOGS.Endpoint.roomFile(community.roomToken) - - default: return Network.FileServer.Endpoint.file - } - }() - let shouldEncrypt: Bool = { - switch authMethod { - case is Authentication.community: return false - default: return true - } - }() - - /// This can occur if an `AttachmentUploadJob` was explicitly created for a message dependant on the attachment being - /// uploaded (in this case the attachment has already been uploaded so just succeed) - if - attachment.state == .uploaded, - let fileId: String = Network.FileServer.fileId(for: attachment.downloadUrl), - !dependencies[singleton: .attachmentManager].isPlaceholderUploadUrl(attachment.downloadUrl) - { - return ( - try Network.PreparedRequest.cached( - FileUploadResponse(id: fileId, uploaded: nil, expires: nil), - endpoint: endpoint, - using: dependencies - ), - attachment, - PreparedAttachment( - attachment: attachment, - filePath: "" - ) - ) - } - - /// 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 then encrypt it - /// - /// **Note:** The most common cases for this will be for `LinkPreviews` - if - attachment.state == .downloaded, - let fileId: String = Network.FileServer.fileId(for: attachment.downloadUrl), - !dependencies[singleton: .attachmentManager].isPlaceholderUploadUrl(attachment.downloadUrl), - ( - !shouldEncrypt || ( - attachment.encryptionKey != nil && - attachment.digest != nil - ) - ) - { - return ( - try Network.PreparedRequest.cached( - FileUploadResponse(id: fileId, uploaded: nil, expires: nil), - endpoint: endpoint, - using: dependencies - ), - attachment, - PreparedAttachment( - attachment: attachment, - filePath: "" - ) - ) - } - - /// Encrypt the attachment if needed - let pendingAttachment: PendingAttachment = try PendingAttachment( - attachment: attachment, - using: dependencies - ) - let preparedAttachment: PreparedAttachment = try pendingAttachment.prepare( - transformations: Set([ - (shouldEncrypt ? .encrypt(domain: .attachment) : nil) - ].compactMap { $0 }), - using: dependencies - ) - let maybePreparedData: Data? = dependencies[singleton: .fileManager] - .contents(atPath: preparedAttachment.filePath) - - guard let preparedData: Data = maybePreparedData else { - Log.error(.cat, "Couldn't retrieve prepared attachment data.") - throw AttachmentError.invalidData - } - - /// Ensure the file size is smaller than our upload limit - Log.info(.cat, "File size: \(preparedData.count) bytes.") - guard preparedData.count <= Network.maxFileSize else { throw NetworkError.maxFileSizeExceeded } - - /// Return the request and the prepared attachment - switch authMethod { - case let communityAuth as Authentication.community: - return ( - try Network.SOGS.preparedUpload( - data: preparedData, - roomToken: communityAuth.roomToken, - authMethod: communityAuth, - using: dependencies - ), - attachment, - preparedAttachment - ) - - default: - return ( - try Network.FileServer.preparedUpload( - data: preparedData, - using: dependencies - ), - attachment, - preparedAttachment - ) - } - } - - @available(*, deprecated, message: "Replace with an async/await call to `upload`") - static func processUploadResponse( - originalAttachment: Attachment, - preparedAttachment: PreparedAttachment, - authMethod: AuthenticationMethod, - response: FileUploadResponse, - using dependencies: Dependencies - ) throws -> Attachment { - /// If the `downloadUrl` previously had a value and we are updating it then we need to move the file from it's current location - /// to the hash that would be generated for the new location - let finalDownloadUrl: String = { - let isPlaceholderUploadUrl: Bool = dependencies[singleton: .attachmentManager] - .isPlaceholderUploadUrl(originalAttachment.downloadUrl) - - switch (originalAttachment.downloadUrl, isPlaceholderUploadUrl, authMethod) { - case (.some(let downloadUrl), false, _): return downloadUrl - case (_, _, let community as Authentication.community): - return Network.SOGS.downloadUrlString( - for: response.id, - server: community.server, - roomToken: community.roomToken - ) - - default: - return Network.FileServer.downloadUrlString( - for: response.id, - using: dependencies - ) - } - }() - - if - let oldUrl: String = originalAttachment.downloadUrl, - finalDownloadUrl != oldUrl, - let oldPath: String = try? dependencies[singleton: .attachmentManager].path(for: oldUrl), - let newPath: String = try? dependencies[singleton: .attachmentManager].path(for: finalDownloadUrl) - { - try dependencies[singleton: .fileManager].moveItem(atPath: oldPath, toPath: newPath) - } - - return Attachment( - id: preparedAttachment.attachment.id, - serverId: response.id, - variant: preparedAttachment.attachment.variant, - state: .uploaded, - contentType: preparedAttachment.attachment.contentType, - byteCount: preparedAttachment.attachment.byteCount, - creationTimestamp: ( - preparedAttachment.attachment.creationTimestamp ?? - (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) - ), - sourceFilename: preparedAttachment.attachment.sourceFilename, - downloadUrl: finalDownloadUrl, - width: preparedAttachment.attachment.width, - height: preparedAttachment.attachment.height, - duration: preparedAttachment.attachment.duration, - isVisualMedia: preparedAttachment.attachment.isVisualMedia, - isValid: preparedAttachment.attachment.isValid, - encryptionKey: preparedAttachment.attachment.encryptionKey, - digest: preparedAttachment.attachment.digest - ) + return (uploadedAttachment, response) } /// This function performs the standard database actions when various upload events occur diff --git a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift index 1d0a98fbb7..8248959bbb 100644 --- a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift +++ b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift @@ -125,14 +125,11 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { ) guard - try profile.profileLastUpdated == 0 || + profile.profileLastUpdated == 0 || dependencies.dateNow.timeIntervalSince(lastUpdated) > maxReuploadFrequency || dependencies[feature: .shortenFileTTL] || - pendingDisplayPicture.needsPreparationForAttachmentUpload( - transformations: [ - .convertToStandardFormats, - .resize(maxDimension: DisplayPictureManager.maxDimension) - ] + dependencies[singleton: .displayPictureManager].reuploadNeedsPreparation( + attachment: pendingDisplayPicture ) else { /// Reset the `nextRunTimestamp` value just in case the last run failed so we don't get stuck in a loop endlessly @@ -152,14 +149,8 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { } /// Prepare and upload the display picture - let preparedAttachment: PreparedAttachment = try dependencies[singleton: .displayPictureManager] - .prepareDisplayPicture( - attachment: pendingDisplayPicture, - transformations: [ - .convertToStandardFormats, - .resize(maxDimension: DisplayPictureManager.maxDimension) - ] - ) + let preparedAttachment: PreparedAttachment = try await dependencies[singleton: .displayPictureManager] + .prepareDisplayPicture(attachment: pendingDisplayPicture) let result = try await dependencies[singleton: .displayPictureManager] .uploadDisplayPicture(preparedAttachment: preparedAttachment) diff --git a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift index dece2a14b7..4f777b3ccd 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift @@ -21,6 +21,7 @@ public enum AttachmentError: Error, CustomStringConvertible { case fileSizeTooLarge case invalidData case couldNotParseImage + case couldNotConvert case couldNotConvertToJpeg case couldNotConvertToMpeg4 case couldNotConvertToWebP @@ -33,6 +34,8 @@ public enum AttachmentError: Error, CustomStringConvertible { case alreadyDownloaded(String?) case downloadNoLongerValid case databaseChangesFailed + case conversionTimeout + case conversionResultedInLargerFile case invalidMediaSource case invalidDimensions @@ -57,6 +60,8 @@ public enum AttachmentError: Error, CustomStringConvertible { case .alreadyDownloaded: return "File already downloaded." case .downloadNoLongerValid: return "Download is no longer valid." case .databaseChangesFailed: return "Database changes failed." + case .conversionTimeout: return "Conversion timed out." + case .conversionResultedInLargerFile: return "Conversion resulted in a larger file." case .invalidMediaSource: return "Invalid media source." case .invalidDimensions: return "Invalid dimensions." @@ -66,7 +71,7 @@ public enum AttachmentError: Error, CustomStringConvertible { case .invalidData, .missingData, .invalidFileFormat, .invalidImageData: return "attachmentsErrorNotSupported".localized() - case .couldNotConvertToJpeg, .couldNotParseImage, .couldNotConvertToMpeg4, + case .couldNotConvert, .couldNotConvertToJpeg, .couldNotParseImage, .couldNotConvertToMpeg4, .couldNotConvertToWebP, .couldNotResizeImage: return "attachmentsErrorOpen".localized() diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index 914e8268a3..401bbe1647 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -21,6 +21,7 @@ extension MessageSender { name: String, description: String?, displayPicture: ImageDataManager.DataSource?, + displayPictureCropRect: CGRect?, members: [(String, Profile?)], using dependencies: Dependencies ) async throws -> SessionThread { @@ -35,8 +36,8 @@ extension MessageSender { source: .media(source), using: dependencies ) - let preparedAttachment: PreparedAttachment = try dependencies[singleton: .displayPictureManager] - .prepareDisplayPicture(attachment: pendingAttachment) + let preparedAttachment: PreparedAttachment = try await dependencies[singleton: .displayPictureManager] + .prepareDisplayPicture(attachment: pendingAttachment, cropRect: displayPictureCropRect) displayPictureInfo = try await dependencies[singleton: .displayPictureManager] .uploadDisplayPicture(preparedAttachment: preparedAttachment) } diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index 86aaee7af7..b1970e35fa 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -473,155 +473,253 @@ public struct PreparedAttachment: Sendable, Equatable, Hashable { } } -// MARK: - Transforms +// MARK: - Operation public extension PendingAttachment { - enum Transform: Sendable, Equatable, Hashable { - case compress - case convertToStandardFormats - case resize(maxDimension: CGFloat) + enum Operation: Sendable, Equatable, Hashable { + case convert(to: ConversionFormat) case stripImageMetadata case encrypt(domain: Crypto.AttachmentDomain) fileprivate enum Erased: Equatable { - case compress - case convertToStandardFormats - case resize + case convert case stripImageMetadata case encrypt } fileprivate var erased: Erased { switch self { - case .compress: return .compress - case .convertToStandardFormats: return .convertToStandardFormats - case .resize: return .resize + case .convert: return .convert case .stripImageMetadata: return .stripImageMetadata case .encrypt: return .encrypt } } } + enum ConversionFormat: Sendable, Equatable, Hashable { + case current + case mp4 + + /// A `compressionQuality` value of `0` gives the smallest size and `1` the largest + case webPLossy(maxDimension: CGFloat?, cropRect: CGRect?, compressionQuality: CGFloat) + + /// A `compressionEffort` value of `0` is the fastest (but gives larger files) and a value of `1` is the slowest but compresses the most + case webPLossless(maxDimension: CGFloat?, cropRect: CGRect?, compressionEffort: CGFloat) + + case gif(maxDimension: CGFloat?, cropRect: CGRect?, compressionQuality: CGFloat) + + private static let defaultWebPCompressionQuality: CGFloat = 0.8 + private static let defaultWebPCompressionEffort: CGFloat = 0.25 + + public static var webPLossy: ConversionFormat { + .webPLossy(maxDimension: nil, cropRect: nil, compressionQuality: defaultWebPCompressionQuality) + } + public static func webPLossy(maxDimension: CGFloat? = nil, cropRect: CGRect? = nil) -> ConversionFormat { + .webPLossy(maxDimension: maxDimension, cropRect: cropRect, compressionQuality: defaultWebPCompressionQuality) + } + + public static var webPLossless: ConversionFormat { + .webPLossless(maxDimension: nil, cropRect: nil, compressionEffort: defaultWebPCompressionEffort) + } + public static func webPLossless(maxDimension: CGFloat? = nil, cropRect: CGRect? = nil) -> ConversionFormat { + .webPLossless(maxDimension: maxDimension, cropRect: cropRect, compressionEffort: defaultWebPCompressionEffort) + } + + private static let defaultGifCompressionQuality: CGFloat = 0.8 + public static var gif: ConversionFormat { + .gif(maxDimension: nil, cropRect: nil, compressionQuality: defaultGifCompressionQuality) + } + public static func gif(maxDimension: CGFloat? = nil, cropRect: CGRect? = nil) -> ConversionFormat { + .gif(maxDimension: maxDimension, cropRect: cropRect, compressionQuality: defaultGifCompressionQuality) + } + + var webPIsLossless: Bool { + switch self { + case .webPLossless: return true + default: return false + } + } + } + // MARK: - Encryption and Preparation - func needsPreparationForAttachmentUpload(transformations: Set) throws -> Bool { - switch source { - case .file: return try fileNeedsPreparation(transformations) - case .voiceMessage, .media: return try mediaNeedsPreparation(transformations) - case .text: return true /// Need to write to a file in order to upload as an attachment + func needsPreparation(operations: Set) -> Bool { + switch (source, metadata) { + case (_, .media(let mediaMetadata)): + return mediaNeedsPreparation(operations, metadata: mediaMetadata) + + case (.file, _): return fileNeedsPreparation(operations) + case (.text, _): return true /// Need to write to a file in order to upload as an attachment + + /// These cases are invalid so if they are called then just return `true` so the `prepare` function gets called (which + /// will then throw when going down an invalid path) + case (.voiceMessage, _), (.media, _): return true } } - private func fileNeedsPreparation(_ transformations: Set) throws -> Bool { + private func fileNeedsPreparation(_ operations: Set) -> Bool { /// Check the type of `metadata` we have (as if the `file` was actually media then the `metadata` will be `media` /// and as such we want to go down the `mediaNeedsPreparation` path) switch self.metadata { - case .none: throw AttachmentError.invalidData - case .media: return try mediaNeedsPreparation(transformations) - case .file: break + case .file, .none: break + case .media(let mediaMetadata): + return mediaNeedsPreparation(operations, metadata: mediaMetadata) } - for transformation in transformations { - switch transformation { + for operation in operations { + switch operation { case .encrypt: return true - case .compress, .convertToStandardFormats, .resize, .stripImageMetadata: - continue /// None of these are supported for general files + case .convert, .stripImageMetadata: continue /// None of these are supported for general files } } - /// None of the requested `transformations` were needed so the file doesn't need preparation + /// None of the requested `operations` were needed so the file doesn't need preparation return false } - private func mediaNeedsPreparation(_ transformations: Set) throws -> Bool { - guard case .media(let mediaMatadata) = self.metadata else { - throw AttachmentError.invalidMediaSource - } - guard mediaMatadata.hasValidPixelSize else { throw AttachmentError.invalidDimensions } - - for transformation in transformations { - switch transformation { + private func mediaNeedsPreparation( + _ operations: Set, + metadata: MediaUtils.MediaMetadata + ) -> Bool { + /// If the media does not have a valid pixel size then just return `true`, this will result in one of the `prepare` functions being + /// called which will throw due to the invalid size + guard metadata.hasValidPixelSize else { return true } + + for operation in operations { + switch operation { case .encrypt: return true - case .compress: - /// We don't currently want to compress animated images - guard mediaMatadata.frameCount == 1 else { continue } - - return true /// Otherwise if we've been told to expliclty compress then we should do so - - case .convertToStandardFormats: - /// We don't currently want to convert animated images - guard mediaMatadata.frameCount == 1 else { continue } - - switch mediaMatadata.utType { - case .png: return true /// Want to convert to a `WebP` - default: continue - } - - case .resize(let maxDimension): - /// We don't currently want to resize animated images - guard mediaMatadata.frameCount == 1 else { continue } - + case .convert(let format): let maxImageDimension: CGFloat = max( - mediaMatadata.pixelSize.width, - mediaMatadata.pixelSize.height + metadata.pixelSize.width, + metadata.pixelSize.height ) - if maxImageDimension > maxDimension { - return true + switch format { + case .current: continue /// Keep in the current format + case .mp4: + guard metadata.utType != .mpeg4Movie else { continue } + + return true + + case .webPLossy(let maxDimension, let cropRect, _), + .webPLossless(let maxDimension, let cropRect, _): + if metadata.utType != .webP { return true } + if maxImageDimension > (maxDimension ?? CGFloat.greatestFiniteMagnitude) { + return true + } + if cropRect != nil && cropRect != CGRect(x: 0, y: 0, width: 1, height: 1) { + return true + } + + /// Already in the desired format + continue + + case .gif(let maxDimension, let cropRect, _): + if metadata.utType != .gif { return true } + if maxImageDimension > (maxDimension ?? CGFloat.greatestFiniteMagnitude) { + return true + } + if cropRect != nil && cropRect != CGRect(x: 0, y: 0, width: 1, height: 1) { + return true + } + + /// Already in the desired format + continue } - continue case .stripImageMetadata: /// We don't currently strip metadata from animated images - guard mediaMatadata.frameCount == 1 else { continue } + guard metadata.frameCount == 1 else { continue } - return mediaMatadata.hasUnsafeMetadata + return metadata.hasUnsafeMetadata } } - /// None of the requested `transformations` were needed so the file doesn't need preparation + /// None of the requested `operations` were needed so the file doesn't need preparation return false } - func prepare( - transformations: Set, - storeAtPendingAttachmentUploadPath: Bool = false, + func ensureExpectedEncryptedSize( + domain: Crypto.AttachmentDomain, + maxFileSize: UInt, using dependencies: Dependencies - ) throws -> PreparedAttachment { - /// Perform any source-specific transformations and load the attachment data into memory - let preparedData: Data + ) throws { + let encryptedSize: Int - switch source { - case .media where utType.isAnimated: preparedData = try prepareImage(transformations) - case .media where utType.isImage: preparedData = try prepareImage(transformations) - case .media where utType.isVideo: preparedData = try prepareVideo(transformations) - case .media where utType.isAudio: preparedData = try prepareAudio(transformations) - case .voiceMessage: preparedData = try prepareAudio(transformations) - case .text: preparedData = try prepareText(transformations) - case .file, .media: preparedData = try prepareGeneral(transformations) + if dependencies[feature: .deterministicAttachmentEncryption] { + encryptedSize = try dependencies[singleton: .crypto].tryGenerate( + .expectedEncryptedAttachmentSize(plaintextSize: Int(fileSize)) + ) + } + else { + switch domain { + case .attachment: + encryptedSize = try dependencies[singleton: .crypto].tryGenerate( + .legacyExpectedEncryptedAttachmentSize(plaintextSize: Int(fileSize)) + ) + + case .profilePicture: + encryptedSize = try dependencies[singleton: .crypto].tryGenerate( + .legacyEncryptedDisplayPictureSize(plaintextSize: Int(fileSize)) + ) + } } - /// Generate the temporary path to use while the upload is pending + /// May as well throw here if we know the attachment is too large to send + guard UInt(encryptedSize) <= maxFileSize else { + throw AttachmentError.fileSizeTooLarge + } + } + + func prepare( + operations: Set, + storeAtPendingAttachmentUploadPath: Bool = false, + using dependencies: Dependencies + ) async throws -> PreparedAttachment { + /// Generate the temporary path to use for the attachment data /// - /// **Note:** This is stored alongside other attachments rather than in the temporary directory because the - /// `AttachmentUploadJob` can exist between launches, but the temporary directory gets cleared on every launch) + /// **Note:** If `storeAtPendingAttachmentUploadPath` is `true` then the file is stored alongside other attachments + /// rather than in the temporary directory because the `AttachmentUploadJob` can exist between launches, but the temporary + /// directory gets cleared on every launch) let attachmentId: String = (existingAttachmentId ?? UUID().uuidString) let filePath: String = try (storeAtPendingAttachmentUploadPath ? dependencies[singleton: .attachmentManager].pendingUploadPath(for: attachmentId) : dependencies[singleton: .fileManager].temporaryFilePath() ) + /// Perform any source-specific operations and load the attachment data into memory + switch source { + case .media where (utType.isImage || utType.isAnimated): + try await prepareImage(operations, filePath: filePath, using: dependencies) + + case .media where utType.isVideo: + try await prepareVideo(operations, filePath: filePath, using: dependencies) + + case .media where utType.isAudio: + try await prepareAudio(operations, filePath: filePath, using: dependencies) + + case .voiceMessage: + try await prepareAudio(operations, filePath: filePath, using: dependencies) + + case .text: + try await prepareText(operations, filePath: filePath, using: dependencies) + + case .file, .media: + try await prepareGeneral(operations, filePath: filePath, using: dependencies) + } + + /// Get the size of the prepared data + let preparedFileSize: UInt64? = dependencies[singleton: .fileManager].fileSize(of: filePath) + /// If we don't have the `encrypt` transform then we can just return the `preparedData` (which is unencrypted but should - /// have all other `Transform` changes applied + /// have all other `Operation` changes applied // FIXME: We should store attachments encrypted and decrypt them when we want to render/open them - guard case .encrypt(let encryptionDomain) = transformations.first(where: { $0.erased == .encrypt }) else { - try dependencies[singleton: .fileManager].write(data: preparedData, toPath: filePath) - + guard case .encrypt(let encryptionDomain) = operations.first(where: { $0.erased == .encrypt }) else { return PreparedAttachment( attachment: try prepareAttachment( id: attachmentId, downloadUrl: filePath, - byteCount: UInt(preparedData.count), + byteCount: UInt(preparedFileSize ?? 0), encryptionKey: nil, digest: nil, using: dependencies @@ -630,58 +728,82 @@ public extension PendingAttachment { ) } + /// May as well throw here if we know the attachment is too large to send + try ensureExpectedEncryptedSize( + domain: encryptionDomain, + maxFileSize: Network.maxFileSize, + using: dependencies + ) + /// Encrypt the data using either the legacy or updated encryption typealias EncryptionData = (ciphertext: Data, encryptionKey: Data, digest: Data) - let encryptedData: EncryptionData - - if dependencies[feature: .deterministicAttachmentEncryption] { - let encryptedSize: Int = try dependencies[singleton: .crypto].tryGenerate( - .expectedEncryptedAttachmentSize(plaintext: preparedData) - ) - - /// May as well throw here if we know the attachment is too large to send - guard UInt(encryptedSize) <= Network.maxFileSize else { - throw AttachmentError.fileSizeTooLarge - } - - let result = try dependencies[singleton: .crypto].tryGenerate( - .encryptAttachment(plaintext: preparedData, domain: encryptionDomain) - ) - - encryptedData = (result.ciphertext, result.encryptionKey, Data()) - } - else { - switch encryptionDomain { - case .attachment: - encryptedData = try dependencies[singleton: .crypto].tryGenerate( - .legacyEncryptedAttachment(plaintext: preparedData) - ) + let (encryptedData, finalByteCount): (EncryptionData, UInt) = try autoreleasepool { + do { + let result: EncryptionData + let finalByteCount: UInt + let plaintext: Data = try dependencies[singleton: .fileManager] + .contents(atPath: filePath) ?? { throw AttachmentError.invalidData }() - case .profilePicture: - let encryptionKey: Data = try dependencies[singleton: .crypto] - .tryGenerate(.randomBytes(DisplayPictureManager.encryptionKeySize)) - let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate( - .legacyEncryptedDisplayPicture(data: preparedData, key: encryptionKey) + if dependencies[feature: .deterministicAttachmentEncryption] { + let encryptionResult = try dependencies[singleton: .crypto].tryGenerate( + .encryptAttachment(plaintext: plaintext, domain: encryptionDomain) ) - encryptedData = (ciphertext, encryptionKey, Data()) + + finalByteCount = UInt(encryptionResult.ciphertext.count) + result = (encryptionResult.ciphertext, encryptionResult.encryptionKey, Data()) + } + else { + switch encryptionDomain { + case .attachment: + result = try dependencies[singleton: .crypto].tryGenerate( + .legacyEncryptedAttachment(plaintext: plaintext) + ) + + /// For legacy attachments we need to set `byteCount` to the size of the data prior to encryption in + /// order to be able to strip the padding correctly + finalByteCount = UInt(preparedFileSize ?? 0) + + case .profilePicture: + let encryptionKey: Data = try dependencies[singleton: .crypto] + .tryGenerate(.randomBytes(DisplayPictureManager.encryptionKeySize)) + let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate( + .legacyEncryptedDisplayPicture(data: plaintext, key: encryptionKey) + ) + + /// Legacy display picture encryption doesn't have the same padding requirement so we can set it + /// to the encrypted size + finalByteCount = UInt(ciphertext.count) + result = (ciphertext, encryptionKey, Data()) + } + + /// Since the legacy encryption is a little more questionable we should double check the ciphertext size + guard result.ciphertext.count <= Network.maxFileSize else { + throw AttachmentError.fileSizeTooLarge + } + } + + /// Since we successfully encrypted the data we can remove the file with the unencrypted content and replace it with + /// the encrypted content + try dependencies[singleton: .fileManager].removeItem(atPath: filePath) + try dependencies[singleton: .fileManager].write( + data: result.ciphertext, + toPath: filePath + ) + + return (result, finalByteCount) } - - /// May as well throw here if we know the attachment is too large to send - guard encryptedData.ciphertext.count <= Network.maxFileSize else { - throw AttachmentError.fileSizeTooLarge + catch { + /// If we failed to encrypt the data then we need to remove the temporary file that we created (as it won't be used) + try? dependencies[singleton: .fileManager].removeItem(atPath: filePath) + throw error } } - try dependencies[singleton: .fileManager].write( - data: encryptedData.ciphertext, - toPath: filePath - ) - return PreparedAttachment( attachment: try prepareAttachment( id: attachmentId, downloadUrl: filePath, - byteCount: UInt(preparedData.count), + byteCount: finalByteCount, encryptionKey: encryptedData.encryptionKey, digest: encryptedData.digest, using: dependencies @@ -690,7 +812,11 @@ public extension PendingAttachment { ) } - private func prepareImage(_ transformations: Set) throws -> Data { + private func prepareImage( + _ operations: Set, + filePath: String, + using dependencies: Dependencies + ) async throws { guard let targetSource: ImageDataManager.DataSource = visualMediaSource, case .media(let mediaMatadata) = self.metadata @@ -701,82 +827,26 @@ public extension PendingAttachment { throw AttachmentError.invalidDimensions } - /// If it's animated then we don't want to do any processing (to performance intensive at this stage, and won't have as big of - /// an impact due to a smaller number of users actually using them) - guard mediaMatadata.frameCount == 1 else { - switch targetSource { - case .url(let url): return try Data(contentsOf: url, options: []) - case .data(_, let data): return data - - /// None of the other source options support animated images so just fail - default: throw AttachmentError.invalidData - } - } - - /// If we can't load the data into a `UIImage` then we can't process it - var image: UIImage - var needsReencoding: Bool = ( - transformations.contains(.compress) || - transformations.contains(.convertToStandardFormats) - ) - let originalImageData: Data? - - switch targetSource { - case .image(_, let directImage): - image = try directImage ?? { throw AttachmentError.invalidData }() - needsReencoding = true /// In-memory image always needs encoding - originalImageData = nil - - case .url(let url): - guard - let imageData: Data = try? Data(contentsOf: url, options: []), - let loadedImage = UIImage(data: imageData) - else { throw AttachmentError.invalidImageData } - - image = loadedImage - originalImageData = imageData - - case .data(_, let data): - guard let loadedImage = UIImage(data: data) else { - throw AttachmentError.invalidImageData - } - - image = loadedImage - originalImageData = data - - default: throw AttachmentError.invalidMediaSource - } - - /// If we have the `resize` and the resolution is too large then we need to scale it down - if case .resize(let targetSize) = transformations.first(where: { $0.erased == .resize }) { - let maxImageDimension: CGFloat = max(mediaMatadata.pixelSize.width, mediaMatadata.pixelSize.height) - - if maxImageDimension > targetSize { - Log.debug(.attachmentManager, "Resizing image to fit in max allows dimension.") - image = image.resized(toFillPixelSize: CGSize(width: targetSize, height: targetSize)) - needsReencoding = true /// We've resized the image so need to re-encode it - } + /// If we want to convert to a certain format then that's all we need to do + if case .convert(let format) = operations.first(where: { $0.erased == .convert }) { + return try await createImage( + source: targetSource, + metadata: mediaMatadata, + format: format, + filePath: filePath, + using: dependencies + ) } - /// If we don't need to re-encode then just check if we want to strip the metadata and return either the original or stripped - /// version of the data - if !needsReencoding { - guard let originalData: Data = originalImageData else { throw AttachmentError.invalidData } - - /// If we don't want to strip the metadata then just return the original data - guard transformations.contains(.stripImageMetadata) else { - return originalData - } - - /// Otherwise clear the metadata and return the updated data - let options: CFDictionary = [ - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false - ] as CFDictionary + /// Otherwise if all we want to do is strip the metadata then we should do that + /// + /// **Note:** We don't currently support stripping metadata from animated images without conversion (as we need to do + /// every frame which would have a negative impact on sending things like GIF attachments since it's fairly slow) + if operations.contains(.stripImageMetadata) && !utType.isAnimated { let outputData: NSMutableData = NSMutableData() - + guard - let source: CGImageSource = CGImageSourceCreateWithData(originalData as CFData, options), + let source: CGImageSource = targetSource.createImageSource(), let sourceType: String = CGImageSourceGetType(source) as? String, let cgImage: CGImage = CGImageSourceCreateImageAtIndex(source, 0, nil), let destination = CGImageDestinationCreateWithData(outputData as CFMutableData, sourceType as CFString, 1, nil) @@ -788,39 +858,27 @@ public extension PendingAttachment { throw AttachmentError.couldNotResizeImage } - return outputData as Data - } - - /// Otherwise, perform the desired re-encoding, images with alpha should be converted to lossless `WebP` - if mediaMatadata.hasAlpha == true { - let maybeWebPData: Data? = SDImageWebPCoder.shared.encodedData( - with: image, - format: .webP, - options: [ - .encodeFirstFrameOnly: true, - .encodeWebPLossless: true, - .encodeCompressionQuality: 0.25 - ] + return try dependencies[singleton: .fileManager].write( + data: outputData as Data, + toPath: filePath ) - - guard let webPData: Data = maybeWebPData else { - throw AttachmentError.couldNotResizeImage - } - - return webPData - } - - /// And opaque images should be converted to `JPEG` with the appropriate quality - let quality: CGFloat = (transformations.contains(.compress) ? 0.75 : 0.95) - - guard let data: Data = image.jpegData(compressionQuality: quality) else { - throw AttachmentError.couldNotResizeImage } - return data + /// If we got here then we don't want to modify the source so we just need to ensure the file exists on disk + return try await createImage( + source: targetSource, + metadata: mediaMatadata, + format: .current, + filePath: filePath, + using: dependencies + ) } - private func prepareVideo(_ transformations: Set) throws -> Data { + private func prepareVideo( + _ operations: Set, + filePath: String, + using dependencies: Dependencies + ) async throws { guard let targetSource: ImageDataManager.DataSource = visualMediaSource, case .media(let mediaMatadata) = self.metadata @@ -835,16 +893,32 @@ public extension PendingAttachment { throw AttachmentError.invalidDuration } - switch targetSource { - case .data(_, let data): return data - case .url(let url), .videoUrl(let url, _, _, _): - return try Data(contentsOf: url, options: []) - - default: throw AttachmentError.invalidMediaSource + /// If we want to convert to a certain format then that's all we need to do + if case .convert(let format) = operations.first(where: { $0.erased == .convert }) { + return try await createVideo( + source: targetSource, + metadata: mediaMatadata, + format: format, + filePath: filePath, + using: dependencies + ) } + + /// If we got here then we don't want to modify the source so we just need to ensure the file exists on disk + try await createVideo( + source: targetSource, + metadata: mediaMatadata, + format: .current, + filePath: filePath, + using: dependencies + ) } - private func prepareAudio(_ transformations: Set) throws -> Data { + private func prepareAudio( + _ operations: Set, + filePath: String, + using dependencies: Dependencies + ) async throws { guard case .media(let mediaMatadata) = self.metadata else { throw AttachmentError.invalidMediaSource } @@ -855,11 +929,20 @@ public extension PendingAttachment { } switch source { - case .voiceMessage(let url): return try Data(contentsOf: url, options: []) + case .voiceMessage(let url): + try dependencies[singleton: .fileManager].copyItem(atPath: url.path, toPath: filePath) + case .media(let mediaSource) where utType.isAudio: switch mediaSource { - case .url(let url): return try Data(contentsOf: url, options: []) - case .data(_, let data): return data + case .url(let url): + try dependencies[singleton: .fileManager].copyItem( + atPath: url.path, + toPath: filePath + ) + + case .data(_, let data): + try dependencies[singleton: .fileManager].write(data: data, toPath: filePath) + default: throw AttachmentError.invalidMediaSource } @@ -867,24 +950,48 @@ public extension PendingAttachment { } } - private func prepareText(_ transformations: Set) throws -> Data { + private func prepareText( + _ operations: Set, + filePath: String, + using dependencies: Dependencies + ) async throws { guard case .text(let text) = source, let data: Data = text.data(using: .ascii) else { throw AttachmentError.invalidData } - return data + try dependencies[singleton: .fileManager].write(data: data, toPath: filePath) } - private func prepareGeneral(_ transformations: Set) throws -> Data { + private func prepareGeneral( + _ operations: Set, + filePath: String, + using dependencies: Dependencies + ) async throws { switch source { - case .file(let url): return try Data(contentsOf: url, options: []) - case .media(let mediaSource): - switch mediaSource { - case .url(let url): return try Data(contentsOf: url, options: []) - case .data(_, let data): return data - default: throw AttachmentError.invalidData - } + case .media where (utType.isImage || utType.isAnimated): + try await prepareImage(operations, filePath: filePath, using: dependencies) + + case .media where utType.isVideo: + try await prepareVideo(operations, filePath: filePath, using: dependencies) + + case .media where utType.isAudio: + try await prepareAudio(operations, filePath: filePath, using: dependencies) + + case .voiceMessage: + try await prepareAudio(operations, filePath: filePath, using: dependencies) + + case .text: + try await prepareText(operations, filePath: filePath, using: dependencies) + + case .file(let url), .media(.url(let url)): + try dependencies[singleton: .fileManager].copyItem( + atPath: url.path, + toPath: filePath + ) + + case .media(.data(_, let data)): + try dependencies[singleton: .fileManager].write(data: data, toPath: filePath) default: throw AttachmentError.invalidData } @@ -984,41 +1091,335 @@ public extension PendingAttachment { } } - func toMp4Video(using dependencies: Dependencies) async throws -> PendingAttachment { + fileprivate func convert( + source: ImageDataManager.DataSource, + to format: ConversionFormat, + filePath: String, + using dependencies: Dependencies + ) async throws { + guard case .media(let metadata) = metadata else { throw AttachmentError.invalidData } + + switch (format, utType.isVideo) { + case (.mp4, _), (.current, true): + return try await createVideo( + source: source, + metadata: metadata, + format: format, + filePath: filePath, + using: dependencies + ) + + case (.webPLossy, _), (.webPLossless, _), (.gif, _), (_, false): + return try await createImage( + source: source, + metadata: metadata, + format: format, + filePath: filePath, + using: dependencies + ) + } + } + + private func createVideo( + source: ImageDataManager.DataSource, + metadata: MediaUtils.MediaMetadata, + format: ConversionFormat, + filePath: String, + using dependencies: Dependencies + ) async throws { + guard case .url(let url) = source else { throw AttachmentError.invalidData } + + /// Ensure the target format is an image format we support + switch format { + case .mp4: break + case .current: throw AttachmentError.invalidFileFormat + case .webPLossy, .webPLossless, .gif: throw AttachmentError.couldNotConvert + } + + /// Ensure we _actually_ need to make changes first + guard mediaNeedsPreparation([.convert(to: format)], metadata: metadata) else { + try dependencies[singleton: .fileManager].copyItem(atPath: url.path, toPath: filePath) + return + } + + return try await PendingAttachment.convertToMpeg4( + asset: AVAsset(url: url), + presetName: AVAssetExportPresetMediumQuality, + filePath: filePath + ) + } + + private func createImage( + source: ImageDataManager.DataSource, + metadata: MediaUtils.MediaMetadata, + format: ConversionFormat, + filePath: String, + using dependencies: Dependencies + ) async throws { + /// Ensure the target format is an image format we support + let targetMaxDimension: CGFloat? + let targetCropRect: CGRect? + + switch format { + case .gif(let maxDimension, let cropRect, _), .webPLossy(let maxDimension, let cropRect, _), + .webPLossless(let maxDimension, let cropRect, _): + targetMaxDimension = maxDimension + targetCropRect = cropRect + break + + case .current: + targetMaxDimension = nil + targetCropRect = nil + break + + case .mp4: throw AttachmentError.couldNotConvert + } + + /// Ensure we _actually_ need to make changes first + guard mediaNeedsPreparation([.convert(to: format)], metadata: metadata) else { + switch source { + case .url(let url): + try dependencies[singleton: .fileManager].copyItem(atPath: url.path, toPath: filePath) + + case .image(_, let directImage): + /// For direct image, convert to data first + guard + let image: UIImage = directImage, + let data: Data = image.pngData() + else { throw AttachmentError.invalidData } + + try dependencies[singleton: .fileManager].write(data: data, toPath: filePath) + + case .data(_, let data): + try dependencies[singleton: .fileManager].write(data: data, toPath: filePath) + + default: throw AttachmentError.invalidMediaSource + } + return + } + + /// Create a task to process the image asyncronously + let task: Task = Task.detached(priority: .userInitiated) { + /// Extract the source + let imageSource: CGImageSource + let options: CFDictionary = [ + kCGImageSourceShouldCache: false, + kCGImageSourceShouldCacheImmediately: false + ] as CFDictionary + let targetSize: CGSize = ( + targetMaxDimension.map { CGSize(width: $0, height: $0) } ?? + metadata.pixelSize + ) + let isGif: Bool = { + switch format { + case .gif: return true + default: return false + } + }() + let isOpaque: Bool = ( + metadata.hasAlpha != true || + isGif /// GIF doesn't support alpha (single transparent color only) + ) + + switch source { + case .image(_, let directImage): + /// For direct image, convert to data first + guard + let image = directImage, + let data = image.pngData() + else { throw AttachmentError.invalidData } + + imageSource = try CGImageSourceCreateWithData(data as CFData, options) ?? { + throw AttachmentError.invalidData + }() + + case .url(let url): + imageSource = try CGImageSourceCreateWithURL(url as CFURL, options) ?? { + throw AttachmentError.invalidData + }() + + case .data(_, let data): + imageSource = try CGImageSourceCreateWithData(data as CFData, options) ?? { + throw AttachmentError.invalidData + }() + + default: throw AttachmentError.invalidMediaSource + } + + /// Process frames in parallel (in batches) to balance performance and memory usage + let estimatedFrameMemory: CGFloat = (targetSize.width * targetSize.height * 4) + let batchSize: Int = max(2, min(8, Int(50_000_000 / estimatedFrameMemory))) + var frames: [CGImage] = [] + frames.reserveCapacity(metadata.frameDurations.count) + + for batchStart in stride(from: 0, to: metadata.frameDurations.count, by: batchSize) { + typealias FrameResult = (index: Int, frame: CGImage) + + let batchEnd: Int = min(batchStart + batchSize, metadata.frameDurations.count) + let batchFrames: [CGImage] = try await withThrowingTaskGroup(of: FrameResult.self) { group in + for i in batchStart.. Data { + /// Convert to an image (`SDImageWebPCoder` only supports encoding a `UIImage`) + let sdFrames: [SDImageFrame] = frames.enumerated().map { index, frame in + autoreleasepool { + SDImageFrame( + image: UIImage( + cgImage: frame, + scale: 1, + orientation: (metadata.orientation ?? .up) + ), + duration: metadata.frameDurations[index] + ) + } + } + + guard let imageToProcess: UIImage = SDImageCoderHelper.animatedImage(with: sdFrames) else { + throw AttachmentError.invalidData + } + + /// Peform the encoding + return try SDImageWebPCoder.shared.encodedData( + with: imageToProcess, + format: .webP, + options: [ + .encodeWebPLossless: encodeWebPLossless, + .encodeCompressionQuality: encodeCompressionQuality + ] + ) ?? { throw AttachmentError.couldNotConvertToWebP }() + } + + static func convertToMpeg4( + asset: AVAsset, + presetName: String, + filePath: String + ) async throws { guard - case .media(let mediaSource) = source, - case .url(let url) = mediaSource, let exportSession: AVAssetExportSession = AVAssetExportSession( - asset: AVAsset(url: url), - presetName: AVAssetExportPresetMediumQuality + asset: asset, + presetName: presetName ) - else { throw AttachmentError.invalidData } + else { throw AttachmentError.couldNotConvertToMpeg4 } - let exportPath: String = dependencies[singleton: .fileManager] - .temporaryFilePath(fileExtension: "mp4") // stringlint:disable - let exportUrl: URL = URL(fileURLWithPath: exportPath) exportSession.shouldOptimizeForNetworkUse = true exportSession.outputFileType = AVFileType.mp4 exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() - exportSession.outputURL = exportUrl - - return await withCheckedContinuation { continuation in - exportSession.exportAsynchronously { - continuation.resume( - returning: PendingAttachment( - source: .media( - .videoUrl( - exportUrl, - .mpeg4Movie, - sourceFilename, - dependencies[singleton: .attachmentManager] - ) - ), - utType: .mpeg4Movie, - sourceFilename: sourceFilename, - using: dependencies - ) - ) + exportSession.outputURL = URL(fileURLWithPath: filePath) + + return try await withCheckedThrowingContinuation { continuation in + exportSession.exportAsynchronously { [weak exportSession] in + guard exportSession?.status == .completed else { + return continuation.resume(throwing: AttachmentError.couldNotConvertToMpeg4) + } + + continuation.resume() } } } diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index 3ab06c61c8..e79be0740e 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -39,7 +39,7 @@ public class DisplayPictureManager { case currentUserUpdateTo(url: String, key: Data, isReupload: Bool) case groupRemove - case groupUploadImage(ImageDataManager.DataSource) + case groupUploadImage(source: ImageDataManager.DataSource, cropRect: CGRect?) case groupUpdateTo(url: String, key: Data) static func from(_ profile: VisibleMessage.VMProfile, fallback: Update, using dependencies: Dependencies) -> Update { @@ -187,26 +187,118 @@ public class DisplayPictureManager { // MARK: - Uploading - public func prepareDisplayPicture( - attachment: PendingAttachment, - transformations: Set? = nil - ) throws -> PreparedAttachment { - /// If we weren't given custom transformations then use the default ones for display pictures - let finalTransfomations: Set = ( - transformations ?? - [ - .compress, - .resize(maxDimension: DisplayPictureManager.maxDimension), - .stripImageMetadata + private static func standardOperations(cropRect: CGRect?) -> Set { + return [ + .convert(to: .webPLossy( + maxDimension: DisplayPictureManager.maxDimension, + cropRect: cropRect + )), + .stripImageMetadata + ] + } + + public func reuploadNeedsPreparation(attachment: PendingAttachment) -> Bool { + /// When re-uploading we only want to check if the file needs to be resized or converted to `WebP` to avoid a situation where + /// different clients end up "ping-ponging" changes to the display picture + return attachment.needsPreparation( + operations: [ + .convert(to: .webPLossy(maxDimension: DisplayPictureManager.maxDimension)) ] ) + } + + public func prepareDisplayPicture( + attachment: PendingAttachment, + fallbackIfConversionTakesTooLong: Bool = false, + cropRect: CGRect? = nil + ) async throws -> PreparedAttachment { + /// If we don't want the fallbacks then just run the standard operations + guard fallbackIfConversionTakesTooLong else { + return try await attachment.prepare( + operations: DisplayPictureManager.standardOperations(cropRect: cropRect), + using: dependencies + ) + } - let preparedAttachment: PreparedAttachment = try attachment.prepare( - transformations: finalTransfomations, + /// The desired output for a profile picture is a `WebP` at the specified size (and `cropRect`) that is generated in under `5s` + do { + let result: PreparedAttachment = try await withThrowingTaskGroup { [dependencies] group in + group.addTask { + return try await attachment.prepare( + operations: DisplayPictureManager.standardOperations(cropRect: cropRect), + using: dependencies + ) + } + group.addTask { + try await Task.sleep(for: .seconds(5)) + throw AttachmentError.conversionTimeout + } + defer { group.cancelAll() } + + return try await group.first(where: { _ in true }) ?? { + throw AttachmentError.couldNotConvert + }() + } + let preparedSize: UInt64? = dependencies[singleton: .fileManager].fileSize(of: result.filePath) + + guard (preparedSize ?? UInt64.max) < attachment.fileSize else { + throw AttachmentError.conversionResultedInLargerFile + } + + return result + } + catch AttachmentError.conversionTimeout {} /// Expected case + catch AttachmentError.conversionResultedInLargerFile {} /// Expected case + catch { throw error } + + /// If the original file was a `GIF` then we should see if we can just resize/crop that instead, but since we've already waited + /// for `5s` we only want to give `2s` for this conversion + /// + /// **Note:** In this case we want to ignore any error and just fallback to the original file (with metadata stripped) + if attachment.utType == .gif { + let maybeResult: PreparedAttachment? = try? await withThrowingTaskGroup { [dependencies] group in + group.addTask { + return try await attachment.prepare( + operations: [ + .convert(to: .gif( + maxDimension: DisplayPictureManager.maxDimension, + cropRect: cropRect + )), + .stripImageMetadata + ], + using: dependencies + ) + } + group.addTask { + try await Task.sleep(for: .seconds(2)) + throw AttachmentError.conversionTimeout + } + defer { group.cancelAll() } + + return try await group.first(where: { _ in true }) ?? { + throw AttachmentError.couldNotConvert + }() + } + + /// Only return the resized GIF if it's smaller than the original (the current GIF encoding we use is just the built-in iOS + /// encoding which isn't very advanced, as such some GIFs can end up quite large, even if they are cropped versions + /// of other GIFs - this is likely due to the lack of "frame differencing" support) + if + let result: PreparedAttachment = maybeResult, + let preparedSize: UInt64 = dependencies[singleton: .fileManager] + .fileSize(of: result.filePath), + preparedSize < attachment.fileSize + { + return result + } + } + + /// If we weren't able to generate the `WebP` (or resized `GIF` if the source was a `GIF`) then just use the original source + /// with metadata stripped + return try await attachment.prepare( + operations: [.stripImageMetadata], using: dependencies ) - - return preparedAttachment } public func uploadDisplayPicture(preparedAttachment: PreparedAttachment) async throws -> UploadResult { @@ -215,8 +307,8 @@ public class DisplayPictureManager { attachment: preparedAttachment.attachment, using: dependencies ) - let attachment: PreparedAttachment = try pendingAttachment.prepare( - transformations: [ + let attachment: PreparedAttachment = try await pendingAttachment.prepare( + operations: [ .encrypt(domain: .profilePicture) ], using: dependencies diff --git a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift index 10ff5d9500..581e8e2814 100644 --- a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift @@ -54,7 +54,15 @@ class DisplayPictureDownloadJobSpec: QuickSpec { @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( initialSetup: { network in network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.response(data: encryptedData)) } ) @@ -65,7 +73,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { initialSetup: { crypto in crypto.when { $0.generate(.uuid()) }.thenReturn(UUID(uuidString: "00000000-0000-0000-0000-000000001234")) crypto - .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } + .when { $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) } .thenReturn(imageData) crypto.when { $0.generate(.hash(message: .any, length: .any)) }.thenReturn("TestHash".bytes) crypto @@ -82,6 +90,9 @@ class DisplayPictureDownloadJobSpec: QuickSpec { crypto .when { $0.generate(.signatureBlind15(message: .any, serverPublicKey: .any, ed25519SecretKey: .any)) } .thenReturn("TestSogsSignature".bytes) + crypto + .when { $0.generate(.x25519(ed25519Pubkey: .any)) } + .thenReturn(Array(Data(hex: TestConstants.serverPublicKey))) } ) @@ -492,7 +503,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { timestamp: 0 ) ) - let expectedRequest: Network.PreparedRequest = try Network.preparedDownload( + let expectedRequest: Network.PreparedRequest = try Network.FileServer.preparedDownload( url: URL(string: "http://filev2.getsession.org/file/1234")!, using: dependencies ) @@ -509,8 +520,11 @@ class DisplayPictureDownloadJobSpec: QuickSpec { expect(mockNetwork) .to(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.FileServer.Endpoint.directUrl( + URL(string: "http://filev2.getsession.org/file/1234")! + ), + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -572,8 +586,9 @@ class DisplayPictureDownloadJobSpec: QuickSpec { expect(mockNetwork) .to(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.SOGS.Endpoint.roomFileIndividual("testRoom", "12"), + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -622,7 +637,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { context("when it fails to decrypt the data") { beforeEach { mockCrypto - .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } + .when { $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) } .thenReturn(nil) } @@ -641,7 +656,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { context("when it decrypts invalid image data") { beforeEach { mockCrypto - .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } + .when { $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) } .thenReturn(Data([1, 2, 3])) } @@ -738,7 +753,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) + $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) @@ -766,7 +781,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) + $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) @@ -803,7 +818,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) + $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) @@ -839,7 +854,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("saves the picture") { expect(mockCrypto) .to(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) + $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { $0.createFile( @@ -936,7 +951,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) + $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) @@ -963,7 +978,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) + $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) @@ -1004,7 +1019,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) + $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) @@ -1092,7 +1107,15 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // SOGS doesn't encrypt it's images so replace the encrypted mock response mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.response(data: imageData)) } diff --git a/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift b/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift index e549e2c221..6d709ef176 100644 --- a/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift @@ -378,13 +378,24 @@ class MessageSendJobSpec: QuickSpec { var didDefer: Bool = false mockStorage.write { db in - try attachment - .with( - state: .uploaded, - downloadUrl: nil, - using: dependencies - ) - .upsert(db) + try Attachment( + id: attachment.id, + serverId: attachment.serverId, + variant: attachment.variant, + state: .uploaded, + contentType: attachment.contentType, + byteCount: attachment.byteCount, + creationTimestamp: attachment.creationTimestamp, + sourceFilename: attachment.sourceFilename, + downloadUrl: nil, + width: attachment.width, + height: attachment.height, + duration: attachment.duration, + isVisualMedia: attachment.isVisualMedia, + isValid: attachment.isValid, + encryptionKey: attachment.encryptionKey, + digest: attachment.digest + ).upsert(db) } MessageSendJob.run( diff --git a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift index 42ff8e59cc..24b8185e74 100644 --- a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift @@ -37,7 +37,15 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( initialSetup: { network in network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn( MockNetwork.batchResponseData( with: [ @@ -182,7 +190,15 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { // MARK: -- creates an inactive entry in the database if one does not exist it("creates an inactive entry in the database if one does not exist") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.errorResponse()) RetrieveDefaultOpenGroupRoomsJob.run( @@ -206,7 +222,15 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { // MARK: -- does not create a new entry if one already exists it("does not create a new entry if one already exists") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.errorResponse()) mockStorage.write { db in @@ -281,8 +305,9 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { expect(mockNetwork) .to(call { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.SOGS.Endpoint.sequence, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -294,7 +319,15 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { // MARK: -- will retry 8 times before it fails it("will retry 8 times before it fails") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.nullResponse()) RetrieveDefaultOpenGroupRoomsJob.run( @@ -312,7 +345,13 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { expect(error).to(matchError(NetworkError.parsingFailed)) expect(mockNetwork) // First attempt + 8 retries .to(call(.exactly(times: 9)) { network in - network.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) + network.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) }) } @@ -376,7 +415,15 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { .insert(db) } mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn( MockNetwork.batchResponseData( with: [ @@ -506,7 +553,15 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { // MARK: -- does not schedule a display picture download if there is no imageId it("does not schedule a display picture download if there is no imageId") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn( MockNetwork.batchResponseData( with: [ diff --git a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift index 7b20940ec9..180d585389 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift @@ -40,7 +40,15 @@ class LibSessionGroupInfoSpec: QuickSpec { @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( initialSetup: { network in network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.response(data: Data([1, 2, 3]))) } ) @@ -894,8 +902,9 @@ class LibSessionGroupInfoSpec: QuickSpec { expect(mockNetwork) .to(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.SnodeAPI.Endpoint.deleteMessages, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -958,7 +967,13 @@ class LibSessionGroupInfoSpec: QuickSpec { expect(result?.map { $0.variant }).to(equal([.standardIncomingDeleted])) expect(mockNetwork) .toNot(call { network in - network.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) + network.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) }) } } diff --git a/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift index 43f6c045de..8d5b88a6ba 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift @@ -39,7 +39,15 @@ class LibSessionGroupMembersSpec: QuickSpec { @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( initialSetup: { network in network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.response(data: Data([1, 2, 3]))) } ) diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 9d2d2d4cb3..5a0783b1e4 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -139,7 +139,15 @@ class OpenGroupManagerSpec: QuickSpec { @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( initialSetup: { network in network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.errorResponse()) } ) @@ -669,7 +677,15 @@ class OpenGroupManagerSpec: QuickSpec { } mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockCapabilitiesAndRoomResponse) mockUserDefaults @@ -807,7 +823,15 @@ class OpenGroupManagerSpec: QuickSpec { context("with an invalid response") { beforeEach { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.response(data: Data())) mockUserDefaults @@ -2559,8 +2583,9 @@ class OpenGroupManagerSpec: QuickSpec { expect(mockNetwork) .to(call { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.SOGS.Endpoint.sequence, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -2574,7 +2599,15 @@ class OpenGroupManagerSpec: QuickSpec { cache.defaultRoomsPublisher.sinkUntilComplete() expect(mockNetwork) - .toNot(call { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) }) + .toNot(call { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + }) } } } diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift index afde49aad2..11092e8a61 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift @@ -67,7 +67,15 @@ class MessageReceiverGroupsSpec: QuickSpec { @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( initialSetup: { network in network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1", uploaded: nil, expires: nil))) network .when { $0.getSwarm(for: .any) } @@ -897,8 +905,9 @@ class MessageReceiverGroupsSpec: QuickSpec { expect(mockNetwork) .toNot(call { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.PushNotification.Endpoint.subscribe, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -974,8 +983,9 @@ class MessageReceiverGroupsSpec: QuickSpec { expect(mockNetwork) .to(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.PushNotification.Endpoint.subscribe, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -2863,8 +2873,9 @@ class MessageReceiverGroupsSpec: QuickSpec { expect(mockNetwork) .to(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( - preparedRequest.body, - to: preparedRequest.destination, + endpoint: Network.SnodeAPI.Endpoint.deleteMessages, + destination: preparedRequest.destination, + body: preparedRequest.body, requestTimeout: preparedRequest.requestTimeout, requestAndPathBuildTimeout: preparedRequest.requestAndPathBuildTimeout ) @@ -2898,7 +2909,13 @@ class MessageReceiverGroupsSpec: QuickSpec { expect(mockNetwork) .toNot(call { network in - network.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) + network.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) }) } } @@ -3136,8 +3153,9 @@ class MessageReceiverGroupsSpec: QuickSpec { expect(mockNetwork) .to(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.PushNotification.Endpoint.unsubscribe, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift index f92b5d0b77..3059665a28 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift @@ -12,7 +12,7 @@ import Nimble @testable import SessionMessagingKit @testable import SessionNetworkingKit -class MessageSenderGroupsSpec: QuickSpec { +class MessageSenderGroupsSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -65,7 +65,15 @@ class MessageSenderGroupsSpec: QuickSpec { @TestState(singleton: .network, in: dependencies) var mockNetwork: MockNetwork! = MockNetwork( initialSetup: { network in network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockConfigSyncResponse) network .when { $0.getSwarm(for: .any) } @@ -106,6 +114,9 @@ class MessageSenderGroupsSpec: QuickSpec { secretKey: groupSecretKey.bytes ) ) + crypto + .when { $0.generate(.x25519(ed25519Pubkey: .any)) } + .thenReturn(Array(Data(hex: TestConstants.serverPublicKey))) crypto .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) @@ -125,7 +136,7 @@ class MessageSenderGroupsSpec: QuickSpec { .when { $0.generate(.uuid()) } .thenReturn(UUID(uuidString: "00000000-0000-0000-0000-000000000000")!) crypto - .when { $0.generate(.encryptedDataDisplayPicture(data: .any, key: .any)) } + .when { $0.generate(.legacyEncryptedDisplayPicture(data: .any, key: .any)) } .thenReturn(TestConstants.validImageData) crypto .when { $0.generate(.ciphertextForGroupMessage(groupSessionId: .any, message: .any)) } @@ -250,17 +261,16 @@ class MessageSenderGroupsSpec: QuickSpec { // MARK: ---- loads the state into the cache it("loads the state into the cache") { - MessageSender - .createGroup( - name: "TestGroupName", - description: nil, - displayPictureData: nil, - members: [ - ("051111111111111111111111111111111111111111111111111111111111111111", nil) - ], - using: dependencies - ) - .sinkAndStore(in: &disposables) + _ = try? await MessageSender.createGroup( + name: "TestGroupName", + description: nil, + displayPicture: nil, + displayPictureCropRect: nil, + members: [ + ("051111111111111111111111111111111111111111111111111111111111111111", nil) + ], + using: dependencies + ) expect(mockLibSessionCache) .to(call(.exactly(times: 1), matchingParameters: .atLeast(2)) { cache in @@ -278,21 +288,19 @@ class MessageSenderGroupsSpec: QuickSpec { // MARK: ---- returns the created thread it("returns the created thread") { - MessageSender - .createGroup( + let thread: SessionThread? = await expect { + try await MessageSender.createGroup( name: "TestGroupName", description: nil, - displayPictureData: nil, + displayPicture: nil, + displayPictureCropRect: nil, members: [ ("051111111111111111111111111111111111111111111111111111111111111111", nil) ], using: dependencies ) - .handleEvents(receiveOutput: { result in thread = result }) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) + }.toEventuallyNot(throwError()).retrieveValue() - expect(error).to(beNil()) expect(thread).toNot(beNil()) expect(thread?.id).to(equal(groupId.hexString)) expect(thread?.variant).to(equal(.group)) @@ -305,18 +313,18 @@ class MessageSenderGroupsSpec: QuickSpec { // MARK: ---- stores the thread in the db it("stores the thread in the db") { - MessageSender - .createGroup( + await expect { + try await MessageSender.createGroup( name: "Test", description: nil, - displayPictureData: nil, + displayPicture: nil, + displayPictureCropRect: nil, members: [ ("051111111111111111111111111111111111111111111111111111111111111111", nil) ], using: dependencies ) - .handleEvents(receiveOutput: { result in thread = result }) - .sinkAndStore(in: &disposables) + }.toEventuallyNot(throwError()) let dbValue: SessionThread? = mockStorage.read { db in try SessionThread.fetchOne(db) } expect(dbValue).to(equal(thread)) @@ -332,17 +340,18 @@ class MessageSenderGroupsSpec: QuickSpec { // MARK: ---- stores the group in the db it("stores the group in the db") { - MessageSender - .createGroup( + await expect { + try await MessageSender.createGroup( name: "TestGroupName", description: nil, - displayPictureData: nil, + displayPicture: nil, + displayPictureCropRect: nil, members: [ ("051111111111111111111111111111111111111111111111111111111111111111", nil) ], using: dependencies ) - .sinkAndStore(in: &disposables) + }.toEventuallyNot(throwError()) let dbValue: ClosedGroup? = mockStorage.read { db in try ClosedGroup.fetchOne(db) } expect(dbValue?.id).to(equal(groupId.hexString)) @@ -357,17 +366,18 @@ class MessageSenderGroupsSpec: QuickSpec { // MARK: ---- stores the group members in the db it("stores the group members in the db") { - MessageSender - .createGroup( + await expect { + try await MessageSender.createGroup( name: "TestGroupName", description: nil, - displayPictureData: nil, + displayPicture: nil, + displayPictureCropRect: nil, members: [ ("051111111111111111111111111111111111111111111111111111111111111111", nil) ], using: dependencies ) - .sinkAndStore(in: &disposables) + }.toEventuallyNot(throwError()) expect(mockStorage.read { db in try GroupMember.fetchSet(db) }) .to(equal([ @@ -390,17 +400,18 @@ class MessageSenderGroupsSpec: QuickSpec { // MARK: ---- starts the group poller it("starts the group poller") { - MessageSender - .createGroup( + await expect { + try await MessageSender.createGroup( name: "TestGroupName", description: nil, - displayPictureData: nil, + displayPicture: nil, + displayPictureCropRect: nil, members: [ ("051111111111111111111111111111111111111111111111111111111111111111", nil) ], using: dependencies ) - .sinkAndStore(in: &disposables) + }.toEventuallyNot(throwError()) expect(mockSwarmPoller) .to(call(.exactly(times: 1), matchingParameters: .all) { poller in @@ -477,23 +488,25 @@ class MessageSenderGroupsSpec: QuickSpec { return preparedRequest }! - MessageSender - .createGroup( + await expect { + try await MessageSender.createGroup( name: "TestGroupName", description: nil, - displayPictureData: nil, + displayPicture: nil, + displayPictureCropRect: nil, members: [ ("051111111111111111111111111111111111111111111111111111111111111111", nil) ], using: dependencies ) - .sinkAndStore(in: &disposables) + }.toEventuallyNot(throwError()) expect(mockNetwork) .to(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.SnodeAPI.Endpoint.sequence, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -504,42 +517,48 @@ class MessageSenderGroupsSpec: QuickSpec { context("and the group configuration sync fails") { beforeEach { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.errorResponse()) } // MARK: ------ throws an error it("throws an error") { - MessageSender - .createGroup( + await expect { + try await MessageSender.createGroup( name: "TestGroupName", description: nil, - displayPictureData: nil, + displayPicture: nil, + displayPictureCropRect: nil, members: [ ("051111111111111111111111111111111111111111111111111111111111111111", nil) ], using: dependencies ) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error).to(matchError(TestError.mock)) + }.toEventually(throwError(TestError.mock)) } // MARK: ------ removes the config state it("removes the config state") { - MessageSender - .createGroup( + await expect { + try await MessageSender.createGroup( name: "TestGroupName", description: nil, - displayPictureData: nil, + displayPicture: nil, + displayPictureCropRect: nil, members: [ ("051111111111111111111111111111111111111111111111111111111111111111", nil) ], using: dependencies ) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) + }.toEventually(throwError(TestError.mock)) expect(mockLibSessionCache) .to(call(.exactly(times: 1), matchingParameters: .all) { cache in @@ -549,18 +568,18 @@ class MessageSenderGroupsSpec: QuickSpec { // MARK: ------ removes the data from the database it("removes the data from the database") { - MessageSender - .createGroup( + await expect { + try await MessageSender.createGroup( name: "TestGroupName", description: nil, - displayPictureData: nil, + displayPicture: nil, + displayPictureCropRect: nil, members: [ ("051111111111111111111111111111111111111111111111111111111111111111", nil) ], using: dependencies ) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) + }.toEventually(throwError(TestError.mock)) let threads: [SessionThread]? = mockStorage.read { db in try SessionThread.fetchAll(db) } let groups: [ClosedGroup]? = mockStorage.read { db in try ClosedGroup.fetchAll(db) } @@ -577,27 +596,28 @@ class MessageSenderGroupsSpec: QuickSpec { // Prevent the ConfigSyncJob network request by making the libSession cache appear empty mockLibSessionCache.when { $0.isEmpty }.thenReturn(true) - MessageSender - .createGroup( + await expect { + try await MessageSender.createGroup( name: "TestGroupName", description: nil, - displayPictureData: nil, + displayPicture: nil, + displayPictureCropRect: nil, members: [ ("051111111111111111111111111111111111111111111111111111111111111111", nil) ], using: dependencies ) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) + }.toEventually(throwError(TestError.mock)) - let expectedRequest: Network.PreparedRequest = try Network + let expectedRequest: Network.PreparedRequest = try Network.FileServer .preparedUpload(data: TestConstants.validImageData, using: dependencies) expect(mockNetwork) .toNot(call { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.FileServer.Endpoint.file, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -609,23 +629,31 @@ class MessageSenderGroupsSpec: QuickSpec { // MARK: ------ uploads the image it("uploads the image") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1", uploaded: nil, expires: nil))) - MessageSender - .createGroup( + await expect { + try await MessageSender.createGroup( name: "TestGroupName", description: nil, - displayPictureData: TestConstants.validImageData, + displayPicture: .data("Test", TestConstants.validImageData), + displayPictureCropRect: nil, members: [ ("051111111111111111111111111111111111111111111111111111111111111111", nil) ], using: dependencies ) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) + }.toEventuallyNot(throwError()) - let expectedRequest: Network.PreparedRequest = try Network + let expectedRequest: Network.PreparedRequest = try Network.FileServer .preparedUpload( data: TestConstants.validImageData, requestAndPathBuildTimeout: Network.fileUploadTimeout, @@ -635,8 +663,9 @@ class MessageSenderGroupsSpec: QuickSpec { expect(mockNetwork) .to(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.FileServer.Endpoint.file, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -648,21 +677,29 @@ class MessageSenderGroupsSpec: QuickSpec { // Prevent the ConfigSyncJob network request by making the libSession cache appear empty mockLibSessionCache.when { $0.isEmpty }.thenReturn(true) mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1", uploaded: nil, expires: nil))) - MessageSender - .createGroup( + await expect { + try await MessageSender.createGroup( name: "TestGroupName", description: nil, - displayPictureData: TestConstants.validImageData, + displayPicture: .data("Test", TestConstants.validImageData), + displayPictureCropRect: nil, members: [ ("051111111111111111111111111111111111111111111111111111111111111111", nil) ], using: dependencies ) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) + }.toEventuallyNot(throwError()) let groups: [ClosedGroup]? = mockStorage.read { db in try ClosedGroup.fetchAll(db) } @@ -674,39 +711,44 @@ class MessageSenderGroupsSpec: QuickSpec { // MARK: ------ fails if the image fails to upload it("fails if the image fails to upload") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(Fail(error: NetworkError.unknown).eraseToAnyPublisher()) - MessageSender - .createGroup( + await expect { + try await MessageSender.createGroup( name: "TestGroupName", description: nil, - displayPictureData: TestConstants.validImageData, + displayPicture: .data("Test", TestConstants.validImageData), + displayPictureCropRect: nil, members: [ ("051111111111111111111111111111111111111111111111111111111111111111", nil) ], using: dependencies ) - .mapError { error.setting(to: $0) } - .sinkAndStore(in: &disposables) - - expect(error).to(matchError(AttachmentError.uploadFailed)) + }.toEventually(throwError(AttachmentError.uploadFailed)) } } // MARK: ---- schedules member invite jobs it("schedules member invite jobs") { - MessageSender - .createGroup( - name: "TestGroupName", - description: nil, - displayPictureData: nil, - members: [ - ("051111111111111111111111111111111111111111111111111111111111111111", nil) - ], - using: dependencies - ) - .sinkAndStore(in: &disposables) + _ = try? await MessageSender.createGroup( + name: "TestGroupName", + description: nil, + displayPicture: nil, + displayPictureCropRect: nil, + members: [ + ("051111111111111111111111111111111111111111111111111111111111111111", nil) + ], + using: dependencies + ) expect(mockJobRunner) .to(call(.exactly(times: 1), matchingParameters: .all) { jobRunner in @@ -791,23 +833,23 @@ class MessageSenderGroupsSpec: QuickSpec { .when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) } .thenReturn(true) - MessageSender - .createGroup( - name: "TestGroupName", - description: nil, - displayPictureData: nil, - members: [ - ("051111111111111111111111111111111111111111111111111111111111111111", nil) - ], - using: dependencies - ) - .sinkAndStore(in: &disposables) + _ = try? await MessageSender.createGroup( + name: "TestGroupName", + description: nil, + displayPicture: nil, + displayPictureCropRect: nil, + members: [ + ("051111111111111111111111111111111111111111111111111111111111111111", nil) + ], + using: dependencies + ) expect(mockNetwork) .to(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.PushNotification.Endpoint.subscribe, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -825,22 +867,22 @@ class MessageSenderGroupsSpec: QuickSpec { .when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) } .thenReturn(false) - MessageSender - .createGroup( - name: "TestGroupName", - description: nil, - displayPictureData: nil, - members: [ - ("051111111111111111111111111111111111111111111111111111111111111111", nil) - ], - using: dependencies - ) - .sinkAndStore(in: &disposables) + _ = try? await MessageSender.createGroup( + name: "TestGroupName", + description: nil, + displayPicture: nil, + displayPictureCropRect: nil, + members: [ + ("051111111111111111111111111111111111111111111111111111111111111111", nil) + ], + using: dependencies + ) expect(mockNetwork).toNot(call { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.PushNotification.Endpoint.subscribe, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -858,22 +900,22 @@ class MessageSenderGroupsSpec: QuickSpec { .when { $0.bool(forKey: UserDefaults.BoolKey.isUsingFullAPNs.rawValue) } .thenReturn(true) - MessageSender - .createGroup( - name: "TestGroupName", - description: nil, - displayPictureData: nil, - members: [ - ("051111111111111111111111111111111111111111111111111111111111111111", nil) - ], - using: dependencies - ) - .sinkAndStore(in: &disposables) + _ = try? await MessageSender.createGroup( + name: "TestGroupName", + description: nil, + displayPicture: nil, + displayPictureCropRect: nil, + members: [ + ("051111111111111111111111111111111111111111111111111111111111111111", nil) + ], + using: dependencies + ) expect(mockNetwork).toNot(call { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.PushNotification.Endpoint.subscribe, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -886,7 +928,15 @@ class MessageSenderGroupsSpec: QuickSpec { context("when adding members to a group") { beforeEach { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockAddMemberConfigSyncResponse) // Rekey a couple of times to increase the key generation to 1 @@ -994,7 +1044,15 @@ class MessageSenderGroupsSpec: QuickSpec { context("and granting access to historic messages") { beforeEach { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockAddMemberHistoricConfigSyncResponse) } @@ -1093,8 +1151,9 @@ class MessageSenderGroupsSpec: QuickSpec { expect(mockNetwork) .to(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.SnodeAPI.Endpoint.sequence, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) @@ -1206,7 +1265,15 @@ class MessageSenderGroupsSpec: QuickSpec { context("and not granting access to historic messages") { beforeEach { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockAddMemberConfigSyncResponse) } @@ -1286,8 +1353,9 @@ class MessageSenderGroupsSpec: QuickSpec { expect(mockNetwork) .to(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( - expectedRequest.body, - to: expectedRequest.destination, + endpoint: Network.SnodeAPI.Endpoint.sequence, + destination: expectedRequest.destination, + body: expectedRequest.body, requestTimeout: expectedRequest.requestTimeout, requestAndPathBuildTimeout: expectedRequest.requestAndPathBuildTimeout ) diff --git a/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift b/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift index c12a80ca24..1433cbab66 100644 --- a/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift @@ -58,7 +58,15 @@ class CommunityPollerSpec: AsyncSpec { initialSetup: { network in // Delay for 10 seconds because we don't want the Poller to get stuck in a recursive loop network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn( MockNetwork.response(with: FileUploadResponse(id: "1", uploaded: nil, expires: nil)) .delay(for: .seconds(10), scheduler: DispatchQueue.main) diff --git a/SessionMessagingKitTests/_TestUtilities/MockPoller.swift b/SessionMessagingKitTests/_TestUtilities/MockPoller.swift index be924bf820..a20133bb86 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockPoller.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockPoller.swift @@ -17,6 +17,7 @@ class MockPoller: Mock, PollerType { var pollerDestination: PollerDestination { mock() } var logStartAndStopCalls: Bool { mock() } nonisolated var receivedPollResponse: AsyncStream { mock() } + nonisolated var successfulPollCount: AsyncStream { mock() } var isPolling: Bool { get { mock() } set { mockNoReturn(args: [newValue]) } diff --git a/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift b/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift index ca1f1925f5..9e952bed30 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockSwarmPoller.swift @@ -13,6 +13,7 @@ class MockSwarmPoller: Mock, SwarmPollerType & Pol var pollerDestination: PollerDestination { mock() } var logStartAndStopCalls: Bool { mock() } nonisolated var receivedPollResponse: AsyncStream { mock() } + nonisolated var successfulPollCount: AsyncStream { mock() } var isPolling: Bool { get { mock() } set { mockNoReturn(args: [newValue]) } diff --git a/SessionNetworkingKit/FileServer/FileServer.swift b/SessionNetworkingKit/FileServer/FileServer.swift index aa2241252f..ff8cd75875 100644 --- a/SessionNetworkingKit/FileServer/FileServer.swift +++ b/SessionNetworkingKit/FileServer/FileServer.swift @@ -19,9 +19,12 @@ public extension Network { } internal static func edPublicKey(using dependencies: Dependencies) -> String { - guard dependencies[feature: .customFileServer].isValid else { - return defaultEdPublicKey - } + let customPubkey: String = dependencies[feature: .customFileServer].pubkey + + guard + dependencies[feature: .customFileServer].isValid, + !customPubkey.isEmpty /// An empty `pubkey` will be considered value (as we just fallback to the default) + else { return defaultEdPublicKey } return dependencies[feature: .customFileServer].pubkey } @@ -130,7 +133,12 @@ public extension Network.FileServer { values.pubkey.count == 64 ) - return (pubkeyValid && URL(string: url) != nil) + return ( + URL(string: url) != nil && ( + values.pubkey.isEmpty || /// Default pubkey would be used if empty + pubkeyValid + ) + ) } /// This is needed to conform to `FeatureOption` so it can be saved to `UserDefaults` diff --git a/SessionNetworkingKit/Types/HTTPFragmentParam.swift b/SessionNetworkingKit/Types/HTTPFragmentParam.swift index aa960e2a6f..50821c5496 100644 --- a/SessionNetworkingKit/Types/HTTPFragmentParam.swift +++ b/SessionNetworkingKit/Types/HTTPFragmentParam.swift @@ -2,7 +2,7 @@ import Foundation -public struct HTTPFragmentParam: RawRepresentable, ExpressibleByStringLiteral, Hashable { +public struct HTTPFragmentParam: RawRepresentable, Codable, ExpressibleByStringLiteral, Hashable { public let rawValue: String public init(_ rawValue: String) { self.rawValue = rawValue } diff --git a/SessionNetworkingKit/Types/HTTPQueryParam.swift b/SessionNetworkingKit/Types/HTTPQueryParam.swift index 77853d002c..a350963bdb 100644 --- a/SessionNetworkingKit/Types/HTTPQueryParam.swift +++ b/SessionNetworkingKit/Types/HTTPQueryParam.swift @@ -2,7 +2,7 @@ import Foundation -public struct HTTPQueryParam: RawRepresentable, ExpressibleByStringLiteral, Hashable { +public struct HTTPQueryParam: RawRepresentable, Codable, ExpressibleByStringLiteral, Hashable { public let rawValue: String public init(_ rawValue: String) { self.rawValue = rawValue } diff --git a/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift b/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift index 342be00b57..80ae78bb07 100644 --- a/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift +++ b/SessionNetworkingKitTests/SOGS/SOGSAPISpec.swift @@ -621,7 +621,15 @@ class SOGSAPISpec: QuickSpec { // MARK: ---- processes a valid response correctly it("processes a valid response correctly") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockCapabilitiesAndRoomResponse) var response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesAndRoomResponse)? @@ -656,7 +664,15 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ errors when not given a room response it("errors when not given a room response") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockCapabilitiesAndBanResponse) var response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesAndRoomResponse)? @@ -689,7 +705,15 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ errors when not given a capabilities response it("errors when not given a capabilities response") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockBanAndRoomResponse) var response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesAndRoomResponse)? @@ -793,7 +817,15 @@ class SOGSAPISpec: QuickSpec { // MARK: ---- processes a valid response correctly it("processes a valid response correctly") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockCapabilitiesAndRoomsResponse) var response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesAndRoomsResponse)? @@ -827,7 +859,15 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ errors when not given a room response it("errors when not given a room response") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn( MockNetwork.batchResponseData(with: [ (Network.SOGS.Endpoint.capabilities, Network.SOGS.CapabilitiesResponse.mockBatchSubResponse()), @@ -867,7 +907,15 @@ class SOGSAPISpec: QuickSpec { // MARK: ------ errors when not given a capabilities response it("errors when not given a capabilities response") { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(Network.BatchResponse.mockBanAndRoomsResponse) var response: (info: ResponseInfoType, data: Network.SOGS.CapabilitiesAndRoomsResponse)? @@ -2237,7 +2285,15 @@ class SOGSAPISpec: QuickSpec { beforeEach { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.response(type: [Network.SOGS.Room].self)) } diff --git a/SessionNetworkingKitTests/Types/DestinationSpec.swift b/SessionNetworkingKitTests/Types/DestinationSpec.swift index 2c228cbdb5..506931260c 100644 --- a/SessionNetworkingKitTests/Types/DestinationSpec.swift +++ b/SessionNetworkingKitTests/Types/DestinationSpec.swift @@ -9,6 +9,15 @@ import Nimble class DestinationSpec: QuickSpec { override class func spec() { + // MARK: Configuration + + @TestState var dependencies: TestDependencies! = TestDependencies() + + @TestState var urlRequest: URLRequest? + @TestState var preparedRequest: Network.PreparedRequest! + @TestState var request: Request! + @TestState var responseInfo: ResponseInfoType! = Network.ResponseInfo(code: 200, headers: [:]) + // MARK: - a Destination describe("a Destination") { // MARK: -- when generating a path @@ -81,13 +90,27 @@ class DestinationSpec: QuickSpec { context("for a server") { // MARK: ---- throws an error if the generated URL is invalid it("throws an error if the generated URL is invalid") { - expect { - _ = try Network.Destination.server( + request = try Request( + endpoint: .testParams("test", 123), + destination: .server( + method: .post, server: "ftp:// test Server", + queryParameters: [:], + headers: [ + "TestCustomHeader": "TestCustom", + HTTPHeader.testHeader: "Test" + ], x25519PublicKey: "" - ).withGeneratedUrl(for: TestEndpoint.testParams("test", 123)) - } - .to(throwError(NetworkError.invalidURL)) + ), + body: nil + ) + preparedRequest = try! Network.PreparedRequest( + request: request, + responseType: TestType.self, + using: dependencies + ) + + expect { try preparedRequest.generateUrl() }.to(throwError(NetworkError.invalidURL)) } } } @@ -96,6 +119,10 @@ class DestinationSpec: QuickSpec { // MARK: - Test Types +fileprivate extension HTTPHeader { + static let testHeader: HTTPHeader = "TestHeader" +} + fileprivate extension HTTPQueryParam { static let testParam: HTTPQueryParam = "testParam" } diff --git a/SessionNetworkingKitTests/Types/PreparedRequestSendingSpec.swift b/SessionNetworkingKitTests/Types/PreparedRequestSendingSpec.swift index a1ad6f438b..c3ce6176f7 100644 --- a/SessionNetworkingKitTests/Types/PreparedRequestSendingSpec.swift +++ b/SessionNetworkingKitTests/Types/PreparedRequestSendingSpec.swift @@ -45,7 +45,15 @@ class PreparedRequestSendingSpec: QuickSpec { context("when sending") { beforeEach { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.response(with: 1)) } @@ -352,7 +360,15 @@ class PreparedRequestSendingSpec: QuickSpec { beforeEach { mockNetwork - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn( MockNetwork.batchResponseData(with: [ (endpoint: TestEndpoint.endpoint1, data: TestType.mockBatchSubResponse()), diff --git a/SessionNetworkingKitTests/Types/RequestSpec.swift b/SessionNetworkingKitTests/Types/RequestSpec.swift index 0b951cf1d3..4f920637f9 100644 --- a/SessionNetworkingKitTests/Types/RequestSpec.swift +++ b/SessionNetworkingKitTests/Types/RequestSpec.swift @@ -48,9 +48,10 @@ class RequestSpec: QuickSpec { ) ) - expect(request.destination.url?.absoluteString).to(equal("testServer/test1")) + expect(request.endpoint).to(equal(.test1)) + expect(request.destination.server).to(equal("testServer")) + expect(request.destination.queryParameters).to(equal([:])) expect(request.destination.method.rawValue).to(equal("DELETE")) - expect(request.destination.urlPathAndParamsString).to(equal("/test1")) expect(request.destination.headers).to(equal(["TestHeader": "test"])) expect(request.body).to(beNil()) } diff --git a/SessionNetworkingKitTests/_TestUtilities/CommonSSKMockExtensions.swift b/SessionNetworkingKitTests/_TestUtilities/CommonSSKMockExtensions.swift index bbd0834908..6ffb4bc3e4 100644 --- a/SessionNetworkingKitTests/_TestUtilities/CommonSSKMockExtensions.swift +++ b/SessionNetworkingKitTests/_TestUtilities/CommonSSKMockExtensions.swift @@ -37,7 +37,7 @@ extension Network.Destination: Mocked { server: "testServer", headers: [:], x25519PublicKey: "" - ).withGeneratedUrl(for: MockEndpoint.mock) + ) } extension Network.SOGS.CapabilitiesResponse: Mocked { diff --git a/SessionNetworkingKitTests/_TestUtilities/MockNetwork.swift b/SessionNetworkingKitTests/_TestUtilities/MockNetwork.swift index eff21161ac..a496460a77 100644 --- a/SessionNetworkingKitTests/_TestUtilities/MockNetwork.swift +++ b/SessionNetworkingKitTests/_TestUtilities/MockNetwork.swift @@ -19,27 +19,19 @@ class MockNetwork: Mock, NetworkType { return mock(args: [count]) } - func send( - _ body: Data?, - to destination: Network.Destination, + func send( + endpoint: E, + destination: Network.Destination, + body: Data?, requestTimeout: TimeInterval, requestAndPathBuildTimeout: TimeInterval? ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { requestData = RequestData( - body: body, method: destination.method, - pathAndParamsString: destination.urlPathAndParamsString, headers: destination.headers, - x25519PublicKey: { - switch destination { - case .server(let info), .serverUpload(let info, _), .serverDownload(let info): return info.x25519PublicKey - case .snode(_, let swarmPublicKey): return swarmPublicKey - case .randomSnode(let swarmPublicKey, _), .randomSnodeLatestNetworkTimeTarget(let swarmPublicKey, _, _): - return swarmPublicKey - - case .cached: return nil - } - }(), + path: endpoint.path, + queryParameters: destination.queryParameters, + body: body, requestTimeout: requestTimeout, requestAndPathBuildTimeout: requestAndPathBuildTimeout ) @@ -116,20 +108,20 @@ struct MockResponseInfo: ResponseInfoType, Mocked { struct RequestData: Codable, Mocked { static let mock: RequestData = RequestData( - body: nil, method: .get, - pathAndParamsString: "", headers: [:], - x25519PublicKey: nil, + path: "/mock", + queryParameters: [:], + body: nil, requestTimeout: 0, requestAndPathBuildTimeout: nil ) - let body: Data? let method: HTTPMethod - let pathAndParamsString: String let headers: [HTTPHeader: String] - let x25519PublicKey: String? + let path: String + let queryParameters: [HTTPQueryParam: String] + let body: Data? let requestTimeout: TimeInterval let requestAndPathBuildTimeout: TimeInterval? } diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index 32e13bcd49..670291fe2e 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -223,6 +223,16 @@ final class ShareNavController: UINavigationController { do { let attachments: [PendingAttachment] = try await buildAttachments() + + /// Validate the expected attachment sizes before proceeding + try attachments.forEach { attachment in + try attachment.ensureExpectedEncryptedSize( + domain: .attachment, + maxFileSize: Network.maxFileSize, + using: self.dependencies + ) + } + await ShareNavController.pendingAttachments.send(attachments) await indicator.dismiss() } @@ -255,7 +265,8 @@ final class ShareNavController: UINavigationController { Log.error("Failed to share due to error: \(error)") let errorTitle: String = { switch error { - case NetworkError.maxFileSizeExceeded: return "attachmentsErrorSending".localized() + case NetworkError.maxFileSizeExceeded, AttachmentError.fileSizeTooLarge: + return "attachmentsErrorSending".localized() case AttachmentError.noAttachment, AttachmentError.encryptionFailed: return Constants.app_name @@ -266,7 +277,9 @@ final class ShareNavController: UINavigationController { }() let errorText: String = { switch error { - case NetworkError.maxFileSizeExceeded: return "attachmentsErrorSize".localized() + case NetworkError.maxFileSizeExceeded, AttachmentError.fileSizeTooLarge: + return "attachmentsErrorSize".localized() + case AttachmentError.noAttachment, AttachmentError.encryptionFailed: return "attachmentsErrorSending".localized() @@ -489,13 +502,76 @@ final class ShareNavController: UINavigationController { } } - /// If the attachment is a video that isn't in the `supportedVideoTypes` then we should try to convert it - guard - pendingAttachment.utType.isVideo && - !UTType.supportedVideoTypes.contains(pendingAttachment.utType) - else { return pendingAttachment } + /// Apple likes to use special formats for media so in order to maintain compatibility with other clients we want to + /// convert videos to `MPEG4` and images to `WebP` if it's not one of the supported output types + let utType: UTType = pendingAttachment.utType + let frameCount: Int = { + switch pendingAttachment.metadata { + case .media(let metadata): return metadata.frameCount + default: return 1 + } + }() + + if utType.isVideo && !UTType.supportedOutputVideoTypes.contains(utType) { + /// Since we need to convert the file we should clean up the temporary one we created earlier (the conversion will create + /// a new one) + defer { + switch pendingAttachment.source { + case .file(let url), .media(.url(let url)): + if dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(url.path) { + try? dependencies[singleton: .fileManager].removeItem(atPath: url.path) + } + default: break + } + } + + let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( + operations: [.convert(to: .mp4)], + using: dependencies + ) + + return PendingAttachment( + source: .media( + .videoUrl( + URL(fileURLWithPath: preparedAttachment.filePath), + .mpeg4Movie, + pendingAttachment.sourceFilename, + dependencies[singleton: .attachmentManager] + ) + ), + utType: .mpeg4Movie, + sourceFilename: pendingAttachment.sourceFilename, + using: dependencies + ) + } + + if utType.isImage && frameCount == 1 && !UTType.supportedOutputImageTypes.contains(utType) { + /// Since we need to convert the file we should clean up the temporary one we created earlier (the conversion will create + /// a new one) + defer { + switch pendingAttachment.source { + case .file(let url), .media(.url(let url)): + if dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(url.path) { + try? dependencies[singleton: .fileManager].removeItem(atPath: url.path) + } + default: break + } + } + + let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( + operations: [.convert(to: .webPLossy)], + using: dependencies + ) + + return PendingAttachment( + source: .media(.url(URL(fileURLWithPath: preparedAttachment.filePath))), + utType: .webP, + sourceFilename: pendingAttachment.sourceFilename, + using: dependencies + ) + } - return try await pendingAttachment.toMp4Video(using: dependencies) + return pendingAttachment } private func buildAttachments() async throws -> [PendingAttachment] { diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index f7b1772884..97e1875fbe 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -249,7 +249,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView // cases we should ignore the attachments let isSharingUrl: Bool = (attachments.count == 1 && attachments[0].utType.conforms(to: .url)) let isSharingText: Bool = (attachments.count == 1 && attachments[0].utType.isText) - let finalAttachments: [PendingAttachment] = (isSharingUrl || isSharingText ? [] : attachments) + let finalPendingAttachments: [PendingAttachment] = (isSharingUrl || isSharingText ? [] : attachments) let body: String? = { guard isSharingUrl else { return messageText } @@ -274,29 +274,65 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView shareNavController?.dismiss(animated: true, completion: nil) - ModalActivityIndicatorViewController.present(fromViewController: shareNavController!, canCancel: false, message: "sending".localized()) { [dependencies = viewModel.dependencies] activityIndicator in + let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController( + canCancel: false, + message: "sending".localized() + ) + shareNavController?.present(indicator, animated: false) + + Task(priority: .userInitiated) { [weak self, indicator, dependencies = viewModel.dependencies] in dependencies[singleton: .storage].resumeDatabaseAccess() dependencies.mutate(cache: .libSessionNetwork) { $0.resumeNetworkAccess() } - /// When we prepare the message we set the timestamp to be the `dependencies[cache: .snodeAPI].currentOffsetTimestampMs()` - /// but won't actually have a value because the share extension won't have talked to a service node yet which can cause - /// issues with Disappearing Messages, as a result we need to explicitly `getNetworkTime` in order to ensure it's accurate - /// before we create the interaction var sharedInteractionId: Int64? - dependencies[singleton: .network] - .getSwarm(for: swarmPublicKey) - .tryFlatMapWithRandomSnode(using: dependencies) { snode in - try Network.SnodeAPI - .preparedGetNetworkTime(from: snode, using: dependencies) - .send(using: dependencies) + + do { + /// When we prepare the message we set the timestamp to be the `dependencies[cache: .snodeAPI].currentOffsetTimestampMs()` + /// but won't actually have a value because the share extension won't have talked to a service node yet which can cause + /// issues with Disappearing Messages, as a result we need to explicitly `getNetworkTime` in order to ensure it's accurate + /// before we create the interaction + // FIXME: Make this async/await when the refactored networking is merged + var swarm: Set = try await dependencies[singleton: .network] + .getSwarm(for: swarmPublicKey) + .values + .first(where: { _ in true }) ?? { throw AttachmentError.uploadFailed }() + let snode: LibSession.Snode = try dependencies.popRandomElement(&swarm) ?? { + throw SnodeAPIError.ranOutOfRandomSnodes(nil) + }() + try Task.checkCancellation() + + /// If there is a `LinkPreviewDraft` then we may need to add it, so generate it's attachment if possible + var linkPreviewAttachment: Attachment? + + if let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft { + linkPreviewAttachment = try? await LinkPreview.generateAttachmentIfPossible( + urlString: linkPreviewDraft.urlString, + imageData: linkPreviewDraft.jpegImageData, + type: .jpeg, + using: dependencies + ) } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .flatMapStorageWritePublisher(using: dependencies) { db, _ -> (Message, Message.Destination, Int64?, AuthenticationMethod, [AttachmentUploadJob.PreparedUpload]) in + + /// Prepare any attachment to be sent + var finalAttachments: [Attachment] = try await AttachmentUploadJob.preparePriorToUpload( + attachments: finalPendingAttachments, + using: dependencies + ) + + typealias ShareDatabaseData = ( + message: Message, + destination: Message.Destination, + interactionId: Int64?, + authMethod: AuthenticationMethod, + attachmentsNeedingUpload: [Attachment] + ) + + let shareData: ShareDatabaseData = try await dependencies[singleton: .storage].writeAsync { db in guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { throw MessageSenderError.noThread } - // Update the thread to be visible (if it isn't already) + /// Update the thread to be visible (if it isn't already) if !thread.shouldBeVisible || thread.pinnedPriority == LibSession.hiddenPriority { try SessionThread.updateVisibility( db, @@ -307,7 +343,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView ) } - // Create the interaction + /// Create the interaction let sentTimestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let destinationDisappearingMessagesConfiguration: DisappearingMessagesConfiguration? = try? DisappearingMessagesConfiguration .filter(id: threadId) @@ -344,13 +380,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView try LinkPreview( url: linkPreviewDraft.urlString, title: linkPreviewDraft.title, - attachmentId: LinkPreview - .generateAttachmentIfPossible( - urlString: linkPreviewDraft.urlString, - imageData: linkPreviewDraft.jpegImageData, - type: .jpeg, - using: dependencies - )? + attachmentId: linkPreviewAttachment? .inserted(db) .id, using: dependencies @@ -360,14 +390,11 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView // Link any attachments to their interaction try AttachmentUploadJob.link( db, - attachments: try AttachmentUploadJob.preparePriorToUpload( - attachments: finalAttachments, - using: dependencies - ), + attachments: finalAttachments, toInteractionWithId: interactionId ) - // Using the same logic as the `MessageSendJob` retrieve + // Using the same logic as the `MessageSendJob` retrieve let authMethod: AuthenticationMethod = try Authentication.with( db, threadId: threadId, @@ -376,16 +403,9 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView ) let attachmentState: MessageSendJob.AttachmentState = try MessageSendJob .fetchAttachmentState(db, interactionId: interactionId, using: dependencies) - let preparedUploads: [AttachmentUploadJob.PreparedUpload] = try Attachment + let attachmentsNeedingUpload: [Attachment] = try Attachment .filter(ids: attachmentState.allAttachmentIds) .fetchAll(db) - .map { attachment in - try AttachmentUploadJob.preparedUpload( - attachment: attachment, - authMethod: authMethod, - using: dependencies - ) - } let visibleMessage: VisibleMessage = VisibleMessage.from(db, interaction: interaction) let destination: Message.Destination = try Message.Destination.from( db, @@ -393,87 +413,80 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView threadVariant: threadVariant ) - return (visibleMessage, destination, interaction.id, authMethod, preparedUploads) + return (visibleMessage, destination, interaction.id, authMethod, attachmentsNeedingUpload) } - .flatMap { (message: Message, destination: Message.Destination, interactionId: Int64?, authMethod: AuthenticationMethod, preparedUploads: [AttachmentUploadJob.PreparedUpload]) -> AnyPublisher<(Message, Message.Destination, Int64?, AuthenticationMethod, [(Attachment, PreparedAttachment, FileUploadResponse)]), Error> in - guard !preparedUploads.isEmpty else { - return Just((message, destination, interactionId, authMethod, [])) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - return Publishers - .MergeMany( - preparedUploads.map { request, attachment, preparedAttachment in - request.send(using: dependencies).map { _, response in - (attachment, preparedAttachment, response) - } + try Task.checkCancellation() + + /// Perform any uploads that are needed + let uploadedAttachments: [(attachment: Attachment, fileId: String)] = (shareData.attachmentsNeedingUpload.isEmpty ? + [] : + try await withThrowingTaskGroup { group in + shareData.attachmentsNeedingUpload.forEach { attachment in + group.addTask { + try await AttachmentUploadJob.upload( + attachment: attachment, + threadId: threadId, + interactionId: shareData.interactionId, + messageSendJobId: nil, + authMethod: shareData.authMethod, + onEvent: AttachmentUploadJob.standardEventHandling(using: dependencies), + using: dependencies + ) } - ) - .collect() - .map { results in (message, destination, interactionId, authMethod, results) } - .eraseToAnyPublisher() - } - .tryFlatMap { message, destination, interactionId, authMethod, uploadResults -> AnyPublisher<(Message, [Attachment]), Error> in - let updatedAttachments: [(attachment: Attachment, fileId: String)] = try uploadResults.map { attachment, preparedAttachment, response in - ( - try AttachmentUploadJob.processUploadResponse( - originalAttachment: attachment, - preparedAttachment: preparedAttachment, - authMethod: authMethod, - response: response, - using: dependencies - ), - response.id - ) - } - - return try MessageSender - .preparedSend( - message: message, - to: destination, - namespace: destination.defaultNamespace, - interactionId: interactionId, - attachments: updatedAttachments, - authMethod: authMethod, - onEvent: MessageSender.standardEventHandling(using: dependencies), - using: dependencies - ) - .send(using: dependencies) - .map { _, message in - (message, updatedAttachments.map { attachment, _ in attachment }) } - .eraseToAnyPublisher() - } - .handleEvents( - receiveOutput: { _, attachments in - guard !attachments.isEmpty else { return } - /// Need to actually save the uploaded attachments now that we are done - dependencies[singleton: .storage].write { db in - attachments.forEach { attachment in - try? attachment.upsert(db) - } - } + return try await group.reduce(into: []) { result, next in + result.append((next.attachment, next.response.id)) } + }) + + let request: Network.PreparedRequest = try MessageSender.preparedSend( + message: shareData.message, + to: shareData.destination, + namespace: shareData.destination.defaultNamespace, + interactionId: shareData.interactionId, + attachments: uploadedAttachments, + authMethod: shareData.authMethod, + onEvent: MessageSender.standardEventHandling(using: dependencies), + using: dependencies ) - .receive(on: DispatchQueue.main) - .sinkUntilComplete( - receiveCompletion: { [weak self] result in - dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() } - dependencies[singleton: .storage].suspendDatabaseAccess() - Log.flush() - activityIndicator.dismiss { } - - switch result { - case .finished: self?.shareNavController?.shareViewWasCompleted( - threadId: threadId, - interactionId: sharedInteractionId - ) - case .failure(let error): self?.shareNavController?.shareViewFailed(error: error) + + // FIXME: Make this async/await when the refactored networking is merged + let response: Message = try await request + .send(using: dependencies) + .values + .first(where: { _ in true })?.1 ?? { throw AttachmentError.uploadFailed }() + try Task.checkCancellation() + + /// Need to actually save the uploaded attachments now that we are done + if !uploadedAttachments.isEmpty { + try? await dependencies[singleton: .storage].writeAsync { db in + uploadedAttachments.forEach { attachment, _ in + try? attachment.upsert(db) } } - ) + } + + dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() } + dependencies[singleton: .storage].suspendDatabaseAccess() + Log.flush() + + await MainActor.run { [weak self] in + indicator.dismiss() + + self?.shareNavController?.shareViewWasCompleted( + threadId: threadId, + interactionId: sharedInteractionId + ) + } + } + catch { + dependencies.mutate(cache: .libSessionNetwork) { $0.suspendNetworkAccess() } + dependencies[singleton: .storage].suspendDatabaseAccess() + Log.flush() + indicator.dismiss() + self?.shareNavController?.shareViewFailed(error: error) + } } } diff --git a/SessionTests/Onboarding/OnboardingSpec.swift b/SessionTests/Onboarding/OnboardingSpec.swift index 5b9c4c1787..568426309e 100644 --- a/SessionTests/Onboarding/OnboardingSpec.swift +++ b/SessionTests/Onboarding/OnboardingSpec.swift @@ -117,7 +117,15 @@ class OnboardingSpec: AsyncSpec { ) network - .when { $0.send(.any, to: .any, requestTimeout: .any, requestAndPathBuildTimeout: .any) } + .when { + $0.send( + endpoint: MockEndpoint.any, + destination: .any, + body: .any, + requestTimeout: .any, + requestAndPathBuildTimeout: .any + ) + } .thenReturn(MockNetwork.batchResponseData( with: [ ( @@ -484,8 +492,8 @@ class OnboardingSpec: AsyncSpec { await expect(mockNetwork) .toEventually(call(.exactly(times: 1), matchingParameters: .atLeast(3)) { $0.send( - Data(base64Encoded: base64EncodedDataString), - to: Network.Destination.snode( + endpoint: Network.SnodeAPI.Endpoint.batch, + destination: Network.Destination.snode( LibSession.Snode( ip: "1.2.3.4", quicPort: 1234, @@ -493,6 +501,7 @@ class OnboardingSpec: AsyncSpec { ), swarmPublicKey: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b" ), + body: Data(base64Encoded: base64EncodedDataString), requestTimeout: 10, requestAndPathBuildTimeout: nil ) diff --git a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift index 90be264afb..28bf9c77ca 100644 --- a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift +++ b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift @@ -511,8 +511,16 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { profileView.setDataManager(dataManager) profileView.update( ProfilePictureView.Info( - source: (source ?? placeholder), - icon: icon + source: { + guard + let source: ImageDataManager.DataSource = source, + source.contentExists + else { return placeholder } + + return source + }(), + icon: icon, + cropRect: style.cropRect ) ) internalOnBodyTap = onClick @@ -632,16 +640,19 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { @objc private func imageViewTapped() { internalOnBodyTap?({ [weak self, info = self.info] valueUpdate in switch (valueUpdate, info.body) { - case (.image(let updatedIdentifier, let updatedData), .image(_, let placeholder, let icon, let style, let accessibility, let dataManager, let onClick)): + case (.image(let source, let cropRect), .image(_, let placeholder, let icon, let style, let accessibility, let dataManager, let onClick)): self?.updateContent( with: info.with( body: .image( - source: updatedData.map { - ImageDataManager.DataSource.data(updatedIdentifier, $0) - }, + source: source, placeholder: placeholder, icon: icon, - style: style, + style: { + switch style { + case .inherit: return .inherit + case .circular: return .circular(cropRect: cropRect) + } + }(), accessibility: accessibility, dataManager: dataManager, onClick: onClick @@ -737,7 +748,7 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { public extension ConfirmationModal { enum ValueUpdate { case input(String) - case image(identifier: String, data: Data?) + case image(source: ImageDataManager.DataSource, cropRect: CGRect?) } struct Info: Equatable, Hashable { @@ -958,8 +969,18 @@ public extension ConfirmationModal.Info { } public enum ImageStyle: Equatable, Hashable { case inherit - case circular + case circular(cropRect: CGRect?) + + public static var circular: ImageStyle { return .circular(cropRect: nil) } + + public var cropRect: CGRect? { + switch self { + case .inherit: return nil + case .circular(let rect): return rect + } + } } + public struct RadioOptionInfo: Equatable, Hashable { public let title: String public let enabled: Bool diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index 73d06a2fcb..0992205bac 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -10,6 +10,7 @@ public final class ProfilePictureView: UIView { let themeTintColor: ThemeValue? let inset: UIEdgeInsets let icon: ProfileIcon + let cropRect: CGRect? let backgroundColor: ThemeValue? let forcedBackgroundColor: ForcedThemeValue? @@ -19,6 +20,7 @@ public final class ProfilePictureView: UIView { themeTintColor: ThemeValue? = nil, inset: UIEdgeInsets = .zero, icon: ProfileIcon = .none, + cropRect: CGRect? = nil, backgroundColor: ThemeValue? = nil, forcedBackgroundColor: ForcedThemeValue? = nil ) { @@ -27,6 +29,7 @@ public final class ProfilePictureView: UIView { self.themeTintColor = themeTintColor self.inset = inset self.icon = icon + self.cropRect = cropRect self.backgroundColor = backgroundColor self.forcedBackgroundColor = forcedBackgroundColor } @@ -117,18 +120,7 @@ public final class ProfilePictureView: UIView { self.widthConstraint.constant = (customWidth ?? self.size.viewSize) } } - override public var clipsToBounds: Bool { - didSet { - imageContainerView.clipsToBounds = clipsToBounds - additionalImageContainerView.clipsToBounds = clipsToBounds - - imageContainerView.layer.cornerRadius = (clipsToBounds ? - (additionalImageContainerView.isHidden ? (size.imageSize / 2) : (size.multiImageSize / 2)) : - 0 - ) - imageContainerView.layer.cornerRadius = (clipsToBounds ? (size.multiImageSize / 2) : 0) - } - } + public override var isHidden: Bool { didSet { widthConstraint.constant = (isHidden ? 0 : size.viewSize) @@ -431,11 +423,9 @@ public final class ProfilePictureView: UIView { private func prepareForReuse() { imageView.image = nil imageView.contentMode = .scaleAspectFill - imageContainerView.clipsToBounds = clipsToBounds imageContainerView.themeBackgroundColor = .backgroundSecondary additionalImageContainerView.isHidden = true additionalImageView.image = nil - additionalImageContainerView.clipsToBounds = clipsToBounds imageViewTopConstraint.isActive = false imageViewLeadingConstraint.isActive = false @@ -478,7 +468,14 @@ public final class ProfilePictureView: UIView { case (.image(_, let image), .some(let renderingMode)): imageView.image = image?.withRenderingMode(renderingMode) - case (.some(let source), _): imageView.loadImage(source) + case (.some(let source), _): + imageView.loadImage(source) { [weak self, weak imageView = self.imageView] _ in + self?.applyCropTransform( + to: imageView, + source: source, + cropRect: info.cropRect + ) + } default: imageView.image = nil } @@ -497,11 +494,18 @@ public final class ProfilePictureView: UIView { } } + // Apply crop transform if needed + applyCropTransform( + to: imageView, + source: info.source, + cropRect: info.cropRect + ) + // Check if there is a second image (if not then set the size and finish) guard let additionalInfo: Info = additionalInfo else { imageViewWidthConstraint.constant = size.imageSize imageViewHeightConstraint.constant = size.imageSize - imageContainerView.layer.cornerRadius = (imageContainerView.clipsToBounds ? (size.imageSize / 2) : 0) + imageContainerView.layer.cornerRadius = (size.imageSize / 2) return } @@ -524,7 +528,13 @@ public final class ProfilePictureView: UIView { additionalImageContainerView.isHidden = false case (.some(let source), _): - additionalImageView.loadImage(source) + additionalImageView.loadImage(source) { [weak self, weak imageView = self.additionalImageView] _ in + self?.applyCropTransform( + to: imageView, + source: additionalInfo.source, + cropRect: additionalInfo.cropRect + ) + } additionalImageContainerView.isHidden = false default: @@ -549,6 +559,11 @@ public final class ProfilePictureView: UIView { default: break } } + applyCropTransform( + to: additionalImageView, + source: additionalInfo.source, + cropRect: additionalInfo.cropRect + ) imageViewTopConstraint.isActive = true imageViewLeadingConstraint.isActive = true @@ -557,15 +572,56 @@ public final class ProfilePictureView: UIView { imageViewWidthConstraint.constant = size.multiImageSize imageViewHeightConstraint.constant = size.multiImageSize - imageContainerView.layer.cornerRadius = (imageContainerView.clipsToBounds ? (size.multiImageSize / 2) : 0) + imageContainerView.layer.cornerRadius = (size.multiImageSize / 2) additionalImageViewWidthConstraint.constant = size.multiImageSize additionalImageViewHeightConstraint.constant = size.multiImageSize - additionalImageContainerView.layer.cornerRadius = (additionalImageContainerView.clipsToBounds ? - (size.multiImageSize / 2) : - 0 - ) + additionalImageContainerView.layer.cornerRadius = (size.multiImageSize / 2) additionalProfileIconBackgroundView.layer.cornerRadius = (size.iconSize / 2) } + + private func applyCropTransform( + to imageView: SessionImageView?, + source: ImageDataManager.DataSource?, + cropRect: CGRect? + ) { + guard + let imageView: UIImageView = imageView, + let cropRect: CGRect = cropRect, + cropRect != CGRect(x: 0, y: 0, width: 1, height: 1) + else { + imageView?.transform = .identity + return + } + + // Calculate scale to fill container with cropped portion + let scaleX = 1.0 / cropRect.width + let scaleY = 1.0 / cropRect.height + let scale = max(scaleX, scaleY) + + // Center of crop rect in normalized coordinates (0-1) + let cropCenterNormalizedX = cropRect.origin.x + cropRect.width / 2 + let cropCenterNormalizedY = cropRect.origin.y + cropRect.height / 2 + + // The imageView's frame determines how the image is initially displayed + // We need to work in the imageView's coordinate space + let imageViewSize = imageView.bounds.size + + // Calculate where the crop center is in the imageView's coordinate space + let cropCenterInViewX = cropCenterNormalizedX * imageViewSize.width + let cropCenterInViewY = cropCenterNormalizedY * imageViewSize.height + + // We want the crop center to be at the imageView's center after transform + let targetCenterX = imageViewSize.width / 2 + let targetCenterY = imageViewSize.height / 2 + + // Translation needed (in pre-scaled coordinate space) + let translateX = (targetCenterX - cropCenterInViewX) / scale + let translateY = (targetCenterY - cropCenterInViewY) / scale + + // Apply transform + imageView.transform = CGAffineTransform(scaleX: scale, y: scale) + .translatedBy(x: translateX, y: translateY) + } } import SwiftUI diff --git a/SessionUtilitiesKit/Media/MediaUtils.swift b/SessionUtilitiesKit/Media/MediaUtils.swift index dfc2a9ce9d..53525ef832 100644 --- a/SessionUtilitiesKit/Media/MediaUtils.swift +++ b/SessionUtilitiesKit/Media/MediaUtils.swift @@ -50,8 +50,11 @@ public enum MediaUtils { kCGImagePropertyHasAlpha, kCGImagePropertyColorModel, kCGImagePropertyOrientation, + kCGImagePropertyGIFLoopCount, + kCGImagePropertyGIFHasGlobalColorMap, kCGImagePropertyGIFDelayTime, - kCGImagePropertyGIFUnclampedDelayTime + kCGImagePropertyGIFUnclampedDelayTime, + kCGImageDestinationLossyCompressionQuality ] public struct MediaMetadata: Sendable, Equatable, Hashable { diff --git a/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift b/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift index 8f7a54b178..6124c55f59 100644 --- a/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift @@ -21,54 +21,21 @@ public extension UIImage { /// This function can be used to resize an image to a different size, it **should not** be used within the UI for rendering smaller /// images as it's fairly inefficient (instead the image should be contained within another view and sized explicitly that way) - func resized(toFillPixelSize dstSize: CGSize) -> UIImage { - let normalized: UIImage = self.normalizedImage() - - guard - let normalizedRef: CGImage = normalized.cgImage, - let imgRef: CGImage = self.cgImage - else { return self } - - // Get the size in pixels, not points - let srcSize: CGSize = CGSize(width: normalizedRef.width, height: normalizedRef.height) - let widthRatio: CGFloat = (srcSize.width / srcSize.height) - let heightRatio: CGFloat = (srcSize.height / srcSize.width) - let drawRect: CGRect = { - guard widthRatio <= heightRatio else { - let targetWidth: CGFloat = (dstSize.height * srcSize.width / srcSize.height) - - return CGRect( - x: (targetWidth - dstSize.width) * -0.5, - y: 0, - width: targetWidth, - height: dstSize.height - ) - } - - let targetHeight: CGFloat = (dstSize.width * srcSize.height / srcSize.width) - - return CGRect( - x: 0, - y: (targetHeight - dstSize.height) * -0.5, - width: dstSize.width, - height: targetHeight - ) - }() + func resized( + toFillPixelSize dstSize: CGSize, + opaque: Bool = false, + cropRect: CGRect? = nil + ) -> UIImage { + guard let imgRef: CGImage = self.cgImage else { return self } - let bounds: CGRect = CGRect(x: 0, y: 0, width: dstSize.width, height: dstSize.height) - let format = UIGraphicsImageRendererFormat() - format.scale = 1 // We are specifying a specific pixel size rather than a point size - format.opaque = false + let result: CGImage = imgRef.resized( + toFillPixelSize: dstSize, + opaque: opaque, + cropRect: cropRect, + orientation: self.imageOrientation + ) - let renderer: UIGraphicsImageRenderer = UIGraphicsImageRenderer(bounds: bounds, format: format) - - return renderer.image { rendererContext in - rendererContext.cgContext.interpolationQuality = .high - - // we use srcSize (and not dstSize) as the size to specify is in user space (and we use the CTM to apply a - // scaleRatio) - rendererContext.cgContext.draw(imgRef, in: drawRect, byTiling: false) - } + return UIImage(cgImage: result, scale: 1.0, orientation: .up) } /// This function can be used to resize an image to a different size, it **should not** be used within the UI for rendering smaller @@ -185,3 +152,173 @@ public extension UIImage { } } } + +public extension CGImage { + func resized( + toFillPixelSize dstSize: CGSize, + opaque: Bool = false, + cropRect: CGRect? = nil, + orientation: UIImage.Orientation = .up + ) -> CGImage { + // Determine actual dimensions accounting for orientation + let srcSize: CGSize + let needsRotation: Bool + + switch orientation { + case .left, .leftMirrored, .right, .rightMirrored: + // 90° or 270° rotation - swap width/height + srcSize = CGSize(width: self.height, height: self.width) + needsRotation = true + + default: + srcSize = CGSize(width: self.width, height: self.height) + needsRotation = (orientation != .up) + } + + // Calculate what portion we're rendering (in oriented coordinate space) + let sourceRect: CGRect + + if let crop: CGRect = cropRect, crop != CGRect(x: 0, y: 0, width: 1, height: 1) { + // User-specified crop in normalized coordinates + sourceRect = CGRect( + x: (crop.origin.x * srcSize.width), + y: (crop.origin.y * srcSize.height), + width: (crop.size.width * srcSize.width), + height: (crop.size.height * srcSize.height) + ) + } else { + // Default: aspect-fill crop (center) + let srcAspect: CGFloat = (srcSize.width / srcSize.height) + let dstAspect: CGFloat = (dstSize.width / dstSize.height) + + if srcAspect > dstAspect { + // Source is wider - crop sides + let targetWidth: CGFloat = (srcSize.height * dstAspect) + sourceRect = CGRect( + x: ((srcSize.width - targetWidth) / 2), + y: 0, + width: targetWidth, + height: srcSize.height + ) + } else { + // Source is taller - crop top/bottom + let targetHeight: CGFloat = (srcSize.width / dstAspect) + sourceRect = CGRect( + x: 0, + y: ((srcSize.height - targetHeight) / 2), + width: srcSize.width, + height: targetHeight + ) + } + } + + // Calculate final size - never scale up + let finalSize: CGSize + + if sourceRect.width <= dstSize.width && sourceRect.height <= dstSize.height { + finalSize = sourceRect.size + } else { + finalSize = dstSize + } + + // Check if any processing is needed + if !needsRotation && sourceRect == CGRect(origin: .zero, size: srcSize) && finalSize == srcSize { + // No processing needed - return original + return self + } + + // Render with orientation transform + let bounds: CGRect = CGRect(x: 0, y: 0, width: finalSize.width, height: finalSize.height) + let colorSpace = self.colorSpace ?? CGColorSpaceCreateDeviceRGB() + let bitmapInfo = (opaque ? + CGImageAlphaInfo.noneSkipFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue : + CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue + ) + + guard let ctx: CGContext = CGContext( + data: nil, + width: Int(finalSize.width), + height: Int(finalSize.height), + bitsPerComponent: 8, + bytesPerRow: Int(finalSize.width) * 4, + space: colorSpace, + bitmapInfo: bitmapInfo + ) else { return self } + + ctx.interpolationQuality = .high + + if needsRotation { + ctx.translateBy(x: finalSize.width / 2, y: finalSize.height / 2) + + switch orientation { + case .down, .downMirrored: ctx.rotate(by: .pi) + case .left, .leftMirrored: ctx.rotate(by: .pi / 2) + case .right, .rightMirrored: ctx.rotate(by: -.pi / 2) + default: break + } + + // Handle mirroring + let mirroredSet: Set = [.left, .leftMirrored, .right, .rightMirrored] + + if mirroredSet.contains(orientation) { + ctx.scaleBy(x: -1, y: 1) + } + + ctx.translateBy(x: -finalSize.width / 2, y: -finalSize.height / 2) + } + + // Determine if we actually need to crop + let imageToDraw: CGImage = { + guard sourceRect != CGRect(origin: .zero, size: srcSize) else { + return self + } + + // Convert crop rect to pixel coordinates and crop + let pixelCropRect: CGRect = convertToPixelCoordinates( + sourceRect: sourceRect, + imgSize: CGSize(width: self.width, height: self.height), + orientation: orientation + ) + + return (self.cropping(to: pixelCropRect) ?? self) + }() + + ctx.draw(imageToDraw, in: bounds, byTiling: false) + return (ctx.makeImage() ?? self) + } + + private func convertToPixelCoordinates( + sourceRect: CGRect, + imgSize: CGSize, + orientation: UIImage.Orientation + ) -> CGRect { + switch orientation { + case .up, .upMirrored: return sourceRect + case .down, .downMirrored: + return CGRect( + x: imgSize.width - sourceRect.maxX, + y: imgSize.height - sourceRect.maxY, + width: sourceRect.width, + height: sourceRect.height + ) + + case .left, .leftMirrored: + return CGRect( + x: sourceRect.minY, + y: imgSize.width - sourceRect.maxX, + width: sourceRect.height, + height: sourceRect.width + ) + + case .right, .rightMirrored: + return CGRect( + x: imgSize.height - sourceRect.maxY, + y: sourceRect.minX, + width: sourceRect.height, + height: sourceRect.width + ) + + @unknown default: return sourceRect + } + } +} diff --git a/_SharedTestUtilities/MockFileManager.swift b/_SharedTestUtilities/MockFileManager.swift index d4702736be..8b5606e2e0 100644 --- a/_SharedTestUtilities/MockFileManager.swift +++ b/_SharedTestUtilities/MockFileManager.swift @@ -23,11 +23,15 @@ class MockFileManager: Mock, FileManagerType { return mock(args: [path]) } + func isLocatedInTemporaryDirectory(_ path: String) -> Bool { + return mock(args: [path]) + } + func temporaryFilePath(fileExtension: String?) -> String { return mock(args: [fileExtension]) } - func write(data: Data, toTemporaryFileWithExtension fileExtension: String?) throws -> String? { + func write(data: Data, toTemporaryFileWithExtension fileExtension: String?) throws -> String { return try mockThrowing(args: [data, fileExtension]) } @@ -115,6 +119,7 @@ extension Mock where T == FileManagerType { self.when { try $0.protectFileOrFolder(at: .any, fileProtectionType: .any) }.thenReturn(()) self.when { $0.fileExists(atPath: .any) }.thenReturn(false) self.when { $0.fileExists(atPath: .any, isDirectory: .any) }.thenReturn(false) + self.when { $0.isLocatedInTemporaryDirectory(.any) }.thenReturn(false) self.when { $0.temporaryFilePath(fileExtension: .any) }.thenReturn("tmpFile") self.when { $0.createFile(atPath: .any, contents: .any, attributes: .any) }.thenReturn(true) self.when { try $0.setAttributes(.any, ofItemAtPath: .any) }.thenReturn(()) From a6de09d238f994ec0e1411e8829c2321bcd7c923 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 10 Oct 2025 15:08:50 +1100 Subject: [PATCH 076/162] Added a dev setting to trigger a refund for a transaction --- Session.xcodeproj/project.pbxproj | 16 ++--- .../DeveloperSettingsProViewModel.swift | 70 ++++++++++++++++--- 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index cd344a81ee..a0e78b72b9 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -8362,7 +8362,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 640; + CURRENT_PROJECT_VERSION = 644; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8402,7 +8402,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.4; + MARKETING_VERSION = 2.14.5; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "-Werror=protocol"; OTHER_SWIFT_FLAGS = "-D DEBUG -Xfrontend -warn-long-expression-type-checking=100"; @@ -8443,7 +8443,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 640; + CURRENT_PROJECT_VERSION = 644; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8478,7 +8478,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.4; + MARKETING_VERSION = 2.14.5; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", @@ -8929,7 +8929,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 640; + CURRENT_PROJECT_VERSION = 644; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8968,7 +8968,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.4; + MARKETING_VERSION = 2.14.5; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "-fobjc-arc-exceptions", @@ -9519,7 +9519,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 640; + CURRENT_PROJECT_VERSION = 644; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; @@ -9552,7 +9552,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.4; + MARKETING_VERSION = 2.14.5; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index 0eb37764ab..13b596b8ae 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -73,6 +73,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case purchaseProSubscription case manageProSubscriptions case restoreProSubscription + case requestRefund case proStatus case proIncomingMessages @@ -88,6 +89,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .purchaseProSubscription: return "purchaseProSubscription" case .manageProSubscriptions: return "manageProSubscriptions" case .restoreProSubscription: return "restoreProSubscription" + case .requestRefund: return "requestRefund" case .proStatus: return "proStatus" case .proIncomingMessages: return "proIncomingMessages" @@ -106,6 +108,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .purchaseProSubscription: result.append(.purchaseProSubscription); fallthrough case .manageProSubscriptions: result.append(.manageProSubscriptions); fallthrough case .restoreProSubscription: result.append(.restoreProSubscription); fallthrough + case .requestRefund: result.append(.requestRefund); fallthrough case .proStatus: result.append(.proStatus); fallthrough case .proIncomingMessages: result.append(.proIncomingMessages) @@ -116,7 +119,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold } public enum DeveloperSettingsProEvent: Hashable { - case purchasedProduct([Product], Product?, String?, String?, UInt64?) + case purchasedProduct([Product], Product?, String?, String?, Transaction?) + case refundTransaction(Transaction.RefundRequestStatus) } // MARK: - Content @@ -128,7 +132,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let purchasedProduct: Product? let purchaseError: String? let purchaseStatus: String? - let purchaseTransactionId: String? + let purchaseTransaction: Transaction? + let refundRequestStatus: Transaction.RefundRequestStatus? let mockCurrentUserSessionPro: Bool let treatAllIncomingMessagesAsProMessages: Bool @@ -156,7 +161,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold purchasedProduct: nil, purchaseError: nil, purchaseStatus: nil, - purchaseTransactionId: nil, + purchaseTransaction: nil, + refundRequestStatus: nil, mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionPro], treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages] @@ -176,18 +182,22 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold var purchasedProduct: Product? = previousState.purchasedProduct var purchaseError: String? = previousState.purchaseError var purchaseStatus: String? = previousState.purchaseStatus - var purchaseTransactionId: String? = previousState.purchaseTransactionId + var purchaseTransaction: Transaction? = previousState.purchaseTransaction + var refundRequestStatus: Transaction.RefundRequestStatus? = previousState.refundRequestStatus events.forEach { event in guard let eventValue: DeveloperSettingsProEvent = event.value as? DeveloperSettingsProEvent else { return } switch eventValue { - case .purchasedProduct(let receivedProducts, let purchased, let error, let status, let id): + case .purchasedProduct(let receivedProducts, let purchased, let error, let status, let transaction): products = receivedProducts purchasedProduct = purchased purchaseError = error purchaseStatus = status - purchaseTransactionId = id.map { "\($0)" } + purchaseTransaction = transaction + + case .refundTransaction(let status): + refundRequestStatus = status } } @@ -197,7 +207,8 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold purchasedProduct: purchasedProduct, purchaseError: purchaseError, purchaseStatus: purchaseStatus, - purchaseTransactionId: purchaseTransactionId, + purchaseTransaction: purchaseTransaction, + refundRequestStatus: refundRequestStatus, mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionPro], treatAllIncomingMessagesAsProMessages: dependencies[feature: .treatAllIncomingMessagesAsProMessages] ) @@ -243,9 +254,17 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold "N/A" ) let transactionId: String = ( - state.purchaseTransactionId.map { "\($0)" } ?? + state.purchaseTransaction.map { "\($0.id)" } ?? "N/A" ) + let refundStatus: String = { + switch state.refundRequestStatus { + case .success: return "Success (Does not mean approved)" + case .userCancelled: return "User Cancelled" + case .none: return "N/A" + @unknown default: return "N/A" + } + }() let subscriptions: SectionModel = SectionModel( model: .subscriptions, elements: [ @@ -287,6 +306,20 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold onTap: { [weak viewModel] in Task { await viewModel?.restoreSubscriptions() } } + ), + SessionCell.Info( + id: .requestRefund, + title: "Request Refund", + subtitle: """ + Request a refund for a Session Pro subscription via the App Store. + + Status:\(refundStatus) + """, + trailingAccessory: .highlightingBackgroundLabel(title: "Request"), + isEnabled: (state.purchaseTransaction != nil), + onTap: { [weak viewModel] in + Task { await viewModel?.requestRefund() } + } ) ] ) @@ -381,7 +414,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let transaction = try verificationResult.payloadValue dependencies.notifyAsync( key: .updateScreen(DeveloperSettingsProViewModel.self), - value: DeveloperSettingsProEvent.purchasedProduct(products, product, nil, "Successful", transaction.id) + value: DeveloperSettingsProEvent.purchasedProduct(products, product, nil, "Successful", transaction) ) await transaction.finish() @@ -421,7 +454,6 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold do { try await AppStore.showManageSubscriptions(in: scene) - print("AS") } catch { Log.error("[DevSettings] Unable to show manage subscriptions: \(error)") @@ -436,4 +468,22 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold Log.error("[DevSettings] Unable to show manage subscriptions: \(error)") } } + + private func requestRefund() async { + guard let transaction: Transaction = await internalState.purchaseTransaction else { return } + guard let scene: UIWindowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { + return Log.error("[DevSettings] Unable to show manage subscriptions: Unable to get UIWindowScene") + } + + do { + let result = try await transaction.beginRefundRequest(in: scene) + dependencies.notifyAsync( + key: .updateScreen(DeveloperSettingsProViewModel.self), + value: DeveloperSettingsProEvent.refundTransaction(result) + ) + } + catch { + Log.error("[DevSettings] Unable to request refund: \(error)") + } + } } From 94fddd3c255fff971aa62929a79ea656c5a19c54 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 10 Oct 2025 15:35:53 +1100 Subject: [PATCH 077/162] Alternate approach for `shouldShowCameraPermissionInstructions` --- Session/Calls/CallVC.swift | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index 4e2b5e8954..c10dc1bfc1 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -33,11 +33,6 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel var floatingViewVideoSource: FloatingViewVideoSource = .local - // Use a local bool flag (set to true) instead of passing it from `endCall`. - // This prevents a race condition between `handleEndCallMessage` and `endCall`, - // since `handleEndCallMessage`, triggered by `call.hasEndedDidChange`, may fire first. - private var shouldShowCameraPermissionInstructions = false - // MARK: - UI Components private lazy var floatingLocalVideoView: LocalVideoView = { @@ -670,9 +665,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel self.callInfoLabelStackView.alpha = 1 } - Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { [weak self] _ in - self?.shouldHandleCallDismiss() - } + self.shouldHandleCallDismiss(delay: 2) } @objc private func answerCall() { @@ -686,16 +679,14 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel } } - @objc private func endCall() { + @objc private func endCall(presentCameraRequestDialog: Bool = false) { dependencies[singleton: .callManager].endCall(call) { [weak self, dependencies] error in + self?.shouldHandleCallDismiss(delay: 1, presentCameraRequestDialog: presentCameraRequestDialog) + if let _ = error { self?.call.endSessionCall() dependencies[singleton: .callManager].reportCurrentCallEnded(reason: .declinedElsewhere) } - - Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { [weak self] _ in - self?.shouldHandleCallDismiss() - } } } @@ -771,9 +762,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel cancelTitle: "remindMeLater".localized(), cancelStyle: .alert_text, onConfirm: { _ in - self?.shouldShowCameraPermissionInstructions = true - - self?.endCall() + self?.endCall(presentCameraRequestDialog: true) }, onCancel: { modal in dependencies[defaults: .standard, key: .shouldRemindGrantingCameraPermissionForCalls] = true @@ -922,13 +911,15 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel } } - private func shouldHandleCallDismiss(_ presentCameraRequestDialog: Bool = false) { - DispatchQueue.main.async { [weak self, dependencies] in + private func shouldHandleCallDismiss(delay: TimeInterval, presentCameraRequestDialog: Bool = false) { + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(Int(delay))) { [weak self, dependencies] in + guard self?.presentingViewController != nil else { return } + self?.dismiss(animated: true, completion: { self?.conversationVC?.becomeFirstResponder() self?.conversationVC?.showInputAccessoryView() - if self?.shouldShowCameraPermissionInstructions == true { + if presentCameraRequestDialog { Permissions.showEnableCameraAccessInstructions(using: dependencies) } else { Permissions.remindCameraAccessRequirement(using: dependencies) From a5a643ade93fd89cb1fb92183dbfb2bf99a21847 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 10 Oct 2025 15:38:39 +1100 Subject: [PATCH 078/162] Added some logs around notification and UserMetadata loading --- .../Utilities/ExtensionHelper.swift | 48 +++++++++++-------- .../NotificationServiceExtension.swift | 2 +- .../Dependency Injection/Dependencies.swift | 6 +++ 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/SessionMessagingKit/Utilities/ExtensionHelper.swift b/SessionMessagingKit/Utilities/ExtensionHelper.swift index 0075b91d78..4693cffae8 100644 --- a/SessionMessagingKit/Utilities/ExtensionHelper.swift +++ b/SessionMessagingKit/Utilities/ExtensionHelper.swift @@ -105,26 +105,30 @@ public class ExtensionHelper: ExtensionHelperType { private func read(from path: String) throws -> Data { /// Load in the data and `encKey` and reset the `encKey` as soon as the function ends - guard - var encKey: [UInt8] = (try? dependencies[singleton: .keychain] - .getOrGenerateEncryptionKey( - forKey: .extensionEncryptionKey, - length: encryptionKeyLength, - cat: .cat - )).map({ Array($0) }) - else { throw ExtensionHelperError.noEncryptionKey } + guard var encKey: [UInt8] = (try? dependencies[singleton: .keychain].getOrGenerateEncryptionKey( + forKey: .extensionEncryptionKey, + length: encryptionKeyLength, + cat: .cat + )).map({ Array($0) }) else { + Log.error(.cat, "Failed to retrieve encryption key") + throw ExtensionHelperError.noEncryptionKey + } defer { encKey.resetBytes(in: 0.. UserMetadata? { guard let plaintext: Data = try? read(from: metadataPath) else { return nil } - return try? JSONDecoder(using: dependencies) - .decode(UserMetadata.self, from: plaintext) + do { + return try JSONDecoder(using: dependencies) + .decode(UserMetadata.self, from: plaintext) + } + catch { + Log.error(.cat, "Failed to parse UserMetadata") + return nil + } } // MARK: - Deduping diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 54fc5cd9a9..0e3e03e4b9 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -49,7 +49,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension Log.info(.cat, "didReceive called with requestId: \(request.identifier).") /// Create the context if we don't have it (needed before _any_ interaction with the database) - if !dependencies[singleton: .appContext].isValid { + if !dependencies.has(singleton: .appContext) || !dependencies[singleton: .appContext].isValid { dependencies.set(singleton: .appContext, to: NotificationServiceExtensionContext(using: dependencies)) Dependencies.setIsRTLRetriever(requiresMainThread: false) { NotificationServiceExtensionContext.determineDeviceRTL() diff --git a/SessionUtilitiesKit/Dependency Injection/Dependencies.swift b/SessionUtilitiesKit/Dependency Injection/Dependencies.swift index 4c6578106a..8308765f4e 100644 --- a/SessionUtilitiesKit/Dependency Injection/Dependencies.swift +++ b/SessionUtilitiesKit/Dependency Injection/Dependencies.swift @@ -117,6 +117,12 @@ public class Dependencies { // MARK: - Instance management + public func has(singleton: SingletonConfig) -> Bool { + let key: DependencyStorage.Key = DependencyStorage.Key.Variant.singleton.key(singleton.identifier) + + return (_storage.performMap({ $0.instances[key]?.value(as: S.self) }) != nil) + } + public func warmCache(cache: CacheConfig) { _ = getOrCreate(cache) } From c4e8fcd8dc5bb33da4f5d832c9fc958c2974b2a8 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 10 Oct 2025 16:33:39 +1100 Subject: [PATCH 079/162] Tweaked the modal buttons to have some padding and wrap --- .../Components/Modals & Toast/ConfirmationModal.swift | 7 +++++++ SessionUIKit/Components/Modals & Toast/Modal.swift | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift index b22ff1e77a..2bf257d7cb 100644 --- a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift +++ b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift @@ -247,6 +247,13 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { let result = UIStackView(arrangedSubviews: [ confirmButton, cancelButton ]) result.axis = .horizontal result.distribution = .fillEqually + result.isLayoutMarginsRelativeArrangement = true + result.layoutMargins = UIEdgeInsets( + top: Values.smallSpacing, + left: 0, + bottom: Values.smallSpacing, + right: 0 + ) return result }() diff --git a/SessionUIKit/Components/Modals & Toast/Modal.swift b/SessionUIKit/Components/Modals & Toast/Modal.swift index 9f8738dfbb..af3eeb0423 100644 --- a/SessionUIKit/Components/Modals & Toast/Modal.swift +++ b/SessionUIKit/Components/Modals & Toast/Modal.swift @@ -151,11 +151,19 @@ open class Modal: UIViewController, UIGestureRecognizerDelegate { public static func createButton(title: String, titleColor: ThemeValue) -> UIButton { let result: UIButton = UIButton() result.titleLabel?.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.titleLabel?.numberOfLines = 0 + result.titleLabel?.textAlignment = .center result.setTitle(title, for: .normal) result.setThemeTitleColor(titleColor, for: .normal) result.setThemeBackgroundColor(.alert_buttonBackground, for: .normal) result.setThemeBackgroundColor(.highlighted(.alert_buttonBackground), for: .highlighted) result.set(.height, to: Values.alertButtonHeight) + result.contentEdgeInsets = UIEdgeInsets( + top: 0, + left: Values.mediumSpacing, + bottom: 0, + right: Values.mediumSpacing + ) return result } From 69c1cda51924886c2e4129e24704708684663b69 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Fri, 10 Oct 2025 16:53:33 +1100 Subject: [PATCH 080/162] Working on fixing unit tests --- .../Jobs/DisplayPictureDownloadJobSpec.swift | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift index 581e8e2814..e5d64b2f49 100644 --- a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift @@ -10,7 +10,7 @@ import Nimble @testable import SessionMessagingKit @testable import SessionUtilitiesKit -class DisplayPictureDownloadJobSpec: QuickSpec { +class DisplayPictureDownloadJobSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -134,7 +134,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { using: dependencies ) - expect(error).to(matchError(JobRunnerError.missingRequiredDetails)) + await expect(error).toEventually(matchError(JobRunnerError.missingRequiredDetails)) expect(permanentFailure).to(beTrue()) } @@ -517,8 +517,8 @@ class DisplayPictureDownloadJobSpec: QuickSpec { using: dependencies ) - expect(mockNetwork) - .to(call(.exactly(times: 1), matchingParameters: .all) { network in + await expect(mockNetwork) + .toEventually(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( endpoint: Network.FileServer.Endpoint.directUrl( URL(string: "http://filev2.getsession.org/file/1234")! @@ -583,8 +583,8 @@ class DisplayPictureDownloadJobSpec: QuickSpec { using: dependencies ) - expect(mockNetwork) - .to(call(.exactly(times: 1), matchingParameters: .all) { network in + await expect(mockNetwork) + .toEventually(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( endpoint: Network.SOGS.Endpoint.roomFileIndividual("testRoom", "12"), destination: expectedRequest.destination, @@ -631,6 +631,8 @@ class DisplayPictureDownloadJobSpec: QuickSpec { deferred: { _ in jobResult = .deferred }, using: dependencies ) + + await expect(jobResult).toEventuallyNot(beNil()) } // MARK: ---- when it fails to decrypt the data @@ -645,7 +647,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }).to(equal(profile)) @@ -664,7 +666,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }).to(equal(profile)) @@ -681,7 +683,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: ------ does not save the picture it("does not save the picture") { - expect(mockImageDataManager).toNotEventually(call { + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }).to(equal(profile)) @@ -702,7 +704,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { // MARK: ---- adds the image data to the displayPicture cache it("adds the image data to the displayPicture cache") { - expect(mockImageDataManager) + await expect(mockImageDataManager) .toEventually(call(.exactly(times: 1), matchingParameters: .all) { await $0.load( .url(URL(fileURLWithPath: "/test/DisplayPictures/5465737448617368")) @@ -757,7 +759,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }).to(beNil()) @@ -785,7 +787,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }) @@ -822,7 +824,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try Profile.fetchOne(db) }) @@ -864,7 +866,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { ) }) - expect(mockImageDataManager) + await expect(mockImageDataManager) .toEventually(call(.exactly(times: 1), matchingParameters: .all) { await $0.load( .url(URL(fileURLWithPath: "/test/DisplayPictures/5465737448617368")) @@ -955,7 +957,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try ClosedGroup.fetchOne(db) }).to(beNil()) @@ -982,7 +984,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try ClosedGroup.fetchOne(db) }) @@ -1023,7 +1025,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try ClosedGroup.fetchOne(db) }) @@ -1129,7 +1131,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { await $0.load(.any) }) + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try OpenGroup.fetchOne(db) }).to(beNil()) } } @@ -1150,7 +1152,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) - expect(mockImageDataManager).toNotEventually(call { await $0.load(.any) }) + await expect(mockImageDataManager).toEventuallyNot(call { await $0.load(.any) }) expect(mockStorage.read { db in try OpenGroup.fetchOne(db) }) .toNot(equal( OpenGroup( @@ -1188,7 +1190,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { attributes: nil ) }) - expect(mockImageDataManager) + await expect(mockImageDataManager) .toEventually(call(.exactly(times: 1), matchingParameters: .all) { await $0.load( .url(URL(fileURLWithPath: "/test/DisplayPictures/5465737448617368")) From 8fb9050fbd9852f2ca870b99929b54a12a0e17a9 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 13 Oct 2025 08:43:39 +1100 Subject: [PATCH 081/162] Bug and unit test fixing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Tweaked the logic to skip re-processing of either WebP or GIF • Fixed the DisplayPictureDownload unit tests --- .../Jobs/DisplayPictureDownloadJob.swift | 9 +- .../Utilities/AttachmentManager.swift | 144 ++++++++++++------ .../Utilities/DisplayPictureManager.swift | 9 +- .../Jobs/DisplayPictureDownloadJobSpec.swift | 29 +++- 4 files changed, 134 insertions(+), 57 deletions(-) diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index f45b35553b..c373b22092 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -367,7 +367,14 @@ extension DisplayPictureDownloadJob { fileprivate func ensureValidUpdate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { switch self.target { case .profile(let id, let url, let encryptionKey): - guard let latestProfile: Profile = try? Profile.fetchOne(db, id: id) else { + /// We should consider `libSession` the source-of-truth for profile data for contacts so try to retrieve the profile data from + /// there before falling back to the one fetched from the database + let maybeLatestProfile: Profile? = try? ( + dependencies.mutate(cache: .libSession) { $0.profile(contactId: id) } ?? + Profile.fetchOne(db, id: id) + ) + + guard let latestProfile: Profile = maybeLatestProfile else { throw AttachmentError.downloadNoLongerValid } diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index b1970e35fa..ac8eae0e21 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -539,10 +539,22 @@ public extension PendingAttachment { default: return false } } + + func utType(metadata: MediaUtils.MediaMetadata) -> UTType { + switch self { + case .current: return (metadata.utType ?? .invalid) + case .mp4: return .mpeg4Movie + case .webPLossy, .webPLossless: return .webP + case .gif: return .gif + } + } } // MARK: - Encryption and Preparation + /// Checks whether the attachment would need preparation based on the provided `operations` + /// + /// **Note:** Any `convert` checks behave as an `OR` func needsPreparation(operations: Set) -> Bool { switch (source, metadata) { case (_, .media(let mediaMetadata)): @@ -585,54 +597,94 @@ public extension PendingAttachment { /// called which will throw due to the invalid size guard metadata.hasValidPixelSize else { return true } - for operation in operations { - switch operation { - case .encrypt: return true - case .convert(let format): - let maxImageDimension: CGFloat = max( - metadata.pixelSize.width, - metadata.pixelSize.height - ) - - switch format { - case .current: continue /// Keep in the current format - case .mp4: - guard metadata.utType != .mpeg4Movie else { continue } - - return true - - case .webPLossy(let maxDimension, let cropRect, _), - .webPLossless(let maxDimension, let cropRect, _): - if metadata.utType != .webP { return true } - if maxImageDimension > (maxDimension ?? CGFloat.greatestFiniteMagnitude) { - return true - } - if cropRect != nil && cropRect != CGRect(x: 0, y: 0, width: 1, height: 1) { - return true - } - - /// Already in the desired format - continue - - case .gif(let maxDimension, let cropRect, _): - if metadata.utType != .gif { return true } - if maxImageDimension > (maxDimension ?? CGFloat.greatestFiniteMagnitude) { - return true - } - if cropRect != nil && cropRect != CGRect(x: 0, y: 0, width: 1, height: 1) { - return true - } + let erasedOperations: Set = Set(operations.map { $0.erased }) + + /// Encryption always needs to happen + guard !erasedOperations.contains(.encrypt) else { return true } + + /// Check if we have unsafe metadata to strip (we don't currently strip metadata from animated images) + if + erasedOperations.contains(.stripImageMetadata) && + metadata.frameCount == 1 && + metadata.hasUnsafeMetadata + { + return true + } + + /// Otherwise we need to check the `convert` operations provided (these should behave as an `OR` to allow us to support + /// multiple possible "allowed" formats + typealias FormatRequirements = (formats: Set, maxDimension: CGFloat?, cropRect: CGRect?) + let fullRect: CGRect = CGRect(x: 0, y: 0, width: 1, height: 1) + let formatRequirements: FormatRequirements = operations + .filter { $0.erased == .convert } + .reduce(FormatRequirements([], nil, nil)) { result, next in + guard case .convert(let format) = next else { return result } + + switch format { + case .current, .mp4: + return ( + result.formats.inserting(format.utType(metadata: metadata)), + result.maxDimension, + result.cropRect + ) + + case .webPLossy(let maxDimension, let cropRect, _), + .webPLossless(let maxDimension, let cropRect, _), + .gif(let maxDimension, let cropRect, _): + let finalMax: CGFloat? + let finalCrop: CGRect? + let validCurrentCrop: CGRect? = (result.cropRect != nil && result.cropRect != fullRect ? + result.cropRect : + nil + ) + let validNextCrop: CGRect? = (cropRect != nil && cropRect != fullRect ? + cropRect : + nil + ) + + switch (result.maxDimension, maxDimension) { + case (.some(let current), .some(let nextMax)): finalMax = min(current, nextMax) + case (.some(let current), .none): finalMax = current + case (.none, .some(let nextMax)): finalMax = nextMax + case (.none, .none): finalMax = nil + } + + switch (validCurrentCrop, validNextCrop) { + case (.some(let current), .some(let nextCrop)): + /// Smallest area wins + let currentArea: CGFloat = (current.width * current.height) + let nextArea: CGFloat = (nextCrop.width * nextCrop.height) + finalCrop = (currentArea < nextArea ? current : nextCrop) - /// Already in the desired format - continue - } - - case .stripImageMetadata: - /// We don't currently strip metadata from animated images - guard metadata.frameCount == 1 else { continue } - - return metadata.hasUnsafeMetadata + case (.some(let current), .none): finalCrop = current + case (.none, .some(let nextCrop)): finalCrop = nextCrop + case (.none, .none): finalCrop = nil + } + + return ( + result.formats.inserting(format.utType(metadata: metadata)), + finalMax, + finalCrop + ) + } } + + /// If the format doesn't match one of the desired formats then convert + guard formatRequirements.formats.contains(metadata.utType ?? .invalid) else { return true } + + /// If the source is too large then we need to scale + let maxImageDimension: CGFloat = max( + metadata.pixelSize.width, + metadata.pixelSize.height + ) + + if let maxDimension: CGFloat = formatRequirements.maxDimension, maxImageDimension > maxDimension { + return true + } + + /// If we want to crop + if let cropRect: CGRect = formatRequirements.cropRect, cropRect != fullRect { + return true } /// None of the requested `operations` were needed so the file doesn't need preparation diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index e79be0740e..6cc65bf6fc 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -198,11 +198,14 @@ public class DisplayPictureManager { } public func reuploadNeedsPreparation(attachment: PendingAttachment) -> Bool { - /// When re-uploading we only want to check if the file needs to be resized or converted to `WebP` to avoid a situation where - /// different clients end up "ping-ponging" changes to the display picture + /// When re-uploading we only want to check if the file needs to be resized or converted to `WebP`/`GIF` to avoid a situation + /// where different clients end up "ping-ponging" changes to the display picture + /// + /// **Note:** The `UTType` check behaves as an `OR` return attachment.needsPreparation( operations: [ - .convert(to: .webPLossy(maxDimension: DisplayPictureManager.maxDimension)) + .convert(to: .webPLossy(maxDimension: DisplayPictureManager.maxDimension)), + .convert(to: .gif(maxDimension: DisplayPictureManager.maxDimension)) ] ) } diff --git a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift index e5d64b2f49..6969e09487 100644 --- a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift @@ -491,12 +491,20 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { // MARK: -- generates a FileServer download request correctly it("generates a FileServer download request correctly") { + profile = Profile( + id: "1234", + name: "test", + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: nil + ) + mockStorage.write { db in try profile.insert(db) } job = Job( variant: .displayPictureDownload, shouldBeUnique: true, details: DisplayPictureDownloadJob.Details( target: .profile( - id: "", + id: "1234", url: "http://filev2.getsession.org/file/1234", encryptionKey: encryptionKey ), @@ -508,15 +516,17 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { using: dependencies ) + var receivedResult: Bool = false DisplayPictureDownloadJob.run( job, scheduler: DispatchQueue.main, - success: { _, _ in }, - failure: { _, _, _ in }, - deferred: { _ in }, + success: { _, _ in receivedResult = true }, + failure: { _, _, _ in receivedResult = true }, + deferred: { _ in receivedResult = true }, using: dependencies ) + await expect(receivedResult).toEventually(beTrue()) await expect(mockNetwork) .toEventually(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( @@ -540,6 +550,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { publicKey: TestConstants.serverPublicKey, isActive: false, name: "test", + imageId: "12", userCount: 0, infoUpdates: 0 ).insert(db) @@ -574,15 +585,17 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { ) }! + var receivedResult: Bool = false DisplayPictureDownloadJob.run( job, scheduler: DispatchQueue.main, - success: { _, _ in }, - failure: { _, _, _ in }, - deferred: { _ in }, + success: { _, _ in receivedResult = true }, + failure: { _, _, _ in receivedResult = true }, + deferred: { _ in receivedResult = true }, using: dependencies ) + await expect(receivedResult).toEventually(beTrue()) await expect(mockNetwork) .toEventually(call(.exactly(times: 1), matchingParameters: .all) { network in network.send( @@ -753,6 +766,8 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { // MARK: -------- does not save the picture it("does not save the picture") { + /// Succeeds as the download has been superseded + await expect(jobResult).toEventually(equal(.succeeded)) expect(mockCrypto) .toNot(call { $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) From 6ca079b5a3720d387799479e14a7231dc1c7a838 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 13 Oct 2025 10:16:37 +1100 Subject: [PATCH 082/162] Fixed issues found during QA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Commented out the call to `user_profile_set_reupload_pic` as it won't currently work correctly • Fixed an issue where profile data changes from libSession wouldn't be applied correctly --- .../Config Handling/LibSession+Contacts.swift | 10 +++++----- .../Config Handling/LibSession+UserProfile.swift | 9 +++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index 63a69e9de1..462ce0a98c 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -61,17 +61,17 @@ internal extension LibSessionCacheType { // observation system can't differ between update calls which do and don't change anything) let contact: Contact = Contact.fetchOrCreate(db, id: sessionId, using: dependencies) let profile: Profile = Profile.fetchOrCreate(db, id: sessionId) - let profileUpdated: Bool = ((profile.profileLastUpdated ?? 0) < (data.profile.profileLastUpdated ?? 0)) + let profileUpdated: Bool = Profile.shouldUpdateProfile( + data.profile.profileLastUpdated, + profile: profile, + using: dependencies + ) if (profileUpdated || (profile.nickname != data.profile.nickname)) { let profileNameShouldBeUpdated: Bool = ( !data.profile.name.isEmpty && profile.name != data.profile.name ) - let profilePictureShouldBeUpdated: Bool = ( - profile.displayPictureUrl != data.profile.displayPictureUrl || - profile.displayPictureEncryptionKey != data.profile.displayPictureEncryptionKey - ) try profile.upsert(db) try Profile diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index 3dae469958..9d5c58efc2 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -237,11 +237,12 @@ public extension LibSession.Cache { var profilePic: user_profile_pic = user_profile_pic() profilePic.set(\.url, to: displayPictureUrl) profilePic.set(\.key, to: displayPictureEncryptionKey) - if isReuploadProfilePicture { - user_profile_set_reupload_pic(conf, profilePic) - } else { + // FIXME: Add this back once `profile_update` is getting set again +// if isReuploadProfilePicture { +// user_profile_set_reupload_pic(conf, profilePic) +// } else { user_profile_set_pic(conf, profilePic) - } +// } try LibSessionError.throwIfNeeded(conf) From b9d9e033bb5d6ef3455513df1ad5c34bcb8cf5d8 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 13 Oct 2025 10:18:10 +1100 Subject: [PATCH 083/162] Some minor tweaks to ensure consistent profile update behaviour --- Session/Conversations/ConversationVC+Interaction.swift | 2 +- SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift | 2 +- .../LibSession/Config Handling/LibSession+GroupMembers.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 8791637b17..28c053d6e1 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -840,7 +840,7 @@ extension ConversationVC: fallback: .none, using: dependencies ), - profileUpdateTimestamp: (currentUserProfile.profileLastUpdated ?? sentTimestamp), + profileUpdateTimestamp: currentUserProfile.profileLastUpdated, using: dependencies ) } diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index b687c07f66..8268b78dbd 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -304,7 +304,7 @@ extension DisplayPictureDownloadJob { let key: Data = profile.displayPictureEncryptionKey, let details: Details = Details( target: .profile(id: profile.id, url: url, encryptionKey: key), - timestamp: (profile.profileLastUpdated ?? 0) + timestamp: profile.profileLastUpdated ) else { return nil } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift index 779b4963c7..7c36349299 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift @@ -134,7 +134,7 @@ internal extension LibSessionCacheType { publicKey: profile.id, displayNameUpdate: .contactUpdate(profile.name), displayPictureUpdate: .from(profile, fallback: .none, using: dependencies), - profileUpdateTimestamp: (profile.profileLastUpdated ?? 0), + profileUpdateTimestamp: profile.profileLastUpdated, using: dependencies ) } From d17be79db0237004b4b767fd40f495af3fe6377d Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 13 Oct 2025 10:28:16 +1100 Subject: [PATCH 084/162] Bumped build number --- Session.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index a0e78b72b9..89e3940ed5 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -8362,7 +8362,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 644; + CURRENT_PROJECT_VERSION = 645; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8443,7 +8443,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 644; + CURRENT_PROJECT_VERSION = 645; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8929,7 +8929,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 644; + CURRENT_PROJECT_VERSION = 645; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -9519,7 +9519,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 644; + CURRENT_PROJECT_VERSION = 645; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; From 38d2ed20d5f9f01cbe3a366b74361de0d0a6b903 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 13 Oct 2025 11:22:37 +1100 Subject: [PATCH 085/162] Fixed a layout issue picked up by the regression tests --- Session/Settings/SettingsViewModel.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 03b7da79fd..d725ccf4c4 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -270,6 +270,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl styling: SessionCell.StyleInfo( alignment: .centerHugging, customPadding: SessionCell.Padding( + top: 0, leading: 0, bottom: Values.smallSpacing ), From 81e824ed7be7cd035a2655272bd266569709d579 Mon Sep 17 00:00:00 2001 From: mpretty-cyro <15862619+mpretty-cyro@users.noreply.github.com> Date: Mon, 13 Oct 2025 00:37:41 +0000 Subject: [PATCH 086/162] [Automated] Update translations from Crowdin --- Session/Meta/Translations/Localizable.xcstrings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Session/Meta/Translations/Localizable.xcstrings b/Session/Meta/Translations/Localizable.xcstrings index b9f96f2706..bf1d25c869 100644 --- a/Session/Meta/Translations/Localizable.xcstrings +++ b/Session/Meta/Translations/Localizable.xcstrings @@ -189804,7 +189804,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Enter the password you use to unlock Session \\non startup, not your Recovery Password" + "value" : "Enter the password you use to unlock {app_name} on startup, not your Recovery Password" } } } From 30600901965c221cefdd81a53cc2f35c049fc6e4 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Mon, 13 Oct 2025 08:37:51 +0800 Subject: [PATCH 087/162] Added NL recognizer and start end markers to check string directions --- SessionUIKit/Types/Localization.swift | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/SessionUIKit/Types/Localization.swift b/SessionUIKit/Types/Localization.swift index a45a924cf5..d34160e8d5 100644 --- a/SessionUIKit/Types/Localization.swift +++ b/SessionUIKit/Types/Localization.swift @@ -3,6 +3,7 @@ // stringlint:disable import UIKit +import NaturalLanguage // MARK: - LocalizationHelper @@ -83,6 +84,12 @@ final public class LocalizationHelper: CustomStringConvertible { // Replace html tag "
" with "\n" localizedString = localizedString.replacingOccurrences(of: "
", with: "\n") + // Add RTL mark for RTL-dominant strings to try to ensure proper rendering when starting/ending + // with English variables + if localizedString.isMostlyRTL { + localizedString = "\u{200F}" + localizedString + "\u{200F}" + } + return localizedString } @@ -145,3 +152,23 @@ public extension String { return LocalizationHelper(template: self).localizedDeformatted() } } + +private extension String { + /// Determines if the string's dominant language is Right-to-Left (RTL). + /// + /// This uses `NLLanguageRecognizer` to find the string's dominant language + /// and then checks that language's character direction using `Locale`. + /// + /// - Returns: `true` if the dominant language is RTL (e.g., Arabic, Hebrew); + /// otherwise, `false`. + var isMostlyRTL: Bool { + let recognizer: NLLanguageRecognizer = NLLanguageRecognizer() + recognizer.processString(self) + + guard let language: NLLanguage = recognizer.dominantLanguage else { + return false // If no dominant language is recognized, assume not RTL. + } + // Check the character direction for the determined dominant language. + return (Locale.characterDirection(forLanguage: language.rawValue) == .rightToLeft) + } +} From 46ab808e3cde8e5dde4f1c60d08ba9f9a98f5f3f Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 13 Oct 2025 14:12:44 +1100 Subject: [PATCH 088/162] Fixed a couple of bugs found during testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Fixed a bug where the user profile modal would incorrectly show the blinded id as part of the name • Fixed a bug where tapping on the display name of the sender wouldn't work if there were multiple messages in a row --- Session.xcodeproj/project.pbxproj | 8 ++++---- .../ConversationVC+Interaction.swift | 9 ++++++--- .../Message Cells/VisibleMessageCell.swift | 16 +++++++++------- .../Shared Models/MessageViewModel.swift | 17 +++++++++++++++++ 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 89e3940ed5..ef261630d7 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -8362,7 +8362,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 645; + CURRENT_PROJECT_VERSION = 646; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8443,7 +8443,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 645; + CURRENT_PROJECT_VERSION = 646; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8929,7 +8929,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 645; + CURRENT_PROJECT_VERSION = 646; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -9519,7 +9519,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 645; + CURRENT_PROJECT_VERSION = 646; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 28c053d6e1..58c317cf6f 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1631,13 +1631,16 @@ extension ConversationVC: let (displayName, contactDisplayName): (String?, String?) = { guard let sessionId: String = sessionId else { - return (cellViewModel.authorName, nil) + return (cellViewModel.authorNameSuppressedId, nil) } - let profile: Profile? = dependencies[singleton: .storage].read { db in try? Profile.fetchOne(db, id: sessionId)} + let profile: Profile? = ( + dependencies.mutate(cache: .libSession) { $0.profile(contactId: sessionId) } ?? + dependencies[singleton: .storage].read { db in try? Profile.fetchOne(db, id: sessionId) } + ) return ( - (profile?.displayName(for: .contact) ?? cellViewModel.authorName), + (profile?.displayName(for: .contact) ?? cellViewModel.authorNameSuppressedId), profile?.displayName(for: .contact, ignoringNickname: true) ) }() diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 977da2a226..f809ec9b80 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -1034,14 +1034,16 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { guard let cellViewModel: MessageViewModel = self.viewModel else { return } let location = gestureRecognizer.location(in: self) - - if - ( - profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)) || - authorLabel.bounds.contains(authorLabel.convert(location, from: self)) - ), + let tappedAuthorName: Bool = ( + authorLabel.bounds.contains(authorLabel.convert(location, from: self)) && + !(cellViewModel.senderName ?? "").isEmpty + ) + let tappedProfilePicture: Bool = ( + profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)) && cellViewModel.shouldShowProfile - { + ) + + if tappedAuthorName || tappedProfilePicture { delegate?.showUserProfileModal(for: cellViewModel) } else if replyButton.alpha > 0 && replyButton.bounds.contains(replyButton.convert(location, from: self)) { diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index b6d6acbb89..9c01a8c2d9 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -62,6 +62,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, case reactionInfo case cellType case authorName + case authorNameSuppressedId case senderName case canHaveProfile case shouldShowProfile @@ -153,6 +154,9 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, /// This value includes the author name information public let authorName: String + + /// This value includes the author name information with the `id` suppressed (if it was present) + public let authorNameSuppressedId: String /// This value will be used to populate the author label, if it's null then the label will be hidden /// @@ -250,6 +254,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, reactionInfo: (reactionInfo ?? self.reactionInfo), cellType: self.cellType, authorName: self.authorName, + authorNameSuppressedId: self.authorNameSuppressedId, senderName: self.senderName, canHaveProfile: self.canHaveProfile, shouldShowProfile: self.shouldShowProfile, @@ -311,6 +316,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, reactionInfo: self.reactionInfo, cellType: self.cellType, authorName: self.authorName, + authorNameSuppressedId: self.authorNameSuppressedId, senderName: self.senderName, canHaveProfile: self.canHaveProfile, shouldShowProfile: self.shouldShowProfile, @@ -371,6 +377,13 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, nickname: nil, // Folded into 'authorName' within the Query suppressId: false // Show the id next to the author name if desired ) + let authorDisplayNameSuppressedId: String = Profile.displayName( + for: self.threadVariant, + id: self.authorId, + name: self.authorNameInternal, + nickname: nil, // Folded into 'authorName' within the Query + suppressId: true // Exclude the id next to the author name + ) let shouldShowDateBeforeThisModel: Bool = { guard self.isTypingIndicator != true else { return false } guard self.variant != .infoCall else { return true } // Always show on calls @@ -495,6 +508,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, reactionInfo: self.reactionInfo, cellType: cellType, authorName: authorDisplayName, + authorNameSuppressedId: authorDisplayNameSuppressedId, senderName: { // Only show for group threads guard isGroupThread else { return nil } @@ -750,6 +764,7 @@ public extension MessageViewModel { self.cellType = cellType self.authorName = "" + self.authorNameSuppressedId = "" self.senderName = nil self.canHaveProfile = false self.shouldShowProfile = false @@ -834,6 +849,7 @@ public extension MessageViewModel { self.cellType = .textOnlyMessage self.authorName = "" + self.authorNameSuppressedId = "" self.senderName = nil self.canHaveProfile = false self.shouldShowProfile = false @@ -992,6 +1008,7 @@ public extension MessageViewModel { -- query from crashing when decoding we need to provide default values \(CellType.textOnlyMessage) AS \(ViewModel.Columns.cellType), '' AS \(ViewModel.Columns.authorName), + '' AS \(ViewModel.Columns.authorNameSuppressedId), false AS \(ViewModel.Columns.canHaveProfile), false AS \(ViewModel.Columns.shouldShowProfile), false AS \(ViewModel.Columns.shouldShowDateHeader), From baea669a8e1267122a6e62efd8ffd1ccd9fd93d1 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 14 Oct 2025 09:26:47 +1100 Subject: [PATCH 089/162] Tweaks due to changes from merge --- .../Conversations/Input View/InputView.swift | 2 +- .../Settings/ThreadSettingsViewModel.swift | 105 ++++++++++++------ .../MediaPageViewController.swift | 2 +- Session/Settings/SettingsViewModel.swift | 43 ++++--- .../Config Handling/LibSession+Shared.swift | 9 +- .../Modals & Toast/ConfirmationModal.swift | 8 +- SessionUIKit/Types/ImageDataManager.swift | 10 +- .../Types/UserDefaultsType.swift | 3 - 8 files changed, 117 insertions(+), 65 deletions(-) diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 54605dcb9b..54cabeae90 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -674,5 +674,5 @@ protocol InputViewDelegate: ExpandingAttachmentsButtonDelegate, VoiceMessageReco @MainActor func handleCharacterLimitLabelTapped() @MainActor func inputTextViewDidChangeContent(_ inputTextView: InputTextView) @MainActor func handleMentionSelected(_ mentionInfo: MentionInfo, from view: MentionSelectionView) - @MainActor func didPasteImageDataFromPasteboard(_ image: UIImage) + @MainActor func didPasteImageDataFromPasteboard(_ imageData: Data) } diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index f7e364422e..8cf659e375 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -23,11 +23,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob private let didTriggerSearch: () -> () private var updatedName: String? private var updatedDescription: String? - private var onDisplayPictureSelected: ((ConfirmationModal.ValueUpdate) -> Void)? + private var onDisplayPictureSelected: ((ImageDataManager.DataSource, CGRect?) -> Void)? private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler( onTransition: { [weak self] in self?.transitionToScreen($0, transitionType: $1) }, onImagePicked: { [weak self] source, cropRect in - self?.onDisplayPictureSelected?(.image(source: source, cropRect: cropRect)) + self?.onDisplayPictureSelected?(source, cropRect) }, using: dependencies ) @@ -1669,48 +1669,75 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob guard dependencies[feature: .updatedGroupsAllowDisplayPicture] else { return } let iconName: String = "profile_placeholder" // stringlint:ignore + var hasSetNewProfilePicture: Bool = false + let currentSource: ImageDataManager.DataSource? = { + let source: ImageDataManager.DataSource? = currentUrl + .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } + .map { ImageDataManager.DataSource.url(URL(fileURLWithPath: $0)) } + + return (source?.contentExists == true ? source : nil) + }() + let body: ConfirmationModal.Info.Body = .image( + source: nil, + placeholder: ( + currentSource ?? + Lucide.image(icon: .image, size: 40).map { image in + ImageDataManager.DataSource.image( + iconName, + image + .withTintColor(#colorLiteral(red: 0.631372549, green: 0.6352941176, blue: 0.631372549, alpha: 1), renderingMode: .alwaysTemplate) + .withCircularBackground(backgroundColor: #colorLiteral(red: 0.1764705882, green: 0.1764705882, blue: 0.1764705882, alpha: 1)) + ) + } + ), + icon: (currentUrl != nil ? .pencil : .rightPlus), + style: .circular, + description: nil, // FIXME: Need to add Group Pro display pic description + accessibility: Accessibility( + identifier: "Upload", + label: "Upload" + ), + dataManager: dependencies[singleton: .imageDataManager], + onProBageTapped: nil, // FIXME: Need to add Group Pro display pic CTA + onClick: { [weak self] onDisplayPictureSelected in + self?.onDisplayPictureSelected = { source, cropRect in + onDisplayPictureSelected(.image( + source: source, + cropRect: cropRect, + replacementIcon: .pencil, + replacementCancelTitle: "clear".localized() + )) + hasSetNewProfilePicture = true + } + self?.showPhotoLibraryForAvatar() + } + ) self.transitionToScreen( ConfirmationModal( info: ConfirmationModal.Info( title: "groupSetDisplayPicture".localized(), - body: .image( - source: currentUrl - .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } - .map { ImageDataManager.DataSource.url(URL(fileURLWithPath: $0)) }, - placeholder: UIImage(named: iconName).map { - ImageDataManager.DataSource.image(iconName, $0) - }, - icon: .rightPlus, - style: .circular, - description: nil, - accessibility: Accessibility( - identifier: "Image picker", - label: "Image picker" - ), - dataManager: dependencies[singleton: .imageDataManager], - onProBageTapped: nil, - onClick: { [weak self] onDisplayPictureSelected in - self?.onDisplayPictureSelected = onDisplayPictureSelected - self?.showPhotoLibraryForAvatar() - } - ), + body: body, confirmTitle: "save".localized(), confirmEnabled: .afterChange { info in switch info.body { - case .image(let source, _, _, _, _, _, _): - return (source?.contentExists == true) - + case .image(.some(let source), _, _, _, _, _, _, _, _): return source.contentExists default: return false } }, cancelTitle: "remove".localized(), - cancelEnabled: .bool(currentUrl != nil), + cancelEnabled: (currentUrl != nil ? .bool(true) : .afterChange { info in + switch info.body { + case .image(.some(let source), _, _, _, _, _, _, _, _): return source.contentExists + default: return false + } + }), hasCloseButton: true, dismissOnConfirm: false, onConfirm: { [weak self] modal in switch modal.info.body { - case .image(.some(let source), _, _, let style, _, _, _): + case .image(.some(let source), _, _, let style, _, _, _, _, _): + // FIXME: Need to add Group Pro display pic CTA self?.updateGroupDisplayPicture( displayPictureUpdate: .groupUploadImage( source: source, @@ -1725,12 +1752,22 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob } }, onCancel: { [weak self] modal in - self?.updateGroupDisplayPicture( - displayPictureUpdate: .groupRemove, - onUploadComplete: { [weak modal] in - Task { @MainActor in modal?.close() } - } - ) + if hasSetNewProfilePicture { + modal.updateContent( + with: modal.info.with( + body: body, + cancelTitle: "remove".localized() + ) + ) + hasSetNewProfilePicture = false + } else { + self?.updateGroupDisplayPicture( + displayPictureUpdate: .groupRemove, + onUploadComplete: { [weak modal] in + Task { @MainActor in modal?.close() } + } + ) + } } ) ), diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index b5ce408a15..980bd3e7a0 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -489,7 +489,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou shareVC.popoverPresentationController?.sourceRect = self.view.bounds } - shareVC.completionWithItemsHandler = { [dependencies = viewModel.dependencies] activityType, completed, returnedItems, activityError in + shareVC.completionWithItemsHandler = { [weak self, dependencies = viewModel.dependencies] activityType, completed, returnedItems, activityError in if let activityError = activityError { Log.error("[MediaPageViewController] Failed to share with activityError: \(activityError)") } diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index c5cf6b2812..c7c0911959 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -18,11 +18,11 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl public let observableState: ObservableTableSourceState = ObservableTableSourceState() private var updatedName: String? - private var onDisplayPictureSelected: ((ConfirmationModal.ValueUpdate) -> Void)? + private var onDisplayPictureSelected: ((ImageDataManager.DataSource, CGRect?) -> Void)? private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler( onTransition: { [weak self] in self?.transitionToScreen($0, transitionType: $1) }, onImagePicked: { [weak self] source, cropRect in - self?.onDisplayPictureSelected?(.image(source: source, cropRect: cropRect)) + self?.onDisplayPictureSelected?(source, cropRect) }, using: dependencies ) @@ -658,19 +658,26 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl private func updateProfilePicture(currentUrl: String?) { let iconName: String = "profile_placeholder" // stringlint:ignore var hasSetNewProfilePicture: Bool = false - let body: ConfirmationModal.Info.Body = .image( - source: nil, - placeholder: currentUrl + let currentSource: ImageDataManager.DataSource? = { + let source: ImageDataManager.DataSource? = currentUrl .map { try? dependencies[singleton: .displayPictureManager].path(for: $0) } .map { ImageDataManager.DataSource.url(URL(fileURLWithPath: $0)) } - .defaulting(to: Lucide.image(icon: .image, size: 40).map { image in + + return (source?.contentExists == true ? source : nil) + }() + let body: ConfirmationModal.Info.Body = .image( + source: nil, + placeholder: ( + currentSource ?? + Lucide.image(icon: .image, size: 40).map { image in ImageDataManager.DataSource.image( iconName, image .withTintColor(#colorLiteral(red: 0.631372549, green: 0.6352941176, blue: 0.631372549, alpha: 1), renderingMode: .alwaysTemplate) .withCircularBackground(backgroundColor: #colorLiteral(red: 0.1764705882, green: 0.1764705882, blue: 0.1764705882, alpha: 1)) ) - }), + } + ), icon: (currentUrl != nil ? .pencil : .rightPlus), style: .circular, description: { @@ -711,8 +718,13 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } }, onClick: { [weak self] onDisplayPictureSelected in - self?.onDisplayPictureSelected = { valueUpdate in - onDisplayPictureSelected(valueUpdate) + self?.onDisplayPictureSelected = { source, cropRect in + onDisplayPictureSelected(.image( + source: source, + cropRect: cropRect, + replacementIcon: .pencil, + replacementCancelTitle: "clear".localized() + )) hasSetNewProfilePicture = true } self?.showPhotoLibraryForAvatar() @@ -732,17 +744,17 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } }, cancelTitle: "remove".localized(), - cancelEnabled: (currentUrl != nil) ? .bool(true) : .afterChange { info in + cancelEnabled: (currentUrl != nil ? .bool(true) : .afterChange { info in switch info.body { case .image(.some(let source), _, _, _, _, _, _, _, _): return source.contentExists default: return false } - }, + }), hasCloseButton: true, dismissOnConfirm: false, onConfirm: { [weak self, dependencies] modal in switch modal.info.body { - case .image(.some(let source), _, _, let style, _, _, _): + case .image(.some(let source), _, _, let style, _, _, _, _, _): let isAnimatedImage: Bool = ImageDataManager.isAnimatedImage(source) guard ( @@ -831,7 +843,12 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl let result = try await dependencies[singleton: .displayPictureManager] .uploadDisplayPicture(preparedAttachment: preparedAttachment) - return .currentUserUpdateTo(url: result.downloadUrl, key: result.encryptionKey, isReupload: false) + return .currentUserUpdateTo( + url: result.downloadUrl, + key: result.encryptionKey, + sessionProProof: dependencies.mutate(cache: .libSession) { $0.getProProof() }, + isReupload: false + ) } @MainActor fileprivate func updateProfile( diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift index 2d69301415..0445e334ad 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift @@ -779,6 +779,7 @@ public extension LibSession.Cache { let displayPic: user_profile_pic = user_profile_get_pic(conf) let displayPictureUrl: String? = displayPic.get(\.url, nullIfEmpty: true) + let lastUpdated: TimeInterval = max((profileLastUpdatedInMessage ?? 0), TimeInterval(user_profile_get_profile_updated(conf))) return Profile( id: contactId, @@ -786,7 +787,7 @@ public extension LibSession.Cache { nickname: nil, displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : displayPic.get(\.key)), - profileLastUpdated: profileLastUpdatedInMessage + profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil) ) } @@ -807,6 +808,7 @@ public extension LibSession.Cache { } let displayPictureUrl: String? = member.get(\.profile_pic.url, nullIfEmpty: true) + let lastUpdated: TimeInterval = max((profileLastUpdatedInMessage ?? 0), TimeInterval(member.get( \.profile_updated))) /// The `displayNameInMessage` value is likely newer than the `name` value in the config so use that if available return Profile( @@ -815,7 +817,7 @@ public extension LibSession.Cache { nickname: nil, displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : member.get(\.profile_pic.key)), - profileLastUpdated: TimeInterval(member.get(\.profile_updated)) + profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil) ) } @@ -832,6 +834,7 @@ public extension LibSession.Cache { } let displayPictureUrl: String? = contact.get(\.profile_pic.url, nullIfEmpty: true) + let lastUpdated: TimeInterval = max((profileLastUpdatedInMessage ?? 0), TimeInterval(contact.get( \.profile_updated))) /// The `displayNameInMessage` value is likely newer than the `name` value in the config so use that if available return Profile( @@ -840,7 +843,7 @@ public extension LibSession.Cache { nickname: contact.get(\.nickname, nullIfEmpty: true), displayPictureUrl: displayPictureUrl, displayPictureEncryptionKey: (displayPictureUrl == nil ? nil : contact.get(\.profile_pic.key)), - profileLastUpdated: TimeInterval(contact.get( \.profile_updated)) + profileLastUpdated: (lastUpdated > 0 ? lastUpdated : nil) ) } diff --git a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift index fb5b52fcfc..083d0c7e6b 100644 --- a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift +++ b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift @@ -681,13 +681,13 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { @objc private func imageViewTapped() { internalOnBodyTap?({ [weak self, info = self.info] valueUpdate in switch (valueUpdate, info.body) { - case (.image(let source, let cropRect), .image(_, let placeholder, let icon, let style, let description, let accessibility, let dataManager, let onProBadgeTapped, let onClick)): + case (.image(let source, let cropRect, let replacementIcon, let replacementCancelTitle), .image(_, let placeholder, let icon, let style, let description, let accessibility, let dataManager, let onProBadgeTapped, let onClick)): self?.updateContent( with: info.with( body: .image( source: source, placeholder: placeholder, - icon: .pencil, + icon: (replacementIcon ?? icon), style: { switch style { case .inherit: return .inherit @@ -700,7 +700,7 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { onProBageTapped: onProBadgeTapped, onClick: onClick ), - cancelTitle: "clear".localized() + cancelTitle: replacementCancelTitle /// Will only replace if it has a value ) ) @@ -797,7 +797,7 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { public extension ConfirmationModal { enum ValueUpdate { case input(String) - case image(source: ImageDataManager.DataSource, cropRect: CGRect?) + case image(source: ImageDataManager.DataSource, cropRect: CGRect?, replacementIcon: ProfilePictureView.ProfileIcon?, replacementCancelTitle: String?) } struct Info: Equatable, Hashable { diff --git a/SessionUIKit/Types/ImageDataManager.swift b/SessionUIKit/Types/ImageDataManager.swift index aef3cff584..b614478e0f 100644 --- a/SessionUIKit/Types/ImageDataManager.swift +++ b/SessionUIKit/Types/ImageDataManager.swift @@ -734,12 +734,10 @@ public extension ImageDataManager { // MARK: - ImageDataManager.isAnimatedImage public extension ImageDataManager { - static func isAnimatedImage(_ imageData: Data?) -> Bool { - guard let data: Data = imageData, let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else { - return false - } - let frameCount = CGImageSourceGetCount(imageSource) - return frameCount > 1 + static func isAnimatedImage(_ source: ImageDataManager.DataSource) -> Bool { + guard let imageSource: CGImageSource = source.createImageSource() else { return false } + + return (CGImageSourceGetCount(imageSource) > 1) } } diff --git a/SessionUtilitiesKit/Types/UserDefaultsType.swift b/SessionUtilitiesKit/Types/UserDefaultsType.swift index 146fe8a849..528858ebaf 100644 --- a/SessionUtilitiesKit/Types/UserDefaultsType.swift +++ b/SessionUtilitiesKit/Types/UserDefaultsType.swift @@ -189,9 +189,6 @@ public extension UserDefaults.DateKey { /// The date/time when we re-uploaded or extended the TTL of the users display picture (used for rate-limiting) static let lastUserDisplayPictureRefresh: UserDefaults.DateKey = "lastProfilePictureUpload" - /// The date/time when the users profile picture expires on the server - static let profilePictureExpiresDate: UserDefaults.DateKey = "profilePictureExpiresDate" - /// The date/time when any open group last had a successful poll (used as a fallback date/time if the open group hasn't been polled /// this session) static let lastOpen: UserDefaults.DateKey = "lastOpen" From d63d04fb679cc608ab8018f05c7a5c284ba12abc Mon Sep 17 00:00:00 2001 From: mikoldin Date: Tue, 14 Oct 2025 10:39:46 +0800 Subject: [PATCH 090/162] Updated icons state for camera and mute buttons Updated other control icons to match changes --- Session/Calls/CallVC.swift | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index cde9fa28e6..617ecfc20e 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -3,6 +3,7 @@ import UIKit import MediaPlayer import AVKit +import Lucide import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit @@ -210,7 +211,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel let result = UIButton(type: .custom) result.isEnabled = call.isVideoEnabled result.setImage( - UIImage(named: "SwitchCamera")? + Lucide.image(icon: .switchCamera, size: IconSize.medium.size)? .withRenderingMode(.alwaysTemplate), for: .normal ) @@ -227,10 +228,15 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel private lazy var switchAudioButton: UIButton = { let result = UIButton(type: .custom) result.setImage( - UIImage(named: "AudioOff")? + Lucide.image(icon: .mic, size: IconSize.medium.size)? .withRenderingMode(.alwaysTemplate), for: .normal ) + result.setImage( + Lucide.image(icon: .micOff, size: IconSize.medium.size)? + .withRenderingMode(.alwaysTemplate), + for: .selected + ) result.themeTintColor = (call.isMuted ? .white : .textPrimary @@ -250,10 +256,15 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel private lazy var videoButton: UIButton = { let result = UIButton(type: .custom) result.setImage( - UIImage(named: "VideoCall")? + Lucide.image(icon: .videoOff, size: IconSize.medium.size)? .withRenderingMode(.alwaysTemplate), for: .normal ) + result.setImage( + Lucide.image(icon: .video, size: IconSize.medium.size)? + .withRenderingMode(.alwaysTemplate), + for: .selected + ) result.themeTintColor = .textPrimary result.themeBackgroundColor = .backgroundSecondary result.layer.cornerRadius = 30 @@ -278,7 +289,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel private lazy var routePickerButton: UIButton = { let result = UIButton(type: .custom) result.setImage( - UIImage(named: "Speaker")? + Lucide.image(icon: .volume2, size: IconSize.medium.size)? .withRenderingMode(.alwaysTemplate), for: .normal ) @@ -741,6 +752,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel videoButton.themeBackgroundColor = .backgroundSecondary switchCameraButton.isEnabled = false call.isVideoEnabled = false + videoButton.isSelected = false } else { guard Permissions.requestCameraPermissionIfNeeded(using: dependencies) else { @@ -775,6 +787,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel videoButton.themeBackgroundColor = .textPrimary switchCameraButton.isEnabled = true call.isVideoEnabled = true + videoButton.isSelected = true } @objc private func switchVideo() { @@ -820,6 +833,8 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel switchAudioButton.themeBackgroundColor = .danger call.isMuted = true } + + switchAudioButton.isSelected = call.isMuted } @objc private func switchRoute() { @@ -843,7 +858,9 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel switch currentOutput.portType { case .builtInSpeaker: - let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate) + let image = Lucide.image(icon: .volume2, size: IconSize.medium.size)? + .withRenderingMode(.alwaysTemplate) + routePickerButton.setImage(image, for: .normal) routePickerButton.themeTintColor = .backgroundSecondary routePickerButton.themeBackgroundColor = .textPrimary @@ -869,7 +886,9 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel case .builtInReceiver: fallthrough default: - let image = UIImage(named: "Speaker")?.withRenderingMode(.alwaysTemplate) + let image = Lucide.image(icon: .volume2, size: IconSize.medium.size)? + .withRenderingMode(.alwaysTemplate) + routePickerButton.setImage(image, for: .normal) routePickerButton.themeTintColor = .textPrimary routePickerButton.themeBackgroundColor = .backgroundSecondary From 7540a8508220c641a40e9ba074d83f3ce04e5379 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 14 Oct 2025 15:38:51 +1100 Subject: [PATCH 091/162] Cleaned up a bunch of issues found during testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Updated the AttachmentDownloadJob to rely solely on the `useDeterministicEncryption` flag to decide which encryption to use • Updated the logic to clean up temporary files a bit more readily • Updated to the latest libSession version • Fixed an issue where non-animated WebP conversion was failing • Fixed an orientation issue after converting photos to WebP --- Session.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- .../Jobs/AttachmentDownloadJob.swift | 8 ++++--- .../Utilities/AttachmentManager.swift | 10 ++++---- SessionUtilitiesKit/Media/MediaUtils.swift | 23 ++++++++----------- 5 files changed, 23 insertions(+), 24 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 06c5d3c227..ad7d0ddcd6 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -10462,7 +10462,7 @@ repositoryURL = "https://github.com/session-foundation/libsession-util-spm"; requirement = { kind = exactVersion; - version = 1.5.6; + version = 1.5.7; }; }; FD6A38E72C2A630E00762359 /* XCRemoteSwiftPackageReference "CocoaLumberjack" */ = { diff --git a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1777b508fc..9953553373 100644 --- a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/session-foundation/libsession-util-spm", "state" : { - "revision" : "a092eb8fa4bbc93756530e08b6c281d9eda06c61", - "version" : "1.5.6" + "revision" : "38baf3f75ba50e6ba3950caa5709a40971c13e89", + "version" : "1.5.7" } }, { diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift index fc21a83822..938d5badb7 100644 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift @@ -142,9 +142,11 @@ public enum AttachmentDownloadJob: JobExecutor { /// Decrypt the data if needed let plaintext: Data + let usesDeterministicEncryption: Bool = Network.FileServer + .usesDeterministicEncryption(attachment.downloadUrl) - switch (attachment.encryptionKey, attachment.digest) { - case (.some(let key), .some(let digest)) where !key.isEmpty && !digest.isEmpty: + switch (attachment.encryptionKey, attachment.digest, usesDeterministicEncryption) { + case (.some(let key), .some(let digest), false) where !key.isEmpty: plaintext = try dependencies[singleton: .crypto].tryGenerate( .legacyDecryptAttachment( ciphertext: response, @@ -154,7 +156,7 @@ public enum AttachmentDownloadJob: JobExecutor { ) ) - case (.some(let key), _) where !key.isEmpty: + case (.some(let key), _, true) where !key.isEmpty: plaintext = try dependencies[singleton: .crypto].tryGenerate( .decryptAttachment( ciphertext: response, diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index cac22f3c23..053e023211 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -1333,12 +1333,12 @@ public extension PendingAttachment { let estimatedFrameMemory: CGFloat = (targetSize.width * targetSize.height * 4) let batchSize: Int = max(2, min(8, Int(50_000_000 / estimatedFrameMemory))) var frames: [CGImage] = [] - frames.reserveCapacity(metadata.frameDurations.count) + frames.reserveCapacity(metadata.frameCount) - for batchStart in stride(from: 0, to: metadata.frameDurations.count, by: batchSize) { + for batchStart in stride(from: 0, to: metadata.frameCount, by: batchSize) { typealias FrameResult = (index: Int, frame: CGImage) - let batchEnd: Int = min(batchStart + batchSize, metadata.frameDurations.count) + let batchEnd: Int = min(batchStart + batchSize, metadata.frameCount) let batchFrames: [CGImage] = try await withThrowingTaskGroup(of: FrameResult.self) { group in for i in batchStart.. Data { + guard frames.count == metadata.frameDurations.count else { throw AttachmentError.invalidData } + /// Convert to an image (`SDImageWebPCoder` only supports encoding a `UIImage`) let sdFrames: [SDImageFrame] = frames.enumerated().map { index, frame in autoreleasepool { @@ -1457,7 +1459,7 @@ public extension PendingAttachment { image: UIImage( cgImage: frame, scale: 1, - orientation: (metadata.orientation ?? .up) + orientation: .up /// Since we loaded the frame as a CGImage the orientation will be stripped ), duration: metadata.frameDurations[index] ) diff --git a/SessionUtilitiesKit/Media/MediaUtils.swift b/SessionUtilitiesKit/Media/MediaUtils.swift index 53525ef832..1ab95b717e 100644 --- a/SessionUtilitiesKit/Media/MediaUtils.swift +++ b/SessionUtilitiesKit/Media/MediaUtils.swift @@ -67,10 +67,7 @@ public enum MediaUtils { /// file type when written to disk) public let fileSize: UInt64 - /// The number of frames this media has (`1` for a static image) - public let frameCount: Int - - /// The duration of each frame (this will be an empty array for anything other than animated images) + /// The duration of each frame (this will contain a single element of `0` for static images, and be empty for anything else) public let frameDurations: [TimeInterval] /// The duration of the content (will be `0` for static images) @@ -94,6 +91,9 @@ public enum MediaUtils { /// The type of the media content public let utType: UTType? + /// The number of frames this media has + public var frameCount: Int { frameDurations.count } + /// A flag indicating whether the media has valid dimensions (this is primarily here to avoid a "GIF bomb" situation) public var hasValidPixelSize: Bool { /// If the content isn't visual media then it should have a `zero` size @@ -114,7 +114,7 @@ public enum MediaUtils { return (duration > 0) } - if utType?.isAnimated == true && frameCount > 1 { + if utType?.isAnimated == true && frameDurations.count > 1 { return (duration > 0) } @@ -149,9 +149,8 @@ public enum MediaUtils { self.pixelSize = CGSize(width: width, height: height) self.fileSize = fileSize - self.frameCount = count self.frameDurations = { - guard count > 1 else { return [] } + guard count > 1 else { return [0] } return (0.. Date: Wed, 15 Oct 2025 09:09:41 +1100 Subject: [PATCH 092/162] Fixed remaining known issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added thumbnailing logic for animated images (shaving a decent amount of memory usage from the media grid) • Added a flag to use PNG instead of WebP for image fallbacks (much faster in debug mode) • Simplified the ImageDataManager to use a single `FrameBuffer` type instead of varying `ProcessedImageData` types • Fixed an issue where reuploading a display picture could result in it being deleted • Fixed an issue where old display pictures wouldn't be removed when replaced • Fixed a minor modal button layout issue • Fixed a missing string variable issue --- .../Content Views/MediaView.swift | 4 +- .../Content Views/QuoteView.swift | 4 +- .../ImagePickerController.swift | 8 +- .../PhotoCollectionPickerViewModel.swift | 8 +- .../PhotoGridViewCell.swift | 4 +- .../PhotoLibrary.swift | 74 +- .../SendMediaNavigationController.swift | 52 +- .../DeveloperSettingsViewModel.swift | 32 +- .../Settings/PrivacySettingsViewModel.swift | 4 +- .../Utilities/ImageLoading+Convenience.swift | 12 +- .../Database/Models/LinkPreview.swift | 5 +- .../Jobs/DisplayPictureDownloadJob.swift | 38 + .../Jobs/ReuploadUserDisplayPictureJob.swift | 4 +- .../Utilities/AttachmentManager.swift | 157 ++- .../Utilities/Profile+Updating.swift | 14 + .../ShareNavController.swift | 5 +- .../Modals & Toast/ConfirmationModal.swift | 2 +- .../Components/SessionImageView.swift | 203 ++-- .../SwiftUI/SessionAsyncImage.swift | 121 +-- SessionUIKit/Types/ImageDataManager.swift | 898 ++++++++++++------ SessionUtilitiesKit/General/Feature.swift | 4 + SessionUtilitiesKit/Media/MediaUtils.swift | 5 +- 22 files changed, 1084 insertions(+), 574 deletions(-) diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index af21c0b126..3f509abbda 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -192,8 +192,8 @@ public class MediaView: UIView { case (_, false, _), (_, _, false): return configure(forError: .invalid) case (_, true, true): - imageView.loadThumbnail(size: .medium, attachment: attachment, using: dependencies) { [weak self] processedData in - guard processedData == nil else { return } + imageView.loadThumbnail(size: .medium, attachment: attachment, using: dependencies) { [weak self] buffer in + guard buffer == nil else { return } Log.error("[MediaView] Could not load thumbnail") self?.configure(forError: .invalid) diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index ecc93aef65..be5ab7daa5 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -132,8 +132,8 @@ final class QuoteView: UIView { } // Generate the thumbnail if needed - imageView.loadThumbnail(size: .small, attachment: attachment, using: dependencies) { [weak imageView] processedData in - guard processedData != nil else { return } + imageView.loadThumbnail(size: .small, attachment: attachment, using: dependencies) { [weak imageView] buffer in + guard buffer != nil else { return } imageView?.contentMode = .scaleAspectFill } diff --git a/Session/Media Viewing & Editing/ImagePickerController.swift b/Session/Media Viewing & Editing/ImagePickerController.swift index 83af9d22a3..74f0df3599 100644 --- a/Session/Media Viewing & Editing/ImagePickerController.swift +++ b/Session/Media Viewing & Editing/ImagePickerController.swift @@ -40,7 +40,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat self.dependencies = dependencies collectionViewFlowLayout = type(of: self).buildLayout() photoCollection = library.defaultPhotoCollection() - photoCollectionContents = photoCollection.contents() + photoCollectionContents = photoCollection.contents(using: dependencies) super.init(collectionViewLayout: collectionViewFlowLayout) } @@ -401,7 +401,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat // MARK: - PhotoLibraryDelegate func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) { - photoCollectionContents = photoCollection.contents() + photoCollectionContents = photoCollection.contents(using: dependencies) collectionView?.reloadData() } @@ -410,7 +410,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat var isShowingCollectionPickerController: Bool = false lazy var collectionPickerController: SessionTableViewController = SessionTableViewController( - viewModel: PhotoCollectionPickerViewModel(library: library, using: dependencies) { [weak self] collection in + viewModel: PhotoCollectionPickerViewModel(library: library, using: dependencies) { [weak self, dependencies] collection in guard self?.photoCollection != collection else { self?.hideCollectionPicker() return @@ -420,7 +420,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat self?.clearCollectionViewSelection() self?.photoCollection = collection - self?.photoCollectionContents = collection.contents() + self?.photoCollectionContents = collection.contents(using: dependencies) self?.titleView.text = collection.localizedTitle() diff --git a/Session/Media Viewing & Editing/PhotoCollectionPickerViewModel.swift b/Session/Media Viewing & Editing/PhotoCollectionPickerViewModel.swift index 4b0ce936ac..2b61824528 100644 --- a/Session/Media Viewing & Editing/PhotoCollectionPickerViewModel.swift +++ b/Session/Media Viewing & Editing/PhotoCollectionPickerViewModel.swift @@ -31,7 +31,7 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel, ObservableTableSour self.library = library self.thumbnailPixelDimension = thumbnailSize.pixelDimension() self.onCollectionSelected = onCollectionSelected - self.photoCollections = CurrentValueSubject(library.allPhotoCollections()) + self.photoCollections = CurrentValueSubject(library.allPhotoCollections(using: dependencies)) } // MARK: - Config @@ -65,12 +65,12 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel, ObservableTableSour lazy var observation: TargetObservation = ObservationBuilderOld .subject(photoCollections) - .map { [thumbnailSize, thumbnailPixelDimension] collections -> [SectionModel] in + .map { [thumbnailSize, thumbnailPixelDimension, dependencies] collections -> [SectionModel] in [ SectionModel( model: .content, elements: collections.map { collection in - let contents: PhotoCollectionContents = collection.contents() + let contents: PhotoCollectionContents = collection.contents(using: dependencies) let lastAssetItem: PhotoPickerAssetItem? = contents.lastAssetItem(size: thumbnailSize, pixelDimension: thumbnailPixelDimension) return SessionCell.Info( @@ -94,6 +94,6 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel, ObservableTableSour // MARK: PhotoLibraryDelegate func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) { - self.photoCollections.send(library.allPhotoCollections()) + self.photoCollections.send(library.allPhotoCollections(using: dependencies)) } } diff --git a/Session/Media Viewing & Editing/PhotoGridViewCell.swift b/Session/Media Viewing & Editing/PhotoGridViewCell.swift index b687238049..cc003abddd 100644 --- a/Session/Media Viewing & Editing/PhotoGridViewCell.swift +++ b/Session/Media Viewing & Editing/PhotoGridViewCell.swift @@ -112,8 +112,8 @@ public class PhotoGridViewCell: UICollectionViewCell { self.item = item imageView.setDataManager(dependencies[singleton: .imageDataManager]) imageView.themeBackgroundColor = .textSecondary - imageView.loadImage(item.source) { [weak imageView] processedData in - imageView?.themeBackgroundColor = (processedData != nil ? .clear : .textSecondary) + imageView.loadImage(item.source) { [weak imageView] buffer in + imageView?.themeBackgroundColor = (buffer != nil ? .clear : .textSecondary) } contentTypeBadgeView.isHidden = !item.isVideo diff --git a/Session/Media Viewing & Editing/PhotoLibrary.swift b/Session/Media Viewing & Editing/PhotoLibrary.swift index 8769bce2e3..73be46da44 100644 --- a/Session/Media Viewing & Editing/PhotoLibrary.swift +++ b/Session/Media Viewing & Editing/PhotoLibrary.swift @@ -27,7 +27,7 @@ class PhotoMediaSize { } class PhotoPickerAssetItem: PhotoGridItem { - + let dependencies: Dependencies let asset: PHAsset let photoCollectionContents: PhotoCollectionContents let size: ImageDataManager.ThumbnailSize @@ -37,8 +37,10 @@ class PhotoPickerAssetItem: PhotoGridItem { asset: PHAsset, photoCollectionContents: PhotoCollectionContents, size: ImageDataManager.ThumbnailSize, - pixelDimension: CGFloat + pixelDimension: CGFloat, + using dependencies: Dependencies ) { + self.dependencies = dependencies self.asset = asset self.photoCollectionContents = photoCollectionContents self.size = size @@ -49,18 +51,19 @@ class PhotoPickerAssetItem: PhotoGridItem { var isVideo: Bool { asset.mediaType == .video } var source: ImageDataManager.DataSource { - return .asyncSource(self.asset.localIdentifier) { [photoCollectionContents, asset, size, pixelDimension] in + return .asyncSource(self.asset.localIdentifier) { [photoCollectionContents, asset, size, pixelDimension, dependencies] in await photoCollectionContents.requestThumbnail( for: asset, size: size, - thumbnailSize: CGSize(width: pixelDimension, height: pixelDimension) + pixelDimension: pixelDimension, + using: dependencies ) } } } class PhotoCollectionContents { - + private let dependencies: Dependencies let fetchResult: PHFetchResult let localizedTitle: String? @@ -69,7 +72,8 @@ class PhotoCollectionContents { case unsupportedMediaType } - init(fetchResult: PHFetchResult, localizedTitle: String?) { + init(fetchResult: PHFetchResult, localizedTitle: String?, using dependencies: Dependencies) { + self.dependencies = dependencies self.fetchResult = fetchResult self.localizedTitle = localizedTitle } @@ -111,7 +115,8 @@ class PhotoCollectionContents { asset: mediaAsset, photoCollectionContents: self, size: size, - pixelDimension: pixelDimension + pixelDimension: pixelDimension, + using: dependencies ) } @@ -122,7 +127,8 @@ class PhotoCollectionContents { asset: mediaAsset, photoCollectionContents: self, size: size, - pixelDimension: pixelDimension + pixelDimension: pixelDimension, + using: dependencies ) } @@ -133,20 +139,26 @@ class PhotoCollectionContents { asset: mediaAsset, photoCollectionContents: self, size: size, - pixelDimension: pixelDimension + pixelDimension: pixelDimension, + using: dependencies ) } // MARK: ImageManager - func requestThumbnail(for asset: PHAsset, size: ImageDataManager.ThumbnailSize, thumbnailSize: CGSize) async -> ImageDataManager.DataSource? { + func requestThumbnail( + for asset: PHAsset, + size: ImageDataManager.ThumbnailSize, + pixelDimension: CGFloat, + using dependencies: Dependencies + ) async -> ImageDataManager.DataSource? { var hasResumed: Bool = false /// The `requestImage` function will always return a static thumbnail so if it's an animated image then we need custom - /// handling (the default PhotoKit resizing can't resize animated images so we need to return the original file) + /// handling (the default PhotoKit resizing can't resize animated images so we need to do it ourselves) switch asset.utType?.isAnimated { case .some(true): - return await withCheckedContinuation { [imageManager] continuation in + let maybeData: Data? = await withCheckedContinuation { [imageManager] continuation in let options = PHImageRequestOptions() options.deliveryMode = .highQualityFormat options.isNetworkAccessAllowed = true @@ -160,12 +172,27 @@ class PhotoCollectionContents { return } - // Successfully fetched the data, resume with the animated result + // Successfully fetched the data hasResumed = true - continuation.resume(returning: .data(asset.localIdentifier, data)) + continuation.resume(returning: data) } } + guard + let data: Data = maybeData, + let path: String = try? dependencies[singleton: .attachmentManager] + .path(for: asset.localIdentifier) + else { return nil } + do { + let generatedFileName: String = URL(fileURLWithPath: path).lastPathComponent + let fileUrl: URL = URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) + .appendingPathComponent(generatedFileName) + try dependencies[singleton: .fileManager].write(data: data, toPath: fileUrl.path) + + return .urlThumbnail(fileUrl, size, dependencies[singleton: .attachmentManager]) + } + catch { return nil } + default: return await withCheckedContinuation { [imageManager] continuation in let options = PHImageRequestOptions() @@ -177,7 +204,7 @@ class PhotoCollectionContents { imageManager.requestImage( for: asset, - targetSize: thumbnailSize, + targetSize: CGSize(width: pixelDimension, height: pixelDimension), contentMode: .aspectFill, options: options ) { image, info in @@ -268,8 +295,11 @@ class PhotoCollectionContents { } } + let targetFormat: PendingAttachment.ConversionFormat = (dependencies[feature: .usePngInsteadOfWebPForFallbackImageType] ? + .png : .webPLossy + ) let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( - operations: [.convert(to: .webPLossy)], + operations: [.convert(to: targetFormat)], using: dependencies ) @@ -375,12 +405,16 @@ class PhotoCollection { } // stringlint:ignore_contents - func contents() -> PhotoCollectionContents { + func contents(using dependencies: Dependencies) -> PhotoCollectionContents { let options = PHFetchOptions() options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)] let fetchResult = PHAsset.fetchAssets(in: collection, options: options) - return PhotoCollectionContents(fetchResult: fetchResult, localizedTitle: localizedTitle()) + return PhotoCollectionContents( + fetchResult: fetchResult, + localizedTitle: localizedTitle(), + using: dependencies + ) } } @@ -444,7 +478,7 @@ class PhotoLibrary: NSObject, PHPhotoLibraryChangeObserver { return photoCollection } - func allPhotoCollections() -> [PhotoCollection] { + func allPhotoCollections(using dependencies: Dependencies) -> [PhotoCollection] { var collections = [PhotoCollection]() var collectionIds = Set() @@ -462,7 +496,7 @@ class PhotoLibrary: NSObject, PHPhotoLibraryChangeObserver { return } let photoCollection = PhotoCollection(id: collectionId, collection: assetCollection) - guard !hideIfEmpty || photoCollection.contents().assetCount > 0 else { + guard !hideIfEmpty || photoCollection.contents(using: dependencies).assetCount > 0 else { return } diff --git a/Session/Media Viewing & Editing/SendMediaNavigationController.swift b/Session/Media Viewing & Editing/SendMediaNavigationController.swift index 1fc48aeea3..644e37ef69 100644 --- a/Session/Media Viewing & Editing/SendMediaNavigationController.swift +++ b/Session/Media Viewing & Editing/SendMediaNavigationController.swift @@ -213,7 +213,7 @@ class SendMediaNavigationController: UINavigationController { return attachmentDraftCollection.attachmentDrafts.map { $0.attachment } } - private lazy var mediaLibrarySelections = OrderedDictionary() // Lazy to avoid https://bugs.swift.org/browse/SR-6657 + private lazy var mediaLibrarySelections = OrderedDictionary() // Lazy to avoid https://bugs.swift.org/browse/SR-6657 // MARK: Child VC's @@ -258,6 +258,36 @@ class SendMediaNavigationController: UINavigationController { } private func didRequestExit() { + /// Kick off a task to clean up any temporary files we had created + let mediaLibrarySelections: [MediaLibrarySelection] = self.mediaLibrarySelections.orderedValues + + if !mediaLibrarySelections.isEmpty { + Task.detached(priority: .utility) { [fileManager = dependencies[singleton: .fileManager]] in + let attachmentResults = await withTaskGroup { group in + mediaLibrarySelections.forEach { selection in + group.addTask { await selection.retrievalTask.result } + } + + return await group.reduce(into: []) { result, next in result.append(next) } + } + + for result in attachmentResults { + switch result { + case .failure: break + case .success(let info): + switch info.attachment.visualMediaSource { + case .url(let url): + if fileManager.isLocatedInTemporaryDirectory(url.path) { + try? fileManager.removeItem(atPath: url.path) + } + + default: break + } + } + } + } + } + self.sendMediaNavDelegate?.sendMediaNavDidCancel(self) } } @@ -335,13 +365,13 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate { didRequestExit() } - func showApprovalAfterProcessingAnyMediaLibrarySelections() { + @MainActor func showApprovalAfterProcessingAnyMediaLibrarySelections() { let mediaLibrarySelections: [MediaLibrarySelection] = self.mediaLibrarySelections.orderedValues let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController() self.present(indicator, animated: false) loadMediaTask?.cancel() - loadMediaTask = Task(priority: .userInitiated) { [weak self, indicator] in + loadMediaTask = Task.detached(priority: .userInitiated) { [weak self, indicator] in do { let attachments = try await withThrowingTaskGroup { group in mediaLibrarySelections.forEach { selection in @@ -353,7 +383,7 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate { guard !Task.isCancelled else { return } Log.debug("[SendMediaNavigationController] Built all attachments") - indicator.dismiss { + await indicator.dismiss { self?.attachmentDraftCollection.selectedFromPicker(attachments: attachments) guard self?.pushApprovalViewController() == true else { @@ -371,7 +401,7 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate { } catch { Log.error("[SendMediaNavigationController] Failed to prepare attachments. error: \(error)") - indicator.dismiss { [weak self] in + await indicator.dismiss { [weak self] in let modal: ConfirmationModal = ConfirmationModal( targetView: self?.view, info: ConfirmationModal.Info( @@ -387,21 +417,21 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate { } func imagePicker(_ imagePicker: ImagePickerGridController, isAssetSelected asset: PHAsset) -> Bool { - return mediaLibrarySelections.hasValue(forKey: asset) + return mediaLibrarySelections.hasValue(forKey: asset.localIdentifier) } func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, retrievalTask: Task) { - guard !mediaLibrarySelections.hasValue(forKey: asset) else { return } + guard !mediaLibrarySelections.hasValue(forKey: asset.localIdentifier) else { return } let libraryMedia = MediaLibrarySelection(asset: asset, retrievalTask: retrievalTask) - mediaLibrarySelections.append(key: asset, value: libraryMedia) + mediaLibrarySelections.append(key: asset.localIdentifier, value: libraryMedia) updateButtons(topViewController: imagePicker) } func imagePicker(_ imagePicker: ImagePickerGridController, didDeselectAsset asset: PHAsset) { - guard mediaLibrarySelections.hasValue(forKey: asset) else { return } + guard mediaLibrarySelections.hasValue(forKey: asset.localIdentifier) else { return } - mediaLibrarySelections.remove(key: asset) + mediaLibrarySelections.remove(key: asset.localIdentifier) updateButtons(topViewController: imagePicker) } @@ -436,7 +466,7 @@ extension SendMediaNavigationController: AttachmentApprovalViewControllerDelegat switch removedDraft.source { case .camera(attachment: _): break case .picker(attachment: let pickerAttachment): - mediaLibrarySelections.remove(key: pickerAttachment.asset) + mediaLibrarySelections.remove(key: pickerAttachment.asset.localIdentifier) } attachmentDraftCollection.remove(attachment: attachment) diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift index ad6068fcdf..8f4aef431a 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift @@ -82,6 +82,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case copyAppGroupPath case resetAppReviewPrompt case simulateAppReviewLimit + case usePngInsteadOfWebPForFallbackImageType case defaultLogLevel case advancedLogging @@ -123,6 +124,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .copyAppGroupPath: return "copyAppGroupPath" case .resetAppReviewPrompt: return "resetAppReviewPrompt" case .simulateAppReviewLimit: return "simulateAppReviewLimit" + case .usePngInsteadOfWebPForFallbackImageType: return "usePngInsteadOfWebPForFallbackImageType" case .defaultLogLevel: return "defaultLogLevel" case .advancedLogging: return "advancedLogging" @@ -167,6 +169,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .copyAppGroupPath: result.append(.copyAppGroupPath); fallthrough case .resetAppReviewPrompt: result.append(.resetAppReviewPrompt); fallthrough case .simulateAppReviewLimit: result.append(.simulateAppReviewLimit); fallthrough + case .usePngInsteadOfWebPForFallbackImageType: + result.append(usePngInsteadOfWebPForFallbackImageType); fallthrough case .defaultLogLevel: result.append(.defaultLogLevel); fallthrough case .advancedLogging: result.append(.advancedLogging); fallthrough @@ -220,6 +224,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, let forceSlowDatabaseQueries: Bool let updateSimulateAppReviewLimit: Bool + let usePngInsteadOfWebPForFallbackImageType: Bool } let title: String = "Developer Settings" @@ -262,7 +267,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, communityPollLimit: dependencies[feature: .communityPollLimit], forceSlowDatabaseQueries: dependencies[feature: .forceSlowDatabaseQueries], - updateSimulateAppReviewLimit: dependencies[feature: .simulateAppReviewLimit] + updateSimulateAppReviewLimit: dependencies[feature: .simulateAppReviewLimit], + usePngInsteadOfWebPForFallbackImageType: dependencies[feature: .usePngInsteadOfWebPForFallbackImageType] ) } .compactMapWithPrevious { [weak self] prev, current -> [SectionModel]? in self?.content(prev, current) } @@ -464,6 +470,25 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, ) } ), + SessionCell.Info( + id: .usePngInsteadOfWebPForFallbackImageType, + title: "Use PNG instead of WebP for fallback image type", + subtitle: """ + Controls whether we should encode to PNG and GIF when sending less common image types (eg. HEIC/HEIF). + + This is beneficial to enable when testing Debug builds as the WebP encoding is an order of magnitude slower than in Release builds. + """, + trailingAccessory: .toggle( + current.usePngInsteadOfWebPForFallbackImageType, + oldValue: previous?.usePngInsteadOfWebPForFallbackImageType + ), + onTap: { [weak self] in + self?.updateFlag( + for: .usePngInsteadOfWebPForFallbackImageType, + to: !current.usePngInsteadOfWebPForFallbackImageType + ) + } + ) ] ) let logging: SectionModel = SectionModel( @@ -815,6 +840,11 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, updateFlag(for: .simulateAppReviewLimit, to: nil) + case .usePngInsteadOfWebPForFallbackImageType: + guard dependencies.hasSet(feature: .usePngInsteadOfWebPForFallbackImageType) else { return } + + updateFlag(for: .usePngInsteadOfWebPForFallbackImageType, to: nil) + case .defaultLogLevel: updateDefaulLogLevel(to: nil) // Always reset case .loggingCategory: resetLoggingCategories() // Always reset diff --git a/Session/Settings/PrivacySettingsViewModel.swift b/Session/Settings/PrivacySettingsViewModel.swift index 0764aa3b77..66eaf0e948 100644 --- a/Session/Settings/PrivacySettingsViewModel.swift +++ b/Session/Settings/PrivacySettingsViewModel.swift @@ -232,7 +232,9 @@ class PrivacySettingsViewModel: SessionTableViewModel, NavigationItemSource, Nav ), confirmationInfo: ConfirmationModal.Info( title: "callsVoiceAndVideoBeta".localized(), - body: .text("callsVoiceAndVideoModalDescription".localized()), + body: .text("callsVoiceAndVideoModalDescription" + .put(key: "session_foundation", value: Constants.session_foundation) + .localized()), showCondition: .disabled, confirmTitle: "theContinue".localized(), confirmStyle: .danger, diff --git a/Session/Utilities/ImageLoading+Convenience.swift b/Session/Utilities/ImageLoading+Convenience.swift index 1b5598f047..2def00a7aa 100644 --- a/Session/Utilities/ImageLoading+Convenience.swift +++ b/Session/Utilities/ImageLoading+Convenience.swift @@ -74,7 +74,7 @@ public extension ImageDataManagerType { func loadImage( attachment: Attachment, using dependencies: Dependencies, - onComplete: @MainActor @escaping (ImageDataManager.ProcessedImageData?) -> Void = { _ in } + onComplete: @MainActor @escaping (ImageDataManager.FrameBuffer?) -> Void = { _ in } ) { guard let source: ImageDataManager.DataSource = ImageDataManager.DataSource.from( attachment: attachment, @@ -89,7 +89,7 @@ public extension ImageDataManagerType { size: ImageDataManager.ThumbnailSize, attachment: Attachment, using dependencies: Dependencies, - onComplete: @MainActor @escaping (ImageDataManager.ProcessedImageData?) -> Void = { _ in } + onComplete: @MainActor @escaping (ImageDataManager.FrameBuffer?) -> Void = { _ in } ) { guard let source: ImageDataManager.DataSource = ImageDataManager.DataSource.thumbnailFrom( attachment: attachment, @@ -105,7 +105,7 @@ public extension ImageDataManagerType { public extension SessionImageView { @MainActor - func loadImage(from path: String, onComplete: (@MainActor (ImageDataManager.ProcessedImageData?) -> Void)? = nil) { + func loadImage(from path: String, onComplete: (@MainActor (ImageDataManager.FrameBuffer?) -> Void)? = nil) { loadImage(.url(URL(fileURLWithPath: path)), onComplete: onComplete) } @@ -113,7 +113,7 @@ public extension SessionImageView { func loadImage( attachment: Attachment, using dependencies: Dependencies, - onComplete: (@MainActor (ImageDataManager.ProcessedImageData?) -> Void)? = nil + onComplete: (@MainActor (ImageDataManager.FrameBuffer?) -> Void)? = nil ) { guard let source: ImageDataManager.DataSource = ImageDataManager.DataSource.from( attachment: attachment, @@ -131,7 +131,7 @@ public extension SessionImageView { size: ImageDataManager.ThumbnailSize, attachment: Attachment, using dependencies: Dependencies, - onComplete: (@MainActor (ImageDataManager.ProcessedImageData?) -> Void)? = nil + onComplete: (@MainActor (ImageDataManager.FrameBuffer?) -> Void)? = nil ) { guard let source: ImageDataManager.DataSource = ImageDataManager.DataSource.thumbnailFrom( attachment: attachment, @@ -146,7 +146,7 @@ public extension SessionImageView { } @MainActor - func loadPlaceholder(seed: String, text: String, size: CGFloat, onComplete: (@MainActor (ImageDataManager.ProcessedImageData?) -> Void)? = nil) { + func loadPlaceholder(seed: String, text: String, size: CGFloat, onComplete: (@MainActor (ImageDataManager.FrameBuffer?) -> Void)? = nil) { loadImage(.placeholderIcon(seed: seed, text: text, size: size), onComplete: onComplete) } } diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 1a77b45a4e..dfeb94773e 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -140,9 +140,12 @@ public extension LinkPreview { utType: type, using: dependencies ) + let targetFormat: PendingAttachment.ConversionFormat = (dependencies[feature: .usePngInsteadOfWebPForFallbackImageType] ? + .png(maxDimension: LinkPreview.maxImageDimension) : .webPLossy(maxDimension: LinkPreview.maxImageDimension) + ) let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( operations: [ - .convert(to: .webPLossy(maxDimension: LinkPreview.maxImageDimension)), + .convert(to: targetFormat), .stripImageMetadata ], using: dependencies diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index c373b22092..36fdb72a48 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -124,6 +124,44 @@ public enum DisplayPictureDownloadJob: JobExecutor { ) } + /// Remove the old display picture (since we are replacing it) + let existingProfileUrl: String? = try? await dependencies[singleton: .storage].readAsync { db in + switch details.target { + case .profile(let id, _, _): + return try? Profile + .filter(id: id) + .select(.displayPictureUrl) + .asRequest(of: String.self) + .fetchOne(db) + + case .group(let id, _, _): + return try? ClosedGroup + .filter(id: id) + .select(.displayPictureUrl) + .asRequest(of: String.self) + .fetchOne(db) + + case .community(_, let roomToken, let server, _): + return try? OpenGroup + .filter(id: OpenGroup.idFor(roomToken: roomToken, server: server)) + .select(.displayPictureOriginalUrl) + .asRequest(of: String.self) + .fetchOne(db) + } + } + if + let existingProfileUrl: String = existingProfileUrl, + let existingFilePath: String = try? dependencies[singleton: .displayPictureManager] + .path(for: existingProfileUrl) + { + Task.detached(priority: .low) { + await dependencies[singleton: .imageDataManager].removeImage( + identifier: existingFilePath + ) + try? dependencies[singleton: .fileManager].removeItem(atPath: existingFilePath) + } + } + /// Store the updated information in the database (this will generally result in the UI refreshing as it'll observe /// the `downloadUrl` changing) try await dependencies[singleton: .storage].writeAsync { db in diff --git a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift index ca7bc3a449..7490fcab23 100644 --- a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift +++ b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift @@ -120,8 +120,10 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { /// Since we made it here it means that refreshing the TTL failed so we may need to reupload the display picture do { + let filePath: String = try dependencies[singleton: .displayPictureManager] + .path(for: displayPictureUrl.absoluteString) let pendingDisplayPicture: PendingAttachment = PendingAttachment( - source: .media(.url(displayPictureUrl)), + source: .media(.url(URL(fileURLWithPath: filePath))), using: dependencies ) diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index 053e023211..b0a0fef35b 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -83,8 +83,8 @@ public final class AttachmentManager: Sendable, ThumbnailManager { /// **Note:** Now that download urls could contain fragments (or query params I guess) that could result in inconsistent paths /// with old attachments so just to be safe we should strip them before generating the `urlHash` let urlNoQueryOrFragment: String = urlString - .components(separatedBy: "?")[0] - .components(separatedBy: "#")[0] + .components(separatedBy: "?")[0] // stringlint:disable + .components(separatedBy: "#")[0] // stringlint:disable let urlHash = try { guard let cachedHash: String = cache.object(forKey: urlNoQueryOrFragment) else { return try dependencies[singleton: .crypto] @@ -221,25 +221,77 @@ public final class AttachmentManager: Sendable, ThumbnailManager { // MARK: - ThumbnailManager - private func thumbnailUrl(for url: URL, size: ImageDataManager.ThumbnailSize) throws -> URL { - guard !url.lastPathComponent.isEmpty else { throw AttachmentError.invalidPath } + private func thumbnailPath(for name: String, size: ImageDataManager.ThumbnailSize) throws -> String { + guard !name.isEmpty else { throw AttachmentError.invalidPath } /// Thumbnails are written to the caches directory, so that iOS can remove them if necessary - return URL(fileURLWithPath: SessionFileManager.cachesDirectoryPath) - .appendingPathComponent(url.lastPathComponent) - .appendingPathComponent("thumbnail-\(size).jpg") // stringlint:ignore + let thumbnailsUrl: URL = URL(fileURLWithPath: SessionFileManager.cachesDirectoryPath) + .appendingPathComponent(name) + try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: thumbnailsUrl.path) + + return thumbnailsUrl + .appendingPathComponent("thumbnail-\(size)") // stringlint:ignore + .path } - public func existingThumbnailImage(url: URL, size: ImageDataManager.ThumbnailSize) -> UIImage? { - guard let thumbnailUrl: URL = try? thumbnailUrl(for: url, size: size) else { return nil } + public func existingThumbnail(name: String, size: ImageDataManager.ThumbnailSize) -> ImageDataManager.DataSource? { + guard + let thumbnailPath: String = try? thumbnailPath(for: name, size: size), + dependencies[singleton: .fileManager].fileExists(atPath: thumbnailPath) + else { return nil } - return UIImage(contentsOfFile: thumbnailUrl.path) + return .url(URL(fileURLWithPath: thumbnailPath)) } - public func saveThumbnail(data: Data, size: ImageDataManager.ThumbnailSize, url: URL) { - guard let thumbnailUrl: URL = try? thumbnailUrl(for: url, size: size) else { return } + public func saveThumbnail( + name: String, + frames: [UIImage], + durations: [TimeInterval], + hasAlpha: Bool?, + size: ImageDataManager.ThumbnailSize + ) { + guard + let thumbnailPath: String = try? thumbnailPath(for: name, size: size), ( + frames.count == durations.count || + frames.count == 1 + ) + else { return } - try? data.write(to: thumbnailUrl) + let finalFrames: [CGImage] = frames.compactMap { $0.cgImage } + + /// Writing a `WebP` is much slower than writing a `GIF` (up to 3-4 times slower) but in many cases the resulting `WebP` + /// file would end up smaller (about 3 times smaller) - since we are generating a thumbnail the output _generally_ shouldn't be + /// that large (and the OS can purge files these thumbnails when it wants) so we default to `GIF` thumbnails here due to encoding + /// speed unless the source has alpha (in which case we need to use `WebP` as `GIF` doesn't have proper alpha support). By + /// spending less time encoding `GIF` would result in less battery drain that encoding to `WebP` would + /// + /// **Note:** The `WebP` encoding runs much slower on debug builds compared to release builds (can be 10 times slower) + if hasAlpha == true { + try? PendingAttachment.writeFramesAsWebPToFile( + frames: finalFrames, + metadata: MediaUtils.MediaMetadata( + pixelSize: (frames.first?.size ?? .zero), + frameDurations: (frames.count == 1 ? [0] : durations), + hasUnsafeMetadata: false + ), + encodeWebPLossless: false, + encodeCompressionQuality: PendingAttachment.ConversionFormat.defaultWebPCompressionQuality, + filePath: thumbnailPath, + using: dependencies + ) + } + else { + try? PendingAttachment.writeFramesAsGifToFile( + frames: finalFrames, + metadata: MediaUtils.MediaMetadata( + pixelSize: (frames.first?.size ?? .zero), + frameDurations: (frames.count == 1 ? [0] : durations), + hasUnsafeMetadata: false + ), + compressionQuality: PendingAttachment.ConversionFormat.defaultGifCompressionQuality, + filePath: thumbnailPath + ) + } } // MARK: - Validity @@ -530,6 +582,7 @@ public extension PendingAttachment { enum ConversionFormat: Sendable, Equatable, Hashable { case current case mp4 + case png(maxDimension: CGFloat?, cropRect: CGRect?) /// A `compressionQuality` value of `0` gives the smallest size and `1` the largest case webPLossy(maxDimension: CGFloat?, cropRect: CGRect?, compressionQuality: CGFloat) @@ -539,8 +592,16 @@ public extension PendingAttachment { case gif(maxDimension: CGFloat?, cropRect: CGRect?, compressionQuality: CGFloat) - private static let defaultWebPCompressionQuality: CGFloat = 0.8 - private static let defaultWebPCompressionEffort: CGFloat = 0.25 + public static var png: ConversionFormat { .png(maxDimension: nil, cropRect: nil) } + public static func png(maxDimension: CGFloat?) -> ConversionFormat { + .png(maxDimension: maxDimension, cropRect: nil) + } + public static func png(cropRect: CGRect?) -> ConversionFormat { + .png(maxDimension: nil, cropRect: cropRect) + } + + fileprivate static let defaultWebPCompressionQuality: CGFloat = 0.8 + fileprivate static let defaultWebPCompressionEffort: CGFloat = 0.25 public static var webPLossy: ConversionFormat { .webPLossy(maxDimension: nil, cropRect: nil, compressionQuality: defaultWebPCompressionQuality) @@ -556,7 +617,7 @@ public extension PendingAttachment { .webPLossless(maxDimension: maxDimension, cropRect: cropRect, compressionEffort: defaultWebPCompressionEffort) } - private static let defaultGifCompressionQuality: CGFloat = 0.8 + fileprivate static let defaultGifCompressionQuality: CGFloat = 0.8 public static var gif: ConversionFormat { .gif(maxDimension: nil, cropRect: nil, compressionQuality: defaultGifCompressionQuality) } @@ -575,6 +636,7 @@ public extension PendingAttachment { switch self { case .current: return (metadata.utType ?? .invalid) case .mp4: return .mpeg4Movie + case .png: return .png case .webPLossy, .webPLossless: return .webP case .gif: return .gif } @@ -659,7 +721,8 @@ public extension PendingAttachment { result.cropRect ) - case .webPLossy(let maxDimension, let cropRect, _), + case .png(let maxDimension, let cropRect), + .webPLossy(let maxDimension, let cropRect, _), .webPLossless(let maxDimension, let cropRect, _), .gif(let maxDimension, let cropRect, _): let finalMax: CGFloat? @@ -1192,7 +1255,7 @@ public extension PendingAttachment { using: dependencies ) - case (.webPLossy, _), (.webPLossless, _), (.gif, _), (_, false): + case (.png, _), (.webPLossy, _), (.webPLossless, _), (.gif, _), (_, false): return try await createImage( source: source, metadata: metadata, @@ -1216,7 +1279,7 @@ public extension PendingAttachment { switch format { case .mp4: break case .current: throw AttachmentError.invalidFileFormat - case .webPLossy, .webPLossless, .gif: throw AttachmentError.couldNotConvert + case .png, .webPLossy, .webPLossless, .gif: throw AttachmentError.couldNotConvert } /// Ensure we _actually_ need to make changes first @@ -1244,7 +1307,7 @@ public extension PendingAttachment { let targetCropRect: CGRect? switch format { - case .gif(let maxDimension, let cropRect, _), .webPLossy(let maxDimension, let cropRect, _), + case .png(let maxDimension, let cropRect), .gif(let maxDimension, let cropRect, _), .webPLossy(let maxDimension, let cropRect, _), .webPLossless(let maxDimension, let cropRect, _): targetMaxDimension = maxDimension targetCropRect = cropRect @@ -1373,6 +1436,13 @@ public extension PendingAttachment { switch format { case .current: throw AttachmentError.invalidFileFormat case .mp4: throw AttachmentError.couldNotConvert + case .png: + try PendingAttachment.writeFramesAsPngToFile( + frames: frames, + metadata: metadata, + filePath: filePath + ) + case .gif(_, _, let quality): try PendingAttachment.writeFramesAsGifToFile( frames: frames, @@ -1382,15 +1452,14 @@ public extension PendingAttachment { ) case .webPLossy(_, _, let quality), .webPLossless(_, _, let quality): - let outputData: Data = try PendingAttachment.convertToWebP( + try PendingAttachment.writeFramesAsWebPToFile( frames: frames, metadata: metadata, encodeWebPLossless: format.webPIsLossless, - encodeCompressionQuality: quality + encodeCompressionQuality: quality, + filePath: filePath, + using: dependencies ) - - /// Write the converted data to a temporary file - try dependencies[singleton: .fileManager].write(data: outputData, toPath: filePath) } } } @@ -1398,14 +1467,35 @@ public extension PendingAttachment { try await task.value } - private static func writeFramesAsGifToFile( + fileprivate static func writeFramesAsPngToFile( + frames: [CGImage], + metadata: MediaUtils.MediaMetadata, + filePath: String + ) throws { + guard frames.count == 1 else { throw AttachmentError.invalidData } + guard + let destination: CGImageDestination = CGImageDestinationCreateWithURL( + URL(fileURLWithPath: filePath) as CFURL, + UTType.png.identifier as CFString, + 1, + nil + ) + else { throw AttachmentError.couldNotResizeImage } + + CGImageDestinationAddImage(destination, frames[0], nil) + + guard CGImageDestinationFinalize(destination) else { + throw AttachmentError.couldNotResizeImage + } + } + + fileprivate static func writeFramesAsGifToFile( frames: [CGImage], metadata: MediaUtils.MediaMetadata, compressionQuality: CGFloat, filePath: String ) throws { guard frames.count == metadata.frameDurations.count else { throw AttachmentError.invalidData } - guard let destination: CGImageDestination = CGImageDestinationCreateWithURL( URL(fileURLWithPath: filePath) as CFURL, @@ -1444,12 +1534,14 @@ public extension PendingAttachment { } } - private static func convertToWebP( + fileprivate static func writeFramesAsWebPToFile( frames: [CGImage], metadata: MediaUtils.MediaMetadata, encodeWebPLossless: Bool, - encodeCompressionQuality: CGFloat - ) throws -> Data { + encodeCompressionQuality: CGFloat, + filePath: String, + using dependencies: Dependencies + ) throws { guard frames.count == metadata.frameDurations.count else { throw AttachmentError.invalidData } /// Convert to an image (`SDImageWebPCoder` only supports encoding a `UIImage`) @@ -1471,7 +1563,7 @@ public extension PendingAttachment { } /// Peform the encoding - return try SDImageWebPCoder.shared.encodedData( + let outputData: Data = try SDImageWebPCoder.shared.encodedData( with: imageToProcess, format: .webP, options: [ @@ -1479,6 +1571,9 @@ public extension PendingAttachment { .encodeCompressionQuality: encodeCompressionQuality ] ) ?? { throw AttachmentError.couldNotConvertToWebP }() + + /// Write the converted data to a temporary file + try dependencies[singleton: .fileManager].write(data: outputData, toPath: filePath) } static func convertToMpeg4( diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index 6cee21f5b9..0ad221fe6d 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -197,6 +197,20 @@ public extension Profile { } else { if url != profile.displayPictureUrl { + /// Remove the old display picture (since we are replacing it) + if + let existingProfileUrl: String = updatedProfile.displayPictureUrl, + let existingFilePath: String = try? dependencies[singleton: .displayPictureManager] + .path(for: existingProfileUrl) + { + Task.detached(priority: .low) { + await dependencies[singleton: .imageDataManager].removeImage( + identifier: existingFilePath + ) + try? dependencies[singleton: .fileManager].removeItem(atPath: existingFilePath) + } + } + updatedProfile = updatedProfile.with(displayPictureUrl: .set(to: url)) profileChanges.append(Profile.Columns.displayPictureUrl.set(to: url)) db.addProfileEvent(id: publicKey, change: .displayPictureUrl(url)) diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index 670291fe2e..c3a809c184 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -558,8 +558,11 @@ final class ShareNavController: UINavigationController { } } + let targetFormat: PendingAttachment.ConversionFormat = (dependencies[feature: .usePngInsteadOfWebPForFallbackImageType] ? + .png : .webPLossy + ) let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( - operations: [.convert(to: .webPLossy)], + operations: [.convert(to: targetFormat)], using: dependencies ) diff --git a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift index 083d0c7e6b..66663822e7 100644 --- a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift +++ b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift @@ -251,7 +251,7 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { result.layoutMargins = UIEdgeInsets( top: Values.smallSpacing, left: 0, - bottom: Values.smallSpacing, + bottom: 0, right: 0 ) diff --git a/SessionUIKit/Components/SessionImageView.swift b/SessionUIKit/Components/SessionImageView.swift index a768725213..633ddb013d 100644 --- a/SessionUIKit/Components/SessionImageView.swift +++ b/SessionUIKit/Components/SessionImageView.swift @@ -11,8 +11,7 @@ public class SessionImageView: UIImageView { private var streamConsumptionTask: Task? private var displayLink: CADisplayLink? - private var animationFrames: [UIImage?]? - private var animationFrameDurations: [TimeInterval]? + private var frameBuffer: ImageDataManager.FrameBuffer? public private(set) var currentFrameIndex: Int = 0 public private(set) var accumulatedTime: TimeInterval = 0 @@ -25,8 +24,7 @@ public class SessionImageView: UIImageView { imageLoadTask?.cancel() stopAnimationLoop() currentLoadIdentifier = nil - animationFrames = nil - animationFrameDurations = nil + frameBuffer = nil currentFrameIndex = 0 accumulatedTime = 0 imageSizeMetadata = nil @@ -120,7 +118,7 @@ public class SessionImageView: UIImageView { case .none: pauseAnimationLoop() /// Pause when not visible case .some: /// Resume only if it has animation data and was meant to be animating - if let frames = animationFrames, frames.count > 1 { + if let frameBuffer: ImageDataManager.FrameBuffer = frameBuffer, frameBuffer.frameCount > 1 { resumeAnimationLoop() } } @@ -138,11 +136,11 @@ public class SessionImageView: UIImageView { } @MainActor - public func loadImage(_ source: ImageDataManager.DataSource, onComplete: (@MainActor (ImageDataManager.ProcessedImageData?) -> Void)? = nil) { + public func loadImage(_ source: ImageDataManager.DataSource, onComplete: (@MainActor (ImageDataManager.FrameBuffer?) -> Void)? = nil) { /// If we are trying to load the image that is already displayed then no need to do anything if currentLoadIdentifier == source.identifier && (self.image == nil || isAnimating()) { /// If it was an animation that got paused then resume it - if let frames: [UIImage?] = animationFrames, !frames.isEmpty, frames[0] != nil, !isAnimating() { + if let buffer: ImageDataManager.FrameBuffer = frameBuffer, !buffer.durations.isEmpty, !isAnimating() { startAnimationLoop() } return @@ -154,12 +152,9 @@ public class SessionImageView: UIImageView { /// No need to kick of an async task if we were given an image directly switch source { case .image(_, .some(let image)): - let processedData: ImageDataManager.ProcessedImageData = ImageDataManager.ProcessedImageData( - type: .staticImage(image) - ) - imageSizeMetadata = image.size - handleLoadedImageData(processedData) - onComplete?(processedData) + let buffer: ImageDataManager.FrameBuffer = ImageDataManager.FrameBuffer(image: image) + handleLoadedImageData(buffer) + onComplete?(buffer) return default: break @@ -178,13 +173,13 @@ public class SessionImageView: UIImageView { } imageLoadTask = Task.detached(priority: .userInitiated) { [weak self, dataManager] in - let processedData: ImageDataManager.ProcessedImageData? = await dataManager.load(source) + let buffer: ImageDataManager.FrameBuffer? = await dataManager.load(source) await MainActor.run { [weak self] in guard !Task.isCancelled && self?.currentLoadIdentifier == source.identifier else { return } - self?.handleLoadedImageData(processedData) - onComplete?(processedData) + self?.handleLoadedImageData(buffer) + onComplete?(buffer) } } } @@ -193,10 +188,8 @@ public class SessionImageView: UIImageView { public func startAnimationLoop() { guard shouldAnimateImage, - let frames: [UIImage?] = animationFrames, - let durations: [TimeInterval] = animationFrameDurations, - !frames.isEmpty, - !durations.isEmpty + let buffer: ImageDataManager.FrameBuffer = frameBuffer, + !buffer.durations.isEmpty else { return stopAnimationLoop() } /// If it's already running (or paused) then no need to start the animation loop @@ -206,8 +199,8 @@ public class SessionImageView: UIImageView { } /// Just to be safe set the initial frame - if self.image == nil, !frames.isEmpty, frames[0] != nil { - self.image = frames[0] + if self.image == nil { + self.image = buffer.firstFrame } stopAnimationLoop() /// Make sure we don't unintentionally create extra `CADisplayLink` instances @@ -220,29 +213,49 @@ public class SessionImageView: UIImageView { @MainActor public func setAnimationPoint(index: Int, time: TimeInterval) { - guard index >= 0, index < animationFrames?.count ?? 0 else { return } - currentFrameIndex = index - self.image = animationFrames?[index] - - /// Stop animating if we don't have a valid animation state - guard - let frames: [UIImage?] = animationFrames, - let durations = animationFrameDurations, - !frames.isEmpty, - frames.count == durations.count, - index >= 0, - index < durations.count, - time > 0, - time < durations.reduce(0, +) - else { return stopAnimationLoop() } + guard index >= 0, index < frameBuffer?.frameCount ?? 0 else { return } + // TODO: Won't this break the animation???? + Task { +// currentFrameIndex = index +// self.image = await frameBuffer?.getFrame(at: index) +// frameBuffer?. + /// Stop animating if we don't have a valid animation state + guard + let durations = frameBuffer?.durations, + index >= 0, + index < durations.count, + time > 0, + time < durations.reduce(0, +) + else { + image = frameBuffer?.getFrame(at: index) + currentFrameIndex = 0 + accumulatedTime = 0 + return stopAnimationLoop() + } + + /// Update the values + accumulatedTime = time + currentFrameIndex = index + + /// Set the image using `super.image` as `self.image` is overwritten to stop the animation (in case it gets called + /// to replace the current image with something else) + super.image = frameBuffer?.getFrame(at: index) + } + } + + @MainActor + public func copyAnimationPoint(from other: SessionImageView) { + self.handleLoadedImageData(other.frameBuffer) + self.image = other.image + self.currentFrameIndex = other.currentFrameIndex + self.accumulatedTime = other.accumulatedTime + self.imageSizeMetadata = other.imageSizeMetadata + self.shouldAnimateImage = other.shouldAnimateImage - /// Update the values - accumulatedTime = time - currentFrameIndex = index + if other.isAnimating { + self.startAnimationLoop() + } - /// Set the image using `super.image` as `self.image` is overwritten to stop the animation (in case it gets called - /// to replace the current image with something else) - super.image = frames[currentFrameIndex] } @MainActor @@ -275,105 +288,89 @@ public class SessionImageView: UIImageView { self.image = nil currentLoadIdentifier = identifier - animationFrames = nil - animationFrameDurations = nil + frameBuffer = nil currentFrameIndex = 0 accumulatedTime = 0 imageSizeMetadata = nil } @MainActor - private func handleLoadedImageData(_ data: ImageDataManager.ProcessedImageData?) { - guard let data: ImageDataManager.ProcessedImageData = data else { + private func handleLoadedImageData(_ buffer: ImageDataManager.FrameBuffer?) { + guard let buffer: ImageDataManager.FrameBuffer = buffer else { self.image = nil stopAnimationLoop() return } - - switch data.type { - case .staticImage(let staticImg): - stopAnimationLoop() - self.image = staticImg - self.animationFrames = nil - self.animationFrameDurations = nil - - case .animatedImage(let frames, let durations): - self.image = frames.first - self.animationFrames = frames - self.animationFrameDurations = durations - self.currentFrameIndex = 0 - self.accumulatedTime = 0 - - guard self.shouldAnimateImage else { return } - - switch frames.count { - case 1...: startAnimationLoop() - default: stopAnimationLoop() /// Treat as a static image - } - - case .bufferedAnimatedImage(let firstFrame, let durations, let bufferedFrameStream): - self.image = firstFrame - self.animationFrameDurations = durations - self.animationFrames = Array(repeating: nil, count: durations.count) - self.animationFrames?[0] = firstFrame - - guard durations.count > 1 else { - stopAnimationLoop() - return + + /// **Note:** Setting `self.image` will reset the current state and clear any existing animation data so we need to call + /// it first and then store data afterwards (otherwise it'd just be cleared) + self.image = buffer.firstFrame + self.frameBuffer = buffer + self.imageSizeMetadata = buffer.firstFrame.size + + guard buffer.durations.count > 1 && self.shouldAnimateImage else { return } + + Task { + if buffer.isComplete { + return await MainActor.run { + if self.shouldAnimateImage { + self.startAnimationLoop() + } } - - streamConsumptionTask = Task { @MainActor in - for await event in bufferedFrameStream { - guard !Task.isCancelled else { break } - - switch event { - case .frame(let index, let frame): self.animationFrames?[index] = frame - case .readyToPlay: - guard self.shouldAnimateImage else { continue } - - startAnimationLoop() - } + } + + streamConsumptionTask = Task { @MainActor in + for await event in buffer.stream { + guard !Task.isCancelled else { break } + + switch event { + case .frameLoaded, .completed: break + case .readyToAnimate: + guard self.shouldAnimateImage else { continue } + + startAnimationLoop() } } + } } } @objc private func updateFrame(displayLink: CADisplayLink) { /// Stop animating if we don't have a valid animation state guard - let frames: [UIImage?] = animationFrames, - let durations = animationFrameDurations, - !frames.isEmpty, - !durations.isEmpty, - currentFrameIndex < durations.count + let buffer: ImageDataManager.FrameBuffer = frameBuffer, + !buffer.durations.isEmpty, + currentFrameIndex < buffer.durations.count else { return stopAnimationLoop() } accumulatedTime += displayLink.duration - var currentFrameDuration: TimeInterval = durations[currentFrameIndex] + var currentFrameDuration: TimeInterval = buffer.durations[currentFrameIndex] /// It's possible for a long `CADisplayLink` tick to take longeer than a single frame so try to handle those cases while accumulatedTime >= currentFrameDuration { accumulatedTime -= currentFrameDuration - - let nextFrameIndex: Int = ((currentFrameIndex + 1) % durations.count) + let nextFrameIndex: Int = ((currentFrameIndex + 1) % buffer.durations.count) /// If the next frame hasn't been decoded yet, pause on the current frame, we'll re-evaluate on the next display tick. - guard nextFrameIndex < frames.count, frames[nextFrameIndex] != nil else { break } + guard + nextFrameIndex < buffer.frameCount, + buffer.getFrame(at: nextFrameIndex) != nil + else { break } /// Prevent an infinite loop for all zero durations - guard durations[nextFrameIndex] > 0.001 else { break } + guard buffer.durations[nextFrameIndex] > 0.001 else { break } currentFrameIndex = nextFrameIndex - currentFrameDuration = durations[currentFrameIndex] + currentFrameDuration = buffer.durations[currentFrameIndex] } /// Make sure we don't cause an index-out-of-bounds somehow - guard currentFrameIndex < frames.count else { return stopAnimationLoop() } + guard currentFrameIndex < buffer.frameCount else { return stopAnimationLoop() } /// Set the image using `super.image` as `self.image` is overwritten to stop the animation (in case it gets called /// to replace the current image with something else) - super.image = frames[currentFrameIndex] + super.image = buffer.getFrame(at: currentFrameIndex) } } diff --git a/SessionUIKit/Components/SwiftUI/SessionAsyncImage.swift b/SessionUIKit/Components/SwiftUI/SessionAsyncImage.swift index 59ddb996e9..41121946e8 100644 --- a/SessionUIKit/Components/SwiftUI/SessionAsyncImage.swift +++ b/SessionUIKit/Components/SwiftUI/SessionAsyncImage.swift @@ -6,8 +6,7 @@ import NaturalLanguage public struct SessionAsyncImage: View { @State private var loadedImage: UIImage? = nil - @State private var animationFrames: [UIImage?]? - @State private var animationFrameDurations: [TimeInterval]? + @State private var frameBuffer: ImageDataManager.FrameBuffer? @State private var isAnimating: Bool = false @State private var currentFrameIndex: Int = 0 @@ -59,7 +58,7 @@ public struct SessionAsyncImage: View { await loadAndProcessData() } .onChange(of: shouldAnimateImage) { newValue in - if let frames = animationFrames, !frames.isEmpty { + if let buffer = frameBuffer, !buffer.durations.isEmpty { isAnimating = newValue } } @@ -71,71 +70,47 @@ public struct SessionAsyncImage: View { /// Reset the state before loading new data await MainActor.run { resetAnimationState() } - let processedData = await dataManager.load(source) + guard let buffer: ImageDataManager.FrameBuffer = await dataManager.load(source) else { + return await MainActor.run { + self.loadedImage = nil + self.frameBuffer = nil + } + } guard !Task.isCancelled else { return } - switch processedData?.type { - case .staticImage(let image): - await MainActor.run { - self.loadedImage = image - } + /// Set the first frame + await MainActor.run { + self.loadedImage = buffer.firstFrame + self.frameBuffer = buffer + self.currentFrameIndex = 0 + self.accumulatedTime = 0.0 + } + + guard buffer.durations.count > 1 && self.shouldAnimateImage else { + self.isAnimating = false /// Treat as a static image + return + } + + for await event in buffer.stream { + guard !Task.isCancelled else { break } - case .animatedImage(let frames, let durations) where frames.count > 1: - await MainActor.run { - self.animationFrames = frames - self.animationFrameDurations = durations - self.loadedImage = frames.first - - if self.shouldAnimateImage { - self.isAnimating = true /// Activate the `TimelineView` - } - } - - case .bufferedAnimatedImage(let firstFrame, let durations, let bufferedFrameStream) where durations.count > 1: - await MainActor.run { - self.loadedImage = firstFrame - self.animationFrameDurations = durations - self.animationFrames = Array(repeating: nil, count: durations.count) - self.animationFrames?[0] = firstFrame - } - - for await event in bufferedFrameStream { - guard !Task.isCancelled else { break } - - await MainActor.run { - switch event { - case .frame(let index, let frame): self.animationFrames?[index] = frame - case .readyToPlay: - guard self.shouldAnimateImage else { return } - - self.isAnimating = true - } - } - } - - case .animatedImage(let frames, _): - await MainActor.run { - self.loadedImage = frames.first - } - - case .bufferedAnimatedImage(let firstFrame, _, _): - await MainActor.run { - self.loadedImage = firstFrame - } - - default: - await MainActor.run { - self.loadedImage = nil + await MainActor.run { + switch event { + case .frameLoaded, .completed: break + case .readyToAnimate: + guard self.shouldAnimateImage else { return } + + self.isAnimating = true } + } } } @MainActor private func resetAnimationState() { self.loadedImage = nil - self.animationFrames = nil - self.animationFrameDurations = nil + self.frameBuffer = nil self.isAnimating = false self.currentFrameIndex = 0 self.accumulatedTime = 0.0 @@ -145,11 +120,9 @@ public struct SessionAsyncImage: View { private func updateAnimationFrame(at date: Date) { guard isAnimating, - let frames: [UIImage?] = animationFrames, - let durations = animationFrameDurations, - !frames.isEmpty, - !durations.isEmpty, - currentFrameIndex < durations.count, + let buffer: ImageDataManager.FrameBuffer = frameBuffer, + !buffer.durations.isEmpty, + currentFrameIndex < buffer.durations.count, let lastDate = lastFrameDate else { isAnimating = false @@ -161,34 +134,38 @@ public struct SessionAsyncImage: View { self.lastFrameDate = date accumulatedTime += elapsed - var currentFrameDuration: TimeInterval = durations[currentFrameIndex] + var currentFrameDuration: TimeInterval = buffer.durations[currentFrameIndex] // Advance frames if the accumulated time exceeds the current frame's duration while accumulatedTime >= currentFrameDuration { accumulatedTime -= currentFrameDuration - let nextFrameIndex: Int = ((currentFrameIndex + 1) % durations.count) + let nextFrameIndex: Int = ((currentFrameIndex + 1) % buffer.durations.count) /// If the next frame hasn't been decoded yet, pause on the current frame, we'll re-evaluate on the next display tick. - guard nextFrameIndex < frames.count, frames[nextFrameIndex] != nil else { break } - - + guard + nextFrameIndex < buffer.frameCount, + buffer.getFrame(at: nextFrameIndex) != nil + else { break } /// Prevent an infinite loop for all zero durations - guard durations[nextFrameIndex] > 0.001 else { break } + guard buffer.durations[nextFrameIndex] > 0.001 else { break } currentFrameIndex = nextFrameIndex - currentFrameDuration = durations[currentFrameIndex] + currentFrameDuration = buffer.durations[currentFrameIndex] } /// Make sure we don't cause an index-out-of-bounds somehow - guard currentFrameIndex < frames.count else { + guard currentFrameIndex < buffer.durations.count else { isAnimating = false return } /// Update the displayed image only if the frame has changed - if loadedImage !== frames[currentFrameIndex] { - loadedImage = frames[currentFrameIndex] + if + let nextFrame: UIImage = buffer.getFrame(at: currentFrameIndex), + loadedImage !== nextFrame + { + loadedImage = nextFrame } } } diff --git a/SessionUIKit/Types/ImageDataManager.swift b/SessionUIKit/Types/ImageDataManager.swift index b614478e0f..c0164dcca3 100644 --- a/SessionUIKit/Types/ImageDataManager.swift +++ b/SessionUIKit/Types/ImageDataManager.swift @@ -13,18 +13,18 @@ public actor ImageDataManager: ImageDataManagerType { ) /// Max memory size for a decoded animation to be considered "small" enough to be fully cached - private static let decodedAnimationCacheLimit: Int = 20 * 1024 * 1024 // 20 M + private static let maxCachableSize: Int = 20 * 1024 * 1024 // 20 M private static let maxAnimatedImageDownscaleDimention: CGFloat = 4096 /// `NSCache` has more nuanced memory management systems than just listening for `didReceiveMemoryWarningNotification` /// and can clear out values gradually, it can also remove items based on their "cost" so is better suited than our custom `LRUCache` - private let cache: NSCache = { - let result: NSCache = NSCache() + private let cache: NSCache = { + let result: NSCache = NSCache() result.totalCostLimit = 200 * 1024 * 1024 // Max 200MB of image data return result }() - private var activeLoadTasks: [String: Task] = [:] + private var activeLoadTasks: [String: Task] = [:] // MARK: - Initialization @@ -32,41 +32,41 @@ public actor ImageDataManager: ImageDataManagerType { // MARK: - Functions - @discardableResult public func load(_ source: DataSource) async -> ProcessedImageData? { + @discardableResult public func load(_ source: DataSource) async -> FrameBuffer? { let identifier: String = source.identifier - if let cachedData: ProcessedImageData = cache.object(forKey: identifier as NSString) { + if let cachedData: FrameBuffer = cache.object(forKey: identifier as NSString) { return cachedData } - if let existingTask: Task = activeLoadTasks[identifier] { + if let existingTask: Task = activeLoadTasks[identifier] { return await existingTask.value } /// Kick off a new processing task in the background - let newTask: Task = Task.detached(priority: .userInitiated) { + let newTask: Task = Task.detached(priority: .userInitiated) { await ImageDataManager.processSource(source) } activeLoadTasks[identifier] = newTask /// Wait for the result then cache and return it - let processedData: ProcessedImageData? = await newTask.value + let maybeBuffer: FrameBuffer? = await newTask.value - if let data: ProcessedImageData = processedData, data.isCacheable { - self.cache.setObject(data, forKey: identifier as NSString, cost: data.estimatedCacheCost) + if let buffer: FrameBuffer = maybeBuffer { + self.cache.setObject(buffer, forKey: identifier as NSString, cost: buffer.estimatedCacheCost) } self.activeLoadTasks[identifier] = nil - return processedData + return maybeBuffer } @MainActor public func load( _ source: ImageDataManager.DataSource, - onComplete: @MainActor @escaping (ImageDataManager.ProcessedImageData?) -> Void + onComplete: @MainActor @escaping (ImageDataManager.FrameBuffer?) -> Void ) { Task { [weak self] in - let result: ImageDataManager.ProcessedImageData? = await self?.load(source) + let result: ImageDataManager.FrameBuffer? = await self?.load(source) await MainActor.run { onComplete(result) @@ -74,7 +74,7 @@ public actor ImageDataManager: ImageDataManagerType { } } - public func cachedImage(identifier: String) async -> ProcessedImageData? { + public func cachedImage(identifier: String) async -> FrameBuffer? { return cache.object(forKey: identifier as NSString) } @@ -88,40 +88,34 @@ public actor ImageDataManager: ImageDataManagerType { // MARK: - Internal Functions - private static func processSource(_ dataSource: DataSource) async -> ProcessedImageData? { + private static func processSource(_ dataSource: DataSource) async -> FrameBuffer? { switch dataSource { case .icon(let icon, let size, let renderingMode): guard let image: UIImage = Lucide.image(icon: icon, size: size) else { return nil } - return ProcessedImageData( - type: .staticImage(image.withRenderingMode(renderingMode)) - ) + return FrameBuffer(image: image.withRenderingMode(renderingMode)) /// If we were given a direct `UIImage` value then use it case .image(_, let maybeImage): guard let image: UIImage = maybeImage else { return nil } - return ProcessedImageData( - type: .staticImage(image) - ) + return FrameBuffer(image: image) /// Custom handle `videoUrl` values since it requires thumbnail generation case .videoUrl(let url, let utType, let sourceFilename, let thumbnailManager): /// If we had already generated a thumbnail then use that if - let existingThumbnail: UIImage = thumbnailManager.existingThumbnailImage(url: url, size: .large), - let existingThumbCgImage: CGImage = existingThumbnail.cgImage, + let existingThumbnailSource: ImageDataManager.DataSource = thumbnailManager + .existingThumbnail(name: url.lastPathComponent, size: .large), + let source: CGImageSource = existingThumbnailSource.createImageSource(), + let existingThumbCgImage: CGImage = createCGImage(source, index: 0, maxDimensionInPixels: nil), let decodingContext: CGContext = createDecodingContext( width: existingThumbCgImage.width, height: existingThumbCgImage.height ), let decodedImage: UIImage = predecode(cgImage: existingThumbCgImage, using: decodingContext) { - let processedData: ProcessedImageData = ProcessedImageData( - type: .staticImage(decodedImage) - ) - - return processedData + return FrameBuffer(image: decodedImage) } /// Otherwise we need to generate a new one @@ -149,70 +143,91 @@ public actor ImageDataManager: ImageDataManagerType { let decodedImage: UIImage = predecode(cgImage: cgImage, using: decodingContext) else { return nil } - let processedData: ProcessedImageData = ProcessedImageData( - type: .staticImage(decodedImage) - ) + let result: FrameBuffer = FrameBuffer(image: decodedImage) /// Since we generated a new thumbnail we should save it to disk - saveThumbnailToDisk( - image: decodedImage, - url: url, - size: .large, - thumbnailManager: thumbnailManager - ) + Task.detached(priority: .background) { + saveThumbnailToDisk( + name: url.lastPathComponent, + frames: [decodedImage], + durations: [], /// Static image so no durations + hasAlpha: false, /// Video can't have alpha + size: .large, + thumbnailManager: thumbnailManager + ) + } - return processedData + return result /// Custom handle `urlThumbnail` generation case .urlThumbnail(let url, let size, let thumbnailManager): /// If we had already generated a thumbnail then use that if - let existingThumbnail: UIImage = thumbnailManager.existingThumbnailImage(url: url, size: .large), - let existingThumbCgImage: CGImage = existingThumbnail.cgImage, - let decodingContext: CGContext = createDecodingContext( - width: existingThumbCgImage.width, - height: existingThumbCgImage.height - ), - let decodedImage: UIImage = predecode(cgImage: existingThumbCgImage, using: decodingContext) + let existingThumbnailSource: ImageDataManager.DataSource = thumbnailManager + .existingThumbnail(name: url.lastPathComponent, size: size), + let source: CGImageSource = existingThumbnailSource.createImageSource() { - let processedData: ProcessedImageData = ProcessedImageData( - type: .staticImage(decodedImage) - ) - - return processedData + /// Thumbnails will always have their orientation removed + return await createBuffer(source, orientation: .up) } - /// Otherwise we need to generate a new one + /// If not then check whether there would be any benefit in creating a thumbnail + guard + let newThumbnailSource: CGImageSource = dataSource.createImageSource(), + let properties: [String: Any] = CGImageSourceCopyPropertiesAtIndex(newThumbnailSource, 0, nil) as? [String: Any], + let sourceWidth: Int = properties[kCGImagePropertyPixelWidth as String] as? Int, + let sourceHeight: Int = properties[kCGImagePropertyPixelHeight as String] as? Int, + sourceWidth > 0, + sourceHeight > 0 + else { return nil } + + /// If the source is smaller than the target thumbnail size then we should just return the target directly let maxDimensionInPixels: CGFloat = await size.pixelDimension() - let options: [CFString: Any] = [ - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false, - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels - ] - + let flooredPixels: Int = Int(floor(maxDimensionInPixels)) + + guard sourceWidth > flooredPixels || sourceHeight > flooredPixels else { + return await processSource(.url(url)) + } + + /// Otherwise, generate the thumbnail guard - let source: CGImageSource = dataSource.createImageSource(options: options), - let cgImage: CGImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary), - let decodingContext: CGContext = createDecodingContext( - width: cgImage.width, - height: cgImage.height - ), - let decodedImage: UIImage = predecode(cgImage: cgImage, using: decodingContext) + let result: FrameBuffer = await createBuffer( + newThumbnailSource, + orientation: orientation(from: properties), + maxDimensionInPixels: maxDimensionInPixels, + customLoaderGenerator: { + /// If we had already generated a thumbnail then use that + if + let existingThumbnailSource: ImageDataManager.DataSource = thumbnailManager + .existingThumbnail(name: url.lastPathComponent, size: size), + let source: CGImageSource = existingThumbnailSource.createImageSource() + { + /// Thumbnails will always have their orientation removed + let existingThumbnailBuffer: FrameBuffer? = await createBuffer(source, orientation: .up) + + return await existingThumbnailBuffer?.generateLoadClosure?() + } + + return nil + } + ) else { return nil } - /// Since we generated a new thumbnail we should save it to disk - saveThumbnailToDisk( - image: decodedImage, - url: url, - size: size, - thumbnailManager: thumbnailManager - ) + /// Since we generated a new thumbnail we should save it to disk (only do this if we created a new thumbnail) + Task.detached(priority: .background) { + let allFrames: [UIImage] = await result.allFramesOnceLoaded() + + saveThumbnailToDisk( + name: url.lastPathComponent, + frames: allFrames, + durations: result.durations, + hasAlpha: (properties[kCGImagePropertyHasAlpha as String] as? Bool), + size: size, + thumbnailManager: thumbnailManager + ) + } - return ProcessedImageData( - type: .staticImage(decodedImage) - ) + return result /// Custom handle `placeholderIcon` generation case .placeholderIcon(let seed, let text, let size): @@ -225,15 +240,9 @@ public actor ImageDataManager: ImageDataManagerType { height: cgImage.height ), let decodedImage: UIImage = predecode(cgImage: cgImage, using: decodingContext) - else { - return ProcessedImageData( - type: .staticImage(image) - ) - } + else { return FrameBuffer(image: image) } - return ProcessedImageData( - type: .staticImage(decodedImage) - ) + return FrameBuffer(image: decodedImage) case .asyncSource(_, let sourceRetriever): guard let source: DataSource = await sourceRetriever() else { return nil } @@ -255,174 +264,185 @@ public actor ImageDataManager: ImageDataManagerType { sourceHeight > 0, sourceHeight < ImageDataManager.DataSource.maxValidDimension else { return nil } - + + return await createBuffer(source, orientation: orientation(from: properties)) + } + + private static func orientation(from properties: [String: Any]) -> UIImage.Orientation { + if + let rawCgOrientation: UInt32 = properties[kCGImagePropertyOrientation as String] as? UInt32, + let cgOrientation: CGImagePropertyOrientation = CGImagePropertyOrientation(rawValue: rawCgOrientation) + { + return UIImage.Orientation(cgOrientation) + } + + return .up + } + + private static func createBuffer( + _ source: CGImageSource, + orientation: UIImage.Orientation, + maxDimensionInPixels: CGFloat? = nil, + customLoaderGenerator: (() async -> AsyncLoadStream.Loader?)? = nil + ) async -> FrameBuffer? { /// Get the number of frames in the image let count: Int = CGImageSourceGetCount(source) - switch count { - /// Invalid image - case ..<1: return nil - - /// Static image - case 1: - /// Extract image orientation if present - var orientation: UIImage.Orientation = .up - - if - let rawCgOrientation: UInt32 = properties[kCGImagePropertyOrientation as String] as? UInt32, - let cgOrientation: CGImagePropertyOrientation = CGImagePropertyOrientation(rawValue: rawCgOrientation) - { - orientation = UIImage.Orientation(cgOrientation) - } - - /// Try to decode the image direct from the `CGImage` - let options: [CFString: Any] = [ - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false - ] - - guard - let cgImage: CGImage = CGImageSourceCreateImageAtIndex(source, 0, options as CFDictionary), - let decodingContext = createDecodingContext(width: cgImage.width, height: cgImage.height), - let decodedImage: UIImage = predecode(cgImage: cgImage, using: decodingContext), - let decodedCgImage: CGImage = decodedImage.cgImage - else { return nil } - - let finalImage: UIImage = UIImage(cgImage: decodedCgImage, scale: 1, orientation: orientation) - - return ProcessedImageData( - type: .staticImage(finalImage) + /// Invalid image + guard count > 0 else { return nil } + + /// Load the first frame + guard + let firstFrameCgImage: CGImage = createCGImage( + source, + index: 0, + maxDimensionInPixels: maxDimensionInPixels + ), + let firstFrameContext: CGContext = createDecodingContext( + width: firstFrameCgImage.width, + height: firstFrameCgImage.height + ), + let decodedFirstFrameImage: UIImage = predecode(cgImage: firstFrameCgImage, using: firstFrameContext), + let decodedCgImage: CGImage = decodedFirstFrameImage.cgImage + else { return nil } + + /// Static image + guard count > 1 else { + return FrameBuffer( + image: UIImage(cgImage: decodedCgImage, scale: 1, orientation: orientation) + ) + } + + /// Animated Image + let durations: [TimeInterval] = getFrameDurations(from: source, count: count) + let standardLoaderGenerator: AsyncLoadStream.Loader = { stream, buffer in + /// Since the `AsyncLoadStream.Loader` gets run in it's own task we need to create a context within the task + guard + let decodingContext: CGContext = createDecodingContext( + width: firstFrameCgImage.width, + height: firstFrameCgImage.height ) + else { return } + + var (frameIndexesToBuffer, probeFrames) = await self.calculateHeuristicBuffer( + startIndex: 1, /// We have already decoded the first frame so skip it + source: source, + durations: durations, + maxDimensionInPixels: maxDimensionInPixels, + using: decodingContext + ) + let lastBufferedFrameIndex: Int = ( + frameIndexesToBuffer.max() ?? + probeFrames.count + ) + + /// Immediately yield the frames decoded when calculating the buffer size + for (index, frame) in probeFrames.enumerated() { + if Task.isCancelled { break } - /// Animated Image - default: - /// Load the first frame - guard - let firstFrameCgImage: CGImage = CGImageSourceCreateImageAtIndex(source, 0, nil), - let decodingContext: CGContext = createDecodingContext( - width: firstFrameCgImage.width, - height: firstFrameCgImage.height - ), - let decodedFirstFrameImage: UIImage = predecode(cgImage: firstFrameCgImage, using: decodingContext) - else { return nil } - - /// If the memory usage of the full animation when is small enough then we should fully decode and cache the decoded - /// result in memory, otherwise we don't want to cache the decoded data, but instead want to generate a buffered stream - /// of frame data to start playing the animation as soon as possible whilst we continue to decode in the background - let decodedMemoryCost: Int = (firstFrameCgImage.width * firstFrameCgImage.height * 4 * count) - let durations: [TimeInterval] = getFrameDurations(from: source, count: count) + /// We `+ 1` because the first frame is always manually assigned + let bufferIndex: Int = (index + 1) + buffer.setFrame(frame, at: bufferIndex) + await stream.send(.frameLoaded(index: bufferIndex)) + } + + /// Clear out the `proveFrames` array so we don't use the extra memory + probeFrames.removeAll(keepingCapacity: false) + + /// Load in any additional buffer frames needed + for i in frameIndexesToBuffer { + guard !Task.isCancelled else { + await stream.cancel() + return + } - guard decodedMemoryCost > decodedAnimationCacheLimit else { - var frames: [UIImage] = [decodedFirstFrameImage] - - for i in 1.. = AsyncStream { continuation in - let task = Task.detached(priority: .userInitiated) { - var (frameIndexesToBuffer, probeFrames) = await self.calculateHeuristicBuffer( - startIndex: 1, /// We have already decoded the first frame so skip it - source: source, - durations: durations, + if let frame: UIImage = decodedFrame { + buffer.setFrame(frame, at: i) + await stream.send(.frameLoaded(index: i)) + } + } + + /// Now that we have buffered enough frames we can start the animation + if !Task.isCancelled { + await stream.send(.readyToAnimate) + } + + /// Start loading the remaining frames (`+ 1` as we want to start from the index after the last buffered index) + if lastBufferedFrameIndex < count { + for i in (lastBufferedFrameIndex + 1).. CGImage? { + /// If we don't have a `maxDimension` then we should just load the full image + guard let maxDimension: CGFloat = maxDimensionInPixels else { + let options: CFDictionary = [ + kCGImageSourceShouldCache: false, + kCGImageSourceShouldCacheImmediately: false + ] as CFDictionary + + return CGImageSourceCreateImageAtIndex(source, index, options) } + + /// Otherwise we should create a thumbnail + let options: CFDictionary = [ + kCGImageSourceShouldCache: false, + kCGImageSourceShouldCacheImmediately: false, + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maxDimension + ] as CFDictionary + + return CGImageSourceCreateThumbnailAtIndex(source, index, options) } private static func createDecodingContext(width: Int, height: Int) -> CGContext? { @@ -504,10 +524,11 @@ public actor ImageDataManager: ImageDataManagerType { startIndex: Int, source: CGImageSource, durations: [TimeInterval], + maxDimensionInPixels: CGFloat?, using context: CGContext ) async -> (frameIndexesToBuffer: [Int], probeFrames: [UIImage]) { - let probeFrameCount: Int = 5 /// Number of frames to decode in order to calculate the approx. time to load each frame - let safetyMargin: Double = 2 /// Number of extra frames to be buffered just in case + let probeFrameCount: Int = 8 /// Number of frames to decode in order to calculate the approx. time to load each frame + let safetyMargin: Double = 4 /// Number of extra frames to be buffered just in case guard durations.count > (startIndex + probeFrameCount) else { return (Array(startIndex.. 0.001 else { return ([], probeFrames) } @@ -545,17 +566,20 @@ public actor ImageDataManager: ImageDataManagerType { } private static func saveThumbnailToDisk( - image: UIImage, - url: URL, + name: String, + frames: [UIImage], + durations: [TimeInterval], + hasAlpha: Bool?, size: ImageDataManager.ThumbnailSize, thumbnailManager: ThumbnailManager ) { - /// Don't want to block updating the UI so detatch this task - Task.detached(priority: .background) { - guard let data: Data = image.jpegData(compressionQuality: 0.85) else { return } - - thumbnailManager.saveThumbnail(data: data, size: size, url: url) - } + thumbnailManager.saveThumbnail( + name: name, + frames: frames, + durations: durations, + hasAlpha: hasAlpha, + size: size + ) } } @@ -609,14 +633,11 @@ public extension ImageDataManager { } } - public func createImageSource(options: [CFString: Any]? = nil) -> CGImageSource? { - let finalOptions: CFDictionary = ( - options ?? - [ - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false - ] - ) as CFDictionary + public func createImageSource() -> CGImageSource? { + let finalOptions: CFDictionary = [ + kCGImageSourceShouldCache: false, + kCGImageSourceShouldCacheImmediately: false + ] as CFDictionary switch self { case .url(let url): return CGImageSourceCreateWithURL(url as CFURL, finalOptions) @@ -712,25 +733,6 @@ public extension ImageDataManager { } } -// MARK: - ImageDataManager.DataType - -public extension ImageDataManager { - enum DataType { - case staticImage(UIImage) - case animatedImage(frames: [UIImage], durations: [TimeInterval]) - case bufferedAnimatedImage( - firstFrame: UIImage, - durations: [TimeInterval], - bufferedFrameStream: AsyncStream - ) - } - - enum BufferedFrameStreamEvent { - case frame(index: Int, frame: UIImage) - case readyToPlay - } -} - // MARK: - ImageDataManager.isAnimatedImage public extension ImageDataManager { @@ -741,49 +743,205 @@ public extension ImageDataManager { } } -// MARK: - ImageDataManager.ProcessedImageData +// MARK: - ImageDataManager.FrameBuffer public extension ImageDataManager { - class ProcessedImageData: @unchecked Sendable { - public let type: DataType + enum AsyncLoadEvent: Equatable { + case frameLoaded(index: Int) + case readyToAnimate + case completed + } + + final class FrameBuffer: @unchecked Sendable { + fileprivate final class Box: @unchecked Sendable { + var frameBuffer: FrameBuffer? + } + + private let lock: NSLock = NSLock() public let frameCount: Int + public let firstFrame: UIImage + public let durations: [TimeInterval] public let estimatedCacheCost: Int + public var stream: AsyncStream { + loadIfNeeded() + return asyncLoadStream.stream + } - public var isCacheable: Bool { - switch type { - case .staticImage, .animatedImage: return true - case .bufferedAnimatedImage: return false - } + public var isComplete: Bool { + lock.lock() + defer { lock.unlock() } + + return _isComplete } + public var framesPurged: Bool { + lock.lock() + defer { lock.unlock() } + + return _framesPurged + } + + fileprivate let generateLoadClosure: (() async -> AsyncLoadStream.Loader)? + private let asyncLoadStream: AsyncLoadStream + private let purgeable: Bool + private var _isLoading: Bool = false + private var _isComplete: Bool = false + private var _framesPurged: Bool = false + private var activeObservers: Set = [] + private var otherFrames: [UIImage?] + + // MARK: - Initialization - init(type: DataType) { - self.type = type + public init(image: UIImage) { + self.frameCount = 1 + self.firstFrame = image + self.durations = [] + self.estimatedCacheCost = FrameBuffer.calculateCost( + forPixelSize: image.size, + count: 1, + bitsPerPixel: image.cgImage?.bitsPerPixel + ) + self.generateLoadClosure = nil + self.purgeable = false + self.asyncLoadStream = .completed + self._isComplete = true + self.otherFrames = [] + } + + fileprivate init( + firstFrame: UIImage, + durations: [TimeInterval], + shouldAutoPurgeIfEstimatedCostExceedsLimit cacheLimit: Int, + generateLoadClosure: @escaping @Sendable () async -> AsyncLoadStream.Loader + ) { + let fullCost: Int = FrameBuffer.calculateCost( + forPixelSize: firstFrame.size, + count: durations.count, + bitsPerPixel: firstFrame.cgImage?.bitsPerPixel + ) - switch type { - case .staticImage(let image): - frameCount = 1 - estimatedCacheCost = ProcessedImageData.calculateCost(for: [image]) - - case .animatedImage(let frames, _): - frameCount = frames.count - estimatedCacheCost = ProcessedImageData.calculateCost(for: frames) - - case .bufferedAnimatedImage(_, let durations, _): - frameCount = durations.count - estimatedCacheCost = 0 + self.frameCount = durations.count + self.firstFrame = firstFrame + self.durations = durations + self.purgeable = (fullCost > cacheLimit) + self.otherFrames = Array(repeating: nil, count: max(0, (durations.count - 1))) + self.generateLoadClosure = generateLoadClosure + self.asyncLoadStream = AsyncLoadStream() + + /// For purgeable buffers we don't keep the full images in the cache (just the first frame) and we release the remaining + /// frames once the final observers have stopped observing + self.estimatedCacheCost = (!purgeable ? + fullCost : + FrameBuffer.calculateCost( + forPixelSize: firstFrame.size, + count: 1, + bitsPerPixel: firstFrame.cgImage?.bitsPerPixel + ) + ) + } + + // MARK: - Functions + + public func getFrame(at index: Int) -> UIImage? { + loadIfNeeded() + + if index == 0 { + return firstFrame } + + lock.lock() + defer { lock.unlock() } + + let otherIndex: Int = (index - 1) + guard otherIndex >= 0, otherIndex < otherFrames.count else { return nil } + + return otherFrames[otherIndex] } - static func calculateCost(for images: [UIImage]) -> Int { - return images.reduce(0) { totalCost, image in - guard let cgImage: CGImage = image.cgImage else { return totalCost } + // MARK: - Internal Functions + + fileprivate func setFrame(_ frame: UIImage, at index: Int) { + lock.lock() + defer { lock.unlock() } + + guard index > 0, index < (otherFrames.count + 1) else { return } + + otherFrames[index - 1] = frame + } + + fileprivate func markComplete() { + lock.lock() + defer { lock.unlock() } + + _isComplete = true + _isLoading = false + } + + fileprivate func allFramesOnceLoaded() async -> [UIImage] { + _ = await asyncLoadStream.stream.first(where: { $0 == .completed }) + + return getAllLoadedFrames() + } + + private func loadIfNeeded() { + let needsLoad: Bool = { + lock.lock() + defer { lock.unlock() } - let bytesPerPixel: Int = (cgImage.bitsPerPixel / 8) - let imagePixels: Int = (cgImage.width * cgImage.height) + return ( + !_isLoading && ( + _framesPurged || + !_isComplete + ) + ) + }() + + guard needsLoad, let generateLoadClosure = generateLoadClosure else { return } + + /// Update the loading and purged states + lock.lock() + _isLoading = true + _framesPurged = false + lock.unlock() + + Task.detached { [weak self] in + guard let self else { return } - return totalCost + (imagePixels * (bytesPerPixel > 0 ? bytesPerPixel : 4)) + await asyncLoadStream.start(with: generateLoadClosure(), buffer: self) } } + + private func getAllLoadedFrames() -> [UIImage] { + lock.lock() + defer { lock.unlock() } + + return [firstFrame] + otherFrames.compactMap { $0 } + } + + fileprivate func purgeIfNeeded() { + guard purgeable else { return } + + lock.lock() + defer { lock.unlock() } + + guard !_framesPurged else { return } + + /// Keep first frame, clear others + otherFrames = Array(repeating: nil, count: otherFrames.count) + _framesPurged = true + _isComplete = false + } + + private static func calculateCost( + forPixelSize size: CGSize, + count: Int, + bitsPerPixel: Int? + ) -> Int { + /// Assume the standard 32 bits per pixel + let imagePixels: Int = Int(size.width * size.height) + let bytesPerPixel: Int = ((bitsPerPixel ?? 32) / 8) + + return (count * (imagePixels * bytesPerPixel)) + } } } @@ -859,15 +1017,15 @@ public extension ImageDataManager { // MARK: - ImageDataManagerType public protocol ImageDataManagerType { - @discardableResult func load(_ source: ImageDataManager.DataSource) async -> ImageDataManager.ProcessedImageData? + @discardableResult func load(_ source: ImageDataManager.DataSource) async -> ImageDataManager.FrameBuffer? @MainActor func load( _ source: ImageDataManager.DataSource, - onComplete: @MainActor @escaping (ImageDataManager.ProcessedImageData?) -> Void + onComplete: @MainActor @escaping (ImageDataManager.FrameBuffer?) -> Void ) - func cachedImage(identifier: String) async -> ImageDataManager.ProcessedImageData? + func cachedImage(identifier: String) async -> ImageDataManager.FrameBuffer? func removeImage(identifier: String) async func clearCache() async } @@ -875,6 +1033,128 @@ public protocol ImageDataManagerType { // MARK: - ThumbnailManager public protocol ThumbnailManager: Sendable { - func existingThumbnailImage(url: URL, size: ImageDataManager.ThumbnailSize) -> UIImage? - func saveThumbnail(data: Data, size: ImageDataManager.ThumbnailSize, url: URL) + func existingThumbnail(name: String, size: ImageDataManager.ThumbnailSize) -> ImageDataManager.DataSource? + func saveThumbnail( + name: String, + frames: [UIImage], + durations: [TimeInterval], + hasAlpha: Bool?, + size: ImageDataManager.ThumbnailSize + ) +} + +// MARK: AsyncLoadStream + +public actor AsyncLoadStream { + public typealias Loader = @Sendable (AsyncLoadStream, ImageDataManager.FrameBuffer) async -> Void + + fileprivate static let completed: AsyncLoadStream = AsyncLoadStream(isFinished: true) + + private var continuations: [UUID: AsyncStream.Continuation] = [:] + private var lastEvent: ImageDataManager.AsyncLoadEvent? + private var isFinished: Bool = false + + /// This being `nonisolated(unsafe)` is ok because it only gets set in `init` or accessed from isolated methods (`send` + /// and `cancel`) + private nonisolated(unsafe) var loadingTask: Task? + private weak var frameBuffer: ImageDataManager.FrameBuffer? + + public nonisolated var stream: AsyncStream { + AsyncStream { continuation in + let id = UUID() + Task { + guard await !self.isFinished else { + if let lastEvent = await self.lastEvent { + continuation.yield(lastEvent) + } + + /// Don't finish, add to continuations to keep the observer registered and the `FrameBuffer` alive (in case + /// it's purgeable) + await self.addContinuation(id: id, continuation: continuation) + return + } + + // Replay the last event if there is one + if let lastEvent = await self.lastEvent { + continuation.yield(lastEvent) + } + + await self.addContinuation(id: id, continuation: continuation) + } + continuation.onTermination = { _ in + Task { await self.removeContinuation(id: id) } + } + } + } + + // MARK: - Initialization + + fileprivate init() {} + + private init(isFinished: Bool) { + self.lastEvent = .completed + self.isFinished = isFinished + self.loadingTask = nil + } + + // MARK: - Functions + + public func start( + priority: TaskPriority? = nil, + with load: @escaping Loader, + buffer: ImageDataManager.FrameBuffer + ) { + loadingTask?.cancel() + loadingTask = nil + + lastEvent = nil + isFinished = false + frameBuffer = buffer + loadingTask = Task.detached(priority: .userInitiated) { [weak self] in + guard let self else { return } + + await load(self, buffer) + } + } + + public func send(_ event: ImageDataManager.AsyncLoadEvent) { + guard !isFinished else { return } + + lastEvent = event + continuations.values.forEach { $0.yield(event) } + + /// Mark as finished by **don't** `finish` the streams so we don't unintentionally purge memory + if case .completed = event { + isFinished = true + loadingTask = nil + } + } + + public func cancel() { + loadingTask?.cancel() + loadingTask = nil + continuations.values.forEach { $0.finish() } + continuations.removeAll() + isFinished = true + } + + // MARK: - Internal Functions + + private func addContinuation(id: UUID, continuation: AsyncStream.Continuation) { + continuations[id] = continuation + } + + private func removeContinuation(id: UUID) { + continuations.removeValue(forKey: id) + + /// When last observer removed, trigger purge check + if continuations.isEmpty { + loadingTask?.cancel() + loadingTask = nil + + Task.detached { [weak frameBuffer] in + frameBuffer?.purgeIfNeeded() + } + } + } } diff --git a/SessionUtilitiesKit/General/Feature.swift b/SessionUtilitiesKit/General/Feature.swift index 9b3ee3d5ee..36d719800b 100644 --- a/SessionUtilitiesKit/General/Feature.swift +++ b/SessionUtilitiesKit/General/Feature.swift @@ -105,6 +105,10 @@ public extension FeatureStorage { static let simulateAppReviewLimit: FeatureConfig = Dependencies.create( identifier: "simulateAppReviewLimit" ) + + static let usePngInsteadOfWebPForFallbackImageType: FeatureConfig = Dependencies.create( + identifier: "usePngInsteadOfWebPForFallbackImageType" + ) } // MARK: - FeatureOption diff --git a/SessionUtilitiesKit/Media/MediaUtils.swift b/SessionUtilitiesKit/Media/MediaUtils.swift index 1ab95b717e..d69232c033 100644 --- a/SessionUtilitiesKit/Media/MediaUtils.swift +++ b/SessionUtilitiesKit/Media/MediaUtils.swift @@ -208,6 +208,7 @@ public enum MediaUtils { public init( pixelSize: CGSize, fileSize: UInt64 = 0, + frameDurations: [TimeInterval] = [0], hasUnsafeMetadata: Bool, depthBytes: CGFloat? = nil, hasAlpha: Bool? = nil, @@ -217,8 +218,8 @@ public enum MediaUtils { ) { self.pixelSize = pixelSize self.fileSize = fileSize - self.frameDurations = [0] - self.duration = 0 + self.frameDurations = frameDurations + self.duration = frameDurations.reduce(0, +) self.hasUnsafeMetadata = hasUnsafeMetadata self.depthBytes = depthBytes self.hasAlpha = hasAlpha From 69595686d5f8c9ae1b32ce3395f57ad56ea5b235 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 15 Oct 2025 11:34:34 +1100 Subject: [PATCH 093/162] Fixed unit tests, mocked out media decoding to silence errors --- Session.xcodeproj/project.pbxproj | 10 +++ Session/Meta/Session+SNUIKit.swift | 16 ++++ Session/Onboarding/Onboarding.swift | 6 -- .../_036_GroupsRebuildChanges.swift | 7 +- .../Jobs/DisplayPictureDownloadJob.swift | 2 +- .../Sending & Receiving/MessageSender.swift | 14 +-- .../Utilities/AttachmentManager.swift | 16 ++-- .../Utilities/DisplayPictureManager.swift | 12 +-- .../Jobs/DisplayPictureDownloadJobSpec.swift | 23 +++-- .../Jobs/MessageSendJobSpec.swift | 19 ++-- .../MessageReceiverGroupsSpec.swift | 6 +- .../MessageSenderGroupsSpec.swift | 90 +++++++++++++------ .../_TestUtilities/MockImageDataManager.swift | 6 +- .../ShareNavController.swift | 16 ++++ SessionTests/Database/DatabaseSpec.swift | 1 + SessionTests/Onboarding/OnboardingSpec.swift | 22 ++--- SessionUIKit/Configuration.swift | 21 +++++ SessionUIKit/Types/ImageDataManager.swift | 26 ++---- SessionUtilitiesKit/Crypto/Crypto.swift | 2 +- SessionUtilitiesKit/Media/MediaUtils.swift | 58 ++++++++++-- .../Media/UTType+Utilities.swift | 4 +- .../Utilities/Result+Utilities.swift | 6 +- _SharedTestUtilities/MockFileManager.swift | 4 + _SharedTestUtilities/MockMediaDecoder.swift | 36 ++++++++ _SharedTestUtilities/Mocked.swift | 1 + _SharedTestUtilities/TestConstants.swift | 31 +++---- 26 files changed, 302 insertions(+), 153 deletions(-) create mode 100644 _SharedTestUtilities/MockMediaDecoder.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index ad7d0ddcd6..7b7607b85b 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -705,6 +705,10 @@ FD5E93D22C12B0580038C25A /* AppVersionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4383727B3863200C60D73 /* AppVersionResponse.swift */; }; FD61FCF92D308CC9005752DE /* GroupMemberSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD61FCF82D308CC5005752DE /* GroupMemberSpec.swift */; }; FD636C672E9DAC4100965D56 /* HTTPFragmentParam+FileServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD636C662E9DAC4100965D56 /* HTTPFragmentParam+FileServer.swift */; }; + FD636C692E9F0D1400965D56 /* MockMediaDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD636C682E9F0D1100965D56 /* MockMediaDecoder.swift */; }; + FD636C6A2E9F0D1400965D56 /* MockMediaDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD636C682E9F0D1100965D56 /* MockMediaDecoder.swift */; }; + FD636C6B2E9F0D1400965D56 /* MockMediaDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD636C682E9F0D1100965D56 /* MockMediaDecoder.swift */; }; + FD636C6C2E9F0D1400965D56 /* MockMediaDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD636C682E9F0D1100965D56 /* MockMediaDecoder.swift */; }; FD65318A2AA025C500DFEEAA /* TestDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6531892AA025C500DFEEAA /* TestDependencies.swift */; }; FD65318B2AA025C500DFEEAA /* TestDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6531892AA025C500DFEEAA /* TestDependencies.swift */; }; FD65318C2AA025C500DFEEAA /* TestDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6531892AA025C500DFEEAA /* TestDependencies.swift */; }; @@ -2055,6 +2059,7 @@ FD61FCF82D308CC5005752DE /* GroupMemberSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberSpec.swift; sourceTree = ""; }; FD61FCFA2D34A5DE005752DE /* _023_SplitSnodeReceivedMessageInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _023_SplitSnodeReceivedMessageInfo.swift; sourceTree = ""; }; FD636C662E9DAC4100965D56 /* HTTPFragmentParam+FileServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HTTPFragmentParam+FileServer.swift"; sourceTree = ""; }; + FD636C682E9F0D1100965D56 /* MockMediaDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaDecoder.swift; sourceTree = ""; }; FD6531892AA025C500DFEEAA /* TestDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDependencies.swift; sourceTree = ""; }; FD6673FC2D77F54400041530 /* ScreenLockViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenLockViewController.swift; sourceTree = ""; }; FD6673FE2D77F9BE00041530 /* ScreenLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenLock.swift; sourceTree = ""; }; @@ -4799,6 +4804,7 @@ FD49E2452B05C1D500FFBBB5 /* MockKeychain.swift */, FD0150372CA24328005B08A1 /* MockJobRunner.swift */, FDB11A552DD17C3000BEF49F /* MockLogger.swift */, + FD636C682E9F0D1100965D56 /* MockMediaDecoder.swift */, FD83B9BD27CF2243005E1583 /* TestConstants.swift */, FD6531892AA025C500DFEEAA /* TestDependencies.swift */, FD9DD2702A72516D00ECB68E /* TestExtensions.swift */, @@ -7125,6 +7131,7 @@ FD19363F2ACA66DE004BCF0F /* DatabaseSpec.swift in Sources */, FD23CE332A67C4D90000B97C /* MockNetwork.swift in Sources */, FD71161528D00D6700B47552 /* ThreadDisappearingMessagesViewModelSpec.swift in Sources */, + FD636C692E9F0D1400965D56 /* MockMediaDecoder.swift in Sources */, FD01503A2CA24328005B08A1 /* MockJobRunner.swift in Sources */, FD23EA5E28ED00FD0058676E /* NimbleExtensions.swift in Sources */, FD481A9B2CB4CAF100ECC4CF /* CustomArgSummaryDescribable+SessionMessagingKit.swift in Sources */, @@ -7171,6 +7178,7 @@ FD0150402CA2433D005B08A1 /* BencodeDecoderSpec.swift in Sources */, FD0150412CA2433D005B08A1 /* BencodeEncoderSpec.swift in Sources */, FD0150422CA2433D005B08A1 /* VersionSpec.swift in Sources */, + FD636C6A2E9F0D1400965D56 /* MockMediaDecoder.swift in Sources */, FD42ECD42E32FF2E002D03EA /* StringUtilitiesSpec.swift in Sources */, FD23EA6328ED0B260058676E /* CombineExtensions.swift in Sources */, FD0150482CA243CB005B08A1 /* Mock.swift in Sources */, @@ -7195,6 +7203,7 @@ buildActionMask = 2147483647; files = ( FDB5DB062A981C67002C8721 /* PreparedRequestSendingSpec.swift in Sources */, + FD636C6C2E9F0D1400965D56 /* MockMediaDecoder.swift in Sources */, FD49E2482B05C1D500FFBBB5 /* MockKeychain.swift in Sources */, FDB5DB082A981F8B002C8721 /* Mocked.swift in Sources */, FD6B92CD2E77B22D004463B5 /* SOGSMessageSpec.swift in Sources */, @@ -7246,6 +7255,7 @@ FD96F3A529DBC3DC00401309 /* MessageSendJobSpec.swift in Sources */, FDE754A82C9B964D002A2623 /* MessageReceiverGroupsSpec.swift in Sources */, FD01504C2CA243CB005B08A1 /* Mock.swift in Sources */, + FD636C6B2E9F0D1400965D56 /* MockMediaDecoder.swift in Sources */, FD981BC92DC4641100564172 /* ExtensionHelperSpec.swift in Sources */, FD981BCB2DC4A21C00564172 /* MessageDeduplicationSpec.swift in Sources */, FD83B9C727CF3F10005E1583 /* CapabilitySpec.swift in Sources */, diff --git a/Session/Meta/Session+SNUIKit.swift b/Session/Meta/Session+SNUIKit.swift index f27d4efc03..a73be074ec 100644 --- a/Session/Meta/Session+SNUIKit.swift +++ b/Session/Meta/Session+SNUIKit.swift @@ -101,4 +101,20 @@ internal struct SessionSNUIKitConfig: SNUIKit.ConfigType { return (result.asset, MediaUtils.isValidVideo(asset: result.asset), result.cleanup) } + + func mediaDecoderDefaultImageOptions() -> CFDictionary { + return dependencies[singleton: .mediaDecoder].defaultImageOptions + } + + func mediaDecoderDefaultThumbnailOptions(maxDimension: CGFloat) -> CFDictionary { + return dependencies[singleton: .mediaDecoder].defaultThumbnailOptions(maxDimension: maxDimension) + } + + func mediaDecoderSource(for url: URL) -> CGImageSource? { + return dependencies[singleton: .mediaDecoder].source(for: url) + } + + func mediaDecoderSource(for data: Data) -> CGImageSource? { + return dependencies[singleton: .mediaDecoder].source(for: data) + } } diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 5dda7496ea..116b92cdad 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -404,12 +404,6 @@ extension Onboarding { /// Clear the `lastNameUpdate` timestamp and forcibly set the `displayName` provided /// during the onboarding step (we do this after handling the config message because we want /// the value provided during onboarding to superseed any retrieved from the config) - try Profile - .fetchOrCreate(db, id: userSessionId.hexString) - .upsert(db) - try Profile - .filter(id: userSessionId.hexString) - .updateAll(db, Profile.Columns.profileLastUpdated.set(to: nil)) try Profile.updateIfNeeded( db, publicKey: userSessionId.hexString, diff --git a/SessionMessagingKit/Database/Migrations/_036_GroupsRebuildChanges.swift b/SessionMessagingKit/Database/Migrations/_036_GroupsRebuildChanges.swift index e34e3cb5b9..94d807c222 100644 --- a/SessionMessagingKit/Database/Migrations/_036_GroupsRebuildChanges.swift +++ b/SessionMessagingKit/Database/Migrations/_036_GroupsRebuildChanges.swift @@ -187,7 +187,7 @@ enum _036_GroupsRebuildChanges: Migration { } let filename: String = generateFilename( - utType: (UTType(imageData: imageData) ?? .jpeg), + utType: (UTType(imageData: imageData, using: dependencies) ?? .jpeg), using: dependencies ) let filePath: String = URL(fileURLWithPath: dependencies[singleton: .displayPictureManager].sharedDataDisplayPictureDirPath()) @@ -197,7 +197,10 @@ enum _036_GroupsRebuildChanges: Migration { // Save the decrypted display picture to disk try? imageData.write(to: URL(fileURLWithPath: filePath), options: [.atomic]) - guard UIImage(contentsOfFile: filePath) != nil else { + // Verify the saved data is valid image data (don't do this when running unit tests because + // the data generally won't be valid and trying to mock the return for any possible test + // that may run this migration would be a nightmare) + guard SNUtilitiesKit.isRunningTests || UIImage(contentsOfFile: filePath) != nil else { Log.error("[GroupsRebuildChanges] Failed to save Community imageData for \(threadId)") return } diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index 36fdb72a48..c287161e55 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -118,7 +118,7 @@ public enum DisplayPictureDownloadJob: JobExecutor { else { throw AttachmentError.invalidData } /// Kick off a task to load the image into the cache (assuming we want to render it soon) - Task.detached(priority: .userInitiated) { + Task.detached(priority: .userInitiated) { [dependencies] in await dependencies[singleton: .imageDataManager].load( .url(URL(fileURLWithPath: filePath)) ) diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 8752d80003..6c114daedd 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -400,7 +400,7 @@ public final class MessageSender { .addingAttachmentsIfNeeded(message, attachments?.map { $0.attachment }) else { throw MessageSenderError.protoConversionFailed } - return try Result(proto.serializedData().paddedMessageBody()) + return try Result { try proto.serializedData().paddedMessageBody() } .mapError { MessageSenderError.other(nil, "Couldn't serialize proto", $0) } .successOrThrow() @@ -410,7 +410,7 @@ public final class MessageSender { .addingAttachmentsIfNeeded(message, attachments?.map { $0.attachment }) else { throw MessageSenderError.protoConversionFailed } - return try Result(proto.serializedData()) + return try Result { try proto.serializedData() } .map { serialisedData -> Data in switch destination { case .closedGroup(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: @@ -427,14 +427,14 @@ public final class MessageSender { switch (destination, namespace) { /// Updated group messages should be wrapped _before_ encrypting case (.closedGroup(let groupId), .groupMessages) where (try? SessionId.Prefix(from: groupId)) == .group: - let messageData: Data = try Result( - MessageWrapper.wrap( + let messageData: Data = try Result { + try MessageWrapper.wrap( type: .closedGroupMessage, timestampMs: sentTimestampMs, content: plaintext, wrapInWebSocketMessage: false ) - ) + } .mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) } .successOrThrow() @@ -459,7 +459,7 @@ public final class MessageSender { ) ) - return try Result( + return try Result { try MessageWrapper.wrap( type: try { switch destination { @@ -477,7 +477,7 @@ public final class MessageSender { }(), content: ciphertext ) - ) + } .mapError { MessageSenderError.other(nil, "Couldn't wrap message", $0) } .successOrThrow() diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index b0a0fef35b..a6629109fc 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -990,11 +990,12 @@ public extension PendingAttachment { /// every frame which would have a negative impact on sending things like GIF attachments since it's fairly slow) if operations.contains(.stripImageMetadata) && !utType.isAnimated { let outputData: NSMutableData = NSMutableData() - + let options: CFDictionary? = dependencies[singleton: .mediaDecoder].defaultImageOptions + guard let source: CGImageSource = targetSource.createImageSource(), let sourceType: String = CGImageSourceGetType(source) as? String, - let cgImage: CGImage = CGImageSourceCreateImageAtIndex(source, 0, nil), + let cgImage: CGImage = CGImageSourceCreateImageAtIndex(source, 0, options), let destination = CGImageDestinationCreateWithData(outputData as CFMutableData, sourceType as CFString, 1, nil) else { throw AttachmentError.invalidData } @@ -1348,10 +1349,6 @@ public extension PendingAttachment { let task: Task = Task.detached(priority: .userInitiated) { /// Extract the source let imageSource: CGImageSource - let options: CFDictionary = [ - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false - ] as CFDictionary let targetSize: CGSize = ( targetMaxDimension.map { CGSize(width: $0, height: $0) } ?? metadata.pixelSize @@ -1375,17 +1372,17 @@ public extension PendingAttachment { let data = image.pngData() else { throw AttachmentError.invalidData } - imageSource = try CGImageSourceCreateWithData(data as CFData, options) ?? { + imageSource = try dependencies[singleton: .mediaDecoder].source(for: data) ?? { throw AttachmentError.invalidData }() case .url(let url): - imageSource = try CGImageSourceCreateWithURL(url as CFURL, options) ?? { + imageSource = try dependencies[singleton: .mediaDecoder].source(for: url) ?? { throw AttachmentError.invalidData }() case .data(_, let data): - imageSource = try CGImageSourceCreateWithData(data as CFData, options) ?? { + imageSource = try dependencies[singleton: .mediaDecoder].source(for: data) ?? { throw AttachmentError.invalidData }() @@ -1393,6 +1390,7 @@ public extension PendingAttachment { } /// Process frames in parallel (in batches) to balance performance and memory usage + let options: CFDictionary? = dependencies[singleton: .mediaDecoder].defaultImageOptions let estimatedFrameMemory: CGFloat = (targetSize.width * targetSize.height * 4) let batchSize: Int = max(2, min(8, Int(50_000_000 / estimatedFrameMemory))) var frames: [CGImage] = [] diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index adbb1518c3..6085efd5b8 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -318,9 +318,9 @@ public class DisplayPictureManager { ) /// Clean up the file after the upload completes - defer { - try? dependencies[singleton: .fileManager].removeItem(atPath: attachment.filePath) - } + defer { try? dependencies[singleton: .fileManager].removeItem(atPath: attachment.filePath) } + + try Task.checkCancellation() /// Ensure we have an encryption key for the `PreparedAttachment` we want to use as a display picture guard let encryptionKey: Data = attachment.attachment.encryptionKey else { @@ -346,6 +346,8 @@ public class DisplayPictureManager { catch NetworkError.maxFileSizeExceeded { throw AttachmentError.fileSizeTooLarge } catch { throw AttachmentError.uploadFailed } + try Task.checkCancellation() + /// Generate the `downloadUrl` and move the temporary file to it's expected destination /// /// **Note:** Display pictures are currently stored unencrypted so we need to move the original `preparedAttachment` @@ -362,8 +364,8 @@ public class DisplayPictureManager { ) /// Load the data into the `imageDataManager` (assuming we will use it elsewhere in the UI) - Task.detached(priority: .userInitiated) { [imageDataManager = dependencies[singleton: .imageDataManager]] in - await imageDataManager.load(.url(URL(fileURLWithPath: finalFilePath))) + Task.detached(priority: .userInitiated) { [dependencies] in + await dependencies[singleton: .imageDataManager].load(.url(URL(fileURLWithPath: finalFilePath))) } return (downloadUrl, finalFilePath, encryptionKey) diff --git a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift index 9617ca135f..705ce13d6d 100644 --- a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift @@ -40,11 +40,6 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) } ) - @TestState var imageData: Data! = Data( - hex: "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c" + - "489000000017352474200aece1ce90000000d49444154185763f8cfc0f01f0005000" + - "1ffa65c9b5d0000000049454e44ae426082" - ) @TestState var encryptionKey: Data! = Data(hex: "c8e52eb1016702a663ac9a1ab5522daa128ab40762a514de271eddf598e3b8d4") @TestState var encryptedData: Data! = Data( hex: "778921bdd0e432227b53ee49c23421aeb796b7e5663468ff79daffb1af08cd1" + @@ -75,7 +70,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { crypto.when { $0.generate(.uuid()) }.thenReturn(UUID(uuidString: "00000000-0000-0000-0000-000000001234")) crypto .when { $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) } - .thenReturn(imageData) + .thenReturn(TestConstants.validImageData) crypto.when { $0.generate(.hash(message: .any, length: .any)) }.thenReturn("TestHash".bytes) crypto .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } @@ -96,12 +91,14 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { .thenReturn(Array(Data(hex: TestConstants.serverPublicKey))) } ) - @TestState(singleton: .imageDataManager, in: dependencies) var mockImageDataManager: MockImageDataManager! = MockImageDataManager( initialSetup: { imageDataManager in imageDataManager .when { await $0.load(.any) } .thenReturn(nil) + imageDataManager + .when { await $0.removeImage(identifier: .any) } + .thenReturn(()) } ) @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( @@ -611,7 +608,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { // MARK: -- checking if a downloaded display picture is valid context("checking if a downloaded display picture is valid") { - @TestState var jobResult: JobRunner.JobResult! = .notFound + @TestState var jobResult: JobRunner.JobResult? beforeEach { profile = Profile( @@ -673,7 +670,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { beforeEach { mockCrypto .when { $0.generate(.legacyDecryptedDisplayPicture(data: .any, key: .any)) } - .thenReturn(Data([1, 2, 3])) + .thenReturn(TestConstants.invalidImageData) } // MARK: ------ does not save the picture @@ -710,7 +707,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { .to(call(.exactly(times: 1), matchingParameters: .all) { mockFileManager in mockFileManager.createFile( atPath: "/test/DisplayPictures/5465737448617368", - contents: imageData, + contents: TestConstants.validImageData, attributes: nil ) }) @@ -877,7 +874,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { $0.createFile( atPath: "/test/DisplayPictures/5465737448617368", - contents: imageData, + contents: TestConstants.validImageData, attributes: nil ) }) @@ -1134,7 +1131,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { requestAndPathBuildTimeout: .any ) } - .thenReturn(MockNetwork.response(data: imageData)) + .thenReturn(MockNetwork.response(data: TestConstants.validImageData)) } // MARK: ------ that does not exist @@ -1202,7 +1199,7 @@ class DisplayPictureDownloadJobSpec: AsyncSpec { expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { $0.createFile( atPath: "/test/DisplayPictures/5465737448617368", - contents: imageData, + contents: TestConstants.validImageData, attributes: nil ) }) diff --git a/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift b/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift index 6d709ef176..693a81bc63 100644 --- a/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift @@ -13,7 +13,7 @@ extension Job: @retroactive MutableIdentifiable { public mutating func setId(_ id: Int64?) { self.id = id } } -class MessageSendJobSpec: QuickSpec { +class MessageSendJobSpec: AsyncSpec { override class func spec() { // MARK: Configuration @@ -24,7 +24,8 @@ class MessageSendJobSpec: QuickSpec { variant: .standard, state: .failedDownload, contentType: "text/plain", - byteCount: 200 + byteCount: 200, + downloadUrl: "http://localhost" ) @TestState var interactionAttachment: InteractionAttachment! @TestState var dependencies: TestDependencies! = TestDependencies { dependencies in @@ -357,10 +358,6 @@ class MessageSendJobSpec: QuickSpec { it("it defers when trying to send with an attachment which is still pending upload") { var didDefer: Bool = false - mockStorage.write { db in - try attachment.with(state: .uploading, using: dependencies).upsert(db) - } - MessageSendJob.run( job, scheduler: DispatchQueue.main, @@ -370,7 +367,7 @@ class MessageSendJobSpec: QuickSpec { using: dependencies ) - expect(didDefer).to(beTrue()) + await expect(didDefer).toEventually(beTrue()) } // MARK: -------- it defers when trying to send with an uploaded attachment that has an invalid downloadUrl @@ -431,8 +428,8 @@ class MessageSendJobSpec: QuickSpec { using: dependencies ) - expect(mockJobRunner) - .to(call(.exactly(times: 1), matchingParameters: .all) { + await expect(mockJobRunner) + .toEventually(call(.exactly(times: 1), matchingParameters: .all) { $0.insert( .any, job: Job( @@ -463,8 +460,8 @@ class MessageSendJobSpec: QuickSpec { using: dependencies ) - expect(mockStorage.read { db in try JobDependencies.fetchOne(db) }) - .to(equal(JobDependencies(jobId: 54321, dependantId: 1000))) + await expect(mockStorage.read { db in try JobDependencies.fetchOne(db) }) + .toEventually(equal(JobDependencies(jobId: 54321, dependantId: 1000))) } } } diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift index e8355f1c8b..25902e1a88 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift @@ -437,7 +437,7 @@ class MessageReceiverGroupsSpec: QuickSpec { shouldBeUnique: true, details: DisplayPictureDownloadJob.Details( target: .profile( - id: "051111111111111111111111111111111" + "111111111111111111111111111111111", + id: "TestProfileId", url: "https://www.oxen.io/1234", encryptionKey: Data((0.. = try Network.FileServer .preparedUpload(data: TestConstants.validImageData, using: dependencies) @@ -628,6 +660,8 @@ class MessageSenderGroupsSpec: AsyncSpec { context("with an image") { // MARK: ------ uploads the image it("uploads the image") { + // Prevent the ConfigSyncJob network request by making the libSession cache appear empty + mockLibSessionCache.when { $0.isEmpty }.thenReturn(true) mockNetwork .when { $0.send( @@ -639,8 +673,9 @@ class MessageSenderGroupsSpec: AsyncSpec { ) } .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1", uploaded: nil, expires: nil))) + mockFileManager.when { $0.contents(atPath: .any) }.thenReturn(TestConstants.validImageData) - await expect { + let result = await Result { try await MessageSender.createGroup( name: "TestGroupName", description: nil, @@ -651,7 +686,8 @@ class MessageSenderGroupsSpec: AsyncSpec { ], using: dependencies ) - }.toEventuallyNot(throwError()) + } + expect { try result.get() }.toNot(throwError()) let expectedRequest: Network.PreparedRequest = try Network.FileServer .preparedUpload( @@ -688,7 +724,7 @@ class MessageSenderGroupsSpec: AsyncSpec { } .thenReturn(MockNetwork.response(with: FileUploadResponse(id: "1", uploaded: nil, expires: nil))) - await expect { + let result = await Result { try await MessageSender.createGroup( name: "TestGroupName", description: nil, @@ -699,7 +735,8 @@ class MessageSenderGroupsSpec: AsyncSpec { ], using: dependencies ) - }.toEventuallyNot(throwError()) + } + expect { try result.get() }.toNot(throwError()) let groups: [ClosedGroup]? = mockStorage.read { db in try ClosedGroup.fetchAll(db) } @@ -722,7 +759,7 @@ class MessageSenderGroupsSpec: AsyncSpec { } .thenReturn(Fail(error: NetworkError.unknown).eraseToAnyPublisher()) - await expect { + let result = await Result { try await MessageSender.createGroup( name: "TestGroupName", description: nil, @@ -733,7 +770,8 @@ class MessageSenderGroupsSpec: AsyncSpec { ], using: dependencies ) - }.toEventually(throwError(AttachmentError.uploadFailed)) + } + expect { try result.get() }.to(throwError(AttachmentError.uploadFailed)) } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockImageDataManager.swift b/SessionMessagingKitTests/_TestUtilities/MockImageDataManager.swift index 78693da700..3e177fcd41 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockImageDataManager.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockImageDataManager.swift @@ -8,14 +8,14 @@ import SessionUIKit class MockImageDataManager: Mock, ImageDataManagerType { @discardableResult func load( _ source: ImageDataManager.DataSource - ) async -> ImageDataManager.ProcessedImageData? { + ) async -> ImageDataManager.FrameBuffer? { return mock(args: [source]) } @MainActor func load( _ source: ImageDataManager.DataSource, - onComplete: @MainActor @escaping (ImageDataManager.ProcessedImageData?) -> Void + onComplete: @MainActor @escaping (ImageDataManager.FrameBuffer?) -> Void ) { mockNoReturn(args: [source], untrackedArgs: [onComplete]) } @@ -24,7 +24,7 @@ class MockImageDataManager: Mock, ImageDataManagerType { mockNoReturn(args: [image, identifier]) } - func cachedImage(identifier: String) async -> ImageDataManager.ProcessedImageData? { + func cachedImage(identifier: String) async -> ImageDataManager.FrameBuffer? { return mock(args: [identifier]) } diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index c3a809c184..3f0139e73b 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -735,4 +735,20 @@ private struct SAESNUIKitConfig: SNUIKit.ConfigType { return (result.asset, MediaUtils.isValidVideo(asset: result.asset), result.cleanup) } + + func mediaDecoderDefaultImageOptions() -> CFDictionary { + return dependencies[singleton: .mediaDecoder].defaultImageOptions + } + + func mediaDecoderDefaultThumbnailOptions(maxDimension: CGFloat) -> CFDictionary { + return dependencies[singleton: .mediaDecoder].defaultThumbnailOptions(maxDimension: maxDimension) + } + + func mediaDecoderSource(for url: URL) -> CGImageSource? { + return dependencies[singleton: .mediaDecoder].source(for: url) + } + + func mediaDecoderSource(for data: Data) -> CGImageSource? { + return dependencies[singleton: .mediaDecoder].source(for: data) + } } diff --git a/SessionTests/Database/DatabaseSpec.swift b/SessionTests/Database/DatabaseSpec.swift index 662bb23e3b..c161a5cf2a 100644 --- a/SessionTests/Database/DatabaseSpec.swift +++ b/SessionTests/Database/DatabaseSpec.swift @@ -35,6 +35,7 @@ class DatabaseSpec: QuickSpec { userSessionId: SessionId(.standard, hex: TestConstants.publicKey), using: dependencies ) + @TestState(singleton: .mediaDecoder, in: dependencies) var mockMediaDecoder: MockMediaDecoder! = MockMediaDecoder(initialSetup: { $0.defaultInitialSetup() }) @TestState var initialResult: Result! = nil @TestState var finalResult: Result! = nil diff --git a/SessionTests/Onboarding/OnboardingSpec.swift b/SessionTests/Onboarding/OnboardingSpec.swift index 568426309e..0b1416a1b0 100644 --- a/SessionTests/Onboarding/OnboardingSpec.swift +++ b/SessionTests/Onboarding/OnboardingSpec.swift @@ -608,23 +608,13 @@ class OnboardingSpec: AsyncSpec { ])) } - // MARK: -- creates a profile record for the current user - it("creates a profile record for the current user") { + // MARK: -- does not insert a profile record into the database for the current user + it("does not insert a profile record into the database for the current user") { let result: [Profile]? = mockStorage.read { db in try Profile.fetchAll(db) } - expect(result).to(equal([ - Profile( - id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", - name: "TestCompleteName", - nickname: nil, - displayPictureUrl: nil, - displayPictureEncryptionKey: nil, - profileLastUpdated: 1234567890, - blocksCommunityMessageRequests: nil - ) - ])) + expect(result).to(beEmpty()) } // MARK: -- creates a thread for Note to Self @@ -652,17 +642,19 @@ class OnboardingSpec: AsyncSpec { // MARK: -- has the correct profile in libSession it("has the correct profile in libSession") { - expect(dependencies.mutate(cache: .libSession) { $0.profile }).to(equal( + let profile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } + expect(profile).to(equal( Profile( id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", name: "TestCompleteName", nickname: nil, displayPictureUrl: nil, displayPictureEncryptionKey: nil, - profileLastUpdated: nil, + profileLastUpdated: profile.profileLastUpdated, blocksCommunityMessageRequests: nil ) )) + expect(profile.profileLastUpdated).toNot(beNil()) } // MARK: -- saves a config dump to the database diff --git a/SessionUIKit/Configuration.swift b/SessionUIKit/Configuration.swift index 856a8bbfa7..b6545140cb 100644 --- a/SessionUIKit/Configuration.swift +++ b/SessionUIKit/Configuration.swift @@ -19,6 +19,11 @@ public actor SNUIKit { func removeCachedContextualActionInfo(tableViewHash: Int, keys: [String]) func shouldShowStringKeys() -> Bool func assetInfo(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, isValidVideo: Bool, cleanup: () -> Void)? + + func mediaDecoderDefaultImageOptions() -> CFDictionary + func mediaDecoderDefaultThumbnailOptions(maxDimension: CGFloat) -> CFDictionary + func mediaDecoderSource(for url: URL) -> CGImageSource? + func mediaDecoderSource(for data: Data) -> CGImageSource? } @MainActor public static var mainWindow: UIWindow? = nil @@ -73,4 +78,20 @@ public actor SNUIKit { return config.assetInfo(for: path, utType: utType, sourceFilename: sourceFilename) } + + internal static func mediaDecoderDefaultImageOptions() -> CFDictionary? { + return config?.mediaDecoderDefaultImageOptions() + } + + internal static func mediaDecoderDefaultThumbnailOptions(maxDimension: CGFloat) -> CFDictionary? { + return config?.mediaDecoderDefaultThumbnailOptions(maxDimension: maxDimension) + } + + internal static func mediaDecoderSource(for url: URL) -> CGImageSource? { + return config?.mediaDecoderSource(for: url) + } + + internal static func mediaDecoderSource(for data: Data) -> CGImageSource? { + return config?.mediaDecoderSource(for: data) + } } diff --git a/SessionUIKit/Types/ImageDataManager.swift b/SessionUIKit/Types/ImageDataManager.swift index c0164dcca3..41fd9594a3 100644 --- a/SessionUIKit/Types/ImageDataManager.swift +++ b/SessionUIKit/Types/ImageDataManager.swift @@ -425,22 +425,11 @@ public actor ImageDataManager: ImageDataManagerType { ) -> CGImage? { /// If we don't have a `maxDimension` then we should just load the full image guard let maxDimension: CGFloat = maxDimensionInPixels else { - let options: CFDictionary = [ - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false - ] as CFDictionary - - return CGImageSourceCreateImageAtIndex(source, index, options) + return CGImageSourceCreateImageAtIndex(source, index, SNUIKit.mediaDecoderDefaultImageOptions()) } /// Otherwise we should create a thumbnail - let options: CFDictionary = [ - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false, - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceCreateThumbnailWithTransform: true, - kCGImageSourceThumbnailMaxPixelSize: maxDimension - ] as CFDictionary + let options: CFDictionary? = SNUIKit.mediaDecoderDefaultThumbnailOptions(maxDimension: maxDimension) return CGImageSourceCreateThumbnailAtIndex(source, index, options) } @@ -634,15 +623,10 @@ public extension ImageDataManager { } public func createImageSource() -> CGImageSource? { - let finalOptions: CFDictionary = [ - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false - ] as CFDictionary - switch self { - case .url(let url): return CGImageSourceCreateWithURL(url as CFURL, finalOptions) - case .data(_, let data): return CGImageSourceCreateWithData(data as CFData, finalOptions) - case .urlThumbnail(let url, _, _): return CGImageSourceCreateWithURL(url as CFURL, finalOptions) + case .url(let url): return SNUIKit.mediaDecoderSource(for: url) + case .data(_, let data): return SNUIKit.mediaDecoderSource(for: data) + case .urlThumbnail(let url, _, _): return SNUIKit.mediaDecoderSource(for: url) // These cases have special handling which doesn't use `createImageSource` case .icon, .image, .videoUrl, .placeholderIcon, .asyncSource: return nil diff --git a/SessionUtilitiesKit/Crypto/Crypto.swift b/SessionUtilitiesKit/Crypto/Crypto.swift index 60c37d0f56..16d0259aba 100644 --- a/SessionUtilitiesKit/Crypto/Crypto.swift +++ b/SessionUtilitiesKit/Crypto/Crypto.swift @@ -32,7 +32,7 @@ public extension CryptoType { } func generateResult(_ generator: Crypto.Generator) -> Result { - return Result(try tryGenerate(generator)) + return Result { try tryGenerate(generator) } } } diff --git a/SessionUtilitiesKit/Media/MediaUtils.swift b/SessionUtilitiesKit/Media/MediaUtils.swift index d69232c033..143a01d26d 100644 --- a/SessionUtilitiesKit/Media/MediaUtils.swift +++ b/SessionUtilitiesKit/Media/MediaUtils.swift @@ -5,6 +5,15 @@ import UIKit import AVFoundation +// MARK: - Singleton + +public extension Singleton { + static let mediaDecoder: SingletonConfig = Dependencies.create( + identifier: "mediaDecoder", + createInstance: { _ in MediaDecoder() } + ) +} + // MARK: - Log.Category public extension Log.Category { @@ -326,14 +335,9 @@ public enum MediaUtils { } /// Load the image source and use that initializer to extract the metadata - let options: CFDictionary = [ - kCGImageSourceShouldCache: false, - kCGImageSourceShouldCacheImmediately: false - ] as CFDictionary - guard let fileSize: UInt64 = dependencies[singleton: .fileManager].fileSize(of: path), - let imageSource = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, options), + let imageSource = dependencies[singleton: .mediaDecoder].source(forPath: path), let metadata: MediaMetadata = MediaMetadata(source: imageSource, fileSize: fileSize) else { return nil } @@ -456,3 +460,45 @@ private extension UIImage.Orientation { } } } + +// MARK: - MediaDecoder + +public protocol MediaDecoderType { + var defaultImageOptions: CFDictionary { get } + + func defaultThumbnailOptions(maxDimension: CGFloat) -> CFDictionary + + func source(for url: URL) -> CGImageSource? + func source(for data: Data) -> CGImageSource? +} + +public extension MediaDecoderType { + func source(forPath path: String) -> CGImageSource? { + return source(for: URL(fileURLWithPath: path)) + } +} + +public final class MediaDecoder: MediaDecoderType { + public let defaultImageOptions: CFDictionary = [ + kCGImageSourceShouldCache: false, + kCGImageSourceShouldCacheImmediately: false + ] as CFDictionary + + public func defaultThumbnailOptions(maxDimension: CGFloat) -> CFDictionary { + return [ + kCGImageSourceShouldCache: false, + kCGImageSourceShouldCacheImmediately: false, + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maxDimension + ] as CFDictionary + } + + public func source(for url: URL) -> CGImageSource? { + return CGImageSourceCreateWithURL(url as CFURL, defaultImageOptions) + } + + public func source(for data: Data) -> CGImageSource? { + return CGImageSourceCreateWithData(data as CFData, defaultImageOptions) + } +} diff --git a/SessionUtilitiesKit/Media/UTType+Utilities.swift b/SessionUtilitiesKit/Media/UTType+Utilities.swift index 1093ebaeb1..3f3d70bf7c 100644 --- a/SessionUtilitiesKit/Media/UTType+Utilities.swift +++ b/SessionUtilitiesKit/Media/UTType+Utilities.swift @@ -158,9 +158,9 @@ public extension UTType { self = result } - init?(imageData: Data) { + init?(imageData: Data, using dependencies: Dependencies) { guard - let imageSource: CGImageSource = CGImageSourceCreateWithData(imageData as CFData, nil), + let imageSource: CGImageSource = dependencies[singleton: .mediaDecoder].source(for: imageData), let typeString: String = CGImageSourceGetType(imageSource) as? String, let result: UTType = UTType(typeString) else { return nil } diff --git a/SessionUtilitiesKit/Utilities/Result+Utilities.swift b/SessionUtilitiesKit/Utilities/Result+Utilities.swift index 69b3d09444..116de5dcdc 100644 --- a/SessionUtilitiesKit/Utilities/Result+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/Result+Utilities.swift @@ -3,9 +3,9 @@ import Foundation public extension Result where Failure == Error { - init(_ closure: @autoclosure () throws -> Success) { - do { self = Result.success(try closure()) } - catch { self = Result.failure(error) } + init(catching closure: () async throws -> Success) async { + do { self = .success(try await closure()) } + catch { self = .failure(error) } } func onFailure(closure: (Failure) -> ()) -> Result { diff --git a/_SharedTestUtilities/MockFileManager.swift b/_SharedTestUtilities/MockFileManager.swift index 8b5606e2e0..0c8e6c9ba7 100644 --- a/_SharedTestUtilities/MockFileManager.swift +++ b/_SharedTestUtilities/MockFileManager.swift @@ -119,10 +119,14 @@ extension Mock where T == FileManagerType { self.when { try $0.protectFileOrFolder(at: .any, fileProtectionType: .any) }.thenReturn(()) self.when { $0.fileExists(atPath: .any) }.thenReturn(false) self.when { $0.fileExists(atPath: .any, isDirectory: .any) }.thenReturn(false) + self.when { $0.fileSize(of: .any) }.thenReturn(1024) self.when { $0.isLocatedInTemporaryDirectory(.any) }.thenReturn(false) self.when { $0.temporaryFilePath(fileExtension: .any) }.thenReturn("tmpFile") self.when { $0.createFile(atPath: .any, contents: .any, attributes: .any) }.thenReturn(true) + self.when { try $0.write(dataToTemporaryFile: .any) }.thenReturn("tmpFile") + self.when { try $0.write(data: .any, toPath: .any) }.thenReturn(()) self.when { try $0.setAttributes(.any, ofItemAtPath: .any) }.thenReturn(()) + self.when { try $0.copyItem(atPath: .any, toPath: .any) }.thenReturn(()) self.when { try $0.moveItem(atPath: .any, toPath: .any) }.thenReturn(()) self.when { _ = try $0.replaceItem( diff --git a/_SharedTestUtilities/MockMediaDecoder.swift b/_SharedTestUtilities/MockMediaDecoder.swift new file mode 100644 index 0000000000..0d45fb1db5 --- /dev/null +++ b/_SharedTestUtilities/MockMediaDecoder.swift @@ -0,0 +1,36 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import ImageIO + +@testable import SessionUtilitiesKit + +class MockMediaDecoder: Mock, MediaDecoderType { + var defaultImageOptions: CFDictionary { mock() } + + func defaultThumbnailOptions(maxDimension: CGFloat) -> CFDictionary { + return mock(args: [maxDimension]) + } + + func source(for url: URL) -> CGImageSource? { return mock(args: [url]) } + func source(for data: Data) -> CGImageSource? { return mock(args: [data]) } +} + +extension Mock where T == MediaDecoderType { + func defaultInitialSetup() { + let options: CFDictionary = [ + kCGImageSourceShouldCache: false, + kCGImageSourceShouldCacheImmediately: false + ] as CFDictionary + + self.when { $0.defaultImageOptions }.thenReturn(options) + self.when { $0.defaultThumbnailOptions(maxDimension: .any) }.thenReturn(options) + + self + .when { $0.source(for: URL.any) } + .thenReturn(CGImageSourceCreateWithData(TestConstants.validImageData as CFData, options)) + self + .when { $0.source(for: Data.any) } + .thenReturn(CGImageSourceCreateWithData(TestConstants.validImageData as CFData, options)) + } +} diff --git a/_SharedTestUtilities/Mocked.swift b/_SharedTestUtilities/Mocked.swift index 4d4aa57415..8458834245 100644 --- a/_SharedTestUtilities/Mocked.swift +++ b/_SharedTestUtilities/Mocked.swift @@ -34,6 +34,7 @@ extension Dictionary: Mocked { static var mock: Self { [:] } } extension Array: Mocked { static var mock: Self { [] } } extension Set: Mocked { static var mock: Self { [] } } extension Float: Mocked { static var mock: Float { 0 } } +extension CGFloat: Mocked { static var mock: CGFloat { 0 } } extension Double: Mocked { static var mock: Double { 0 } } extension String: Mocked { static var mock: String { "" } } extension Data: Mocked { static var mock: Data { Data() } } diff --git a/_SharedTestUtilities/TestConstants.swift b/_SharedTestUtilities/TestConstants.swift index 0bd76aa6b7..3a51bb1487 100644 --- a/_SharedTestUtilities/TestConstants.swift +++ b/_SharedTestUtilities/TestConstants.swift @@ -1,6 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation +import UIKit enum TestConstants { // Test keys (from here https://github.com/jagerman/session-pysogs/blob/docs/contrib/auth-example.py) @@ -16,24 +16,17 @@ enum TestConstants { static let serverPublicKey: String = "c3b3c6f32f0ab5a57f853cc4f30f5da7fda5624b0c77b3fb0829de562ada081d" static let invalidImageData: Data = Data([1, 2, 3]) - static let validImageData: Data = Data(hex: "ffd8ffe000104a46494600010100004800480000ffe1008045" + - "78696600004d4d002a000000080005011200030000000100010000011a0005000000010000004a011b000500000001" + - "0000005201280003000000010002000087690004000000010000005a00000000000000480000000100000048000000" + - "010002a00200040000000100000001a0030004000000010000000100000000ffed003850686f746f73686f7020332e" + - "30003842494d04040000000000003842494d0425000000000010d41d8cd98f00b204e9800998ecf8427effc0001108" + - "0001000103011100021101031101ffc4001f0000010501010101010100000000000000000102030405060708090a0b" + - "ffc400b5100002010303020403050504040000017d01020300041105122131410613516107227114328191a1082342" + - "b1c11552d1f02433627282090a161718191a25262728292a3435363738393a434445464748494a535455565758595a" + - "636465666768696a737475767778797a838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6" + - "b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae1e2e3e4e5e6e7e8e9eaf1f2f3f4f5f6f7f8f9faffc4001f01" + - "00030101010101010101010000000000000102030405060708090a0bffc400b5110002010204040304070504040001" + - "0277000102031104052131061241510761711322328108144291a1b1c109233352f0156272d10a162434e125f11718" + - "191a262728292a35363738393a434445464748494a535455565758595a636465666768696a737475767778797a8283" + - "8485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5" + - "d6d7d8d9dae2e3e4e5e6e7e8e9eaf2f3f4f5f6f7f8f9faffdb00430001010101010101010101010101010101010101" + - "010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101ffdb" + - "0043010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101" + - "0101010101010101010101010101010101010101ffdd00040001ffda000c03010002110311003f00fefe2803ffd9") + static let validImageData: Data = { + let size = CGSize(width: 1, height: 1) + UIGraphicsBeginImageContext(size) + defer { UIGraphicsEndImageContext() } + + UIColor.red.setFill() + UIRectFill(CGRect(origin: .zero, size: size)) + + let image: UIImage = UIGraphicsGetImageFromCurrentImageContext()! + return image.jpegData(compressionQuality: 1.0)! + }() } public enum TestError: Error, Equatable { From 6e593ed7388b553c735087dced593ab893b7c9c5 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 15 Oct 2025 12:13:24 +1100 Subject: [PATCH 094/162] Few very minor tweaks --- .../Settings/ThreadSettingsViewModel.swift | 17 +---------------- .../Views/SessionProBadge+Utilities.swift | 6 ++++-- .../Utilities/MentionUtilities+Attributes.swift | 6 ++++-- .../Components/ProfilePictureView.swift | 2 ++ 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index c1124f6aef..ef7d666fac 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -256,22 +256,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } else { self?.showQRCodeLightBox(for: threadViewModel) } - - // If we already have a display picture then the main profile gets the icon - return (threadViewModel.threadDisplayPictureUrl != nil ? .rightPlus : .none) - }(), - additionalProfile: threadViewModel.additionalProfile, -// additionalProfileIcon: { -// guard -// threadViewModel.threadVariant == .group && -// currentUserIsClosedGroupAdmin && -// dependencies[feature: .updatedGroupsAllowDisplayPicture] -// else { return .none } -// -// // No display picture means the dual-profile so the additionalProfile gets the icon -// return .rightPlus -// }(), - accessibility: nil + }, ) : SessionCell.Info( diff --git a/Session/Shared/Views/SessionProBadge+Utilities.swift b/Session/Shared/Views/SessionProBadge+Utilities.swift index 0c5651c35e..7fe881dd2a 100644 --- a/Session/Shared/Views/SessionProBadge+Utilities.swift +++ b/Session/Shared/Views/SessionProBadge+Utilities.swift @@ -18,8 +18,10 @@ public extension SessionProBadge.Size{ public extension SessionProBadge { func toImage(using dependencies: Dependencies) -> UIImage { - let themePrimaryColor = dependencies.mutate(cache: .libSession) { libSession -> Theme.PrimaryColor? in libSession.get(.themePrimaryColor)} - let cacheKey: String = self.size.cacheKey + ".\(themePrimaryColor.defaulting(to: .defaultPrimaryColor))" // stringlint:ignore + let themePrimaryColor: Theme.PrimaryColor = dependencies + .mutate(cache: .libSession) { $0.get(.themePrimaryColor) } + .defaulting(to: .defaultPrimaryColor) + let cacheKey: String = "\(self.size.cacheKey).\(themePrimaryColor)" // stringlint:ignore if let cachedImage = dependencies[cache: .generalUI].get(for: cacheKey) { return cachedImage diff --git a/Session/Utilities/MentionUtilities+Attributes.swift b/Session/Utilities/MentionUtilities+Attributes.swift index 6a0f9a4f1b..08169965d6 100644 --- a/Session/Utilities/MentionUtilities+Attributes.swift +++ b/Session/Utilities/MentionUtilities+Attributes.swift @@ -84,8 +84,10 @@ public extension MentionUtilities { public extension HighlightMentionView { func toImage(using dependencies: Dependencies) -> UIImage { - let themePrimaryColor = dependencies.mutate(cache: .libSession) { libSession -> Theme.PrimaryColor? in libSession.get(.themePrimaryColor)} - let cacheKey: String = "Mention.CurrentUser.\(themePrimaryColor.defaulting(to: .defaultPrimaryColor))" // stringlint:ignore + let themePrimaryColor: Theme.PrimaryColor = dependencies + .mutate(cache: .libSession) { $0.get(.themePrimaryColor) } + .defaulting(to: .defaultPrimaryColor) + let cacheKey: String = "Mention.CurrentUser.\(themePrimaryColor)" // stringlint:ignore if let cachedImage = dependencies[cache: .generalUI].get(for: cacheKey) { return cachedImage diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index 2ce35aee44..7b0b8aa960 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -456,6 +456,8 @@ public final class ProfilePictureView: UIView { backgroundView.themeBackgroundColor = (dangerMode ? .danger : .textPrimary) label.isHidden = false label.text = "\(character)" + profileIconBackgroundTopAlignConstraint.isActive = true + profileIconBackgroundBottomAlignConstraint.isActive = false case .pencil: imageView.image = Lucide.image(icon: .pencil, size: 14)?.withRenderingMode(.alwaysTemplate) From 2e189a7f9983fa0397a0bb3eeb5c6ce9bf732161 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 15 Oct 2025 12:49:58 +1100 Subject: [PATCH 095/162] Revert a lib session path change --- Session.xcodeproj/project.pbxproj | 8 ++++---- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 7b7607b85b..fb4d7ea0b7 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -8400,7 +8400,7 @@ GCC_WARN_UNUSED_VALUE = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.6; - LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util_copy"; + LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; MARKETING_VERSION = 2.14.5; ONLY_ACTIVE_ARCH = YES; @@ -8476,7 +8476,7 @@ GCC_WARN_UNUSED_VALUE = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.6; - LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util_copy"; + LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; MARKETING_VERSION = 2.14.5; ONLY_ACTIVE_ARCH = NO; @@ -8966,7 +8966,7 @@ GCC_WARN_UNUSED_VALUE = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.6; - LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util_copy"; + LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; MARKETING_VERSION = 2.14.5; ONLY_ACTIVE_ARCH = YES; @@ -9550,7 +9550,7 @@ GCC_WARN_UNUSED_VALUE = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.6; - LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util_copy"; + LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; MARKETING_VERSION = 2.14.5; ONLY_ACTIVE_ARCH = NO; diff --git a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9953553373..4c74fe5c21 100644 --- a/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Session.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SDWebImage/SDWebImage.git", "state" : { - "revision" : "34cf2423a2c4088d06a3b08655603b5bc3eeeb3a", - "version" : "5.21.2" + "revision" : "2053b120767c42a70bcba21095f34e4cfb54a75d", + "version" : "5.21.3" } }, { From 135c528f4ea4ed082b630410ad4753d42af33b76 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 15 Oct 2025 16:22:26 +1100 Subject: [PATCH 096/162] Format issue --- SessionNetworkingKit/LibSession/LibSession+Networking.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SessionNetworkingKit/LibSession/LibSession+Networking.swift b/SessionNetworkingKit/LibSession/LibSession+Networking.swift index 7b23befdfb..7c43114a6d 100644 --- a/SessionNetworkingKit/LibSession/LibSession+Networking.swift +++ b/SessionNetworkingKit/LibSession/LibSession+Networking.swift @@ -585,7 +585,7 @@ private extension Network.Destination.ServerInfo { let pathWithParams: String = Network.Destination.generatePathWithParamsAndFragments( endpoint: endpoint, queryParameters: queryParameters, - fragmentParameters: fragmentParameters, + fragmentParameters: fragmentParameters ) let port: UInt16 = UInt16(self.port ?? (targetScheme == "https" ? 443 : 80)) let headerKeys: [String] = headers.map { $0.key } From 91e2855b88b5e450d139a6d34dbdae09f612247a Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 15 Oct 2025 16:28:01 +1100 Subject: [PATCH 097/162] More CI-specific build errors -_- --- SessionMessagingKit/Utilities/DisplayPictureManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index 6085efd5b8..294436f453 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -225,7 +225,7 @@ public class DisplayPictureManager { /// The desired output for a profile picture is a `WebP` at the specified size (and `cropRect`) that is generated in under `5s` do { - let result: PreparedAttachment = try await withThrowingTaskGroup { [dependencies] group in + let result: PreparedAttachment = try await withThrowingTaskGroup(of: PreparedAttachment.self) { [dependencies] group in group.addTask { return try await attachment.prepare( operations: DisplayPictureManager.standardOperations(cropRect: cropRect), @@ -259,7 +259,7 @@ public class DisplayPictureManager { /// /// **Note:** In this case we want to ignore any error and just fallback to the original file (with metadata stripped) if attachment.utType == .gif { - let maybeResult: PreparedAttachment? = try? await withThrowingTaskGroup { [dependencies] group in + let maybeResult: PreparedAttachment? = try? await withThrowingTaskGroup(of: PreparedAttachment.self) { [dependencies] group in group.addTask { return try await attachment.prepare( operations: [ From 315a89f5eca2f69991b98a0b5e612e9085296948 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 15 Oct 2025 16:37:06 +1100 Subject: [PATCH 098/162] More CI complaints... --- .../Media Viewing & Editing/SendMediaNavigationController.swift | 2 +- SessionShareExtension/ThreadPickerVC.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Session/Media Viewing & Editing/SendMediaNavigationController.swift b/Session/Media Viewing & Editing/SendMediaNavigationController.swift index 644e37ef69..61e5e5be30 100644 --- a/Session/Media Viewing & Editing/SendMediaNavigationController.swift +++ b/Session/Media Viewing & Editing/SendMediaNavigationController.swift @@ -373,7 +373,7 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate { loadMediaTask?.cancel() loadMediaTask = Task.detached(priority: .userInitiated) { [weak self, indicator] in do { - let attachments = try await withThrowingTaskGroup { group in + let attachments: [MediaLibraryAttachment] = try await withThrowingTaskGroup(of: MediaLibraryAttachment.self) { group in mediaLibrarySelections.forEach { selection in group.addTask { try await selection.retrievalTask.value } } diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 97e1875fbe..367c3258d3 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -420,7 +420,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView /// Perform any uploads that are needed let uploadedAttachments: [(attachment: Attachment, fileId: String)] = (shareData.attachmentsNeedingUpload.isEmpty ? [] : - try await withThrowingTaskGroup { group in + try await withThrowingTaskGroup(of: (attachment: Attachment, response: FileUploadResponse).self) { group in shareData.attachmentsNeedingUpload.forEach { attachment in group.addTask { try await AttachmentUploadJob.upload( From 5730dbc80a15ade4aeaeec30dc85addf1034b423 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Wed, 15 Oct 2025 14:08:55 +0800 Subject: [PATCH 099/162] Fix non matching highlighted states --- Session/Calls/CallVC.swift | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index 617ecfc20e..3d09da53db 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -232,11 +232,6 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel .withRenderingMode(.alwaysTemplate), for: .normal ) - result.setImage( - Lucide.image(icon: .micOff, size: IconSize.medium.size)? - .withRenderingMode(.alwaysTemplate), - for: .selected - ) result.themeTintColor = (call.isMuted ? .white : .textPrimary @@ -260,11 +255,6 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel .withRenderingMode(.alwaysTemplate), for: .normal ) - result.setImage( - Lucide.image(icon: .video, size: IconSize.medium.size)? - .withRenderingMode(.alwaysTemplate), - for: .selected - ) result.themeTintColor = .textPrimary result.themeBackgroundColor = .backgroundSecondary result.layer.cornerRadius = 30 @@ -746,13 +736,16 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel : fullScreenLocalVideoView).alpha = 0 floatingViewContainer.isHidden = !call.isRemoteVideoEnabled - cameraManager.stop() videoButton.themeTintColor = .textPrimary videoButton.themeBackgroundColor = .backgroundSecondary + videoButton.setImage( + Lucide.image(icon: .videoOff, size: IconSize.medium.size)? + .withRenderingMode(.alwaysTemplate), + for: .normal + ) switchCameraButton.isEnabled = false call.isVideoEnabled = false - videoButton.isSelected = false } else { guard Permissions.requestCameraPermissionIfNeeded(using: dependencies) else { @@ -785,9 +778,13 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel cameraManager.start() videoButton.themeTintColor = .backgroundSecondary videoButton.themeBackgroundColor = .textPrimary + videoButton.setImage( + Lucide.image(icon: .video, size: IconSize.medium.size)? + .withRenderingMode(.alwaysTemplate), + for: .normal + ) switchCameraButton.isEnabled = true call.isVideoEnabled = true - videoButton.isSelected = true } @objc private func switchVideo() { @@ -834,7 +831,11 @@ final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDel call.isMuted = true } - switchAudioButton.isSelected = call.isMuted + switchAudioButton.setImage( + Lucide.image(icon: call.isMuted ? .micOff: .mic, size: IconSize.medium.size)? + .withRenderingMode(.alwaysTemplate), + for: .normal + ) } @objc private func switchRoute() { From a52ba37763ca5569c39950239d90caae646f5166 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 16 Oct 2025 08:12:03 +1100 Subject: [PATCH 100/162] Another tweak for the CI --- .../Media Viewing & Editing/SendMediaNavigationController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Session/Media Viewing & Editing/SendMediaNavigationController.swift b/Session/Media Viewing & Editing/SendMediaNavigationController.swift index 61e5e5be30..173893ae08 100644 --- a/Session/Media Viewing & Editing/SendMediaNavigationController.swift +++ b/Session/Media Viewing & Editing/SendMediaNavigationController.swift @@ -263,7 +263,7 @@ class SendMediaNavigationController: UINavigationController { if !mediaLibrarySelections.isEmpty { Task.detached(priority: .utility) { [fileManager = dependencies[singleton: .fileManager]] in - let attachmentResults = await withTaskGroup { group in + let attachmentResults = await withTaskGroup(of: Result.self) { group in mediaLibrarySelections.forEach { selection in group.addTask { await selection.retrievalTask.result } } From c26ab506125b6ffcca77d936698b402a17fb181e Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 16 Oct 2025 08:23:09 +1100 Subject: [PATCH 101/162] Fixing CI complaints --- Session/Conversations/Settings/ThreadSettingsViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index ef7d666fac..4f5ba1086d 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -256,7 +256,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } else { self?.showQRCodeLightBox(for: threadViewModel) } - }, + } ) : SessionCell.Info( From 3ae101fad090f51f5c25a80ef510d0b23b6fb0fc Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 16 Oct 2025 08:29:23 +1100 Subject: [PATCH 102/162] Fixed an IP2Country crash on launch Fixed a crash which could occur on launch due to the IP2Country warming running on the main thread and taking too long --- Session/Home/HomeVC.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index cf9e8ed989..a851b427c5 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -355,7 +355,9 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi } // Onion request path countries cache - viewModel.dependencies.warmCache(cache: .ip2Country) + Task.detached(priority: .background) { [dependencies = viewModel.dependencies] in + dependencies.warmCache(cache: .ip2Country) + } // Bind the UI to the view model bindViewModel() From 2298c343a4990a80e314b4c17fc989ba14d056b9 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 16 Oct 2025 15:59:36 +1100 Subject: [PATCH 103/162] Removed duplicate import --- .../Media Viewing & Editing/CropScaleImageViewController.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Session/Media Viewing & Editing/CropScaleImageViewController.swift b/Session/Media Viewing & Editing/CropScaleImageViewController.swift index 9def02c883..5aa5345439 100644 --- a/Session/Media Viewing & Editing/CropScaleImageViewController.swift +++ b/Session/Media Viewing & Editing/CropScaleImageViewController.swift @@ -3,7 +3,6 @@ import UIKit import MediaPlayer import SessionUIKit -import SessionUIKit import SignalUtilitiesKit import SessionUtilitiesKit From 1c72f3154523c4f28390d41c2aff9a9c77428039 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 17 Oct 2025 09:21:58 +1100 Subject: [PATCH 104/162] fix: plus icon when display picture is null --- Session/Settings/SettingsViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 70be265ed4..da6a971571 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -297,7 +297,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl switch (state.serviceNetwork, state.forceOffline) { case (.testnet, false): return .letter("T", false) // stringlint:ignore case (.testnet, true): return .letter("T", true) // stringlint:ignore - default: return .pencil + default: return (state.profile.displayPictureUrl?.isEmpty == false) ? .pencil : .rightPlus } }() ), From 5e827caf237a277ba22c10544bc35e201705c295 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 17 Oct 2025 09:48:18 +1100 Subject: [PATCH 105/162] fix: update icons to lucide in message info screens --- .../Context Menu/ContextMenuVC+Action.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 53b44ea9b5..4c6610be0c 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -66,7 +66,7 @@ extension ContextMenuVC { static func retry(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( - icon: UIImage(systemName: "arrow.triangle.2.circlepath"), + icon: Lucide.image(icon: .repeat2, size: 24), title: (cellViewModel.state == .failedToSync ? "resync".localized() : "resend".localized() @@ -77,7 +77,7 @@ extension ContextMenuVC { static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( - icon: UIImage(named: "ic_reply"), + icon: Lucide.image(icon: .reply, size: 24), title: "reply".localized(), shouldDismissInfoScreen: true, accessibilityLabel: "Reply to message" @@ -86,7 +86,7 @@ extension ContextMenuVC { static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( - icon: UIImage(named: "ic_copy"), + icon: Lucide.image(icon: .copy, size: 24), title: "copy".localized(), feedback: "copied".localized(), accessibilityLabel: "Copy text" @@ -95,7 +95,7 @@ extension ContextMenuVC { static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( - icon: UIImage(named: "ic_copy"), + icon: Lucide.image(icon: .copy, size: 24), title: "accountIDCopy".localized(), feedback: "copied".localized(), accessibilityLabel: "Copy Session ID" @@ -118,7 +118,7 @@ extension ContextMenuVC { static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( - icon: UIImage(named: "ic_download"), + icon: Lucide.image(icon: .arrowDownToLine, size: 24), title: "save".localized(), feedback: "saved".localized(), accessibilityLabel: "Save attachment" From 35898b9d3a9c9b6aa0392111e77c178944c1adbd Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 17 Oct 2025 10:01:05 +1100 Subject: [PATCH 106/162] fix: remove pro badge in note-to-self thread settings screen --- .../Settings/ThreadSettingsViewModel.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 4f5ba1086d..bea79947fc 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -306,11 +306,11 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi threadViewModel.displayName, font: .titleLarge, alignment: .center, - trailingImage: ( - (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }) ? - ("ProBadge", { [dependencies] in SessionProBadge(size: .medium).toImage(using: dependencies) }) : - nil - ) + trailingImage: { + guard !threadViewModel.threadIsNoteToSelf else { return nil } + guard (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }) else { return nil } + return ("ProBadge", { [dependencies] in SessionProBadge(size: .medium).toImage(using: dependencies) }) + }() ), styling: SessionCell.StyleInfo( alignment: .centerHugging, From 002799cce5da9cbd3f86e637c12d17edf1a8c387 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 17 Oct 2025 11:01:25 +1100 Subject: [PATCH 107/162] fix: missing pro badge at the end of strings in pro cta modal --- .../Settings/ThreadSettingsViewModel.swift | 3 ++- .../Components/SwiftUI/ProCTAModal.swift | 16 ++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index bea79947fc..2abb3f4951 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -348,7 +348,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi case .group: return .groupLimit( isAdmin: currentUserIsClosedGroupAdmin, - isSessionProActivated: (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }) + isSessionProActivated: (dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: threadId) }), + proBadgeImage: SessionProBadge(size: .mini).toImage(using: dependencies) ) default: return .generic } diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index 165a04b5be..88ef28dd6c 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -10,7 +10,7 @@ public struct ProCTAModal: View { case longerMessages case animatedProfileImage(isSessionProActivated: Bool) case morePinnedConvos(isGrandfathered: Bool) - case groupLimit(isAdmin: Bool, isSessionProActivated: Bool) + case groupLimit(isAdmin: Bool, isSessionProActivated: Bool, proBadgeImage: UIImage) // stringlint:ignore_contents public var backgroundImageName: String { @@ -23,7 +23,7 @@ public struct ProCTAModal: View { return "AnimatedProfileCTA.webp" case .morePinnedConvos: return "PinnedConversationsCTA.webp" - case .groupLimit(let isAdmin, let isSessionProActivated): + case .groupLimit(let isAdmin, let isSessionProActivated, _): switch (isAdmin, isSessionProActivated) { case (false, false): return "GroupNonAdminCTA.webp" @@ -78,7 +78,7 @@ public struct ProCTAModal: View { "proCallToActionPinnedConversationsMoreThan" .put(key: "app_pro", value: Constants.app_pro) .localized() - case .groupLimit(let isAdmin, let isSessionProActivated): + case .groupLimit(let isAdmin, let isSessionProActivated, _): switch (isAdmin, isSessionProActivated) { case (_, true): return "proGroupActivatedDescription".localized() @@ -120,7 +120,7 @@ public struct ProCTAModal: View { "proFeatureListLargerGroups".localized(), "proFeatureListLoadsMore".localized() ] - case .groupLimit(let isAdmin, let isSessionProActivated): + case .groupLimit(let isAdmin, let isSessionProActivated, _): switch (isAdmin, isSessionProActivated) { case (true, false): return [ @@ -238,7 +238,7 @@ public struct ProCTAModal: View { .font(.Headings.H4) .foregroundColor(themeColor: .textPrimary) } - } else if case .groupLimit(_, let isSessionProActivated) = variant, isSessionProActivated { + } else if case .groupLimit(_, let isSessionProActivated, _) = variant, isSessionProActivated { HStack(spacing: Values.smallSpacing) { SessionProBadge_SwiftUI(size: .large) @@ -269,10 +269,10 @@ public struct ProCTAModal: View { } if - case .groupLimit(_, let isSessionProActivated) = variant, + case .groupLimit(_, let isSessionProActivated, let proBadgeImage) = variant, isSessionProActivated { - Text(variant.subtitle) + (Text(variant.subtitle) + Text("\(proBadgeImage)")) .font(.Body.largeRegular) .foregroundColor(themeColor: .textSecondary) .multilineTextAlignment(.center) @@ -315,7 +315,7 @@ public struct ProCTAModal: View { // Buttons let onlyShowCloseButton: Bool = { - if case .groupLimit(let isAdmin, let isSessionProActivated) = variant, (!isAdmin || isSessionProActivated) { return true } + if case .groupLimit(let isAdmin, let isSessionProActivated, _) = variant, (!isAdmin || isSessionProActivated) { return true } if case .animatedProfileImage(let isSessionProActivated) = variant, isSessionProActivated { return true } return false }() From 6e82a5ba0013e32f0302e421051689023e20a902 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 17 Oct 2025 12:13:55 +1100 Subject: [PATCH 108/162] fix: user profile modal doesn't show "You" --- .../ConversationVC+Interaction.swift | 5 ++++ .../MessageInfoScreen.swift | 26 +++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 9f5e601f26..c44026ec23 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1639,6 +1639,11 @@ extension ConversationVC: dependencies[singleton: .storage].read { db in try? Profile.fetchOne(db, id: sessionId) } ) + let isCurrentUser: Bool = (viewModel.threadData.currentUserSessionIds?.contains(sessionId) == true) + guard !isCurrentUser else { + return ("you".localized(), "you".localized()) + } + return ( (profile?.displayName(for: .contact) ?? cellViewModel.authorNameSuppressedId), profile?.displayName(for: .contact, ignoringNickname: true) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 1293375e9e..f5541bd850 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -573,6 +573,25 @@ struct MessageInfoScreen: View { return messageViewModel.profile?.blocksCommunityMessageRequests != true }() + let (displayName, contactDisplayName): (String?, String?) = { + guard let sessionId: String = sessionId else { + return (messageViewModel.authorNameSuppressedId, nil) + } + + let isCurrentUser: Bool = (messageViewModel.currentUserSessionIds?.contains(sessionId) == true) + guard !isCurrentUser else { + return ("you".localized(), "you".localized()) + } + + return ( + messageViewModel.authorName, + messageViewModel.profile?.displayName( + for: messageViewModel.threadVariant, + ignoringNickname: true + ) + ) + }() + let userProfileModal: ModalHostingViewController = ModalHostingViewController( modal: UserProfileModal( info: .init( @@ -580,11 +599,8 @@ struct MessageInfoScreen: View { blindedId: blindedId, qrCodeImage: qrCodeImage, profileInfo: profileInfo, - displayName: messageViewModel.authorName, - contactDisplayName: messageViewModel.profile?.displayName( - for: messageViewModel.threadVariant, - ignoringNickname: true - ), + displayName: displayName, + contactDisplayName: contactDisplayName, isProUser: dependencies.mutate(cache: .libSession, { $0.validateProProof(for: messageViewModel.profile) }), isMessageRequestsEnabled: isMessasgeRequestsEnabled, onStartThread: self.onStartThread, From 6fbbe0eab4b7f7a55b99b1b3632f12fe3c4561a2 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 17 Oct 2025 14:24:10 +1100 Subject: [PATCH 109/162] fix: session pro badge for pro settings cell in light mode in settings screen --- Session/Settings/SettingsViewModel.swift | 3 --- Session/Shared/Views/SessionCell+AccessoryView.swift | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index da6a971571..b463d9b8c3 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -410,9 +410,6 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl id: .sessionPro, leadingAccessory: .proBadge(size: .small), title: Constants.app_pro, - styling: SessionCell.StyleInfo( - tintColor: .sessionButton_border - ), onTap: { [weak viewModel] in // TODO: Implement } diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index 84b08d0372..1b23ef69d8 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -268,7 +268,7 @@ extension SessionCell { configureQRCodeView(view, accessory) case let accessory as SessionCell.AccessoryConfig.ProBadge: - configureProBadgeView(view, tintColor: tintColor) + configureProBadgeView(view, tintColor: .primary) case let accessory as SessionCell.AccessoryConfig.Icon: configureIconView(view, accessory, tintColor: tintColor) From ca6c352eee2dc58ec6431284c20a0ae3a909027e Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 17 Oct 2025 14:50:28 +1100 Subject: [PATCH 110/162] fix: contact name bottom padding in conversation settings screen --- Session/Conversations/Settings/ThreadSettingsViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 2abb3f4951..93fe33552b 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -371,7 +371,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi tintColor: .textSecondary, customPadding: SessionCell.Padding( top: 0, - bottom: 0 + bottom: Values.largeSpacing ), backgroundStyle: .noBackground ) From 7abde5aead0e5732f0f7cd29ac22a647ebd3ab9b Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 17 Oct 2025 16:31:02 +1100 Subject: [PATCH 111/162] fix: group avatar was not expandable --- .../Settings/ThreadSettingsViewModel.swift | 22 +++++++------------ .../Components/ProfilePictureView.swift | 3 ++- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 93fe33552b..0c3823cad8 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -282,21 +282,15 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi onTapView: { [weak self] targetView in let didTapQRCodeIcon: Bool = !(targetView is ProfilePictureView) - switch (threadViewModel.threadVariant, currentUserIsClosedGroupAdmin, didTapQRCodeIcon) { - case (.group, true, _): - self?.updateGroupDisplayPicture(currentUrl: threadViewModel.threadDisplayPictureUrl) - case (.group, _, _): - break - case (_, _, true): - self?.profileImageStatus = (previous: profileImageStatus?.current, current: .qrCode) - self?.forceRefresh(type: .postDatabaseQuery) - case (_, _, false): - self?.profileImageStatus = ( - previous: profileImageStatus?.current, - current: (profileImageStatus?.current == .expanded ? .normal : .expanded) - ) - self?.forceRefresh(type: .postDatabaseQuery) + if didTapQRCodeIcon { + self?.profileImageStatus = (previous: profileImageStatus?.current, current: .qrCode) + } else { + self?.profileImageStatus = ( + previous: profileImageStatus?.current, + current: (profileImageStatus?.current == .expanded ? .normal : .expanded) + ) } + self?.forceRefresh(type: .postDatabaseQuery) } ) ), diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index 7b0b8aa960..959622b7aa 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -72,9 +72,10 @@ public final class ProfilePictureView: UIView { public var multiImageSize: CGFloat { switch self { - case .navigation, .message, .modal, .expanded: return 18 // Shouldn't be used + case .navigation, .message, .modal: return 18 // Shouldn't be used case .list: return 32 case .hero: return 80 + case .expanded: return 140 } } From c084157df1b62098f5d859300a349190d0849ba7 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 17 Oct 2025 17:09:48 +1100 Subject: [PATCH 112/162] fix: close button alignment issue on quote view --- .../Message Cells/Content Views/QuoteView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index 8a2e763385..621040eb6b 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -4,6 +4,7 @@ import UIKit import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit +import Lucide final class QuoteView: UIView { static let thumbnailSize: CGFloat = 48 @@ -241,7 +242,7 @@ final class QuoteView: UIView { if mode == .draft { // Cancel button let cancelButton = UIButton(type: .custom) - cancelButton.setImage(UIImage(named: "X")?.withRenderingMode(.alwaysTemplate), for: .normal) + cancelButton.setImage(Lucide.image(icon: .x, size: 24)?.withRenderingMode(.alwaysTemplate), for: .normal) cancelButton.themeTintColor = .textPrimary cancelButton.set(.width, to: cancelButtonSize) cancelButton.set(.height, to: cancelButtonSize) @@ -249,6 +250,8 @@ final class QuoteView: UIView { mainStackView.addArrangedSubview(cancelButton) cancelButton.center(.vertical, in: self) + mainStackView.isLayoutMarginsRelativeArrangement = true + mainStackView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 2) } } From c2714316a3b8b671102ad170700f3ac2db23f581 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 20 Oct 2025 09:27:56 +1100 Subject: [PATCH 113/162] fix: group control messages deformatted --- .../Database/Models/ClosedGroup.swift | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index da2655eb59..99131cbffa 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -442,150 +442,150 @@ public extension ClosedGroup { return "messageRequestGroupInvite" .put(key: "name", value: adminName) .put(key: "group_name", value: groupName) - .localized() + .localizedDeformatted() - case .invitedFallback: return "groupInviteYou".localized() + case .invitedFallback: return "groupInviteYou".localizedDeformatted() case .invitedAdmin(let adminName, let groupName): return "groupInviteReinvite" .put(key: "name", value: adminName) .put(key: "group_name", value: groupName) - .localized() + .localizedDeformatted() case .invitedAdminFallback(let groupName): return "groupInviteReinviteYou" .put(key: "group_name", value: groupName) - .localized() + .localizedDeformatted() case .updatedName(let name): return "groupNameNew" .put(key: "group_name", value: name) - .localized() + .localizedDeformatted() - case .updatedNameFallback: return "groupNameUpdated".localized() - case .updatedDisplayPicture: return "groupDisplayPictureUpdated".localized() + case .updatedNameFallback: return "groupNameUpdated".localizedDeformatted() + case .updatedDisplayPicture: return "groupDisplayPictureUpdated".localizedDeformatted() case .addedUsers(false, let names, false) where names.count > 2: return "groupMemberNewMultiple" .put(key: "name", value: names[0]) .put(key: "count", value: names.count - 1) - .localized() + .localizedDeformatted() case .addedUsers(false, let names, true) where names.count > 2: return "groupMemberNewHistoryMultiple" .put(key: "name", value: names[0]) .put(key: "count", value: names.count - 1) - .localized() + .localizedDeformatted() case .addedUsers(true, let names, false) where names.count > 2: return "groupInviteYouAndMoreNew" .put(key: "count", value: names.count - 1) - .localized() + .localizedDeformatted() case .addedUsers(true, let names, true) where names.count > 2: return "groupMemberNewYouHistoryMultiple" .put(key: "count", value: names.count - 1) - .localized() + .localizedDeformatted() case .addedUsers(false, let names, false) where names.count == 2: return "groupMemberNewTwo" .put(key: "name", value: names[0]) .put(key: "other_name", value: names[1]) - .localized() + .localizedDeformatted() case .addedUsers(false, let names, true) where names.count == 2: return "groupMemberNewHistoryTwo" .put(key: "name", value: names[0]) .put(key: "other_name", value: names[1]) - .localized() + .localizedDeformatted() case .addedUsers(true, let names, false) where names.count == 2: return "groupInviteYouAndOtherNew" .put(key: "other_name", value: names[1]) // The current user will always be the first name - .localized() + .localizedDeformatted() case .addedUsers(true, let names, true) where names.count == 2: return "groupMemberNewYouHistoryTwo" .put(key: "other_name", value: names[1]) // The current user will always be the first name - .localized() + .localizedDeformatted() case .addedUsers(false, let names, false): return "groupMemberNew" .put(key: "name", value: names.first ?? "anonymous".localized()) - .localized() + .localizedDeformatted() case .addedUsers(false, let names, true): return "groupMemberNewHistory" .put(key: "name", value: names.first ?? "anonymous".localized()) - .localized() + .localizedDeformatted() - case .addedUsers(true, _, false): return "groupInviteYou".localized() - case .addedUsers(true, _, true): return "groupInviteYouHistory".localized() + case .addedUsers(true, _, false): return "groupInviteYou".localizedDeformatted() + case .addedUsers(true, _, true): return "groupInviteYouHistory".localizedDeformatted() case .removedUsers(false, let names) where names.count > 2: return "groupRemovedMultiple" .put(key: "name", value: names[0]) .put(key: "count", value: names.count - 1) - .localized() + .localizedDeformatted() case .removedUsers(true, let names) where names.count > 2: return "groupRemovedYouMultiple" .put(key: "count", value: names.count - 1) - .localized() + .localizedDeformatted() case .removedUsers(false, let names) where names.count == 2: return "groupRemovedTwo" .put(key: "name", value: names[0]) .put(key: "other_name", value: names[1]) - .localized() + .localizedDeformatted() case .removedUsers(true, let names) where names.count == 2: return "groupRemovedYouTwo" .put(key: "other_name", value: names[1]) // The current user will always be the first name - .localized() + .localizedDeformatted() case .removedUsers(false, let names): return "groupRemoved" .put(key: "name", value: names.first ?? "anonymous".localized()) - .localized() + .localizedDeformatted() - case .removedUsers(true, _): return "groupRemovedYouGeneral".localized() + case .removedUsers(true, _): return "groupRemovedYouGeneral".localizedDeformatted() case .memberLeft(false, let name): return "groupMemberLeft" .put(key: "name", value: name) - .localized() + .localizedDeformatted() - case .memberLeft(true, _): return "groupMemberYouLeft".localized() + case .memberLeft(true, _): return "groupMemberYouLeft".localizedDeformatted() case .promotedUsers(false, let names) where names.count > 2: return "adminMorePromotedToAdmin" .put(key: "name", value: names[0]) .put(key: "count", value: names.count - 1) - .localized() + .localizedDeformatted() case .promotedUsers(true, let names) where names.count > 2: return "groupPromotedYouMultiple" .put(key: "count", value: names.count - 1) - .localized() + .localizedDeformatted() case .promotedUsers(false, let names) where names.count == 2: return "adminTwoPromotedToAdmin" .put(key: "name", value: names[0]) .put(key: "other_name", value: names[1]) - .localized() + .localizedDeformatted() case .promotedUsers(true, let names) where names.count == 2: return "groupPromotedYouTwo" .put(key: "other_name", value: names[1]) // The current user will always be the first name - .localized() + .localizedDeformatted() case .promotedUsers(false, let names): return "adminPromotedToAdmin" .put(key: "name", value: names.first ?? "anonymous".localized()) - .localized() + .localizedDeformatted() - case .promotedUsers(true, _): return "groupPromotedYou".localized() + case .promotedUsers(true, _): return "groupPromotedYou".localizedDeformatted() } } From 5dbc58f5ddbe2762023c92c99d75420b4cbca2f0 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 20 Oct 2025 10:25:19 +1100 Subject: [PATCH 114/162] fix: confirmation modal save button should be disabled if there is an error in setting display name --- .../Components/Modals & Toast/ConfirmationModal.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift index df625bc620..2cd0ce5f73 100644 --- a/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift +++ b/SessionUIKit/Components/Modals & Toast/ConfirmationModal.swift @@ -457,9 +457,10 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { internalOnTextChanged = { [weak textField, weak confirmButton, weak cancelButton] text, _ in onTextChanged(text) textField?.accessibilityLabel = text - confirmButton?.isEnabled = info.confirmEnabled.isValid(with: info) + let error: String? = inputInfo.inputChecker?(text) + confirmButton?.isEnabled = info.confirmEnabled.isValid(with: info) && error == nil cancelButton?.isEnabled = info.cancelEnabled.isValid(with: info) - self.updateContent(withError: inputInfo.inputChecker?(text)) + self.updateContent(withError: error) } textFieldContainer.layoutIfNeeded() From ec7cf93ed7ade7761c8f26fe57bc2884bb33fafb Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 20 Oct 2025 10:33:20 +1100 Subject: [PATCH 115/162] fix: pro badge not showing in share extension --- SessionShareExtension/SimplifiedConversationCell.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/SessionShareExtension/SimplifiedConversationCell.swift b/SessionShareExtension/SimplifiedConversationCell.swift index d4ced9d635..7d3c4d4047 100644 --- a/SessionShareExtension/SimplifiedConversationCell.swift +++ b/SessionShareExtension/SimplifiedConversationCell.swift @@ -49,11 +49,15 @@ final class SimplifiedConversationCell: UITableViewCell { return view }() - private lazy var displayNameLabel: UILabel = { - let result = UILabel() + private lazy var displayNameLabel: SessionLabelWithProBadge = { + let result = SessionLabelWithProBadge( + proBadgeSize: .mini, + withStretchingSpacer: false + ) result.font = .boldSystemFont(ofSize: Values.mediumFontSize) result.themeTextColor = .textPrimary result.lineBreakMode = .byTruncatingTail + result.isProBadgeHidden = true return result }() @@ -100,6 +104,7 @@ final class SimplifiedConversationCell: UITableViewCell { using: dependencies ) displayNameLabel.text = cellViewModel.displayName + displayNameLabel.isProBadgeHidden = !cellViewModel.isSessionPro(using: dependencies) self.isAccessibilityElement = true self.accessibilityIdentifier = "Contact" From 97b63a8a3a0ad55ea45b2197a46a09b64f3e672e Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 20 Oct 2025 10:44:48 +1100 Subject: [PATCH 116/162] fix: Session Pro wordmark in the app heading should not be flipped in RTL --- Session/Shared/BaseVC.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Session/Shared/BaseVC.swift b/Session/Shared/BaseVC.swift index 441b5bf632..531395ed97 100644 --- a/Session/Shared/BaseVC.swift +++ b/Session/Shared/BaseVC.swift @@ -96,7 +96,9 @@ public class BaseVC: UIViewController { let sessionProBadge: SessionProBadge = SessionProBadge(size: .medium) sessionProBadge.isHidden = !currentUserSessionProState.isSessionProSubject.value - let stackView: UIStackView = UIStackView(arrangedSubviews: [ headingImageView, sessionProBadge ]) + let stackView: UIStackView = UIStackView( + arrangedSubviews: MainAppContext.determineDeviceRTL() ? [ sessionProBadge, headingImageView ] : [ headingImageView, sessionProBadge ] + ) stackView.axis = .horizontal stackView.alignment = .center stackView.spacing = 0 From a0e1c1409a7cda8dfa2a089e9f390fcae6fb2229 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 20 Oct 2025 13:21:19 +1100 Subject: [PATCH 117/162] fix: incorrectly showing long message pro feature in message info screen --- Session/Media Viewing & Editing/MessageInfoScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index f5541bd850..66ccab0277 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -504,7 +504,7 @@ struct MessageInfoScreen: View { proFeatures.append("appProBadge".put(key: "app_pro", value: Constants.app_pro).localized()) } - if (messageViewModel.isProMessage || messageViewModel.body.defaulting(to: "").utf16.count > LibSession.CharacterLimit) { + if (messageViewModel.isProMessage && messageViewModel.body.defaulting(to: "").utf16.count > LibSession.CharacterLimit) { proFeatures.append("proIncreasedMessageLengthFeature".localized()) proCTAVariant = (proFeatures.count > 1 ? .generic : .longerMessages) } From 5872478f5fa93173f71f7e3fcaab791331327d84 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 20 Oct 2025 14:43:55 +1100 Subject: [PATCH 118/162] fix: incorrect pro badge position for community members --- .../Message Cells/VisibleMessageCell.swift | 8 +--- .../Components/SessionLabelWithProBadge.swift | 45 ++++++++++++++++--- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 71e8dd3353..88ba8b2853 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -342,11 +342,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { ) contentHStackTopConstraint.constant = (shouldAddTopInset ? Values.mediumSpacing : 0) - // Author label - authorLabel.isHidden = (cellViewModel.senderName == nil) - authorLabel.text = cellViewModel.senderName - authorLabel.themeTextColor = .textPrimary - let isGroupThread: Bool = ( cellViewModel.threadVariant == .community || cellViewModel.threadVariant == .legacyGroup || @@ -394,7 +389,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { // Author label authorLabel.isHidden = (cellViewModel.senderName == nil) - authorLabel.text = cellViewModel.senderName + authorLabel.text = cellViewModel.authorNameSuppressedId + authorLabel.extraText = cellViewModel.authorName.replacingOccurrences(of: cellViewModel.authorNameSuppressedId, with: "").trimmingCharacters(in: .whitespacesAndNewlines) authorLabel.themeTextColor = .textPrimary authorLabel.isProBadgeHidden = !dependencies.mutate(cache: .libSession) { $0.validateSessionProState(for: cellViewModel.authorId) } diff --git a/SessionUIKit/Components/SessionLabelWithProBadge.swift b/SessionUIKit/Components/SessionLabelWithProBadge.swift index 22d4c45e9e..d8565bdae6 100644 --- a/SessionUIKit/Components/SessionLabelWithProBadge.swift +++ b/SessionUIKit/Components/SessionLabelWithProBadge.swift @@ -5,7 +5,10 @@ import UIKit public class SessionLabelWithProBadge: UIView { public var font: UIFont { get { label.font } - set { label.font = newValue } + set { + label.font = newValue + extraLabel.font = newValue + } } public var text: String? { @@ -16,6 +19,15 @@ public class SessionLabelWithProBadge: UIView { } } + public var extraText: String? { + get { extraLabel.text } + set { + guard extraLabel.text != newValue else { return } + extraLabel.text = newValue + extraLabel.isHidden = !(newValue?.isEmpty == false) + } + } + public var themeAttributedText: ThemedAttributedString? { get { label.themeAttributedText } set { @@ -24,24 +36,44 @@ public class SessionLabelWithProBadge: UIView { } } + public var extraThemeAttributedText: ThemedAttributedString? { + get { extraLabel.themeAttributedText } + set { + guard extraLabel.themeAttributedText != newValue else { return } + extraLabel.themeAttributedText = newValue + } + } + public var themeTextColor: ThemeValue? { get { label.themeTextColor } - set { label.themeTextColor = newValue } + set { + label.themeTextColor = newValue + extraLabel.themeTextColor = newValue + } } public var textAlignment: NSTextAlignment { get { label.textAlignment } - set { label.textAlignment = newValue } + set { + label.textAlignment = newValue + extraLabel.textAlignment = newValue + } } public var lineBreakMode: NSLineBreakMode { get { label.lineBreakMode } - set { label.lineBreakMode = newValue } + set { + label.lineBreakMode = newValue + extraLabel.lineBreakMode = newValue + } } public var numberOfLines: Int { get { label.numberOfLines } - set { label.numberOfLines = newValue } + set { + label.numberOfLines = newValue + extraLabel.numberOfLines = newValue + } } public var isProBadgeHidden: Bool { @@ -54,6 +86,7 @@ public class SessionLabelWithProBadge: UIView { set { super.isUserInteractionEnabled = newValue label.isUserInteractionEnabled = newValue + extraLabel.isUserInteractionEnabled = newValue } } @@ -64,6 +97,7 @@ public class SessionLabelWithProBadge: UIView { // MARK: - UI Components private let label: SRCopyableLabel = SRCopyableLabel() + private let extraLabel: UILabel = UILabel() private lazy var sessionProBadge: SessionProBadge = { let result: SessionProBadge = SessionProBadge(size: proBadgeSize) @@ -79,6 +113,7 @@ public class SessionLabelWithProBadge: UIView { [ label, sessionProBadge, + extraLabel, withStretchingSpacer ? UIView.hStretchingSpacer() : nil ] .compactMap { $0 } From 0940dd2c26c1f5419696e0dc72c62de083f69bf3 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 20 Oct 2025 14:58:24 +1100 Subject: [PATCH 119/162] fix: pro badge in quote view --- .../LibSession/Config Handling/LibSession+Pro.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift index b550cc78e6..8cb57bab68 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift @@ -46,7 +46,11 @@ public extension LibSessionCacheType { .fetchOne(db) } guard threadVariant != .community else { return false } - return dependencies[feature: .allUsersSessionPro] + if threadId == dependencies[cache: .general].sessionId.hexString { + return dependencies[feature: .mockCurrentUserSessionPro] + } else { + return dependencies[feature: .allUsersSessionPro] + } } func shouldShowProBadge(for profile: Profile?) -> Bool { From f16107f33c196729506d02028253c29b22645894 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 20 Oct 2025 15:07:32 +1100 Subject: [PATCH 120/162] fix: message author UI in message info screen --- .../MessageInfoScreen.swift | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 66ccab0277..1e4b86839b 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -387,8 +387,8 @@ struct MessageInfoScreen: View { .font(.Body.extraLargeBold) .foregroundColor(themeColor: .textPrimary) } - else if !messageViewModel.authorName.isEmpty { - Text(messageViewModel.authorName) + else if !messageViewModel.authorNameSuppressedId.isEmpty { + Text(messageViewModel.authorNameSuppressedId) .font(.Body.extraLargeBold) .foregroundColor(themeColor: .textPrimary) } @@ -403,7 +403,19 @@ struct MessageInfoScreen: View { Text(messageViewModel.authorId) .font(.Display.base) - .foregroundColor(themeColor: .textPrimary) + .foregroundColor( + themeColor: { + if + messageViewModel.authorId.hasPrefix(SessionId.Prefix.blinded15.rawValue) || + messageViewModel.authorId.hasPrefix(SessionId.Prefix.blinded25.rawValue) + { + return .textSecondary + } + else { + return .textPrimary + } + }() + ) } } } From 908212043f012dca96832f2961a343ebb5083d81 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 20 Oct 2025 15:50:20 +1100 Subject: [PATCH 121/162] fix: message info screen failed to send status overlaps with message bubble --- Session/Media Viewing & Editing/MessageInfoScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 1e4b86839b..57d0dd4bd2 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -121,7 +121,7 @@ struct MessageInfoScreen: View { .foregroundColor(themeColor: tintColor) } } - .padding(.top, -Values.smallSpacing) + .padding(.top, -Values.verySmallSpacing) .padding(.bottom, Values.verySmallSpacing) .padding(.horizontal, Values.largeSpacing) } From edbc7b1197b3fbb748a5f7b61280745328e13b12 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 20 Oct 2025 15:57:10 +1100 Subject: [PATCH 122/162] fix: blinded id incorrectly shown in quote view --- .../Message Cells/Content Views/QuoteView.swift | 2 ++ SessionMessagingKit/Database/Models/Profile.swift | 14 +++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index 621040eb6b..16cfdc1c18 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -209,6 +209,7 @@ final class QuoteView: UIView { return Profile.displayNameNoFallback( id: authorId, threadVariant: threadVariant, + suppressId: true, using: dependencies ) } @@ -216,6 +217,7 @@ final class QuoteView: UIView { return Profile.displayName( id: authorId, threadVariant: threadVariant, + suppressId: true, using: dependencies ) }() diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index efdb275535..c2186feaf7 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -193,10 +193,11 @@ public extension Profile { _ db: ObservingDatabase, id: ID, threadVariant: SessionThread.Variant = .contact, + suppressId: Bool = false, customFallback: String? = nil ) -> String { let existingDisplayName: String? = (try? Profile.fetchOne(db, id: id))? - .displayName(for: threadVariant) + .displayName(for: threadVariant, suppressId: suppressId) return (existingDisplayName ?? (customFallback ?? id)) } @@ -204,10 +205,11 @@ public extension Profile { static func displayNameNoFallback( _ db: ObservingDatabase, id: ID, - threadVariant: SessionThread.Variant = .contact + threadVariant: SessionThread.Variant = .contact, + suppressId: Bool = false ) -> String? { return (try? Profile.fetchOne(db, id: id))? - .displayName(for: threadVariant) + .displayName(for: threadVariant, suppressId: suppressId) } // MARK: - Fetch or Create @@ -245,13 +247,14 @@ public extension Profile { static func displayName( id: ID, threadVariant: SessionThread.Variant = .contact, + suppressId: Bool = false, customFallback: String? = nil, using dependencies: Dependencies ) -> String { let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) var displayName: String? dependencies[singleton: .storage].readAsync( - retrieve: { db in Profile.displayName(db, id: id, threadVariant: threadVariant) }, + retrieve: { db in Profile.displayName(db, id: id, threadVariant: threadVariant, suppressId: suppressId) }, completion: { result in switch result { case .failure: break @@ -268,12 +271,13 @@ public extension Profile { static func displayNameNoFallback( id: ID, threadVariant: SessionThread.Variant = .contact, + suppressId: Bool = false, using dependencies: Dependencies ) -> String? { let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) var displayName: String? dependencies[singleton: .storage].readAsync( - retrieve: { db in Profile.displayNameNoFallback(db, id: id, threadVariant: threadVariant) }, + retrieve: { db in Profile.displayNameNoFallback(db, id: id, threadVariant: threadVariant, suppressId: suppressId) }, completion: { result in switch result { case .failure: break From 364aefbc3371012af10c374058be0910ac64e3e8 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 20 Oct 2025 16:20:15 +1100 Subject: [PATCH 123/162] fix: qr code generation --- Session/Settings/RecoveryPasswordScreen.swift | 32 +++++++++++-------- .../Components/SwiftUI/QRCodeView.swift | 1 - SessionUIKit/Utilities/QRCode.swift | 15 +++++---- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/Session/Settings/RecoveryPasswordScreen.swift b/Session/Settings/RecoveryPasswordScreen.swift index 1fd3034852..989a75a93b 100644 --- a/Session/Settings/RecoveryPasswordScreen.swift +++ b/Session/Settings/RecoveryPasswordScreen.swift @@ -91,19 +91,25 @@ struct RecoveryPasswordScreen: View { self.showQRCode.toggle() } } label: { - Text("recoveryPasswordView".localized()) - .bold() - .font(.system(size: Values.verySmallFontSize)) - .foregroundColor(themeColor: .textPrimary) - .frame( - maxWidth: Self.buttonWidth, - maxHeight: Values.mediumSmallButtonHeight, - alignment: .center - ) - .overlay( - Capsule() - .stroke(themeColor: .textPrimary) - ) + HStack { + Spacer() + + Text("recoveryPasswordView".localized()) + .bold() + .font(.system(size: Values.verySmallFontSize)) + .foregroundColor(themeColor: .textPrimary) + .frame( + maxHeight: Values.mediumSmallButtonHeight, + alignment: .center + ) + .padding(.horizontal, Values.mediumSmallSpacing) + .overlay( + Capsule() + .stroke(themeColor: .textPrimary) + ) + + Spacer() + } } } .frame(maxWidth: .infinity) diff --git a/SessionUIKit/Components/SwiftUI/QRCodeView.swift b/SessionUIKit/Components/SwiftUI/QRCodeView.swift index 03c310b66b..a3422a53c0 100644 --- a/SessionUIKit/Components/SwiftUI/QRCodeView.swift +++ b/SessionUIKit/Components/SwiftUI/QRCodeView.swift @@ -23,7 +23,6 @@ public struct QRCodeView: View { } static private var cornerRadius: CGFloat = 10 - static private var logoSize: CGFloat = 66 public init( qrCodeImage: UIImage?, diff --git a/SessionUIKit/Utilities/QRCode.swift b/SessionUIKit/Utilities/QRCode.swift index 3bb1828c70..4aad6e7ca7 100644 --- a/SessionUIKit/Utilities/QRCode.swift +++ b/SessionUIKit/Utilities/QRCode.swift @@ -53,13 +53,16 @@ public enum QRCode { let iconName = iconName, let icon: UIImage = UIImage(named: iconName) { - let iconPercent: CGFloat = 0.25 - let iconSize = size.width * iconPercent + let iconPercent: CGFloat = 0.2 + let iconSize: CGSize = CGSize( + width: size.width * iconPercent, + height: icon.size.height * (size.width * iconPercent) / icon.size.width + ) let iconRect = CGRect( - x: (size.width - iconSize) / 2, - y: (size.height - iconSize) / 2, - width: iconSize, - height: iconSize + x: (size.width - iconSize.width) / 2, + y: (size.height - iconSize.height) / 2, + width: iconSize.width, + height: iconSize.height ) // Clear the area under the icon From 29a474815d505b3cab1fe40b4ae31a412fdf82c3 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 20 Oct 2025 16:29:27 +1100 Subject: [PATCH 124/162] fix: light box qr code color --- .../Settings/ThreadSettingsViewModel.swift | 3 +-- .../Components/SwiftUI/UserProfileModal.swift | 3 +-- SessionUIKit/Utilities/QRCode.swift | 17 +++-------------- 3 files changed, 5 insertions(+), 18 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 0c3823cad8..4993137ec3 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -2099,9 +2099,8 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi let viewController = SessionHostingViewController( rootView: LightBox( itemsToShare: [ - QRCode.qrCodeImageWithTintAndBackground( + QRCode.qrCodeImageWithBackground( image: qrCodeImage, - themeStyle: ThemeManager.currentTheme.interfaceStyle, size: CGSize(width: 400, height: 400), insets: UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) ) diff --git a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift index e28a22c9f5..1939c19c16 100644 --- a/SessionUIKit/Components/SwiftUI/UserProfileModal.swift +++ b/SessionUIKit/Components/SwiftUI/UserProfileModal.swift @@ -366,9 +366,8 @@ public struct UserProfileModal: View { let viewController = SessionHostingViewController( rootView: LightBox( itemsToShare: [ - QRCode.qrCodeImageWithTintAndBackground( + QRCode.qrCodeImageWithBackground( image: qrCodeImage, - themeStyle: ThemeManager.currentTheme.interfaceStyle, size: CGSize(width: 400, height: 400), insets: UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) ) diff --git a/SessionUIKit/Utilities/QRCode.swift b/SessionUIKit/Utilities/QRCode.swift index 4aad6e7ca7..ee7482b0b5 100644 --- a/SessionUIKit/Utilities/QRCode.swift +++ b/SessionUIKit/Utilities/QRCode.swift @@ -80,24 +80,13 @@ public enum QRCode { return finalImage ?? qrUIImage } - public static func qrCodeImageWithTintAndBackground( + public static func qrCodeImageWithBackground( image: UIImage, - themeStyle: UIUserInterfaceStyle, size: CGSize? = nil, insets: UIEdgeInsets = .zero ) -> UIImage { - var backgroundColor: UIColor { - switch themeStyle { - case .light: return .classicDark1 - default: return .white - } - } - var tintColor: UIColor { - switch themeStyle { - case .light: return .white - default: return .classicDark1 - } - } + var backgroundColor: UIColor = .white + var tintColor: UIColor = .classicDark1 let outputSize = size ?? image.size let renderer = UIGraphicsImageRenderer(size: outputSize) From be6f840f0d42438b6f4c2900d709dee1e1b82ac6 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 20 Oct 2025 17:00:36 +1100 Subject: [PATCH 125/162] Fixed a number of issues found during QA testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added a dev action to be able to share a file by entering it's download url • Added a minor recovery mechanism for lost/missing already-download files • Dropped unused columns and a foreign key constraint from the Quote table • Updated the MessageViewModel to no longer directly query Quote data (the associated MessageViewModel.QuotedInfo retrieves it) • Fixed an issue where the current users profile may not update in all places • Fixed an issue where sharing files was incorrectly trying to share them as URLs (most obvious when trying to share the log file) • Fixed an issue where link preview images weren't being uploaded • Fixed a buggy LinkPreviewView loading state --- Session.xcodeproj/project.pbxproj | 20 +- .../Context Menu/ContextMenuVC+Action.swift | 6 +- .../ConversationVC+Interaction.swift | 81 +++-- .../Conversations/ConversationViewModel.swift | 44 ++- .../Content Views/LinkPreviewView.swift | 42 ++- .../Message Cells/VisibleMessageCell.swift | 26 +- .../Settings/ThreadSettingsViewModel.swift | 18 +- Session/Home/HomeViewModel.swift | 63 +++- .../MessageRequestsViewModel.swift | 11 +- .../MessageInfoScreen.swift | 11 +- ...DeveloperSettingsFileServerViewModel.swift | 130 +++++++ .../Views/ThemeMessagePreviewView.swift | 7 +- SessionMessagingKit/Configuration.swift | 3 +- ...moveQuoteUnusedColumnsAndForeignKeys.swift | 40 +++ .../Database/Models/Interaction.swift | 5 - .../Database/Models/LinkPreview.swift | 13 +- .../Database/Models/Quote.swift | 41 +-- .../Jobs/AttachmentDownloadJob.swift | 17 +- .../VisibleMessage+Quote.swift | 14 +- .../MessageSender+Convenience.swift | 4 +- .../Shared Models/MessageViewModel.swift | 324 ++++++++---------- .../SessionThreadViewModel.swift | 8 +- .../Utilities/AttachmentManager.swift | 100 +++--- .../NotificationsManagerSpec.swift | 3 +- .../ShareNavController.swift | 3 +- SessionShareExtension/ThreadPickerVC.swift | 16 +- .../ThreadPickerViewModel.swift | 11 + .../AttachmentApprovalViewController.swift | 8 +- .../MediaMessageView.swift | 12 +- 29 files changed, 653 insertions(+), 428 deletions(-) create mode 100644 SessionMessagingKit/Database/Migrations/_046_RemoveQuoteUnusedColumnsAndForeignKeys.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index fb4d7ea0b7..40d473c3dd 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -939,6 +939,7 @@ FD9DD2712A72516D00ECB68E /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9DD2702A72516D00ECB68E /* TestExtensions.swift */; }; FD9DD2722A72516D00ECB68E /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9DD2702A72516D00ECB68E /* TestExtensions.swift */; }; FD9DD2732A72516D00ECB68E /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9DD2702A72516D00ECB68E /* TestExtensions.swift */; }; + FD9E26AF2EA5DC7D00404C7F /* _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9E26AE2EA5DC7100404C7F /* _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift */; }; FDA335F52D91157A007E0EB6 /* SessionImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA335F42D911576007E0EB6 /* SessionImageView.swift */; }; FDAA16762AC28A3B00DDBF77 /* UserDefaultsType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA16752AC28A3B00DDBF77 /* UserDefaultsType.swift */; }; FDAA167B2AC28E2F00DDBF77 /* SnodeRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA167A2AC28E2F00DDBF77 /* SnodeRequestSpec.swift */; }; @@ -2209,6 +2210,7 @@ FD99D0912D10F5EB005D2E15 /* ThreadSafeSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafeSpec.swift; sourceTree = ""; }; FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationResolution.swift; sourceTree = ""; }; FD9DD2702A72516D00ECB68E /* TestExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestExtensions.swift; sourceTree = ""; }; + FD9E26AE2EA5DC7100404C7F /* _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift; sourceTree = ""; }; FDA335F42D911576007E0EB6 /* SessionImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionImageView.swift; sourceTree = ""; }; FDAA16752AC28A3B00DDBF77 /* UserDefaultsType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsType.swift; sourceTree = ""; }; FDAA167A2AC28E2F00DDBF77 /* SnodeRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeRequestSpec.swift; sourceTree = ""; }; @@ -4094,6 +4096,7 @@ FD05594D2E012D1A00DC48CE /* _043_RenameAttachments.swift */, 94CD95C02E0CBF1C0097754D /* _044_AddProMessageFlag.swift */, 942BA9BE2E4ABB9F007C4595 /* _045_LastProfileUpdateTimestamp.swift */, + FD9E26AE2EA5DC7100404C7F /* _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift */, ); path = Migrations; sourceTree = ""; @@ -6701,6 +6704,7 @@ FD2273082C353109004D8A6C /* DisplayPictureManager.swift in Sources */, FDE521A22E0D23AB00061B8E /* ObservableKey+SessionMessagingKit.swift in Sources */, FD2273022C352D8E004D8A6C /* LibSession+GroupInfo.swift in Sources */, + FD9E26AF2EA5DC7D00404C7F /* _046_RemoveQuoteUnusedColumnsAndForeignKeys.swift in Sources */, FDDD554E2C1FCB77006CBF03 /* _033_ScheduleAppUpdateCheckJob.swift in Sources */, FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, 94CD95C12E0CBF430097754D /* _044_AddProMessageFlag.swift in Sources */, @@ -8362,7 +8366,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 646; + CURRENT_PROJECT_VERSION = 647; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8402,7 +8406,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.5; + MARKETING_VERSION = 2.14.6; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "-Werror=protocol"; OTHER_SWIFT_FLAGS = "-D DEBUG -Xfrontend -warn-long-expression-type-checking=100"; @@ -8443,7 +8447,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 646; + CURRENT_PROJECT_VERSION = 647; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8478,7 +8482,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.5; + MARKETING_VERSION = 2.14.6; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", @@ -8929,7 +8933,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 646; + CURRENT_PROJECT_VERSION = 647; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8968,7 +8972,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.5; + MARKETING_VERSION = 2.14.6; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "-fobjc-arc-exceptions", @@ -9519,7 +9523,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 646; + CURRENT_PROJECT_VERSION = 647; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; @@ -9552,7 +9556,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.5; + MARKETING_VERSION = 2.14.6; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 53b44ea9b5..dd98833b78 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -21,7 +21,7 @@ extension ContextMenuVC { let actionType: ActionType let shouldDismissInfoScreen: Bool let accessibilityLabel: String? - let work: ((() -> Void)?) -> Void + let work: @MainActor ((@MainActor () -> Void)?) -> Void enum ActionType { case emoji @@ -41,7 +41,7 @@ extension ContextMenuVC { actionType: ActionType = .generic, shouldDismissInfoScreen: Bool = false, accessibilityLabel: String? = nil, - work: @escaping ((() -> Void)?) -> Void + work: @escaping @MainActor ((@MainActor () -> Void)?) -> Void ) { self.icon = icon self.title = title @@ -316,7 +316,7 @@ extension ContextMenuVC { protocol ContextMenuActionDelegate { func info(_ cellViewModel: MessageViewModel) - func retry(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) + @MainActor func retry(_ cellViewModel: MessageViewModel, completion: (@MainActor () -> Void)?) func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) func copy(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) func copySessionID(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 7cd063f572..5940334a16 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -783,13 +783,13 @@ extension ConversationVC: } } - private func sendMessage(optimisticData: ConversationViewModel.OptimisticMessageData) { + private func sendMessage(optimisticData: ConversationViewModel.OptimisticMessageData) async { let threadId: String = self.viewModel.threadData.threadId let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant // Actually send the message - viewModel.dependencies[singleton: .storage] - .writePublisher { [weak self, dependencies = viewModel.dependencies] db in + do { + try await viewModel.dependencies[singleton: .storage].writeAsync { [weak self, dependencies = viewModel.dependencies] db in // Update the thread to be visible (if it isn't already) if self?.viewModel.threadData.threadShouldBeVisible == false { try SessionThread.updateVisibility( @@ -831,7 +831,10 @@ extension ConversationVC: try LinkPreview( url: linkPreviewDraft.urlString, title: linkPreviewDraft.title, - attachmentId: try optimisticData.linkPreviewAttachment?.inserted(db).id, + attachmentId: try optimisticData.linkPreviewPreparedAttachment? + .attachment + .inserted(db) + .id, using: dependencies ).upsert(db) } @@ -842,8 +845,7 @@ extension ConversationVC: try Quote( interactionId: interactionId, authorId: quoteModel.authorId, - timestampMs: quoteModel.timestampMs, - body: nil + timestampMs: quoteModel.timestampMs ).insert(db) } @@ -884,36 +886,25 @@ extension ConversationVC: using: dependencies ) } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .sinkUntilComplete( - receiveCompletion: { [weak self] result in - switch result { - case .finished: break - case .failure(let error): - self?.viewModel.failedToStoreOptimisticOutgoingMessage(id: optimisticData.id, error: error) - } - - self?.handleMessageSent() - } - ) + + await handleMessageSent() + } + catch { + viewModel.failedToStoreOptimisticOutgoingMessage(id: optimisticData.id, error: error) + } } - func handleMessageSent() { + func handleMessageSent() async { if viewModel.dependencies.mutate(cache: .libSession, { $0.get(.playNotificationSoundInForeground) }) { let soundID = Preferences.Sound.systemSoundId(for: .messageSent, quiet: true) AudioServicesPlaySystemSound(soundID) } - let threadId: String = self.viewModel.threadData.threadId - - Task { - await viewModel.dependencies[singleton: .typingIndicators].didStopTyping( - threadId: threadId, - direction: .outgoing - ) - } - - viewModel.dependencies[singleton: .storage].writeAsync { db in + await viewModel.dependencies[singleton: .typingIndicators].didStopTyping( + threadId: viewModel.threadData.threadId, + direction: .outgoing + ) + try? await viewModel.dependencies[singleton: .storage].writeAsync { [threadId = viewModel.threadData.threadId] db in _ = try SessionThread .filter(id: threadId) .updateAll(db, SessionThread.Columns.messageDraft.set(to: "")) @@ -1527,23 +1518,27 @@ extension ConversationVC: let quoteViewContainsTouch: Bool = (visibleCell.quoteView?.bounds.contains(quotePoint) == true) let linkPreviewViewContainsTouch: Bool = (visibleCell.linkPreviewView?.previewView.bounds.contains(linkPreviewPoint) == true) - switch (containsLinks, quoteViewContainsTouch, linkPreviewViewContainsTouch, cellViewModel.quote, cellViewModel.linkPreview) { + switch (containsLinks, quoteViewContainsTouch, linkPreviewViewContainsTouch, cellViewModel.quotedInfo, cellViewModel.linkPreview) { // If the message contains both links and a quote, and the user tapped on the quote; OR the // message only contained a quote, then scroll to the quote - case (true, true, _, .some(let quote), _), (false, _, _, .some(let quote), _): - let maybeOriginalInteractionInfo: Interaction.TimestampInfo? = viewModel.dependencies[singleton: .storage].read { db in - try quote.originalInteraction - .select(.id, .timestampMs) - .asRequest(of: Interaction.TimestampInfo.self) + case (true, true, _, .some(let quotedInfo), _), (false, _, _, .some(let quotedInfo), _): + let maybeTimestampMs: Int64? = viewModel.dependencies[singleton: .storage].read { db in + try Interaction + .filter(id: quotedInfo.quotedInteractionId) + .select(.timestampMs) + .asRequest(of: Int64.self) .fetchOne(db) } - guard let interactionInfo: Interaction.TimestampInfo = maybeOriginalInteractionInfo else { + guard let timestampMs: Int64 = maybeTimestampMs else { return } self.scrollToInteractionIfNeeded( - with: interactionInfo, + with: Interaction.TimestampInfo( + id: quotedInfo.quotedInteractionId, + timestampMs: timestampMs + ), focusBehaviour: .highlight, originalIndexPath: self.tableView.indexPath(for: cell) ) @@ -2353,7 +2348,7 @@ extension ConversationVC: cellViewModel.authorId != viewModel.threadData.currentUserSessionId { finalCellViewModel = finalCellViewModel.with( - profile: viewModel.dependencies.mutate(cache: .libSession) { $0.profile } + profile: .set(to: viewModel.dependencies.mutate(cache: .libSession) { $0.profile }) ) } @@ -2367,7 +2362,7 @@ extension ConversationVC: } } - func retry(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { + @MainActor func retry(_ cellViewModel: MessageViewModel, completion: (@MainActor () -> Void)?) { guard cellViewModel.id != MessageViewModel.optimisticUpdateId else { guard let optimisticMessageId: UUID = cellViewModel.optimisticMessageId, @@ -2391,8 +2386,12 @@ extension ConversationVC: } // Try to send the optimistic message again - sendMessage(optimisticData: optimisticMessageData) - completion?() + Task.detached(priority: .userInitiated) { [weak self] in + await self?.sendMessage(optimisticData: optimisticMessageData) + await MainActor.run { + completion?() + } + } return } diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index c1885ca635..96e41cf3f7 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -272,6 +272,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold threadWasMarkedUnread: initialData?.threadWasMarkedUnread, using: dependencies ).populatingPostQueryData( + threadDisplayPictureUrl: nil, + contactProfile: nil, recentReactionEmoji: nil, openGroupCapabilities: nil, currentUserSessionIds: ( @@ -378,7 +380,18 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold } ) + // TODO: [Database Relocation] Clean up this query as well + var targetContactProfile: Profile? = viewModel.contactProfile + var targetThreadDisplayPictureUrl: String? = viewModel.threadDisplayPictureUrl + + if viewModel.id == userSessionId.hexString { + targetContactProfile = dependencies.mutate(cache: .libSession) { $0.profile } + targetThreadDisplayPictureUrl = targetContactProfile?.displayPictureUrl + } + return viewModel.populatingPostQueryData( + threadDisplayPictureUrl: targetThreadDisplayPictureUrl, + contactProfile: targetContactProfile, recentReactionEmoji: recentReactionEmoji, openGroupCapabilities: openGroupCapabilities, currentUserSessionIds: ( @@ -630,6 +643,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs } let threadIsTrusted: Bool = data.contains(where: { $0.threadIsTrusted }) + // TODO: [Database Relocation] Source profile data via a separate query for efficiency + var currentUserProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } + // We load messages from newest to oldest so having a pageOffset larger than zero means // there are newer pages to load return [ @@ -660,6 +676,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold .id ), currentUserSessionIds: (threadData.currentUserSessionIds ?? []), + currentUserProfile: currentUserProfile, threadIsTrusted: threadIsTrusted, using: dependencies ) @@ -709,7 +726,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold interaction: Interaction, attachmentData: [Attachment]?, linkPreviewDraft: LinkPreviewDraft?, - linkPreviewAttachment: Attachment?, + linkPreviewPreparedAttachment: PreparedAttachment?, quoteModel: QuotedReplyModel? ) @@ -746,7 +763,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold using: dependencies ) var optimisticAttachments: [Attachment]? - var linkPreviewAttachment: Attachment? + var linkPreviewPreparedAttachment: PreparedAttachment? if let pendingAttachments: [PendingAttachment] = attachments { optimisticAttachments = try? await AttachmentUploadJob.preparePriorToUpload( @@ -756,7 +773,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold } if let draft: LinkPreviewDraft = linkPreviewDraft { - linkPreviewAttachment = try? await LinkPreview.generateAttachmentIfPossible( + linkPreviewPreparedAttachment = try? await LinkPreview.prepareAttachmentIfPossible( urlString: draft.urlString, imageData: draft.jpegImageData, type: .jpeg, @@ -798,16 +815,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold } }(), currentUserProfile: currentUserProfile, - quote: quoteModel.map { model in - // Don't care about this optimistic quote (the proper one will be generated in the database) - Quote( - interactionId: -1, // Can't save to db optimistically - authorId: model.authorId, - timestampMs: model.timestampMs, - body: model.body - ) - }, - quoteAttachment: quoteModel?.attachment, + quotedInfo: MessageViewModel.QuotedInfo(replyModel: quoteModel), linkPreview: linkPreviewDraft.map { draft in LinkPreview( url: draft.urlString, @@ -816,7 +824,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold using: dependencies ) }, - linkPreviewAttachment: linkPreviewAttachment, + linkPreviewAttachment: linkPreviewPreparedAttachment?.attachment, attachments: optimisticAttachments ) let optimisticData: OptimisticMessageData = ( @@ -825,7 +833,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold interaction, optimisticAttachments, linkPreviewDraft, - linkPreviewAttachment, + linkPreviewPreparedAttachment, quoteModel ) @@ -843,13 +851,13 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ( $0.id, $0.messageViewModel.with( - state: .failed, - mostRecentFailureText: "shareExtensionDatabaseError".localized() + state: .set(to: .failed), + mostRecentFailureText: .set(to: "shareExtensionDatabaseError".localized()) ), $0.interaction, $0.attachmentData, $0.linkPreviewDraft, - $0.linkPreviewAttachment, + $0.linkPreviewPreparedAttachment, $0.quoteModel ) } diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift index 10ff9a5e55..84aeeb2b7b 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift @@ -159,13 +159,31 @@ final class LinkPreviewView: UIView { ) { cancelButton.removeFromSuperview() - var imageSource: ImageDataManager.DataSource? = state.imageSource - let stateHasImage: Bool = (imageSource != nil && imageSource?.contentExists == true) - if - (imageSource == nil || imageSource?.contentExists != true) && - (state is LinkPreview.DraftState || state is LinkPreview.SentState) - { - imageSource = .icon(.link, size: 32, renderingMode: .alwaysTemplate) + switch state { + case is LinkPreview.LoadingState: + loader.alpha = 1 + loader.startAnimating() + imageView.image = nil + + case is LinkPreview.DraftState, is LinkPreview.SentState: + let imageContentExists: Bool = (state.imageSource?.contentExists == true) + let imageSource: ImageDataManager.DataSource = { + guard + let source: ImageDataManager.DataSource = state.imageSource, + source.contentExists + else { return .icon(.link, size: 32, renderingMode: .alwaysTemplate) } + + return source + }() + loader.alpha = 0 + loader.stopAnimating() + imageView.loadImage(imageSource) + imageView.contentMode = (imageContentExists ? .scaleAspectFill : .center) + + default: + loader.alpha = 0 + loader.stopAnimating() + imageView.image = nil } // Image view @@ -173,20 +191,10 @@ final class LinkPreviewView: UIView { imageViewContainerWidthConstraint.constant = imageViewContainerSize imageViewContainerHeightConstraint.constant = imageViewContainerSize imageViewContainer.layer.cornerRadius = (state is LinkPreview.SentState ? 0 : 8) - - if let source: ImageDataManager.DataSource = imageSource { - imageView.loadImage(source) - } - imageView.themeTintColor = (isOutgoing ? .messageBubble_outgoingText : .messageBubble_incomingText ) - imageView.contentMode = (stateHasImage ? .scaleAspectFill : .center) - - // Loader - loader.alpha = (imageSource != nil ? 0 : 1) - if imageSource != nil { loader.stopAnimating() } else { loader.startAnimating() } // Title titleLabel.text = state.title diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index df2f74552c..b6b64b2ef3 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -596,16 +596,16 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { stackView.setCompressionResistance(.vertical, to: .required) // Quote view - if let quote: Quote = cellViewModel.quote { + if let quotedInfo: MessageViewModel.QuotedInfo = cellViewModel.quotedInfo { let hInset: CGFloat = 2 let quoteView: QuoteView = QuoteView( for: .regular, - authorId: quote.authorId, - quotedText: quote.body, + authorId: quotedInfo.authorId, + quotedText: quotedInfo.body, threadVariant: cellViewModel.threadVariant, currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), - attachment: cellViewModel.quoteAttachment, + attachment: quotedInfo.attachment, using: dependencies ) self.quoteView = quoteView @@ -679,9 +679,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { ) - 2 * inset ) - switch (cellViewModel.quote, cellViewModel.body) { + switch (cellViewModel.quotedInfo, cellViewModel.body) { /// Both quote and body - case (.some(let quote), .some(let body)) where !body.isEmpty: + case (.some(let quotedInfo), .some(let body)) where !body.isEmpty: // Stack view let stackView = UIStackView(arrangedSubviews: []) stackView.axis = .vertical @@ -691,12 +691,12 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { let hInset: CGFloat = 2 let quoteView: QuoteView = QuoteView( for: .regular, - authorId: quote.authorId, - quotedText: quote.body, + authorId: quotedInfo.authorId, + quotedText: quotedInfo.body, threadVariant: cellViewModel.threadVariant, currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), - attachment: cellViewModel.quoteAttachment, + attachment: quotedInfo.attachment, using: dependencies ) self.quoteView = quoteView @@ -767,15 +767,15 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { snContentView.addArrangedSubview(bubbleBackgroundView) /// Just quote - case (.some(let quote), _): + case (.some(let quotedInfo), _): let quoteView: QuoteView = QuoteView( for: .regular, - authorId: quote.authorId, - quotedText: quote.body, + authorId: quotedInfo.authorId, + quotedText: quotedInfo.body, threadVariant: cellViewModel.threadVariant, currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), - attachment: cellViewModel.quoteAttachment, + attachment: quotedInfo.attachment, using: dependencies ) self.quoteView = quoteView diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 8cf659e375..15b83c06d4 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -128,13 +128,29 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob lazy var observation: TargetObservation = ObservationBuilderOld .databaseObservation(self) { [dependencies, threadId = self.threadId] db -> State in let userSessionId: SessionId = dependencies[cache: .general].sessionId - let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel + var threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel .conversationSettingsQuery(threadId: threadId, userSessionId: userSessionId) .fetchOne(db) let disappearingMessagesConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration .fetchOne(db, id: threadId) .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) + // TODO: [Database Relocation] Clean up this query as well + if threadViewModel?.id == userSessionId.hexString { + let userProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } + threadViewModel = threadViewModel?.populatingPostQueryData( + threadDisplayPictureUrl: userProfile.displayPictureUrl, + contactProfile: userProfile, + recentReactionEmoji: nil, + openGroupCapabilities: nil, + currentUserSessionIds: [userSessionId.hexString], + wasKickedFromGroup: false, + groupIsDestroyed: false, + threadCanWrite: true, + threadCanUpload: true + ) + } + return State( threadViewModel: threadViewModel, disappearingMessagesConfig: disappearingMessagesConfig diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 5e85c61117..618a82cd06 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -94,6 +94,7 @@ public class HomeViewModel: NavigatableStateHolder { let unreadMessageRequestThreadCount: Int let loadedPageInfo: PagedData.LoadedInfo let itemCache: [String: SessionThreadViewModel] + let profileCache: [String: Profile] let appReviewPromptState: AppReviewPromptState? let pendingAppReviewPromptState: AppReviewPromptState? let appWasInstalledPriorToAppReviewRelease: Bool @@ -176,6 +177,7 @@ public class HomeViewModel: NavigatableStateHolder { orderSQL: SessionThreadViewModel.homeOrderSQL ), itemCache: [:], + profileCache: [:], appReviewPromptState: nil, pendingAppReviewPromptState: appReviewPromptState, appWasInstalledPriorToAppReviewRelease: appWasInstalledPriorToAppReviewRelease @@ -200,6 +202,7 @@ public class HomeViewModel: NavigatableStateHolder { var unreadMessageRequestThreadCount: Int = previousState.unreadMessageRequestThreadCount var loadResult: PagedData.LoadResult = previousState.loadedPageInfo.asResult var itemCache: [String: SessionThreadViewModel] = previousState.itemCache + var profileCache: [String: Profile] = previousState.profileCache var appReviewPromptState: AppReviewPromptState? = previousState.appReviewPromptState var pendingAppReviewPromptState: AppReviewPromptState? = previousState.pendingAppReviewPromptState let appWasInstalledPriorToAppReviewRelease: Bool = previousState.appWasInstalledPriorToAppReviewRelease @@ -222,6 +225,9 @@ public class HomeViewModel: NavigatableStateHolder { hasHiddenMessageRequests = libSession.get(.hasHiddenMessageRequests) } + // TODO: [Database Relocation] All profiles should be stored in the `profileCache` + profileCache[userProfile.id] = userProfile + /// If we haven't hidden the message requests banner then we should include that in the initial fetch if !hasHiddenMessageRequests { eventsToProcess.append(ObservedEvent( @@ -245,8 +251,30 @@ public class HomeViewModel: NavigatableStateHolder { result[.other, default: []].insert(next) } } + let groupedOtherEvents: [GenericObservableKey: Set]? = splitEvents[.other]? + .reduce(into: [:]) { result, event in + result[event.key.generic, default: []].insert(event) + } + + /// Handle profile events first + groupedOtherEvents?[.profile]?.forEach { event in + guard + let eventValue: ProfileEvent = event.value as? ProfileEvent, + eventValue.id == userProfile.id + else { return } + + switch eventValue.change { + case .name(let name): userProfile = userProfile.with(name: name) + case .nickname(let nickname): userProfile = userProfile.with(nickname: .set(to: nickname)) + case .displayPictureUrl(let url): userProfile = userProfile.with(displayPictureUrl: .set(to: url)) + } + + // TODO: [Database Relocation] All profiles should be stored in the `profileCache` + profileCache[eventValue.id] = userProfile + } + - /// Handle database events first + /// Then handle database events if !dependencies[singleton: .storage].isSuspended, let databaseEvents: Set = splitEvents[.databaseQuery], !databaseEvents.isEmpty { do { var fetchedConversations: [SessionThreadViewModel] = [] @@ -360,23 +388,7 @@ public class HomeViewModel: NavigatableStateHolder { Log.warn(.homeViewModel, "Ignored \(databaseEvents.count) database event(s) sent while storage was suspended.") } - /// Then handle non-database events - let groupedOtherEvents: [GenericObservableKey: Set]? = splitEvents[.other]? - .reduce(into: [:]) { result, event in - result[event.key.generic, default: []].insert(event) - } - groupedOtherEvents?[.profile]?.forEach { event in - guard - let eventValue: ProfileEvent = event.value as? ProfileEvent, - eventValue.id == userProfile.id - else { return } - - switch eventValue.change { - case .name(let name): userProfile = userProfile.with(name: name) - case .nickname(let nickname): userProfile = userProfile.with(nickname: .set(to: nickname)) - case .displayPictureUrl(let url): userProfile = userProfile.with(displayPictureUrl: .set(to: url)) - } - } + /// Then handle remaining non-database events groupedOtherEvents?[.setting]?.forEach { event in guard let updatedValue: Bool = event.value as? Bool else { return } @@ -440,6 +452,7 @@ public class HomeViewModel: NavigatableStateHolder { unreadMessageRequestThreadCount: unreadMessageRequestThreadCount, loadedPageInfo: loadResult.info, itemCache: itemCache, + profileCache: profileCache, appReviewPromptState: appReviewPromptState, pendingAppReviewPromptState: pendingAppReviewPromptState, appWasInstalledPriorToAppReviewRelease: appWasInstalledPriorToAppReviewRelease @@ -495,6 +508,8 @@ public class HomeViewModel: NavigatableStateHolder { } private static func sections(state: State, viewModel: HomeViewModel) -> [SectionModel] { + let userSessionId: SessionId = viewModel.dependencies[cache: .general].sessionId + return [ /// If the message request section is hidden or there are no unread message requests then hide the message request banner (state.hasHiddenMessageRequests || state.unreadMessageRequestThreadCount == 0 ? @@ -517,10 +532,20 @@ public class HomeViewModel: NavigatableStateHolder { .compactMap { state.itemCache[$0] } .map { conversation -> SessionThreadViewModel in conversation.populatingPostQueryData( + // TODO: [Database Relocation] The 'threadDisplayPictureUrl' should be based on the conversation type when creating the SessionThreadViewModel rather than via the query + threadDisplayPictureUrl: ( + conversation.id == userSessionId.hexString ? + state.userProfile.displayPictureUrl : + nil + ), + contactProfile: ( + state.profileCache[conversation.id] ?? + conversation.contactProfile + ), recentReactionEmoji: nil, openGroupCapabilities: nil, // TODO: [Database Relocation] Do we need all of these???? - currentUserSessionIds: [viewModel.dependencies[cache: .general].sessionId.hexString], + currentUserSessionIds: [userSessionId.hexString], wasKickedFromGroup: ( conversation.threadVariant == .group && viewModel.dependencies.mutate(cache: .libSession) { cache in diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index de9e64fe72..ef1e358c92 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -297,8 +297,17 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O elements: state.loadedPageInfo.currentIds .compactMap { state.itemCache[$0] } .map { conversation -> SessionCell.Info in - SessionCell.Info( + // TODO: [Database Relocation] Source profile data via a separate query for efficiency + var customProfile: Profile? + + if conversation.id == viewModel.dependencies[cache: .general].sessionId.hexString { + customProfile = viewModel.dependencies.mutate(cache: .libSession) { $0.profile } + } + + return SessionCell.Info( id: conversation.populatingPostQueryData( + threadDisplayPictureUrl: customProfile?.displayPictureUrl, + contactProfile: customProfile, recentReactionEmoji: nil, openGroupCapabilities: nil, // TODO: [Database Relocation] Do we need all of these???? diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 77ae448b70..87f46cd747 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -495,16 +495,16 @@ struct MessageBubble: View { } } else { - if let quote = messageViewModel.quote { + if let quotedInfo: MessageViewModel.QuotedInfo = messageViewModel.quotedInfo { QuoteView_SwiftUI( info: .init( mode: .regular, - authorId: quote.authorId, - quotedText: quote.body, + authorId: quotedInfo.authorId, + quotedText: quotedInfo.body, threadVariant: messageViewModel.threadVariant, currentUserSessionIds: (messageViewModel.currentUserSessionIds ?? []), direction: (messageViewModel.variant == .standardOutgoing ? .outgoing : .incoming), - attachment: messageViewModel.quoteAttachment + attachment: quotedInfo.attachment ), using: dependencies ) @@ -633,8 +633,7 @@ struct MessageInfoView_Previews: PreviewProvider { id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", name: "TestUser" ), - quote: nil, - quoteAttachment: nil, + quotedInfo: nil, linkPreview: nil, linkPreviewAttachment: nil, attachments: nil diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift index 5e9efbab02..bacb638a73 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsFileServerViewModel.swift @@ -17,6 +17,8 @@ class DeveloperSettingsFileServerViewModel: SessionTableViewModel, NavigatableSt public let state: TableDataState = TableDataState() public let observableState: ObservableTableSourceState = ObservableTableSourceState() + private var shareDownloadedFileUrl: String? + private var shareDownloadedFileMimetype: String? private var updatedCustomServerUrl: String? private var updatedCustomServerPubkey: String? @@ -67,6 +69,7 @@ class DeveloperSettingsFileServerViewModel: SessionTableViewModel, NavigatableSt public enum TableItem: Hashable, Differentiable, CaseIterable { case shortenFileTTL case deterministicAttachmentEncryption + case shareDownloadedFile case customFileServerUrl case customFileServerPubkey @@ -78,6 +81,7 @@ class DeveloperSettingsFileServerViewModel: SessionTableViewModel, NavigatableSt switch self { case .shortenFileTTL: return "shortenFileTTL" case .deterministicAttachmentEncryption: return "deterministicAttachmentEncryption" + case .shareDownloadedFile: return "shareDownloadedFile" case .customFileServerUrl: return "customFileServerUrl" case .customFileServerPubkey: return "customFileServerPubkey" } @@ -92,6 +96,7 @@ class DeveloperSettingsFileServerViewModel: SessionTableViewModel, NavigatableSt switch TableItem.shortenFileTTL { case .shortenFileTTL: result.append(.shortenFileTTL); fallthrough case .deterministicAttachmentEncryption: result.append(.deterministicAttachmentEncryption); fallthrough + case .shareDownloadedFile: result.append(.shareDownloadedFile); fallthrough case .customFileServerUrl: result.append(.customFileServerUrl); fallthrough case .customFileServerPubkey: result.append(.customFileServerPubkey) } @@ -242,6 +247,17 @@ class DeveloperSettingsFileServerViewModel: SessionTableViewModel, NavigatableSt ) } ), + SessionCell.Info( + id: .shareDownloadedFile, + title: "Share Downloaded File", + subtitle: """ + Share the downloaded file of a given URL if it exists via the native share sheet + """, + trailingAccessory: .icon(.share), + onTap: { [weak viewModel] in + viewModel?.showShareFileModal() + } + ), SessionCell.Info( id: .customFileServerUrl, title: "Custom File Server URL", @@ -276,6 +292,120 @@ class DeveloperSettingsFileServerViewModel: SessionTableViewModel, NavigatableSt // MARK: - Internal Functions + private func showShareFileModal() { + func originalPath(for value: String) -> String? { + let maybeAttachmentPath: String? = try? dependencies[singleton: .attachmentManager].path(for: value) + let maybeDisplayPicPath: String? = try? dependencies[singleton: .displayPictureManager].path(for: value) + + if + let path: String = maybeAttachmentPath, + dependencies[singleton: .fileManager].fileExists(atPath: path) + { + return path + } + + if + let path: String = maybeDisplayPicPath, + dependencies[singleton: .fileManager].fileExists(atPath: path) + { + return path + } + + return nil + } + func showFileMissingError() { + let modal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "theError".localized(), + body: .text("There doesn't appear to be a downloaded file for that url."), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text + + ) + ) + self.transitionToScreen(modal, transitionType: .present) + } + + self.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Share Downloaded File", + body: .dualInput( + explanation: ThemedAttributedString( + string: "The download url and mime type for the file to share." + ), + firstInfo: ConfirmationModal.Info.Body.InputInfo(placeholder: "MIME Type (Optional)"), + secondInfo: ConfirmationModal.Info.Body.InputInfo(placeholder: "Enter URL"), + onChange: { [weak self] mimeTypeValue, urlValue in + self?.shareDownloadedFileUrl = urlValue.lowercased() + self?.shareDownloadedFileMimetype = mimeTypeValue.lowercased() + } + ), + confirmTitle: "Copy Path", + confirmEnabled: .afterChange { [weak self] _ in + guard let value: String = self?.shareDownloadedFileUrl else { return false } + + return !value.isEmpty + }, + cancelTitle: "share".localized(), + cancelStyle: .alert_text, + cancelEnabled: .afterChange { [weak self] _ in + guard let value: String = self?.shareDownloadedFileUrl else { return false } + + return !value.isEmpty + }, + hasCloseButton: true, + dismissOnConfirm: false, + onConfirm: { [weak self] modal in + guard let value: String = self?.shareDownloadedFileUrl else { return } + guard let originalPath: String = originalPath(for: value) else { + return showFileMissingError() + } + + UIPasteboard.general.string = originalPath + self?.showToast(text: "copied".localized()) + }, + onCancel: { [weak self, dependencies] modal in + guard let value: String = self?.shareDownloadedFileUrl else { return } + + modal.dismiss(animated: true) { + guard + let originalPath: String = originalPath(for: value), + let temporaryPath: String = try? dependencies[singleton: .attachmentManager].temporaryPathForOpening( + originalPath: originalPath, + mimeType: self?.shareDownloadedFileMimetype?.lowercased(), + sourceFilename: nil, + allowInvalidType: true + ) + else { return showFileMissingError() } + + /// Create the temporary file + try? dependencies[singleton: .fileManager].copyItem( + atPath: originalPath, + toPath: temporaryPath + ) + + /// Ensure the temporary file was created successfully + guard dependencies[singleton: .fileManager].fileExists(atPath: temporaryPath) else { + return showFileMissingError() + } + + let shareVC = UIActivityViewController(activityItems: [ URL(fileURLWithPath: temporaryPath) ], applicationActivities: nil) + shareVC.completionWithItemsHandler = { [dependencies] activityType, completed, returnedItems, activityError in + /// Sanity check to make sure we don't unintentionally remove a proper attachment file + if dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(temporaryPath) { + try? dependencies[singleton: .fileManager].removeItem(atPath: temporaryPath) + } + } + self?.transitionToScreen(shareVC, transitionType: .present) + } + } + ) + ), + transitionType: .present + ) + } + private func showServerUrlModal(pendingState: State.Info) { self.transitionToScreen( ConfirmationModal( diff --git a/Session/Settings/Views/ThemeMessagePreviewView.swift b/Session/Settings/Views/ThemeMessagePreviewView.swift index e82f5b444a..5d833e6363 100644 --- a/Session/Settings/Views/ThemeMessagePreviewView.swift +++ b/Session/Settings/Views/ThemeMessagePreviewView.swift @@ -18,12 +18,7 @@ final class ThemeMessagePreviewView: UIView { with: MessageViewModel( variant: .standardIncoming, body: "appearancePreview2".localized(), - quote: Quote( - interactionId: -1, - authorId: "", - timestampMs: 0, - body: "appearancePreview1".localized() - ), + quotedInfo: MessageViewModel.QuotedInfo(previewBody: "appearancePreview1".localized()), cellType: .textOnlyMessage ), playbackInfo: nil, diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index 70ea8a2579..4e71f0f955 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -48,7 +48,8 @@ public enum SNMessagingKit { _042_MoveSettingsToLibSession.self, _043_RenameAttachments.self, _044_AddProMessageFlag.self, - _045_LastProfileUpdateTimestamp.self + _045_LastProfileUpdateTimestamp.self, + _046_RemoveQuoteUnusedColumnsAndForeignKeys.self ] public static func configure(using dependencies: Dependencies) { diff --git a/SessionMessagingKit/Database/Migrations/_046_RemoveQuoteUnusedColumnsAndForeignKeys.swift b/SessionMessagingKit/Database/Migrations/_046_RemoveQuoteUnusedColumnsAndForeignKeys.swift new file mode 100644 index 0000000000..e38eddd742 --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_046_RemoveQuoteUnusedColumnsAndForeignKeys.swift @@ -0,0 +1,40 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +enum _046_RemoveQuoteUnusedColumnsAndForeignKeys: Migration { + static let identifier: String = "RemoveQuoteUnusedColumnsAndForeignKeys" + static let minExpectedRunDuration: TimeInterval = 0.1 + static var createdTables: [(FetchableRecord & TableRecord).Type] = [] + + static func migrate(_ db: ObservingDatabase, using dependencies: Dependencies) throws { + // SQLite doesn't support adding a new primary key after creation so we need to create a new table with + // the setup we want, copy data from the old table over, drop the old table and rename the new table + try db.create(table: "tmpQuote") { t in + t.column("interactionId", .integer) + .notNull() + .primaryKey() + .references("interaction", onDelete: .cascade) // Delete if interaction deleted + t.column("authorId", .text).notNull() + t.column("timestampMs", .double).notNull() + } + + // Insert into the new table, drop the old table and rename the new table to be the old one + try db.execute(literal: """ + INSERT INTO tmpQuote + SELECT interactionId, authorId, timestampMs + FROM quote + """) + + try db.drop(table: "quote") + try db.rename(table: "tmpQuote", to: "quote") + + // Need to create the indexes separately from creating 'tmpQuote' to ensure they have the + // correct names + try db.create(indexOn: "quote", columns: ["authorId", "timestampMs"]) + + MigrationExecution.updateProgress(1) + } +} diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 43b0dfdcb7..26be5ee827 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -23,7 +23,6 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable through: interactionAttachments, using: InteractionAttachment.attachment ) - public static let quote = hasOne(Quote.self, using: Quote.interactionForeignKey) /// Whenever using this `linkPreview` association make sure to filter the result using /// `.filter(literal: Interaction.linkPreviewFilterLiteral)` to ensure the correct LinkPreview is returned @@ -248,10 +247,6 @@ public struct Interaction: Codable, Identifiable, Equatable, Hashable, Fetchable .order(interactionAttachment[.albumIndex]) } - public var quote: QueryInterfaceRequest { - request(for: Interaction.quote) - } - public var linkPreview: QueryInterfaceRequest { /// **Note:** This equation **MUST** match the `linkPreviewFilterLiteral` logic let halfResolution: Double = LinkPreview.timstampResolution diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index dfeb94773e..50cbacb4f9 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -132,7 +132,12 @@ public extension LinkPreview { return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution) } - static func generateAttachmentIfPossible(urlString: String, imageData: Data?, type: UTType, using dependencies: Dependencies) async throws -> Attachment? { + static func prepareAttachmentIfPossible( + urlString: String, + imageData: Data?, + type: UTType, + using dependencies: Dependencies + ) async throws -> PreparedAttachment? { guard let imageData: Data = imageData, !imageData.isEmpty else { return nil } let pendingAttachment: PendingAttachment = PendingAttachment( @@ -143,15 +148,15 @@ public extension LinkPreview { let targetFormat: PendingAttachment.ConversionFormat = (dependencies[feature: .usePngInsteadOfWebPForFallbackImageType] ? .png(maxDimension: LinkPreview.maxImageDimension) : .webPLossy(maxDimension: LinkPreview.maxImageDimension) ) - let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( + return try await pendingAttachment.prepare( operations: [ .convert(to: targetFormat), .stripImageMetadata ], + /// We only call `prepareAttachmentIfPossible` before sending so always store at the pending upload path + storeAtPendingAttachmentUploadPath: true, using: dependencies ) - - return preparedAttachment.attachment } static func isValidLinkUrl(_ urlString: String) -> Bool { diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift index 04d43134ef..c13fde3ebc 100644 --- a/SessionMessagingKit/Database/Models/Quote.swift +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -6,22 +6,12 @@ import SessionUtilitiesKit public struct Quote: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "quote" } - public static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id]) - internal static let originalInteractionForeignKey = ForeignKey( - [Columns.timestampMs, Columns.authorId], - to: [Interaction.Columns.timestampMs, Interaction.Columns.authorId] - ) - internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id]) - internal static let interaction = belongsTo(Interaction.self, using: interactionForeignKey) - private static let profile = hasOne(Profile.self, using: profileForeignKey) - private static let quotedInteraction = hasOne(Interaction.self, using: originalInteractionForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { case interactionId case authorId case timestampMs - case body } /// The id for the interaction this Quote belongs to @@ -33,35 +23,16 @@ public struct Quote: Codable, Equatable, Hashable, FetchableRecord, PersistableR /// The timestamp in milliseconds since epoch when the quoted interaction was sent public let timestampMs: Int64 - /// The body of the quoted message if the user is quoting a text message or an attachment with a caption - public let body: String? - - // MARK: - Relationships - - public var interaction: QueryInterfaceRequest { - request(for: Quote.interaction) - } - - public var profile: QueryInterfaceRequest { - request(for: Quote.profile) - } - - public var originalInteraction: QueryInterfaceRequest { - request(for: Quote.quotedInteraction) - } - // MARK: - Interaction public init( interactionId: Int64, authorId: String, - timestampMs: Int64, - body: String? + timestampMs: Int64 ) { self.interactionId = interactionId self.authorId = authorId self.timestampMs = timestampMs - self.body = body } } @@ -71,14 +42,12 @@ public extension Quote { func with( interactionId: Int64? = nil, authorId: String? = nil, - timestampMs: Int64? = nil, - body: String? = nil + timestampMs: Int64? = nil ) -> Quote { return Quote( interactionId: interactionId ?? self.interactionId, authorId: authorId ?? self.authorId, - timestampMs: timestampMs ?? self.timestampMs, - body: body ?? self.body + timestampMs: timestampMs ?? self.timestampMs ) } @@ -86,8 +55,7 @@ public extension Quote { return Quote( interactionId: self.interactionId, authorId: self.authorId, - timestampMs: self.timestampMs, - body: nil + timestampMs: self.timestampMs ) } } @@ -105,6 +73,5 @@ public extension Quote { self.interactionId = interactionId self.timestampMs = Int64(quoteProto.id) self.authorId = quoteProto.author - self.body = nil } } diff --git a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift index 938d5badb7..d6f8402365 100644 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift @@ -36,9 +36,20 @@ public enum AttachmentDownloadJob: JobExecutor { /// Due to the complex nature of jobs and how attachments can be reused it's possible for an /// `AttachmentDownloadJob` to get created for an attachment which has already been downloaded or /// uploaded so in those cases just succeed immediately - guard attachment.state != .downloaded && attachment.state != .uploaded else { - throw AttachmentDownloadError.alreadyDownloaded - } + let fileAlreadyDownloaded: Bool = try { + guard attachment.state == .downloaded || attachment.state == .uploaded else { + return false + } + + /// If the attachment should have been downloaded then check to ensure the file exists (if it doesn't then + /// wr should try to download it again - this will result in the file going into a "failed" state if not which is + /// better than the "file is downloaded but doesn't exist" state which is handled poorly + let path: String = try dependencies[singleton: .attachmentManager].path(for: attachment.downloadUrl) + + return dependencies[singleton: .fileManager].fileExists(atPath: path) + }() + + guard !fileAlreadyDownloaded else { throw AttachmentDownloadError.alreadyDownloaded } /// 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 diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift index d37de1670a..d51cad72ee 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift @@ -8,16 +8,14 @@ public extension VisibleMessage { struct VMQuote: Codable { public let timestamp: UInt64? public let authorId: String? - public let text: String? public func isValid(isSending: Bool) -> Bool { timestamp != nil && authorId != nil } // MARK: - Initialization - internal init(timestamp: UInt64, authorId: String, text: String?) { + internal init(timestamp: UInt64, authorId: String) { self.timestamp = timestamp self.authorId = authorId - self.text = text } // MARK: - Proto Conversion @@ -25,8 +23,7 @@ public extension VisibleMessage { public static func fromProto(_ proto: SNProtoDataMessageQuote) -> VMQuote? { return VMQuote( timestamp: proto.id, - authorId: proto.author, - text: proto.text + authorId: proto.author ) } @@ -36,7 +33,6 @@ public extension VisibleMessage { return nil } let quoteProto = SNProtoDataMessageQuote.builder(id: timestamp, author: authorId) - if let text = text { quoteProto.setText(text) } do { return try quoteProto.build() } catch { @@ -51,8 +47,7 @@ public extension VisibleMessage { """ Quote( timestamp: \(timestamp?.description ?? "null"), - authorId: \(authorId ?? "null"), - text: \(text ?? "null") + authorId: \(authorId ?? "null") ) """ } @@ -65,8 +60,7 @@ public extension VisibleMessage.VMQuote { static func from(quote: Quote) -> VisibleMessage.VMQuote { return VisibleMessage.VMQuote( timestamp: UInt64(quote.timestampMs), - authorId: quote.authorId, - text: quote.body + authorId: quote.authorId ) } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index f10e1dca4c..1680c1d75f 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -436,7 +436,9 @@ public extension VisibleMessage { text: interaction.body, attachmentIds: ((try? interaction.attachments.fetchAll(db)) ?? []) .map { $0.id }, - quote: (try? interaction.quote.fetchOne(db)) + quote: (try? Quote + .filter(Quote.Columns.interactionId == interaction.id) + .fetchOne(db)) .map { VMQuote.from(quote: $0) }, linkPreview: linkPreview .map { linkPreview in diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 9c01a8c2d9..6b37c426f4 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -14,6 +14,22 @@ fileprivate typealias ReactionInfo = MessageViewModel.ReactionInfo fileprivate typealias TypingIndicatorInfo = MessageViewModel.TypingIndicatorInfo fileprivate typealias QuotedInfo = MessageViewModel.QuotedInfo +public struct QuoteViewModel: FetchableRecord, Decodable, Equatable, Hashable, Differentiable { + fileprivate static let numberOfColumns: Int = 4 + + public let interactionId: Int64 + public let authorId: String + public let timestampMs: Int64 + public let body: String? + + public init(interactionId: Int64, authorId: String, timestampMs: Int64, body: String?) { + self.interactionId = interactionId + self.authorId = authorId + self.timestampMs = timestampMs + self.body = body + } +} + // TODO: [Database Relocation] Refactor this to split database data from no-database data (to avoid unneeded nullables) public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible { public typealias Columns = CodingKeys @@ -49,8 +65,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, case isSenderModeratorOrAdmin case isTypingIndicator case profile - case quote - case quoteAttachment + case quotedInfo case linkPreview case linkPreviewAttachment @@ -133,8 +148,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public let isSenderModeratorOrAdmin: Bool public let isTypingIndicator: Bool? public let profile: Profile? - public let quote: Quote? - public let quoteAttachment: Attachment? + public let quotedInfo: QuotedInfo? public let linkPreview: LinkPreview? public let linkPreviewAttachment: Attachment? @@ -209,13 +223,12 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, // MARK: - Mutation public func with( - state: Interaction.State? = nil, // Optimistic outgoing messages - mostRecentFailureText: String? = nil, // Optimistic outgoing messages - profile: Profile? = nil, - quote: Quote? = nil, // Workaround for blinded current user - quoteAttachment: [Attachment]? = nil, // Pass an empty array to clear - attachments: [Attachment]? = nil, - reactionInfo: [ReactionInfo]? = nil + state: Update = .useExisting, // Optimistic outgoing messages + mostRecentFailureText: Update = .useExisting, // Optimistic outgoing messages + profile: Update = .useExisting, + quotedInfo: Update = .useExisting, // Workaround for blinded current user + attachments: Update<[Attachment]?> = .useExisting, + reactionInfo: Update<[ReactionInfo]?> = .useExisting, ) -> MessageViewModel { return MessageViewModel( threadId: self.threadId, @@ -239,81 +252,18 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, rawBody: self.rawBody, expiresStartedAtMs: self.expiresStartedAtMs, expiresInSeconds: self.expiresInSeconds, - state: (state ?? self.state), + state: state.or(self.state), hasBeenReadByRecipient: self.hasBeenReadByRecipient, - mostRecentFailureText: (mostRecentFailureText ?? self.mostRecentFailureText), - isSenderModeratorOrAdmin: self.isSenderModeratorOrAdmin, - isTypingIndicator: self.isTypingIndicator, - profile: (profile ?? self.profile), - quote: (quote ?? self.quote), - quoteAttachment: (quoteAttachment ?? self.quoteAttachment.map { [$0] })?.first, // Only contains one - linkPreview: self.linkPreview, - linkPreviewAttachment: self.linkPreviewAttachment, - currentUserSessionId: self.currentUserSessionId, - attachments: (attachments ?? self.attachments), - reactionInfo: (reactionInfo ?? self.reactionInfo), - cellType: self.cellType, - authorName: self.authorName, - authorNameSuppressedId: self.authorNameSuppressedId, - senderName: self.senderName, - canHaveProfile: self.canHaveProfile, - shouldShowProfile: self.shouldShowProfile, - shouldShowDateHeader: self.shouldShowDateHeader, - containsOnlyEmoji: self.containsOnlyEmoji, - glyphCount: self.glyphCount, - previousVariant: self.previousVariant, - positionInCluster: self.positionInCluster, - isOnlyMessageInCluster: self.isOnlyMessageInCluster, - isLast: self.isLast, - isLastOutgoing: self.isLastOutgoing, - currentUserSessionIds: self.currentUserSessionIds, - optimisticMessageId: self.optimisticMessageId - ) - } - - public func removingQuoteAttachmentsIfNeeded( - validAttachments: [Attachment] - ) -> MessageViewModel { - guard - let quoteAttachment: Attachment = self.quoteAttachment, - !validAttachments.contains(quoteAttachment) - else { return self } - - return ViewModel( - threadId: self.threadId, - threadVariant: self.threadVariant, - threadIsTrusted: self.threadIsTrusted, - threadExpirationType: self.threadExpirationType, - threadExpirationTimer: self.threadExpirationTimer, - threadOpenGroupServer: self.threadOpenGroupServer, - threadOpenGroupPublicKey: self.threadOpenGroupPublicKey, - threadContactNameInternal: self.threadContactNameInternal, - rowId: self.rowId, - id: self.id, - serverHash: self.serverHash, - openGroupServerMessageId: self.openGroupServerMessageId, - variant: self.variant, - timestampMs: self.timestampMs, - receivedAtTimestampMs: self.receivedAtTimestampMs, - authorId: self.authorId, - authorNameInternal: self.authorNameInternal, - body: self.body, - rawBody: self.body, - expiresStartedAtMs: self.expiresStartedAtMs, - expiresInSeconds: self.expiresInSeconds, - state: self.state, - hasBeenReadByRecipient: self.hasBeenReadByRecipient, - mostRecentFailureText: self.mostRecentFailureText, + mostRecentFailureText: mostRecentFailureText.or(self.mostRecentFailureText), isSenderModeratorOrAdmin: self.isSenderModeratorOrAdmin, isTypingIndicator: self.isTypingIndicator, - profile: self.profile, - quote: self.quote, - quoteAttachment: nil, + profile: profile.or(self.profile), + quotedInfo: quotedInfo.or(self.quotedInfo), linkPreview: self.linkPreview, linkPreviewAttachment: self.linkPreviewAttachment, currentUserSessionId: self.currentUserSessionId, - attachments: self.attachments, - reactionInfo: self.reactionInfo, + attachments: attachments.or(self.attachments), + reactionInfo: reactionInfo.or(self.reactionInfo), cellType: self.cellType, authorName: self.authorName, authorNameSuppressedId: self.authorNameSuppressedId, @@ -339,6 +289,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, isLast: Bool, isLastOutgoing: Bool, currentUserSessionIds: Set, + currentUserProfile: Profile, threadIsTrusted: Bool, using dependencies: Dependencies ) -> MessageViewModel { @@ -370,20 +321,41 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, return .genericAttachment }() - let authorDisplayName: String = Profile.displayName( - for: self.threadVariant, - id: self.authorId, - name: self.authorNameInternal, - nickname: nil, // Folded into 'authorName' within the Query - suppressId: false // Show the id next to the author name if desired - ) - let authorDisplayNameSuppressedId: String = Profile.displayName( - for: self.threadVariant, - id: self.authorId, - name: self.authorNameInternal, - nickname: nil, // Folded into 'authorName' within the Query - suppressId: true // Exclude the id next to the author name - ) + // TODO: [Database Relocation] Clean up `currentUserProfile` logic (profile data should be sourced from a separate query for efficiency) + let authorDisplayName: String = { + guard authorId != currentUserProfile.id else { + return currentUserProfile.displayName( + for: self.threadVariant, + ignoringNickname: true, // Current user has no nickname + suppressId: false // Show the id next to the author name if desired + ) + } + + return Profile.displayName( + for: self.threadVariant, + id: self.authorId, + name: self.authorNameInternal, + nickname: nil, // Folded into 'authorName' within the Query + suppressId: false // Show the id next to the author name if desired + ) + }() + let authorDisplayNameSuppressedId: String = { + guard authorId != currentUserProfile.id else { + return currentUserProfile.displayName( + for: self.threadVariant, + ignoringNickname: true, // Current user has no nickname + suppressId: true // Exclude the id next to the author name + ) + } + + return Profile.displayName( + for: self.threadVariant, + id: self.authorId, + name: self.authorNameInternal, + nickname: nil, // Folded into 'authorName' within the Query + suppressId: true // Exclude the id next to the author name + ) + }() let shouldShowDateBeforeThisModel: Bool = { guard self.isTypingIndicator != true else { return false } guard self.variant != .infoCall else { return true } // Always show on calls @@ -462,7 +434,10 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, timestampMs: self.timestampMs, receivedAtTimestampMs: self.receivedAtTimestampMs, authorId: self.authorId, - authorNameInternal: self.authorNameInternal, + authorNameInternal: (self.threadId == currentUserProfile.id ? + "you".localized() : + self.authorNameInternal + ), body: (!self.variant.isInfoMessage ? self.body : // Info messages might not have a body so we should use the 'previewText' value instead @@ -498,9 +473,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, mostRecentFailureText: self.mostRecentFailureText, isSenderModeratorOrAdmin: self.isSenderModeratorOrAdmin, isTypingIndicator: self.isTypingIndicator, - profile: self.profile, - quote: self.quote, - quoteAttachment: self.quoteAttachment, + profile: (self.profile?.id == currentUserProfile.id ? currentUserProfile : self.profile), + quotedInfo: self.quotedInfo, linkPreview: self.linkPreview, linkPreviewAttachment: self.linkPreviewAttachment, currentUserSessionId: self.currentUserSessionId, @@ -674,25 +648,61 @@ public extension MessageViewModel { // MARK: - QuotedInfo public extension MessageViewModel { - struct QuotedInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, ColumnExpressible { + struct QuotedInfo: FetchableRecordWithRowId, Decodable, Identifiable, Equatable, Hashable, ColumnExpressible { public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { case rowId - case quote + case interactionId + case authorId + case timestampMs + case body case attachment case quotedInteractionId case quotedInteractionVariant } public let rowId: Int64 - public let quote: Quote + public let interactionId: Int64 + public let authorId: String + public let timestampMs: Int64 + public let body: String? public let attachment: Attachment? public let quotedInteractionId: Int64 public let quotedInteractionVariant: Interaction.Variant // MARK: - Identifiable - public var id: String { "quote-\(quote.interactionId)-attachment_\(attachment?.id ?? "None")" } + public var id: String { "quote-\(interactionId)-attachment_\(attachment?.id ?? "None")" } + + // MARK: - Initialization + + public init(previewBody: String) { + self.body = previewBody + + /// This is an preview version so none of these values matter + self.rowId = -1 + self.interactionId = -1 + self.authorId = "" + self.timestampMs = 0 + self.attachment = nil + self.quotedInteractionId = -1 + self.quotedInteractionVariant = .standardOutgoing + } + + public init?(replyModel: QuotedReplyModel?) { + guard let model: QuotedReplyModel = replyModel else { return nil } + + self.authorId = model.authorId + self.timestampMs = model.timestampMs + self.body = model.body + self.attachment = model.attachment + + /// This is an optimistic version so none of these values exist yet + self.rowId = -1 + self.interactionId = -1 + self.quotedInteractionId = -1 + self.quotedInteractionVariant = .standardOutgoing + } } } @@ -709,7 +719,7 @@ public extension MessageViewModel { timestampMs: Int64 = Int64.max, receivedAtTimestampMs: Int64 = Int64.max, body: String? = nil, - quote: Quote? = nil, + quotedInfo: QuotedInfo? = nil, cellType: CellType = .typingIndicator, isTypingIndicator: Bool? = nil, isLast: Bool = true, @@ -752,8 +762,7 @@ public extension MessageViewModel { self.isSenderModeratorOrAdmin = false self.isTypingIndicator = isTypingIndicator self.profile = nil - self.quote = quote - self.quoteAttachment = nil + self.quotedInfo = quotedInfo self.linkPreview = nil self.linkPreviewAttachment = nil self.currentUserSessionId = "" @@ -800,8 +809,7 @@ public extension MessageViewModel { state: Interaction.State = .sending, isSenderModeratorOrAdmin: Bool, currentUserProfile: Profile, - quote: Quote?, - quoteAttachment: Attachment?, + quotedInfo: QuotedInfo?, linkPreview: LinkPreview?, linkPreviewAttachment: Attachment?, attachments: [Attachment]? @@ -837,8 +845,7 @@ public extension MessageViewModel { self.isSenderModeratorOrAdmin = isSenderModeratorOrAdmin self.isTypingIndicator = false self.profile = currentUserProfile - self.quote = quote - self.quoteAttachment = quoteAttachment + self.quotedInfo = quotedInfo self.linkPreview = linkPreview self.linkPreviewAttachment = linkPreviewAttachment self.currentUserSessionId = currentUserProfile.id @@ -942,13 +949,6 @@ public extension MessageViewModel { let disappearingMessagesConfig: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() let threadProfile: TypedTableAlias = TypedTableAlias(name: "threadProfile") - let quote: TypedTableAlias = TypedTableAlias() - let quoteInteraction: TypedTableAlias = TypedTableAlias(name: "quoteInteraction") - let quoteInteractionAttachment: TypedTableAlias = TypedTableAlias( - name: "quoteInteractionAttachment" - ) - let quoteLinkPreview: TypedTableAlias = TypedTableAlias(name: "quoteLinkPreview") - let quoteAttachment: TypedTableAlias = TypedTableAlias(name: ViewModel.CodingKeys.quoteAttachment.stringValue) let linkPreview: TypedTableAlias = TypedTableAlias() let linkPreviewAttachment: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .linkPreviewAttachment) @@ -993,12 +993,6 @@ public extension MessageViewModel { ) AS \(ViewModel.Columns.isSenderModeratorOrAdmin), \(profile.allColumns), - \(quote[.interactionId]), - \(quote[.authorId]), - \(quote[.timestampMs]), - \(quoteInteraction[.body]), - \(quoteInteractionAttachment[.attachmentId]), - \(quoteAttachment.allColumns), \(linkPreview.allColumns), \(linkPreviewAttachment.allColumns), @@ -1024,32 +1018,6 @@ public extension MessageViewModel { LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) - LEFT JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id]) - LEFT JOIN \(quoteInteraction) ON ( - \(quoteInteraction[.timestampMs]) = \(quote[.timestampMs]) AND ( - \(quoteInteraction[.authorId]) = \(quote[.authorId]) OR ( - -- A users outgoing message is stored in some cases using their standard id - -- but the quote will use their blinded id so handle that case - \(quoteInteraction[.authorId]) = \(userSessionId.hexString) AND - \(quote[.authorId]) IN \(currentUserSessionIds) - ) - ) - ) - LEFT JOIN \(quoteInteractionAttachment) ON ( - \(quoteInteractionAttachment[.interactionId]) = \(quoteInteraction[.id]) AND - \(quoteInteractionAttachment[.albumIndex]) = 0 - ) - LEFT JOIN \(quoteLinkPreview) ON ( - \(quoteLinkPreview[.url]) = \(quoteInteraction[.linkPreviewUrl]) AND - \(Interaction.linkPreviewFilterLiteral( - interaction: quoteInteraction, - linkPreview: quoteLinkPreview - )) - ) - LEFT JOIN \(quoteAttachment) ON ( - \(quoteAttachment[.id]) = \(quoteInteractionAttachment[.attachmentId]) OR - \(quoteAttachment[.id]) = \(quoteLinkPreview[.attachmentId]) - ) LEFT JOIN \(LinkPreview.self) ON ( \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND @@ -1066,18 +1034,14 @@ public extension MessageViewModel { let adapters = try splittingRowAdapters(columnCounts: [ numColumnsBeforeLinkedRecords, Profile.numberOfSelectedColumns(db), - Quote.numberOfSelectedColumns(db), - Attachment.numberOfSelectedColumns(db), LinkPreview.numberOfSelectedColumns(db), Attachment.numberOfSelectedColumns(db) ]) return ScopeAdapter.with(ViewModel.self, [ .profile: adapters[1], - .quote: adapters[2], - .quoteAttachment: adapters[3], - .linkPreview: adapters[4], - .linkPreviewAttachment: adapters[5] + .linkPreview: adapters[2], + .linkPreviewAttachment: adapters[3] ]) } } @@ -1153,9 +1117,9 @@ public extension MessageViewModel.AttachmentInteractionInfo { updatedPagedDataCache = updatedPagedDataCache.upserting( dataToUpdate.with( - attachments: attachments + attachments: .set(to: attachments .sorted() - .map { $0.attachment } + .map { $0.attachment }) ) ) } @@ -1233,7 +1197,7 @@ public extension MessageViewModel.ReactionInfo { else { return } updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with(reactionInfo: reactionInfo.sorted()) + dataToUpdate.with(reactionInfo: .set(to: reactionInfo.sorted())) ) pagedRowIdsWithNoReactions.remove(interactionRowId) } @@ -1243,7 +1207,7 @@ public extension MessageViewModel.ReactionInfo { items: pagedRowIdsWithNoReactions .compactMap { rowId -> ViewModel? in updatedPagedDataCache.data[rowId] } .filter { viewModel -> Bool in (viewModel.reactionInfo?.isEmpty == false) } - .map { viewModel -> ViewModel in viewModel.with(reactionInfo: []) } + .map { viewModel -> ViewModel in viewModel.with(reactionInfo: .set(to: nil)) } ) return updatedPagedDataCache @@ -1312,6 +1276,7 @@ public extension MessageViewModel.QuotedInfo { let quoteInteractionAttachment: TypedTableAlias = TypedTableAlias( name: "quoteInteractionAttachment" ) + let quoteLinkPreview: TypedTableAlias = TypedTableAlias(name: "quoteLinkPreview") let attachment: TypedTableAlias = TypedTableAlias() let finalFilterSQL: SQL = { @@ -1324,11 +1289,14 @@ public extension MessageViewModel.QuotedInfo { """ }() - let numColumnsBeforeLinkedRecords: Int = 1 + let numColumnsBeforeLinkedRecords: Int = 5 let request: SQLRequest = """ SELECT \(quote[.rowId]) AS \(QuotedInfo.Columns.rowId), - \(quote.allColumns), + \(quote[.interactionId]) AS \(QuotedInfo.Columns.interactionId), + \(quote[.authorId]) AS \(QuotedInfo.Columns.authorId), + \(quote[.timestampMs]) AS \(QuotedInfo.Columns.timestampMs), + \(quoteInteraction[.body]) AS \(QuotedInfo.Columns.body), \(attachment.allColumns), \(quoteInteraction[.id]) AS \(QuotedInfo.Columns.quotedInteractionId), \(quoteInteraction[.variant]) AS \(QuotedInfo.Columns.quotedInteractionVariant) @@ -1347,20 +1315,28 @@ public extension MessageViewModel.QuotedInfo { \(quoteInteractionAttachment[.interactionId]) = \(quoteInteraction[.id]) AND \(quoteInteractionAttachment[.albumIndex]) = 0 ) - LEFT JOIN \(Attachment.self) ON \(attachment[.id]) = \(quoteInteractionAttachment[.attachmentId]) + LEFT JOIN \(quoteLinkPreview) ON ( + \(quoteLinkPreview[.url]) = \(quoteInteraction[.linkPreviewUrl]) AND + \(Interaction.linkPreviewFilterLiteral( + interaction: quoteInteraction, + linkPreview: quoteLinkPreview + )) + ) + LEFT JOIN \(Attachment.self) ON ( + \(attachment[.id]) = \(quoteInteractionAttachment[.attachmentId]) OR + \(attachment[.id]) = \(quoteLinkPreview[.attachmentId]) + ) \(finalFilterSQL) """ return request.adapted { db in let adapters = try splittingRowAdapters(columnCounts: [ numColumnsBeforeLinkedRecords, - Quote.numberOfSelectedColumns(db), Attachment.numberOfSelectedColumns(db) ]) return ScopeAdapter.with(QuotedInfo.self, [ - .quote: adapters[1], - .attachment: adapters[2] + .attachment: adapters[1] ]) } } @@ -1380,7 +1356,7 @@ public extension MessageViewModel.QuotedInfo { dataCache.values.compactMap { quotedInfo in guard pagedRowIds.contains(quotedInfo.quotedInteractionId) || - pagedRowIds.contains(quotedInfo.quote.interactionId) + pagedRowIds.contains(quotedInfo.interactionId) else { return nil } return quotedInfo.rowId @@ -1395,7 +1371,7 @@ public extension MessageViewModel.QuotedInfo { // Update changed records dataCache.values.forEach { quoteInfo in guard - let interactionRowId: Int64 = updatedPagedDataCache.lookup[quoteInfo.quote.interactionId], + let interactionRowId: Int64 = updatedPagedDataCache.lookup[quoteInfo.interactionId], let dataToUpdate: ViewModel = updatedPagedDataCache.data[interactionRowId] else { return } @@ -1404,26 +1380,16 @@ public extension MessageViewModel.QuotedInfo { // then remove that content from the quote case false: updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with( - quoteAttachment: quoteInfo.attachment.map { [$0] } - ) + dataToUpdate.with(quotedInfo: .set(to: quoteInfo)) ) // If the original message was deleted and the quote contains some of it's content // then remove that content from the quote case true: - guard - ( - dataToUpdate.quote?.body != nil || - dataToUpdate.quoteAttachment != nil - ) - else { return } + guard dataToUpdate.quotedInfo != nil else { return } updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with( - quote: quoteInfo.quote.withOriginalMessageDeleted(), - quoteAttachment: [] - ) + dataToUpdate.with(quotedInfo: .set(to: nil)) ) } } diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 562a846611..7990949310 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -158,7 +158,7 @@ public struct SessionThreadViewModel: PagableRecord, FetchableRecordWithRowId, D public let contactLastKnownClientVersion: FeatureVersion? public let threadDisplayPictureUrl: String? - internal let contactProfile: Profile? + public let contactProfile: Profile? internal let closedGroupProfileFront: Profile? internal let closedGroupProfileBack: Profile? internal let closedGroupProfileBackFallback: Profile? @@ -614,6 +614,8 @@ public extension SessionThreadViewModel { public extension SessionThreadViewModel { func populatingPostQueryData( + threadDisplayPictureUrl: String?, + contactProfile: Profile?, recentReactionEmoji: [String]?, openGroupCapabilities: Set?, currentUserSessionIds: Set, @@ -648,8 +650,8 @@ public extension SessionThreadViewModel { threadCanUpload: threadCanUpload, disappearingMessagesConfiguration: self.disappearingMessagesConfiguration, contactLastKnownClientVersion: self.contactLastKnownClientVersion, - threadDisplayPictureUrl: self.threadDisplayPictureUrl, - contactProfile: self.contactProfile, + threadDisplayPictureUrl: (threadDisplayPictureUrl ?? self.threadDisplayPictureUrl), + contactProfile: (contactProfile ?? self.contactProfile), closedGroupProfileFront: self.closedGroupProfileFront, closedGroupProfileBack: self.closedGroupProfileBack, closedGroupProfileBackFallback: self.closedGroupProfileBackFallback, diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index a6629109fc..b1d70c272b 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -100,7 +100,7 @@ public final class AttachmentManager: Sendable, ThumbnailManager { .path } - public func pendingUploadPath(for id: String) throws -> String { + public func pendingUploadPath(for id: String) -> String { return URL(fileURLWithPath: placeholderUrlPath()) .appendingPathComponent(id) .path @@ -115,54 +115,73 @@ public final class AttachmentManager: Sendable, ThumbnailManager { return url.path.hasPrefix(placeholderUrlPath()) } - public func temporaryPathForOpening(downloadUrl: String?, mimeType: String?, sourceFilename: String?) throws -> String { - guard let downloadUrl: String = downloadUrl else { throw AttachmentError.invalidData } - + public func temporaryPathForOpening( + originalPath: String, + mimeType: String?, + sourceFilename: String?, + allowInvalidType: Bool + ) throws -> String { /// Since `mimeType` and/or `sourceFilename` can be null we need to try to resolve them both to values let finalExtension: String let targetFilenameNoExtension: String - switch (mimeType, sourceFilename) { - case (.none, .none): throw AttachmentError.invalidData - case (.none, .some(let sourceFilename)): - guard - let type: UTType = UTType( - sessionFileExtension: URL(fileURLWithPath: sourceFilename).pathExtension - ), - let fileExtension: String = type.sessionFileExtension(sourceFilename: sourceFilename) - else { throw AttachmentError.invalidData } - - finalExtension = fileExtension - targetFilenameNoExtension = String(sourceFilename.prefix(sourceFilename.count - (1 + fileExtension.count))) - - case (.some(let mimeType), let sourceFilename): - guard - let fileExtension: String = UTType(sessionMimeType: mimeType)? - .sessionFileExtension(sourceFilename: sourceFilename) - else { throw AttachmentError.invalidData } - - finalExtension = fileExtension - targetFilenameNoExtension = try { - guard let sourceFilename: String = sourceFilename else { - return URL(fileURLWithPath: try path(for: downloadUrl)).lastPathComponent - } + do { + switch (mimeType, sourceFilename) { + case (.none, .none): throw AttachmentError.invalidData + case (.none, .some(let sourceFilename)): + guard + let type: UTType = UTType( + sessionFileExtension: URL(fileURLWithPath: sourceFilename).pathExtension + ), + let fileExtension: String = type.sessionFileExtension(sourceFilename: sourceFilename) + else { throw AttachmentError.invalidData } - return (sourceFilename.hasSuffix(".\(fileExtension)") ? // stringlint:ignore - String(sourceFilename.prefix(sourceFilename.count - (1 + fileExtension.count))) : - sourceFilename - ) - }() + finalExtension = fileExtension + targetFilenameNoExtension = String(sourceFilename.prefix(sourceFilename.count - (1 + fileExtension.count))) + + case (.some(let mimeType), let sourceFilename): + guard + let fileExtension: String = UTType(sessionMimeType: mimeType)? + .sessionFileExtension(sourceFilename: sourceFilename) + else { throw AttachmentError.invalidData } + + finalExtension = fileExtension + targetFilenameNoExtension = { + guard let sourceFilename: String = sourceFilename else { + return URL(fileURLWithPath: originalPath).lastPathComponent + } + + return (sourceFilename.hasSuffix(".\(fileExtension)") ? // stringlint:ignore + String(sourceFilename.prefix(sourceFilename.count - (1 + fileExtension.count))) : + sourceFilename + ) + }() + } + } catch { + /// If an error was thrown it was because we couldn't get a valid file extension, in which case only continue if we want to + /// allow invalid types + guard allowInvalidType else { throw error } + + return URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) + .appendingPathComponent( + URL(fileURLWithPath: originalPath) + .lastPathComponent + .replacingWhitespacesWithUnderscores + ) + .path } - let sanitizedFileName = targetFilenameNoExtension.replacingWhitespacesWithUnderscores - return URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) - .appendingPathComponent(sanitizedFileName) + .appendingPathComponent(targetFilenameNoExtension.replacingWhitespacesWithUnderscores) .appendingPathExtension(finalExtension) .path } - public func createTemporaryFileForOpening(downloadUrl: String?, mimeType: String?, sourceFilename: String?) throws -> String { + public func createTemporaryFileForOpening( + downloadUrl: String?, + mimeType: String?, + sourceFilename: String? + ) throws -> String { let path: String = try path(for: downloadUrl) /// Ensure the original file exists before generating a path for opening or trying to copy it @@ -171,9 +190,10 @@ public final class AttachmentManager: Sendable, ThumbnailManager { } let tmpPath: String = try temporaryPathForOpening( - downloadUrl: downloadUrl, + originalPath: path, mimeType: mimeType, - sourceFilename: sourceFilename + sourceFilename: sourceFilename, + allowInvalidType: false ) /// If the file already exists (since it's deterministically generated) then no need to copy it again @@ -828,7 +848,7 @@ public extension PendingAttachment { /// rather than in the temporary directory because the `AttachmentUploadJob` can exist between launches, but the temporary /// directory gets cleared on every launch) let attachmentId: String = (existingAttachmentId ?? UUID().uuidString) - let filePath: String = try (storeAtPendingAttachmentUploadPath ? + let filePath: String = (storeAtPendingAttachmentUploadPath ? dependencies[singleton: .attachmentManager].pendingUploadPath(for: attachmentId) : dependencies[singleton: .fileManager].temporaryFilePath() ) diff --git a/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift b/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift index 8681ce24c8..4ef4c200d4 100644 --- a/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/Notifications/NotificationsManagerSpec.swift @@ -220,8 +220,7 @@ class NotificationsManagerSpec: QuickSpec { text: "Test", quote: VisibleMessage.VMQuote( timestamp: 1234567880, - authorId: "05\(TestConstants.publicKey)", - text: "TestQuote" + authorId: "05\(TestConstants.publicKey)" ) ) diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index 3f0139e73b..3566ed92a6 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -468,7 +468,8 @@ final class ShareNavController: UINavigationController { return continuation.resume( returning: PendingAttachment( source: .file(URL(fileURLWithPath: tmpPath)), - utType: .url, + utType: (UTType(sessionFileExtension: url.pathExtension) ?? .url), + sourceFilename: url.lastPathComponent, using: dependencies ) ) diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 367c3258d3..6dbf7c0196 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -248,7 +248,14 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView // Sharing a URL or plain text will populate the 'messageText' field so in those // cases we should ignore the attachments let isSharingUrl: Bool = (attachments.count == 1 && attachments[0].utType.conforms(to: .url)) - let isSharingText: Bool = (attachments.count == 1 && attachments[0].utType.isText) + let isSharingText: Bool = { + guard attachments.count == 1 else { return false } + + switch attachments[0].source { + case .text: return true + default: return false + } + }() let finalPendingAttachments: [PendingAttachment] = (isSharingUrl || isSharingText ? [] : attachments) let body: String? = { guard isSharingUrl else { return messageText } @@ -302,10 +309,10 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView try Task.checkCancellation() /// If there is a `LinkPreviewDraft` then we may need to add it, so generate it's attachment if possible - var linkPreviewAttachment: Attachment? + var linkPreviewPreparedAttachment: PreparedAttachment? if let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft { - linkPreviewAttachment = try? await LinkPreview.generateAttachmentIfPossible( + linkPreviewPreparedAttachment = try? await LinkPreview.prepareAttachmentIfPossible( urlString: linkPreviewDraft.urlString, imageData: linkPreviewDraft.jpegImageData, type: .jpeg, @@ -380,7 +387,8 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView try LinkPreview( url: linkPreviewDraft.urlString, title: linkPreviewDraft.title, - attachmentId: linkPreviewAttachment? + attachmentId: linkPreviewPreparedAttachment? + .attachment .inserted(db) .id, using: dependencies diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index e8323b83f4..0e5c08900d 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -75,7 +75,18 @@ public class ThreadPickerViewModel { } ) + // TODO: [Database Relocation] Clean up this query as well + var targetContactProfile: Profile? = threadViewModel.contactProfile + var targetThreadDisplayPictureUrl: String? = threadViewModel.threadDisplayPictureUrl + + if threadViewModel.id == userSessionId.hexString { + targetContactProfile = dependencies.mutate(cache: .libSession) { $0.profile } + targetThreadDisplayPictureUrl = targetContactProfile?.displayPictureUrl + } + return threadViewModel.populatingPostQueryData( + threadDisplayPictureUrl: targetThreadDisplayPictureUrl, + contactProfile: targetContactProfile, recentReactionEmoji: nil, openGroupCapabilities: nil, currentUserSessionIds: [userSessionId.hexString], diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index 554f5d7fd3..2cbef2e093 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -256,7 +256,13 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC // If the first item is just text, or is a URL and LinkPreviews are disabled // then just fill the 'message' box with it - if firstItem.attachment.utType.isText || (firstItem.attachment.utType.conforms(to: .url) && LinkPreview.previewUrl(for: firstItem.attachment.toText(), using: dependencies) == nil) { + let firstItemIsPlainText: Bool = { + switch firstItem.attachment.source { + case .text: return true + default: return false + } + }() + if firstItemIsPlainText || (firstItem.attachment.utType.conforms(to: .url) && LinkPreview.previewUrl(for: firstItem.attachment.toText(), using: dependencies) == nil) { bottomToolView.attachmentTextToolbar.text = firstItem.attachment.toText() } } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift index 91cc271a2f..1aa45befff 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift @@ -271,8 +271,10 @@ public class MediaMessageView: UIView { // MARK: - Layout private func setupViews(using dependencies: Dependencies) { - // Plain text will just be put in the 'message' input so do nothing - guard !attachment.utType.isText else { return } + switch attachment.source { + case .text: return /// Plain text will just be put in the 'message' input so do nothing + default: break + } // Setup the view hierarchy addSubview(stackView) @@ -318,8 +320,10 @@ public class MediaMessageView: UIView { } private func setupLayout() { - // Plain text will just be put in the 'message' input so do nothing - guard !attachment.utType.isText else { return } + switch attachment.source { + case .text: return /// Plain text will just be put in the 'message' input so do nothing + default: break + } // Sizing calculations let clampedRatio: CGFloat = { From c25071f73f6633847317ee7fa4491be73259bdcd Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 21 Oct 2025 12:51:14 +1100 Subject: [PATCH 126/162] Fixed more QA issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added more details to profile update logs (also added debug logs for updating other users profiles) • Added a delay before killing the app after clearing data to give the libSession logger more time to shutdown (false crash reports) • Removed some emoji encoding which is no longer needed • Fixed a couple of cases where we were trying to access the libSession cache for new accounts before it had been created • Fixed a bug where we could try to render a profile picture for the current user even if it hadn't been downloaded yet (resulting in nothing being rendered) • Fixed a bug where restoring an account would clear the users display picture • Fixed a bug where we wouldn't immediately download the users display picture after restoring an account • Fixed a bug where video attachments weren't getting their metadata extracted correctly (preventing them from being sent correctly) • Fixed a bug where some video formats might not be recognised as video • Fixed a bug preventing emoji reactions in communities • Fixed a bug where portrait images wouldn't crop correctly --- .../SendMediaNavigationController.swift | 2 +- Session/Meta/AppDelegate.swift | 26 +++-- Session/Meta/Session+SNUIKit.swift | 4 +- Session/Meta/SessionApp.swift | 6 +- Session/Onboarding/Onboarding.swift | 38 ++++++- .../DeveloperSettingsViewModel.swift | 2 +- Session/Shared/ScreenLockWindow.swift | 4 +- .../_028_GenerateInitialUserConfigDumps.swift | 6 +- .../Database/Models/Interaction.swift | 3 + .../Database/Models/LinkPreview.swift | 1 + .../Jobs/DisplayPictureDownloadJob.swift | 61 ++++++---- .../Jobs/GarbageCollectionJob.swift | 6 +- .../LibSession+UserProfile.swift | 52 +++++++-- .../LibSession+SessionMessagingKit.swift | 18 +-- .../Pollers/SwarmPoller.swift | 11 +- .../Utilities/AttachmentManager.swift | 25 ++++- .../Utilities/Profile+Updating.swift | 23 +++- .../ProfilePictureView+Convenience.swift | 17 +-- SessionNetworkingKit/SOGS/SOGSAPI.swift | 32 +----- .../SessionNetwork/SessionNetworkAPI.swift | 20 ++-- SessionNetworkingKit/Types/Destination.swift | 6 +- .../Types/PreparedRequest.swift | 21 ---- .../Types/DestinationSpec.swift | 28 ----- .../ShareNavController.swift | 8 +- .../Components/ProfilePictureView.swift | 104 +++++++----------- SessionUIKit/Types/ImageDataManager.swift | 23 ++++ .../LibSession/Types/ObservingDatabase.swift | 4 + SessionUtilitiesKit/Media/MediaUtils.swift | 15 +-- .../Media/UTType+Utilities.swift | 9 +- .../Utilities/AVURLAsset+Utilities.swift | 18 ++- .../MediaMessageView.swift | 4 +- 31 files changed, 320 insertions(+), 277 deletions(-) diff --git a/Session/Media Viewing & Editing/SendMediaNavigationController.swift b/Session/Media Viewing & Editing/SendMediaNavigationController.swift index 173893ae08..89663e8702 100644 --- a/Session/Media Viewing & Editing/SendMediaNavigationController.swift +++ b/Session/Media Viewing & Editing/SendMediaNavigationController.swift @@ -276,7 +276,7 @@ class SendMediaNavigationController: UINavigationController { case .failure: break case .success(let info): switch info.attachment.visualMediaSource { - case .url(let url): + case .url(let url), .videoUrl(let url, _, _, _): if fileManager.isLocatedInTemporaryDirectory(url.path) { try? fileManager.removeItem(atPath: url.path) } diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 27ee35d5db..72d0ee2c58 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -130,13 +130,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD Task { @MainActor in SNUIKit.configure( with: SessionSNUIKitConfig(using: dependencies), - themeSettings: dependencies.mutate(cache: .libSession) { cache -> ThemeSettings in - ( - cache.get(.theme), - cache.get(.themePrimaryColor), - cache.get(.themeMatchSystemDayNightCycle) - ) - } + themeSettings: { + /// Only try to extract the theme settings if we actually have an account (if not the `libSession` + /// cache won't exist anyway) + guard dependencies[cache: .general].userExists else { return nil } + + return dependencies.mutate(cache: .libSession) { cache -> ThemeSettings in + ( + cache.get(.theme), + cache.get(.themePrimaryColor), + cache.get(.themeMatchSystemDayNightCycle) + ) + } + }() ) } @@ -147,8 +153,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD /// So we need this to keep it the correct order of the permission chain. /// For users who already enabled the calls permission and made calls, the local network permission should already be asked for. /// It won't affect anything. - dependencies[defaults: .standard, key: .hasRequestedLocalNetworkPermission] = dependencies.mutate(cache: .libSession) { cache in - cache.get(.areCallsEnabled) + if dependencies[cache: .general].userExists { + dependencies[defaults: .standard, key: .hasRequestedLocalNetworkPermission] = dependencies.mutate(cache: .libSession) { cache in + cache.get(.areCallsEnabled) + } } /// Now that the theme settings have been applied we can complete the migrations diff --git a/Session/Meta/Session+SNUIKit.swift b/Session/Meta/Session+SNUIKit.swift index a73be074ec..1f146f1257 100644 --- a/Session/Meta/Session+SNUIKit.swift +++ b/Session/Meta/Session+SNUIKit.swift @@ -91,7 +91,7 @@ internal struct SessionSNUIKitConfig: SNUIKit.ConfigType { func assetInfo(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, isValidVideo: Bool, cleanup: () -> Void)? { guard - let result: (asset: AVURLAsset, cleanup: () -> Void) = AVURLAsset.asset( + let result: (asset: AVURLAsset, utType: UTType, cleanup: () -> Void) = AVURLAsset.asset( for: path, utType: utType, sourceFilename: sourceFilename, @@ -99,7 +99,7 @@ internal struct SessionSNUIKitConfig: SNUIKit.ConfigType { ) else { return nil } - return (result.asset, MediaUtils.isValidVideo(asset: result.asset), result.cleanup) + return (result.asset, MediaUtils.isValidVideo(asset: result.asset, utType: result.utType), result.cleanup) } func mediaDecoderDefaultImageOptions() -> CFDictionary { diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index e311b2ed71..d468e3d906 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -149,9 +149,9 @@ public class SessionApp: SessionAppType { Log.info("Data Reset Complete.") Log.flush() - /// Wait until the next run loop to kill the app (hoping to avoid a crash due to the connection closes - /// triggering logs) - DispatchQueue.main.async { + /// Wait for a small duration before killing the app (hoping to avoid a crash due to `libSession` shutting down connections + /// which result in spdlog trying to log and crashing) + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { exit(0) } } diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 116b92cdad..da1c130d59 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -135,8 +135,13 @@ extension Onboarding { return KeyPair(publicKey: x25519PublicKey, secretKey: x25519SecretKey) }() - /// Retrieve the users `displayName` from `libSession` (the source of truth) - let displayName: String = dependencies.mutate(cache: .libSession) { $0.profile }.name + /// Retrieve the users `displayName` from `libSession` (the source of truth - if the `ed25519SecretKey` is + /// empty then we don't have an account yet so don't want to try to access the invalid `libSession` cache) + let displayName: String = (ed25519SecretKey.isEmpty ? + "" : + dependencies.mutate(cache: .libSession) { $0.profile }.name + ) + let hasInitialDisplayName: Bool = !displayName.isEmpty self.ed25519KeyPair = ed25519KeyPair @@ -395,9 +400,32 @@ extension Onboarding { ) } - /// Update the `displayName` and trigger a dump/push of the config - try? cache.performAndPushChange(db, for: .userProfile) { - try? cache.updateProfile(displayName: displayName) + /// Update the `displayName` if changed and trigger a dump/push of the config + let cacheProfile: Profile = cache.profile + + if cacheProfile.name != displayName { + try? cache.performAndPushChange(db, for: .userProfile) { + try? cache.updateProfile(displayName: displayName) + } + } + + /// If the account has a display picture then we need to download it + if + let url: String = cacheProfile.displayPictureUrl, + let key: Data = cacheProfile.displayPictureEncryptionKey + { + dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details( + target: .profile(id: cacheProfile.id, url: url, encryptionKey: key), + timestamp: cacheProfile.profileLastUpdated + ) + ), + canStartJob: dependencies[singleton: .appContext].isMainApp + ) } } diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift index 8f4aef431a..5978f00441 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel.swift @@ -1058,7 +1058,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, x25519KeyPair: identityData.x25519KeyPair, displayName: existingProfile.name .nullIfEmpty - .defaulting(to: "Anonymous"), + .defaulting(to: "anonymous".localized()), using: dependencies ).completeRegistration { [dependencies] in /// Re-enable developer mode diff --git a/Session/Shared/ScreenLockWindow.swift b/Session/Shared/ScreenLockWindow.swift index b2d25211fb..d41dfb1ef5 100644 --- a/Session/Shared/ScreenLockWindow.swift +++ b/Session/Shared/ScreenLockWindow.swift @@ -118,7 +118,9 @@ public class ScreenLockWindow { /// /// It's not safe to access `isScreenLockEnabled` in `storage` until the app is ready dependencies[singleton: .appReadiness].runNowOrWhenAppWillBecomeReady { [weak self, dependencies] in - self?.isScreenLockLocked = dependencies.mutate(cache: .libSession, { $0.get(.isScreenLockEnabled) }) + if dependencies[cache: .general].userExists { + self?.isScreenLockLocked = dependencies.mutate(cache: .libSession, { $0.get(.isScreenLockEnabled) }) + } switch Thread.isMainThread { case true: self?.ensureUI() diff --git a/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift b/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift index 76f27c71dd..fc0e97b3d2 100644 --- a/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift +++ b/SessionMessagingKit/Database/Migrations/_028_GenerateInitialUserConfigDumps.swift @@ -61,9 +61,9 @@ enum _028_GenerateInitialUserConfigDumps: Migration { arguments: [userSessionId.hexString] ) try cache.updateProfile( - displayName: (userProfile?["name"] ?? ""), - displayPictureUrl: userProfile?["profilePictureUrl"], - displayPictureEncryptionKey: userProfile?["profileEncryptionKey"], + displayName: .set(to: (userProfile?["name"] ?? "")), + displayPictureUrl: .set(to: userProfile?["profilePictureUrl"]), + displayPictureEncryptionKey: .set(to: userProfile?["profileEncryptionKey"]), isReuploadProfilePicture: false ) diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 26be5ee827..5df57bec91 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -531,6 +531,9 @@ public extension Interaction { _ db: ObservingDatabase, using dependencies: Dependencies ) throws -> Int { + /// If we don't have an account yet then no need to do any queries + guard dependencies[cache: .general].userExists else { return 0 } + // TODO: [Database Relocation] Should be able to clean this up by getting the conversation list and filtering struct ThreadIdVariant: Decodable, Hashable, FetchableRecord { let id: String diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 50cbacb4f9..464e76934e 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -148,6 +148,7 @@ public extension LinkPreview { let targetFormat: PendingAttachment.ConversionFormat = (dependencies[feature: .usePngInsteadOfWebPForFallbackImageType] ? .png(maxDimension: LinkPreview.maxImageDimension) : .webPLossy(maxDimension: LinkPreview.maxImageDimension) ) + return try await pendingAttachment.prepare( operations: [ .convert(to: targetFormat), diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index c287161e55..b20cf27433 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -70,7 +70,7 @@ public enum DisplayPictureDownloadJob: JobExecutor { try details.ensureValidUpdate(db, using: dependencies) } - let downloadUrl: String = ((try? request.generateUrl())?.absoluteString ?? request.path) + let downloadUrl: String = details.target.downloadUrl let filePath: String = try dependencies[singleton: .displayPictureManager] .path(for: downloadUrl) @@ -149,8 +149,22 @@ public enum DisplayPictureDownloadJob: JobExecutor { .fetchOne(db) } } + + /// Store the updated information in the database (this will generally result in the UI refreshing as it'll observe + /// the `downloadUrl` changing) + try await dependencies[singleton: .storage].writeAsync { db in + try writeChanges( + db, + details: details, + downloadUrl: downloadUrl, + using: dependencies + ) + } + + /// Remove the old display picture (as long as it's different from the new one) if let existingProfileUrl: String = existingProfileUrl, + existingProfileUrl != downloadUrl, let existingFilePath: String = try? dependencies[singleton: .displayPictureManager] .path(for: existingProfileUrl) { @@ -162,17 +176,6 @@ public enum DisplayPictureDownloadJob: JobExecutor { } } - /// Store the updated information in the database (this will generally result in the UI refreshing as it'll observe - /// the `downloadUrl` changing) - try await dependencies[singleton: .storage].writeAsync { db in - try writeChanges( - db, - details: details, - downloadUrl: downloadUrl, - using: dependencies - ) - } - return scheduler.schedule { success(job, false) } @@ -243,15 +246,19 @@ public enum DisplayPictureDownloadJob: JobExecutor { ) throws { switch details.target { case .profile(let id, let url, let encryptionKey): - _ = try? Profile - .filter(id: id) - .updateAllAndConfig( - db, - Profile.Columns.displayPictureUrl.set(to: url), - Profile.Columns.displayPictureEncryptionKey.set(to: encryptionKey), - Profile.Columns.profileLastUpdated.set(to: details.timestamp), - using: dependencies - ) + /// Don't want to store the current users profile data in the database (should only be sourced from `libSession`) + if id != dependencies[cache: .general].sessionId.hexString { + _ = try? Profile + .filter(id: id) + .updateAllAndConfig( + db, + Profile.Columns.displayPictureUrl.set(to: url), + Profile.Columns.displayPictureEncryptionKey.set(to: encryptionKey), + Profile.Columns.profileLastUpdated.set(to: details.timestamp), + using: dependencies + ) + } + db.addProfileEvent(id: id, change: .displayPictureUrl(url)) db.addConversationEvent(id: id, type: .updated(.displayPictureUrl(url))) @@ -310,6 +317,14 @@ extension DisplayPictureDownloadJob { } } + var downloadUrl: String { + switch self { + case .profile(_, let url, _), .group(_, let url, _): return url + case .community(let fileId, let roomToken, let server, _): + return Network.SOGS.downloadUrlString(for: fileId, server: server, roomToken: roomToken) + } + } + // MARK: - CustomStringConvertible public var description: String { @@ -424,8 +439,8 @@ extension DisplayPictureDownloadJob { ) guard - Profile.shouldUpdateProfile(timestamp, profile: latestProfile, using: dependencies) || - dataMatches + dataMatches || + Profile.shouldUpdateProfile(timestamp, profile: latestProfile, using: dependencies) else { throw AttachmentError.downloadNoLongerValid } break diff --git a/SessionMessagingKit/Jobs/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/GarbageCollectionJob.swift index aacaa48e5c..9a85a06a1b 100644 --- a/SessionMessagingKit/Jobs/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/GarbageCollectionJob.swift @@ -385,7 +385,7 @@ public enum GarbageCollectionJob: JobExecutor { .filter(Profile.Columns.displayPictureUrl != nil) .asRequest(of: String.self) .fetchSet(db) - .compactMap { try? dependencies[singleton: .displayPictureManager].path(for: $0) }) + .compactMap { try? dependencies[singleton: .displayPictureManager].path(for: $0) }) ) displayPictureFilePaths.insert( contentsOf: Set(try ClosedGroup @@ -393,7 +393,7 @@ public enum GarbageCollectionJob: JobExecutor { .filter(ClosedGroup.Columns.displayPictureUrl != nil) .asRequest(of: String.self) .fetchSet(db) - .compactMap { try? dependencies[singleton: .displayPictureManager].path(for: $0) }) + .compactMap { try? dependencies[singleton: .displayPictureManager].path(for: $0) }) ) displayPictureFilePaths.insert( contentsOf: Set(try OpenGroup @@ -401,7 +401,7 @@ public enum GarbageCollectionJob: JobExecutor { .filter(OpenGroup.Columns.displayPictureOriginalUrl != nil) .asRequest(of: String.self) .fetchSet(db) - .compactMap { try? dependencies[singleton: .displayPictureManager].path(for: $0) }) + .compactMap { try? dependencies[singleton: .displayPictureManager].path(for: $0) }) ) } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index d10434958d..ffcbbc62e7 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -39,6 +39,7 @@ internal extension LibSessionCacheType { let profileName: String = String(cString: profileNamePtr) let displayPic: user_profile_pic = user_profile_get_pic(conf) let displayPictureUrl: String? = displayPic.get(\.url, nullIfEmpty: true) + let displayPictureEncryptionKey: Data? = displayPic.get(\.key, nullIfEmpty: true) let profileLastUpdateTimestamp: TimeInterval = TimeInterval(user_profile_get_profile_updated(conf)) let updatedProfile: Profile = Profile( id: userSessionId.hexString, @@ -63,11 +64,14 @@ internal extension LibSessionCacheType { publicKey: userSessionId.hexString, displayNameUpdate: .currentUserUpdate(profileName), displayPictureUpdate: { - guard let displayPictureUrl: String = displayPictureUrl else { return .currentUserRemove } + guard + let displayPictureUrl: String = displayPictureUrl, + let displayPictureEncryptionKey: Data = displayPictureEncryptionKey + else { return .currentUserRemove } return .currentUserUpdateTo( url: displayPictureUrl, - key: displayPic.get(\.key), + key: displayPictureEncryptionKey, sessionProProof: getProProof(), // TODO: double check if this is needed after Pro Proof is implemented isReupload: false ) @@ -77,6 +81,25 @@ internal extension LibSessionCacheType { using: dependencies ) + // Kick off a job to download the display picture + if + let url: String = displayPictureUrl, + let key: Data = displayPictureEncryptionKey + { + dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details( + target: .profile(id: userSessionId.hexString, url: url, encryptionKey: key), + timestamp: profileLastUpdateTimestamp + ) + ), + canStartJob: dependencies[singleton: .appContext].isMainApp + ) + } + // Extract the 'Note to Self' conversation settings let targetPriority: Int32 = user_profile_get_nts_priority(conf) let targetExpiry: Int32 = user_profile_get_nts_expiry(conf) @@ -206,9 +229,9 @@ public extension LibSession.Cache { } func updateProfile( - displayName: String, - displayPictureUrl: String?, - displayPictureEncryptionKey: Data?, + displayName: Update, + displayPictureUrl: Update, + displayPictureEncryptionKey: Update, isReuploadProfilePicture: Bool ) throws { guard let config: LibSession.Config = config(for: .userProfile, sessionId: userSessionId) else { @@ -220,11 +243,13 @@ public extension LibSession.Cache { // Get the old values to determine if something changed let oldName: String? = user_profile_get_name(conf).map { String(cString: $0) } + let oldNameFallback: String = (oldName ?? "") let oldDisplayPic: user_profile_pic = user_profile_get_pic(conf) let oldDisplayPictureUrl: String? = oldDisplayPic.get(\.url, nullIfEmpty: true) + let oldDisplayPictureKey: Data? = oldDisplayPic.get(\.key, nullIfEmpty: true) // Update the name - var cUpdatedName: [CChar] = try displayName.cString(using: .utf8) ?? { + var cUpdatedName: [CChar] = try displayName.or(oldNameFallback).cString(using: .utf8) ?? { throw LibSessionError.invalidCConversion }() user_profile_set_name(conf, &cUpdatedName) @@ -232,8 +257,8 @@ public extension LibSession.Cache { // Either assign the updated profile pic, or sent a blank profile pic (to remove the current one) var profilePic: user_profile_pic = user_profile_pic() - profilePic.set(\.url, to: displayPictureUrl) - profilePic.set(\.key, to: displayPictureEncryptionKey) + profilePic.set(\.url, to: displayPictureUrl.or(oldDisplayPictureUrl)) + profilePic.set(\.key, to: displayPictureEncryptionKey.or(oldDisplayPictureKey)) switch isReuploadProfilePicture { case true: user_profile_set_reupload_pic(conf, profilePic) @@ -243,17 +268,20 @@ public extension LibSession.Cache { try LibSessionError.throwIfNeeded(conf) /// Add a pending observation to notify any observers of the change once it's committed - if displayName != oldName { + if displayName.or("") != oldName { addEvent( key: .profile(userSessionId.hexString), - value: ProfileEvent(id: userSessionId.hexString, change: .name(displayName)) + value: ProfileEvent(id: userSessionId.hexString, change: .name(displayName.or(oldNameFallback))) ) } - if displayPictureUrl != oldDisplayPictureUrl { + if displayPictureUrl.or(oldDisplayPictureUrl) != oldDisplayPictureUrl { addEvent( key: .profile(userSessionId.hexString), - value: ProfileEvent(id: userSessionId.hexString, change: .displayPictureUrl(displayPictureUrl)) + value: ProfileEvent( + id: userSessionId.hexString, + change: .displayPictureUrl(displayPictureUrl.or(oldDisplayPictureUrl)) + ) ) } } diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index ee2407b0e8..09a5d765c7 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -1043,9 +1043,9 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT var displayName: String? { get } func updateProfile( - displayName: String, - displayPictureUrl: String?, - displayPictureEncryptionKey: Data?, + displayName: Update, + displayPictureUrl: Update, + displayPictureEncryptionKey: Update, isReuploadProfilePicture: Bool ) throws @@ -1187,9 +1187,9 @@ public extension LibSessionCacheType { func updateProfile(displayName: String) throws { try updateProfile( - displayName: displayName, - displayPictureUrl: nil, - displayPictureEncryptionKey: nil, + displayName: .set(to: displayName), + displayPictureUrl: .useExisting, + displayPictureEncryptionKey: .useExisting, isReuploadProfilePicture: false ) } @@ -1324,9 +1324,9 @@ private final class NoopLibSessionCache: LibSessionCacheType, NoopDependency { func set(_ key: Setting.BoolKey, _ value: Bool?) {} func set(_ key: Setting.EnumKey, _ value: T?) {} func updateProfile( - displayName: String, - displayPictureUrl: String?, - displayPictureEncryptionKey: Data?, + displayName: Update, + displayPictureUrl: Update, + displayPictureEncryptionKey: Update, isReuploadProfilePicture: Bool ) throws {} diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift index ba5c1bd1f6..306a12dc92 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift @@ -108,9 +108,14 @@ public class SwarmPoller: SwarmPollerType & PollerType { /// for cases where we need explicit/custom behaviours to occur (eg. Onboarding) public func poll(forceSynchronousProcessing: Bool) -> AnyPublisher { let pollerQueue: DispatchQueue = self.pollerQueue - let activeHashes: [String] = dependencies.mutate(cache: .libSession) { cache in - cache.activeHashes(for: pollerDestination.target) - } + let activeHashes: [String] = { + /// If we don't have an account then there won't be any active hashes so don't bother trying to get them + guard dependencies[cache: .general].userExists else { return [] } + + return dependencies.mutate(cache: .libSession) { cache in + cache.activeHashes(for: pollerDestination.target) + } + }() /// Fetch the messages return dependencies[singleton: .network] diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index b1d70c272b..c118911c91 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -420,8 +420,8 @@ public struct PendingAttachment: Sendable, Equatable, Hashable { ) -> Metadata? { let maybeFileSize: UInt64? = dataSource.fileSize(using: dependencies) - switch (dataSource, dataSource.visualMediaSource) { - case (.file(let url), _), (.voiceMessage(let url), _): + switch dataSource { + case .file(let url), .voiceMessage(let url): guard let utType: UTType = utType, let fileSize: UInt64 = maybeFileSize @@ -439,14 +439,26 @@ public struct PendingAttachment: Sendable, Equatable, Hashable { return .media(metadata) - case (_, .image(_, .some(let image))): + case .media(.image(_, .some(let image))): guard let metadata: MediaUtils.MediaMetadata = MediaUtils.MediaMetadata(image: image) else { return nil } return .media(metadata) - case (.media(let mediaSource), _): + case .media(.videoUrl(let url, _, _, _)): + guard + let metadata: MediaUtils.MediaMetadata = MediaUtils.MediaMetadata( + from: url.path, + utType: utType, + sourceFilename: sourceFilename, + using: dependencies + ) + else { return nil } + + return .media(metadata) + + case .media(let mediaSource): guard let fileSize: UInt64 = maybeFileSize, let source: CGImageSource = mediaSource.createImageSource(), @@ -458,7 +470,7 @@ public struct PendingAttachment: Sendable, Equatable, Hashable { return .media(metadata) - case (.text, _): + case .text: guard let utType: UTType = utType, let fileSize: UInt64 = maybeFileSize @@ -508,7 +520,8 @@ public extension PendingAttachment { fileprivate func fileSize(using dependencies: Dependencies) -> UInt64? { switch (self, visualMediaSource) { - case (.file(let url), _), (.voiceMessage(let url), _), (_, .url(let url)): + case (.file(let url), _), (.voiceMessage(let url), _), (_, .url(let url)), + (_, .videoUrl(let url, _, _, _)): return dependencies[singleton: .fileManager].fileSize(of: url.path) case (_, .data(_, let data)): return UInt64(data.count) diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index 0ad221fe6d..d15c96ea7d 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -78,7 +78,6 @@ public extension Profile { using: dependencies ) } - Log.info(.profile, "Successfully updated user profile.") } catch { throw AttachmentError.databaseChangesFailed } } @@ -230,6 +229,20 @@ public extension Profile { /// Persist any changes if !profileChanges.isEmpty { + let changeString: String = db.currentEvents() + .filter { $0.key.generic == .profile } + .compactMap { + switch ($0.value as? ProfileEvent)?.change { + case .none: return nil + case .name: return "name updated" + case .displayPictureUrl(let url): + return (url != nil ? "displayPictureUrl updated" : "displayPictureUrl removed") + + case .nickname(let nickname): + return (nickname != nil ? "nickname updated" : "nickname removed") + } + } + .joined(separator: ", ") updatedProfile = updatedProfile.with(profileLastUpdated: .set(to: profileUpdateTimestamp)) profileChanges.append(Profile.Columns.profileLastUpdated.set(to: profileUpdateTimestamp)) @@ -244,6 +257,7 @@ public extension Profile { profileChanges, using: dependencies ) + Log.debug(.profile, "Successfully updated profile for \(publicKey) (\(changeString)).") } /// We don't automatically update the current users profile data when changed in the database so need to manually @@ -252,9 +266,9 @@ public extension Profile { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .userProfile, sessionId: userSessionId) { _ in try cache.updateProfile( - displayName: updatedProfile.name, - displayPictureUrl: updatedProfile.displayPictureUrl, - displayPictureEncryptionKey: updatedProfile.displayPictureEncryptionKey, + displayName: .set(to: updatedProfile.name), + displayPictureUrl: .set(to: updatedProfile.displayPictureUrl), + displayPictureEncryptionKey: .set(to: updatedProfile.displayPictureEncryptionKey), isReuploadProfilePicture: { switch displayPictureUpdate { case .currentUserUpdateTo(_, _, _, let isReupload): return isReupload @@ -264,6 +278,7 @@ public extension Profile { ) } } + Log.info(.profile, "Successfully updated user profile (\(changeString)).") } } } diff --git a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift index 6a0ddff983..1806635be0 100644 --- a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift +++ b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift @@ -46,11 +46,12 @@ public extension ProfilePictureView { let explicitPath: String? = try? dependencies[singleton: .displayPictureManager].path( for: displayPictureUrl ) + let explicitPathFileExists: Bool = (explicitPath.map { dependencies[singleton: .fileManager].fileExists(atPath: $0) } ?? false) - switch (explicitPath, publicKey.isEmpty, threadVariant) { + switch (explicitPath, explicitPathFileExists, publicKey.isEmpty, threadVariant) { // TODO: Deal with this case later when implement group related Pro features - case (.some(let path), _, .legacyGroup), (.some(let path), _, .group): fallthrough - case (.some(let path), _, .community): + case (.some(let path), true, _, .legacyGroup), (.some(let path), true, _, .group): fallthrough + case (.some(let path), true, _, .community): /// If we are given an explicit `displayPictureUrl` then only use that return (Info( source: .url(URL(fileURLWithPath: path)), @@ -58,7 +59,7 @@ public extension ProfilePictureView { icon: profileIcon ), nil) - case (.some(let path), _, _): + case (.some(let path), true, _, _): /// If we are given an explicit `displayPictureUrl` then only use that return ( Info( @@ -69,7 +70,7 @@ public extension ProfilePictureView { nil ) - case (_, _, .community): + case (_, _, _, .community): return ( Info( source: { @@ -92,9 +93,9 @@ public extension ProfilePictureView { nil ) - case (_, true, _): return (nil, nil) + case (_, _, true, _): return (nil, nil) - case (_, _, .legacyGroup), (_, _, .group): + case (_, _, _, .legacyGroup), (_, _, _, .group): let source: ImageDataManager.DataSource = { guard let path: String = try? dependencies[singleton: .displayPictureManager] @@ -162,7 +163,7 @@ public extension ProfilePictureView { ) ) - case (_, _, .contact): + case (_, _, _, .contact): let source: ImageDataManager.DataSource = { guard let path: String = try? dependencies[singleton: .displayPictureManager] diff --git a/SessionNetworkingKit/SOGS/SOGSAPI.swift b/SessionNetworkingKit/SOGS/SOGSAPI.swift index 4700b36d80..a4a3b34b7e 100644 --- a/SessionNetworkingKit/SOGS/SOGSAPI.swift +++ b/SessionNetworkingKit/SOGS/SOGSAPI.swift @@ -621,16 +621,10 @@ public extension Network.SOGS { authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { - /// URL(String:) won't convert raw emojis, so need to do a little encoding here. - /// The raw emoji will come back when calling url.path - guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - throw SOGSError.invalidEmoji - } - return try Network.PreparedRequest( request: Request( method: .get, - endpoint: .reactors(roomToken, id: id, emoji: encodedEmoji), + endpoint: .reactors(roomToken, id: id, emoji: emoji), authMethod: authMethod ), responseType: NoResponse.self, @@ -651,16 +645,10 @@ public extension Network.SOGS { authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { - /// URL(String:) won't convert raw emojis, so need to do a little encoding here. - /// The raw emoji will come back when calling url.path - guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - throw SOGSError.invalidEmoji - } - return try Network.PreparedRequest( request: Request( method: .put, - endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji), + endpoint: .reaction(roomToken, id: id, emoji: emoji), authMethod: authMethod ), responseType: ReactionAddResponse.self, @@ -679,16 +667,10 @@ public extension Network.SOGS { authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { - /// URL(String:) won't convert raw emojis, so need to do a little encoding here. - /// The raw emoji will come back when calling url.path - guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - throw SOGSError.invalidEmoji - } - return try Network.PreparedRequest( request: Request( method: .delete, - endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji), + endpoint: .reaction(roomToken, id: id, emoji: emoji), authMethod: authMethod ), responseType: ReactionRemoveResponse.self, @@ -708,16 +690,10 @@ public extension Network.SOGS { authMethod: AuthenticationMethod, using dependencies: Dependencies ) throws -> Network.PreparedRequest { - /// URL(String:) won't convert raw emojis, so need to do a little encoding here. - /// The raw emoji will come back when calling url.path - guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - throw SOGSError.invalidEmoji - } - return try Network.PreparedRequest( request: Request( method: .delete, - endpoint: .reactionDelete(roomToken, id: id, emoji: encodedEmoji), + endpoint: .reactionDelete(roomToken, id: id, emoji: emoji), authMethod: authMethod ), responseType: ReactionRemoveAllResponse.self, diff --git a/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift b/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift index 962d1bd99f..a0fe1260a5 100644 --- a/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift +++ b/SessionNetworkingKit/SessionNetwork/SessionNetworkAPI.swift @@ -37,19 +37,16 @@ public extension Network.SessionNetwork { // MARK: - Authentication fileprivate static func signatureHeaders( - url: URL, method: HTTPMethod, + pathAndParamsString: String, body: Data?, using dependencies: Dependencies ) throws -> [HTTPHeader: String] { let timestamp: UInt64 = UInt64(floor(dependencies.dateNow.timeIntervalSince1970)) - let path: String = url.path - .appending(url.query.map { value in "?\(value)" }) - let signResult: (publicKey: String, signature: [UInt8]) = try sign( timestamp: timestamp, method: method.rawValue, - path: path, + pathAndParamsString: pathAndParamsString, body: body, using: dependencies ) @@ -64,7 +61,7 @@ public extension Network.SessionNetwork { private static func sign( timestamp: UInt64, method: String, - path: String, + pathAndParamsString: String, body: Data?, using dependencies: Dependencies ) throws -> (publicKey: String, signature: [UInt8]) { @@ -84,7 +81,7 @@ public extension Network.SessionNetwork { .signatureVersionBlind07( timestamp: timestamp, method: method, - path: path, + path: pathAndParamsString, body: bodyString, ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey ) @@ -101,10 +98,9 @@ public extension Network.SessionNetwork { preparedRequest: Network.PreparedRequest, using dependencies: Dependencies ) throws -> Network.Destination { - guard - let url: URL = try? preparedRequest.generateUrl(), - case let .server(info) = preparedRequest.destination - else { throw NetworkError.invalidPreparedRequest } + guard case let .server(info) = preparedRequest.destination else { + throw NetworkError.invalidPreparedRequest + } return .server( info: Network.Destination.ServerInfo( @@ -114,8 +110,8 @@ public extension Network.SessionNetwork { fragmentParameters: info.fragmentParameters, headers: info.headers.updated( with: try signatureHeaders( - url: url, method: preparedRequest.method, + pathAndParamsString: preparedRequest.path, body: preparedRequest.body, using: dependencies ) diff --git a/SessionNetworkingKit/Types/Destination.swift b/SessionNetworkingKit/Types/Destination.swift index 3251852657..b8e0c473a3 100644 --- a/SessionNetworkingKit/Types/Destination.swift +++ b/SessionNetworkingKit/Types/Destination.swift @@ -17,9 +17,9 @@ public extension Network { // Use iOS URL processing to extract the values from `server` - public var host: String? { URL(string: server)?.host } - public var scheme: String? { URL(string: server)?.scheme } - public var port: Int? { URL(string: server)?.port } + public var host: String? { URLComponents(string: server)?.host } + public var scheme: String? { URLComponents(string: server)?.scheme } + public var port: Int? { URLComponents(string: server)?.port } // MARK: - Initialization diff --git a/SessionNetworkingKit/Types/PreparedRequest.swift b/SessionNetworkingKit/Types/PreparedRequest.swift index 0a5e005b33..66f1899245 100644 --- a/SessionNetworkingKit/Types/PreparedRequest.swift +++ b/SessionNetworkingKit/Types/PreparedRequest.swift @@ -335,27 +335,6 @@ public extension Network { self.b64 = b64 self.bytes = bytes } - - // MARK: - Functions - - public func generateUrl() throws -> URL { - switch destination { - case .server(let info), .serverUpload(let info, _), .serverDownload(let info): - let pathWithParams: String = Destination.generatePathWithParamsAndFragments( - endpoint: endpoint, - queryParameters: info.queryParameters, - fragmentParameters: info.fragmentParameters - ) - - guard let url: URL = URL(string: "\(info.server)\(pathWithParams)") else { - throw NetworkError.invalidURL - } - - return url - - default: throw NetworkError.invalidURL - } - } } } diff --git a/SessionNetworkingKitTests/Types/DestinationSpec.swift b/SessionNetworkingKitTests/Types/DestinationSpec.swift index 506931260c..f50243fd77 100644 --- a/SessionNetworkingKitTests/Types/DestinationSpec.swift +++ b/SessionNetworkingKitTests/Types/DestinationSpec.swift @@ -85,34 +85,6 @@ class DestinationSpec: QuickSpec { expect(result).to(equal("/test1?testParam=123#testFrag=456")) } } - - // MARK: -- for a server - context("for a server") { - // MARK: ---- throws an error if the generated URL is invalid - it("throws an error if the generated URL is invalid") { - request = try Request( - endpoint: .testParams("test", 123), - destination: .server( - method: .post, - server: "ftp:// test Server", - queryParameters: [:], - headers: [ - "TestCustomHeader": "TestCustom", - HTTPHeader.testHeader: "Test" - ], - x25519PublicKey: "" - ), - body: nil - ) - preparedRequest = try! Network.PreparedRequest( - request: request, - responseType: TestType.self, - using: dependencies - ) - - expect { try preparedRequest.generateUrl() }.to(throwError(NetworkError.invalidURL)) - } - } } } } diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index 3566ed92a6..e23b225d62 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -518,7 +518,7 @@ final class ShareNavController: UINavigationController { /// a new one) defer { switch pendingAttachment.source { - case .file(let url), .media(.url(let url)): + case .file(let url), .media(.url(let url)), .media(.videoUrl(let url, _, _, _)): if dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(url.path) { try? dependencies[singleton: .fileManager].removeItem(atPath: url.path) } @@ -551,7 +551,7 @@ final class ShareNavController: UINavigationController { /// a new one) defer { switch pendingAttachment.source { - case .file(let url), .media(.url(let url)): + case .file(let url), .media(.url(let url)), .media(.videoUrl(let url, _, _, _)): if dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(url.path) { try? dependencies[singleton: .fileManager].removeItem(atPath: url.path) } @@ -726,7 +726,7 @@ private struct SAESNUIKitConfig: SNUIKit.ConfigType { func assetInfo(for path: String, utType: UTType, sourceFilename: String?) -> (asset: AVURLAsset, isValidVideo: Bool, cleanup: () -> Void)? { guard - let result: (asset: AVURLAsset, cleanup: () -> Void) = AVURLAsset.asset( + let result: (asset: AVURLAsset, utType: UTType, cleanup: () -> Void) = AVURLAsset.asset( for: path, utType: utType, sourceFilename: sourceFilename, @@ -734,7 +734,7 @@ private struct SAESNUIKitConfig: SNUIKit.ConfigType { ) else { return nil } - return (result.asset, MediaUtils.isValidVideo(asset: result.asset), result.cleanup) + return (result.asset, MediaUtils.isValidVideo(asset: result.asset, utType: result.utType), result.cleanup) } func mediaDecoderDefaultImageOptions() -> CFDictionary { diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index 4689d0d4c0..d8ef43f246 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -455,7 +455,6 @@ public final class ProfilePictureView: UIView { imageView.image = nil imageView.shouldAnimateImage = false - imageView.contentMode = .scaleAspectFill imageContainerView.themeBackgroundColor = .backgroundSecondary additionalImageContainerView.isHidden = true additionalImageView.image = nil @@ -502,19 +501,12 @@ public final class ProfilePictureView: UIView { case (.image(_, let image), .some(let renderingMode)): imageView.image = image?.withRenderingMode(renderingMode) - case (.some(let source), _): - imageView.loadImage(source) { [weak self, weak imageView = self.imageView] _ in - self?.applyCropTransform( - to: imageView, - source: source, - cropRect: info.cropRect - ) - } - + case (.some(let source), _): imageView.loadImage(source) default: imageView.image = nil } imageView.themeTintColor = info.themeTintColor + imageView.layer.contentsRect = contentsRect(for: info.source, cropRect: info.cropRect) imageContainerView.themeBackgroundColor = info.backgroundColor imageContainerView.themeBackgroundColorForced = info.forcedBackgroundColor profileIconBackgroundView.layer.cornerRadius = (size.iconSize / 2) @@ -529,11 +521,6 @@ public final class ProfilePictureView: UIView { } // Apply crop transform if needed - applyCropTransform( - to: imageView, - source: info.source, - cropRect: info.cropRect - ) startAnimationIfNeeded(for: info, with: imageView) // Check if there is a second image (if not then set the size and finish) @@ -563,13 +550,7 @@ public final class ProfilePictureView: UIView { additionalImageContainerView.isHidden = false case (.some(let source), _): - additionalImageView.loadImage(source) { [weak self, weak imageView = self.additionalImageView] _ in - self?.applyCropTransform( - to: imageView, - source: additionalInfo.source, - cropRect: additionalInfo.cropRect - ) - } + additionalImageView.loadImage(source) additionalImageContainerView.isHidden = false default: @@ -578,6 +559,7 @@ public final class ProfilePictureView: UIView { } additionalImageView.themeTintColor = additionalInfo.themeTintColor + additionalImageView.layer.contentsRect = contentsRect(for: additionalInfo.source, cropRect: additionalInfo.cropRect) switch (info.backgroundColor, info.forcedBackgroundColor) { case (_, .some(let color)): additionalImageContainerView.themeBackgroundColorForced = color @@ -594,11 +576,6 @@ public final class ProfilePictureView: UIView { default: break } } - applyCropTransform( - to: additionalImageView, - source: additionalInfo.source, - cropRect: additionalInfo.cropRect - ) startAnimationIfNeeded(for: additionalInfo, with: additionalImageView) @@ -616,48 +593,41 @@ public final class ProfilePictureView: UIView { additionalProfileIconBackgroundView.layer.cornerRadius = (size.iconSize / 2) } - private func applyCropTransform( - to imageView: SessionImageView?, - source: ImageDataManager.DataSource?, - cropRect: CGRect? - ) { + private func contentsRect(for source: ImageDataManager.DataSource?, cropRect: CGRect?) -> CGRect { guard - let imageView: UIImageView = imageView, - let cropRect: CGRect = cropRect, - cropRect != CGRect(x: 0, y: 0, width: 1, height: 1) - else { - imageView?.transform = .identity - return - } - - // Calculate scale to fill container with cropped portion - let scaleX = 1.0 / cropRect.width - let scaleY = 1.0 / cropRect.height - let scale = max(scaleX, scaleY) - - // Center of crop rect in normalized coordinates (0-1) - let cropCenterNormalizedX = cropRect.origin.x + cropRect.width / 2 - let cropCenterNormalizedY = cropRect.origin.y + cropRect.height / 2 - - // The imageView's frame determines how the image is initially displayed - // We need to work in the imageView's coordinate space - let imageViewSize = imageView.bounds.size + let source: ImageDataManager.DataSource = source, + let cropRect: CGRect = cropRect + else { return CGRect(x: 0, y: 0, width: 1, height: 1) } - // Calculate where the crop center is in the imageView's coordinate space - let cropCenterInViewX = cropCenterNormalizedX * imageViewSize.width - let cropCenterInViewY = cropCenterNormalizedY * imageViewSize.height - - // We want the crop center to be at the imageView's center after transform - let targetCenterX = imageViewSize.width / 2 - let targetCenterY = imageViewSize.height / 2 - - // Translation needed (in pre-scaled coordinate space) - let translateX = (targetCenterX - cropCenterInViewX) / scale - let translateY = (targetCenterY - cropCenterInViewY) / scale - - // Apply transform - imageView.transform = CGAffineTransform(scaleX: scale, y: scale) - .translatedBy(x: translateX, y: translateY) + switch source.orientationFromMetadata { + case .up, .upMirrored: return cropRect + + case .down, .downMirrored: + return CGRect( + x: (1 - cropRect.maxX), + y: (1 - cropRect.maxY), + width: cropRect.width, + height: cropRect.height + ) + + case .left, .leftMirrored: + return CGRect( + x: cropRect.minY, + y: (1 - cropRect.maxX), + width: cropRect.height, + height: cropRect.width + ) + + case .right, .rightMirrored: + return CGRect( + x: (1 - cropRect.maxY), + y: cropRect.minX, + width: cropRect.height, + height: cropRect.width + ) + + @unknown default: return cropRect + } } private func startAnimationIfNeeded(for info: Info, with targetImageView: SessionImageView) { diff --git a/SessionUIKit/Types/ImageDataManager.swift b/SessionUIKit/Types/ImageDataManager.swift index 41fd9594a3..a1e9dc3edb 100644 --- a/SessionUIKit/Types/ImageDataManager.swift +++ b/SessionUIKit/Types/ImageDataManager.swift @@ -972,6 +972,29 @@ public extension ImageDataManager.DataSource { return CGSize(width: sourceWidth, height: sourceHeight) } + + @MainActor + var orientationFromMetadata: UIImage.Orientation { + /// There are a number of types which have fixed sizes, in those cases we should return the target size rather than try to + /// read it from data so we doncan avoid processing + switch self { + case .icon, .urlThumbnail, .placeholderIcon: return .up + case .image(_, let image): + guard let image: UIImage = image else { break } + + return image.imageOrientation + + case .url, .data, .videoUrl, .asyncSource: break + } + + /// Since we don't have a direct size, try to extract it from the data + guard + let source: CGImageSource = createImageSource(), + let properties: [String: Any] = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] + else { return .up } + + return ImageDataManager.orientation(from: properties) + } } // MARK: - ImageDataManager.ThumbnailSize diff --git a/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift b/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift index 34a9bb0582..bb0cb7ab73 100644 --- a/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift +++ b/SessionUtilitiesKit/LibSession/Types/ObservingDatabase.swift @@ -26,6 +26,10 @@ public class ObservingDatabase { // MARK: - Functions + public func currentEvents() -> [ObservedEvent] { + return events + } + public func addEvent(_ event: ObservedEvent) { events.append(event) } diff --git a/SessionUtilitiesKit/Media/MediaUtils.swift b/SessionUtilitiesKit/Media/MediaUtils.swift index 143a01d26d..0f490c347f 100644 --- a/SessionUtilitiesKit/Media/MediaUtils.swift +++ b/SessionUtilitiesKit/Media/MediaUtils.swift @@ -278,7 +278,7 @@ public enum MediaUtils { ) { /// Videos don't have the same metadata as images so need custom handling guard utType?.isVideo != true else { - let assetInfo: (asset: AVURLAsset, cleanup: () -> Void)? = AVURLAsset.asset( + let assetInfo: (asset: AVURLAsset, utType: UTType, cleanup: () -> Void)? = AVURLAsset.asset( for: path, utType: utType, sourceFilename: sourceFilename, @@ -293,7 +293,7 @@ public enum MediaUtils { else { return nil } /// Get the maximum size of any video track in the file - var maxTrackSize: CGSize = asset.maxVideoTrackSize + let maxTrackSize: CGSize = asset.maxVideoTrackSize guard maxTrackSize.width > 0, maxTrackSize.height > 0 else { return nil } @@ -308,7 +308,7 @@ public enum MediaUtils { self.hasAlpha = false self.colorModel = nil self.orientation = nil - self.utType = utType + self.utType = (assetInfo?.utType ?? utType) return } @@ -345,10 +345,11 @@ public enum MediaUtils { } } - public static func isValidVideo(asset: AVURLAsset) -> Bool { + public static func isValidVideo(asset: AVURLAsset, utType: UTType) -> Bool { return MediaMetadata( pixelSize: asset.maxVideoTrackSize, - hasUnsafeMetadata: false + hasUnsafeMetadata: false, + utType: utType ).hasValidPixelSize } @@ -356,7 +357,7 @@ public enum MediaUtils { /// otherwise this will be inefficient as it can create a temporary file for the `AVURLAsset` on old iOS versions public static func isValidVideo(path: String, utType: UTType?, sourceFilename: String?, using dependencies: Dependencies) -> Bool { guard - let assetInfo: (asset: AVURLAsset, cleanup: () -> Void) = AVURLAsset.asset( + let assetInfo: (asset: AVURLAsset, utType: UTType, cleanup: () -> Void) = AVURLAsset.asset( for: path, utType: utType, sourceFilename: sourceFilename, @@ -364,7 +365,7 @@ public enum MediaUtils { ) else { return false } - let result: Bool = isValidVideo(asset: assetInfo.asset) + let result: Bool = isValidVideo(asset: assetInfo.asset, utType: assetInfo.utType) assetInfo.cleanup() return result diff --git a/SessionUtilitiesKit/Media/UTType+Utilities.swift b/SessionUtilitiesKit/Media/UTType+Utilities.swift index 3f3d70bf7c..c7c8a524f4 100644 --- a/SessionUtilitiesKit/Media/UTType+Utilities.swift +++ b/SessionUtilitiesKit/Media/UTType+Utilities.swift @@ -8,16 +8,11 @@ import UniformTypeIdentifiers public extension UTType { /// This is an invalid type used to improve DSL for UTType usage - static let invalid: UTType = UTType(exportedAs: "invalid") - static let fileExtensionText: String = "txt" - static let fileExtensionDefault: String = "bin" + static let invalid: UTType = UTType(exportedAs: "org.getsession.invalid") static let fileExtensionDefaultImage: String = "png" static let mimeTypeDefault: String = "application/octet-stream" static let mimeTypeJpeg: String = "image/jpeg" static let mimeTypePdf: String = "application/pdf" - - static let xTiff: UTType = UTType(mimeType: "image/x-tiff")! - static let xWinBpm: UTType = UTType(mimeType: "image/x-windows-bmp")! static let supportedAnimatedImageTypes: Set = [ .gif, .webP @@ -101,7 +96,7 @@ public extension UTType { var isAnimated: Bool { UTType.supportedAnimatedImageTypes.contains(self) } var isImage: Bool { conforms(to: .image) } - var isVideo: Bool { conforms(to: .video) } + var isVideo: Bool { conforms(to: .video) || conforms(to: .movie) } var isAudio: Bool { conforms(to: .audio) } var isText: Bool { conforms(to: .text) } var isMicrosoftDoc: Bool { UTType.supportedMicrosoftDocTypes.contains(self) } diff --git a/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift b/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift index d8e590f566..28be0407a8 100644 --- a/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/AVURLAsset+Utilities.swift @@ -17,10 +17,11 @@ public extension AVURLAsset { return result } - static func asset(for path: String, utType: UTType?, sourceFilename: String?, using dependencies: Dependencies) -> (asset: AVURLAsset, cleanup: () -> Void)? { + static func asset(for path: String, utType: UTType?, sourceFilename: String?, using dependencies: Dependencies) -> (asset: AVURLAsset, utType: UTType, cleanup: () -> Void)? { if #available(iOS 17.0, *) { /// Since `mimeType` can be null we need to try to resolve it to a value let finalMimeType: String + let finalUTType: UTType switch (utType, sourceFilename) { case (.none, .none): return nil @@ -30,16 +31,18 @@ public extension AVURLAsset { } finalMimeType = mimeType + finalUTType = utType case (.none, .some(let sourceFilename)): guard - let type: UTType = UTType( + let utType: UTType = UTType( sessionFileExtension: URL(fileURLWithPath: sourceFilename).pathExtension ), - let mimeType: String = type.sessionMimeType + let mimeType: String = utType.sessionMimeType else { return nil } finalMimeType = mimeType + finalUTType = utType } return ( @@ -47,24 +50,27 @@ public extension AVURLAsset { url: URL(fileURLWithPath: path), options: [AVURLAssetOverrideMIMETypeKey: finalMimeType] ), + finalUTType, {} ) } else { /// Since `mimeType` and/or `sourceFilename` can be null we need to try to resolve them both to values let finalExtension: String + let finalUTType: UTType switch (utType, sourceFilename) { case (.none, .none): return nil case (.none, .some(let sourceFilename)): guard - let type: UTType = UTType( + let utType: UTType = UTType( sessionFileExtension: URL(fileURLWithPath: sourceFilename).pathExtension ), - let fileExtension: String = type.sessionFileExtension(sourceFilename: sourceFilename) + let fileExtension: String = utType.sessionFileExtension(sourceFilename: sourceFilename) else { return nil } finalExtension = fileExtension + finalUTType = utType case (.some(let utType), let sourceFilename): guard let fileExtension: String = utType.sessionFileExtension(sourceFilename: sourceFilename) else { @@ -72,6 +78,7 @@ public extension AVURLAsset { } finalExtension = fileExtension + finalUTType = utType } let tmpPath: String = URL(fileURLWithPath: dependencies[singleton: .fileManager].temporaryDirectory) @@ -83,6 +90,7 @@ public extension AVURLAsset { return ( AVURLAsset(url: URL(fileURLWithPath: tmpPath), options: nil), + finalUTType, { [dependencies] in try? dependencies[singleton: .fileManager].removeItem(atPath: tmpPath) } ) } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift index 1aa45befff..637b45ae92 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift @@ -231,7 +231,7 @@ public class MediaMessageView: UIView { if let linkPreviewURL: String = linkPreviewInfo?.url { let httpsScheme: String = "https" // stringlint:ignore - if let targetUrl: URL = URL(string: linkPreviewURL), targetUrl.scheme?.lowercased() != httpsScheme { + if URLComponents(string: linkPreviewURL)?.scheme?.lowercased() != httpsScheme { label.font = UIFont.systemFont(ofSize: Values.verySmallFontSize) label.text = "linkPreviewsErrorUnsecure".localized() label.themeTextColor = (mode == .attachmentApproval ? @@ -445,7 +445,7 @@ public class MediaMessageView: UIView { self?.subtitleLabel.isHidden = false // Set the error text appropriately - if let targetUrl: URL = URL(string: linkPreviewURL), targetUrl.scheme?.lowercased() != "https" { // stringlint:ignore + if URLComponents(string: linkPreviewURL)?.scheme?.lowercased() != "https" { // stringlint:ignore // This error case is handled already in the 'subtitleLabel' creation } else { From 6d3b261a866b6375b05090584837d51b8aca95e7 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 21 Oct 2025 12:55:35 +1100 Subject: [PATCH 127/162] Fixed CI build issue and unit tests --- SessionMessagingKit/Shared Models/MessageViewModel.swift | 2 +- .../_TestUtilities/MockLibSessionCache.swift | 7 ++++++- SessionTests/Database/DatabaseSpec.swift | 3 ++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 6b37c426f4..1b6164f6c2 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -228,7 +228,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, profile: Update = .useExisting, quotedInfo: Update = .useExisting, // Workaround for blinded current user attachments: Update<[Attachment]?> = .useExisting, - reactionInfo: Update<[ReactionInfo]?> = .useExisting, + reactionInfo: Update<[ReactionInfo]?> = .useExisting ) -> MessageViewModel { return MessageViewModel( threadId: self.threadId, diff --git a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift index 24f5fa056a..8fc93a25a2 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift @@ -184,7 +184,12 @@ class MockLibSessionCache: Mock, LibSessionCacheType { mockNoReturn(generics: [T.self], args: [key, value]) } - func updateProfile(displayName: String, displayPictureUrl: String?, displayPictureEncryptionKey: Data?, isReuploadProfilePicture: Bool) throws { + func updateProfile( + displayName: Update, + displayPictureUrl: Update, + displayPictureEncryptionKey: Update, + isReuploadProfilePicture: Bool + ) throws { try mockThrowingNoReturn(args: [displayName, displayPictureUrl, displayPictureEncryptionKey, isReuploadProfilePicture]) } diff --git a/SessionTests/Database/DatabaseSpec.swift b/SessionTests/Database/DatabaseSpec.swift index c161a5cf2a..9dede2231b 100644 --- a/SessionTests/Database/DatabaseSpec.swift +++ b/SessionTests/Database/DatabaseSpec.swift @@ -238,7 +238,8 @@ class DatabaseSpec: QuickSpec { "messagingKit.MoveSettingsToLibSession", "messagingKit.RenameAttachments", "messagingKit.AddProMessageFlag", - "LastProfileUpdateTimestamp" + "LastProfileUpdateTimestamp", + "RemoveQuoteUnusedColumnsAndForeignKeys" ])) } From 827a8f3cad5f789424a31aa8167e277c9bd5035b Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Tue, 21 Oct 2025 14:14:14 +1100 Subject: [PATCH 128/162] fix: pro cta ui issues --- SessionUIKit/Components/SwiftUI/ProCTAModal.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index 88ef28dd6c..a22b42c576 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -45,10 +45,8 @@ public struct ProCTAModal: View { /// of the modal. public var animatedAvatarImagePadding: (leading: CGFloat, top: CGFloat) { switch self { - case .generic: - return (1313.5, 753) - case .animatedProfileImage: - return (690, 363) + case .generic: return (1293, 743) + case .animatedProfileImage: return (690, 363) default: return (0, 0) } } @@ -172,7 +170,7 @@ public struct ProCTAModal: View { ZStack { if let animatedAvatarImageURL = variant.animatedAvatarImageURL { GeometryReader { geometry in - let size: CGFloat = geometry.size.width / 1522.0 * 187.0 + let size: CGFloat = geometry.size.width / 1522.0 * 135 let scale: CGFloat = geometry.size.width / 1522.0 SessionAsyncImage( source: .url(animatedAvatarImageURL), @@ -272,7 +270,7 @@ public struct ProCTAModal: View { case .groupLimit(_, let isSessionProActivated, let proBadgeImage) = variant, isSessionProActivated { - (Text(variant.subtitle) + Text("\(proBadgeImage)")) + (Text(variant.subtitle) + Text(" \(Image(uiImage: proBadgeImage))")) .font(.Body.largeRegular) .foregroundColor(themeColor: .textSecondary) .multilineTextAlignment(.center) From 531e6f5867b7d1a40d5ee640f6dc5c4098df1d62 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 21 Oct 2025 15:39:19 +1100 Subject: [PATCH 129/162] Fixed an issue where sending videos was erroring --- Session.xcodeproj/project.pbxproj | 8 ++++---- SessionMessagingKit/Utilities/AttachmentManager.swift | 11 ++++++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 40d473c3dd..8515ef3623 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -8366,7 +8366,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 647; + CURRENT_PROJECT_VERSION = 648; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8447,7 +8447,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 647; + CURRENT_PROJECT_VERSION = 648; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8933,7 +8933,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 647; + CURRENT_PROJECT_VERSION = 648; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -9523,7 +9523,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 647; + CURRENT_PROJECT_VERSION = 648; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index c118911c91..2310d56baa 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -1307,12 +1307,17 @@ public extension PendingAttachment { filePath: String, using dependencies: Dependencies ) async throws { - guard case .url(let url) = source else { throw AttachmentError.invalidData } + let url: URL + + switch source { + case .url(let targetUrl), .videoUrl(let targetUrl, _, _, _): url = targetUrl + case .data, .icon, .image, .urlThumbnail, .placeholderIcon, .asyncSource: + throw AttachmentError.invalidData + } /// Ensure the target format is an image format we support switch format { - case .mp4: break - case .current: throw AttachmentError.invalidFileFormat + case .mp4, .current: break case .png, .webPLossy, .webPLossless, .gif: throw AttachmentError.couldNotConvert } From 560d83ed3a11948b0d29f1654e8b784c415d60a8 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Tue, 21 Oct 2025 17:10:46 +1100 Subject: [PATCH 130/162] fix: Pro badge is cut off screen with a long name --- Session/Closed Groups/EditGroupViewModel.swift | 13 ++++--------- Session/Settings/BlockedContactsViewModel.swift | 13 ++++--------- Session/Shared/UserListViewModel.swift | 13 ++++--------- 3 files changed, 12 insertions(+), 27 deletions(-) diff --git a/Session/Closed Groups/EditGroupViewModel.swift b/Session/Closed Groups/EditGroupViewModel.swift index 07fc997c39..c3378090e6 100644 --- a/Session/Closed Groups/EditGroupViewModel.swift +++ b/Session/Closed Groups/EditGroupViewModel.swift @@ -301,15 +301,10 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Observa accessibility: Accessibility( identifier: "Contact" ), - extraViewGenerator: (dependencies.mutate(cache: .libSession) { $0.validateProProof(for: memberInfo.profile) }) ? - { - let result: UIView = UIView() - let probadge: SessionProBadge = SessionProBadge(size: .small) - result.addSubview(probadge) - probadge.pin(to: result, withInset: 4) - return result - } - : nil + trailingImage: { + guard (dependencies.mutate(cache: .libSession) { $0.validateProProof(for: memberInfo.profile) }) else { return nil } + return ("ProBadge", { [dependencies] in SessionProBadge(size: .small).toImage(using: dependencies) }) + }() ), subtitle: (!isUpdatedGroup ? nil : SessionCell.TextInfo( memberInfo.value.statusDescription, diff --git a/Session/Settings/BlockedContactsViewModel.swift b/Session/Settings/BlockedContactsViewModel.swift index 84019f4f60..95d766688f 100644 --- a/Session/Settings/BlockedContactsViewModel.swift +++ b/Session/Settings/BlockedContactsViewModel.swift @@ -300,15 +300,10 @@ public class BlockedContactsViewModel: SessionTableViewModel, NavigatableStateHo title: SessionCell.TextInfo( (model.profile?.displayName() ?? model.id.truncated()), font: .title, - extraViewGenerator: (viewModel.dependencies.mutate(cache: .libSession) { $0.validateProProof(for: model.profile) }) ? - { - let result: UIView = UIView() - let probadge: SessionProBadge = SessionProBadge(size: .small) - result.addSubview(probadge) - probadge.pin(to: result, withInset: 4) - return result - } - : nil + trailingImage: { + guard (viewModel.dependencies.mutate(cache: .libSession) { $0.validateProProof(for: model.profile) }) else { return nil } + return ("ProBadge", { [dependencies = viewModel.dependencies] in SessionProBadge(size: .small).toImage(using: dependencies) }) + }() ), trailingAccessory: .radio( isSelected: state.selectedIds.contains(model.id) diff --git a/Session/Shared/UserListViewModel.swift b/Session/Shared/UserListViewModel.swift index 8af904d433..6928938b09 100644 --- a/Session/Shared/UserListViewModel.swift +++ b/Session/Shared/UserListViewModel.swift @@ -152,15 +152,10 @@ class UserListViewModel: SessionTableVie title: SessionCell.TextInfo( title, font: .title, - extraViewGenerator: (dependencies.mutate(cache: .libSession) { $0.validateProProof(for: userInfo.profile) }) ? - { - let result: UIView = UIView() - let probadge: SessionProBadge = SessionProBadge(size: .small) - result.addSubview(probadge) - probadge.pin(to: result, withInset: 4) - return result - } - : nil + trailingImage: { + guard (dependencies.mutate(cache: .libSession) { $0.validateProProof(for: userInfo.profile) }) else { return nil } + return ("ProBadge", { [dependencies] in SessionProBadge(size: .small).toImage(using: dependencies) }) + }() ), subtitle: SessionCell.TextInfo(userInfo.itemDescription(using: dependencies), font: .subtitle), trailingAccessory: trailingAccessory, From 12dd293c44f1a5a4f42c0dfba2c535e6363690b9 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Tue, 21 Oct 2025 17:19:14 +1100 Subject: [PATCH 131/162] fix: pro badge alignment with inline text --- .../Shared/Views/SessionProBadge+Utilities.swift | 4 ++-- .../Themes/ThemedAttributedString.swift | 16 ++++++++++++++-- SessionUIKit/Utilities/UILabel+Utilities.swift | 2 +- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/Session/Shared/Views/SessionProBadge+Utilities.swift b/Session/Shared/Views/SessionProBadge+Utilities.swift index 7fe881dd2a..81f7ccae2b 100644 --- a/Session/Shared/Views/SessionProBadge+Utilities.swift +++ b/Session/Shared/Views/SessionProBadge+Utilities.swift @@ -49,13 +49,13 @@ public extension String { let base = ThemedAttributedString() switch postion { case .leading: - base.append(ThemedAttributedString(imageAttachmentGenerator: { SessionProBadge(size: proBadgeSize).toImage(using: dependencies) })) + base.append(ThemedAttributedString(imageAttachmentGenerator: { SessionProBadge(size: proBadgeSize).toImage(using: dependencies) }, referenceFont: font)) base.append(ThemedAttributedString(string: spacing)) base.append(ThemedAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) case .trailing: base.append(ThemedAttributedString(string: self, attributes: [.font: font, .themeForegroundColor: textColor])) base.append(ThemedAttributedString(string: spacing)) - base.append(ThemedAttributedString(imageAttachmentGenerator: { SessionProBadge(size: proBadgeSize).toImage(using: dependencies) })) + base.append(ThemedAttributedString(imageAttachmentGenerator: { SessionProBadge(size: proBadgeSize).toImage(using: dependencies) }, referenceFont: font)) } return base diff --git a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift index 675cab5e2e..a13d429b3e 100644 --- a/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift +++ b/SessionUIKit/Style Guide/Themes/ThemedAttributedString.swift @@ -32,13 +32,24 @@ public extension NSAttributedString.Key { public class ThemedAttributedString: Equatable, Hashable { internal var value: NSMutableAttributedString { if let image = imageAttachmentGenerator?() { - return NSMutableAttributedString(attachment: NSTextAttachment(image: image)) + let attachment = NSTextAttachment(image: image) + if let font = imageAttachmentReferenceFont { + attachment.bounds = CGRect( + x: 0, + y: font.capHeight / 2 - image.size.height / 2, + width: image.size.width, + height: image.size.height + ) + } + + return NSMutableAttributedString(attachment: attachment) } return attributedString } public var string: String { value.string } public var length: Int { value.length } internal var imageAttachmentGenerator: (() -> UIImage?)? + internal var imageAttachmentReferenceFont: UIFont? internal var attributedString: NSMutableAttributedString public init() { @@ -71,9 +82,10 @@ public class ThemedAttributedString: Equatable, Hashable { self.attributedString = NSMutableAttributedString(attachment: attachment) } - public init(imageAttachmentGenerator: @escaping (() -> UIImage?)) { + public init(imageAttachmentGenerator: @escaping (() -> UIImage?), referenceFont: UIFont?) { self.attributedString = NSMutableAttributedString() self.imageAttachmentGenerator = imageAttachmentGenerator + self.imageAttachmentReferenceFont = referenceFont } required init?(coder: NSCoder) { diff --git a/SessionUIKit/Utilities/UILabel+Utilities.swift b/SessionUIKit/Utilities/UILabel+Utilities.swift index e0782d8e04..ef6476a079 100644 --- a/SessionUIKit/Utilities/UILabel+Utilities.swift +++ b/SessionUIKit/Utilities/UILabel+Utilities.swift @@ -15,7 +15,7 @@ public extension UILabel { } base.append(NSAttributedString(string: spacing)) - base.append(ThemedAttributedString(imageAttachmentGenerator: imageGenerator)) + base.append(ThemedAttributedString(imageAttachmentGenerator: imageGenerator, referenceFont: font)) themeAttributedText = base numberOfLines = 0 From ed61e7fab5122d4188d973132c6631ece41a3ed7 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Wed, 22 Oct 2025 10:22:33 +1100 Subject: [PATCH 132/162] feat: dev settings for pro message features --- .../MessageInfoScreen.swift | 11 ++- .../DeveloperSettingsProViewModel.swift | 76 ++++++++++++++++++- .../Config Handling/LibSession+Pro.swift | 6 +- SessionUtilitiesKit/General/Feature.swift | 12 +++ 4 files changed, 98 insertions(+), 7 deletions(-) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 57d0dd4bd2..d80013f827 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -516,12 +516,19 @@ struct MessageInfoScreen: View { proFeatures.append("appProBadge".put(key: "app_pro", value: Constants.app_pro).localized()) } - if (messageViewModel.isProMessage && messageViewModel.body.defaulting(to: "").utf16.count > LibSession.CharacterLimit) { + if ( + messageViewModel.isProMessage && + messageViewModel.body.defaulting(to: "").utf16.count > LibSession.CharacterLimit || + dependencies[feature: .messageFeatureLongMessage] + ) { proFeatures.append("proIncreasedMessageLengthFeature".localized()) proCTAVariant = (proFeatures.count > 1 ? .generic : .longerMessages) } - if ImageDataManager.isAnimatedImage(profileInfo?.source) { + if ( + ImageDataManager.isAnimatedImage(profileInfo?.source) || + dependencies[feature: .messageFeatureAnimatedAvatar] + ) { proFeatures.append("proAnimatedDisplayPictureFeature".localized()) proCTAVariant = (proFeatures.count > 1 ? .generic : .animatedProfileImage(isSessionProActivated: false)) } diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index 32321fe032..384b5c2138 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -78,6 +78,10 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case proStatus case allUsersSessionPro + case messageFeatureProBadge + case messageFeatureLongMessage + case messageFeatureAnimatedAvatar + // MARK: - Conformance public typealias DifferenceIdentifier = String @@ -93,6 +97,10 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .proStatus: return "proStatus" case .allUsersSessionPro: return "allUsersSessionPro" + + case .messageFeatureProBadge: return "messageFeatureProBadge" + case .messageFeatureLongMessage: return "messageFeatureLongMessage" + case .messageFeatureAnimatedAvatar: return "messageFeatureAnimatedAvatar" } } @@ -111,7 +119,11 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold case .requestRefund: result.append(.requestRefund); fallthrough case .proStatus: result.append(.proStatus); fallthrough - case .allUsersSessionPro: result.append(.allUsersSessionPro) + case .allUsersSessionPro: result.append(.allUsersSessionPro); fallthrough + + case .messageFeatureProBadge: result.append(.messageFeatureProBadge); fallthrough + case .messageFeatureLongMessage: result.append(.messageFeatureLongMessage); fallthrough + case .messageFeatureAnimatedAvatar: result.append(.messageFeatureAnimatedAvatar) } return result @@ -138,6 +150,10 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold let mockCurrentUserSessionPro: Bool let allUsersSessionPro: Bool + let messageFeatureProBadge: Bool + let messageFeatureLongMessage: Bool + let messageFeatureAnimatedAvatar: Bool + @MainActor public func sections(viewModel: DeveloperSettingsProViewModel, previousState: State) -> [SectionModel] { DeveloperSettingsProViewModel.sections( state: self, @@ -165,7 +181,11 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold refundRequestStatus: nil, mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionPro], - allUsersSessionPro: dependencies[feature: .allUsersSessionPro] + allUsersSessionPro: dependencies[feature: .allUsersSessionPro], + + messageFeatureProBadge: dependencies[feature: .messageFeatureProBadge], + messageFeatureLongMessage: dependencies[feature: .messageFeatureLongMessage], + messageFeatureAnimatedAvatar: dependencies[feature: .messageFeatureAnimatedAvatar] ) } } @@ -210,7 +230,10 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold purchaseTransaction: purchaseTransaction, refundRequestStatus: refundRequestStatus, mockCurrentUserSessionPro: dependencies[feature: .mockCurrentUserSessionPro], - allUsersSessionPro: dependencies[feature: .allUsersSessionPro] + allUsersSessionPro: dependencies[feature: .allUsersSessionPro], + messageFeatureProBadge: dependencies[feature: .messageFeatureProBadge], + messageFeatureLongMessage: dependencies[feature: .messageFeatureLongMessage], + messageFeatureAnimatedAvatar: dependencies[feature: .messageFeatureAnimatedAvatar] ) } @@ -362,7 +385,52 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold ) } ) - ] + ].appending( + contentsOf: !state.allUsersSessionPro ? [] : [ + SessionCell.Info( + id: .messageFeatureProBadge, + title: .init("Message Feature: Pro Badge", font: .subtitle), + trailingAccessory: .toggle( + state.messageFeatureProBadge, + oldValue: previousState.messageFeatureProBadge + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .messageFeatureProBadge, + to: !state.messageFeatureProBadge + ) + } + ), + SessionCell.Info( + id: .messageFeatureLongMessage, + title: .init("Message Feature: Long Message", font: .subtitle), + trailingAccessory: .toggle( + state.messageFeatureLongMessage, + oldValue: previousState.messageFeatureLongMessage + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .messageFeatureLongMessage, + to: !state.messageFeatureLongMessage + ) + } + ), + SessionCell.Info( + id: .messageFeatureAnimatedAvatar, + title: .init("Message Feature: Animated Avatar", font: .subtitle), + trailingAccessory: .toggle( + state.messageFeatureAnimatedAvatar, + oldValue: previousState.messageFeatureAnimatedAvatar + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .messageFeatureAnimatedAvatar, + to: !state.messageFeatureAnimatedAvatar + ) + } + ) + ] + ) ) return [general, subscriptions, features] diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift index 8cb57bab68..4ac416ab01 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Pro.swift @@ -55,7 +55,11 @@ public extension LibSessionCacheType { func shouldShowProBadge(for profile: Profile?) -> Bool { guard let profile = profile, dependencies[feature: .sessionProEnabled] else { return false } - return dependencies[feature: .allUsersSessionPro] || (profile.showProBadge == true) + return ( + dependencies[feature: .allUsersSessionPro] && + dependencies[feature: .messageFeatureProBadge] || + (profile.showProBadge == true) + ) } func getCurrentUserProProof() -> String? { diff --git a/SessionUtilitiesKit/General/Feature.swift b/SessionUtilitiesKit/General/Feature.swift index 8c6a77d04a..e51dcdbbd6 100644 --- a/SessionUtilitiesKit/General/Feature.swift +++ b/SessionUtilitiesKit/General/Feature.swift @@ -94,6 +94,18 @@ public extension FeatureStorage { identifier: "allUsersSessionPro" ) + static let messageFeatureProBadge: FeatureConfig = Dependencies.create( + identifier: "messageFeatureProBadge" + ) + + static let messageFeatureLongMessage: FeatureConfig = Dependencies.create( + identifier: "messageFeatureLongMessage" + ) + + static let messageFeatureAnimatedAvatar: FeatureConfig = Dependencies.create( + identifier: "messageFeatureAnimatedAvatar" + ) + static let shortenFileTTL: FeatureConfig = Dependencies.create( identifier: "shortenFileTTL" ) From 5457ff31f0b30355ba948cbfe926bfefb0a36707 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 22 Oct 2025 15:03:30 +1100 Subject: [PATCH 133/162] Added a `with(userProfile:)` function to handle the libSession profile --- Session.xcodeproj/project.pbxproj | 8 +- .../Conversations/ConversationViewModel.swift | 74 ++++++------- .../Settings/ThreadSettingsViewModel.swift | 20 +--- .../GlobalSearchViewController.swift | 9 +- Session/Home/HomeViewModel.swift | 11 +- .../MessageRequestsViewModel.swift | 11 +- .../SessionThreadViewModel.swift | 102 ++++++++++++++++-- .../ThreadPickerViewModel.swift | 34 +++--- 8 files changed, 157 insertions(+), 112 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 8515ef3623..64cd162523 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -8366,7 +8366,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 648; + CURRENT_PROJECT_VERSION = 649; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8447,7 +8447,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 648; + CURRENT_PROJECT_VERSION = 649; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8933,7 +8933,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 648; + CURRENT_PROJECT_VERSION = 649; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -9523,7 +9523,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 648; + CURRENT_PROJECT_VERSION = 649; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 96e41cf3f7..d8aa14ebf0 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -271,9 +271,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold openGroupPermissions: initialData?.openGroupPermissions, threadWasMarkedUnread: initialData?.threadWasMarkedUnread, using: dependencies - ).populatingPostQueryData( - threadDisplayPictureUrl: nil, - contactProfile: nil, + ) + .with(userProfile: dependencies.mutate(cache: .libSession) { $0.profile }) + .populatingPostQueryData( recentReactionEmoji: nil, openGroupCapabilities: nil, currentUserSessionIds: ( @@ -351,11 +351,13 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold private func setupObservableThreadData(for threadId: String) -> ThreadObservation { return ObservationBuilderOld .databaseObservation(dependencies) { [weak self, dependencies] db -> SessionThreadViewModel? in + let userProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } let userSessionId: SessionId = dependencies[cache: .general].sessionId let recentReactionEmoji: [String] = try Emoji.getRecent(db, withDefaultEmoji: true) let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel .conversationQuery(threadId: threadId, userSessionId: userSessionId) - .fetchOne(db) + .fetchOne(db)? + .with(userProfile: userProfile) let openGroupCapabilities: Set? = (threadViewModel?.threadVariant != .community ? nil : try Capability @@ -366,44 +368,34 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold .fetchSet(db) ) - return threadViewModel.map { viewModel -> SessionThreadViewModel in - let wasKickedFromGroup: Bool = ( - viewModel.threadVariant == .group && - dependencies.mutate(cache: .libSession) { cache in - cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: viewModel.threadId)) - } - ) - let groupIsDestroyed: Bool = ( - viewModel.threadVariant == .group && - dependencies.mutate(cache: .libSession) { cache in - cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: viewModel.threadId)) - } - ) - - // TODO: [Database Relocation] Clean up this query as well - var targetContactProfile: Profile? = viewModel.contactProfile - var targetThreadDisplayPictureUrl: String? = viewModel.threadDisplayPictureUrl - - if viewModel.id == userSessionId.hexString { - targetContactProfile = dependencies.mutate(cache: .libSession) { $0.profile } - targetThreadDisplayPictureUrl = targetContactProfile?.displayPictureUrl + return (threadViewModel? + .with(userProfile: userProfile)) + .map { viewModel -> SessionThreadViewModel in + let (wasKickedFromGroup, groupIsDestroyed): (Bool, Bool) = { + guard viewModel.threadVariant == .group else { return (false, false) } + + let sessionId: SessionId = SessionId(.group, hex: viewModel.threadId) + return dependencies.mutate(cache: .libSession) { cache in + ( + cache.wasKickedFromGroup(groupSessionId: sessionId), + cache.groupIsDestroyed(groupSessionId: sessionId) + ) + } + }() + + return viewModel.populatingPostQueryData( + recentReactionEmoji: recentReactionEmoji, + openGroupCapabilities: openGroupCapabilities, + currentUserSessionIds: ( + self?.threadData.currentUserSessionIds ?? + [userSessionId.hexString] + ), + wasKickedFromGroup: wasKickedFromGroup, + groupIsDestroyed: groupIsDestroyed, + threadCanWrite: viewModel.determineInitialCanWriteFlag(using: dependencies), + threadCanUpload: viewModel.determineInitialCanUploadFlag(using: dependencies) + ) } - - return viewModel.populatingPostQueryData( - threadDisplayPictureUrl: targetThreadDisplayPictureUrl, - contactProfile: targetContactProfile, - recentReactionEmoji: recentReactionEmoji, - openGroupCapabilities: openGroupCapabilities, - currentUserSessionIds: ( - self?.threadData.currentUserSessionIds ?? - [userSessionId.hexString] - ), - wasKickedFromGroup: wasKickedFromGroup, - groupIsDestroyed: groupIsDestroyed, - threadCanWrite: viewModel.determineInitialCanWriteFlag(using: dependencies), - threadCanUpload: viewModel.determineInitialCanUploadFlag(using: dependencies) - ) - } } .handleEvents(didFail: { Log.error(.conversation, "Observation failed with error: \($0)") }) } diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 15b83c06d4..4b91a902c2 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -127,30 +127,16 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob lazy var observation: TargetObservation = ObservationBuilderOld .databaseObservation(self) { [dependencies, threadId = self.threadId] db -> State in + let userProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } let userSessionId: SessionId = dependencies[cache: .general].sessionId var threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel .conversationSettingsQuery(threadId: threadId, userSessionId: userSessionId) - .fetchOne(db) + .fetchOne(db)? + .with(userProfile: userProfile) let disappearingMessagesConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration .fetchOne(db, id: threadId) .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) - // TODO: [Database Relocation] Clean up this query as well - if threadViewModel?.id == userSessionId.hexString { - let userProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } - threadViewModel = threadViewModel?.populatingPostQueryData( - threadDisplayPictureUrl: userProfile.displayPictureUrl, - contactProfile: userProfile, - recentReactionEmoji: nil, - openGroupCapabilities: nil, - currentUserSessionIds: [userSessionId.hexString], - wasKickedFromGroup: false, - groupIsDestroyed: false, - threadCanWrite: true, - threadCanUpload: true - ) - } - return State( threadViewModel: threadViewModel, disappearingMessagesConfig: disappearingMessagesConfig diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index 4de1f19992..2378d82524 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -65,9 +65,12 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI } private lazy var defaultSearchResultsObservation = ValueObservation .trackingConstantRegion { [dependencies] db -> [SessionThreadViewModel] in - try SessionThreadViewModel + let userProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } + + return try SessionThreadViewModel .defaultContactsQuery(using: dependencies) .fetchAll(db) + .map { $0.with(userProfile: userProfile) } } .map { GlobalSearchViewController.processDefaultSearchResults($0) } .removeDuplicates() @@ -303,20 +306,24 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI _currentSearchCancellable.set(to: dependencies[singleton: .storage] .readPublisher { [dependencies] db -> [SectionModel] in + let userProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } let userSessionId: SessionId = dependencies[cache: .general].sessionId let contactsAndGroupsResults: [SessionThreadViewModel] = try SessionThreadViewModel .contactsAndGroupsQuery( userSessionId: userSessionId, + currentUserName: userProfile.name, pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText), searchTerm: searchText ) .fetchAll(db) + .map { $0.with(userProfile: userProfile) } let messageResults: [SessionThreadViewModel] = try SessionThreadViewModel .messagesQuery( userSessionId: userSessionId, pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText) ) .fetchAll(db) + .map { $0.with(userProfile: userProfile) } return [ ArraySection(model: .contactsAndGroups, elements: contactsAndGroupsResults), diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 618a82cd06..d4fbb0eec3 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -371,6 +371,7 @@ public class HomeViewModel: NavigatableStateHolder { ids: Array(idsNeedingRequery) + loadResult.newIds ) .fetchAll(db) + .map { $0.with(userProfile: userProfile) } ) } @@ -532,16 +533,6 @@ public class HomeViewModel: NavigatableStateHolder { .compactMap { state.itemCache[$0] } .map { conversation -> SessionThreadViewModel in conversation.populatingPostQueryData( - // TODO: [Database Relocation] The 'threadDisplayPictureUrl' should be based on the conversation type when creating the SessionThreadViewModel rather than via the query - threadDisplayPictureUrl: ( - conversation.id == userSessionId.hexString ? - state.userProfile.displayPictureUrl : - nil - ), - contactProfile: ( - state.profileCache[conversation.id] ?? - conversation.contactProfile - ), recentReactionEmoji: nil, openGroupCapabilities: nil, // TODO: [Database Relocation] Do we need all of these???? diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index ef1e358c92..abf784fded 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -206,6 +206,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O } } + let userProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } try await dependencies[singleton: .storage].readAsync { db in /// Update loaded page info as needed if loadPageEvent != nil || !insertedIds.isEmpty || !deletedIds.isEmpty { @@ -228,6 +229,7 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O ids: Array(idsNeedingRequery) + loadResult.newIds ) .fetchAll(db) + .map { $0.with(userProfile: userProfile) } ) } @@ -297,17 +299,8 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O elements: state.loadedPageInfo.currentIds .compactMap { state.itemCache[$0] } .map { conversation -> SessionCell.Info in - // TODO: [Database Relocation] Source profile data via a separate query for efficiency - var customProfile: Profile? - - if conversation.id == viewModel.dependencies[cache: .general].sessionId.hexString { - customProfile = viewModel.dependencies.mutate(cache: .libSession) { $0.profile } - } - return SessionCell.Info( id: conversation.populatingPostQueryData( - threadDisplayPictureUrl: customProfile?.displayPictureUrl, - contactProfile: customProfile, recentReactionEmoji: nil, openGroupCapabilities: nil, // TODO: [Database Relocation] Do we need all of these???? diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 7990949310..a2fcd4ab09 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -613,9 +613,80 @@ public extension SessionThreadViewModel { // MARK: - Mutation public extension SessionThreadViewModel { + @available(*, deprecated, message: "The 'SessionThreadViewModel' should be refactored so it doesn't directly fetch profile data which would remove the need for this behaviour") + func with(userProfile: Profile) -> SessionThreadViewModel { + func replaceIfCurrentUser(_ profile: Profile?) -> Profile? { + return (profile?.id == userProfile.id ? userProfile : profile) + } + + return SessionThreadViewModel( + rowId: self.rowId, + threadId: self.threadId, + threadVariant: self.threadVariant, + threadCreationDateTimestamp: self.threadCreationDateTimestamp, + threadMemberNames: self.threadMemberNames, + threadIsNoteToSelf: self.threadIsNoteToSelf, + outdatedMemberId: self.outdatedMemberId, + threadIsMessageRequest: self.threadIsMessageRequest, + threadRequiresApproval: self.threadRequiresApproval, + threadShouldBeVisible: self.threadShouldBeVisible, + threadPinnedPriority: self.threadPinnedPriority, + threadIsBlocked: self.threadIsBlocked, + threadMutedUntilTimestamp: self.threadMutedUntilTimestamp, + threadOnlyNotifyForMentions: self.threadOnlyNotifyForMentions, + threadMessageDraft: self.threadMessageDraft, + threadIsDraft: self.threadIsDraft, + threadContactIsTyping: self.threadContactIsTyping, + threadWasMarkedUnread: self.threadWasMarkedUnread, + threadUnreadCount: self.threadUnreadCount, + threadUnreadMentionCount: self.threadUnreadMentionCount, + threadHasUnreadMessagesOfAnyKind: self.threadHasUnreadMessagesOfAnyKind, + threadCanWrite: self.threadCanWrite, + threadCanUpload: self.threadCanUpload, + disappearingMessagesConfiguration: self.disappearingMessagesConfiguration, + contactLastKnownClientVersion: self.contactLastKnownClientVersion, + threadDisplayPictureUrl: (threadId == userProfile.id ? userProfile.displayPictureUrl : self.threadDisplayPictureUrl), + contactProfile: (self.contactProfile?.id == userProfile.id || threadId == userProfile.id ? userProfile : self.contactProfile), + closedGroupProfileFront: replaceIfCurrentUser(self.closedGroupProfileFront), + closedGroupProfileBack: replaceIfCurrentUser(self.closedGroupProfileBack), + closedGroupProfileBackFallback: replaceIfCurrentUser(self.closedGroupProfileBackFallback), + closedGroupAdminProfile: replaceIfCurrentUser(self.closedGroupAdminProfile), + closedGroupName: self.closedGroupName, + closedGroupDescription: self.closedGroupDescription, + closedGroupUserCount: self.closedGroupUserCount, + closedGroupExpired: self.closedGroupExpired, + currentUserIsClosedGroupMember: self.currentUserIsClosedGroupMember, + currentUserIsClosedGroupAdmin: self.currentUserIsClosedGroupAdmin, + openGroupName: self.openGroupName, + openGroupDescription: self.openGroupDescription, + openGroupServer: self.openGroupServer, + openGroupRoomToken: self.openGroupRoomToken, + openGroupPublicKey: self.openGroupPublicKey, + openGroupUserCount: self.openGroupUserCount, + openGroupPermissions: self.openGroupPermissions, + openGroupCapabilities: self.openGroupCapabilities, + interactionId: self.interactionId, + interactionVariant: self.interactionVariant, + interactionTimestampMs: self.interactionTimestampMs, + interactionBody: self.interactionBody, + interactionState: self.interactionState, + interactionHasBeenReadByRecipient: self.interactionHasBeenReadByRecipient, + interactionIsOpenGroupInvitation: self.interactionIsOpenGroupInvitation, + interactionAttachmentDescriptionInfo: self.interactionAttachmentDescriptionInfo, + interactionAttachmentCount: self.interactionAttachmentCount, + authorId: self.authorId, + threadContactNameInternal: self.threadContactNameInternal, + authorNameInternal: self.authorNameInternal, + currentUserSessionId: self.currentUserSessionId, + currentUserSessionIds: self.currentUserSessionIds, + recentReactionEmoji: self.recentReactionEmoji, + wasKickedFromGroup: self.wasKickedFromGroup, + groupIsDestroyed: self.groupIsDestroyed, + isContactApproved: self.isContactApproved + ) + } + func populatingPostQueryData( - threadDisplayPictureUrl: String?, - contactProfile: Profile?, recentReactionEmoji: [String]?, openGroupCapabilities: Set?, currentUserSessionIds: Set, @@ -650,8 +721,8 @@ public extension SessionThreadViewModel { threadCanUpload: threadCanUpload, disappearingMessagesConfiguration: self.disappearingMessagesConfiguration, contactLastKnownClientVersion: self.contactLastKnownClientVersion, - threadDisplayPictureUrl: (threadDisplayPictureUrl ?? self.threadDisplayPictureUrl), - contactProfile: (contactProfile ?? self.contactProfile), + threadDisplayPictureUrl: self.threadDisplayPictureUrl, + contactProfile: self.contactProfile, closedGroupProfileFront: self.closedGroupProfileFront, closedGroupProfileBack: self.closedGroupProfileBack, closedGroupProfileBackFallback: self.closedGroupProfileBackFallback, @@ -1624,7 +1695,12 @@ public extension SessionThreadViewModel { /// /// **Note 2:** Since the "Hidden Contact" records don't have associated threads the `rowId` value in the /// returned results will always be `-1` for those results - static func contactsAndGroupsQuery(userSessionId: SessionId, pattern: FTS5Pattern, searchTerm: String) -> AdaptedFetchRequest> { + static func contactsAndGroupsQuery( + userSessionId: SessionId, + currentUserName: String, + pattern: FTS5Pattern, + searchTerm: String + ) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) let closedGroup: TypedTableAlias = TypedTableAlias() @@ -1738,10 +1814,20 @@ public extension SessionThreadViewModel { LEFT JOIN ( SELECT \(groupMember[.groupId]), - GROUP_CONCAT(IFNULL(\(profile[.nickname]), \(profile[.name])), ', ') AS \(GroupMemberInfo.Columns.threadMemberNames) + GROUP_CONCAT( + CASE + WHEN \(groupMember[.profileId]) = \(userSessionId.hexString) + THEN \(currentUserName) + ELSE IFNULL(\(profile[.nickname]), \(profile[.name])) + END, + ', ' + ) AS \(GroupMemberInfo.Columns.threadMemberNames) FROM \(GroupMember.self) - JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) + LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + WHERE ( + \(groupMember[.profileId]) = \(userSessionId.hexString) OR + \(profile[.id]) IS NOT NULL + ) GROUP BY \(groupMember[.groupId]) ) AS \(groupMemberInfo) ON \(groupMemberInfo[.groupId]) = \(closedGroup[.threadId]) LEFT JOIN \(closedGroupProfileFront) ON ( diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index 0e5c08900d..0b7adec2e8 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -56,37 +56,27 @@ public class ThreadPickerViewModel { /// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this public lazy var observableViewData = ValueObservation .trackingConstantRegion { [dependencies] db -> [SessionThreadViewModel] in + let userProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } let userSessionId: SessionId = dependencies[cache: .general].sessionId return try SessionThreadViewModel .shareQuery(userSessionId: userSessionId) .fetchAll(db) + .map { $0.with(userProfile: userProfile) } .map { threadViewModel in - let wasKickedFromGroup: Bool = ( - threadViewModel.threadVariant == .group && - dependencies.mutate(cache: .libSession) { cache in - cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadViewModel.threadId)) + let (wasKickedFromGroup, groupIsDestroyed): (Bool, Bool) = { + guard threadViewModel.threadVariant == .group else { return (false, false) } + + let sessionId: SessionId = SessionId(.group, hex: threadViewModel.threadId) + return dependencies.mutate(cache: .libSession) { cache in + ( + cache.wasKickedFromGroup(groupSessionId: sessionId), + cache.groupIsDestroyed(groupSessionId: sessionId) + ) } - ) - let groupIsDestroyed: Bool = ( - threadViewModel.threadVariant == .group && - dependencies.mutate(cache: .libSession) { cache in - cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadViewModel.threadId)) - } - ) - - // TODO: [Database Relocation] Clean up this query as well - var targetContactProfile: Profile? = threadViewModel.contactProfile - var targetThreadDisplayPictureUrl: String? = threadViewModel.threadDisplayPictureUrl - - if threadViewModel.id == userSessionId.hexString { - targetContactProfile = dependencies.mutate(cache: .libSession) { $0.profile } - targetThreadDisplayPictureUrl = targetContactProfile?.displayPictureUrl - } + }() return threadViewModel.populatingPostQueryData( - threadDisplayPictureUrl: targetThreadDisplayPictureUrl, - contactProfile: targetContactProfile, recentReactionEmoji: nil, openGroupCapabilities: nil, currentUserSessionIds: [userSessionId.hexString], From 2c01af5e3933aadf9a07dff20e58080c9704e944 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 22 Oct 2025 17:17:58 +1100 Subject: [PATCH 134/162] Fixed a number of QA issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Fixed an issue where large display pic uploads weren't timing out correctly • Fixed an issue where other platforms always want the "byteCount" to be the plaintext value • Fixed an issue where the home and settings screens wouldn't update if they were open when the current users display picture finished downloading • Fixed an issue where we wouldn't retry downloading a display picture because the profile data hadn't changed (even if the file hadn't been downloaded) • Updated the re-upload job to run if the `shortenFileTTL` dev setting is turned on --- Session.xcodeproj/project.pbxproj | 8 +- .../Closed Groups/EditGroupViewModel.swift | 2 +- Session/Home/HomeViewModel.swift | 11 ++ Session/Settings/SettingsViewModel.swift | 11 ++ .../Database/Models/Profile.swift | 10 +- .../Jobs/DisplayPictureDownloadJob.swift | 19 ++- .../Jobs/ReuploadUserDisplayPictureJob.swift | 2 +- .../Utilities/AttachmentManager.swift | 53 +++---- .../Utilities/DisplayPictureManager.swift | 129 ++++++++++++------ .../Utilities/Profile+Updating.swift | 43 ++++++ 10 files changed, 200 insertions(+), 88 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 64cd162523..23a7d0e2b9 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -8366,7 +8366,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 649; + CURRENT_PROJECT_VERSION = 650; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8447,7 +8447,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 649; + CURRENT_PROJECT_VERSION = 650; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8933,7 +8933,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 649; + CURRENT_PROJECT_VERSION = 650; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -9523,7 +9523,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 649; + CURRENT_PROJECT_VERSION = 650; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; diff --git a/Session/Closed Groups/EditGroupViewModel.swift b/Session/Closed Groups/EditGroupViewModel.swift index 8a4817f5dd..f388d5bf59 100644 --- a/Session/Closed Groups/EditGroupViewModel.swift +++ b/Session/Closed Groups/EditGroupViewModel.swift @@ -119,7 +119,7 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Observa .fetchOne(db) profileFront = try frontProfileId.map { try Profile.fetchOne(db, id: $0) } - profileBack = try Profile.fetchOne(db, id: backProfileId ?? userSessionId.hexString) + profileBack = (backProfileId.map { try? Profile.fetchOne(db, id: $0) } ?? dependencies.mutate(cache: .libSession) { $0.profile }) } return State( diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index d4fbb0eec3..87b5d87fa1 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -225,6 +225,17 @@ public class HomeViewModel: NavigatableStateHolder { hasHiddenMessageRequests = libSession.get(.hasHiddenMessageRequests) } + /// If the users profile picture doesn't exist on disk then clear out the value (that way if we get events after downloading + /// it then then there will be a diff in the `State` and the UI will update + if + let displayPictureUrl: String = userProfile.displayPictureUrl, + let filePath: String = try? dependencies[singleton: .displayPictureManager] + .path(for: displayPictureUrl), + !dependencies[singleton: .fileManager].fileExists(atPath: filePath) + { + userProfile = userProfile.with(displayPictureUrl: .set(to: nil)) + } + // TODO: [Database Relocation] All profiles should be stored in the `profileCache` profileCache[userProfile.id] = userProfile diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index c7c0911959..7a3e349321 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -205,6 +205,17 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl } } + /// If the users profile picture doesn't exist on disk then clear out the value (that way if we get events after downloading + /// it then then there will be a diff in the `State` and the UI will update + if + let displayPictureUrl: String = profile.displayPictureUrl, + let filePath: String = try? dependencies[singleton: .displayPictureManager] + .path(for: displayPictureUrl), + !dependencies[singleton: .fileManager].fileExists(atPath: filePath) + { + profile = profile.with(displayPictureUrl: .set(to: nil)) + } + /// Process any event changes let groupedEvents: [GenericObservableKey: Set]? = events .reduce(into: [:]) { result, event in diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index ac561974f2..ee7a996fbb 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -402,16 +402,24 @@ public extension ProfileAssociated { public extension FetchRequest where RowDecoder: FetchableRecord & ProfileAssociated { func fetchAllWithProfiles(_ db: ObservingDatabase, using dependencies: Dependencies) throws -> [WithProfile] { + let userSessionId: SessionId = dependencies[cache: .general].sessionId let originalResult: [RowDecoder] = try self.fetchAll(db) + var userProfile: Profile? + + if Set(originalResult.map { $0.profileId }).contains(userSessionId.hexString) { + userProfile = dependencies.mutate(cache: .libSession) { $0.profile } + } + let profiles: [String: Profile]? = try? Profile .fetchAll(db, ids: originalResult.map { $0.profileId }.asSet()) .reduce(into: [:]) { result, next in result[next.id] = next } + .setting(userSessionId.hexString, userProfile) return originalResult.map { WithProfile( value: $0, profile: profiles?[$0.profileId], - currentUserSessionId: dependencies[cache: .general].sessionId + currentUserSessionId: userSessionId ) } } diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index b20cf27433..6bf1855915 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -128,11 +128,18 @@ public enum DisplayPictureDownloadJob: JobExecutor { let existingProfileUrl: String? = try? await dependencies[singleton: .storage].readAsync { db in switch details.target { case .profile(let id, _, _): - return try? Profile - .filter(id: id) - .select(.displayPictureUrl) - .asRequest(of: String.self) - .fetchOne(db) + /// We should consider `libSession` the source-of-truth for profile data for contacts so try to retrieve the profile data from + /// there before falling back to the one fetched from the database + return try? ( + dependencies.mutate(cache: .libSession) { + $0.profile(contactId: id)?.displayPictureUrl + } ?? + Profile + .filter(id: id) + .select(.displayPictureUrl) + .asRequest(of: String.self) + .fetchOne(db) + ) case .group(let id, _, _): return try? ClosedGroup @@ -440,7 +447,7 @@ extension DisplayPictureDownloadJob { guard dataMatches || - Profile.shouldUpdateProfile(timestamp, profile: latestProfile, using: dependencies) + Profile.shouldUpdateProfile(timestamp, profile: latestProfile, forDownloadingDisplayPicture: true, using: dependencies) else { throw AttachmentError.downloadNoLongerValid } break diff --git a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift index 7490fcab23..aa995bf7a3 100644 --- a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift +++ b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift @@ -63,7 +63,7 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { /// Only try to extend the TTL of the users display pic if enough time has passed since it was last updated let lastUpdated: Date = Date(timeIntervalSince1970: profile.profileLastUpdated ?? 0) - guard dependencies.dateNow.timeIntervalSince(lastUpdated) > maxExtendTTLFrequency else { + guard dependencies.dateNow.timeIntervalSince(lastUpdated) > maxExtendTTLFrequency || dependencies[feature: .shortenFileTTL] else { /// Reset the `nextRunTimestamp` value just in case the last run failed so we don't get stuck in a loop endlessly /// deferring the job if let jobId: Int64 = job.id { diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index 2310d56baa..0908d2de9c 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -928,7 +928,10 @@ public extension PendingAttachment { .encryptAttachment(plaintext: plaintext, domain: encryptionDomain) ) - finalByteCount = UInt(encryptionResult.ciphertext.count) + /// Ideally we would set this to the `ciphertext` size so that the "download file" UI is accurate but then we'd + /// need to update it after the download to be the `plaintext` so the "message info" UI was accurate - this + /// also (currently) causes issues on Desktop so for the time being just stick with the `plaintext` size + finalByteCount = UInt(preparedFileSize ?? 0) result = (encryptionResult.ciphertext, encryptionResult.encryptionKey, Data()) } else { @@ -949,9 +952,10 @@ public extension PendingAttachment { .legacyEncryptedDisplayPicture(data: plaintext, key: encryptionKey) ) - /// Legacy display picture encryption doesn't have the same padding requirement so we can set it - /// to the encrypted size - finalByteCount = UInt(ciphertext.count) + /// Ideally we would set this to the `ciphertext` size so that the "download file" UI is accurate but then we'd + /// need to update it after the download to be the `plaintext` so the "message info" UI was accurate - this + /// also (currently) causes issues on Desktop so for the time being just stick with the `plaintext` size + finalByteCount = UInt(preparedFileSize ?? 0) result = (ciphertext, encryptionKey, Data()) } @@ -1271,35 +1275,6 @@ public extension PendingAttachment { } } - fileprivate func convert( - source: ImageDataManager.DataSource, - to format: ConversionFormat, - filePath: String, - using dependencies: Dependencies - ) async throws { - guard case .media(let metadata) = metadata else { throw AttachmentError.invalidData } - - switch (format, utType.isVideo) { - case (.mp4, _), (.current, true): - return try await createVideo( - source: source, - metadata: metadata, - format: format, - filePath: filePath, - using: dependencies - ) - - case (.png, _), (.webPLossy, _), (.webPLossless, _), (.gif, _), (_, false): - return try await createImage( - source: source, - metadata: metadata, - format: format, - filePath: filePath, - using: dependencies - ) - } - } - private func createVideo( source: ImageDataManager.DataSource, metadata: MediaUtils.MediaMetadata, @@ -1434,18 +1409,26 @@ public extension PendingAttachment { var frames: [CGImage] = [] frames.reserveCapacity(metadata.frameCount) + try Task.checkCancellation() + for batchStart in stride(from: 0, to: metadata.frameCount, by: batchSize) { typealias FrameResult = (index: Int, frame: CGImage) + try Task.checkCancellation() + let batchEnd: Int = min(batchStart + batchSize, metadata.frameCount) let batchFrames: [CGImage] = try await withThrowingTaskGroup(of: FrameResult.self) { group in for i in batchStart.. { + private let allTasks: [Task] + private var continuation: CheckedContinuation? + private var hasFinished = false + + public static func race(_ tasks: Task...) async throws -> Success { + guard !tasks.isEmpty else { throw AttachmentError.invalidData } + + let racer: TaskRacer = TaskRacer(tasks: tasks) + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + Task { + await racer.setContinuation(continuation) + + for task in tasks { + Task { + let result = await task.result + await racer.tryToFinish(with: result) + } + } + } + } + } onCancel: { + for task in tasks { + task.cancel() + } + } + } + + init(tasks: [Task]) { + self.allTasks = tasks + } + + func setContinuation(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func tryToFinish(with result: Result) { + guard !hasFinished else { return } + + hasFinished = true + + continuation?.resume(with: result) + continuation = nil + + for task in allTasks { + task.cancel() + } + } + } + /// The desired output for a profile picture is a `WebP` at the specified size (and `cropRect`) that is generated in under `5s` do { - let result: PreparedAttachment = try await withThrowingTaskGroup(of: PreparedAttachment.self) { [dependencies] group in - group.addTask { + let result: PreparedAttachment = try await TaskRacer.race( + Task { return try await attachment.prepare( operations: DisplayPictureManager.standardOperations(cropRect: cropRect), using: dependencies ) - } - group.addTask { + }, + Task { try await Task.sleep(for: .seconds(5)) throw AttachmentError.conversionTimeout } - defer { group.cancelAll() } - - return try await group.first(where: { _ in true }) ?? { - throw AttachmentError.couldNotConvert - }() - } + ) + let preparedSize: UInt64? = dependencies[singleton: .fileManager].fileSize(of: result.filePath) guard (preparedSize ?? UInt64.max) < attachment.fileSize else { @@ -259,41 +307,40 @@ public class DisplayPictureManager { /// /// **Note:** In this case we want to ignore any error and just fallback to the original file (with metadata stripped) if attachment.utType == .gif { - let maybeResult: PreparedAttachment? = try? await withThrowingTaskGroup(of: PreparedAttachment.self) { [dependencies] group in - group.addTask { - return try await attachment.prepare( - operations: [ - .convert(to: .gif( - maxDimension: DisplayPictureManager.maxDimension, - cropRect: cropRect - )), - .stripImageMetadata - ], - using: dependencies - ) - } - group.addTask { - try await Task.sleep(for: .seconds(2)) - throw AttachmentError.conversionTimeout + do { + let result: PreparedAttachment = try await TaskRacer.race( + Task { + return try await attachment.prepare( + operations: [ + .convert(to: .gif( + maxDimension: DisplayPictureManager.maxDimension, + cropRect: cropRect + )), + .stripImageMetadata + ], + using: dependencies + ) + }, + Task { + try await Task.sleep(for: .seconds(2)) + throw AttachmentError.conversionTimeout + } + ) + + /// Only return the resized GIF if it's smaller than the original (the current GIF encoding we use is just the built-in iOS + /// encoding which isn't very advanced, as such some GIFs can end up quite large, even if they are cropped versions + /// of other GIFs - this is likely due to the lack of "frame differencing" support) + let preparedSize: UInt64? = dependencies[singleton: .fileManager].fileSize(of: result.filePath) + + guard (preparedSize ?? UInt64.max) < attachment.fileSize else { + throw AttachmentError.conversionResultedInLargerFile } - defer { group.cancelAll() } - return try await group.first(where: { _ in true }) ?? { - throw AttachmentError.couldNotConvert - }() - } - - /// Only return the resized GIF if it's smaller than the original (the current GIF encoding we use is just the built-in iOS - /// encoding which isn't very advanced, as such some GIFs can end up quite large, even if they are cropped versions - /// of other GIFs - this is likely due to the lack of "frame differencing" support) - if - let result: PreparedAttachment = maybeResult, - let preparedSize: UInt64 = dependencies[singleton: .fileManager] - .fileSize(of: result.filePath), - preparedSize < attachment.fileSize - { return result } + catch AttachmentError.conversionTimeout {} /// Expected case + catch AttachmentError.conversionResultedInLargerFile {} /// Expected case + catch { throw error } } /// If we weren't able to generate the `WebP` (or resized `GIF` if the source was a `GIF`) then just use the original source diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index d15c96ea7d..3e3612804b 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -88,6 +88,7 @@ public extension Profile { static func shouldUpdateProfile( _ profileUpdateTimestamp: TimeInterval?, profile: Profile, + forDownloadingDisplayPicture: Bool = false, using dependencies: Dependencies ) -> Bool { /// We should consider `libSession` the source-of-truth for profile data for contacts so try to retrieve the profile data from @@ -107,6 +108,12 @@ public extension Profile { return true } + /// Check if we are validating an update for the purpose of downloading a display picture + if forDownloadingDisplayPicture { + /// If so then the the timestamp can either be newer or match the cached value + return (finalProfileUpdateTimestamp >= finalCachedProfileUpdateTimestamp) + } + /// Otherwise we should only accept the update if it's newer than our cached value return (finalProfileUpdateTimestamp > finalCachedProfileUpdateTimestamp) } @@ -131,6 +138,42 @@ public extension Profile { var profileChanges: [ConfigColumnAssignment] = [] guard shouldUpdateProfile(profileUpdateTimestamp, profile: profile, using: dependencies) else { + /// If we can update for the purpose of downloading a display picture then it's possible this is a missing display picture so + /// schedule a new download job + if shouldUpdateProfile(profileUpdateTimestamp, profile: profile, forDownloadingDisplayPicture: true, using: dependencies) { + var targetUrl: String? = profile.displayPictureUrl + var targetKey: Data? = profile.displayPictureEncryptionKey + + switch displayPictureUpdate { + case .contactUpdateTo(let url, let key, _), .currentUserUpdateTo(let url, let key, _, _): + targetUrl = url + targetKey = key + + default: break + } + + if let url: String = targetUrl, let key: Data = targetKey { + let fileExists: Bool = ((try? dependencies[singleton: .displayPictureManager] + .path(for: url)) + .map { dependencies[singleton: .fileManager].fileExists(atPath: $0) } ?? false) + + if !fileExists { + dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details( + target: .profile(id: profile.id, url: url, encryptionKey: key), + timestamp: profileUpdateTimestamp + ) + ), + canStartJob: dependencies[singleton: .appContext].isMainApp + ) + } + } + } + return } From 95b0759bf0a29320327deb25a73fdbcff61accbd Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 23 Oct 2025 09:40:28 +1100 Subject: [PATCH 135/162] fix: message status padding to message bubble in message info screen --- Session/Media Viewing & Editing/MessageInfoScreen.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index d80013f827..8bd9adb08b 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -121,7 +121,6 @@ struct MessageInfoScreen: View { .foregroundColor(themeColor: tintColor) } } - .padding(.top, -Values.verySmallSpacing) .padding(.bottom, Values.verySmallSpacing) .padding(.horizontal, Values.largeSpacing) } From 4f2e990e8fe891e3a57f6dc3686478b5c06589b2 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Fri, 24 Oct 2025 12:55:26 +1100 Subject: [PATCH 136/162] fix: minor QA issues --- .../Conversations/Context Menu/ContextMenuVC+Action.swift | 6 +++--- .../Message Cells/Content Views/QuoteView.swift | 2 +- .../DeveloperSettings/DeveloperSettingsProViewModel.swift | 5 ++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 4c6610be0c..8579f90819 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -84,10 +84,10 @@ extension ContextMenuVC { ) { completion in delegate?.reply(cellViewModel, completion: completion) } } - static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?, forMessageInfoScreen: Bool) -> Action { return Action( icon: Lucide.image(icon: .copy, size: 24), - title: "copy".localized(), + title: forMessageInfoScreen ? "messageCopy".localized() : "copy".localized(), feedback: "copied".localized(), accessibilityLabel: "Copy text" ) { completion in delegate?.copy(cellViewModel, completion: completion) } @@ -291,7 +291,7 @@ extension ContextMenuVC { let generatedActions: [Action] = [ (canRetry ? Action.retry(cellViewModel, delegate) : nil), (viewModelCanReply(cellViewModel, using: dependencies) ? Action.reply(cellViewModel, delegate) : nil), - (canCopy ? Action.copy(cellViewModel, delegate) : nil), + (canCopy ? Action.copy(cellViewModel, delegate, forMessageInfoScreen: forMessageInfoScreen) : nil), (canSave ? Action.save(cellViewModel, delegate) : nil), (canCopySessionId ? Action.copySessionID(cellViewModel, delegate) : nil), (canDelete ? Action.delete(cellViewModel, delegate) : nil), diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index 16cfdc1c18..cf164f29ff 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -253,7 +253,7 @@ final class QuoteView: UIView { mainStackView.addArrangedSubview(cancelButton) cancelButton.center(.vertical, in: self) mainStackView.isLayoutMarginsRelativeArrangement = true - mainStackView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 2) + mainStackView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 1) } } diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index 384b5c2138..4d6ff0165e 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -166,7 +166,10 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold .feature(.sessionProEnabled), .updateScreen(DeveloperSettingsProViewModel.self), .feature(.mockCurrentUserSessionPro), - .feature(.allUsersSessionPro) + .feature(.allUsersSessionPro), + .feature(.messageFeatureProBadge), + .feature(.messageFeatureLongMessage), + .feature(.messageFeatureAnimatedAvatar) ] static func initialState(using dependencies: Dependencies) -> State { From 8d8caa817d7810cda0182ab97b978fe47fde0959 Mon Sep 17 00:00:00 2001 From: mpretty-cyro <15862619+mpretty-cyro@users.noreply.github.com> Date: Mon, 27 Oct 2025 00:41:02 +0000 Subject: [PATCH 137/162] [Automated] Update translations from Crowdin --- .../Meta/Translations/Localizable.xcstrings | 2402 ++++++++--------- 1 file changed, 1182 insertions(+), 1220 deletions(-) diff --git a/Session/Meta/Translations/Localizable.xcstrings b/Session/Meta/Translations/Localizable.xcstrings index bf1d25c869..b05a5a167f 100644 --- a/Session/Meta/Translations/Localizable.xcstrings +++ b/Session/Meta/Translations/Localizable.xcstrings @@ -6916,6 +6916,39 @@ } } }, + "addAdmin" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Admin" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Admins" + } + } + } + } + } + } + } + } + }, "addAdmins" : { "extractionState" : "manual", "localizations" : { @@ -7220,6 +7253,17 @@ } } }, + "adminCannotBeDemoted" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Admins cannot be demoted or removed from the group." + } + } + } + }, "adminCannotBeRemoved" : { "extractionState" : "manual", "localizations" : { @@ -18128,6 +18172,40 @@ } } }, + "admins" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Admins" + } + } + } + }, + "adminSelected" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Admin Selected" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Admins Selected" + } + } + } + } + } + } + }, "adminSendingPromotion" : { "extractionState" : "manual", "localizations" : { @@ -19584,6 +19662,17 @@ } } }, + "adminStatusYou" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You cannot change your admin status. To leave the group, open the conversation settings and select Leave Group." + } + } + } + }, "adminTwoPromotedToAdmin" : { "extractionState" : "manual", "localizations" : { @@ -83892,49 +83981,13 @@ } } }, - "cancelPlan" : { + "cancelAccess" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Planı ləğv et" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zrušit tarif" - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Cancel Plan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Annuler l’abonnement" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abonnement annuleren" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Anuluj plan" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Скасувати тарифний план" + "value" : "Cancel {pro}" } } } @@ -83956,7 +84009,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Cancel your plan on the {platform} website, using the {platform_account} you signed up for {pro} with." + "value" : "Cancel on the {platform} website, using the {platform_account} you signed up for {pro} with." } } } @@ -83967,7 +84020,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Cancel your plan on the {platform_store} website, using the {platform_account} you signed up for {pro} with." + "value" : "Cancel on the {platform_store} website, using the {platform_account} you signed up for {pro} with." } } } @@ -84610,6 +84663,17 @@ } } }, + "checkingProStatusContinue" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checking your {pro} status. You'll be able to continue once this check is complete." + } + } + } + }, "checkingProStatusDescription" : { "extractionState" : "manual", "localizations" : { @@ -108233,6 +108297,28 @@ } } }, + "confirmPromotion" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirm Promotion" + } + } + } + }, + "confirmPromotionDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Are you sure? Admins cannot be demoted or removed from the group." + } + } + } + }, "contactContacts" : { "extractionState" : "manual", "localizations" : { @@ -110646,6 +110732,29 @@ } } }, + "contactSelected" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Contact Selected" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Contacts Selected" + } + } + } + } + } + } + }, "contactUserDetails" : { "extractionState" : "manual", "localizations" : { @@ -126722,6 +126831,17 @@ } } }, + "currentBilling" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current Billing" + } + } + } + }, "currentPassword" : { "extractionState" : "manual", "localizations" : { @@ -126781,53 +126901,6 @@ } } }, - "currentPlan" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hazırkı plan" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Současný tarif" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Current Plan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Forfait actuel" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Huidig abonnement" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Obecny plan" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Поточна передплата" - } - } - } - }, "cut" : { "extractionState" : "manual", "localizations" : { @@ -191418,13 +191491,24 @@ } } }, - "errorLoadingProPlan" : { + "errorLoadingProAccess" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Error loading {pro} plan" + "value" : "Error loading {pro} access" + } + } + } + }, + "errorNoLookupOns" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{app_name} was unable to search for this ONS. Please check your network connection and try again." } } } @@ -191914,6 +191998,50 @@ } } }, + "errorUnregisteredOns" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This ONS is not registered. Please check it is correct and try again." + } + } + } + }, + "failedResendInvite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to resend invite to {name} in {group_name}" + } + } + } + }, + "failedResendInviteMultiple" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to resend invite to {name} and {count} others in {group_name}" + } + } + } + }, + "failedResendInviteTwo" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to resend invite to {name} and {other_name} in {group_name}" + } + } + } + }, "failedToDownload" : { "extractionState" : "manual", "localizations" : { @@ -211638,6 +211766,39 @@ } } }, + "groupMemberInvitedHistory" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{name} was invited to join the group. Chat history from the last 14 days was shared." + } + } + } + }, + "groupMemberInvitedHistoryMultiple" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{name} and {count} others were invited to join the group. Chat history from the last 14 days was shared." + } + } + } + }, + "groupMemberInvitedHistoryTwo" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{name} and {other_name} were invited to join the group. Chat history from the last 14 days was shared." + } + } + } + }, "groupMemberLeft" : { "extractionState" : "manual", "localizations" : { @@ -222228,6 +222389,17 @@ } } }, + "groupOnlyAdminLeave" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You are the only admin in {group_name}.

Group members and settings cannot be changed without an admin. To leave the group without deleting it, please add a new admin first" + } + } + } + }, "groupPendingRemoval" : { "extractionState" : "manual", "localizations" : { @@ -242167,6 +242339,39 @@ } } }, + "inviteContactsPlural" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invite Contact" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invite Contacts" + } + } + } + } + } + } + } + } + }, "inviteFailed" : { "extractionState" : "manual", "localizations" : { @@ -244061,6 +244266,50 @@ } } }, + "inviteMembers" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invite Member" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invite Members" + } + } + } + } + } + } + } + } + }, + "inviteNewMemberGroupLink" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invite a new member to the group by entering your friend's Account ID, ONS or scanning their QR code {icon}" + } + } + } + }, "join" : { "extractionState" : "manual", "localizations" : { @@ -260956,6 +261205,17 @@ } } }, + "manageAdmins" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manage Admins" + } + } + } + }, "manageMembers" : { "extractionState" : "manual", "localizations" : { @@ -265737,6 +265997,40 @@ } } }, + "memberSelected" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Member Selected" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Members Selected" + } + } + } + } + } + } + }, + "membersGroupPromotionAcceptInvite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Members can only be promoted once they've\r\naccepted an invite to join the group." + } + } + } + }, "membersInvite" : { "extractionState" : "manual", "localizations" : { @@ -266216,6 +266510,17 @@ } } }, + "membersInviteNoContacts" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You don’t have any contacts to invite to this group.
Go back and invite members using their Account ID or ONS." + } + } + } + }, "membersInviteSend" : { "extractionState" : "manual", "localizations" : { @@ -270425,6 +270730,17 @@ } } }, + "membersInviteShareMessageHistoryDays" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share message history from last\r\n14 days" + } + } + } + }, "membersInviteShareNewMessagesOnly" : { "extractionState" : "manual", "localizations" : { @@ -271383,6 +271699,17 @@ } } }, + "membersNonAdmins" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Members (Non-Admins)" + } + } + } + }, "menuBar" : { "extractionState" : "manual", "localizations" : { @@ -279218,6 +279545,17 @@ } } }, + "messageNewDescriptionMobileLink" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start a new conversation by entering your friend's Account ID, ONS or scanning their QR code {icon}" + } + } + } + }, "messageNewYouveGot" : { "extractionState" : "manual", "localizations" : { @@ -300091,6 +300429,39 @@ } } }, + "NoNonAdminsInGroup" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There are no non-admin members in this group." + } + } + } + }, + "nonProLongerMessagesDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send messages up to 10,000 characters in all conversations." + } + } + } + }, + "nonProUnlimitedPinnedDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Organize chats with unlimited pinned conversations." + } + } + } + }, "noSuggestions" : { "extractionState" : "manual", "localizations" : { @@ -327764,7 +328135,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, cancel your plan via the {app_pro} settings." + "value" : "Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, cancel {pro} via the {app_pro} settings." } } } @@ -327772,34 +328143,10 @@ "onDeviceDescription" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Başda qeydiyyatdan keçdiyiniz {platform_account} hesabına giriş etdiyiniz {device_type} cihazından bu {app_name} hesabını açın. Sonra planınızı {app_pro} ayarları vasitəsilə dəyişdirin." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Otevřete tento účet {app_name} na zařízení {device_type}, které je přihlášeno do účtu {platform_account}, pomocí kterého jste se původně zaregistrovali. Poté změňte svůj tarif v nastavení {app_pro}." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, change your plan via the {app_pro} settings." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ouvrez ce compte {app_name} sur un appareil {device_type} connecté au compte {platform_account} avec lequel vous vous êtes inscrit à l'origine. Ensuite, modifiez votre abonnement via les paramètres {app_pro}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open dit {app_name} account op een {device_type} apparaat waarop je bent aangemeld met het {platform_account} waarmee je je oorspronkelijk hebt geregistreerd. Wijzig vervolgens je abonnement via de instellingen van {app_pro}." + "value" : "Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, update your {pro} access via the {app_pro} settings." } } } @@ -354677,6 +355024,237 @@ } } }, + "proAccessActivatedAutoShort" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {pro} access is active!

Your {pro} access will automatically renew for another {current_plan_length} on {date}." + } + } + } + }, + "proAccessActivatedNotAuto" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {pro} access will expire on {date}.

Update your {pro} access now to ensure you automatically renew before your {pro} access expires." + } + } + } + }, + "proAccessActivatesAuto" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {pro} access is active!

Your {pro} access will automatically renew for another
{current_plan_length} on {date}. Any updates you make here will take effect at your next renewal." + } + } + } + }, + "proAccessError" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} Access Error" + } + } + } + }, + "proAccessExpireDate" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {pro} access will expire on {date}." + } + } + } + }, + "proAccessLoading" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} Access Loading" + } + } + } + }, + "proAccessLoadingDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your {pro} access information is still being loaded. You cannot update until this process is complete." + } + } + } + }, + "proAccessLoadingEllipsis" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} access loading..." + } + } + } + }, + "proAccessNetworkLoadError" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to connect to the network to load your {pro} access information. Updating {pro} via {app_name} will be disabled until connectivity is restored.

Please check your network connection and retry." + } + } + } + }, + "proAccessNotFound" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} Access Not Found" + } + } + } + }, + "proAccessNotFoundDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{app_name} detected that your account does not have {pro} access. If you believe this is a mistake, please reach out to {app_name} support for assistance." + } + } + } + }, + "proAccessRecover" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recover {pro} Access" + } + } + } + }, + "proAccessRenew" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew {pro} Access" + } + } + } + }, + "proAccessRenewDesktop" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Currently, {pro} access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you are using {app_name} Desktop, you're not able to renew here.

{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon}" + } + } + } + }, + "proAccessRenewPlatformStoreWebsite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew your {pro} access on the {platform_store} website using the {platform_account} you signed up for {pro} with." + } + } + } + }, + "proAccessRenewPlatformWebsite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew on the {platform} website using the {platform_account} you signed up for {pro} with." + } + } + } + }, + "proAccessRenewStart" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew your {pro} access to start using powerful {app_pro} Beta features again." + } + } + } + }, + "proAccessRestored" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{pro} Access Recovered" + } + } + } + }, + "proAccessRestoredDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "{app_name} detected and recovered {pro} access for your account. Your {pro} status has been restored!" + } + } + } + }, + "proAccessSignUp" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Because you originally signed up for {app_pro} via the {platform_store}, you'll need to use your {platform_account} to update your {pro} access." + } + } + } + }, + "proAccessUpgradeDesktop" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Currently, {pro} access can only be purchased via the {platform_store} or {platform_store_other}. Because you are using {app_name} Desktop, you're not able to upgrade to {pro} here.

{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon}" + } + } + } + }, "proActivated" : { "extractionState" : "manual", "localizations" : { @@ -354852,46 +355430,10 @@ "proAllSetDescription" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} planınız güncəlləndi! Hazırkı {pro} planınız avtomatik olaraq {date} tarixində yeniləndiyi zaman ödəniş haqqı alınacaq." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Váš tarif {app_pro} byl aktualizován! Účtování proběhne při automatickém obnovení vašeho aktuálního tarifu {pro} dne {date}." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your {app_pro} plan was updated! You will be billed when your current {pro} plan is automatically renewed on {date}." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre forfait {app_pro} a été mis à jour ! Vous serez facturé lorsque votre forfait {pro} actuel sera automatiquement renouvelé le {date}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je {app_pro} abonnement is bijgewerkt! Je wordt gefactureerd wanneer je huidige {pro} abonnement automatisch wordt verlengd op {date}." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Twój plan {app_pro} został zaktualizowany! Opłata zostanie pobrana, kiedy Twój obecny plan {pro} odnowi się automatycznie {date}." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Твою підписку {app_pro} оновлено. {date} коли підписку {pro} буде подовжено, тоді й стягнуть гроші." + "value" : "Your {app_pro} access was updated! You will be billed when {pro} is automatically renewed on {date}." } } } @@ -355785,12 +356327,6 @@ "value" : "{pro}, {time} tarixində avto-yenilənir" } }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} se automaticky obnoví za {time}" - } - }, "de" : { "stringUnit" : { "state" : "translated", @@ -356880,7 +357416,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Canceling your {app_pro} plan will prevent your plan from automatically renewing before your {pro} plan expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your plan expires.

Because you originally signed up for {app_pro} using your {platform_account}, you'll need to use the same {platform_account} to cancel your plan." + "value" : "Canceling {pro} access will prevent automatic renewal from occurring before {pro} access expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires.

Because you originally signed up for {app_pro} using your {platform_account}, you'll need to use the same {platform_account} to cancel {pro}." } } } @@ -356888,16 +357424,10 @@ "proCancellationOptions" : { "extractionState" : "manual", "localizations" : { - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dva způsoby, jak zrušit váš tarif:" - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Two ways to cancel your plan:" + "value" : "Two ways to cancel your {pro} access:" } } } @@ -356908,7 +357438,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Canceling your {app_pro} plan will prevent your plan from automatically renewing before your {pro} plan expires.

Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your plan expires." + "value" : "Canceling {pro} access will prevent automatic renewal from occurring before {pro} expires.

Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires." } } } @@ -356919,7 +357449,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "We’re sorry to see you cancel {pro}. Here's what you need to know before canceling your {app_pro} plan." + "value" : "Sorry to see you cancel {pro}. Here's what you need to know before canceling your {pro} access." } } } @@ -356935,19 +357465,24 @@ } } }, - "proClearAllDataDevice" : { + "proChooseAccess" : { "extractionState" : "manual", "localizations" : { - "cs" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Opravdu chcete smazat svá data z tohoto zařízení?

{app_pro} nelze převést na jiný účet. Uložte si své heslo pro obnovení, abyste mohli svůj tarif {pro} později obnovit." + "value" : "Choose the {pro} access option that's right for you.
Longer access means bigger discounts." } - }, + } + } + }, + "proClearAllDataDevice" : { + "extractionState" : "manual", + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Are you sure you want to delete your data from this device?

{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} plan later." + "value" : "Are you sure you want to delete your data from this device?

{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} access later." } } } @@ -356955,16 +357490,10 @@ "proClearAllDataNetwork" : { "extractionState" : "manual", "localizations" : { - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Opravdu chcete smazat svá data ze sítě? Pokud budete pokračovat, nebudete moci obnovit vaše zprávy ani kontakty.

{app_pro} nelze převést na jiný účet. Uložte si své heslo pro obnovení, abyste mohli svůj tarif {pro} později obnovit." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts.

{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} plan later." + "value" : "Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts.

{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} access later." } } } @@ -356972,46 +357501,10 @@ "proDiscountTooltip" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hazırkı planınızda artıq tam {app_pro} qiymətinin {percent}% endirimi mövcuddur." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Váš aktuální tarif je již zlevněn o {percent} % z plné ceny {app_pro}." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your current plan is already discounted by {percent}% of the full {app_pro} price." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre abonnement actuel bénéficie déjà d'une remise de {percent}% sur le prix de {app_pro}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je huidige abonnement is al met {percent}% korting ten opzichte van de volledige {app_pro} prijs." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cena Twojego obecnego planu jest obniżona o {percent}% pełnej ceny {app_pro}." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "На поточну підписку ти вже маєш знижку {percent}% від загальної ціни {app_pro}." + "value" : "Your {pro} access is already discounted by {percent}% of the full {app_pro} price." } } } @@ -357089,46 +357582,10 @@ "proExpiredDescription" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Təəssüf ki, {pro} planınızın müddəti bitib. {app_pro} tətbiqinin eksklüziv imtiyazlarına və özəlliklərinə erişimi davam etdirmək üçün yeniləyin." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bohužel, váš tarif {pro} vypršel. Obnovte jej, abyste nadále měli přístup k exkluzivním výhodám a funkcím {app_pro}." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Unfortunately, your {pro} plan has expired. Renew to keep accessing the exclusive perks and features of {app_pro}." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Malheureusement, votre formule {pro} a expiré. Renouvelez-la pour continuer à profiter des avantages et fonctionnalités exclusifs de {app_pro}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Helaas is je {pro} abonnement verlopen. Verleng om toegang te blijven houden tot de exclusieve voordelen en functies van {app_pro}." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Niestety, Twój plan {pro} wygasł. Odnów go, by odzyskać dostęp do ekskluzywnych korzyści i funkcji {app_pro}." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "На жаль, підписка {pro} сплила. Онови її задля збереження переваг і можливостей {app_pro}." + "value" : "Unfortunately, your {pro} access has expired. Renew to reactivate the exclusive perks and features of {app_pro}." } } } @@ -357183,46 +357640,10 @@ "proExpiringSoonDescription" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planınızın müddəti {time} vaxtında bitir. {app_pro} tətbiqinin eksklüziv imtiyazlarına və özəlliklərinə erişimi davam etdirmək üçün planınızı güncəlləyin." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Váš tarif {pro} vyprší za {time}. Aktualizujte si tarif, abyste i nadále měli přístup k exkluzivním výhodám a funkcím {app_pro}." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your {pro} plan is expiring in {time}. Update your plan to keep accessing the exclusive perks and features of {app_pro}." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre formule {pro} expire dans {time}. Mettez à jour votre formule pour continuer à profiter des avantages et fonctionnalités exclusifs de {app_pro}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je {pro} abonnement verloopt over {time}. Werk je abonnement bij om toegang te blijven houden tot de exclusieve voordelen en functies van {app_pro}." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Twój plan {pro} wygasa za {time}. Zaktualizuj swój plan, aby zachować dostęp do ekskluzywnych korzyści i funkcji {app_pro}." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Підписка {pro} спливе {time}. Онови підписку задля збереження переваг і можливостей {app_pro}." + "value" : "Your {pro} access is expiring in {time}. Update now to keep accessing the exclusive perks and features of {app_pro}." } } } @@ -360909,6 +361330,17 @@ } } }, + "proFullestPotential" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Want to use {app_name} to its fullest potential?
Upgrade to {app_pro} Beta to get access to loads of exclusive perks and features." + } + } + } + }, "proGroupActivated" : { "extractionState" : "manual", "localizations" : { @@ -361387,46 +361819,10 @@ "proImportantDescription" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Geri ödəniş tələbi qətidir. Əgər təsdiqlənsə, {pro} planınız dərhal ləğv ediləcək və bütün {pro} özəlliklərinə erişimi itirəcəksiniz." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Žádost o vrácení peněz je konečné. Pokud bude schváleno, váš tarif {pro} bude ihned zrušen a ztratíte přístup ke všem funkcím {pro}." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Requesting a refund is final. If approved, your {pro} plan will be canceled immediately and you will lose access to all {pro} features." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "La demande de remboursement est définitive. Si elle est approuvée, votre formule {pro} sera immédiatement annulée et vous perdrez l'accès à toutes les fonctionnalités {pro}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Het aanvragen van een terugbetaling is definitief. Indien goedgekeurd, wordt je {pro} abonnement onmiddellijk geannuleerd en verlies je de toegang tot alle {pro} functies." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wniosek o zwrot jest ostateczny. Jeżeli zostanie zatwierdzony, Twój plan {pro} zostanie natychmiast anulowany i utracisz dostęp do wszystkich funkcji {pro}." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вимагання повернення грошей закінчено. В разі схвалення твою підписку {pro} негайно скасують і ти втратиш всі можливості {pro}." + "value" : "Requesting a refund is final. If approved, your {pro} access will be canceled immediately and you will lose access to all {pro} features." } } } @@ -362709,6 +363105,50 @@ } } }, + "promoteAdminsWarning" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Admins will be able to see the last 14 days of message history and cannot be demoted or removed from the group." + } + } + } + }, + "promoteMember" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Promote Member" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Promote Members" + } + } + } + } + } + } + } + } + }, "promotionFailed" : { "extractionState" : "manual", "localizations" : { @@ -364508,7 +364948,18 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Reinstall {app_name} on this device via the {platform_store}, restore your account with your Recovery Password, and renew your plan in the {app_pro} settings." + "value" : "Reinstall {app_name} on this device via the {platform_store}, restore your account with your Recovery Password, and renew {pro} from the {app_pro} settings." + } + } + } + }, + "proNewInstallationUpgrade" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reinstall {app_name} on this device via the {platform_store}, restore your account with your Recovery Password, and upgrade to {pro} from the {app_pro} settings." } } } @@ -364516,16 +364967,21 @@ "proOptionsRenewalSubtitle" : { "extractionState" : "manual", "localizations" : { - "cs" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Nyní jsou k dispozici tři způsoby obnovy:" + "value" : "For now, there are three ways to renew:" } - }, + } + } + }, + "proOptionsTwoRenewalSubtitle" : { + "extractionState" : "manual", + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Currently, there are three ways to renew:" + "value" : "For now, there are two ways to renew:" } } } @@ -364796,397 +365252,6 @@ } } }, - "proPlanActivatedAuto" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} planınız aktivdir!

Planınız avtomatik olaraq {date} tarixində başqa bir {current_plan} üçün yenilənəcək. Planınıza edilən güncəlləmələr növbəti {pro} yenilənməsi zamanı qüvvəyə minəcək." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Váš tarif {app_pro} je aktivní!

Váš tarif se automaticky obnoví na další {current_plan} dne {date}. Změny tarifu se projeví při příštím obnovení {pro}." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Your {app_pro} plan is active!

Your plan will automatically renew for another {current_plan} on {date}. Updates to your plan take effect when {pro} is next renewed." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre formule {app_pro} est active !

Votre formule sera automatiquement renouvelée pour une autre {current_plan} le {date}. Les modifications apportées à votre formule prendront effet lors du prochain renouvellement de {pro}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je {app_pro} abonnement is actief!

Je abonnement wordt automatisch verlengd voor een nieuw {current_plan} op {date}. Wijzigingen aan je abonnement gaan in wanneer {pro} de volgende keer wordt verlengd." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Twój plan {app_pro} jest aktywny!

Zostanie on automatycznie odnowiony na kolejny {current_plan}, dnia {date}. Zmiany w Twoim planie wejdą w życie przy następnym odnowieniu subskrypcji {pro}." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Для тебе діє підписка {app_pro}.

{date} твою підписку буде самодійно поновлено як {current_plan}. Оновлення підписки настане під час наступного оновлення {pro}." - } - } - } - }, - "proPlanActivatedAutoShort" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} planınız aktivdir!

Planınız avtomatik olaraq {date} tarixində başqa bir {current_plan} üçün yenilənəcək." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Váš tarif {app_pro} je aktivní!

Tarif se automaticky obnoví na další {current_plan} dne {date}." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Your {app_pro} plan is active!

Your plan will automatically renew for another {current_plan} on {date}." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre forfait {app_pro} est actif

Votre abonnement se renouvellera automatiquement pour un autre {current_plan} le {date}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je {app_pro} abonnement is actief!

Je abonnement wordt automatisch verlengd met een {current_plan} op {date}." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Twój plan {app_pro} jest aktywny!

Zostanie on automatycznie odnowiony na kolejny {current_plan}, dnia {date}." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Для тебе діє підписка {app_pro}.

{date} твою підписку буде самодійно поновлено як {current_plan}." - } - } - } - }, - "proPlanActivatedNotAuto" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} planınızın müddəti {date} tarixində bitir.

Eksklüziv Pro özəlliklərinə kəsintisiz erişimi təmin etmək üçün planınızı indi güncəlləyin." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Váš tarif {app_pro} vyprší dne {date}.

Aktualizujte si tarif nyní, abyste měli i nadále nepřerušený přístup k exkluzivním funkcím Pro." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Your {app_pro} plan will expire on {date}.

Update your plan now to ensure uninterrupted access to exclusive Pro features." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre offre {app_pro} expirera le {date}.

Mettez votre offre à jour maintenant pour garantir un accès ininterrompu aux fonctionnalités exclusives Pro." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je {app_pro} abonnement verloopt op {date}.

Werk je abonnement nu bij om ononderbroken toegang te behouden tot exclusieve Pro functies." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Twój plan {app_pro} wygasa {date}.

Zaktualizuj swój plan już teraz, by zapewnić sobie nieprzerwany dostęp do ekskluzywnych funkcji Pro." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Твоя підписка {app_pro} спливе {date}.

Для збереження особливих можливостей подовж свою підписку." - } - } - } - }, - "proPlanError" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planı xətası" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chyba tarifu {pro}" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} Plan Error" - } - } - } - }, - "proPlanExpireDate" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} planınızın müddəti {date} tarixində bitir." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Váš tarif {app_pro} vyprší dne {date}." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Your {app_pro} plan will expire on {date}." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre forfait {app_pro} expirera le {date}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je {app_pro} abonnement verloopt op {date}." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Twój plan {app_pro} wygasa {date}." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Підписка {app_pro} спливе {date}." - } - } - } - }, - "proPlanLoading" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planı yüklənir" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Načítání tarifu {pro}" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} Plan Loading" - } - } - } - }, - "proPlanLoadingDescription" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planınız barədə məlumatlar hələ də yüklənir. Bu proses tamamlanana qədər planınızı güncəlləyə bilməzsiniz." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Váš tarif {pro} se stále načítá. Dokud nebude načítání dokončeno, nemůžete tarif změnit." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Information about your {pro} plan is still being loaded. You cannot update your plan until this process is complete." - } - } - } - }, - "proPlanLoadingEllipsis" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planı yüklənir..." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Načítání tarifu {pro}..." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} plan loading..." - } - } - } - }, - "proPlanNetworkLoadError" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hazırkı planınızı yükləmək üçün şəbəkəyə bağlana bilmir. Bağlantı bərpa olunana qədər {app_name} ilə planınızı güncəlləmək sıradan çıxarılacaq.

Lütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nelze se připojit k síti a načíst váš aktuální tarif. Aktualizace tarifu prostřednictvím {app_name} bude deaktivována, dokud nebude obnoveno připojení.

Zkontrolujte připojení k síti a zkuste to znovu." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unable to connect to the network to load your current plan. Updating your plan via {app_name} will be disabled until connectivity is restored.

Please check your network connection and retry." - } - } - } - }, - "proPlanNotFound" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planı tapılmadı" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} nebyl nalezen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} Plan Not Found" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Forfait {pro} introuvable" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} abonnement niet gevonden" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nie znaleziono planu {pro}" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Передплата {pro} не знайдена" - } - } - } - }, - "proPlanNotFoundDescription" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hesabınız üçün heç bir aktiv plan tapılmadı. Bunun bir səhv olduğunu düşünürsünüzsə, lütfən kömək üçün {app_name} dəstəyi ilə əlaqə saxlayın." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pro váš účet nebyl nalezen žádný aktivní tarif. Pokud si myslíte, že se jedná o chybu, kontaktujte podporu {app_name} a požádejte o pomoc." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No active plan was found for your account. If you believe this is a mistake, please reach out to {app_name} support for assistance." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aucun forfait actif n’a été trouvé pour votre compte. Si vous pensez qu’il s’agit d’une erreur, veuillez contacter l’assistance de {app_name} pour obtenir de l’aide." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Er is geen actief abonnement gevonden voor je account. Als je denkt dat dit een vergissing is, neem dan contact op met de ondersteuning van {app_name} voor hulp." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nie znaleziono aktywnych planów dla Twojego konta. Jeżeli uważasz, że to błąd, prosimy o kontakt z Supportem {app_name}." - } - } - } - }, "proPlanPlatformRefund" : { "extractionState" : "manual", "localizations" : { @@ -365209,327 +365274,13 @@ } } }, - "proPlanRecover" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planını geri qaytar" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Znovu nabýt tarif {pro}" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Recover {pro} Plan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Récupérer le forfait {pro}" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} abonnement herstellen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odzyskaj plan {pro}" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Відновити передплату {pro}" - } - } - } - }, - "proPlanRenew" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planını yenilə" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Obnovit tarif {pro}" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Renew {pro} Plan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Renouveler l’abonnement {pro}" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} abonnement verlengen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odnów plan {pro}" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Оновити підписку {pro}" - } - } - } - }, - "proPlanRenewDesktop" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you are using {app_name} Desktop, you're not able to renew your plan here.

{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon}" - } - } - } - }, - "proPlanRenewDesktopLinked" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Renew your plan in the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store_other}." - } - } - } - }, - "proPlanRenewPlatformStoreWebsite" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Renew your plan on the {platform_store} website using the {platform_account} you signed up for {pro} with." - } - } - } - }, - "proPlanRenewPlatformWebsite" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Renew your plan on the {platform} website using the {platform_account} you signed up for {pro} with." - } - } - } - }, - "proPlanRenewStart" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Güclü {app_pro} özəlliklərini yenidən istifadə etməyə başlamaq üçün {app_pro} planınızı yeniləyin." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Obnovte váš tarif {app_pro}, abyste mohli znovu využívat užitečné funkce {app_pro}." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Renew your {app_pro} plan to start using powerful {app_pro} Beta features again." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Renouvelez votre abonnement {app_pro} pour recommencer à utiliser les puissantes fonctionnalités bêta de {app_pro}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verleng je {app_pro} abonnement om opnieuw gebruik te maken van krachtige {app_pro} functies." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odnów swój plan {app_pro}, aby znów używać potężnych funkcji {app_pro} Beta." - } - } - } - }, "proPlanRenewSupport" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} planınız yeniləndi! {network_name} dəstək verdiyiniz üçün təşəkkürlər." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Váš tarif {app_pro} byl obnoven! Děkujeme, že podporujete síť {network_name}." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Your {app_pro} plan has been renewed! Thank you for supporting the {network_name}." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Votre forfait {app_pro} a été renouvelé ! Merci de soutenir le {network_name}." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Je {app_pro} abonnement is verlengd! Bedankt voor je steun aan de {network_name}." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Twój plan {app_pro} został odnowiony! Dziękujemy za wspieranie {network_name}." - } - } - } - }, - "proPlanRestored" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planı bərpa edildi" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} obnoven" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} Plan Restored" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} Forfait rétabli" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} abonnement hersteld" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Plan {pro} został odzyskany" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "План {pro} відновлено" - } - } - } - }, - "proPlanRestoredDescription" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{app_pro} üçün yararlı bir plan aşkarlandı və {pro} statusunuz bərpa edildi!" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Byl rozpoznán platný tarif {app_pro} a váš stav {pro} byl obnoven!" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "A valid plan for {app_pro} was detected and your {pro} status has been restored!" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Un abonnement valide pour {app_pro} a été détecté et votre statut {pro} a été restauré !" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Een geldig abonnement voor {app_pro} is gedetecteerd en je {pro} status is hersteld!" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wykryto ważny plan {app_pro} oraz przywrócono Twój status {pro}!" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Виявлено дійсний план {app_pro}, та ваш статус {pro} було відновлено!" - } - } - } - }, - "proPlanSignUp" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Because you originally signed up for {app_pro} via the {platform_store}, you'll need to use your {platform_account} to update your plan." + "value" : "Your {app_pro} access has been renewed! Thank you for supporting the {network_name}." } } } @@ -365786,7 +365537,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Refunds for {app_pro} plans are handled exclusively by {platform} through the {platform_store}.

Due to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued." + "value" : "Refunds for {app_pro} are handled exclusively by {platform} through the {platform_store}.

Due to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued." } } } @@ -365859,15 +365610,31 @@ } } }, - "proRenewBeta" : { + "proRenewalUnsuccessful" : { "extractionState" : "manual", "localizations" : { - "cs" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Obnovit {pro} Beta" + "value" : "Pro renewal unsuccessful, retrying soon" } - }, + } + } + }, + "proRenewAnimatedDisplayPicture" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Want to use animated display pictures again?
Renew your {pro} access to unlock the features you’ve been missing out on." + } + } + } + }, + "proRenewBeta" : { + "extractionState" : "manual", + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", @@ -365876,13 +365643,79 @@ } } }, + "proRenewDesktopLinked" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renew your {pro} access from the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store_other}." + } + } + } + }, "proRenewingNoAccessBilling" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you're not able to renew your plan here.

{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon}" + "value" : "Currently, {pro} access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you're not able to renew here.

{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon}" + } + } + } + }, + "proRenewLongerMessages" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Want to send longer messages again?
Renew your {pro} access to unlock the features you’ve been missing out on." + } + } + } + }, + "proRenewMaxPotential" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Want to use {app_name} to its max potential again?
Renew your {pro} access to unlock the features you’ve been missing out on." + } + } + } + }, + "proRenewPinFiveConversations" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Want to pin more than 5 conversations again?
Renew your {pro} access to unlock the features you’ve been missing out on." + } + } + } + }, + "proRenewPinMoreConversations" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Want to pin more conversations again?
Renew your {pro} access to unlock the features you’ve been missing out on." + } + } + } + }, + "proRenewTosPrivacy" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "By renewing, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon}" } } } @@ -366118,6 +365951,17 @@ } } }, + "proStartUsing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start Using {pro}" + } + } + } + }, "proStats" : { "extractionState" : "manual", "localizations" : { @@ -366379,6 +366223,17 @@ } } }, + "proStatusNetworkErrorContinue" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to connect to the network to check your {pro} status. You cannot continue until connectivity is restored.

Please check your network connection and retry." + } + } + } + }, "proStatusNetworkErrorDescription" : { "extractionState" : "manual", "localizations" : { @@ -366431,7 +366286,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Unable to connect to the network to load your current plan. Renewing your plan via {app_name} will be disabled until connectivity is restored.

Please check your network connection and retry." + "value" : "Unable to connect to the network to load your current {pro} access. Renewing {pro} via {app_name} will be disabled until connectivity is restored.

Please check your network connection and retry." } } } @@ -366439,46 +366294,10 @@ "proSupportDescription" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "{pro} planınızla bağlı kömək lazımdır? Dəstək komandamıza müraciət edin." - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Potřebujete pomoc se svým tarifem {pro}? Pošlete žádost týmu podpory." - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Need help with your {pro} plan? Submit a request to the support team." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Besoin d'aide avec votre forfait {pro} ? Envoyez une demande à l'équipe d'assistance." - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hulp nodig met je {pro} abonnement? Dien een verzoek in bij het ondersteuningsteam." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Potrzebujesz pomocy z planem {pro}? Wyślij zgłoszenie zespołowi wsparcia." - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Якщо потребуєш допомоги щодо підписки {pro}, надійшли звернення до відділу підтримки." + "value" : "Need help with {pro}? Submit a request to the support team." } } } @@ -366630,78 +366449,112 @@ } } }, - "proUpdatePlanDescription" : { + "proUpdateAccessDescription" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hazırda {current_plan} Planı üzərindəsiniz. {selected_plan} Planınana keçmək istədiyinizə əminsiniz?

Güncəlləsəniz, planınız {date} tarixində əlavə {selected_plan} {pro} erişimi üçün avtomatik yenilənəcək." - } - }, - "cs" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "V současné době jste na tarifu {current_plan}. Jste si jisti, že chcete přepnout na tarif {selected_plan}?

Po aktualizaci bude váš tarif automaticky obnoven {date} na další {selected_plan} přístupu {pro}." + "value" : "Your current billing option grants {current_plan_length} of {pro} access. Are you sure you want to switch to the {selected_plan_length_singular} billing option?

By updating, your {pro} access will automatically renew on {date} for an additional {selected_plan_length} of {pro} access." } - }, + } + } + }, + "proUpdateAccessExpireDescription" : { + "extractionState" : "manual", + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "You are currently on the {current_plan} Plan. Are you sure you want to switch to the {selected_plan} Plan?

By updating, your plan will automatically renew on {date} for an additional {selected_plan} of {pro} access." + "value" : "Your {pro} access will expire on {date}.

By updating, your {pro} access will automatically renew on {date} for an additional {selected_plan_length} of {pro} access." } - }, - "fr" : { + } + } + }, + "proUpgradeAccess" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Vous êtes actuellement sur le forfait {current_plan}. Voulez-vous vraiment passer au forfait {selected_plan}?

En mettant à jour, votre forfait sera automatiquement renouvelé le {date} pour {selected_plan} supplémentaire de l'accès {pro}." + "value" : "Upgrade to {app_pro} Beta to get access to loads of exclusive perks and features." } - }, - "nl" : { + } + } + }, + "proUpgraded" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Je zit momenteel op het {current_plan} abonnement. Weet je zeker dat je wilt overschakelen naar het {selected_plan} abonnement?

Als je dit bijwerkt, wordt je abonnement op {date} automatisch verlengd met {selected_plan} {pro} toegang." + "value" : "You have upgraded to {app_pro}!

Thank you for supporting the {network_name}." } } } }, - "proUpdatePlanExpireDescription" : { + "proUpgradeDesktopLinked" : { "extractionState" : "manual", "localizations" : { - "az" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Planınız {date} tarixində bitəcək.

Güncəlləsəniz, planınız {date} tarixində əlavə {selected_plan} Pro erişimi üçün avtomatik olaraq yenilənəcək." + "value" : "Upgrade to {pro} from the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store_other}." } - }, - "cs" : { + } + } + }, + "proUpgradeNoAccessBilling" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Váš tarif vyprší {date}.

Po aktualizaci se váš tarif automaticky obnoví {date} na další {selected_plan} přístupu Pro." + "value" : "Currently, {pro} access can only be purchased via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you're not able to upgrade to {pro} here.

{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon}" } - }, + } + } + }, + "proUpgradeOption" : { + "extractionState" : "manual", + "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your plan will expire on {date}.

By updating, your plan will automatically renew on {date} for an additional {selected_plan} of Pro access." + "value" : "For now, there is only one way to upgrade:" } - }, - "fr" : { + } + } + }, + "proUpgradeOptionsTwo" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Votre forfait expirera le {date}.

En le mettant à jour, votre forfait sera automatiquement renouvelé le {date} pour {selected_plan} supplémentaires d’accès Pro." + "value" : "For now, there are two ways to upgrade:" } - }, - "nl" : { + } + } + }, + "proUpgradingTo" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Je abonnement verloopt op {date}.

Door bij te werken wordt je abonnement automatisch verlengd op {date} voor een extra {selected_plan} aan Pro-toegang." + "value" : "Upgrading to {pro}" } - }, - "uk" : { + } + } + }, + "proUpgradingTosPrivacy" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ваш план завершиться {date}.

Після оновлення налаштувань автоматичної оплати, ваш план автоматично подовжиться {date} на додатковий {selected_plan} доступу до Pro." + "value" : "By upgrading, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon}" } } } @@ -381809,13 +381662,13 @@ } } }, - "refundPlanNonOriginatorApple" : { + "refundNonOriginatorApple" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Because you originally signed up for {app_pro} via a different {platform_account}, you'll need to use that {platform_account} to update your plan." + "value" : "Because you originally signed up for {app_pro} via a different {platform_account}, you'll need to use that {platform_account} to update your {pro} access." } } } @@ -382955,6 +382808,39 @@ } } }, + "removeMember" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove Member" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove Members" + } + } + } + } + } + } + } + } + }, "removePasswordFail" : { "extractionState" : "manual", "localizations" : { @@ -383508,12 +383394,6 @@ "value" : "Yenilə" } }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Obnovit" - } - }, "en" : { "stringUnit" : { "state" : "translated", @@ -384573,6 +384453,138 @@ } } }, + "resendingInvite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resending invite" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resending invites" + } + } + } + } + } + } + } + } + }, + "resendingPromotion" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resending promotion" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resending promotions" + } + } + } + } + } + } + } + } + }, + "resendInvite" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resend Invite" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resend Invites" + } + } + } + } + } + } + } + } + }, + "resendPromotion" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resend Promotion" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resend Promotions" + } + } + } + } + } + } + } + } + }, "resolving" : { "extractionState" : "manual", "localizations" : { @@ -418568,6 +418580,39 @@ } } }, + "update" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update" + } + } + } + }, + "updateAccess" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update {pro} Access" + } + } + } + }, + "updateAccessTwo" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Two ways to update your {pro} access:" + } + } + } + }, "updateApp" : { "extractionState" : "manual", "localizations" : { @@ -422795,100 +422840,6 @@ } } }, - "updatePlan" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Planı güncəllə" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aktualizovat tarif" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Update Plan" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mettre à jour le forfait" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Abonnement bijwerken" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zaktualizuj plan" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Оновити тарифний план" - } - } - } - }, - "updatePlanTwo" : { - "extractionState" : "manual", - "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "Planınızı güncəlləməyin iki yolu var:" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dva způsoby, jak aktualizovat váš tarif:" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Two ways to update your plan:" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Deux façons de mettre à jour votre abonnement :" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Twee manieren om je abonnement bij te werken:" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dwa sposoby na aktualizację planu:" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Два шляхи поновлення твоєї підписки:" - } - } - } - }, "updateProfileInformation" : { "extractionState" : "manual", "localizations" : { @@ -424616,6 +424567,17 @@ } } }, + "upgrade" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upgrade" + } + } + } + }, "upgradeSession" : { "extractionState" : "manual", "localizations" : { @@ -427719,7 +427681,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Change your plan using the {platform_account} you used to sign up with, via the {platform_store} website ." + "value" : "Update your {pro} access using the {platform_account} you used to sign up with, via the {platform_store} website." } } } From 68964990a1acf76823c996e06b82a39d1505f2c3 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 27 Oct 2025 15:55:50 +1100 Subject: [PATCH 138/162] Replicating the current user profile into the database again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Rolled back the change to stop replicating the current user profile into the database (and the various attempted fixes that came as a result) • Added env variable handling for "shortenFileTTL" • Made some improvements to the libSession build script and error handling • Fixed an issue where the reupload job would always extend to the max TTL regardless of the "shorten TTL" dev setting • Fixed an issue where setting the profile name before setting the display pic could result in the old display pic being used (even when setting to the same value - bug in libSession needs to be fixed to handle this case) • Fixed an issue where profile updates coming from libSession wouldn't be saved correctly in some cases --- Scripts/build_libSession_util.sh | 201 +++++---- Session.xcodeproj/project.pbxproj | 8 +- .../Closed Groups/EditGroupViewModel.swift | 2 +- .../Conversations/ConversationViewModel.swift | 57 ++- .../Settings/ThreadSettingsViewModel.swift | 36 +- .../GlobalSearchViewController.swift | 9 +- Session/Home/HomeViewModel.swift | 1 - .../MessageRequestsViewModel.swift | 2 - Session/Onboarding/Onboarding.swift | 72 ++-- .../DeveloperSettingsViewModel+Testing.swift | 8 + .../Database/Models/Profile.swift | 9 +- .../Jobs/DisplayPictureDownloadJob.swift | 11 +- .../Jobs/ReuploadUserDisplayPictureJob.swift | 6 +- .../Config Handling/LibSession+Contacts.swift | 74 +--- .../LibSession+UserProfile.swift | 82 ++-- .../MessageReceiver+Groups.swift | 6 +- .../MessageReceiver+VisibleMessages.swift | 2 +- .../SessionThreadViewModel.swift | 95 +---- .../Utilities/Profile+Updating.swift | 393 ++++++++++-------- .../FileServer/FileServerAPI.swift | 10 +- .../ThreadPickerViewModel.swift | 2 - SessionTests/Onboarding/OnboardingSpec.swift | 200 +++++++-- SessionUtilitiesKit/Crypto/CryptoError.swift | 19 +- 23 files changed, 672 insertions(+), 633 deletions(-) diff --git a/Scripts/build_libSession_util.sh b/Scripts/build_libSession_util.sh index df2d64b325..4b64dbb9c1 100755 --- a/Scripts/build_libSession_util.sh +++ b/Scripts/build_libSession_util.sh @@ -12,15 +12,48 @@ COMPILE_DIR="${TARGET_BUILD_DIR}/LibSessionUtil" INDEX_DIR="${DERIVED_DATA_PATH}/Index.noindex/Build/Products/Debug-${PLATFORM_NAME}" LAST_SUCCESSFUL_HASH_FILE="${TARGET_BUILD_DIR}/last_successful_source_tree.hash.log" LAST_BUILT_FRAMEWORK_SLICE_DIR_FILE="${TARGET_BUILD_DIR}/last_built_framework_slice_dir.log" -BUILT_LIB_FINAL_TIMESTAMP_FILE="${TARGET_BUILD_DIR}/libsession_util_built.timestamp" -# Save original stdout and set trap for cleanup -exec 3>&1 -function finish { - # Restore stdout - exec 1>&3 3>&- +# Robustly removes a directory, first clearing any immutable flags (work around Xcode's indexer file locking) +remove_locked_dir() { + local dir_to_remove="$1" + if [ -d "${dir_to_remove}" ]; then + echo "- Unlocking and removing ${dir_to_remove}" + chflags -R nouchg "${dir_to_remove}" &>/dev/null || true + rm -rf "${dir_to_remove}" + fi +} + +sync_headers() { + local source_dir="$1" + echo "- Syncing headers from ${source_dir}" + + local destinations=( + "${TARGET_BUILD_DIR}/include" + "${INDEX_DIR}/include" + "${BUILT_PRODUCTS_DIR}/include" + "${CONFIGURATION_BUILD_DIR}/include" + ) + + for dest in "${destinations[@]}"; do + if [ -n "$dest" ]; then + remove_locked_dir "$dest" + mkdir -p "$dest" + rsync -rtc --delete --exclude='.DS_Store' "${source_dir}/" "$dest/" + echo " Synced to: $dest" + fi + done } -trap finish EXIT ERR SIGINT SIGTERM + +# Modify the platform detection to handle archive builds +if [ "${ACTION}" = "install" ] || [ "${CONFIGURATION}" = "Release" ]; then + # Archive builds typically use 'install' action + if [ -z "$PLATFORM_NAME" ]; then + # During archive, PLATFORM_NAME might not be set correctly + # Default to device build for archives + PLATFORM_NAME="iphoneos" + echo "Missing 'PLATFORM_NAME' value, manually set to ${PLATFORM_NAME}" + fi +fi # Determine whether we want to build from source TARGET_ARCH_DIR="" @@ -35,11 +68,13 @@ else fi if [ "${COMPILE_LIB_SESSION}" != "YES" ]; then - echo "Restoring original headers to Xcode Indexer cache from backup..." - rm -rf "${INDEX_DIR}/include" - rsync -rt --exclude='.DS_Store' "${PRE_BUILT_FRAMEWORK_DIR}/${FRAMEWORK_DIR}/${TARGET_ARCH_DIR}/Headers/" "${INDEX_DIR}/include" - echo "Using pre-packaged SessionUtil" + sync_headers "${PRE_BUILT_FRAMEWORK_DIR}/${FRAMEWORK_DIR}/${TARGET_ARCH_DIR}/Headers/" + + # Create the placeholder in the FINAL products directory to satisfy dependency. + touch "${BUILT_PRODUCTS_DIR}/libsession-util.a" + + echo "- Revert to SPM complete." exit 0 fi @@ -83,20 +118,22 @@ fi echo "- Checking if libSession changed..." REQUIRES_BUILD=0 -# Generate a hash to determine whether any source files have changed -SOURCE_HASH=$(find "${LIB_SESSION_SOURCE_DIR}/src" -type f -not -name '.DS_Store' -exec md5 {} + | awk '{print $NF}' | sort | md5 | awk '{print $NF}') -HEADER_HASH=$(find "${LIB_SESSION_SOURCE_DIR}/include" -type f -not -name '.DS_Store' -exec md5 {} + | awk '{print $NF}' | sort | md5 | awk '{print $NF}') -EXTERNAL_HASH=$(find "${LIB_SESSION_SOURCE_DIR}/external" -type f -not -name '.DS_Store' -exec md5 {} + | awk '{print $NF}' | sort | md5 | awk '{print $NF}') -MAKE_LISTS_HASH=$(md5 -q "${LIB_SESSION_SOURCE_DIR}/CMakeLists.txt") -STATIC_BUNDLE_HASH=$(md5 -q "${LIB_SESSION_SOURCE_DIR}/utils/static-bundle.sh") - -CURRENT_SOURCE_TREE_HASH=$( ( - echo "${SOURCE_HASH}" - echo "${HEADER_HASH}" - echo "${EXTERNAL_HASH}" - echo "${MAKE_LISTS_HASH}" - echo "${STATIC_BUNDLE_HASH}" -) | sort | md5 -q) +# Generate a hash to determine whether any source files have changed (by using git we automatically +# respect .gitignore) +CURRENT_SOURCE_TREE_HASH=$( \ + ( \ + cd "${LIB_SESSION_SOURCE_DIR}" && git ls-files --recurse-submodules \ + ) \ + | grep -vE '/(tests?|docs?|examples?)/|\.md$|/(\.DS_Store|\.gitignore)$' \ + | sort \ + | tr '\n' '\0' \ + | ( \ + cd "${LIB_SESSION_SOURCE_DIR}" && xargs -0 md5 -r \ + ) \ + | awk '{print $1}' \ + | sort \ + | md5 -q \ +) PREVIOUS_BUILT_FRAMEWORK_SLICE_DIR="" if [ -f "$LAST_BUILT_FRAMEWORK_SLICE_DIR_FILE" ]; then @@ -131,10 +168,6 @@ if [ "${REQUIRES_BUILD}" == 1 ]; then VALID_SIM_ARCH_PLATFORMS=(SIMULATORARM64 SIMULATOR64) VALID_DEVICE_ARCH_PLATFORMS=(OS64) - OUTPUT_DIR="${TARGET_BUILD_DIR}" - IPHONEOS_DEPLOYMENT_TARGET=${IPHONEOS_DEPLOYMENT_TARGET} - ENABLE_BITCODE=${ENABLE_BITCODE} - # Generate the target architectures we want to build for TARGET_ARCHS=() TARGET_PLATFORMS=() @@ -204,69 +237,31 @@ if [ "${REQUIRES_BUILD}" == 1 ]; then log_file="${COMPILE_DIR}/libsession_util_output.log" echo "- Building ${TARGET_ARCHS[$i]} for $platform in $build" - # Redirect the build output to a log file and only include the progress lines in the XCode output - exec > >(tee "$log_file" | grep --line-buffered '^\[.*%\]') 2>&1 - cd "${LIB_SESSION_SOURCE_DIR}" - env -i PATH="$PATH" SDKROOT="$(xcrun --sdk macosx --show-sdk-path)" \ - ./utils/static-bundle.sh "$build" "" \ - -DCMAKE_TOOLCHAIN_FILE="${LIB_SESSION_SOURCE_DIR}/external/ios-cmake/ios.toolchain.cmake" \ - -DPLATFORM=$platform \ - -DDEPLOYMENT_TARGET=$IPHONEOS_DEPLOYMENT_TARGET \ - -DENABLE_BITCODE=$ENABLE_BITCODE \ - -DBUILD_TESTS=OFF \ - -DBUILD_STATIC_DEPS=ON \ - -DENABLE_VISIBILITY=ON \ - -DSUBMODULE_CHECK=$submodule_check \ - -DCMAKE_BUILD_TYPE=$build_type \ - -DLOCAL_MIRROR=https://oxen.rocks/deps + { + env -i PATH="$PATH" SDKROOT="$(xcrun --sdk macosx --show-sdk-path)" \ + ./utils/static-bundle.sh "$build" "" \ + -DCMAKE_TOOLCHAIN_FILE="${LIB_SESSION_SOURCE_DIR}/external/ios-cmake/ios.toolchain.cmake" \ + -DPLATFORM=$platform \ + -DDEPLOYMENT_TARGET=$IPHONEOS_DEPLOYMENT_TARGET \ + -DENABLE_BITCODE=$ENABLE_BITCODE \ + -DBUILD_TESTS=OFF \ + -DBUILD_STATIC_DEPS=ON \ + -DENABLE_VISIBILITY=ON \ + -DSUBMODULE_CHECK=$submodule_check \ + -DCMAKE_BUILD_TYPE=$build_type \ + -DLOCAL_MIRROR=https://oxen.rocks/deps + } 2>&1 | tee "$log_file" | grep --line-buffered -E '^\[.*%\]|:[0-9]+:[0-9]+: error:|^make.*\*\*\*|^error:|^CMake Error' # Capture the exit status of the ./utils/static-bundle.sh command - EXIT_STATUS=$? - - # Flush the tee buffer (ensure any errors have been properly written to the log before continuing) and - # restore stdout - echo "" - exec 1>&3 - - # Retrieve and log any submodule errors/warnings - ALL_CMAKE_ERROR_LINES=($(grep -nE "CMake Error" "$log_file" | cut -d ":" -f 1)) - ALL_SUBMODULE_ISSUE_LINES=($(grep -nE "\s*Submodule '([^']+)' is not up-to-date" "$log_file" | cut -d ":" -f 1)) - ALL_CMAKE_ERROR_LINES_STR=" ${ALL_CMAKE_ERROR_LINES[*]} " - ALL_SUBMODULE_ISSUE_LINES_STR=" ${ALL_SUBMODULE_ISSUE_LINES[*]} " - - for i in "${!ALL_SUBMODULE_ISSUE_LINES[@]}"; do - line="${ALL_SUBMODULE_ISSUE_LINES[$i]}" - prev_line=$((line - 1)) - value=$(sed "${line}q;d" "$log_file" | sed -E "s/.*Submodule '([^']+)'.*/Submodule '\1' is not up-to-date./") - - if [[ "$ALL_CMAKE_ERROR_LINES_STR" == *" $prev_line "* ]]; then - echo "error: $value" - else - echo "warning: $value" - fi - done - + EXIT_STATUS=${PIPESTATUS[0]} + if [ $EXIT_STATUS -ne 0 ]; then - ALL_ERROR_LINES=($(grep -n "error:" "$log_file" | cut -d ":" -f 1)) - - # Log any other errors - for e in "${!ALL_ERROR_LINES[@]}"; do - error_line="${ALL_ERROR_LINES[$e]}" - error=$(sed "${error_line}q;d" "$log_file") - - # If it was a CMake Error then the actual error will be on the next line so we want to append that info - if [[ $error == *'CMake Error'* ]]; then - actual_error_line=$((error_line + 1)) - error="${error}$(sed "${actual_error_line}q;d" "$log_file")" - fi - - # Exclude the 'ALL_ERROR_LINES' line and the 'grep' line - if [[ ! $error == *'grep -n "error'* ]] && [[ ! $error == *'grep -n error'* ]]; then - echo "error: $error" - fi + # Extract and display CMake/make errors from the log in Xcode error format + grep -E '^CMake Error' "$log_file" | sort -u | while IFS= read -r line; do + echo "error: $line" done - + # If the build failed we still want to copy files across because it'll help errors appear correctly echo "- Replacing build dir files" @@ -276,9 +271,14 @@ if [ "${REQUIRES_BUILD}" == 1 ]; then rm -rf "${INDEX_DIR}/include" # Rsync the compiled ones (maintaining timestamps) - rsync -rt "${COMPILE_DIR}/libsession-util.a" "${TARGET_BUILD_DIR}/libsession-util.a" - rsync -rt --exclude='.DS_Store' "${COMPILE_DIR}/Headers/" "${TARGET_BUILD_DIR}/include" - rsync -rt --exclude='.DS_Store' "${COMPILE_DIR}/Headers/" "${INDEX_DIR}/include" + if [ -f "${COMPILE_DIR}/libsession-util.a" ]; then + rsync -rt "${COMPILE_DIR}/libsession-util.a" "${TARGET_BUILD_DIR}/libsession-util.a" + fi + + if [ -d "${COMPILE_DIR}/Headers" ]; then + sync_headers "${COMPILE_DIR}/Headers/" + fi + exit 1 fi done @@ -309,24 +309,23 @@ if [ "${REQUIRES_BUILD}" == 1 ]; then echo "${TARGET_ARCH_DIR}" > "${LAST_BUILT_FRAMEWORK_SLICE_DIR_FILE}" echo "${CURRENT_SOURCE_TREE_HASH}" > "${LAST_SUCCESSFUL_HASH_FILE}" - echo "- Touching timestamp file to signal update to Xcode" - touch "${BUILT_LIB_FINAL_TIMESTAMP_FILE}" - cp "${BUILT_LIB_FINAL_TIMESTAMP_FILE}" "${SPM_TIMESTAMP_FILE}" - echo "- Build complete" fi echo "- Replacing build dir files" -# Remove the current files (might be "newer") -rm -rf "${TARGET_BUILD_DIR}/libsession-util.a" -rm -rf "${TARGET_BUILD_DIR}/include" -rm -rf "${INDEX_DIR}/include" - # Rsync the compiled ones (maintaining timestamps) +rm -rf "${TARGET_BUILD_DIR}/libsession-util.a" rsync -rt "${COMPILE_DIR}/libsession-util.a" "${TARGET_BUILD_DIR}/libsession-util.a" -rsync -rt --exclude='.DS_Store' "${COMPILE_DIR}/Headers/" "${TARGET_BUILD_DIR}/include" -rsync -rt --exclude='.DS_Store' "${COMPILE_DIR}/Headers/" "${INDEX_DIR}/include" + +if [ "${TARGET_BUILD_DIR}" != "${BUILT_PRODUCTS_DIR}" ]; then + echo "- TARGET_BUILD_DIR and BUILT_PRODUCTS_DIR are different. Copying library." + rm -f "${BUILT_PRODUCTS_DIR}/libsession-util.a" + rsync -rt "${COMPILE_DIR}/libsession-util.a" "${BUILT_PRODUCTS_DIR}/libsession-util.a" +fi + +sync_headers "${COMPILE_DIR}/Headers/" +echo "- Sync complete." # Output to XCode just so the output is good echo "LibSession is Ready" diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 23a7d0e2b9..bb6763e231 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -8366,7 +8366,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 650; + CURRENT_PROJECT_VERSION = 651; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8447,7 +8447,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 650; + CURRENT_PROJECT_VERSION = 651; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8933,7 +8933,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 650; + CURRENT_PROJECT_VERSION = 651; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -9523,7 +9523,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 650; + CURRENT_PROJECT_VERSION = 651; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; diff --git a/Session/Closed Groups/EditGroupViewModel.swift b/Session/Closed Groups/EditGroupViewModel.swift index f388d5bf59..8a4817f5dd 100644 --- a/Session/Closed Groups/EditGroupViewModel.swift +++ b/Session/Closed Groups/EditGroupViewModel.swift @@ -119,7 +119,7 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Observa .fetchOne(db) profileFront = try frontProfileId.map { try Profile.fetchOne(db, id: $0) } - profileBack = (backProfileId.map { try? Profile.fetchOne(db, id: $0) } ?? dependencies.mutate(cache: .libSession) { $0.profile }) + profileBack = try Profile.fetchOne(db, id: backProfileId ?? userSessionId.hexString) } return State( diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index d8aa14ebf0..b05e14d651 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -272,7 +272,6 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold threadWasMarkedUnread: initialData?.threadWasMarkedUnread, using: dependencies ) - .with(userProfile: dependencies.mutate(cache: .libSession) { $0.profile }) .populatingPostQueryData( recentReactionEmoji: nil, openGroupCapabilities: nil, @@ -351,13 +350,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold private func setupObservableThreadData(for threadId: String) -> ThreadObservation { return ObservationBuilderOld .databaseObservation(dependencies) { [weak self, dependencies] db -> SessionThreadViewModel? in - let userProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } let userSessionId: SessionId = dependencies[cache: .general].sessionId let recentReactionEmoji: [String] = try Emoji.getRecent(db, withDefaultEmoji: true) let threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel .conversationQuery(threadId: threadId, userSessionId: userSessionId) - .fetchOne(db)? - .with(userProfile: userProfile) + .fetchOne(db) let openGroupCapabilities: Set? = (threadViewModel?.threadVariant != .community ? nil : try Capability @@ -368,34 +365,32 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold .fetchSet(db) ) - return (threadViewModel? - .with(userProfile: userProfile)) - .map { viewModel -> SessionThreadViewModel in - let (wasKickedFromGroup, groupIsDestroyed): (Bool, Bool) = { - guard viewModel.threadVariant == .group else { return (false, false) } - - let sessionId: SessionId = SessionId(.group, hex: viewModel.threadId) - return dependencies.mutate(cache: .libSession) { cache in - ( - cache.wasKickedFromGroup(groupSessionId: sessionId), - cache.groupIsDestroyed(groupSessionId: sessionId) - ) - } - }() + return threadViewModel.map { viewModel -> SessionThreadViewModel in + let (wasKickedFromGroup, groupIsDestroyed): (Bool, Bool) = { + guard viewModel.threadVariant == .group else { return (false, false) } - return viewModel.populatingPostQueryData( - recentReactionEmoji: recentReactionEmoji, - openGroupCapabilities: openGroupCapabilities, - currentUserSessionIds: ( - self?.threadData.currentUserSessionIds ?? - [userSessionId.hexString] - ), - wasKickedFromGroup: wasKickedFromGroup, - groupIsDestroyed: groupIsDestroyed, - threadCanWrite: viewModel.determineInitialCanWriteFlag(using: dependencies), - threadCanUpload: viewModel.determineInitialCanUploadFlag(using: dependencies) - ) - } + let sessionId: SessionId = SessionId(.group, hex: viewModel.threadId) + return dependencies.mutate(cache: .libSession) { cache in + ( + cache.wasKickedFromGroup(groupSessionId: sessionId), + cache.groupIsDestroyed(groupSessionId: sessionId) + ) + } + }() + + return viewModel.populatingPostQueryData( + recentReactionEmoji: recentReactionEmoji, + openGroupCapabilities: openGroupCapabilities, + currentUserSessionIds: ( + self?.threadData.currentUserSessionIds ?? + [userSessionId.hexString] + ), + wasKickedFromGroup: wasKickedFromGroup, + groupIsDestroyed: groupIsDestroyed, + threadCanWrite: viewModel.determineInitialCanWriteFlag(using: dependencies), + threadCanUpload: viewModel.determineInitialCanUploadFlag(using: dependencies) + ) + } } .handleEvents(didFail: { Log.error(.conversation, "Observation failed with error: \($0)") }) } diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 4b91a902c2..239770af67 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -127,12 +127,10 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob lazy var observation: TargetObservation = ObservationBuilderOld .databaseObservation(self) { [dependencies, threadId = self.threadId] db -> State in - let userProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } let userSessionId: SessionId = dependencies[cache: .general].sessionId var threadViewModel: SessionThreadViewModel? = try SessionThreadViewModel .conversationSettingsQuery(threadId: threadId, userSessionId: userSessionId) - .fetchOne(db)? - .with(userProfile: userProfile) + .fetchOne(db) let disappearingMessagesConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration .fetchOne(db, id: threadId) .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) @@ -1528,15 +1526,13 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob /// Update the nickname dependencies[singleton: .storage].writeAsync( updates: { db in - try Profile - .filter(id: threadId) - .updateAllAndConfig( - db, - Profile.Columns.nickname.set(to: finalNickname), - using: dependencies - ) - db.addProfileEvent(id: threadId, change: .nickname(finalNickname)) - db.addConversationEvent(id: threadId, type: .updated(.displayName(finalNickname))) + try Profile.updateIfNeeded( + db, + publicKey: threadId, + nicknameUpdate: .set(to: finalNickname), + profileUpdateTimestamp: nil, + using: dependencies + ) }, completion: { _ in DispatchQueue.main.async { @@ -1549,15 +1545,13 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob /// Remove the nickname dependencies[singleton: .storage].writeAsync( updates: { db in - try Profile - .filter(id: threadId) - .updateAllAndConfig( - db, - Profile.Columns.nickname.set(to: nil), - using: dependencies - ) - db.addProfileEvent(id: threadId, change: .nickname(nil)) - db.addConversationEvent(id: threadId, type: .updated(.displayName(displayName))) + try Profile.updateIfNeeded( + db, + publicKey: threadId, + nicknameUpdate: .set(to: nil), + profileUpdateTimestamp: nil, + using: dependencies + ) }, completion: { _ in DispatchQueue.main.async { diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index 2378d82524..4de1f19992 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -65,12 +65,9 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI } private lazy var defaultSearchResultsObservation = ValueObservation .trackingConstantRegion { [dependencies] db -> [SessionThreadViewModel] in - let userProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } - - return try SessionThreadViewModel + try SessionThreadViewModel .defaultContactsQuery(using: dependencies) .fetchAll(db) - .map { $0.with(userProfile: userProfile) } } .map { GlobalSearchViewController.processDefaultSearchResults($0) } .removeDuplicates() @@ -306,24 +303,20 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI _currentSearchCancellable.set(to: dependencies[singleton: .storage] .readPublisher { [dependencies] db -> [SectionModel] in - let userProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } let userSessionId: SessionId = dependencies[cache: .general].sessionId let contactsAndGroupsResults: [SessionThreadViewModel] = try SessionThreadViewModel .contactsAndGroupsQuery( userSessionId: userSessionId, - currentUserName: userProfile.name, pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText), searchTerm: searchText ) .fetchAll(db) - .map { $0.with(userProfile: userProfile) } let messageResults: [SessionThreadViewModel] = try SessionThreadViewModel .messagesQuery( userSessionId: userSessionId, pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText) ) .fetchAll(db) - .map { $0.with(userProfile: userProfile) } return [ ArraySection(model: .contactsAndGroups, elements: contactsAndGroupsResults), diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 87b5d87fa1..951c9d0698 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -382,7 +382,6 @@ public class HomeViewModel: NavigatableStateHolder { ids: Array(idsNeedingRequery) + loadResult.newIds ) .fetchAll(db) - .map { $0.with(userProfile: userProfile) } ) } diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index abf784fded..ddece1ddc5 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -206,7 +206,6 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O } } - let userProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } try await dependencies[singleton: .storage].readAsync { db in /// Update loaded page info as needed if loadPageEvent != nil || !insertedIds.isEmpty || !deletedIds.isEmpty { @@ -229,7 +228,6 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O ids: Array(idsNeedingRequery) + loadResult.newIds ) .fetchAll(db) - .map { $0.with(userProfile: userProfile) } ) } diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index da1c130d59..1ad6a5d528 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -375,17 +375,8 @@ extension Onboarding { db.addContactEvent(id: userSessionId.hexString, change: .isApproved(true)) db.addContactEvent(id: userSessionId.hexString, change: .didApproveMe(true)) - /// Create the 'Note to Self' thread (not visible by default) - try SessionThread.upsert( - db, - id: userSessionId.hexString, - variant: .contact, - values: SessionThread.TargetValues(shouldBeVisible: .setTo(false)), - using: dependencies - ) - /// Load the initial `libSession` state (won't have been created on launch due to lack of ed25519 key) - dependencies.mutate(cache: .libSession) { cache in + let cachedProfile: Profile = dependencies.mutate(cache: .libSession) { cache in cache.loadState(db) /// If we have a `userProfileConfigMessage` then we should try to handle it here as if we don't then @@ -400,46 +391,31 @@ extension Onboarding { ) } - /// Update the `displayName` if changed and trigger a dump/push of the config - let cacheProfile: Profile = cache.profile - - if cacheProfile.name != displayName { - try? cache.performAndPushChange(db, for: .userProfile) { - try? cache.updateProfile(displayName: displayName) - } - } - - /// If the account has a display picture then we need to download it - if - let url: String = cacheProfile.displayPictureUrl, - let key: Data = cacheProfile.displayPictureEncryptionKey - { - dependencies[singleton: .jobRunner].add( - db, - job: Job( - variant: .displayPictureDownload, - shouldBeUnique: true, - details: DisplayPictureDownloadJob.Details( - target: .profile(id: cacheProfile.id, url: url, encryptionKey: key), - timestamp: cacheProfile.profileLastUpdated - ) - ), - canStartJob: dependencies[singleton: .appContext].isMainApp - ) - } + return cache.profile } - /// Clear the `lastNameUpdate` timestamp and forcibly set the `displayName` provided - /// during the onboarding step (we do this after handling the config message because we want - /// the value provided during onboarding to superseed any retrieved from the config) - try Profile.updateIfNeeded( - db, - publicKey: userSessionId.hexString, - displayNameUpdate: .currentUserUpdate(displayName), - displayPictureUpdate: .none, - profileUpdateTimestamp: dependencies.dateNow.timeIntervalSince1970, - using: dependencies - ) + /// If we don't have the `Note to Self` thread then create it (not visible by default) + if (try? SessionThread.exists(db, id: userSessionId.hexString)) != nil { + try SessionThread.upsert( + db, + id: userSessionId.hexString, + variant: .contact, + values: SessionThread.TargetValues(shouldBeVisible: .setTo(false)), + using: dependencies + ) + } + + /// Update the `displayName` if changed + if cachedProfile.name != displayName { + try Profile.updateIfNeeded( + db, + publicKey: userSessionId.hexString, + displayNameUpdate: .currentUserUpdate(displayName), + displayPictureUpdate: .none, + profileUpdateTimestamp: dependencies.dateNow.timeIntervalSince1970, + using: dependencies + ) + } /// Emit observation events (_shouldn't_ be needed since this is happening during onboarding but /// doesn't hurt just to be safe) diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel+Testing.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel+Testing.swift index 09381d297d..50858db264 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel+Testing.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel+Testing.swift @@ -65,6 +65,11 @@ extension DeveloperSettingsViewModel { /// /// **Value:** `1-256` (default: `100`, a value of `0` will use the default) case communityPollLimit + + /// Controls whether we should shorten the TTL of files to `60s` instead of the default on the File Server + /// + /// **Value:** `true`/`false` (default: `false`) + case shortenFileTTL } ProcessInfo.processInfo.environment.forEach { key, value in @@ -107,6 +112,9 @@ extension DeveloperSettingsViewModel { else { return } dependencies.set(feature: .communityPollLimit, to: intValue) + + case .shortenFileTTL: + dependencies.set(feature: .shortenFileTTL, to: (value == "true")) } } #endif diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index ee7a996fbb..35f4225f80 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -402,24 +402,17 @@ public extension ProfileAssociated { public extension FetchRequest where RowDecoder: FetchableRecord & ProfileAssociated { func fetchAllWithProfiles(_ db: ObservingDatabase, using dependencies: Dependencies) throws -> [WithProfile] { - let userSessionId: SessionId = dependencies[cache: .general].sessionId let originalResult: [RowDecoder] = try self.fetchAll(db) - var userProfile: Profile? - - if Set(originalResult.map { $0.profileId }).contains(userSessionId.hexString) { - userProfile = dependencies.mutate(cache: .libSession) { $0.profile } - } let profiles: [String: Profile]? = try? Profile .fetchAll(db, ids: originalResult.map { $0.profileId }.asSet()) .reduce(into: [:]) { result, next in result[next.id] = next } - .setting(userSessionId.hexString, userProfile) return originalResult.map { WithProfile( value: $0, profile: profiles?[$0.profileId], - currentUserSessionId: userSessionId + currentUserSessionId: dependencies[cache: .general].sessionId ) } } diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index 6bf1855915..9710c0114a 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -444,11 +444,14 @@ extension DisplayPictureDownloadJob { encryptionKey == latestProfile.displayPictureEncryptionKey && url == latestProfile.displayPictureUrl ) + let updateStatus: Profile.UpdateStatus = Profile.UpdateStatus( + updateTimestamp: timestamp, + cachedProfile: latestProfile + ) - guard - dataMatches || - Profile.shouldUpdateProfile(timestamp, profile: latestProfile, forDownloadingDisplayPicture: true, using: dependencies) - else { throw AttachmentError.downloadNoLongerValid } + guard dataMatches || updateStatus == .shouldUpdate || updateStatus == .matchesCurrent else { + throw AttachmentError.downloadNoLongerValid + } break diff --git a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift index aa995bf7a3..afdb9b7bfd 100644 --- a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift +++ b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift @@ -80,11 +80,13 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { } } - /// Try to extend the TTL of the existing profile pic first + /// Try to extend the TTL of the existing profile pic first (default to providing no TTL which will extend to the server + /// configuration) do { + let targetTTL: TimeInterval? = (dependencies[feature: .shortenFileTTL] ? 60 : nil) let request: Network.PreparedRequest = try Network.FileServer.preparedExtend( url: displayPictureUrl, - ttl: maxDisplayPictureTTL, + customTtl: targetTTL, using: dependencies ) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index 141a0c2710..ebb82f02d3 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -59,65 +59,33 @@ internal extension LibSessionCacheType { // Note: We only update the contact and profile records if the data has actually changed // in order to avoid triggering UI updates for every thread on the home screen (the DB // observation system can't differ between update calls which do and don't change anything) - let contact: Contact = Contact.fetchOrCreate(db, id: sessionId, using: dependencies) - let profile: Profile = Profile.fetchOrCreate(db, id: sessionId) - let profileUpdated: Bool = Profile.shouldUpdateProfile( - data.profile.profileLastUpdated, - profile: profile, - using: dependencies - ) - - if (profileUpdated || (profile.nickname != data.profile.nickname)) { - let profileNameShouldBeUpdated: Bool = ( - !data.profile.name.isEmpty && - profile.name != data.profile.name - ) - - try profile.upsert(db) - try Profile - .filter(id: sessionId) - .updateAllAndConfig( - db, - [ - (!profileNameShouldBeUpdated ? nil : - Profile.Columns.name.set(to: data.profile.name) - ), - (profile.nickname == data.profile.nickname ? nil : - Profile.Columns.nickname.set(to: data.profile.nickname) - ), - (profile.displayPictureUrl != data.profile.displayPictureUrl ? nil : - Profile.Columns.displayPictureUrl.set(to: data.profile.displayPictureUrl) - ), - (profile.displayPictureEncryptionKey != data.profile.displayPictureEncryptionKey ? nil : - Profile.Columns.displayPictureEncryptionKey.set(to: data.profile.displayPictureEncryptionKey) - ), - (!profileUpdated ? nil : - Profile.Columns.profileLastUpdated.set(to: data.profile.profileLastUpdated) - ) - ].compactMap { $0 }, - using: dependencies - ) - - if profileNameShouldBeUpdated { - db.addProfileEvent(id: sessionId, change: .name(data.profile.name)) + try Profile.updateIfNeeded( + db, + publicKey: sessionId, + displayNameUpdate: .contactUpdate(data.profile.name), + displayPictureUpdate: { + guard + let displayPictureUrl: String = data.profile.displayPictureUrl, + let displayPictureEncryptionKey: Data = data.profile.displayPictureEncryptionKey + else { return .currentUserRemove } - if data.profile.nickname == nil { - db.addConversationEvent(id: sessionId, type: .updated(.displayName(data.profile.name))) - } - } - - if profile.nickname != data.profile.nickname { - db.addProfileEvent(id: sessionId, change: .nickname(data.profile.nickname)) - db.addConversationEvent( - id: sessionId, - type: .updated(.displayName(data.profile.nickname ?? data.profile.name)) + return .contactUpdateTo( + url: displayPictureUrl, + key: displayPictureEncryptionKey, + contactProProof: getProProof() // TODO: double check if this is needed after Pro Proof is implemented ) - } - } + }(), + nicknameUpdate: .set(to: data.profile.nickname), + profileUpdateTimestamp: data.profile.profileLastUpdated, + cacheSource: .database, + using: dependencies + ) /// Since message requests have no reverse, we should only handle setting `isApproved` /// and `didApproveMe` to `true`. This may prevent some weird edge cases where a config message /// swapping `isApproved` and `didApproveMe` to `false` + let contact: Contact = Contact.fetchOrCreate(db, id: sessionId, using: dependencies) + if (contact.isApproved != data.contact.isApproved) || (contact.isBlocked != data.contact.isBlocked) || diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index ffcbbc62e7..94a9b3d35b 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -41,22 +41,6 @@ internal extension LibSessionCacheType { let displayPictureUrl: String? = displayPic.get(\.url, nullIfEmpty: true) let displayPictureEncryptionKey: Data? = displayPic.get(\.key, nullIfEmpty: true) let profileLastUpdateTimestamp: TimeInterval = TimeInterval(user_profile_get_profile_updated(conf)) - let updatedProfile: Profile = Profile( - id: userSessionId.hexString, - name: profileName, - displayPictureUrl: (oldState[.profile(userSessionId.hexString)] as? Profile)?.displayPictureUrl, - profileLastUpdated: profileLastUpdateTimestamp - ) - - if let profile: Profile = oldState[.profile(userSessionId.hexString)] as? Profile { - if profile.name != updatedProfile.name { - db.addProfileEvent(id: updatedProfile.id, change: .name(updatedProfile.name)) - } - - if profile.displayPictureUrl != updatedProfile.displayPictureUrl { - db.addProfileEvent(id: updatedProfile.id, change: .displayPictureUrl(updatedProfile.displayPictureUrl)) - } - } // Handle user profile changes try Profile.updateIfNeeded( @@ -77,6 +61,7 @@ internal extension LibSessionCacheType { ) }(), profileUpdateTimestamp: profileLastUpdateTimestamp, + cacheSource: .value((oldState[.profile(userSessionId.hexString)] as? Profile), fallback: .database), suppressUserProfileConfigUpdate: true, using: dependencies ) @@ -241,41 +226,32 @@ public extension LibSession.Cache { throw LibSessionError.invalidConfigObject(wanted: .userProfile, got: config) } - // Get the old values to determine if something changed + /// Get the old values to determine if something changed let oldName: String? = user_profile_get_name(conf).map { String(cString: $0) } let oldNameFallback: String = (oldName ?? "") let oldDisplayPic: user_profile_pic = user_profile_get_pic(conf) let oldDisplayPictureUrl: String? = oldDisplayPic.get(\.url, nullIfEmpty: true) let oldDisplayPictureKey: Data? = oldDisplayPic.get(\.key, nullIfEmpty: true) - // Update the name - var cUpdatedName: [CChar] = try displayName.or(oldNameFallback).cString(using: .utf8) ?? { - throw LibSessionError.invalidCConversion - }() - user_profile_set_name(conf, &cUpdatedName) - try LibSessionError.throwIfNeeded(conf) - - // Either assign the updated profile pic, or sent a blank profile pic (to remove the current one) - var profilePic: user_profile_pic = user_profile_pic() - profilePic.set(\.url, to: displayPictureUrl.or(oldDisplayPictureUrl)) - profilePic.set(\.key, to: displayPictureEncryptionKey.or(oldDisplayPictureKey)) - - switch isReuploadProfilePicture { - case true: user_profile_set_reupload_pic(conf, profilePic) - case false: user_profile_set_pic(conf, profilePic) - } - - try LibSessionError.throwIfNeeded(conf) - - /// Add a pending observation to notify any observers of the change once it's committed - if displayName.or("") != oldName { - addEvent( - key: .profile(userSessionId.hexString), - value: ProfileEvent(id: userSessionId.hexString, change: .name(displayName.or(oldNameFallback))) - ) - } - + /// Either assign the updated profile pic, or sent a blank profile pic (to remove the current one) + /// + /// **Note:** We **MUST** update the profile picture first because doing so will result in any subsequent profile changes + /// which impact the `profile_updated` timestamp being routed to the "reupload" storage instead of the "standard" + /// storage - if we don't do this first then the "standard" timestamp will also get updated which can result in both timestamps + /// matching (in which case the "standard" profile wins and the re-uploaded content would be ignored) if displayPictureUrl.or(oldDisplayPictureUrl) != oldDisplayPictureUrl { + var profilePic: user_profile_pic = user_profile_pic() + profilePic.set(\.url, to: displayPictureUrl.or(oldDisplayPictureUrl)) + profilePic.set(\.key, to: displayPictureEncryptionKey.or(oldDisplayPictureKey)) + + switch isReuploadProfilePicture { + case true: user_profile_set_reupload_pic(conf, profilePic) + case false: user_profile_set_pic(conf, profilePic) + } + + try LibSessionError.throwIfNeeded(conf) + + /// Add a pending observation to notify any observers of the change once it's committed addEvent( key: .profile(userSessionId.hexString), value: ProfileEvent( @@ -284,6 +260,24 @@ public extension LibSession.Cache { ) ) } + + /// Update the nam + /// + /// **Note:** Setting the name (even if it hasn't changed) currently results in a timestamp change so only do this if it was + /// changed (this will be fixed in `libSession v1.5.8`) + if displayName.or("") != oldName { + var cUpdatedName: [CChar] = try displayName.or(oldNameFallback).cString(using: .utf8) ?? { + throw LibSessionError.invalidCConversion + }() + user_profile_set_name(conf, &cUpdatedName) + try LibSessionError.throwIfNeeded(conf) + + /// Add a pending observation to notify any observers of the change once it's committed + addEvent( + key: .profile(userSessionId.hexString), + value: ProfileEvent(id: userSessionId.hexString, change: .name(displayName.or(oldNameFallback))) + ) + } } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift index eb4a2cc6ac..7e7fda9a59 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift @@ -145,7 +145,7 @@ extension MessageReceiver { publicKey: sender, displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), - blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, + blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) @@ -249,7 +249,7 @@ extension MessageReceiver { publicKey: sender, displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), - blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, + blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) @@ -610,7 +610,7 @@ extension MessageReceiver { publicKey: sender, displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), - blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, + blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index 430b38e325..ea756faa8d 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -43,7 +43,7 @@ extension MessageReceiver { publicKey: sender, displayNameUpdate: .contactUpdate(profile.displayName), displayPictureUpdate: .from(profile, fallback: .contactRemove, using: dependencies), - blocksCommunityMessageRequests: profile.blocksCommunityMessageRequests, + blocksCommunityMessageRequests: .set(to: profile.blocksCommunityMessageRequests), profileUpdateTimestamp: profile.updateTimestampSeconds, using: dependencies ) diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index a2fcd4ab09..90e986ca60 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -613,79 +613,6 @@ public extension SessionThreadViewModel { // MARK: - Mutation public extension SessionThreadViewModel { - @available(*, deprecated, message: "The 'SessionThreadViewModel' should be refactored so it doesn't directly fetch profile data which would remove the need for this behaviour") - func with(userProfile: Profile) -> SessionThreadViewModel { - func replaceIfCurrentUser(_ profile: Profile?) -> Profile? { - return (profile?.id == userProfile.id ? userProfile : profile) - } - - return SessionThreadViewModel( - rowId: self.rowId, - threadId: self.threadId, - threadVariant: self.threadVariant, - threadCreationDateTimestamp: self.threadCreationDateTimestamp, - threadMemberNames: self.threadMemberNames, - threadIsNoteToSelf: self.threadIsNoteToSelf, - outdatedMemberId: self.outdatedMemberId, - threadIsMessageRequest: self.threadIsMessageRequest, - threadRequiresApproval: self.threadRequiresApproval, - threadShouldBeVisible: self.threadShouldBeVisible, - threadPinnedPriority: self.threadPinnedPriority, - threadIsBlocked: self.threadIsBlocked, - threadMutedUntilTimestamp: self.threadMutedUntilTimestamp, - threadOnlyNotifyForMentions: self.threadOnlyNotifyForMentions, - threadMessageDraft: self.threadMessageDraft, - threadIsDraft: self.threadIsDraft, - threadContactIsTyping: self.threadContactIsTyping, - threadWasMarkedUnread: self.threadWasMarkedUnread, - threadUnreadCount: self.threadUnreadCount, - threadUnreadMentionCount: self.threadUnreadMentionCount, - threadHasUnreadMessagesOfAnyKind: self.threadHasUnreadMessagesOfAnyKind, - threadCanWrite: self.threadCanWrite, - threadCanUpload: self.threadCanUpload, - disappearingMessagesConfiguration: self.disappearingMessagesConfiguration, - contactLastKnownClientVersion: self.contactLastKnownClientVersion, - threadDisplayPictureUrl: (threadId == userProfile.id ? userProfile.displayPictureUrl : self.threadDisplayPictureUrl), - contactProfile: (self.contactProfile?.id == userProfile.id || threadId == userProfile.id ? userProfile : self.contactProfile), - closedGroupProfileFront: replaceIfCurrentUser(self.closedGroupProfileFront), - closedGroupProfileBack: replaceIfCurrentUser(self.closedGroupProfileBack), - closedGroupProfileBackFallback: replaceIfCurrentUser(self.closedGroupProfileBackFallback), - closedGroupAdminProfile: replaceIfCurrentUser(self.closedGroupAdminProfile), - closedGroupName: self.closedGroupName, - closedGroupDescription: self.closedGroupDescription, - closedGroupUserCount: self.closedGroupUserCount, - closedGroupExpired: self.closedGroupExpired, - currentUserIsClosedGroupMember: self.currentUserIsClosedGroupMember, - currentUserIsClosedGroupAdmin: self.currentUserIsClosedGroupAdmin, - openGroupName: self.openGroupName, - openGroupDescription: self.openGroupDescription, - openGroupServer: self.openGroupServer, - openGroupRoomToken: self.openGroupRoomToken, - openGroupPublicKey: self.openGroupPublicKey, - openGroupUserCount: self.openGroupUserCount, - openGroupPermissions: self.openGroupPermissions, - openGroupCapabilities: self.openGroupCapabilities, - interactionId: self.interactionId, - interactionVariant: self.interactionVariant, - interactionTimestampMs: self.interactionTimestampMs, - interactionBody: self.interactionBody, - interactionState: self.interactionState, - interactionHasBeenReadByRecipient: self.interactionHasBeenReadByRecipient, - interactionIsOpenGroupInvitation: self.interactionIsOpenGroupInvitation, - interactionAttachmentDescriptionInfo: self.interactionAttachmentDescriptionInfo, - interactionAttachmentCount: self.interactionAttachmentCount, - authorId: self.authorId, - threadContactNameInternal: self.threadContactNameInternal, - authorNameInternal: self.authorNameInternal, - currentUserSessionId: self.currentUserSessionId, - currentUserSessionIds: self.currentUserSessionIds, - recentReactionEmoji: self.recentReactionEmoji, - wasKickedFromGroup: self.wasKickedFromGroup, - groupIsDestroyed: self.groupIsDestroyed, - isContactApproved: self.isContactApproved - ) - } - func populatingPostQueryData( recentReactionEmoji: [String]?, openGroupCapabilities: Set?, @@ -1695,12 +1622,7 @@ public extension SessionThreadViewModel { /// /// **Note 2:** Since the "Hidden Contact" records don't have associated threads the `rowId` value in the /// returned results will always be `-1` for those results - static func contactsAndGroupsQuery( - userSessionId: SessionId, - currentUserName: String, - pattern: FTS5Pattern, - searchTerm: String - ) -> AdaptedFetchRequest> { + static func contactsAndGroupsQuery(userSessionId: SessionId, pattern: FTS5Pattern, searchTerm: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() let contactProfile: TypedTableAlias = TypedTableAlias(ViewModel.self, column: .contactProfile) let closedGroup: TypedTableAlias = TypedTableAlias() @@ -1814,20 +1736,9 @@ public extension SessionThreadViewModel { LEFT JOIN ( SELECT \(groupMember[.groupId]), - GROUP_CONCAT( - CASE - WHEN \(groupMember[.profileId]) = \(userSessionId.hexString) - THEN \(currentUserName) - ELSE IFNULL(\(profile[.nickname]), \(profile[.name])) - END, - ', ' - ) AS \(GroupMemberInfo.Columns.threadMemberNames) + GROUP_CONCAT(IFNULL(\(profile[.nickname]), \(profile[.name])), ', ') AS \(GroupMemberInfo.Columns.threadMemberNames) FROM \(GroupMember.self) - LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) - WHERE ( - \(groupMember[.profileId]) = \(userSessionId.hexString) OR - \(profile[.id]) IS NOT NULL - ) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) GROUP BY \(groupMember[.groupId]) ) AS \(groupMemberInfo) ON \(groupMemberInfo[.groupId]) = \(closedGroup[.threadId]) LEFT JOIN \(closedGroupProfileFront) ON ( diff --git a/SessionMessagingKit/Utilities/Profile+Updating.swift b/SessionMessagingKit/Utilities/Profile+Updating.swift index 3e3612804b..a762fe8b86 100644 --- a/SessionMessagingKit/Utilities/Profile+Updating.swift +++ b/SessionMessagingKit/Utilities/Profile+Updating.swift @@ -20,6 +20,63 @@ public extension Profile { case currentUserUpdate(String?) } + indirect enum CacheSource { + case value(Profile?, fallback: CacheSource) + case libSession(fallback: CacheSource) + case database + + func resolve(_ db: ObservingDatabase, publicKey: String, using dependencies: Dependencies) -> Profile { + switch self { + case .value(.some(let profile), _): return profile + case .value(.none, let fallback): + return fallback.resolve(db, publicKey: publicKey, using: dependencies) + + case .libSession(let fallback): + if let profile: Profile = dependencies.mutate(cache: .libSession, { $0.profile(contactId: publicKey) }) { + return profile + } + + return fallback.resolve(db, publicKey: publicKey, using: dependencies) + + case .database: return Profile.fetchOrCreate(db, id: publicKey) + } + } + } + + enum UpdateStatus { + case shouldUpdate + case matchesCurrent + case stale + + /// To try to maintain backwards compatibility with profile changes we want to continue to accept profile changes from old clients if + /// we haven't received a profile update from a new client yet otherwise, if we have, then we should only accept profile changes if + /// they are newer that our cached version of the profile data + init(updateTimestamp: TimeInterval?, cachedProfile: Profile) { + let finalProfileUpdateTimestamp: TimeInterval = (updateTimestamp ?? 0) + let finalCachedProfileUpdateTimestamp: TimeInterval = (cachedProfile.profileLastUpdated ?? 0) + + /// If neither the profile update or the cached profile have a timestamp then we should just always accept the update + /// + /// **Note:** We check if they are equal to `0` here because the default value from `libSession` will be `0` + /// rather than `null` + guard finalProfileUpdateTimestamp != 0 || finalCachedProfileUpdateTimestamp != 0 else { + self = .shouldUpdate + return + } + + /// Otherwise we compare the values to determine the current state + switch finalProfileUpdateTimestamp { + case finalCachedProfileUpdateTimestamp...: + self = (finalProfileUpdateTimestamp == finalCachedProfileUpdateTimestamp ? + .matchesCurrent : + .shouldUpdate + ) + + default: self = .stale + } + } + } + static func isTooLong(profileName: String) -> Bool { /// String.utf8CString will include the null terminator (Int8)0 as the end of string buffer. /// When the string is exactly 100 bytes String.utf8CString.count will be 101. @@ -82,192 +139,189 @@ public extension Profile { catch { throw AttachmentError.databaseChangesFailed } } - /// To try to maintain backwards compatibility with profile changes we want to continue to accept profile changes from old clients if - /// we haven't received a profile update from a new client yet otherwise, if we have, then we should only accept profile changes if - /// they are newer that our cached version of the profile data - static func shouldUpdateProfile( - _ profileUpdateTimestamp: TimeInterval?, - profile: Profile, - forDownloadingDisplayPicture: Bool = false, - using dependencies: Dependencies - ) -> Bool { - /// We should consider `libSession` the source-of-truth for profile data for contacts so try to retrieve the profile data from - /// there before falling back to the one fetched from the database - let targetProfile: Profile = ( - dependencies.mutate(cache: .libSession) { $0.profile(contactId: profile.id) } ?? - profile - ) - let finalProfileUpdateTimestamp: TimeInterval = (profileUpdateTimestamp ?? 0) - let finalCachedProfileUpdateTimestamp: TimeInterval = (targetProfile.profileLastUpdated ?? 0) - - /// If neither the profile update or the cached profile have a timestamp then we should just always accept the update - /// - /// **Note:** We check if they are equal to `0` here because the default value from `libSession` will be `0` - /// rather than `null` - guard finalProfileUpdateTimestamp != 0 || finalCachedProfileUpdateTimestamp != 0 else { - return true - } - - /// Check if we are validating an update for the purpose of downloading a display picture - if forDownloadingDisplayPicture { - /// If so then the the timestamp can either be newer or match the cached value - return (finalProfileUpdateTimestamp >= finalCachedProfileUpdateTimestamp) - } - - /// Otherwise we should only accept the update if it's newer than our cached value - return (finalProfileUpdateTimestamp > finalCachedProfileUpdateTimestamp) - } - static func updateIfNeeded( _ db: ObservingDatabase, publicKey: String, displayNameUpdate: DisplayNameUpdate = .none, - displayPictureUpdate: DisplayPictureManager.Update, - blocksCommunityMessageRequests: Bool? = nil, + displayPictureUpdate: DisplayPictureManager.Update = .none, + nicknameUpdate: Update = .useExisting, + blocksCommunityMessageRequests: Update = .useExisting, profileUpdateTimestamp: TimeInterval?, + cacheSource: CacheSource = .libSession(fallback: .database), suppressUserProfileConfigUpdate: Bool = false, using dependencies: Dependencies ) throws { let userSessionId: SessionId = dependencies[cache: .general].sessionId let isCurrentUser = (publicKey == userSessionId.hexString) - let profile: Profile = ( - dependencies.mutate(cache: .libSession) { $0.profile(contactId: publicKey) } ?? - Profile.fetchOrCreate(db, id: publicKey) + let profile: Profile = cacheSource.resolve(db, publicKey: publicKey, using: dependencies) + let updateStatus: UpdateStatus = UpdateStatus( + updateTimestamp: profileUpdateTimestamp, + cachedProfile: profile ) var updatedProfile: Profile = profile var profileChanges: [ConfigColumnAssignment] = [] - guard shouldUpdateProfile(profileUpdateTimestamp, profile: profile, using: dependencies) else { - /// If we can update for the purpose of downloading a display picture then it's possible this is a missing display picture so - /// schedule a new download job - if shouldUpdateProfile(profileUpdateTimestamp, profile: profile, forDownloadingDisplayPicture: true, using: dependencies) { - var targetUrl: String? = profile.displayPictureUrl - var targetKey: Data? = profile.displayPictureEncryptionKey - - switch displayPictureUpdate { - case .contactUpdateTo(let url, let key, _), .currentUserUpdateTo(let url, let key, _, _): - targetUrl = url - targetKey = key - - default: break - } - - if let url: String = targetUrl, let key: Data = targetKey { + /// We should only update profile info controled by other users if `updateStatus` is `shouldUpdate` + if updateStatus == .shouldUpdate { + /// Name + switch (displayNameUpdate, isCurrentUser) { + case (.none, _): break + case (.currentUserUpdate(let name), true), (.contactUpdate(let name), false): + guard let name: String = name, !name.isEmpty, name != profile.name else { break } + + if profile.name != name { + updatedProfile = updatedProfile.with(name: name) + profileChanges.append(Profile.Columns.name.set(to: name)) + db.addProfileEvent(id: publicKey, change: .name(name)) + } + + /// Don't want profiles in messages to modify the current users profile info so ignore those cases + default: break + } + + /// Blocks community message requests flag + switch blocksCommunityMessageRequests { + case .useExisting: break + case .set(let value): + guard value != profile.blocksCommunityMessageRequests else { break } + + updatedProfile = updatedProfile.with(blocksCommunityMessageRequests: .set(to: value)) + profileChanges.append(Profile.Columns.blocksCommunityMessageRequests.set(to: value)) + } + + /// Profile picture & profile key + switch (displayPictureUpdate, isCurrentUser) { + case (.none, _): break + case (.groupRemove, _), (.groupUpdateTo, _): throw AttachmentError.invalidStartState + case (.contactRemove, false), (.currentUserRemove, true): + if profile.displayPictureEncryptionKey != nil { + updatedProfile = updatedProfile.with(displayPictureEncryptionKey: .set(to: nil)) + profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: nil)) + } + + if profile.displayPictureUrl != nil { + updatedProfile = updatedProfile.with(displayPictureUrl: .set(to: nil)) + profileChanges.append(Profile.Columns.displayPictureUrl.set(to: nil)) + db.addProfileEvent(id: publicKey, change: .displayPictureUrl(nil)) + } + + case (.contactUpdateTo(let url, let key, let proProof), false), + (.currentUserUpdateTo(let url, let key, let proProof, _), true): + /// If we have already downloaded the image then we can just directly update the stored profile data (it normally + /// wouldn't be updated until after the download completes) let fileExists: Bool = ((try? dependencies[singleton: .displayPictureManager] .path(for: url)) .map { dependencies[singleton: .fileManager].fileExists(atPath: $0) } ?? false) - if !fileExists { - dependencies[singleton: .jobRunner].add( - db, - job: Job( - variant: .displayPictureDownload, - shouldBeUnique: true, - details: DisplayPictureDownloadJob.Details( - target: .profile(id: profile.id, url: url, encryptionKey: key), - timestamp: profileUpdateTimestamp - ) - ), - canStartJob: dependencies[singleton: .appContext].isMainApp - ) + if fileExists { + if url != profile.displayPictureUrl { + /// Remove the old display picture (since we are replacing it) + if + let existingProfileUrl: String = updatedProfile.displayPictureUrl, + let existingFilePath: String = try? dependencies[singleton: .displayPictureManager] + .path(for: existingProfileUrl) + { + Task.detached(priority: .low) { + await dependencies[singleton: .imageDataManager].removeImage( + identifier: existingFilePath + ) + try? dependencies[singleton: .fileManager].removeItem(atPath: existingFilePath) + } + } + + updatedProfile = updatedProfile.with(displayPictureUrl: .set(to: url)) + profileChanges.append(Profile.Columns.displayPictureUrl.set(to: url)) + db.addProfileEvent(id: publicKey, change: .displayPictureUrl(url)) + } + + if key != profile.displayPictureEncryptionKey && key.count == DisplayPictureManager.encryptionKeySize { + updatedProfile = updatedProfile.with(displayPictureEncryptionKey: .set(to: key)) + profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: key)) + } } - } + + // TODO: Handle Pro Proof update + + /// Don't want profiles in messages to modify the current users profile info so ignore those cases + default: break } - - return } - // Name - switch (displayNameUpdate, isCurrentUser) { - case (.none, _): break - case (.currentUserUpdate(let name), true), (.contactUpdate(let name), false): - guard let name: String = name, !name.isEmpty, name != profile.name else { break } + /// Nickname - this is controlled by the current user so should always be used + switch (nicknameUpdate, isCurrentUser) { + case (.useExisting, _): break + case (.set(let nickname), false): + let finalNickname: String? = (nickname?.isEmpty == false ? nickname : nil) - if profile.name != name { - updatedProfile = updatedProfile.with(name: name) - profileChanges.append(Profile.Columns.name.set(to: name)) - db.addProfileEvent(id: publicKey, change: .name(name)) + if profile.nickname != finalNickname { + updatedProfile = updatedProfile.with(nickname: .set(to: finalNickname)) + profileChanges.append(Profile.Columns.nickname.set(to: finalNickname)) + db.addProfileEvent(id: publicKey, change: .nickname(finalNickname)) } - - // Don't want profiles in messages to modify the current users profile info so ignore those cases + default: break } - // Blocks community message requests flag - if let blocksCommunityMessageRequests: Bool = blocksCommunityMessageRequests { - updatedProfile = updatedProfile.with(blocksCommunityMessageRequests: .set(to: blocksCommunityMessageRequests)) - profileChanges.append(Profile.Columns.blocksCommunityMessageRequests.set(to: blocksCommunityMessageRequests)) + /// Add a conversation event if the display name for a conversation changed + let effectiveDisplayName: String? = { + if isCurrentUser { + guard case .currentUserUpdate(let name) = displayNameUpdate else { return nil } + + return name + } + + if case .set(let nickname) = nicknameUpdate, let nickname, !nickname.isEmpty { + return nickname + } + + if case .contactUpdate(let name) = displayNameUpdate, let name, !name.isEmpty { + return name + } + + return nil + }() + + if + let newDisplayName: String = effectiveDisplayName, + newDisplayName != (isCurrentUser ? profile.name : (profile.nickname ?? profile.name)) + { + db.addConversationEvent(id: publicKey, type: .updated(.displayName(newDisplayName))) } - // Profile picture & profile key - switch (displayPictureUpdate, isCurrentUser) { - case (.none, _): break - case (.groupRemove, _), (.groupUpdateTo, _): throw AttachmentError.invalidStartState - case (.contactRemove, false), (.currentUserRemove, true): - if profile.displayPictureEncryptionKey != nil { - updatedProfile = updatedProfile.with(displayPictureEncryptionKey: .set(to: nil)) - profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: nil)) - } - - if profile.displayPictureUrl != nil { - updatedProfile = updatedProfile.with(displayPictureUrl: .set(to: nil)) - profileChanges.append(Profile.Columns.displayPictureUrl.set(to: nil)) - db.addProfileEvent(id: publicKey, change: .displayPictureUrl(nil)) - } + /// If the profile was either updated or matches the current (latest) state then we should check if we have the display picture on + /// disk and, if not, we should schedule a download (a display picture may not be present after linking devices, restoration, etc.) + if updateStatus == .shouldUpdate || updateStatus == .matchesCurrent { + var targetUrl: String? = profile.displayPictureUrl + var targetKey: Data? = profile.displayPictureEncryptionKey - case (.contactUpdateTo(let url, let key, let proProof), false), - (.currentUserUpdateTo(let url, let key, let proProof, _), true): - /// If we have already downloaded the image then no need to download it again (the database records will be updated - /// once the download completes) - let fileExists: Bool = ((try? dependencies[singleton: .displayPictureManager] - .path(for: url)) - .map { dependencies[singleton: .fileManager].fileExists(atPath: $0) } ?? false) - - if !fileExists { - dependencies[singleton: .jobRunner].add( - db, - job: Job( - variant: .displayPictureDownload, - shouldBeUnique: true, - details: DisplayPictureDownloadJob.Details( - target: .profile(id: profile.id, url: url, encryptionKey: key), - timestamp: profileUpdateTimestamp - ) - ), - canStartJob: dependencies[singleton: .appContext].isMainApp - ) - } - else { - if url != profile.displayPictureUrl { - /// Remove the old display picture (since we are replacing it) - if - let existingProfileUrl: String = updatedProfile.displayPictureUrl, - let existingFilePath: String = try? dependencies[singleton: .displayPictureManager] - .path(for: existingProfileUrl) - { - Task.detached(priority: .low) { - await dependencies[singleton: .imageDataManager].removeImage( - identifier: existingFilePath - ) - try? dependencies[singleton: .fileManager].removeItem(atPath: existingFilePath) - } - } - - updatedProfile = updatedProfile.with(displayPictureUrl: .set(to: url)) - profileChanges.append(Profile.Columns.displayPictureUrl.set(to: url)) - db.addProfileEvent(id: publicKey, change: .displayPictureUrl(url)) - } + switch displayPictureUpdate { + case .contactUpdateTo(let url, let key, _), .currentUserUpdateTo(let url, let key, _, _): + targetUrl = url + targetKey = key - if key != profile.displayPictureEncryptionKey && key.count == DisplayPictureManager.encryptionKeySize { - updatedProfile = updatedProfile.with(displayPictureEncryptionKey: .set(to: key)) - profileChanges.append(Profile.Columns.displayPictureEncryptionKey.set(to: key)) - } - } - - // TODO: Handle Pro Proof update + default: break + } - /// Don't want profiles in messages to modify the current users profile info so ignore those cases - default: break + if + let url: String = targetUrl, + let key: Data = targetKey, + !key.isEmpty, + let path: String = try? dependencies[singleton: .displayPictureManager].path(for: url), + !dependencies[singleton: .fileManager].fileExists(atPath: path) + { + dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details( + target: .profile(id: profile.id, url: url, encryptionKey: key), + timestamp: profileUpdateTimestamp + ) + ), + canStartJob: dependencies[singleton: .appContext].isMainApp + ) + } } /// Persist any changes @@ -277,31 +331,27 @@ public extension Profile { .compactMap { switch ($0.value as? ProfileEvent)?.change { case .none: return nil - case .name: return "name updated" + case .name: return "name updated" // stringlint:ignore case .displayPictureUrl(let url): - return (url != nil ? "displayPictureUrl updated" : "displayPictureUrl removed") + return (url != nil ? "displayPictureUrl updated" : "displayPictureUrl removed") // stringlint:ignore case .nickname(let nickname): - return (nickname != nil ? "nickname updated" : "nickname removed") + return (nickname != nil ? "nickname updated" : "nickname removed") // stringlint:ignore } } .joined(separator: ", ") updatedProfile = updatedProfile.with(profileLastUpdated: .set(to: profileUpdateTimestamp)) profileChanges.append(Profile.Columns.profileLastUpdated.set(to: profileUpdateTimestamp)) - /// The current users profile is sourced from `libSession` everywhere so no need to update the database - if !isCurrentUser { - try updatedProfile.upsert(db) - - try Profile - .filter(id: publicKey) - .updateAllAndConfig( - db, - profileChanges, - using: dependencies - ) - Log.debug(.profile, "Successfully updated profile for \(publicKey) (\(changeString)).") - } + try updatedProfile.upsert(db) + + try Profile + .filter(id: publicKey) + .updateAllAndConfig( + db, + profileChanges, + using: dependencies + ) /// We don't automatically update the current users profile data when changed in the database so need to manually /// trigger the update @@ -321,8 +371,9 @@ public extension Profile { ) } } - Log.info(.profile, "Successfully updated user profile (\(changeString)).") } + + Log.custom(isCurrentUser ? .info : .debug, [.profile], "Successfully updated \(isCurrentUser ? "user profile" : "profile for \(publicKey)")) (\(changeString)).") } } } diff --git a/SessionNetworkingKit/FileServer/FileServerAPI.swift b/SessionNetworkingKit/FileServer/FileServerAPI.swift index a909f024ce..58915d03a5 100644 --- a/SessionNetworkingKit/FileServer/FileServerAPI.swift +++ b/SessionNetworkingKit/FileServer/FileServerAPI.swift @@ -61,18 +61,24 @@ public extension Network.FileServer { static func preparedExtend( url: URL, - ttl: TimeInterval, + customTtl: TimeInterval?, using dependencies: Dependencies ) throws -> Network.PreparedRequest { let strippedUrl: URL = try url.strippingQueryAndFragment ?? { throw NetworkError.invalidURL }() + var headers: [HTTPHeader: String] = [:] + + if let ttl: TimeInterval = customTtl { + headers = [.fileCustomTTL: "\(Int(floor(ttl)))"] + } + return try Network.PreparedRequest( request: Request( endpoint: .extendUrl(strippedUrl), destination: .server( method: .post, url: strippedUrl, - headers: [.fileCustomTTL: "\(Int(floor(ttl)))"], + headers: headers, x25519PublicKey: FileServer.x25519PublicKey(for: url, using: dependencies) ) ), diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index 0b7adec2e8..206ca22f7b 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -56,13 +56,11 @@ public class ThreadPickerViewModel { /// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this public lazy var observableViewData = ValueObservation .trackingConstantRegion { [dependencies] db -> [SessionThreadViewModel] in - let userProfile: Profile = dependencies.mutate(cache: .libSession) { $0.profile } let userSessionId: SessionId = dependencies[cache: .general].sessionId return try SessionThreadViewModel .shareQuery(userSessionId: userSessionId) .fetchAll(db) - .map { $0.with(userProfile: userProfile) } .map { threadViewModel in let (wasKickedFromGroup, groupIsDestroyed): (Bool, Bool) = { guard threadViewModel.threadVariant == .group else { return (false, false) } diff --git a/SessionTests/Onboarding/OnboardingSpec.swift b/SessionTests/Onboarding/OnboardingSpec.swift index 0b1416a1b0..86e2af1abc 100644 --- a/SessionTests/Onboarding/OnboardingSpec.swift +++ b/SessionTests/Onboarding/OnboardingSpec.swift @@ -57,6 +57,9 @@ class OnboardingSpec: AsyncSpec { crypto .when { $0.generate(.signature(message: .any, ed25519SecretKey: .any)) } .thenReturn(Authentication.Signature.standard(signature: "TestSignature".bytes)) + crypto + .when { $0.generate(.hash(message: .any)) } + .thenReturn([1, 2, 3]) } ) @TestState(cache: .libSession, in: dependencies) var mockLibSession: MockLibSessionCache! = MockLibSessionCache( @@ -173,6 +176,19 @@ class OnboardingSpec: AsyncSpec { @TestState(cache: .snodeAPI, in: dependencies) var mockSnodeAPICache: MockSnodeAPICache! = MockSnodeAPICache( initialSetup: { $0.defaultInitialSetup() } ) + @TestState(singleton: .jobRunner, in: dependencies) var mockJobRunner: MockJobRunner! = MockJobRunner( + initialSetup: { jobRunner in + jobRunner + .when { $0.add(.any, job: .any, dependantJob: .any, canStartJob: .any) } + .thenReturn(nil) + jobRunner + .when { $0.upsert(.any, job: .any, canStartJob: .any) } + .thenReturn(nil) + jobRunner + .when { $0.jobInfoFor(jobs: .any, state: .any, variant: .any) } + .thenReturn([:]) + } + ) @TestState var disposables: [AnyCancellable]! = [] @TestState var cache: Onboarding.Cache! @@ -554,6 +570,10 @@ class OnboardingSpec: AsyncSpec { // MARK: - an Onboarding Cache - Complete Registration describe("an Onboarding Cache when completing registration") { justBeforeEach { + /// The `profile_updated` timestamp in `libSession` is set to now so we need to set the value to some + /// distant future value to force the update logic to trigger + dependencies.dateNow = Date(timeIntervalSince1970: 12345678900) + cache = Onboarding.Cache( flow: .register, using: dependencies @@ -608,13 +628,23 @@ class OnboardingSpec: AsyncSpec { ])) } - // MARK: -- does not insert a profile record into the database for the current user - it("does not insert a profile record into the database for the current user") { + // MARK: -- creates a profile record for the current user + it("creates a profile record for the current user") { let result: [Profile]? = mockStorage.read { db in try Profile.fetchAll(db) } - expect(result).to(beEmpty()) + expect(result).to(equal([ + Profile( + id: "0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b", + name: "TestCompleteName", + nickname: nil, + displayPictureUrl: nil, + displayPictureEncryptionKey: nil, + profileLastUpdated: 12345678900, + blocksCommunityMessageRequests: nil + ) + ])) } // MARK: -- creates a thread for Note to Self @@ -663,19 +693,23 @@ class OnboardingSpec: AsyncSpec { try ConfigDump.fetchAll(db) } - try require(result).to(haveCount(1)) + try require(result).to(haveCount(2)) + try require(Set((result?.map { $0.variant })!)).to(equal([.userProfile, .local])) expect(result![0].variant).to(equal(.userProfile)) - expect(result![0].sessionId).to(equal(SessionId(.standard, hex: TestConstants.publicKey))) - expect(result![0].timestampMs).to(equal(1234567890000)) + let userProfileDump: ConfigDump = (result?.first(where: { $0.variant == .userProfile }))! + expect(userProfileDump.variant).to(equal(.userProfile)) + expect(userProfileDump.sessionId).to(equal(SessionId(.standard, hex: TestConstants.publicKey))) + expect(userProfileDump.timestampMs).to(equal(1234567890000)) /// The data now contains a `now` timestamp so won't be an exact match anymore, but we _can_ check to ensure /// the rest of the data matches and that the timestamps are close enough to `now` /// /// **Note:** The data contains non-ASCII content so we can't do a straight conversion unfortunately - let resultData: Data = result![0].data - let prefixData: Data = "d1:!i1e1:$144:d1:#i1e1:&d1:+i-1e1:Ti".data(using: .ascii)! - let infixData: Data = "e1:n16:TestCompleteName1:ti".data(using: .ascii)! - let suffixData: Data = "ee1: = resultData.range(of: prefixData), @@ -685,41 +719,30 @@ class OnboardingSpec: AsyncSpec { .range(of: suffixData, in: infixRange.upperBound.. = prefixRange.upperBound.. = infixRange.upperBound.. = prefixRange.upperBound.. Date: Mon, 27 Oct 2025 17:03:56 +1100 Subject: [PATCH 139/162] Fixed PN registration so a single group missing auth block all PNs --- Session.xcodeproj/project.pbxproj | 16 +++---- ...hNotificationAPI+SessionMessagingKit.swift | 46 ++++++++++++------- SessionUtilitiesKit/Crypto/CryptoError.swift | 20 +++++++- 3 files changed, 56 insertions(+), 26 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index ef261630d7..9c0d367037 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -8362,7 +8362,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 646; + CURRENT_PROJECT_VERSION = 652; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8402,7 +8402,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.5; + MARKETING_VERSION = 2.14.6; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "-Werror=protocol"; OTHER_SWIFT_FLAGS = "-D DEBUG -Xfrontend -warn-long-expression-type-checking=100"; @@ -8443,7 +8443,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 646; + CURRENT_PROJECT_VERSION = 652; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8478,7 +8478,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.5; + MARKETING_VERSION = 2.14.6; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", @@ -8929,7 +8929,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 646; + CURRENT_PROJECT_VERSION = 652; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8968,7 +8968,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.5; + MARKETING_VERSION = 2.14.6; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = ( "-fobjc-arc-exceptions", @@ -9519,7 +9519,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 646; + CURRENT_PROJECT_VERSION = 652; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; @@ -9552,7 +9552,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.6; LIB_SESSION_SOURCE_DIR = "${SRCROOT}/../LibSession-Util"; LOCALIZED_STRING_SWIFTUI_SUPPORT = NO; - MARKETING_VERSION = 2.14.5; + MARKETING_VERSION = 2.14.6; ONLY_ACTIVE_ARCH = NO; OTHER_CFLAGS = ( "-DNS_BLOCK_ASSERTIONS=1", diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift index dda9ad50dd..b868c35b3b 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI+SessionMessagingKit.swift @@ -46,15 +46,21 @@ public extension Network.PushNotification { .filter(ClosedGroup.Columns.shouldPoll) .asRequest(of: String.self) .fetchSet(db) - .map { threadId in - ( - SessionId(.group, hex: threadId), - try Authentication.with( - db, - swarmPublicKey: threadId, - using: dependencies + .compactMap { threadId in + do { + return ( + SessionId(.group, hex: threadId), + try Authentication.with( + db, + swarmPublicKey: threadId, + using: dependencies + ) ) - ) + } + catch { + Log.warn(.pushNotificationAPI, "Unable to subscribe for push notifications to \(threadId) due to error: \(error).") + return nil + } } ), using: dependencies @@ -74,7 +80,7 @@ public extension Network.PushNotification { .eraseToAnyPublisher() } - public static func unsubscribeAll( + static func unsubscribeAll( token: Data, using dependencies: Dependencies ) -> AnyPublisher { @@ -100,15 +106,21 @@ public extension Network.PushNotification { .asRequest(of: String.self) .fetchSet(db)) .defaulting(to: []) - .map { threadId in - ( - SessionId(.group, hex: threadId), - try Authentication.with( - db, - swarmPublicKey: threadId, - using: dependencies + .compactMap { threadId in + do { + return ( + SessionId(.group, hex: threadId), + try Authentication.with( + db, + swarmPublicKey: threadId, + using: dependencies + ) ) - ) + } + catch { + Log.info(.pushNotificationAPI, "Unable to unsubscribe for push notifications to \(threadId) due to error: \(error).") + return nil + } }), using: dependencies ) diff --git a/SessionUtilitiesKit/Crypto/CryptoError.swift b/SessionUtilitiesKit/Crypto/CryptoError.swift index 9ed1bfe208..b937368132 100644 --- a/SessionUtilitiesKit/Crypto/CryptoError.swift +++ b/SessionUtilitiesKit/Crypto/CryptoError.swift @@ -1,8 +1,10 @@ // Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable import Foundation -public enum CryptoError: Error { +public enum CryptoError: Error, CustomStringConvertible { case invalidSeed case keyGenerationFailed case randomGenerationFailed @@ -14,4 +16,20 @@ public enum CryptoError: Error { case missingUserSecretKey case invalidAuthentication case invalidBase64EncodedData + + public var description: String { + switch self { + case .invalidSeed: return "CryptoError: Invalid seed" + case .keyGenerationFailed: return "CryptoError: Key generation failed" + case .randomGenerationFailed: return "CryptoError: Random generation failed" + case .signatureGenerationFailed: return "CryptoError: Signature generation failed" + case .signatureVerificationFailed: return "CryptoError: Signature verification failed" + case .encryptionFailed: return "CryptoError: Encryption failed" + case .decryptionFailed: return "CryptoError: Decryption failed" + case .failedToGenerateOutput: return "CryptoError: Failed to generate output" + case .missingUserSecretKey: return "CryptoError: Missing user secret key" + case .invalidAuthentication: return "CryptoError: Invalid authentication" + case .invalidBase64EncodedData: return "CryptoError: Invalid Base64 encoded data" + } + } } From 9b71171e2963c9a5def12ee50b095d94c426d514 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 27 Oct 2025 17:29:55 +1100 Subject: [PATCH 140/162] Debugging CI errors --- Scripts/build_libSession_util.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Scripts/build_libSession_util.sh b/Scripts/build_libSession_util.sh index 4b64dbb9c1..d27d0850ac 100755 --- a/Scripts/build_libSession_util.sh +++ b/Scripts/build_libSession_util.sh @@ -23,6 +23,13 @@ remove_locked_dir() { fi } +echo "DEBUG: TARGET_BUILD_DIR = ${TARGET_BUILD_DIR}" +echo "DEBUG: BUILT_PRODUCTS_DIR = ${BUILT_PRODUCTS_DIR}" +echo "DEBUG: CONFIGURATION_BUILD_DIR = ${CONFIGURATION_BUILD_DIR}" +echo "DEBUG: Looking for modulemap at each location..." +ls -la "${TARGET_BUILD_DIR}/include/module.modulemap" 2>&1 || echo "Not in TARGET_BUILD_DIR" +ls -la "${BUILT_PRODUCTS_DIR}/include/module.modulemap" 2>&1 || echo "Not in BUILT_PRODUCTS_DIR" + sync_headers() { local source_dir="$1" echo "- Syncing headers from ${source_dir}" @@ -32,6 +39,7 @@ sync_headers() { "${INDEX_DIR}/include" "${BUILT_PRODUCTS_DIR}/include" "${CONFIGURATION_BUILD_DIR}/include" + "${BUILD_DIR}/Products/${CONFIGURATION}-${PLATFORM_NAME}/include" ) for dest in "${destinations[@]}"; do From d487f090a3acce8b5ad7bbfcbeeebf49f292b596 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 27 Oct 2025 17:32:58 +1100 Subject: [PATCH 141/162] Attempted fix for CI error --- Scripts/build_libSession_util.sh | 36 +++++++++++++++----------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/Scripts/build_libSession_util.sh b/Scripts/build_libSession_util.sh index d27d0850ac..9d3300a0de 100755 --- a/Scripts/build_libSession_util.sh +++ b/Scripts/build_libSession_util.sh @@ -13,6 +13,17 @@ INDEX_DIR="${DERIVED_DATA_PATH}/Index.noindex/Build/Products/Debug-${PLATFORM_NA LAST_SUCCESSFUL_HASH_FILE="${TARGET_BUILD_DIR}/last_successful_source_tree.hash.log" LAST_BUILT_FRAMEWORK_SLICE_DIR_FILE="${TARGET_BUILD_DIR}/last_built_framework_slice_dir.log" +# Modify the platform detection to handle archive builds +if [ "${ACTION}" = "install" ] || [ "${CONFIGURATION}" = "Release" ]; then + # Archive builds typically use 'install' action + if [ -z "$PLATFORM_NAME" ]; then + # During archive, PLATFORM_NAME might not be set correctly + # Default to device build for archives + PLATFORM_NAME="iphoneos" + echo "Missing 'PLATFORM_NAME' value, manually set to ${PLATFORM_NAME}" + fi +fi + # Robustly removes a directory, first clearing any immutable flags (work around Xcode's indexer file locking) remove_locked_dir() { local dir_to_remove="$1" @@ -23,13 +34,6 @@ remove_locked_dir() { fi } -echo "DEBUG: TARGET_BUILD_DIR = ${TARGET_BUILD_DIR}" -echo "DEBUG: BUILT_PRODUCTS_DIR = ${BUILT_PRODUCTS_DIR}" -echo "DEBUG: CONFIGURATION_BUILD_DIR = ${CONFIGURATION_BUILD_DIR}" -echo "DEBUG: Looking for modulemap at each location..." -ls -la "${TARGET_BUILD_DIR}/include/module.modulemap" 2>&1 || echo "Not in TARGET_BUILD_DIR" -ls -la "${BUILT_PRODUCTS_DIR}/include/module.modulemap" 2>&1 || echo "Not in BUILT_PRODUCTS_DIR" - sync_headers() { local source_dir="$1" echo "- Syncing headers from ${source_dir}" @@ -39,9 +43,14 @@ sync_headers() { "${INDEX_DIR}/include" "${BUILT_PRODUCTS_DIR}/include" "${CONFIGURATION_BUILD_DIR}/include" - "${BUILD_DIR}/Products/${CONFIGURATION}-${PLATFORM_NAME}/include" ) + # For archive builds, add the archive-specific path + if [ "${ACTION}" = "install" ]; then + local ARCHIVE_PRODUCTS_PATH="${BUILD_DIR}/../../BuildProductsPath/${CONFIGURATION}-${PLATFORM_NAME}/include" + destinations+=("${ARCHIVE_PRODUCTS_PATH}") + fi + for dest in "${destinations[@]}"; do if [ -n "$dest" ]; then remove_locked_dir "$dest" @@ -52,17 +61,6 @@ sync_headers() { done } -# Modify the platform detection to handle archive builds -if [ "${ACTION}" = "install" ] || [ "${CONFIGURATION}" = "Release" ]; then - # Archive builds typically use 'install' action - if [ -z "$PLATFORM_NAME" ]; then - # During archive, PLATFORM_NAME might not be set correctly - # Default to device build for archives - PLATFORM_NAME="iphoneos" - echo "Missing 'PLATFORM_NAME' value, manually set to ${PLATFORM_NAME}" - fi -fi - # Determine whether we want to build from source TARGET_ARCH_DIR="" From b1ca35cf9be997cab204e047ef5546101415e72d Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 28 Oct 2025 09:38:40 +1100 Subject: [PATCH 142/162] Added a dev setting to show the group pubkey in conversation settings --- .../Settings/ThreadSettingsViewModel.swift | 8 +++++- .../DeveloperSettingsGroupsViewModel.swift | 26 ++++++++++++++++++- SessionUtilitiesKit/General/Feature.swift | 4 +++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 5ae2cd91bc..cf75fd81e0 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -175,6 +175,12 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob threadViewModel.threadShouldBeVisible != true && threadViewModel.threadPinnedPriority == LibSession.hiddenPriority ) + let showThreadPubkey: Bool = ( + threadViewModel.threadVariant == .contact || ( + threadViewModel.threadVariant == .group && + dependencies[feature: .groupsShowPubkeyInConversationSettings] + ) + ) // MARK: - Conversation Info let conversationInfoSection: SectionModel = SectionModel( model: .conversationInfo, @@ -318,7 +324,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob ) }, - (threadViewModel.threadVariant != .contact ? nil : + (!showThreadPubkey ? nil : SessionCell.Info( id: .sessionId, subtitle: SessionCell.TextInfo( diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsGroupsViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsGroupsViewModel.swift index 61d62d1034..7c7c169262 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsGroupsViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsGroupsViewModel.swift @@ -62,6 +62,7 @@ class DeveloperSettingsGroupsViewModel: SessionTableViewModel, NavigatableStateH } public enum TableItem: Hashable, Differentiable, CaseIterable { + case groupsShowPubkeyInConversationSettings case updatedGroupsDisableAutoApprove case updatedGroupsRemoveMessagesOnKick case updatedGroupsAllowHistoricAccessOnInvite @@ -78,6 +79,7 @@ class DeveloperSettingsGroupsViewModel: SessionTableViewModel, NavigatableStateH public var differenceIdentifier: String { switch self { + case .groupsShowPubkeyInConversationSettings: return "groupsShowPubkeyInConversationSettings" case .updatedGroupsDisableAutoApprove: return "updatedGroupsDisableAutoApprove" case .updatedGroupsRemoveMessagesOnKick: return "updatedGroupsRemoveMessagesOnKick" case .updatedGroupsAllowHistoricAccessOnInvite: return "updatedGroupsAllowHistoricAccessOnInvite" @@ -96,7 +98,8 @@ class DeveloperSettingsGroupsViewModel: SessionTableViewModel, NavigatableStateH public static var allCases: [TableItem] { var result: [TableItem] = [] - switch TableItem.updatedGroupsDisableAutoApprove { + switch TableItem.groupsShowPubkeyInConversationSettings { + case .groupsShowPubkeyInConversationSettings: result.append(groupsShowPubkeyInConversationSettings); fallthrough case .updatedGroupsDisableAutoApprove: result.append(.updatedGroupsDisableAutoApprove); fallthrough case .updatedGroupsRemoveMessagesOnKick: result.append(.updatedGroupsRemoveMessagesOnKick); fallthrough case .updatedGroupsAllowHistoricAccessOnInvite: @@ -116,6 +119,7 @@ class DeveloperSettingsGroupsViewModel: SessionTableViewModel, NavigatableStateH // MARK: - Content public struct State: Equatable, ObservableKeyProvider { + let groupsShowPubkeyInConversationSettings: Bool let updatedGroupsDisableAutoApprove: Bool let updatedGroupsRemoveMessagesOnKick: Bool let updatedGroupsAllowHistoricAccessOnInvite: Bool @@ -135,6 +139,7 @@ class DeveloperSettingsGroupsViewModel: SessionTableViewModel, NavigatableStateH } public let observedKeys: Set = [ + .feature(.groupsShowPubkeyInConversationSettings), .feature(.updatedGroupsDisableAutoApprove), .feature(.updatedGroupsRemoveMessagesOnKick), .feature(.updatedGroupsAllowHistoricAccessOnInvite), @@ -148,6 +153,7 @@ class DeveloperSettingsGroupsViewModel: SessionTableViewModel, NavigatableStateH static func initialState(using dependencies: Dependencies) -> State { return State( + groupsShowPubkeyInConversationSettings: dependencies[feature: .groupsShowPubkeyInConversationSettings], updatedGroupsDisableAutoApprove: dependencies[feature: .updatedGroupsDisableAutoApprove], updatedGroupsRemoveMessagesOnKick: dependencies[feature: .updatedGroupsRemoveMessagesOnKick], updatedGroupsAllowHistoricAccessOnInvite: dependencies[feature: .updatedGroupsAllowHistoricAccessOnInvite], @@ -170,6 +176,7 @@ class DeveloperSettingsGroupsViewModel: SessionTableViewModel, NavigatableStateH using dependencies: Dependencies ) async -> State { return State( + groupsShowPubkeyInConversationSettings: dependencies[feature: .groupsShowPubkeyInConversationSettings], updatedGroupsDisableAutoApprove: dependencies[feature: .updatedGroupsDisableAutoApprove], updatedGroupsRemoveMessagesOnKick: dependencies[feature: .updatedGroupsRemoveMessagesOnKick], updatedGroupsAllowHistoricAccessOnInvite: dependencies[feature: .updatedGroupsAllowHistoricAccessOnInvite], @@ -190,6 +197,23 @@ class DeveloperSettingsGroupsViewModel: SessionTableViewModel, NavigatableStateH let general: SectionModel = SectionModel( model: .general, elements: [ + SessionCell.Info( + id: .groupsShowPubkeyInConversationSettings, + title: "Show Group Pubkey in Conversation Settings", + subtitle: """ + Makes the group identity public key appear in the conversation settings screen. + """, + trailingAccessory: .toggle( + state.groupsShowPubkeyInConversationSettings, + oldValue: previousState.groupsShowPubkeyInConversationSettings + ), + onTap: { [dependencies = viewModel.dependencies] in + dependencies.set( + feature: .groupsShowPubkeyInConversationSettings, + to: !state.groupsShowPubkeyInConversationSettings + ) + } + ), SessionCell.Info( id: .updatedGroupsDisableAutoApprove, title: "Disable Auto Approve", diff --git a/SessionUtilitiesKit/General/Feature.swift b/SessionUtilitiesKit/General/Feature.swift index 55998cbed9..11bbb34bdb 100644 --- a/SessionUtilitiesKit/General/Feature.swift +++ b/SessionUtilitiesKit/General/Feature.swift @@ -46,6 +46,10 @@ public extension FeatureStorage { defaultOption: 100 ) + static let groupsShowPubkeyInConversationSettings: FeatureConfig = Dependencies.create( + identifier: "groupsShowPubkeyInConversationSettings" + ) + static let updatedGroupsDisableAutoApprove: FeatureConfig = Dependencies.create( identifier: "updatedGroupsDisableAutoApprove" ) From 95a44d20785c5177110788b01f903962e5ae4829 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 28 Oct 2025 13:58:39 +1100 Subject: [PATCH 143/162] Fixed a couple of issues found during testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added some defensive coding to try to recover (re-download) the current users display picture if there is a discrepancy between the database and libSession states • Fixed an issue where some screens wouldn't update when the current users display picture was updated on a different device --- Session.xcodeproj/project.pbxproj | 8 ++--- .../Jobs/DisplayPictureDownloadJob.swift | 21 +++++------ .../LibSession+SessionMessagingKit.swift | 36 +++++++++++++++++++ 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index bb6763e231..07f37bf15c 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -8366,7 +8366,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 651; + CURRENT_PROJECT_VERSION = 653; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8447,7 +8447,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 651; + CURRENT_PROJECT_VERSION = 653; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8933,7 +8933,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 651; + CURRENT_PROJECT_VERSION = 653; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -9523,7 +9523,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 651; + CURRENT_PROJECT_VERSION = 653; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index 9710c0114a..483be09a87 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -253,18 +253,15 @@ public enum DisplayPictureDownloadJob: JobExecutor { ) throws { switch details.target { case .profile(let id, let url, let encryptionKey): - /// Don't want to store the current users profile data in the database (should only be sourced from `libSession`) - if id != dependencies[cache: .general].sessionId.hexString { - _ = try? Profile - .filter(id: id) - .updateAllAndConfig( - db, - Profile.Columns.displayPictureUrl.set(to: url), - Profile.Columns.displayPictureEncryptionKey.set(to: encryptionKey), - Profile.Columns.profileLastUpdated.set(to: details.timestamp), - using: dependencies - ) - } + _ = try? Profile + .filter(id: id) + .updateAllAndConfig( + db, + Profile.Columns.displayPictureUrl.set(to: url), + Profile.Columns.displayPictureEncryptionKey.set(to: encryptionKey), + Profile.Columns.profileLastUpdated.set(to: details.timestamp), + using: dependencies + ) db.addProfileEvent(id: id, change: .displayPictureUrl(url)) db.addConversationEvent(id: id, type: .updated(.displayPictureUrl(url))) diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index 09a5d765c7..76466c3493 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -285,6 +285,42 @@ public extension LibSession { ) } + /// There is a bit of an odd discrepancy between `libSession` and the database for the users profile where `libSession` + /// could have updated display picture information but the database could have old data - this is because we don't update + /// the values in the database until after the display picture is downloaded + /// + /// Due to this we should schedule a `DispalyPictureDownloadJob` for the current users display picture if it happens + /// to be different from the database value (or the file doesn't exist) to ensure it gets downloaded + let libSessionProfile: Profile = profile + let databaseProfile: Profile = Profile.fetchOrCreate(db, id: libSessionProfile.id) + + if + let url: String = libSessionProfile.displayPictureUrl, + let key: Data = libSessionProfile.displayPictureEncryptionKey, + !key.isEmpty, + ( + databaseProfile.displayPictureUrl != url || + databaseProfile.displayPictureEncryptionKey != key + ), + let path: String = try? dependencies[singleton: .displayPictureManager] + .path(for: libSessionProfile.displayPictureUrl), + !dependencies[singleton: .fileManager].fileExists(atPath: path) + { + Log.info(.libSession, "Scheduling display picture download due to discrepancy with database") + dependencies[singleton: .jobRunner].add( + db, + job: Job( + variant: .displayPictureDownload, + shouldBeUnique: true, + details: DisplayPictureDownloadJob.Details( + target: .profile(id: libSessionProfile.id, url: url, encryptionKey: key), + timestamp: libSessionProfile.profileLastUpdated + ) + ), + canStartJob: dependencies[singleton: .appContext].isMainApp + ) + } + Log.info(.libSession, "Completed loadState\(requestId.map { " for \($0)" } ?? "")") } From 30eb7f2e78cda78573801b0b726b8f55095672f5 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 28 Oct 2025 14:46:43 +1100 Subject: [PATCH 144/162] Bumped build number --- Session.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 07f37bf15c..45e35aa12e 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -8366,7 +8366,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 653; + CURRENT_PROJECT_VERSION = 654; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8447,7 +8447,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 653; + CURRENT_PROJECT_VERSION = 654; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8933,7 +8933,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 653; + CURRENT_PROJECT_VERSION = 654; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -9523,7 +9523,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 653; + CURRENT_PROJECT_VERSION = 654; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; From f2cc186ad9f3e88fe6f779271af1ebd76f1d83dd Mon Sep 17 00:00:00 2001 From: ThomasSession <171472362+ThomasSession@users.noreply.github.com> Date: Tue, 28 Oct 2025 03:58:28 +0000 Subject: [PATCH 145/162] [Automated] Update translations from Crowdin --- .../Meta/Translations/Localizable.xcstrings | 101 +++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/Session/Meta/Translations/Localizable.xcstrings b/Session/Meta/Translations/Localizable.xcstrings index b05a5a167f..a2df415fed 100644 --- a/Session/Meta/Translations/Localizable.xcstrings +++ b/Session/Meta/Translations/Localizable.xcstrings @@ -216732,6 +216732,39 @@ } } }, + "groupMemberRemoveFailed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to remove {name} from {group_name}" + } + } + } + }, + "groupMemberRemoveFailedMultiple" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to remove {name} and {count} others from {group_name}" + } + } + } + }, + "groupMemberRemoveFailedOther" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to remove {name} and {other_name} from {group_name}" + } + } + } + }, "groupMembers" : { "extractionState" : "manual", "localizations" : { @@ -366488,7 +366521,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "You have upgraded to {app_pro}!

Thank you for supporting the {network_name}." + "value" : "You have upgraded to {app_pro}!
Thank you for supporting the {network_name}." } } } @@ -382841,6 +382874,39 @@ } } }, + "removeMemberMessages" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove member and their messages" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove member and their messages" + } + } + } + } + } + } + } + } + }, "removePasswordFail" : { "extractionState" : "manual", "localizations" : { @@ -383385,6 +383451,39 @@ } } }, + "removingMember" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%#@arg1@" + }, + "substitutions" : { + "arg1" : { + "argNum" : 1, + "formatSpecifier" : "lld", + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Removing member" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Removing members" + } + } + } + } + } + } + } + } + }, "renew" : { "extractionState" : "manual", "localizations" : { From b0cea5d308f4829f0f89892651b25c2bdb45d7df Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 28 Oct 2025 15:14:26 +1100 Subject: [PATCH 146/162] Fixed a bug where you couldn't upload the same file multiple times --- Session.xcodeproj/project.pbxproj | 8 ++++---- SessionMessagingKit/Jobs/AttachmentUploadJob.swift | 8 +++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 45e35aa12e..ccabdb6b69 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -8366,7 +8366,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 654; + CURRENT_PROJECT_VERSION = 655; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8447,7 +8447,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 654; + CURRENT_PROJECT_VERSION = 655; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8933,7 +8933,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 654; + CURRENT_PROJECT_VERSION = 655; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -9523,7 +9523,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 654; + CURRENT_PROJECT_VERSION = 655; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; diff --git a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift index c02c11f647..320090f52b 100644 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift @@ -360,7 +360,13 @@ public extension AttachmentUploadJob { let oldPath: String = try? dependencies[singleton: .attachmentManager].path(for: oldUrl), let newPath: String = try? dependencies[singleton: .attachmentManager].path(for: finalDownloadUrl) { - try dependencies[singleton: .fileManager].moveItem(atPath: oldPath, toPath: newPath) + if !dependencies[singleton: .fileManager].fileExists(atPath: newPath) { + try dependencies[singleton: .fileManager].moveItem(atPath: oldPath, toPath: newPath) + } + else { + try? dependencies[singleton: .fileManager].removeItem(atPath: oldPath) + Log.info(.cat, "File already existed at final path, assuming re-upload of existing attachment") + } } /// Generate the final uploaded attachment data and trigger the success callback From 4ca7a1a1cd053d51ce899ef5b7ac363bfb7728d2 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Tue, 28 Oct 2025 16:04:49 +1100 Subject: [PATCH 147/162] Bumped build number --- Session.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index ccabdb6b69..b04c73d261 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -8366,7 +8366,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 655; + CURRENT_PROJECT_VERSION = 656; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8447,7 +8447,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 655; + CURRENT_PROJECT_VERSION = 656; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8933,7 +8933,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 655; + CURRENT_PROJECT_VERSION = 656; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -9523,7 +9523,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 655; + CURRENT_PROJECT_VERSION = 656; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; From 90d13cba83e428d5c2478f2461561077e785d4e0 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Wed, 29 Oct 2025 16:52:23 +1100 Subject: [PATCH 148/162] Fixed a number of issues found when debugging image orientation bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Added the ability to set the custom file server via env variables for testing • Disable image editing on the share extension if there isn't enough RAM available to edit the image (ie. prevent crashes due to insufficient memory) • Tweaked image loading to avoid pre-decoding large static images to reduce memory spikes (was crashing the share extension on some devices) • Updated link previews to load images using the ImageDataManager • Fixed an issue where the file id on the message info screen would include extra info • Fixed an issue where sharing a file wouldn't show the preview correctly • Fixed an issue where the re-upload job could run when a display picture file was missing • Fixed an issue where deleting messages across all devices in Note to Self was using the wrong deletion function • Fixed an issue where an invalid database value could result in no attachments being able to be loaded in a conversation • Fixed an issue where images with orientation metadata would be incorrectly rotated and letterboxed --- Session.xcodeproj/project.pbxproj | 12 +- .../ConversationVC+Interaction.swift | 2 +- .../Conversations/ConversationViewModel.swift | 3 +- .../Conversations/Input View/InputView.swift | 63 +-- .../Content Views/LinkPreviewState.swift | 12 +- .../Content Views/LinkPreviewView.swift | 2 +- .../SwiftUI/LinkPreviewView_SwiftUI.swift | 2 +- .../MessageInfoScreen.swift | 2 +- .../PhotoCapture.swift | 2 +- .../DeveloperSettingsViewModel+Testing.swift | 53 ++- Session/Settings/ImagePickerHandler.swift | 4 +- Session/Utilities/UIImage+Scaling.swift | 18 - .../Database/Models/Attachment.swift | 19 +- .../Database/Models/LinkPreview.swift | 270 +++++------- .../Jobs/ReuploadUserDisplayPictureJob.swift | 13 +- .../Link Previews/LinkPreviewDraft.swift | 16 +- .../MessageReceiver+UnsendRequests.swift | 2 +- .../Utilities/AttachmentManager.swift | 157 +++++-- .../Utilities/DisplayPictureManager.swift | 10 +- .../Types/ProxiedContentDownloader.swift | 95 ++++- .../ShareNavController.swift | 2 +- SessionShareExtension/ThreadPickerVC.swift | 3 +- SessionUIKit/Types/ImageDataManager.swift | 64 ++- SessionUtilitiesKit/Media/MediaUtils.swift | 6 +- .../Utilities/UIImage+Utilities.swift | 394 +++++++----------- .../AttachmentItemCollection.swift | 3 +- .../ImageEditorBrushViewController.swift | 5 +- .../Image Editing/ImageEditorCanvasView.swift | 64 ++- .../Image Editing/ImageEditorModel.swift | 6 +- .../ImageEditorTextViewController.swift | 6 +- .../Image Editing/ImageEditorView.swift | 76 +++- .../MediaMessageView.swift | 105 ++--- .../OWSViewController+ImageEditor.swift | 3 +- 33 files changed, 832 insertions(+), 662 deletions(-) delete mode 100644 Session/Utilities/UIImage+Scaling.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index b04c73d261..dcc9e4b793 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -235,7 +235,6 @@ B835247925C38D880089A44F /* MessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B835247825C38D880089A44F /* MessageCell.swift */; }; B835249B25C3AB650089A44F /* VisibleMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B835249A25C3AB650089A44F /* VisibleMessageCell.swift */; }; B83524A525C3BA4B0089A44F /* InfoMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83524A425C3BA4B0089A44F /* InfoMessageCell.swift */; }; - B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */; }; B849789625D4A2F500D0D0B3 /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B849789525D4A2F500D0D0B3 /* LinkPreviewView.swift */; }; B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84A89BB25DE328A0040017D /* ProfilePictureVC.swift */; }; B85357BF23A1AE0800AAF6CD /* SeedReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85357BE23A1AE0800AAF6CD /* SeedReminderView.swift */; }; @@ -1633,7 +1632,6 @@ B835247825C38D880089A44F /* MessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCell.swift; sourceTree = ""; }; B835249A25C3AB650089A44F /* VisibleMessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibleMessageCell.swift; sourceTree = ""; }; B83524A425C3BA4B0089A44F /* InfoMessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoMessageCell.swift; sourceTree = ""; }; - B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Scaling.swift"; sourceTree = ""; }; B84664F4235022F30083A1CD /* MentionUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionUtilities.swift; sourceTree = ""; }; B849789525D4A2F500D0D0B3 /* LinkPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewView.swift; sourceTree = ""; }; B84A89BB25DE328A0040017D /* ProfilePictureVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePictureVC.swift; sourceTree = ""; }; @@ -2749,7 +2747,6 @@ FDE521992E08DBB000061B8E /* ImageLoading+Convenience.swift */, FDB3DA832E1CA21C00148F8D /* UIActivityViewController+Utilities.swift */, B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */, - B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */, FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */, FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */, C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */, @@ -6929,7 +6926,6 @@ C374EEF425DB31D40073A857 /* VoiceMessageRecordingView.swift in Sources */, 7B1581E6271FD2A100848B49 /* VideoPreviewVC.swift in Sources */, 9422568A2C23F8C800C0FDBF /* LoadingScreen.swift in Sources */, - B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */, 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */, 4C090A1B210FD9C7001FD7F9 /* HapticFeedback.swift in Sources */, FDE754FA2C9BB0B0002A2623 /* NotificationPresenter.swift in Sources */, @@ -8366,7 +8362,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 656; + CURRENT_PROJECT_VERSION = 657; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8447,7 +8443,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 656; + CURRENT_PROJECT_VERSION = 657; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8933,7 +8929,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 656; + CURRENT_PROJECT_VERSION = 657; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -9523,7 +9519,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 656; + CURRENT_PROJECT_VERSION = 657; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 5940334a16..a683e730fd 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -969,7 +969,7 @@ extension ConversationVC: @MainActor func didPasteImageDataFromPasteboard(_ imageData: Data) { let pendingAttachment: PendingAttachment = PendingAttachment( - source: .media(UUID().uuidString, imageData), + source: .media(.data(UUID().uuidString, imageData)), sourceFilename: nil, using: viewModel.dependencies ) diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index b05e14d651..75549db6f3 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -762,8 +762,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold if let draft: LinkPreviewDraft = linkPreviewDraft { linkPreviewPreparedAttachment = try? await LinkPreview.prepareAttachmentIfPossible( urlString: draft.urlString, - imageData: draft.jpegImageData, - type: .jpeg, + imageSource: draft.imageSource, using: dependencies ) } diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 54cabeae90..38fd63053b 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -21,6 +21,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M var quoteDraftInfo: (model: QuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } } var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)? + private var linkPreviewLoadTask: Task? private var voiceMessageRecordingView: VoiceMessageRecordingView? private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0) @@ -231,6 +232,10 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M required init?(coder: NSCoder) { preconditionFailure("Use init(delegate:) instead.") } + + deinit { + linkPreviewLoadTask?.cancel() + } private func setUpViewHierarchy() { autoresizingMask = .flexibleHeight @@ -400,7 +405,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M } } - func autoGenerateLinkPreview() { + @MainActor func autoGenerateLinkPreview() { // Check that a valid URL is present guard let linkPreviewURL = LinkPreview.previewUrl(for: text, selectedRange: inputTextView.selectedRange, using: dependencies) else { return @@ -425,37 +430,43 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M linkPreviewView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -4) // Build the link preview - LinkPreview - .tryToBuildPreviewInfo( - previewUrl: linkPreviewURL, - skipImageDownload: (inputState.allowedInputTypes != .all), /// Disable image download if attachments are disabled - using: dependencies - ) - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { [weak self] result in - switch result { - case .finished: break - case .failure: - guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete - - self?.linkPreviewInfo = nil - self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } - } - }, - receiveValue: { [weak self, dependencies] draft in - guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete + linkPreviewLoadTask?.cancel() + linkPreviewLoadTask = Task.detached(priority: .userInitiated) { [weak self, allowedInputTypes = inputState.allowedInputTypes, dependencies] in + do { + /// Load the draft + let draft: LinkPreviewDraft = try await LinkPreview.tryToBuildPreviewInfo( + previewUrl: linkPreviewURL, + skipImageDownload: (allowedInputTypes != .all), /// Disable if attachments are disabled + using: dependencies + ) + try Task.checkCancellation() + + await MainActor.run { [weak self] in + guard let self else { return } + guard linkPreviewInfo?.url == linkPreviewURL else { return } /// Obsolete - self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft) - self?.linkPreviewView.update( + linkPreviewInfo = (url: linkPreviewURL, draft: draft) + linkPreviewView.update( with: LinkPreview.DraftState(linkPreviewDraft: draft), isOutgoing: false, using: dependencies ) + setNeedsLayout() + layoutIfNeeded() } - ) - .store(in: &disposables) + } + catch { + await MainActor.run { [weak self] in + guard let self else { return } + guard linkPreviewInfo?.url == linkPreviewURL else { return } /// Obsolete + + linkPreviewInfo = nil + additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } + setNeedsLayout() + layoutIfNeeded() + } + } + } } func setMessageInputState(_ updatedInputState: SessionThreadViewModel.MessageInputState) { diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift index 7b62f3b844..953dd97f4d 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift @@ -44,20 +44,12 @@ public extension LinkPreview { } var imageState: LinkPreview.ImageState { - if linkPreviewDraft.jpegImageData != nil { return .loaded } + if linkPreviewDraft.imageSource != nil { return .loaded } return .none } - var imageSource: ImageDataManager.DataSource? { - guard let jpegImageData = linkPreviewDraft.jpegImageData else { return nil } - guard let image = UIImage(data: jpegImageData) else { - Log.error("[LinkPreview] Could not load image: \(jpegImageData.count)") - return nil - } - - return .image(urlString ?? "Invalid_Link_Preview", image) - } + var imageSource: ImageDataManager.DataSource? { linkPreviewDraft.imageSource } // MARK: - Type Specific diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift index 84aeeb2b7b..adb0ce5294 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift @@ -148,7 +148,7 @@ final class LinkPreviewView: UIView { // MARK: - Updating - public func update( + @MainActor public func update( with state: LinkPreviewState, isOutgoing: Bool, delegate: TappableLabelDelegate? = nil, diff --git a/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift b/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift index 82419b5a63..270133b68f 100644 --- a/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift +++ b/Session/Conversations/Message Cells/Content Views/SwiftUI/LinkPreviewView_SwiftUI.swift @@ -146,7 +146,7 @@ struct LinkPreview_SwiftUI_Previews: PreviewProvider { linkPreviewDraft: .init( urlString: "https://github.com/oxen-io", title: "Github - oxen-io/session-ios: A private messenger for iOS.", - jpegImageData: UIImage(named: "AppIcon")?.jpegData(compressionQuality: 1) + imageSource: .image("AppIcon", UIImage(named: "AppIcon")) ) ), dataManager: ImageDataManager(), diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 87f46cd747..76cdb79c27 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -183,7 +183,7 @@ struct MessageInfoScreen: View { spacing: Values.mediumSpacing ) { InfoBlock(title: "attachmentsFileId".localized()) { - Text(attachment.downloadUrl.map { Network.FileServer.fileId(for: $0) } ?? "") + Text(attachment.downloadUrl.map { Network.FileServer.fileId(for: URL(string: $0)?.strippingQueryAndFragment?.absoluteString) } ?? "") .font(.system(size: Values.mediumFontSize)) .foregroundColor(themeColor: .textPrimary) } diff --git a/Session/Media Viewing & Editing/PhotoCapture.swift b/Session/Media Viewing & Editing/PhotoCapture.swift index 69d6718be4..69243f1487 100644 --- a/Session/Media Viewing & Editing/PhotoCapture.swift +++ b/Session/Media Viewing & Editing/PhotoCapture.swift @@ -425,7 +425,7 @@ extension PhotoCapture: CaptureOutputDelegate { } let pendingAttachment: PendingAttachment = PendingAttachment( - source: .media(UUID().uuidString, photoData), + source: .media(.data(UUID().uuidString, photoData)), utType: .jpeg, sourceFilename: nil, using: dependencies diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel+Testing.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel+Testing.swift index 50858db264..7f2bc2f823 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel+Testing.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsViewModel+Testing.swift @@ -3,6 +3,7 @@ // stringlint:disable import UIKit +import SessionNetworkingKit import SessionUtilitiesKit // MARK: - Automated Test Convenience @@ -29,7 +30,7 @@ extension DeveloperSettingsViewModel { /// **Note:** All values need to be provided as strings (eg. booleans) static func processUnitTestEnvVariablesIfNeeded(using dependencies: Dependencies) { #if targetEnvironment(simulator) - enum EnvironmentVariable: String { + enum EnvironmentVariable: String, CaseIterable { /// Disables animations for the app (where possible) /// /// **Value:** `true`/`false` (default: `true`) @@ -70,12 +71,38 @@ extension DeveloperSettingsViewModel { /// /// **Value:** `true`/`false` (default: `false`) case shortenFileTTL + + /// Controls the url which is used for the file server + /// + /// **Value:** Valid url string + /// + /// **Note:** If `customFileServerPubkey` isn't also provided then the default file server pubkey will be used + case customFileServerUrl + + /// Controls the pubkey which is used for the file server + /// + /// **Value:** 64 character hex encoded public key + /// + /// **Note:** Only used if `customFileServerUrl` is valid + case customFileServerPubkey } - ProcessInfo.processInfo.environment.forEach { key, value in - guard let variable: EnvironmentVariable = EnvironmentVariable(rawValue: key) else { return } + let envVars: [EnvironmentVariable: String] = ProcessInfo.processInfo.environment + .reduce(into: [:]) { result, next in + guard let variable: EnvironmentVariable = EnvironmentVariable(rawValue: next.key) else { + return + } + + result[variable] = next.value + } + let allKeys: Set = Set(envVars.keys) + + /// The order the the environment variables are applied in is important (configuring the network needs to happen in a certain + /// order to simplify the below logic) + for key in EnvironmentVariable.allCases { + guard let value: String = envVars[key] else { continue } - switch variable { + switch key { case .animationsEnabled: dependencies.set(feature: .animationsEnabled, to: (value == "true")) @@ -115,6 +142,24 @@ extension DeveloperSettingsViewModel { case .shortenFileTTL: dependencies.set(feature: .shortenFileTTL, to: (value == "true")) + + case .customFileServerUrl: + /// Ensure values were provided first + guard let url: String = envVars[.customFileServerUrl], !url.isEmpty else { + Log.warn("An empty 'customFileServerUrl' was provided") + break + } + let pubkey: String = (envVars[.customFileServerPubkey] ?? "") + let server: Network.FileServer.Custom = Network.FileServer.Custom(url: url, pubkey: pubkey) + + guard server.isValid else { + Log.warn("The custom file server info provided was not valid: (url: '\(url)', pubkey: '\(pubkey)'") + break + } + dependencies.set(feature: .customFileServer, to: server) + + /// This is handled in the `customFileServerUrl` case + case .customFileServerPubkey: break } } #endif diff --git a/Session/Settings/ImagePickerHandler.swift b/Session/Settings/ImagePickerHandler.swift index d2324040e6..dfa08073b9 100644 --- a/Session/Settings/ImagePickerHandler.swift +++ b/Session/Settings/ImagePickerHandler.swift @@ -39,7 +39,7 @@ class ImagePickerHandler: PHPickerViewControllerDelegate { result.itemProvider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in guard let self = self else { return } guard let url: URL = url else { - print("Error loading file: \(error?.localizedDescription ?? "unknown")") + Log.debug("[ImagePickHandler] Error loading file: \(error?.localizedDescription ?? "unknown")") return } @@ -69,7 +69,7 @@ class ImagePickerHandler: PHPickerViewControllerDelegate { } } catch { - print("Error copying file: \(error)") + Log.debug("[ImagePickHandler] Error copying file: \(error)") } } } diff --git a/Session/Utilities/UIImage+Scaling.swift b/Session/Utilities/UIImage+Scaling.swift deleted file mode 100644 index bc6208071f..0000000000 --- a/Session/Utilities/UIImage+Scaling.swift +++ /dev/null @@ -1,18 +0,0 @@ -import UIKit - -extension UIImage { - - func scaled(to size: CGSize) -> UIImage { - var rect = CGRect.zero - let aspectRatio = min(size.width / self.size.width, size.height / self.size.height) - rect.size.width = self.size.width * aspectRatio - rect.size.height = self.size.height * aspectRatio - rect.origin.x = (size.width - rect.size.width) / 2 - rect.origin.y = (size.height - rect.size.height) / 2 - UIGraphicsBeginImageContextWithOptions(size, false, 0) - draw(in: rect) - let result = UIGraphicsGetImageFromCurrentImageContext()! - UIGraphicsEndImageContext() - return result - } -} diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 4654d00262..f5bff7ce4a 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -57,6 +57,23 @@ public struct Attachment: Sendable, Codable, Identifiable, Equatable, Hashable, case uploaded case invalid = 100 + + public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> State? { + /// There was an odd issue where there were values of `50` in the `state` column in the database, though it doesn't + /// seem like that has ever been an option. Unfortunately this results in all attachments in a conversation being broken so + /// instead we custom handle the conversion to the `State` enum and consider anything invalid as `invalid` + switch dbValue.storage { + case .int64(let value): + guard let result: State = State(rawValue: Int(value)) else { + return .invalid + } + + return result + + default: return .invalid + } + + } } /// A unique identifier for the attachment @@ -331,7 +348,7 @@ extension Attachment { .path(for: downloadUrl) else { return nil } - return MediaUtils.unrotatedSize( + return MediaUtils.displaySize( for: path, utType: UTType(sessionMimeType: contentType), sourceFilename: sourceFilename, diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 464e76934e..9f53541edb 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -6,6 +6,7 @@ import Foundation import Combine import UniformTypeIdentifiers import GRDB +import SessionUIKit import SessionUtilitiesKit import SessionNetworkingKit @@ -134,15 +135,15 @@ public extension LinkPreview { static func prepareAttachmentIfPossible( urlString: String, - imageData: Data?, - type: UTType, + imageSource: ImageDataManager.DataSource?, using dependencies: Dependencies ) async throws -> PreparedAttachment? { - guard let imageData: Data = imageData, !imageData.isEmpty else { return nil } + guard let imageSource: ImageDataManager.DataSource = imageSource, imageSource.contentExists else { + return nil + } let pendingAttachment: PendingAttachment = PendingAttachment( - source: .media(urlString, imageData), - utType: type, + source: .media(imageSource), using: dependencies ) let targetFormat: PendingAttachment.ConversionFormat = (dependencies[feature: .usePngInsteadOfWebPForFallbackImageType] ? @@ -326,49 +327,43 @@ public extension LinkPreview { previewUrl: String?, skipImageDownload: Bool, using dependencies: Dependencies - ) -> AnyPublisher { + ) async throws -> LinkPreviewDraft { guard dependencies.mutate(cache: .libSession, { $0.get(.areLinkPreviewsEnabled) }) else { - return Fail(error: LinkPreviewError.featureDisabled) - .eraseToAnyPublisher() + throw LinkPreviewError.featureDisabled } // Force the url to lowercase to ensure we casing doesn't result in redownloading the // details guard let previewUrl: String = previewUrl?.lowercased() else { - return Fail(error: LinkPreviewError.invalidInput) - .eraseToAnyPublisher() + throw LinkPreviewError.invalidInput } if let cachedInfo = cachedLinkPreview(forPreviewUrl: previewUrl) { - return Just(cachedInfo) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - return downloadLink(url: previewUrl) - .flatMap { [dependencies] data, response in - parseLinkDataAndBuildDraft( - linkData: data, - response: response, - linkUrlString: previewUrl, - skipImageDownload: skipImageDownload, - using: dependencies - ) - } - .tryMap { [dependencies] linkPreviewDraft -> LinkPreviewDraft in - guard linkPreviewDraft.isValid() else { throw LinkPreviewError.noPreview } + return cachedInfo + } + + let (data, response) = try await downloadLink(url: previewUrl) + try Task.checkCancellation() /// No use trying to parse and potentially download an image if the task was cancelled + + let linkPreviewDraft: LinkPreviewDraft = try await parseLinkDataAndBuildDraft( + linkData: data, + response: response, + linkUrlString: previewUrl, + skipImageDownload: skipImageDownload, + using: dependencies + ) + + guard linkPreviewDraft.isValid() else { throw LinkPreviewError.noPreview } - setCachedLinkPreview(linkPreviewDraft, forPreviewUrl: previewUrl, using: dependencies) - - return linkPreviewDraft - } - .eraseToAnyPublisher() + setCachedLinkPreview(linkPreviewDraft, forPreviewUrl: previewUrl, using: dependencies) + + return linkPreviewDraft } private static func downloadLink( url urlString: String, remainingRetries: UInt = 3 - ) -> AnyPublisher<(Data, URLResponse), Error> { + ) async throws -> (Data, URLResponse) { Log.verbose("[LinkPreview] Download url: \(urlString)") // let sessionConfiguration = ContentProxy.sessionConfiguration() // Loki: Signal's proxy appears to have been banned by YouTube @@ -380,45 +375,42 @@ public extension LinkPreview { guard var request: URLRequest = URL(string: urlString).map({ URLRequest(url: $0) }), + request.url?.scheme != nil, + (request.url?.host ?? "").isEmpty == false, ContentProxy.configureProxiedRequest(request: &request) - else { - return Fail(error: LinkPreviewError.assertionFailure) - .eraseToAnyPublisher() - } + else { throw LinkPreviewError.assertionFailure } request.setValue(self.userAgentString, forHTTPHeaderField: "User-Agent") // Set a fake value let session: URLSession = URLSession(configuration: sessionConfiguration) - return session - .dataTaskPublisher(for: request) - .mapError { _ -> Error in NetworkError.unknown } // URLError codes are negative values - .tryMap { data, response -> (Data, URLResponse) in - guard let urlResponse: HTTPURLResponse = response as? HTTPURLResponse else { - throw LinkPreviewError.assertionFailure - } - if let contentType: String = urlResponse.allHeaderFields["Content-Type"] as? String { - guard contentType.lowercased().hasPrefix("text/") else { - throw LinkPreviewError.invalidContent - } - } - guard data.count > 0 else { throw LinkPreviewError.invalidContent } - - return (data, response) + do { + let (data, response) = try await session.data(for: request) + + guard let urlResponse: HTTPURLResponse = response as? HTTPURLResponse else { + throw LinkPreviewError.assertionFailure } - .catch { error -> AnyPublisher<(Data, URLResponse), Error> in - guard isRetryable(error: error), remainingRetries > 0 else { - return Fail(error: LinkPreviewError.couldNotDownload) - .eraseToAnyPublisher() + + if let contentType: String = urlResponse.allHeaderFields["Content-Type"] as? String { + guard contentType.lowercased().hasPrefix("text/") else { + throw LinkPreviewError.invalidContent } - - return LinkPreview - .downloadLink( - url: urlString, - remainingRetries: (remainingRetries - 1) - ) } - .eraseToAnyPublisher() + + guard data.count > 0 else { throw LinkPreviewError.invalidContent } + + return (data, response) + } + catch { + guard isRetryable(error: error), remainingRetries > 0 else { + throw LinkPreviewError.couldNotDownload + } + + return try await LinkPreview.downloadLink( + url: urlString, + remainingRetries: (remainingRetries - 1) + ) + } } private static func parseLinkDataAndBuildDraft( @@ -427,51 +419,35 @@ public extension LinkPreview { linkUrlString: String, skipImageDownload: Bool, using dependencies: Dependencies - ) -> AnyPublisher { + ) async throws -> LinkPreviewDraft { + let contents: LinkPreview.Contents = try parse(linkData: linkData, response: response) + let title: String? = contents.title + + /// If we don't want to download the image then just return the non-image content + guard !skipImageDownload else { + return LinkPreviewDraft(urlString: linkUrlString, title: title) + } + do { - let contents = try parse(linkData: linkData, response: response) - let title = contents.title - - // If we don't want to download the image then just return the non-image content - guard !skipImageDownload else { - return Just(LinkPreviewDraft(urlString: linkUrlString, title: title)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } + /// If the image isn't valid then just return the non-image content + let imageUrl: URL = try contents.imageUrl.map({ URL(string: $0) }) ?? { + throw LinkPreviewError.invalidContent + }() + let imageSource: ImageDataManager.DataSource = try await downloadImage( + url: imageUrl, + using: dependencies + ) - // If the image isn't valid then just return the non-image content - guard - let imageUrl: String = contents.imageUrl, - URL(string: imageUrl) != nil, - let imageFileExtension: String = fileExtension(forImageUrl: imageUrl), - let imageUTType: UTType = UTType(sessionFileExtension: imageFileExtension) - else { - return Just(LinkPreviewDraft(urlString: linkUrlString, title: title)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - return LinkPreview - .downloadImage(url: imageUrl, imageUTType: imageUTType, using: dependencies) - .map { imageData -> LinkPreviewDraft in - // We always recompress images to Jpeg - LinkPreviewDraft(urlString: linkUrlString, title: title, jpegImageData: imageData) - } - .catch { _ -> AnyPublisher in - return Just(LinkPreviewDraft(urlString: linkUrlString, title: title)) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } catch { - return Fail(error: error) - .eraseToAnyPublisher() + return LinkPreviewDraft(urlString: linkUrlString, title: title, imageSource: imageSource) + } + catch { + return LinkPreviewDraft(urlString: linkUrlString, title: title) } } private static func parse(linkData: Data, response: URLResponse) throws -> Contents { guard let linkText = String(bytes: linkData, encoding: response.stringEncoding ?? .utf8) else { - print("Could not parse link text.") + Log.verbose("[LinkPreview] Could not parse link text.") throw LinkPreviewError.invalidInput } @@ -500,78 +476,32 @@ public extension LinkPreview { } private static func downloadImage( - url urlString: String, - imageUTType: UTType, + url: URL, using dependencies: Dependencies - ) -> AnyPublisher { - guard - let url = URL(string: urlString), - let assetDescription: ProxiedContentAssetDescription = ProxiedContentAssetDescription( - url: url as NSURL - ) - else { - return Fail(error: LinkPreviewError.invalidInput) - .eraseToAnyPublisher() - } + ) async throws -> ImageDataManager.DataSource { + guard let assetDescription: ProxiedContentAssetDescription = ProxiedContentAssetDescription( + url: url as NSURL + ) else { throw LinkPreviewError.invalidInput } - return dependencies[singleton: .proxiedContentDownloader] - .requestAsset( - assetDescription: assetDescription, - priority: .high, - shouldIgnoreSignalProxy: true - ) - .tryMap { asset, _ -> Data in - let imageSize = MediaUtils.unrotatedSize( - for: asset.filePath, - utType: imageUTType, - sourceFilename: nil, - using: dependencies + do { + let asset: ProxiedContentAsset = try await dependencies[singleton: .proxiedContentDownloader] + .requestAsset( + assetDescription: assetDescription, + priority: .high, + shouldIgnoreSignalProxy: true ) - - guard imageSize.width > 0, imageSize.height > 0 else { - throw LinkPreviewError.invalidContent - } - - // Loki: If it's a GIF then ensure its validity and don't download it as a JPG - if - imageUTType == .gif, - let metadata: MediaUtils.MediaMetadata = MediaUtils.MediaMetadata( - from: asset.filePath, - utType: .gif, - sourceFilename: nil, - using: dependencies - ), - metadata.isValidImage - { - return try Data(contentsOf: URL(fileURLWithPath: asset.filePath)) - } - - guard let data: Data = try? Data(contentsOf: URL(fileURLWithPath: asset.filePath)) else { - throw LinkPreviewError.assertionFailure - } - - guard let srcImage = UIImage(data: data) else { throw LinkPreviewError.invalidContent } - - let maxImageSize: CGFloat = 1024 - let shouldResize = imageSize.width > maxImageSize || imageSize.height > maxImageSize - - guard shouldResize else { - guard let dstData = srcImage.jpegData(compressionQuality: 0.8) else { - throw LinkPreviewError.invalidContent - } - - return dstData - } - - guard - let dstImage = srcImage.resized(maxDimensionPoints: maxImageSize), - let dstData = dstImage.jpegData(compressionQuality: 0.8) - else { throw LinkPreviewError.invalidContent } - - return dstData - } - .mapError { _ -> Error in LinkPreviewError.couldNotDownload } - .eraseToAnyPublisher() + let pendingAttachment: PendingAttachment = PendingAttachment( + source: .media(.url(URL(fileURLWithPath: asset.filePath))), + using: dependencies + ) + let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare( + operations: [.convert(to: .webPLossy(maxDimension: 1024))], + using: dependencies + ) + + return .url(URL(fileURLWithPath: preparedAttachment.filePath)) + } + catch { throw LinkPreviewError.couldNotDownload } } private static func isRetryable(error: Error) -> Bool { diff --git a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift index afdb9b7bfd..32608ed6e7 100644 --- a/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift +++ b/SessionMessagingKit/Jobs/ReuploadUserDisplayPictureJob.swift @@ -60,6 +60,17 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { } } + guard + let filePath: String = try? dependencies[singleton: .displayPictureManager] + .path(for: displayPictureUrl.absoluteString), + dependencies[singleton: .fileManager].fileExists(atPath: filePath) + else { + Log.warn(.cat, "User has display picture but file was not found") + return scheduler.schedule { + success(job, false) + } + } + /// Only try to extend the TTL of the users display pic if enough time has passed since it was last updated let lastUpdated: Date = Date(timeIntervalSince1970: profile.profileLastUpdated ?? 0) @@ -122,8 +133,6 @@ public enum ReuploadUserDisplayPictureJob: JobExecutor { /// Since we made it here it means that refreshing the TTL failed so we may need to reupload the display picture do { - let filePath: String = try dependencies[singleton: .displayPictureManager] - .path(for: displayPictureUrl.absoluteString) let pendingDisplayPicture: PendingAttachment = PendingAttachment( source: .media(.url(URL(fileURLWithPath: filePath))), using: dependencies diff --git a/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift b/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift index 92b540875e..7d28a3df98 100644 --- a/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift +++ b/SessionMessagingKit/Sending & Receiving/Link Previews/LinkPreviewDraft.swift @@ -1,26 +1,22 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUIKit public struct LinkPreviewDraft: Equatable, Hashable { public var urlString: String public var title: String? - public var jpegImageData: Data? + public var imageSource: ImageDataManager.DataSource? - public init(urlString: String, title: String?, jpegImageData: Data? = nil) { + public init(urlString: String, title: String?, imageSource: ImageDataManager.DataSource? = nil) { self.urlString = urlString self.title = title - self.jpegImageData = jpegImageData + self.imageSource = imageSource } public func isValid() -> Bool { - var hasTitle = false - - if let titleValue = title { - hasTitle = titleValue.count > 0 - } - - let hasImage = jpegImageData != nil + let hasTitle = (title == nil || title?.isEmpty == false) + let hasImage: Bool = (imageSource != nil) return (hasTitle || hasImage) } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift index c10460d2f4..ec2aa46540 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift @@ -61,7 +61,7 @@ extension MessageReceiver { /// If it's the `Note to Self` conversation then we want to just delete the interaction if userSessionId.hexString == interactionInfo.threadId { - try Interaction.deleteOne(db, id: interactionInfo.id) + try Interaction.deleteWhere(db, .filter(Interaction.Columns.id == interactionInfo.id)) } /// Can't delete from the legacy group swarm so only bother for contact conversations diff --git a/SessionMessagingKit/Utilities/AttachmentManager.swift b/SessionMessagingKit/Utilities/AttachmentManager.swift index 0908d2de9c..60c6bf85cc 100644 --- a/SessionMessagingKit/Utilities/AttachmentManager.swift +++ b/SessionMessagingKit/Utilities/AttachmentManager.swift @@ -375,15 +375,28 @@ public struct PendingAttachment: Sendable, Equatable, Hashable { sourceFilename: String? = nil, using dependencies: Dependencies ) { - self.source = source - self.sourceFilename = sourceFilename self.metadata = PendingAttachment.metadata( for: source, utType: utType, sourceFilename: sourceFilename, using: dependencies ) + self.sourceFilename = sourceFilename self.existingAttachmentId = nil + + /// To avoid confusion (and reduce bugs related to checking the `source` type) if we are given a `file` source that is + /// actually media, then convert it to a `media` source + switch (source, metadata) { + case (.file(let url), .media(let mediaMetadata)): + if let utType: UTType = mediaMetadata.utType, utType.isVideo { + self.source = .media(.videoUrl(url, utType, sourceFilename, dependencies[singleton: .attachmentManager])) + } + else { + self.source = .media(.url(url)) + } + + default: self.source = source + } } public init( @@ -496,10 +509,6 @@ public extension PendingAttachment { return .media(.url(url)) } - public static func media(_ identifier: String, _ data: Data) -> DataSource { - return .media(.data(identifier, data)) - } - fileprivate var visualMediaSource: ImageDataManager.DataSource? { switch self { case .media(let source): return source @@ -613,49 +622,103 @@ public extension PendingAttachment { } enum ConversionFormat: Sendable, Equatable, Hashable { + fileprivate static let defaultWebPCompressionQuality: CGFloat = 0.8 + fileprivate static let defaultWebPCompressionEffort: CGFloat = 0.25 + fileprivate static let defaultGifCompressionQuality: CGFloat = 0.8 + fileprivate static let defaultResizeMode: UIImage.ResizeMode = .fit + case current case mp4 - case png(maxDimension: CGFloat?, cropRect: CGRect?) + case png(maxDimension: CGFloat?, cropRect: CGRect?, resizeMode: UIImage.ResizeMode) /// A `compressionQuality` value of `0` gives the smallest size and `1` the largest - case webPLossy(maxDimension: CGFloat?, cropRect: CGRect?, compressionQuality: CGFloat) + case webPLossy(maxDimension: CGFloat?, cropRect: CGRect?, resizeMode: UIImage.ResizeMode, compressionQuality: CGFloat) /// A `compressionEffort` value of `0` is the fastest (but gives larger files) and a value of `1` is the slowest but compresses the most - case webPLossless(maxDimension: CGFloat?, cropRect: CGRect?, compressionEffort: CGFloat) + case webPLossless(maxDimension: CGFloat?, cropRect: CGRect?, resizeMode: UIImage.ResizeMode, compressionEffort: CGFloat) - case gif(maxDimension: CGFloat?, cropRect: CGRect?, compressionQuality: CGFloat) + case gif(maxDimension: CGFloat?, cropRect: CGRect?, resizeMode: UIImage.ResizeMode, compressionQuality: CGFloat) - public static var png: ConversionFormat { .png(maxDimension: nil, cropRect: nil) } - public static func png(maxDimension: CGFloat?) -> ConversionFormat { - .png(maxDimension: maxDimension, cropRect: nil) + public static var png: ConversionFormat { + .png( + maxDimension: nil, + cropRect: nil, + resizeMode: defaultResizeMode + ) } - public static func png(cropRect: CGRect?) -> ConversionFormat { - .png(maxDimension: nil, cropRect: cropRect) + public static func png( + maxDimension: CGFloat? = nil, + cropRect: CGRect? = nil, + resizeMode: UIImage.ResizeMode? = nil + ) -> ConversionFormat { + return .png( + maxDimension: maxDimension, + cropRect: cropRect, + resizeMode: (resizeMode ?? defaultResizeMode) + ) } - - fileprivate static let defaultWebPCompressionQuality: CGFloat = 0.8 - fileprivate static let defaultWebPCompressionEffort: CGFloat = 0.25 - + public static var webPLossy: ConversionFormat { - .webPLossy(maxDimension: nil, cropRect: nil, compressionQuality: defaultWebPCompressionQuality) + .webPLossy( + maxDimension: nil, + cropRect: nil, + resizeMode: defaultResizeMode, + compressionQuality: defaultWebPCompressionQuality + ) } - public static func webPLossy(maxDimension: CGFloat? = nil, cropRect: CGRect? = nil) -> ConversionFormat { - .webPLossy(maxDimension: maxDimension, cropRect: cropRect, compressionQuality: defaultWebPCompressionQuality) + public static func webPLossy( + maxDimension: CGFloat? = nil, + cropRect: CGRect? = nil, + resizeMode: UIImage.ResizeMode? = nil + ) -> ConversionFormat { + return .webPLossy( + maxDimension: maxDimension, + cropRect: cropRect, + resizeMode: (resizeMode ?? defaultResizeMode), + compressionQuality: defaultWebPCompressionQuality + ) } public static var webPLossless: ConversionFormat { - .webPLossless(maxDimension: nil, cropRect: nil, compressionEffort: defaultWebPCompressionEffort) + .webPLossless( + maxDimension: nil, + cropRect: nil, + resizeMode: defaultResizeMode, + compressionEffort: defaultWebPCompressionEffort + ) } - public static func webPLossless(maxDimension: CGFloat? = nil, cropRect: CGRect? = nil) -> ConversionFormat { - .webPLossless(maxDimension: maxDimension, cropRect: cropRect, compressionEffort: defaultWebPCompressionEffort) + public static func webPLossless( + maxDimension: CGFloat? = nil, + cropRect: CGRect? = nil, + resizeMode: UIImage.ResizeMode? = nil + ) -> ConversionFormat { + return .webPLossless( + maxDimension: maxDimension, + cropRect: cropRect, + resizeMode: (resizeMode ?? defaultResizeMode), + compressionEffort: defaultWebPCompressionEffort + ) } - fileprivate static let defaultGifCompressionQuality: CGFloat = 0.8 public static var gif: ConversionFormat { - .gif(maxDimension: nil, cropRect: nil, compressionQuality: defaultGifCompressionQuality) + .gif( + maxDimension: nil, + cropRect: nil, + resizeMode: defaultResizeMode, + compressionQuality: defaultGifCompressionQuality + ) } - public static func gif(maxDimension: CGFloat? = nil, cropRect: CGRect? = nil) -> ConversionFormat { - .gif(maxDimension: maxDimension, cropRect: cropRect, compressionQuality: defaultGifCompressionQuality) + public static func gif( + maxDimension: CGFloat? = nil, + cropRect: CGRect? = nil, + resizeMode: UIImage.ResizeMode? = nil + ) -> ConversionFormat { + return .gif( + maxDimension: maxDimension, + cropRect: cropRect, + resizeMode: (resizeMode ?? defaultResizeMode), + compressionQuality: defaultGifCompressionQuality + ) } var webPIsLossless: Bool { @@ -754,10 +817,10 @@ public extension PendingAttachment { result.cropRect ) - case .png(let maxDimension, let cropRect), - .webPLossy(let maxDimension, let cropRect, _), - .webPLossless(let maxDimension, let cropRect, _), - .gif(let maxDimension, let cropRect, _): + case .png(let maxDimension, let cropRect, _), + .webPLossy(let maxDimension, let cropRect, _, _), + .webPLossless(let maxDimension, let cropRect, _, _), + .gif(let maxDimension, let cropRect, _, _): let finalMax: CGFloat? let finalCrop: CGRect? let validCurrentCrop: CGRect? = (result.cropRect != nil && result.cropRect != fullRect ? @@ -1036,7 +1099,15 @@ public extension PendingAttachment { let destination = CGImageDestinationCreateWithData(outputData as CFMutableData, sourceType as CFString, 1, nil) else { throw AttachmentError.invalidData } - CGImageDestinationAddImage(destination, cgImage, nil) + /// Preserve orientation metadata + let properties: [String: Any]? = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] + let orientation: Any? = properties?[kCGImagePropertyOrientation as String] + let imageProperties: [CFString: Any] = ( + orientation.map { [kCGImagePropertyOrientation: $0] } ?? + [:] + ) + + CGImageDestinationAddImage(destination, cgImage, imageProperties as CFDictionary) guard CGImageDestinationFinalize(destination) else { throw AttachmentError.couldNotResizeImage @@ -1202,7 +1273,7 @@ public extension PendingAttachment { }() let imageSize: CGSize? = { switch metadata { - case .media(let mediaMetadata): return mediaMetadata.unrotatedSize + case .media(let mediaMetadata): return mediaMetadata.displaySize case .file, .none: return nil } }() @@ -1319,17 +1390,22 @@ public extension PendingAttachment { /// Ensure the target format is an image format we support let targetMaxDimension: CGFloat? let targetCropRect: CGRect? + let targetResizeMode: UIImage.ResizeMode switch format { - case .png(let maxDimension, let cropRect), .gif(let maxDimension, let cropRect, _), .webPLossy(let maxDimension, let cropRect, _), - .webPLossless(let maxDimension, let cropRect, _): + case .png(let maxDimension, let cropRect, let resizeMode), + .gif(let maxDimension, let cropRect, let resizeMode, _), + .webPLossy(let maxDimension, let cropRect, let resizeMode, _), + .webPLossless(let maxDimension, let cropRect, let resizeMode, _): targetMaxDimension = maxDimension targetCropRect = cropRect + targetResizeMode = resizeMode break case .current: targetMaxDimension = nil targetCropRect = nil + targetResizeMode = ConversionFormat.defaultResizeMode break case .mp4: throw AttachmentError.couldNotConvert @@ -1430,7 +1506,8 @@ public extension PendingAttachment { try Task.checkCancellation() let scaledImage: CGImage = cgImage.resized( - toFillPixelSize: targetSize, + toPixelSize: targetSize, + mode: targetResizeMode, opaque: isOpaque, cropRect: targetCropRect, orientation: (metadata.orientation ?? .up) @@ -1464,7 +1541,7 @@ public extension PendingAttachment { filePath: filePath ) - case .gif(_, _, let quality): + case .gif(_, _, _, let quality): try PendingAttachment.writeFramesAsGifToFile( frames: frames, metadata: metadata, @@ -1472,7 +1549,7 @@ public extension PendingAttachment { filePath: filePath ) - case .webPLossy(_, _, let quality), .webPLossless(_, _, let quality): + case .webPLossy(_, _, _, let quality), .webPLossless(_, _, _, let quality): try PendingAttachment.writeFramesAsWebPToFile( frames: frames, metadata: metadata, diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index 69421b958b..151479c9ce 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -191,7 +191,8 @@ public class DisplayPictureManager { return [ .convert(to: .webPLossy( maxDimension: DisplayPictureManager.maxDimension, - cropRect: cropRect + cropRect: cropRect, + resizeMode: .fill )), .stripImageMetadata ] @@ -204,8 +205,8 @@ public class DisplayPictureManager { /// **Note:** The `UTType` check behaves as an `OR` return attachment.needsPreparation( operations: [ - .convert(to: .webPLossy(maxDimension: DisplayPictureManager.maxDimension)), - .convert(to: .gif(maxDimension: DisplayPictureManager.maxDimension)) + .convert(to: .webPLossy(maxDimension: DisplayPictureManager.maxDimension, resizeMode: .fill)), + .convert(to: .gif(maxDimension: DisplayPictureManager.maxDimension, resizeMode: .fill)) ] ) } @@ -314,7 +315,8 @@ public class DisplayPictureManager { operations: [ .convert(to: .gif( maxDimension: DisplayPictureManager.maxDimension, - cropRect: cropRect + cropRect: cropRect, + resizeMode: .fill )), .stripImageMetadata ], diff --git a/SessionNetworkingKit/Types/ProxiedContentDownloader.swift b/SessionNetworkingKit/Types/ProxiedContentDownloader.swift index 6bff42eac9..0939623ca2 100644 --- a/SessionNetworkingKit/Types/ProxiedContentDownloader.swift +++ b/SessionNetworkingKit/Types/ProxiedContentDownloader.swift @@ -12,6 +12,12 @@ public enum ProxiedContentRequestPriority: Equatable { case low, high } +// MARK: - Log.Category + +private extension Log.Category { + static let cat: Log.Category = .create("ProxiedContentDownloader", defaultLevel: .off) +} + // MARK: - Singleton public extension Singleton { @@ -154,8 +160,8 @@ public class ProxiedContentAssetRequest: Equatable { // Exactly one of success or failure should be called once, // on the main thread _unless_ this request is cancelled before // the request succeeds or fails. - private var success: ((ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void)? - private var failure: ((ProxiedContentAssetRequest) -> Void)? + private var success: (@MainActor (ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void)? + private var failure: (@MainActor (ProxiedContentAssetRequest) -> Void)? var shouldIgnoreSignalProxy = false var wasCancelled = false @@ -176,8 +182,8 @@ public class ProxiedContentAssetRequest: Equatable { init( assetDescription: ProxiedContentAssetDescription, priority: ProxiedContentRequestPriority, - success: @escaping ((ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void), - failure: @escaping ((ProxiedContentAssetRequest) -> Void), + success: @escaping (@MainActor (ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void), + failure: @escaping (@MainActor (ProxiedContentAssetRequest) -> Void), using dependencies: Dependencies ) { self.dependencies = dependencies @@ -340,17 +346,21 @@ public class ProxiedContentAssetRequest: Equatable { } public func requestDidSucceed(asset: ProxiedContentAsset) { - success?(self, asset) - + let callback: (@MainActor (ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void)? = success + // Only one of the callbacks should be called, and only once. clearCallbacks() + + Task { @MainActor in callback?(self, asset) } } public func requestDidFail() { - failure?(self) - + let callback: (@MainActor (ProxiedContentAssetRequest) -> Void)? = failure + // Only one of the callbacks should be called, and only once. clearCallbacks() + + Task { @MainActor in callback?(self) } } public static func == (lhs: ProxiedContentAssetRequest, rhs: ProxiedContentAssetRequest) -> Bool { @@ -482,12 +492,12 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio // which case the ProxiedContentAssetRequest parameter will be nil. public func requestAsset(assetDescription: ProxiedContentAssetDescription, priority: ProxiedContentRequestPriority, - success:@escaping ((ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void), - failure:@escaping ((ProxiedContentAssetRequest) -> Void), + success:@escaping (@MainActor (ProxiedContentAssetRequest?, ProxiedContentAsset) -> Void), + failure:@escaping (@MainActor (ProxiedContentAssetRequest) -> Void), shouldIgnoreSignalProxy: Bool = false) -> ProxiedContentAssetRequest? { if let asset = assetMap.get(key: assetDescription.url) { // Synchronous cache hit. - success(nil, asset) + Task { @MainActor in success(nil, asset) } return nil } @@ -636,9 +646,8 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio // // stringlint:ignore_contents @MainActor private func processRequestQueueSync() { - guard let assetRequest = popNextAssetRequest() else { - return - } + guard let assetRequest = popNextAssetRequest() else { return } + guard !assetRequest.wasCancelled else { // Discard the cancelled asset request and try again. removeAssetRequestFromQueue(assetRequest: assetRequest) @@ -698,7 +707,7 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio // Start a download task. guard let assetSegment = assetRequest.firstWaitingSegment() else { - print("queued asset request does not have a waiting segment.") + Log.verbose(.cat, "queued asset request does not have a waiting segment.") return } assetSegment.state = .downloading @@ -739,13 +748,13 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio } guard let data = data, data.count > 0 else { - print("Asset size response missing data.") + Log.debug(.cat, "Asset size response missing data.") assetRequest.state = .failed self.assetRequestDidFail(assetRequest: assetRequest) return } guard let httpResponse = response as? HTTPURLResponse else { - print("Asset size response is invalid.") + Log.debug(.cat, "Asset size response is invalid.") assetRequest.state = .failed self.assetRequestDidFail(assetRequest: assetRequest) return @@ -753,7 +762,7 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio var firstContentRangeString: String? for header in httpResponse.allHeaderFields.keys { guard let headerString = header as? String else { - print("Invalid header: \(header)") + Log.debug(.cat, "Invalid header: \(header)") continue } if headerString.lowercased() == "content-range" { // stringlint:ignore @@ -761,7 +770,7 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio } } guard let contentRangeString = firstContentRangeString else { - print("Asset size response is missing content range.") + Log.debug(.cat, "Asset size response is missing content range.") assetRequest.state = .failed self.assetRequestDidFail(assetRequest: assetRequest) return @@ -778,13 +787,13 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio } guard contentLengthString.count > 0, let contentLength = Int(contentLengthString) else { - print("Asset size response has unparsable content length.") + Log.debug(.cat, "Asset size response has unparsable content length.") assetRequest.state = .failed self.assetRequestDidFail(assetRequest: assetRequest) return } guard contentLength > 0 else { - print("Asset size response has invalid content length.") + Log.debug(.cat, "Asset size response has invalid content length.") assetRequest.state = .failed self.assetRequestDidFail(assetRequest: assetRequest) return @@ -942,3 +951,47 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio } } } + +extension ProxiedContentDownloader { + /// Async/await version of requestAsset + public func requestAsset( + assetDescription: ProxiedContentAssetDescription, + priority: ProxiedContentRequestPriority, + shouldIgnoreSignalProxy: Bool = false + ) async throws -> ProxiedContentAsset { + if let asset: ProxiedContentAsset = assetMap.get(key: assetDescription.url) { + return asset + } + + return try await withCheckedThrowingContinuation { continuation in + var hasResumed: Bool = false + let lock: NSLock = NSLock() + let safeResume: (Result) -> Void = { result in + lock.lock() + defer { lock.unlock() } + + guard !hasResumed else { return } + hasResumed = true + + switch result { + case .success(let asset): continuation.resume(returning: asset) + case .failure(let error): continuation.resume(throwing: error) + } + } + + let request: ProxiedContentAssetRequest? = requestAsset( + assetDescription: assetDescription, + priority: priority, + success: { _, asset in safeResume(.success(asset)) }, + failure: { _ in safeResume(.failure(NetworkError.invalidResponse)) }, + shouldIgnoreSignalProxy: shouldIgnoreSignalProxy + ) + + // If the task is already cancelled, cancel the request immediately + if Task.isCancelled { + request?.cancel() + safeResume(.failure(CancellationError())) + } + } + } +} diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index e23b225d62..f1dae8efd7 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -36,7 +36,7 @@ final class ShareNavController: UINavigationController { /// This should be the first thing we do (Note: If you leave the share context and return to it the context will already exist, trying /// to override it results in the share context crashing so ensure it doesn't exist first) - if !dependencies[singleton: .appContext].isValid { + if !dependencies.has(singleton: .appContext) { dependencies.set(singleton: .appContext, to: ShareAppExtensionContext(rootViewController: self, using: dependencies)) Dependencies.setIsRTLRetriever(requiresMainThread: false) { ShareAppExtensionContext.determineDeviceRTL() } } diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 6dbf7c0196..5c55187216 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -314,8 +314,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView if let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft { linkPreviewPreparedAttachment = try? await LinkPreview.prepareAttachmentIfPossible( urlString: linkPreviewDraft.urlString, - imageData: linkPreviewDraft.jpegImageData, - type: .jpeg, + imageSource: linkPreviewDraft.imageSource, using: dependencies ) } diff --git a/SessionUIKit/Types/ImageDataManager.swift b/SessionUIKit/Types/ImageDataManager.swift index a1e9dc3edb..740f4cf402 100644 --- a/SessionUIKit/Types/ImageDataManager.swift +++ b/SessionUIKit/Types/ImageDataManager.swift @@ -161,14 +161,21 @@ public actor ImageDataManager: ImageDataManagerType { /// Custom handle `urlThumbnail` generation case .urlThumbnail(let url, let size, let thumbnailManager): + let maxDimensionInPixels: CGFloat = await size.pixelDimension() + let flooredPixels: Int = Int(floor(maxDimensionInPixels)) + /// If we had already generated a thumbnail then use that if let existingThumbnailSource: ImageDataManager.DataSource = thumbnailManager .existingThumbnail(name: url.lastPathComponent, size: size), let source: CGImageSource = existingThumbnailSource.createImageSource() { - /// Thumbnails will always have their orientation removed - return await createBuffer(source, orientation: .up) + return await createBuffer( + source, + orientation: .up, /// Thumbnails will always have their orientation removed + sourceWidth: flooredPixels, + sourceHeight: flooredPixels + ) } /// If not then check whether there would be any benefit in creating a thumbnail @@ -182,9 +189,6 @@ public actor ImageDataManager: ImageDataManagerType { else { return nil } /// If the source is smaller than the target thumbnail size then we should just return the target directly - let maxDimensionInPixels: CGFloat = await size.pixelDimension() - let flooredPixels: Int = Int(floor(maxDimensionInPixels)) - guard sourceWidth > flooredPixels || sourceHeight > flooredPixels else { return await processSource(.url(url)) } @@ -193,7 +197,9 @@ public actor ImageDataManager: ImageDataManagerType { guard let result: FrameBuffer = await createBuffer( newThumbnailSource, - orientation: orientation(from: properties), + orientation: .up, /// Thumbnails will always have their orientation removed + sourceWidth: sourceWidth, + sourceHeight: sourceHeight, maxDimensionInPixels: maxDimensionInPixels, customLoaderGenerator: { /// If we had already generated a thumbnail then use that @@ -202,8 +208,12 @@ public actor ImageDataManager: ImageDataManagerType { .existingThumbnail(name: url.lastPathComponent, size: size), let source: CGImageSource = existingThumbnailSource.createImageSource() { - /// Thumbnails will always have their orientation removed - let existingThumbnailBuffer: FrameBuffer? = await createBuffer(source, orientation: .up) + let existingThumbnailBuffer: FrameBuffer? = await createBuffer( + source, + orientation: .up, /// Thumbnails will always have their orientation removed + sourceWidth: flooredPixels, + sourceHeight: flooredPixels + ) return await existingThumbnailBuffer?.generateLoadClosure?() } @@ -265,7 +275,12 @@ public actor ImageDataManager: ImageDataManagerType { sourceHeight < ImageDataManager.DataSource.maxValidDimension else { return nil } - return await createBuffer(source, orientation: orientation(from: properties)) + return await createBuffer( + source, + orientation: orientation(from: properties), + sourceWidth: sourceWidth, + sourceHeight: sourceHeight + ) } private static func orientation(from properties: [String: Any]) -> UIImage.Orientation { @@ -282,6 +297,8 @@ public actor ImageDataManager: ImageDataManagerType { private static func createBuffer( _ source: CGImageSource, orientation: UIImage.Orientation, + sourceWidth: Int, + sourceHeight: Int, maxDimensionInPixels: CGFloat? = nil, customLoaderGenerator: (() async -> AsyncLoadStream.Loader?)? = nil ) async -> FrameBuffer? { @@ -297,7 +314,34 @@ public actor ImageDataManager: ImageDataManagerType { source, index: 0, maxDimensionInPixels: maxDimensionInPixels - ), + ) + else { return nil } + + /// The share extension has limited RAM (~120Mb on an iPhone X) and pre-decoding an image results in approximately `3x` + /// the RAM usage of the standard lazy loading (as buffers need to be allocated and image data copied during the pre-decode), + /// in order to avoid this we check if the estimated pre-decoded image RAM usage is smaller than `80%` of the currently + /// available RAM and if not we just rely on lazy `UIImage` loading and the OS + let hasEnoughMemoryToPreDecode: Bool = { + #if targetEnvironment(simulator) + /// On the simulator `os_proc_available_memory` seems to always return `0` so just assume we have enough memort + return true + #else + let estimatedMemorySize: Int = (sourceWidth * sourceHeight * 4) + let estimatedMemorySizeToLoad: Int = (estimatedMemorySize * 3) + let currentAvailableMemory: Int = os_proc_available_memory() + + return (estimatedMemorySizeToLoad < Int(floor(CGFloat(currentAvailableMemory) * 0.8))) + #endif + }() + + guard hasEnoughMemoryToPreDecode else { + return FrameBuffer( + image: UIImage(cgImage: firstFrameCgImage, scale: 1, orientation: orientation) + ) + } + + /// Otherwise we want to "predecode" the first (and other) frames while in the background to reduce the load on the UI thread + guard let firstFrameContext: CGContext = createDecodingContext( width: firstFrameCgImage.width, height: firstFrameCgImage.height diff --git a/SessionUtilitiesKit/Media/MediaUtils.swift b/SessionUtilitiesKit/Media/MediaUtils.swift index 0f490c347f..d4a4c494c3 100644 --- a/SessionUtilitiesKit/Media/MediaUtils.swift +++ b/SessionUtilitiesKit/Media/MediaUtils.swift @@ -131,7 +131,7 @@ public enum MediaUtils { return (duration == 0) } - public var unrotatedSize: CGSize { + public var displaySize: CGSize { /// If the metadata doesn't have an orientation then don't rotate the size (WebP and videos shouldn't have orientations) guard let orientation: UIImage.Orientation = orientation else { return pixelSize } @@ -371,7 +371,7 @@ public enum MediaUtils { return result } - public static func unrotatedSize( + public static func displaySize( for path: String, utType: UTType?, sourceFilename: String?, @@ -386,7 +386,7 @@ public enum MediaUtils { ) else { return .zero } - return metadata.unrotatedSize + return metadata.displaySize } private static func getFrameDuration(from source: CGImageSource, at index: Int) -> TimeInterval { diff --git a/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift b/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift index 6124c55f59..df8775ed80 100644 --- a/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift @@ -3,33 +3,35 @@ import UIKit.UIImage public extension UIImage { + enum ResizeMode: Sendable, Equatable, Hashable { + case fill /// Aspect-fill (crops to fill size) + case fit /// Aspect-fit (fits within size, may have empty space) + } + func normalizedImage() -> UIImage { guard imageOrientation != .up else { return self } + guard let cgImage: CGImage = self.cgImage else { return self } - // The actual resize: draw the image on a new context, applying a transform matrix - let bounds: CGRect = CGRect(x: 0, y: 0, width: size.width, height: size.height) - let format = UIGraphicsImageRendererFormat() - format.scale = self.scale - format.opaque = false - - // Note: We use the UIImage.draw function here instead of using the CGContext because UIImage - // automatically deals with orientations so we don't have to - return UIGraphicsImageRenderer(bounds: bounds, format: format).image { _ in - self.draw(in: bounds) - } + return UIImage( + cgImage: cgImage.normalized(orientation: imageOrientation), + scale: self.scale, + orientation: .up + ) } /// This function can be used to resize an image to a different size, it **should not** be used within the UI for rendering smaller /// images as it's fairly inefficient (instead the image should be contained within another view and sized explicitly that way) func resized( - toFillPixelSize dstSize: CGSize, + toPixelSize dstSize: CGSize, + mode: ResizeMode = .fill, opaque: Bool = false, cropRect: CGRect? = nil ) -> UIImage { guard let imgRef: CGImage = self.cgImage else { return self } let result: CGImage = imgRef.resized( - toFillPixelSize: dstSize, + toPixelSize: dstSize, + mode: mode, opaque: opaque, cropRect: cropRect, orientation: self.imageOrientation @@ -37,143 +39,36 @@ public extension UIImage { return UIImage(cgImage: result, scale: 1.0, orientation: .up) } - - /// This function can be used to resize an image to a different size, it **should not** be used within the UI for rendering smaller - /// images as it's fairly inefficient (instead the image should be contained within another view and sized explicitly that way) - func resized(maxDimensionPoints: CGFloat) -> UIImage? { - guard let imgRef: CGImage = self.cgImage else { return nil } - - let originalSize: CGSize = self.size - let maxOriginalDimensionPoints: CGFloat = max(originalSize.width, originalSize.height) - - guard originalSize.width > 0 && originalSize.height > 0 else { return nil } - - // Don't bother scaling an image that is already smaller than the max dimension. - guard maxOriginalDimensionPoints > maxDimensionPoints else { return self } - - let thumbnailSize: CGSize = { - guard originalSize.width <= originalSize.height else { - return CGSize( - width: maxDimensionPoints, - height: round(maxDimensionPoints * originalSize.height / originalSize.width) - ) - } - - return CGSize( - width: round(maxDimensionPoints * originalSize.width / originalSize.height), - height: maxDimensionPoints - ) - }() - - guard thumbnailSize.width > 0 && thumbnailSize.height > 0 else { return nil } - - // the below values are regardless of orientation : for UIImages from Camera, width>height (landscape) - // - // Note: Not equivalent to self.size (which is dependant on the imageOrientation)! - let srcSize: CGSize = CGSize(width: imgRef.width, height: imgRef.height) - var dstSize: CGSize = thumbnailSize - - // Don't resize if we already meet the required destination size - guard dstSize != srcSize else { return self } - - let scaleRatio: CGFloat = (dstSize.width / srcSize.width) - let orient: UIImage.Orientation = self.imageOrientation - var transform: CGAffineTransform = .identity - - switch orient { - case .up: break // EXIF = 1 - case .upMirrored: // EXIF = 2 - transform = CGAffineTransform(translationX: srcSize.width, y: 0) - .scaledBy(x: -1, y: 1) - - case .down: // EXIF = 3 - transform = CGAffineTransform(translationX: srcSize.width, y: srcSize.height) - .rotated(by: CGFloat.pi) - - case .downMirrored: // EXIF = 4 - transform = CGAffineTransform(translationX: 0, y: srcSize.height) - .scaledBy(x: 1, y: -1) - - case .leftMirrored: // EXIF = 5 - dstSize = CGSize(width: dstSize.height, height: dstSize.width) - transform = CGAffineTransform(translationX: srcSize.height, y: srcSize.width) - .scaledBy(x: -1, y: 1) - .rotated(by: (3 * (CGFloat.pi / 2))) - - case .left: // EXIF = 6 - dstSize = CGSize(width: dstSize.height, height: dstSize.width) - transform = CGAffineTransform(translationX: 0, y: srcSize.width) - .scaledBy(x: -1, y: 1) - .rotated(by: (3 * (CGFloat.pi / 2))) - - case .rightMirrored: // EXIF = 7 - dstSize = CGSize(width: dstSize.height, height: dstSize.width) - transform = CGAffineTransform(scaleX: -1, y: 1) - .rotated(by: (CGFloat.pi / 2)) - - case .right: // EXIF = 8 - dstSize = CGSize(width: dstSize.height, height: dstSize.width) - transform = CGAffineTransform(translationX: srcSize.height, y: 0) - .rotated(by: (CGFloat.pi / 2)) - - @unknown default: return nil - } - - // The actual resize: draw the image on a new context, applying a transform matrix - let bounds: CGRect = CGRect(x: 0, y: 0, width: dstSize.width, height: dstSize.height) - let format = UIGraphicsImageRendererFormat() - format.scale = self.scale - format.opaque = false - - let renderer: UIGraphicsImageRenderer = UIGraphicsImageRenderer(bounds: bounds, format: format) - - return renderer.image { rendererContext in - rendererContext.cgContext.interpolationQuality = .high - - switch orient { - case .right, .left: - rendererContext.cgContext.scaleBy(x: -scaleRatio, y: scaleRatio) - rendererContext.cgContext.translateBy(x: -srcSize.height, y: 0) - - default: - rendererContext.cgContext.scaleBy(x: scaleRatio, y: -scaleRatio) - rendererContext.cgContext.translateBy(x: 0, y: -srcSize.height) - } - - rendererContext.cgContext.concatenate(transform) - - // we use srcSize (and not dstSize) as the size to specify is in user space (and we use the CTM to apply a - // scaleRatio) - rendererContext.cgContext.draw( - imgRef, - in: CGRect(x: 0, y: 0, width: srcSize.width, height: srcSize.height), - byTiling: false - ) - } - } } public extension CGImage { + func normalized(orientation: UIImage.Orientation) -> CGImage { + guard orientation != .up else { return self } + + let pixelSize: CGSize = CGSize(width: self.width, height: self.height) + + return self.resized( + toPixelSize: pixelSize, + mode: .fit, + opaque: (self.alphaInfo == .none || self.alphaInfo == .noneSkipFirst), + cropRect: nil, + orientation: orientation + ) + } + func resized( - toFillPixelSize dstSize: CGSize, + toPixelSize dstSize: CGSize, + mode: UIImage.ResizeMode = .fill, opaque: Bool = false, cropRect: CGRect? = nil, orientation: UIImage.Orientation = .up ) -> CGImage { // Determine actual dimensions accounting for orientation - let srcSize: CGSize - let needsRotation: Bool - - switch orientation { - case .left, .leftMirrored, .right, .rightMirrored: - // 90° or 270° rotation - swap width/height - srcSize = CGSize(width: self.height, height: self.width) - needsRotation = true - - default: - srcSize = CGSize(width: self.width, height: self.height) - needsRotation = (orientation != .up) - } + let needsRotation: Bool = [.left, .leftMirrored, .right, .rightMirrored].contains(orientation) + let srcSize: CGSize = (needsRotation ? + CGSize(width: self.height, height: self.width) : + CGSize(width: self.width, height: self.height) + ) // Calculate what portion we're rendering (in oriented coordinate space) let sourceRect: CGRect @@ -191,134 +86,159 @@ public extension CGImage { let srcAspect: CGFloat = (srcSize.width / srcSize.height) let dstAspect: CGFloat = (dstSize.width / dstSize.height) - if srcAspect > dstAspect { - // Source is wider - crop sides - let targetWidth: CGFloat = (srcSize.height * dstAspect) - sourceRect = CGRect( - x: ((srcSize.width - targetWidth) / 2), - y: 0, - width: targetWidth, - height: srcSize.height - ) - } else { - // Source is taller - crop top/bottom - let targetHeight: CGFloat = (srcSize.width / dstAspect) - sourceRect = CGRect( - x: 0, - y: ((srcSize.height - targetHeight) / 2), - width: srcSize.width, - height: targetHeight - ) + switch mode { + case .fill: + // Aspect-fill: crop to fill destination + if srcAspect > dstAspect { + // Source is wider - crop sides + let targetWidth: CGFloat = (srcSize.height * dstAspect) + sourceRect = CGRect( + x: ((srcSize.width - targetWidth) / 2), + y: 0, + width: targetWidth, + height: srcSize.height + ) + } else { + // Source is taller - crop top/bottom + let targetHeight: CGFloat = (srcSize.width / dstAspect) + sourceRect = CGRect( + x: 0, + y: ((srcSize.height - targetHeight) / 2), + width: srcSize.width, + height: targetHeight + ) + } + + case .fit: + // Aspect-fit: use entire source, will fit within destination + sourceRect = CGRect(origin: .zero, size: srcSize) } } - // Calculate final size - never scale up + // Calculate final size let finalSize: CGSize - if sourceRect.width <= dstSize.width && sourceRect.height <= dstSize.height { - finalSize = sourceRect.size - } else { - finalSize = dstSize + switch mode { + case .fill: + // Never scale up + if sourceRect.width <= dstSize.width && sourceRect.height <= dstSize.height { + finalSize = sourceRect.size + } else { + finalSize = dstSize + } + + case .fit: + if sourceRect.width <= dstSize.width && sourceRect.height <= dstSize.height { + // Already fits - use original size + finalSize = sourceRect.size + } else { + // Needs scaling down - fit within destination bounds + let srcAspect: CGFloat = (sourceRect.width / sourceRect.height) + let dstAspect: CGFloat = (dstSize.width / dstSize.height) + + if srcAspect > dstAspect { + // Width constrained + finalSize = CGSize( + width: dstSize.width, + height: (dstSize.width / srcAspect) + ) + } else { + // Height constrained + finalSize = CGSize( + width: (dstSize.height * srcAspect), + height: dstSize.height + ) + } + } } // Check if any processing is needed - if !needsRotation && sourceRect == CGRect(origin: .zero, size: srcSize) && finalSize == srcSize { + if orientation == .up && sourceRect == CGRect(origin: .zero, size: srcSize) && finalSize == srcSize { // No processing needed - return original return self } // Render with orientation transform - let bounds: CGRect = CGRect(x: 0, y: 0, width: finalSize.width, height: finalSize.height) - let colorSpace = self.colorSpace ?? CGColorSpaceCreateDeviceRGB() - let bitmapInfo = (opaque ? - CGImageAlphaInfo.noneSkipFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue : - CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue + let bitmapInfo: UInt32 + let colorSpace = (self.colorSpace ?? CGColorSpaceCreateDeviceRGB()) + let scale: CGFloat = (mode == .fill ? + max(finalSize.width / sourceRect.width, finalSize.height / sourceRect.height) : + min(finalSize.width / sourceRect.width, finalSize.height / sourceRect.height) ) + let physicalSize = CGSize(width: self.width, height: self.height) + let drawRect: CGRect = CGRect( + x: -(sourceRect.origin.x * scale), + y: -(sourceRect.origin.y * scale), + width: (physicalSize.width * scale), + height: (physicalSize.height * scale) + ) + + if colorSpace.model == .monochrome { + bitmapInfo = (opaque ? + CGImageAlphaInfo.none.rawValue : + CGImageAlphaInfo.alphaOnly.rawValue + ) + } else { + // RGB/RGBA context + bitmapInfo = (opaque ? + CGImageAlphaInfo.noneSkipFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue : + CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue + ) + } guard let ctx: CGContext = CGContext( data: nil, width: Int(finalSize.width), height: Int(finalSize.height), bitsPerComponent: 8, - bytesPerRow: Int(finalSize.width) * 4, + bytesPerRow: 0, // Let the system calculate space: colorSpace, bitmapInfo: bitmapInfo ) else { return self } ctx.interpolationQuality = .high + ctx.applyOrientationTransform(orientation: orientation, size: finalSize) + ctx.draw(self, in: drawRect, byTiling: false) - if needsRotation { - ctx.translateBy(x: finalSize.width / 2, y: finalSize.height / 2) - - switch orientation { - case .down, .downMirrored: ctx.rotate(by: .pi) - case .left, .leftMirrored: ctx.rotate(by: .pi / 2) - case .right, .rightMirrored: ctx.rotate(by: -.pi / 2) - default: break - } - - // Handle mirroring - let mirroredSet: Set = [.left, .leftMirrored, .right, .rightMirrored] - - if mirroredSet.contains(orientation) { - ctx.scaleBy(x: -1, y: 1) - } - - ctx.translateBy(x: -finalSize.width / 2, y: -finalSize.height / 2) - } - - // Determine if we actually need to crop - let imageToDraw: CGImage = { - guard sourceRect != CGRect(origin: .zero, size: srcSize) else { - return self - } - - // Convert crop rect to pixel coordinates and crop - let pixelCropRect: CGRect = convertToPixelCoordinates( - sourceRect: sourceRect, - imgSize: CGSize(width: self.width, height: self.height), - orientation: orientation - ) - - return (self.cropping(to: pixelCropRect) ?? self) - }() - - ctx.draw(imageToDraw, in: bounds, byTiling: false) return (ctx.makeImage() ?? self) } - - private func convertToPixelCoordinates( - sourceRect: CGRect, - imgSize: CGSize, - orientation: UIImage.Orientation - ) -> CGRect { +} + +// MARK: - Conveneince +private extension CGContext { + func applyOrientationTransform(orientation: UIImage.Orientation, size: CGSize) { switch orientation { - case .up, .upMirrored: return sourceRect - case .down, .downMirrored: - return CGRect( - x: imgSize.width - sourceRect.maxX, - y: imgSize.height - sourceRect.maxY, - width: sourceRect.width, - height: sourceRect.height - ) + case .up: break + case .down: + translateBy(x: size.width, y: size.height) + rotate(by: .pi) + + case .left: + translateBy(x: size.width, y: 0) + rotate(by: .pi / 2) + + case .right: + translateBy(x: 0, y: size.height) + rotate(by: -.pi / 2) + + case .upMirrored: + translateBy(x: size.width, y: 0) + scaleBy(x: -1, y: 1) + + case .downMirrored: + translateBy(x: 0, y: size.height) + scaleBy(x: 1, y: -1) - case .left, .leftMirrored: - return CGRect( - x: sourceRect.minY, - y: imgSize.width - sourceRect.maxX, - width: sourceRect.height, - height: sourceRect.width - ) + case .leftMirrored: + translateBy(x: size.width, y: size.height) + rotate(by: .pi / 2) + scaleBy(x: 1, y: -1) - case .right, .rightMirrored: - return CGRect( - x: imgSize.height - sourceRect.maxY, - y: sourceRect.minX, - width: sourceRect.height, - height: sourceRect.width - ) + case .rightMirrored: + rotate(by: -.pi / 2) + scaleBy(x: 1, y: -1) - @unknown default: return sourceRect + @unknown default: break } } } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift index 3279e7dcda..5a675985b4 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift @@ -46,8 +46,7 @@ class PendingAttachmentRailItem: Equatable { ImageEditorModel.isFeatureEnabled && attachment.utType.isImage && attachment.duration == 0, - case .media(let mediaSource) = attachment.source, - case .url = mediaSource + case .media = attachment.metadata { do { imageEditorModel = try ImageEditorModel(attachment: attachment, using: dependencies) diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorBrushViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorBrushViewController.swift index 35a39417a9..c4c9ee6152 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorBrushViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorBrushViewController.swift @@ -27,11 +27,12 @@ public class ImageEditorBrushViewController: OWSViewController { delegate: ImageEditorBrushViewControllerDelegate, model: ImageEditorModel, currentColor: ImageEditorColor, - bottomInset: CGFloat + bottomInset: CGFloat, + using dependencies: Dependencies ) { self.delegate = delegate self.model = model - self.canvasView = ImageEditorCanvasView(model: model) + self.canvasView = ImageEditorCanvasView(model: model, using: dependencies) self.paletteView = ImageEditorPaletteView(currentColor: currentColor) self.firstUndoOperationId = model.currentUndoOperationId() self.bottomInset = bottomInset diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift index 6c8464aa73..a065ac91b6 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift @@ -24,6 +24,7 @@ public class EditorTextLayer: CATextLayer { // A view for previewing an image editor model. public class ImageEditorCanvasView: UIView { + private let dependencies: Dependencies private let model: ImageEditorModel private let itemIdsToIgnore: [String] @@ -35,7 +36,8 @@ public class ImageEditorCanvasView: UIView { // We leave space for 10k items/layers of each type. private static let zPositionSpacing: CGFloat = 0.0001 - public required init(model: ImageEditorModel, itemIdsToIgnore: [String] = []) { + public required init(model: ImageEditorModel, itemIdsToIgnore: [String] = [], using dependencies: Dependencies) { + self.dependencies = dependencies self.model = model self.itemIdsToIgnore = itemIdsToIgnore @@ -134,18 +136,20 @@ public class ImageEditorCanvasView: UIView { } public func loadSrcImage() -> UIImage? { - return ImageEditorCanvasView.loadSrcImage(model: model) + return ImageEditorCanvasView.loadSrcImage(model: model, using: dependencies) } - public class func loadSrcImage(model: ImageEditorModel) -> UIImage? { + public class func loadSrcImage(model: ImageEditorModel, using dependencies: Dependencies) -> UIImage? { + let options: CFDictionary? = dependencies[singleton: .mediaDecoder].defaultImageOptions + switch model.src { case .url(let url): // We use this constructor so that we can specify the scale. // // UIImage(contentsOfFile:) will sometimes use device scale. guard - let data: Data = try? Data(contentsOf: url), - let srcImage: UIImage = UIImage(data: data, scale: 1.0) + let source: CGImageSource = dependencies[singleton: .mediaDecoder].source(for: url), + let cgImage: CGImage = CGImageSourceCreateImageAtIndex(source, 0, options) else { Log.error("[ImageEditorCanvasView] Couldn't load source image.") return nil @@ -155,13 +159,30 @@ public class ImageEditorCanvasView: UIView { // of code simplicity. We could modify the image layer's // transform to handle the normalization, which would // have perf benefits. - return srcImage.normalizedImage() + let orientation: UIImage.Orientation = { + guard + let properties: [CFString: Any] = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let rawCgOrientation: UInt32 = properties[kCGImagePropertyOrientation] as? UInt32, + let cgOrientation: CGImagePropertyOrientation = CGImagePropertyOrientation(rawValue: rawCgOrientation) + else { return .up } + + return UIImage.Orientation(cgOrientation) + }() + + return UIImage( + cgImage: cgImage.normalized(orientation: orientation), + scale: 1, + orientation: .up + ) case .data(_, let data): // We use this constructor so that we can specify the scale. // // UIImage(contentsOfFile:) will sometimes use device scale. - guard let srcImage: UIImage = UIImage(data: data, scale: 1.0) else { + guard + let source: CGImageSource = dependencies[singleton: .mediaDecoder].source(for: data), + let cgImage: CGImage = CGImageSourceCreateImageAtIndex(source, 0, options) + else { Log.error("[ImageEditorCanvasView] Couldn't load source image.") return nil } @@ -170,7 +191,21 @@ public class ImageEditorCanvasView: UIView { // of code simplicity. We could modify the image layer's // transform to handle the normalization, which would // have perf benefits. - return srcImage.normalizedImage() + let orientation: UIImage.Orientation = { + guard + let properties: [CFString: Any] = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let rawCgOrientation: UInt32 = properties[kCGImagePropertyOrientation] as? UInt32, + let cgOrientation: CGImagePropertyOrientation = CGImagePropertyOrientation(rawValue: rawCgOrientation) + else { return .up } + + return UIImage.Orientation(cgOrientation) + }() + + return UIImage( + cgImage: cgImage.normalized(orientation: orientation), + scale: 1, + orientation: .up + ) case .image(_, let maybeImage): guard let image: UIImage = maybeImage else { @@ -349,10 +384,13 @@ public class ImageEditorCanvasView: UIView { return imageFrame } - private class func imageLayerForItem(model: ImageEditorModel, - transform: ImageEditorTransform, - viewSize: CGSize) -> CALayer? { - guard let srcImage = loadSrcImage(model: model) else { + private class func imageLayerForItem( + model: ImageEditorModel, + transform: ImageEditorTransform, + viewSize: CGSize, + using dependencies: Dependencies + ) -> CALayer? { + guard let srcImage = loadSrcImage(model: model, using: dependencies) else { Log.error("[ImageEditorCanvasView] Could not load src image.") return nil } @@ -653,7 +691,7 @@ public class ImageEditorCanvasView: UIView { contentView.layer.setAffineTransform(transform.affineTransform(viewSize: viewSize)) - guard let imageLayer = imageLayerForItem(model: model, transform: transform, viewSize: viewSize) else { + guard let imageLayer = imageLayerForItem(model: model, transform: transform, viewSize: viewSize, using: dependencies) else { Log.error("[ImageEditorCanvasView] Could not load src image.") return nil } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift index 45bac1a7d4..bba4d928b8 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift @@ -72,16 +72,16 @@ public class ImageEditorModel { throw ImageEditorError.invalidInput } - let unrotatedSize: CGSize = metadata.unrotatedSize + let displaySize: CGSize = metadata.displaySize - guard unrotatedSize.width > 0, unrotatedSize.height > 0 else { + guard displaySize.width > 0, displaySize.height > 0 else { Log.error("[ImageEditorModel] Couldn't determine image size.") throw ImageEditorError.invalidInput } self.src = source self.srcMetadata = metadata - self.srcImageSizePixels = unrotatedSize + self.srcImageSizePixels = displaySize self.contents = ImageEditorContents() self.transform = ImageEditorTransform.defaultTransform(srcImageSizePixels: srcImageSizePixels) diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorTextViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorTextViewController.swift index 4febd32c7b..ae7165489d 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorTextViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorTextViewController.swift @@ -114,7 +114,8 @@ public class ImageEditorTextViewController: OWSViewController, VAlignTextViewDel textItem: ImageEditorTextItem, isNewItem: Bool, maxTextWidthPoints: CGFloat, - bottomInset: CGFloat + bottomInset: CGFloat, + using dependencies: Dependencies ) { self.delegate = delegate self.model = model @@ -123,7 +124,8 @@ public class ImageEditorTextViewController: OWSViewController, VAlignTextViewDel self.maxTextWidthPoints = maxTextWidthPoints self.canvasView = ImageEditorCanvasView( model: model, - itemIdsToIgnore: [textItem.itemId] + itemIdsToIgnore: [textItem.itemId], + using: dependencies ) self.paletteView = ImageEditorPaletteView(currentColor: textItem.color) self.bottomInset = bottomInset diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorView.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorView.swift index be0b023167..5fb188a3ac 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorView.swift @@ -1,6 +1,7 @@ // Copyright (c) 2019 Open Whisper Systems. All rights reserved. import UIKit +import SessionUIKit import SessionUtilitiesKit @objc @@ -23,16 +24,41 @@ public class ImageEditorView: UIView { private let dependencies: Dependencies private let model: ImageEditorModel private let canvasView: ImageEditorCanvasView + + private lazy var uneditableImageView: SessionImageView = { + let result: SessionImageView = SessionImageView(dataManager: dependencies[singleton: .imageDataManager]) + result.contentMode = .scaleAspectFit + + return result + }() // TODO: We could hang this on the model or make this static // if we wanted more color continuity. private var currentColor = ImageEditorColor.defaultColor() + + /// The share extension has limited RAM (~120Mb on an iPhone X) so only allow image editing if there is likely enough RAM to do + /// so (if there isn't then it would just crash when trying to normalise the image since that requires `3x` RAM in order to allocate the + /// buffers needed for manipulating the image data), in order to avoid this we check if the estimated RAM usage is smaller than `80%` + /// of the currently available RAM and if not we don't allow image editing (instead we load the image in a `SessionImageView` + /// which falls back to lazy `UIImage` loading due to the memory limits) + public var canSupportImageEditing: Bool { + #if targetEnvironment(simulator) + /// On the simulator `os_proc_available_memory` seems to always return `0` so just assume we have enough memort + return true + #else + let estimatedMemorySize: Int = Int(floor((model.srcImageSizePixels.width * model.srcImageSizePixels.height * 4))) + let estimatedMemorySizeToLoad: Int = (estimatedMemorySize * 3) + let currentAvailableMemory: Int = os_proc_available_memory() + + return (estimatedMemorySizeToLoad < Int(floor(CGFloat(currentAvailableMemory) * 0.8))) + #endif + } public required init(model: ImageEditorModel, delegate: ImageEditorViewDelegate, using dependencies: Dependencies) { self.dependencies = dependencies self.model = model self.delegate = delegate - self.canvasView = ImageEditorCanvasView(model: model) + self.canvasView = ImageEditorCanvasView(model: model, using: dependencies) super.init(frame: .zero) @@ -52,9 +78,16 @@ public class ImageEditorView: UIView { @objc public func configureSubviews() -> Bool { - canvasView.configureSubviews() - self.addSubview(canvasView) - canvasView.pin(to: self) + if canSupportImageEditing { + canvasView.configureSubviews() + self.addSubview(canvasView) + canvasView.pin(to: self) + } + else { + uneditableImageView.loadImage(model.src) + self.addSubview(uneditableImageView) + uneditableImageView.pin(to: self) + } self.isUserInteractionEnabled = true @@ -92,14 +125,27 @@ public class ImageEditorView: UIView { return [] } - let undoButton = navigationBarButton(imageName: "image_editor_undo", - selector: #selector(didTapUndo(sender:))) - let brushButton = navigationBarButton(imageName: "image_editor_brush", - selector: #selector(didTapBrush(sender:))) - let cropButton = navigationBarButton(imageName: "image_editor_crop", - selector: #selector(didTapCrop(sender:))) - let newTextButton = navigationBarButton(imageName: "image_editor_text", - selector: #selector(didTapNewText(sender:))) + let canEditImage: Bool = canSupportImageEditing + let undoButton = navigationBarButton( + imageName: "image_editor_undo", + enabled: canEditImage, + selector: #selector(didTapUndo(sender:)) + ) + let brushButton = navigationBarButton( + imageName: "image_editor_brush", + enabled: canEditImage, + selector: #selector(didTapBrush(sender:)) + ) + let cropButton = navigationBarButton( + imageName: "image_editor_crop", + enabled: canEditImage, + selector: #selector(didTapCrop(sender:)) + ) + let newTextButton = navigationBarButton( + imageName: "image_editor_text", + enabled: canEditImage, + selector: #selector(didTapNewText(sender:)) + ) var buttons: [UIView] if model.canUndo() { @@ -135,7 +181,8 @@ public class ImageEditorView: UIView { delegate: self, model: model, currentColor: currentColor, - bottomInset: ((self.superview?.frame.height ?? 0) - self.frame.height) + bottomInset: ((self.superview?.frame.height ?? 0) - self.frame.height), + using: dependencies ) self.delegate?.imageEditor(presentFullScreenView: brushView, isTransparent: false) @@ -446,7 +493,8 @@ public class ImageEditorView: UIView { textItem: textItem, isNewItem: isNewItem, maxTextWidthPoints: maxTextWidthPoints, - bottomInset: ((self.superview?.frame.height ?? 0) - self.frame.height) + bottomInset: ((self.superview?.frame.height ?? 0) - self.frame.height), + using: dependencies ) self.delegate?.imageEditor(presentFullScreenView: textEditor, isTransparent: false) diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift index 637b45ae92..ef3a7188b4 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift @@ -18,12 +18,12 @@ public class MediaMessageView: UIView { // MARK: Properties private let dependencies: Dependencies - private var disposables: Set = Set() public let mode: Mode public let attachment: PendingAttachment private let disableLinkPreviewImageDownload: Bool - private let didLoadLinkPreview: ((LinkPreviewDraft) -> Void)? + private let didLoadLinkPreview: (@MainActor (LinkPreviewDraft) -> Void)? private var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)? + private var linkPreviewLoadTask: Task? // MARK: Initializers @@ -34,11 +34,11 @@ public class MediaMessageView: UIView { // Currently we only use one mode (AttachmentApproval), so we could simplify this class, but it's kind // of nice that it's written in a flexible way in case we'd want to use it elsewhere again in the future. - public required init( + @MainActor public required init( attachment: PendingAttachment, mode: MediaMessageView.Mode, disableLinkPreviewImageDownload: Bool, - didLoadLinkPreview: ((LinkPreviewDraft) -> Void)?, + didLoadLinkPreview: (@MainActor (LinkPreviewDraft) -> Void)?, using dependencies: Dependencies ) { self.dependencies = dependencies @@ -64,6 +64,8 @@ public class MediaMessageView: UIView { deinit { NotificationCenter.default.removeObserver(self) + + linkPreviewLoadTask?.cancel() } // MARK: - UI @@ -270,7 +272,7 @@ public class MediaMessageView: UIView { // MARK: - Layout - private func setupViews(using dependencies: Dependencies) { + @MainActor private func setupViews(using dependencies: Dependencies) { switch attachment.source { case .text: return /// Plain text will just be put in the 'message' input so do nothing default: break @@ -319,7 +321,7 @@ public class MediaMessageView: UIView { } } - private func setupLayout() { + @MainActor private func setupLayout() { switch attachment.source { case .text: return /// Plain text will just be put in the 'message' input so do nothing default: break @@ -423,58 +425,65 @@ public class MediaMessageView: UIView { // MARK: - Link Loading - private func loadLinkPreview( + @MainActor private func loadLinkPreview( linkPreviewURL: String, skipImageDownload: Bool, using dependencies: Dependencies ) { loadingView.startAnimating() - LinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL, skipImageDownload: skipImageDownload, using: dependencies) - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .receive(on: DispatchQueue.main) - .sink( - receiveCompletion: { [weak self] result in - switch result { - case .finished: break - case .failure: - self?.loadingView.alpha = 0 - self?.loadingView.stopAnimating() - self?.imageView.alpha = 1 - self?.titleLabel.numberOfLines = 1 // Truncates the URL at 1 line so the error is more readable - self?.subtitleLabel.isHidden = false - - // Set the error text appropriately - if URLComponents(string: linkPreviewURL)?.scheme?.lowercased() != "https" { // stringlint:ignore - // This error case is handled already in the 'subtitleLabel' creation - } - else { - self?.subtitleLabel.font = UIFont.systemFont(ofSize: Values.verySmallFontSize) - self?.subtitleLabel.text = "linkPreviewsErrorLoad".localized() - self?.subtitleLabel.themeTextColor = (self?.mode == .attachmentApproval ? - .textSecondary : - .primary - ) - self?.subtitleLabel.textAlignment = .left - } - } - }, - receiveValue: { [weak self] draft in - self?.didLoadLinkPreview?(draft) - self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft) + linkPreviewLoadTask?.cancel() + linkPreviewLoadTask = Task.detached(priority: .userInitiated) { [weak self] in + do { + let draft: LinkPreviewDraft = try await LinkPreview.tryToBuildPreviewInfo( + previewUrl: linkPreviewURL, + skipImageDownload: skipImageDownload, + using: dependencies + ) + + await MainActor.run { [weak self] in + guard let self else { return } + + didLoadLinkPreview?(draft) + linkPreviewInfo = (url: linkPreviewURL, draft: draft) // Update the UI - self?.titleLabel.text = (draft.title ?? self?.titleLabel.text) - self?.loadingView.alpha = 0 - self?.loadingView.stopAnimating() - self?.imageView.alpha = 1 + titleLabel.text = (draft.title ?? titleLabel.text) + loadingView.alpha = 0 + loadingView.stopAnimating() + imageView.alpha = 1 + + if let imageSource: ImageDataManager.DataSource = draft.imageSource { + imageView.loadImage(imageSource) + } + } + } + catch { + await MainActor.run { [weak self] in + guard let self else { return } + + loadingView.alpha = 0 + loadingView.stopAnimating() + imageView.alpha = 1 + titleLabel.numberOfLines = 1 /// Truncates the URL at 1 line so the error is more readable + subtitleLabel.isHidden = false - if let jpegImageData: Data = draft.jpegImageData, let loadedImage: UIImage = UIImage(data: jpegImageData) { - self?.imageView.image = loadedImage - self?.imageView.contentMode = .scaleAspectFill + /// Set the error text appropriately + let httpsScheme: String = "https" // stringlint:ignore + if URLComponents(string: linkPreviewURL)?.scheme?.lowercased() != httpsScheme { + // This error case is handled already in the 'subtitleLabel' creation + } + else { + subtitleLabel.font = UIFont.systemFont(ofSize: Values.verySmallFontSize) + subtitleLabel.text = "linkPreviewsErrorLoad".localized() + subtitleLabel.themeTextColor = (mode == .attachmentApproval ? + .textSecondary : + .primary + ) + subtitleLabel.textAlignment = .left } } - ) - .store(in: &disposables) + } + } } } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/OWSViewController+ImageEditor.swift b/SignalUtilitiesKit/Media Viewing & Editing/OWSViewController+ImageEditor.swift index fc9563586f..f72d0f36d7 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/OWSViewController+ImageEditor.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/OWSViewController+ImageEditor.swift @@ -6,10 +6,11 @@ import UIKit import SessionUIKit public extension NSObject { - func navigationBarButton(imageName: String, selector: Selector) -> UIView { + func navigationBarButton(imageName: String, enabled: Bool = true, selector: Selector) -> UIView { let button = OWSButton() button.setImage(imageName: imageName) button.themeTintColor = .textPrimary + button.isEnabled = enabled button.addTarget(self, action: selector, for: .touchUpInside) return button From 14bd381937b6aaa6e9cbaf66d51d8bc37da5ddeb Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Thu, 30 Oct 2025 16:31:59 +1100 Subject: [PATCH 149/162] Fixed more image orientation issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Fixed an issue where you couldn't properly include the sides of rectangular images when cropping display pictures • Fixed an issue where cropping display pictures could result in incorrect crop locations or images not being rendered --- Session.xcodeproj/project.pbxproj | 8 +- .../CropScaleImageViewController.swift | 260 +++++++++--------- .../MediaPageViewController.swift | 6 +- .../Components/ProfilePictureView.swift | 46 +++- .../Components/SessionImageView.swift | 12 +- SessionUIKit/Types/ImageDataManager.swift | 22 +- .../Utilities/UIImage+Utilities.swift | 70 ++++- 7 files changed, 253 insertions(+), 171 deletions(-) diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index dcc9e4b793..f3b1c82806 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -8362,7 +8362,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = ""; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 657; + CURRENT_PROJECT_VERSION = 658; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -8443,7 +8443,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = ""; - CURRENT_PROJECT_VERSION = 657; + CURRENT_PROJECT_VERSION = 658; ENABLE_BITCODE = NO; ENABLE_MODULE_VERIFIER = YES; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -8929,7 +8929,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; COMPILE_LIB_SESSION = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 657; + CURRENT_PROJECT_VERSION = 658; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -9519,7 +9519,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Distribution"; COMPILE_LIB_SESSION = YES; - CURRENT_PROJECT_VERSION = 657; + CURRENT_PROJECT_VERSION = 658; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_NO_COMMON_BLOCKS = YES; diff --git a/Session/Media Viewing & Editing/CropScaleImageViewController.swift b/Session/Media Viewing & Editing/CropScaleImageViewController.swift index 5aa5345439..8e858b1681 100644 --- a/Session/Media Viewing & Editing/CropScaleImageViewController.swift +++ b/Session/Media Viewing & Editing/CropScaleImageViewController.swift @@ -55,7 +55,13 @@ class CropScaleImageViewController: OWSViewController, UIScrollViewDelegate { result.maximumZoomScale = 5 result.showsHorizontalScrollIndicator = false result.showsVerticalScrollIndicator = false -// result.clipsToBounds = false + + return result + }() + + private lazy var imageContainerView: UIView = { + let result: UIView = UIView() + result.themeBackgroundColor = .clear return result }() @@ -66,6 +72,72 @@ class CropScaleImageViewController: OWSViewController, UIScrollViewDelegate { return result }() + + private lazy var buttonStackView: UIStackView = { + let result: UIStackView = UIStackView(arrangedSubviews: [cancelButton, doneButton]) + result.axis = .horizontal + result.distribution = .fillEqually + result.alignment = .fill + + return result + }() + + private lazy var cancelButton: UIButton = { + let result: UIButton = UIButton() + result.titleLabel?.font = .systemFont(ofSize: 18) + result.setTitle("cancel".localized(), for: .normal) + result.setThemeTitleColor(.textPrimary, for: .normal) + result.setThemeBackgroundColor(.backgroundSecondary, for: .highlighted) + result.contentEdgeInsets = UIEdgeInsets( + top: Values.mediumSpacing, + leading: 0, + bottom: (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? Values.mediumSpacing), + trailing: 0 + ) + result.addTarget(self, action: #selector(cancelPressed), for: .touchUpInside) + + return result + }() + + private lazy var doneButton: UIButton = { + let result: UIButton = UIButton() + result.titleLabel?.font = .systemFont(ofSize: 18) + result.setTitle("done".localized(), for: .normal) + result.setThemeTitleColor(.textPrimary, for: .normal) + result.setThemeBackgroundColor(.backgroundPrimary, for: .highlighted) + result.setThemeBackgroundColor(.backgroundSecondary, for: .highlighted) + result.contentEdgeInsets = UIEdgeInsets( + top: Values.mediumSpacing, + leading: 0, + bottom: (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? Values.mediumSpacing), + trailing: 0 + ) + result.addTarget(self, action: #selector(donePressed), for: .touchUpInside) + + return result + }() + + private lazy var maskingView: BezierPathView = { + let result: BezierPathView = BezierPathView() + result.configureShapeLayer = { [weak self] layer, bounds in + guard let self = self else { return } + + let path = UIBezierPath(rect: bounds) + let circleRect = cropFrame(forBounds: bounds) + let radius = circleRect.size.width * 0.5 + let circlePath = UIBezierPath(roundedRect: circleRect, cornerRadius: radius) + + path.append(circlePath) + path.usesEvenOddFillRule = true + + layer.path = path.cgPath + layer.fillRule = .evenOdd + layer.themeFillColor = .black + layer.opacity = 0.75 + } + + return result + }() // MARK: Initializers @@ -87,7 +159,7 @@ class CropScaleImageViewController: OWSViewController, UIScrollViewDelegate { super.init(nibName: nil, bundle: nil) - srcImageSizePoints = (source.sizeFromMetadata ?? .zero) + srcImageSizePoints = (source.displaySizeFromMetadata ?? .zero) } // MARK: View Lifecycle @@ -112,153 +184,78 @@ class CropScaleImageViewController: OWSViewController, UIScrollViewDelegate { title = "attachmentsMoveAndScale".localized() view.themeBackgroundColor = .backgroundPrimary - let contentView = UIView() - contentView.themeBackgroundColor = .backgroundPrimary - self.view.addSubview(contentView) - contentView.pin(to: self.view) + view.addSubview(scrollView) + view.addSubview(maskingView) + view.addSubview(buttonStackView) + scrollView.addSubview(imageContainerView) + imageContainerView.addSubview(imageView) - contentView.addSubview(scrollView) - scrollView.pin(.top, to: .top, of: contentView, withInset: (Values.massiveSpacing + Values.smallSpacing)) - scrollView.pin(.leading, to: .leading, of: contentView) - scrollView.pin(.trailing, to: .trailing, of: contentView) + scrollView.pin(.top, to: .top, of: view) + scrollView.pin(.leading, to: .leading, of: view) + scrollView.pin(.trailing, to: .trailing, of: view) + scrollView.pin(.bottom, to: .top, of: buttonStackView) - imageView.frame = CGRect(origin: .zero, size: srcImageSizePoints) - scrollView.addSubview(imageView) - scrollView.contentSize = srcImageSizePoints + maskingView.pin(to: scrollView) - let buttonRowBackground: UIView = UIView() - buttonRowBackground.themeBackgroundColor = .backgroundPrimary - contentView.addSubview(buttonRowBackground) + imageContainerView.pin(to: scrollView) + imageView.pin(to: imageContainerView) + imageView.set(.width, to: srcImageSizePoints.width) + imageView.set(.height, to: srcImageSizePoints.height) - let buttonRow: UIView = createButtonRow() - contentView.addSubview(buttonRow) - buttonRow.pin(.top, to: .bottom, of: scrollView) - buttonRow.pin(.leading, to: .leading, of: contentView) - buttonRow.pin(.trailing, to: .trailing, of: contentView) - buttonRow.pin(.bottom, to: .bottom, of: contentView) - buttonRow.set( - .height, - to: ( - Values.scaleFromIPhone5To7Plus(35, 45) + - Values.mediumSpacing + - (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? Values.mediumSpacing) - ) - ) - buttonRowBackground.pin(to: buttonRow) - - let maskingView = BezierPathView() - contentView.addSubview(maskingView) - - maskingView.configureShapeLayer = { [weak self] layer, bounds in - guard let self = self else { return } - - let path = UIBezierPath(rect: bounds) - let circleRect = cropFrame(forBounds: bounds) - let radius = circleRect.size.width * 0.5 - let circlePath = UIBezierPath(roundedRect: circleRect, cornerRadius: radius) - - path.append(circlePath) - path.usesEvenOddFillRule = true - - layer.path = path.cgPath - layer.fillRule = .evenOdd - layer.themeFillColor = .black - layer.opacity = 0.75 - } - maskingView.pin(.top, to: .top, of: contentView, withInset: (Values.massiveSpacing + Values.smallSpacing)) - maskingView.pin(.leading, to: .leading, of: contentView) - maskingView.pin(.trailing, to: .trailing, of: contentView) - maskingView.pin(.bottom, to: .top, of: buttonRow) + buttonStackView.pin(.leading, to: .leading, of: view) + buttonStackView.pin(.trailing, to: .trailing, of: view) + buttonStackView.pin(.bottom, to: .bottom, of: view) } private func configureScrollView() { guard srcImageSizePoints.width > 0 && srcImageSizePoints.height > 0 else { return } - - let scrollViewBounds = scrollView.bounds - guard scrollViewBounds.width > 0 && scrollViewBounds.height > 0 else { return } + guard scrollView.bounds.width > 0 && scrollView.bounds.height > 0 else { return } // Get the crop circle size - let cropCircleSize = min(scrollViewBounds.width, scrollViewBounds.height) - (maskMargin * 2) + let cropCircleSize: CGFloat = min(scrollView.bounds.width, scrollView.bounds.height) - (maskMargin * 2) - // Calculate the scale to fit the image to fill the crop circle - let widthScale = cropCircleSize / srcImageSizePoints.width - let heightScale = cropCircleSize / srcImageSizePoints.height + // Calculate the scale to fit the image to fill the crop circle then start at min scale + let widthScale: CGFloat = (cropCircleSize / srcImageSizePoints.width) + let heightScale: CGFloat = (cropCircleSize / srcImageSizePoints.height) let minScale = max(widthScale, heightScale) // Fill, not fit - let maxScale = minScale * 5.0 - scrollView.minimumZoomScale = minScale - scrollView.maximumZoomScale = maxScale - - // Start at minimum scale (fills the circle) + scrollView.maximumZoomScale = (minScale * 5.0) scrollView.zoomScale = minScale // Center the content - centerScrollViewContents() - } - - private func centerScrollViewContents() { - let scrollViewSize = scrollView.bounds.size - let imageViewSize = imageView.frame.size - - let horizontalInset = max(0, (scrollViewSize.width - imageViewSize.width) / 2) - let verticalInset = max(0, (scrollViewSize.height - imageViewSize.height) / 2) + let cropRect: CGRect = cropFrame(forBounds: scrollView.bounds) + let scaledImageWidth: CGFloat = (srcImageSizePoints.width * minScale) + let scaledImageHeight: CGFloat = (srcImageSizePoints.height * minScale) + let offsetX: CGFloat = ((cropCircleSize - scaledImageWidth) / 2) + let offsetY: CGFloat = ((cropCircleSize - scaledImageHeight) / 2) scrollView.contentInset = UIEdgeInsets( - top: verticalInset, - left: horizontalInset, - bottom: verticalInset, - right: horizontalInset + top: cropRect.minY, + left: cropRect.minX, + bottom: (scrollView.bounds.height - cropRect.maxY), + right: (scrollView.bounds.width - cropRect.maxX) + ) + scrollView.contentOffset = CGPoint( + x: -cropRect.minX - offsetX, + y: -cropRect.minY - offsetY ) } // Given the current bounds for the image view, return the frame of the // crop region within that view. private func cropFrame(forBounds bounds: CGRect) -> CGRect { - let radius = min(bounds.size.width, bounds.size.height) * 0.5 - self.maskMargin - // Center the circle's bounding rectangle - let circleRect = CGRect(x: bounds.size.width * 0.5 - radius, y: bounds.size.height * 0.5 - radius, width: radius * 2, height: radius * 2) - return circleRect + let radius: CGFloat = ((min(bounds.size.width, bounds.size.height) * 0.5) - self.maskMargin) + + return CGRect( + x: ((bounds.size.width * 0.5) - radius), + y: ((bounds.size.height * 0.5) - radius), + width: (radius * 2), + height: (radius * 2) + ) } func viewForZooming(in scrollView: UIScrollView) -> UIView? { - return imageView - } - - func scrollViewDidZoom(_ scrollView: UIScrollView) { - centerScrollViewContents() - } - - private func createButtonRow() -> UIView { - let result: UIStackView = UIStackView() - result.axis = .horizontal - result.distribution = .fillEqually - result.alignment = .fill - - let cancelButton = createButton(title: "cancel".localized(), action: #selector(cancelPressed)) - result.addArrangedSubview(cancelButton) - - let doneButton = createButton(title: "done".localized(), action: #selector(donePressed)) - doneButton.accessibilityLabel = "Done" - result.addArrangedSubview(doneButton) - - return result - } - - private func createButton(title: String, action: Selector) -> UIButton { - let button: UIButton = UIButton() - button.titleLabel?.font = .systemFont(ofSize: 18) - button.setTitle(title, for: .normal) - button.setThemeTitleColor(.textPrimary, for: .normal) - button.setThemeBackgroundColor(.backgroundSecondary, for: .highlighted) - button.contentEdgeInsets = UIEdgeInsets( - top: Values.mediumSpacing, - leading: 0, - bottom: (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? Values.mediumSpacing), - trailing: 0 - ) - button.addTarget(self, action: action, for: .touchUpInside) - - return button + return imageContainerView } // MARK: - Event Handlers @@ -278,28 +275,21 @@ class CropScaleImageViewController: OWSViewController, UIScrollViewDelegate { // MARK: - Internal Functions private func calculateCropRect() -> CGRect { - let scrollViewBounds = scrollView.bounds - let cropCircleFrame = cropFrame(forBounds: scrollViewBounds) - - // Convert crop circle frame to image coordinates + let cropCircleFrame = cropFrame(forBounds: scrollView.bounds) let zoomScale = scrollView.zoomScale let contentOffset = scrollView.contentOffset let contentInset = scrollView.contentInset - // Crop circle center in scroll view coordinates - let cropCenterX = cropCircleFrame.midX - let cropCenterY = cropCircleFrame.midY - // Convert to content coordinates - let contentX = (cropCenterX + contentOffset.x - contentInset.left) / zoomScale - let contentY = (cropCenterY + contentOffset.y - contentInset.top) / zoomScale + let contentX = (contentOffset.x + contentInset.left) / zoomScale + let contentY = (contentOffset.y + contentInset.top) / zoomScale // Crop size in image coordinates let cropSize = cropCircleFrame.width / zoomScale // Convert to normalized coordinates (0-1) - let normalizedX = (contentX - cropSize / 2) / srcImageSizePoints.width - let normalizedY = (contentY - cropSize / 2) / srcImageSizePoints.height + let normalizedX = contentX / srcImageSizePoints.width + let normalizedY = contentY / srcImageSizePoints.height let normalizedWidth = cropSize / srcImageSizePoints.width let normalizedHeight = cropSize / srcImageSizePoints.height @@ -317,5 +307,3 @@ class CropScaleImageViewController: OWSViewController, UIScrollViewDelegate { ) } } -// TODO: Fix modal on Dean's calls PR -// TODO: Create the libSession PR to re-enable the profile_updated stuff, also merge in the attachment encryption stuff diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 980bd3e7a0..1c32207dc9 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -931,12 +931,12 @@ extension MediaPageViewController: MediaPresentationContextProvider { guard let mediaView: SessionImageView = currentViewController?.mediaView, let mediaSuperview: UIView = mediaView.superview, - let mediaSize: CGSize = { + let mediaDisplaySize: CGSize = { /// Because we load images in the background now it can take a small amount of time for the image to actually be /// loaded in that case we want to use the size of the image found in the image metadata (which we read in /// synchronously when scheduling an image to be loaded) guard let image: UIImage = mediaView.image else { - return mediaView.imageSizeMetadata + return mediaView.imageDisplaySizeMetadata } return image.size @@ -944,7 +944,7 @@ extension MediaPageViewController: MediaPresentationContextProvider { else { return nil } let scaledWidth: CGFloat = mediaSuperview.frame.width - let scaledHeight: CGFloat = (mediaSize.height * (mediaSuperview.frame.width / mediaSize.width)) + let scaledHeight: CGFloat = (mediaDisplaySize.height * (mediaSuperview.frame.width / mediaDisplaySize.width)) let topInset: CGFloat = ((mediaSuperview.frame.height - scaledHeight) / 2.0) let leftInset: CGFloat = ((mediaSuperview.frame.width - scaledWidth) / 2.0) diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift index d8ef43f246..d1beb7ff9e 100644 --- a/SessionUIKit/Components/ProfilePictureView.swift +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -600,9 +600,17 @@ public final class ProfilePictureView: UIView { else { return CGRect(x: 0, y: 0, width: 1, height: 1) } switch source.orientationFromMetadata { - case .up, .upMirrored: return cropRect - - case .down, .downMirrored: + case .up: return cropRect + + case .upMirrored: + return CGRect( + x: (1 - cropRect.maxX), + y: cropRect.minY, + width: cropRect.width, + height: cropRect.height + ) + + case .down: return CGRect( x: (1 - cropRect.maxX), y: (1 - cropRect.maxY), @@ -610,18 +618,42 @@ public final class ProfilePictureView: UIView { height: cropRect.height ) - case .left, .leftMirrored: + case .downMirrored: + return CGRect( + x: cropRect.minX, + y: (1 - cropRect.maxY), + width: cropRect.width, + height: cropRect.height + ) + + case .left: + return CGRect( + x: (1 - cropRect.maxY), + y: cropRect.minX, + width: cropRect.height, + height: cropRect.width + ) + + case .leftMirrored: return CGRect( x: cropRect.minY, - y: (1 - cropRect.maxX), + y: cropRect.minX, width: cropRect.height, height: cropRect.width ) - case .right, .rightMirrored: + case .right: + return CGRect( + x: cropRect.minY, + y: (1 - cropRect.maxX), + width: cropRect.height, + height: cropRect.width + ) + + case .rightMirrored: return CGRect( x: (1 - cropRect.maxY), - y: cropRect.minX, + y: (1 - cropRect.maxX), width: cropRect.height, height: cropRect.width ) diff --git a/SessionUIKit/Components/SessionImageView.swift b/SessionUIKit/Components/SessionImageView.swift index 633ddb013d..96ca56d73f 100644 --- a/SessionUIKit/Components/SessionImageView.swift +++ b/SessionUIKit/Components/SessionImageView.swift @@ -15,7 +15,7 @@ public class SessionImageView: UIImageView { public private(set) var currentFrameIndex: Int = 0 public private(set) var accumulatedTime: TimeInterval = 0 - public var imageSizeMetadata: CGSize? + public var imageDisplaySizeMetadata: CGSize? public override var image: UIImage? { didSet { @@ -27,7 +27,7 @@ public class SessionImageView: UIImageView { frameBuffer = nil currentFrameIndex = 0 accumulatedTime = 0 - imageSizeMetadata = nil + imageDisplaySizeMetadata = nil } } @@ -162,7 +162,7 @@ public class SessionImageView: UIImageView { /// Otherwise read the size of the image from the metadata (so we can layout prior to the image being loaded) and schedule the /// background task for loading - imageSizeMetadata = source.sizeFromMetadata + imageDisplaySizeMetadata = source.displaySizeFromMetadata guard let dataManager: ImageDataManagerType = self.dataManager else { #if DEBUG @@ -249,7 +249,7 @@ public class SessionImageView: UIImageView { self.image = other.image self.currentFrameIndex = other.currentFrameIndex self.accumulatedTime = other.accumulatedTime - self.imageSizeMetadata = other.imageSizeMetadata + self.imageDisplaySizeMetadata = other.imageDisplaySizeMetadata self.shouldAnimateImage = other.shouldAnimateImage if other.isAnimating { @@ -291,7 +291,7 @@ public class SessionImageView: UIImageView { frameBuffer = nil currentFrameIndex = 0 accumulatedTime = 0 - imageSizeMetadata = nil + imageDisplaySizeMetadata = nil } @MainActor @@ -306,7 +306,7 @@ public class SessionImageView: UIImageView { /// it first and then store data afterwards (otherwise it'd just be cleared) self.image = buffer.firstFrame self.frameBuffer = buffer - self.imageSizeMetadata = buffer.firstFrame.size + self.imageDisplaySizeMetadata = buffer.firstFrame.size guard buffer.durations.count > 1 && self.shouldAnimateImage else { return } diff --git a/SessionUIKit/Types/ImageDataManager.swift b/SessionUIKit/Types/ImageDataManager.swift index 740f4cf402..2f71b024b9 100644 --- a/SessionUIKit/Types/ImageDataManager.swift +++ b/SessionUIKit/Types/ImageDataManager.swift @@ -983,7 +983,7 @@ public extension ImageDataManager.DataSource { static let maxValidDimension: Int = 1 << 18 // 262,144 pixels @MainActor - var sizeFromMetadata: CGSize? { + var displaySizeFromMetadata: CGSize? { /// There are a number of types which have fixed sizes, in those cases we should return the target size rather than try to /// read it from data so we doncan avoid processing switch self { @@ -1014,7 +1014,25 @@ public extension ImageDataManager.DataSource { sourceHeight < ImageDataManager.DataSource.maxValidDimension else { return nil } - return CGSize(width: sourceWidth, height: sourceHeight) + /// Since we want the "display size" (ie. size after the orientation has been applied) we may need to rotate the resolution + let orientation: UIImage.Orientation? = { + guard + let rawCgOrientation: UInt32 = properties[kCGImagePropertyOrientation as String] as? UInt32, + let cgOrientation: CGImagePropertyOrientation = CGImagePropertyOrientation(rawValue: rawCgOrientation) + else { return nil } + + return UIImage.Orientation(cgOrientation) + }() + + switch orientation { + case .up, .upMirrored, .down, .downMirrored, .none: + return CGSize(width: sourceWidth, height: sourceHeight) + + case .leftMirrored, .left, .rightMirrored, .right: + return CGSize(width: sourceHeight, height: sourceWidth) + + @unknown default: return CGSize(width: sourceWidth, height: sourceHeight) + } } @MainActor diff --git a/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift b/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift index df8775ed80..2fe1a1096f 100644 --- a/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/UIImage+Utilities.swift @@ -165,21 +165,13 @@ public extension CGImage { max(finalSize.width / sourceRect.width, finalSize.height / sourceRect.height) : min(finalSize.width / sourceRect.width, finalSize.height / sourceRect.height) ) - let physicalSize = CGSize(width: self.width, height: self.height) - let drawRect: CGRect = CGRect( - x: -(sourceRect.origin.x * scale), - y: -(sourceRect.origin.y * scale), - width: (physicalSize.width * scale), - height: (physicalSize.height * scale) - ) - + if colorSpace.model == .monochrome { bitmapInfo = (opaque ? CGImageAlphaInfo.none.rawValue : CGImageAlphaInfo.alphaOnly.rawValue ) } else { - // RGB/RGBA context bitmapInfo = (opaque ? CGImageAlphaInfo.noneSkipFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue : CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue @@ -195,9 +187,57 @@ public extension CGImage { space: colorSpace, bitmapInfo: bitmapInfo ) else { return self } - + + // Transform the context to have the correct orientation, positioning and scale (order matters here) + let drawRect: CGRect = CGRect(origin: .zero, size: CGSize(width: self.width, height: self.height)) ctx.interpolationQuality = .high ctx.applyOrientationTransform(orientation: orientation, size: finalSize) + + // After orientation, we need to translate/scale in the NEW coordinate space + // For rotated orientations, the coordinate axes are swapped + let translateX: CGFloat + let translateY: CGFloat + + switch orientation { + case .up: + translateX = -sourceRect.origin.x + translateY = -(srcSize.height - sourceRect.maxY) + + case .upMirrored: + translateX = -(srcSize.width - sourceRect.maxX) + translateY = -(srcSize.height - sourceRect.maxY) + + case .down: + translateX = -(srcSize.width - sourceRect.maxX) + translateY = -sourceRect.origin.y + + case .downMirrored: + translateX = -sourceRect.origin.x + translateY = -sourceRect.origin.y + + case .left: + translateX = -(srcSize.height - sourceRect.maxY) + translateY = -(srcSize.width - sourceRect.maxX) + + case .leftMirrored: + translateX = -sourceRect.origin.y + translateY = -(srcSize.width - sourceRect.maxX) + + case .right: + translateX = -sourceRect.origin.y + translateY = -sourceRect.origin.x + + case .rightMirrored: + translateX = -(srcSize.height - sourceRect.maxY) + translateY = -sourceRect.origin.x + + @unknown default: + translateX = -sourceRect.origin.x + translateY = -sourceRect.origin.y + } + + ctx.scaleBy(x: scale, y: scale) + ctx.translateBy(x: translateX, y: translateY) ctx.draw(self, in: drawRect, byTiling: false) return (ctx.makeImage() ?? self) @@ -205,6 +245,7 @@ public extension CGImage { } // MARK: - Conveneince + private extension CGContext { func applyOrientationTransform(orientation: UIImage.Orientation, size: CGSize) { switch orientation { @@ -230,13 +271,16 @@ private extension CGContext { scaleBy(x: 1, y: -1) case .leftMirrored: - translateBy(x: size.width, y: size.height) + translateBy(x: size.width, y: 0) rotate(by: .pi / 2) - scaleBy(x: 1, y: -1) + translateBy(x: size.height, y: 0) + scaleBy(x: -1, y: 1) case .rightMirrored: + translateBy(x: 0, y: size.height) rotate(by: -.pi / 2) - scaleBy(x: 1, y: -1) + translateBy(x: size.width, y: 0) + scaleBy(x: -1, y: 1) @unknown default: break } From c3c96b9f9dc915155696216de5101a1ebde7ec72 Mon Sep 17 00:00:00 2001 From: mpretty-cyro <15862619+mpretty-cyro@users.noreply.github.com> Date: Mon, 3 Nov 2025 00:39:43 +0000 Subject: [PATCH 150/162] [Automated] Update translations from Crowdin --- .../Meta/Translations/Localizable.xcstrings | 204 +----------------- 1 file changed, 2 insertions(+), 202 deletions(-) diff --git a/Session/Meta/Translations/Localizable.xcstrings b/Session/Meta/Translations/Localizable.xcstrings index a2df415fed..28a6b11b68 100644 --- a/Session/Meta/Translations/Localizable.xcstrings +++ b/Session/Meta/Translations/Localizable.xcstrings @@ -84680,7 +84680,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Checking your {pro} details. Some information on this page may be unavailable until this check is complete." + "value" : "Checking your {pro} details. Some actions on this page may be unavailable until this check is complete." } } } @@ -228514,46 +228514,6 @@ } } }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "%#@arg1@" - }, - "substitutions" : { - "arg1" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "few" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odstranit uživatele a jejich zprávy" - } - }, - "many" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odstranit uživatele a jejich zprávy" - } - }, - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odstranit uživatele a jeho zprávy" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odstranit uživatele a jejich zprávy" - } - } - } - } - } - } - }, "cy" : { "stringUnit" : { "state" : "translated", @@ -230807,46 +230767,6 @@ } } }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "%#@arg1@" - }, - "substitutions" : { - "arg1" : { - "argNum" : 1, - "formatSpecifier" : "lld", - "variations" : { - "plural" : { - "few" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odstranit uživatele" - } - }, - "many" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odstranit uživatele" - } - }, - "one" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odstranit uživatele" - } - }, - "other" : { - "stringUnit" : { - "state" : "translated", - "value" : "Odstranit uživatele" - } - } - } - } - } - } - }, "cy" : { "stringUnit" : { "state" : "translated", @@ -357298,130 +357218,10 @@ "proCallToActionPinnedConversationsMoreThan" : { "extractionState" : "manual", "localizations" : { - "az" : { - "stringUnit" : { - "state" : "translated", - "value" : "5-dən çoxunu sancmaq istəyirsiniz? {app_pro} ilə söhbətlərinizi təşkil edin və premium özəlliklərin kilidini açın" - } - }, - "ca" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vols més de 5 pins? Organitzes els teus xats i desbloqueges les funcions premium amb {app_pro}" - } - }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chcete více než 5 připnutí? Organizujte své chaty a odemkněte prémiové funkce pomocí Session Pro" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mehr als 5 Anheftungen gewünscht? Organisiere deine Chats und schalte Premium-Funktionen mit {app_pro} frei" - } - }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Want more than 5 pins? Organize your chats and unlock premium features with {app_pro}" - } - }, - "es-419" : { - "stringUnit" : { - "state" : "translated", - "value" : "¿Quieres más de 5 conversaciones fijadas? Organiza tus chats y desbloquea funciones premium con {app_pro}" - } - }, - "es-ES" : { - "stringUnit" : { - "state" : "translated", - "value" : "¿Quieres más de 5 conversaciones fijadas? Organiza tus chats y desbloquea funciones premium con {app_pro}" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vous voulez plus que 5 messages épinglés ? Organisez vos chats et débloquez les fonctionnalités premium avec {app_pro}" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "5 से अधिक पिन करना चाहते हैं? अपनी चैट व्यवस्थित करें और {app_pro} के साथ प्रीमियम सुविधाओं का अनलॉक करें" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vuoi più di 5 chat bloccate? Organizza le tue chat e sblocca le funzionalità premium con {app_pro}" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "5件以上ピン留めしたいですか?{app_pro}でチャットを整理して、プレミアム機能を解除しましょう" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Wil je meer dan 5 vastgezette gesprekken? Organiseer je chats en ontgrendel premiumfuncties met {app_pro}" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Chcesz przypiąć więcej niż 5 czatów? Zorganizuj konwersacje i odblokuj funkcje premium dzięki {app_pro}" - } - }, - "pt-PT" : { - "stringUnit" : { - "state" : "translated", - "value" : "Quer fixar mais de 5 conversas? Organize os seus chats e desbloqueie funcionalidades premium com {app_pro}" - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vrei mai mult de 5 fixări? Organizează-ți conversațiile și deblochează funcționalități premium cu {app_pro}" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Нужно более 5 закреплений? С {app_pro} организуйте свои чаты и получите доступ к премиум функциям" - } - }, - "sv-SE" : { - "stringUnit" : { - "state" : "translated", - "value" : "Vill du ha mer än 5 fästisar? Organisera dina chattar och lås upp premiumfunktioner med {app_pro}" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "5'ten fazla sabitleme mi istiyorsunuz? Sohbetlerinizi düzenleyin ve {app_pro} ile premium özelliklerin kilidini açın" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "Потрібно понад 5 закріплених бесід? Впорядкуйте свої бесіди та розблокуйте преміальні функції з {app_pro}" - } - }, - "zh-CN" : { - "stringUnit" : { - "state" : "translated", - "value" : "想要固定超过 5 个对话?使用 {app_pro} 整理你的聊天并解锁高级功能" - } - }, - "zh-TW" : { - "stringUnit" : { - "state" : "translated", - "value" : "想要釘選超過 5 則對話嗎?使用 {app_pro} 整理您的聊天並解鎖進階功能" + "value" : "Want more than {limit} pins? Organize your chats and unlock premium features with {app_pro}" } } } From e5695f4129cc3bb2442fa0cf3b09b228ac23d58b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 3 Nov 2025 13:59:49 +1100 Subject: [PATCH 151/162] Fixed a string which has a new variable --- SessionUIKit/Components/SwiftUI/ProCTAModal.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift index 6b4ed4802c..80a6c44825 100644 --- a/SessionUIKit/Components/SwiftUI/ProCTAModal.swift +++ b/SessionUIKit/Components/SwiftUI/ProCTAModal.swift @@ -66,13 +66,17 @@ public struct ProCTAModal: View { .put(key: "app_pro", value: Constants.app_pro) .localized() case .morePinnedConvos(let isGrandfathered): - return isGrandfathered ? - "proCallToActionPinnedConversations" - .put(key: "app_pro", value: Constants.app_pro) - .localized() : - "proCallToActionPinnedConversationsMoreThan" + if isGrandfathered { + return "proCallToActionPinnedConversations" .put(key: "app_pro", value: Constants.app_pro) .localized() + } + + return "proCallToActionPinnedConversationsMoreThan" + .put(key: "app_pro", value: Constants.app_pro) + .put(key: "limit", value: 5) // TODO: [PRO] Get from SessionProUIManager + .localized() + case .groupLimit: return "proUserProfileModalCallToAction" .put(key: "app_pro", value: Constants.app_pro) From 6c54a5450c8d6c07ea9a1dcf3810379629274e78 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 3 Nov 2025 14:00:06 +1100 Subject: [PATCH 152/162] Testing CI tweaks to reduce flaky builds --- Scripts/build_ci.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Scripts/build_ci.sh b/Scripts/build_ci.sh index 0034642f7b..ab4b29c1a2 100755 --- a/Scripts/build_ci.sh +++ b/Scripts/build_ci.sh @@ -16,7 +16,6 @@ COMMON_ARGS=( -project Session.xcodeproj -scheme Session -derivedDataPath ./build/derivedData - -parallelizeTargets -configuration "App_Store_Release" ) @@ -80,6 +79,9 @@ if [[ "$MODE" == "test" ]]; then exit "$xcodebuild_exit_code" elif [[ "$MODE" == "archive" ]]; then + + # Clean derived data to prevent race conditions + rm -rf ./build/derivedData echo "--- Running Simulator Archive Build (App_Store_Release) ---" From c4e15e6f340b9068fce361cf58507f67fc157f81 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 3 Nov 2025 14:42:41 +1100 Subject: [PATCH 153/162] Further CI tweaks --- Scripts/build_libSession_util.sh | 35 ++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/Scripts/build_libSession_util.sh b/Scripts/build_libSession_util.sh index 9d3300a0de..ae6a7bc52b 100755 --- a/Scripts/build_libSession_util.sh +++ b/Scripts/build_libSession_util.sh @@ -24,16 +24,6 @@ if [ "${ACTION}" = "install" ] || [ "${CONFIGURATION}" = "Release" ]; then fi fi -# Robustly removes a directory, first clearing any immutable flags (work around Xcode's indexer file locking) -remove_locked_dir() { - local dir_to_remove="$1" - if [ -d "${dir_to_remove}" ]; then - echo "- Unlocking and removing ${dir_to_remove}" - chflags -R nouchg "${dir_to_remove}" &>/dev/null || true - rm -rf "${dir_to_remove}" - fi -} - sync_headers() { local source_dir="$1" echo "- Syncing headers from ${source_dir}" @@ -53,9 +43,28 @@ sync_headers() { for dest in "${destinations[@]}"; do if [ -n "$dest" ]; then - remove_locked_dir "$dest" - mkdir -p "$dest" - rsync -rtc --delete --exclude='.DS_Store' "${source_dir}/" "$dest/" + local temp_dest="${dest}.tmp-$(uuidgen)" + rm -rf "$temp_dest" + mkdir -p "$temp_dest" + + rsync -rtc --delete --exclude='.DS_Store' "${source_dir}/" "$temp_dest/" + + # Atomically move the old directory out of the way + local old_dest="${dest}.old-$(uuidgen)" + if [ -d "$dest" ]; then + mv "$dest" "$old_dest" + fi + + # Atomically move the new, correct directory into place + mv "$temp_dest" "$dest" + + # Clean up the old directory + if [ -d "$old_dest" ]; then + # Clear any immutable flags (work around Xcode's indexer file locking) + chflags -R nouchg "${dir_to_remove}" &>/dev/null || true + rm -rf "$old_dest" + fi + echo " Synced to: $dest" fi done From 635badb1370cb97b1f1ccfa0c9b7e118335b878e Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 3 Nov 2025 15:11:29 +1100 Subject: [PATCH 154/162] More CI tweaks --- Scripts/build_ci.sh | 4 +--- Scripts/build_libSession_util.sh | 12 ++++++++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Scripts/build_ci.sh b/Scripts/build_ci.sh index ab4b29c1a2..0034642f7b 100755 --- a/Scripts/build_ci.sh +++ b/Scripts/build_ci.sh @@ -16,6 +16,7 @@ COMMON_ARGS=( -project Session.xcodeproj -scheme Session -derivedDataPath ./build/derivedData + -parallelizeTargets -configuration "App_Store_Release" ) @@ -79,9 +80,6 @@ if [[ "$MODE" == "test" ]]; then exit "$xcodebuild_exit_code" elif [[ "$MODE" == "archive" ]]; then - - # Clean derived data to prevent race conditions - rm -rf ./build/derivedData echo "--- Running Simulator Archive Build (App_Store_Release) ---" diff --git a/Scripts/build_libSession_util.sh b/Scripts/build_libSession_util.sh index ae6a7bc52b..3a8e445a9c 100755 --- a/Scripts/build_libSession_util.sh +++ b/Scripts/build_libSession_util.sh @@ -84,9 +84,17 @@ fi if [ "${COMPILE_LIB_SESSION}" != "YES" ]; then echo "Using pre-packaged SessionUtil" - sync_headers "${PRE_BUILT_FRAMEWORK_DIR}/${FRAMEWORK_DIR}/${TARGET_ARCH_DIR}/Headers/" - # Create the placeholder in the FINAL products directory to satisfy dependency. + if [ "$CI" = "true" ] || [ "$DRONE" = "true" ]; then + # In CI, Xcode's SPM integration is reliable. Skip manual header sync + # to avoid the 'redefinition of module' error. + echo "- CI environment detected, skipping manual header sync to rely on SPM" + else + echo "- Local build detected, syncing headers to assist Xcode indexer" + sync_headers "${PRE_BUILT_FRAMEWORK_DIR}/${FRAMEWORK_DIR}/${TARGET_ARCH_DIR}/Headers/" + fi + + # Create the placeholder in the FINAL products directory to satisfy dependency touch "${BUILT_PRODUCTS_DIR}/libsession-util.a" echo "- Revert to SPM complete." From 2572ed129b0fd36471938cb6e2115c0a38e3ce8c Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 3 Nov 2025 15:55:48 +1100 Subject: [PATCH 155/162] =?UTF-8?q?fix=EF=BC=9A=20UCS=20UI=20&=20Pro=20bad?= =?UTF-8?q?ge=20tapping=20issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settings/ThreadSettingsViewModel.swift | 55 +++---------------- .../Utilities/SessionProState.swift | 8 ++- 2 files changed, 16 insertions(+), 47 deletions(-) diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 0bb69a2387..43718f2822 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -333,7 +333,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi label: threadViewModel.displayName ), onTapView: { [weak self, threadId, dependencies] targetView in - guard targetView is SessionProBadge else { + guard targetView is SessionProBadge, !dependencies[cache: .libSession].isSessionPro else { guard let info: ConfirmationModal.Info = self?.updateDisplayNameModal( threadViewModel: threadViewModel, @@ -357,7 +357,12 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } }() - self?.showSessionProCTAIfNeeded(proCTAModalVariant) + dependencies[singleton: .sessionProState].showSessionProCTAIfNeeded( + proCTAModalVariant, + presenting: { modal in + self?.transitionToScreen(modal, transitionType: .present) + } + ) } ), @@ -402,29 +407,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi label: threadDescription ) ) - }, - - (!showThreadPubkey ? nil : - SessionCell.Info( - id: .sessionId, - subtitle: SessionCell.TextInfo( - threadViewModel.id, - font: .monoSmall, - alignment: .center, - interaction: .copy - ), - onTap: { [weak self] in - guard - let info: ConfirmationModal.Info = self?.updateDisplayNameModal( - threadViewModel: threadViewModel, - currentUserIsClosedGroupAdmin: currentUserIsClosedGroupAdmin - ) - else { return } - - self?.transitionToScreen(ConfirmationModal(info: info), transitionType: .present) - } - ) - ) + } ].compactMap { $0 } ) @@ -1230,7 +1213,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi return [ conversationInfoSection, - (threadViewModel.threadVariant != .contact ? nil : sessionIdSection), + (!showThreadPubkey ? nil : sessionIdSection), standardActionsSection, adminActionsSection, destructiveActionsSection @@ -2130,26 +2113,6 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigationItemSource, Navi } } - private func showSessionProCTAIfNeeded(_ variant: ProCTAModal.Variant) { - let shouldShowProCTA: Bool = { - guard dependencies[feature: .sessionProEnabled] else { return false } - if case .groupLimit = variant { return true } - return !dependencies[cache: .libSession].isSessionPro - }() - - guard shouldShowProCTA else { return } - - let sessionProModal: ModalHostingViewController = ModalHostingViewController( - modal: ProCTAModal( - delegate: dependencies[singleton: .sessionProState], - variant: variant, - dataManager: dependencies[singleton: .imageDataManager] - ) - ) - - self.transitionToScreen(sessionProModal, transitionType: .present) - } - private func showQRCodeLightBox(for threadViewModel: SessionThreadViewModel) { let qrCodeImage: UIImage = QRCode.generate( for: threadViewModel.getQRCodeString(), diff --git a/SessionMessagingKit/Utilities/SessionProState.swift b/SessionMessagingKit/Utilities/SessionProState.swift index 2c58ab1d59..b6e3aaaa83 100644 --- a/SessionMessagingKit/Utilities/SessionProState.swift +++ b/SessionMessagingKit/Utilities/SessionProState.swift @@ -42,7 +42,13 @@ public class SessionProState: SessionProManagerType { afterClosed: (() -> Void)?, presenting: ((UIViewController) -> Void)? ) -> Bool { - guard dependencies[feature: .sessionProEnabled] && (!dependencies[feature: .mockCurrentUserSessionPro]) else { + let shouldShowProCTA: Bool = { + guard dependencies[feature: .sessionProEnabled] else { return false } + if case .groupLimit = variant { return true } + return !dependencies[feature: .mockCurrentUserSessionPro] + }() + + guard shouldShowProCTA else { return false } beforePresented?() From edae077c2bbc544500944900c46a6cc6e5a6a3cd Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Mon, 3 Nov 2025 16:54:46 +1100 Subject: [PATCH 156/162] fix: message info screen tweak --- .../MessageInfoScreen.swift | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 4058b77e05..1f2a1bb7ee 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -336,18 +336,6 @@ struct MessageInfoScreen: View { } } - InfoBlock(title: "sent".localized()) { - Text(messageViewModel.dateForUI.fromattedForMessageInfo) - .font(.Body.largeRegular) - .foregroundColor(themeColor: .textPrimary) - } - - InfoBlock(title: "received".localized()) { - Text(messageViewModel.receivedDateForUI.fromattedForMessageInfo) - .font(.Body.largeRegular) - .foregroundColor(themeColor: .textPrimary) - } - if isMessageFailed { let failureText: String = messageViewModel.mostRecentFailureText ?? "messageStatusFailedToSend".localized() InfoBlock(title: "theError".localized() + ":") { @@ -355,6 +343,18 @@ struct MessageInfoScreen: View { .font(.Body.largeRegular) .foregroundColor(themeColor: .danger) } + } else { + InfoBlock(title: "sent".localized()) { + Text(messageViewModel.dateForUI.fromattedForMessageInfo) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textPrimary) + } + + InfoBlock(title: "received".localized()) { + Text(messageViewModel.receivedDateForUI.fromattedForMessageInfo) + .font(.Body.largeRegular) + .foregroundColor(themeColor: .textPrimary) + } } InfoBlock(title: "from".localized()) { From 7cb734b8dd5b070a196139e326cc6ebc5f5be8ad Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 6 Nov 2025 11:04:13 +1100 Subject: [PATCH 157/162] fix: style tags shouldn't show in home screen message cells --- Session/Shared/FullConversationCell.swift | 6 +- .../Database/Models/ClosedGroup.swift | 66 +++++++++---------- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index 27d7df7d53..3065e50df8 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -605,7 +605,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC string: "messageSnippetGroup" .put(key: "author", value: authorName) .put(key: "message_snippet", value: "") - .localized(), + .localizedDeformatted(), attributes: [ .themeForegroundColor: textColor ] )) } @@ -615,7 +615,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC case .infoGroupCurrentUserErrorLeaving: return "groupLeaveErrorFailed" .put(key: "group_name", value: cellViewModel.displayName) - .localized() + .localizedDeformatted() default: return Interaction.previewText( @@ -627,7 +627,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC attachmentCount: cellViewModel.interactionAttachmentCount, isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true), using: dependencies - ) + ).localizedDeformatted() } }() diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index 99131cbffa..da2655eb59 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -442,150 +442,150 @@ public extension ClosedGroup { return "messageRequestGroupInvite" .put(key: "name", value: adminName) .put(key: "group_name", value: groupName) - .localizedDeformatted() + .localized() - case .invitedFallback: return "groupInviteYou".localizedDeformatted() + case .invitedFallback: return "groupInviteYou".localized() case .invitedAdmin(let adminName, let groupName): return "groupInviteReinvite" .put(key: "name", value: adminName) .put(key: "group_name", value: groupName) - .localizedDeformatted() + .localized() case .invitedAdminFallback(let groupName): return "groupInviteReinviteYou" .put(key: "group_name", value: groupName) - .localizedDeformatted() + .localized() case .updatedName(let name): return "groupNameNew" .put(key: "group_name", value: name) - .localizedDeformatted() + .localized() - case .updatedNameFallback: return "groupNameUpdated".localizedDeformatted() - case .updatedDisplayPicture: return "groupDisplayPictureUpdated".localizedDeformatted() + case .updatedNameFallback: return "groupNameUpdated".localized() + case .updatedDisplayPicture: return "groupDisplayPictureUpdated".localized() case .addedUsers(false, let names, false) where names.count > 2: return "groupMemberNewMultiple" .put(key: "name", value: names[0]) .put(key: "count", value: names.count - 1) - .localizedDeformatted() + .localized() case .addedUsers(false, let names, true) where names.count > 2: return "groupMemberNewHistoryMultiple" .put(key: "name", value: names[0]) .put(key: "count", value: names.count - 1) - .localizedDeformatted() + .localized() case .addedUsers(true, let names, false) where names.count > 2: return "groupInviteYouAndMoreNew" .put(key: "count", value: names.count - 1) - .localizedDeformatted() + .localized() case .addedUsers(true, let names, true) where names.count > 2: return "groupMemberNewYouHistoryMultiple" .put(key: "count", value: names.count - 1) - .localizedDeformatted() + .localized() case .addedUsers(false, let names, false) where names.count == 2: return "groupMemberNewTwo" .put(key: "name", value: names[0]) .put(key: "other_name", value: names[1]) - .localizedDeformatted() + .localized() case .addedUsers(false, let names, true) where names.count == 2: return "groupMemberNewHistoryTwo" .put(key: "name", value: names[0]) .put(key: "other_name", value: names[1]) - .localizedDeformatted() + .localized() case .addedUsers(true, let names, false) where names.count == 2: return "groupInviteYouAndOtherNew" .put(key: "other_name", value: names[1]) // The current user will always be the first name - .localizedDeformatted() + .localized() case .addedUsers(true, let names, true) where names.count == 2: return "groupMemberNewYouHistoryTwo" .put(key: "other_name", value: names[1]) // The current user will always be the first name - .localizedDeformatted() + .localized() case .addedUsers(false, let names, false): return "groupMemberNew" .put(key: "name", value: names.first ?? "anonymous".localized()) - .localizedDeformatted() + .localized() case .addedUsers(false, let names, true): return "groupMemberNewHistory" .put(key: "name", value: names.first ?? "anonymous".localized()) - .localizedDeformatted() + .localized() - case .addedUsers(true, _, false): return "groupInviteYou".localizedDeformatted() - case .addedUsers(true, _, true): return "groupInviteYouHistory".localizedDeformatted() + case .addedUsers(true, _, false): return "groupInviteYou".localized() + case .addedUsers(true, _, true): return "groupInviteYouHistory".localized() case .removedUsers(false, let names) where names.count > 2: return "groupRemovedMultiple" .put(key: "name", value: names[0]) .put(key: "count", value: names.count - 1) - .localizedDeformatted() + .localized() case .removedUsers(true, let names) where names.count > 2: return "groupRemovedYouMultiple" .put(key: "count", value: names.count - 1) - .localizedDeformatted() + .localized() case .removedUsers(false, let names) where names.count == 2: return "groupRemovedTwo" .put(key: "name", value: names[0]) .put(key: "other_name", value: names[1]) - .localizedDeformatted() + .localized() case .removedUsers(true, let names) where names.count == 2: return "groupRemovedYouTwo" .put(key: "other_name", value: names[1]) // The current user will always be the first name - .localizedDeformatted() + .localized() case .removedUsers(false, let names): return "groupRemoved" .put(key: "name", value: names.first ?? "anonymous".localized()) - .localizedDeformatted() + .localized() - case .removedUsers(true, _): return "groupRemovedYouGeneral".localizedDeformatted() + case .removedUsers(true, _): return "groupRemovedYouGeneral".localized() case .memberLeft(false, let name): return "groupMemberLeft" .put(key: "name", value: name) - .localizedDeformatted() + .localized() - case .memberLeft(true, _): return "groupMemberYouLeft".localizedDeformatted() + case .memberLeft(true, _): return "groupMemberYouLeft".localized() case .promotedUsers(false, let names) where names.count > 2: return "adminMorePromotedToAdmin" .put(key: "name", value: names[0]) .put(key: "count", value: names.count - 1) - .localizedDeformatted() + .localized() case .promotedUsers(true, let names) where names.count > 2: return "groupPromotedYouMultiple" .put(key: "count", value: names.count - 1) - .localizedDeformatted() + .localized() case .promotedUsers(false, let names) where names.count == 2: return "adminTwoPromotedToAdmin" .put(key: "name", value: names[0]) .put(key: "other_name", value: names[1]) - .localizedDeformatted() + .localized() case .promotedUsers(true, let names) where names.count == 2: return "groupPromotedYouTwo" .put(key: "other_name", value: names[1]) // The current user will always be the first name - .localizedDeformatted() + .localized() case .promotedUsers(false, let names): return "adminPromotedToAdmin" .put(key: "name", value: names.first ?? "anonymous".localized()) - .localizedDeformatted() + .localized() - case .promotedUsers(true, _): return "groupPromotedYou".localizedDeformatted() + case .promotedUsers(true, _): return "groupPromotedYou".localized() } } From c1ebd365262a3b91b35b936b2200c47ccc0eaec5 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 6 Nov 2025 11:42:05 +1100 Subject: [PATCH 158/162] fix: UPM in Message Info Screen --- .../MessageInfoScreen.swift | 31 +++++++++++++------ .../Utilities/DisplayPictureManager.swift | 10 +++--- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 1f2a1bb7ee..b49b9926e4 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -572,13 +572,24 @@ struct MessageInfoScreen: View { } let (sessionId, blindedId): (String?, String?) = { - guard (try? SessionId.Prefix(from: messageViewModel.authorId)) == .blinded15 else { + guard + (try? SessionId.Prefix(from: messageViewModel.authorId)) == .blinded15, + let openGroupServer: String = messageViewModel.threadOpenGroupServer, + let openGroupPublicKey: String = messageViewModel.threadOpenGroupPublicKey + else { return (messageViewModel.authorId, nil) } - let lookup: BlindedIdLookup? = dependencies[singleton: .storage].read { db in - try? BlindedIdLookup.fetchOne(db, id: messageViewModel.authorId) + let lookup: BlindedIdLookup? = dependencies[singleton: .storage].write { db in + try BlindedIdLookup.fetchOrCreate( + db, + blindedId: messageViewModel.authorId, + openGroupServer: openGroupServer, + openGroupPublicKey: openGroupPublicKey, + isCheckingForOutbox: false, + using: dependencies + ) } - return (lookup?.sessionId, messageViewModel.authorId) + return (lookup?.sessionId, messageViewModel.authorId.truncated(prefix: 10, suffix: 10)) }() let qrCodeImage: UIImage? = { @@ -596,17 +607,19 @@ struct MessageInfoScreen: View { return (messageViewModel.authorNameSuppressedId, nil) } + let profile: Profile? = ( + dependencies.mutate(cache: .libSession) { $0.profile(contactId: sessionId) } ?? + dependencies[singleton: .storage].read { db in try? Profile.fetchOne(db, id: sessionId) } + ) + let isCurrentUser: Bool = (messageViewModel.currentUserSessionIds?.contains(sessionId) == true) guard !isCurrentUser else { return ("you".localized(), "you".localized()) } return ( - messageViewModel.authorName, - messageViewModel.profile?.displayName( - for: messageViewModel.threadVariant, - ignoringNickname: true - ) + (profile?.displayName(for: .contact) ?? messageViewModel.authorNameSuppressedId), + profile?.displayName(for: .contact, ignoringNickname: true) ) }() diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index 151479c9ce..2ab4e8ea19 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -189,11 +189,11 @@ public class DisplayPictureManager { private static func standardOperations(cropRect: CGRect?) -> Set { return [ - .convert(to: .webPLossy( - maxDimension: DisplayPictureManager.maxDimension, - cropRect: cropRect, - resizeMode: .fill - )), +// .convert(to: .webPLossy( +// maxDimension: DisplayPictureManager.maxDimension, +// cropRect: cropRect, +// resizeMode: .fill +// )), .stripImageMetadata ] } From f3a4a669b86c1c362acdd558123abbb700dc8e81 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 6 Nov 2025 11:51:39 +1100 Subject: [PATCH 159/162] clean --- .../Utilities/DisplayPictureManager.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index 2ab4e8ea19..151479c9ce 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -189,11 +189,11 @@ public class DisplayPictureManager { private static func standardOperations(cropRect: CGRect?) -> Set { return [ -// .convert(to: .webPLossy( -// maxDimension: DisplayPictureManager.maxDimension, -// cropRect: cropRect, -// resizeMode: .fill -// )), + .convert(to: .webPLossy( + maxDimension: DisplayPictureManager.maxDimension, + cropRect: cropRect, + resizeMode: .fill + )), .stripImageMetadata ] } From 56117d0a3c961ab929c89d6cd160d464a3894e51 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 6 Nov 2025 12:02:55 +1100 Subject: [PATCH 160/162] fix: Pro badges in Session heading did not respond to dev settings changes --- .../DeveloperSettings/DeveloperSettingsProViewModel.swift | 1 + SessionMessagingKit/Utilities/SessionProState.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift index 4d6ff0165e..9d1ff903fd 100644 --- a/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift +++ b/Session/Settings/DeveloperSettings/DeveloperSettingsProViewModel.swift @@ -368,6 +368,7 @@ class DeveloperSettingsProViewModel: SessionTableViewModel, NavigatableStateHold feature: .mockCurrentUserSessionPro, to: !state.mockCurrentUserSessionPro ) + dependencies[singleton: .sessionProState].isSessionProSubject.send(!state.mockCurrentUserSessionPro) } ), SessionCell.Info( diff --git a/SessionMessagingKit/Utilities/SessionProState.swift b/SessionMessagingKit/Utilities/SessionProState.swift index b6e3aaaa83..e90ce744f4 100644 --- a/SessionMessagingKit/Utilities/SessionProState.swift +++ b/SessionMessagingKit/Utilities/SessionProState.swift @@ -20,7 +20,7 @@ public class SessionProState: SessionProManagerType { public var isSessionProSubject: CurrentValueSubject public var isSessionProPublisher: AnyPublisher { isSessionProSubject - .filter { $0 } + .compactMap { $0 } .eraseToAnyPublisher() } From c5942a2a0b92027ae30fcb4845316d7d2f35da65 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 6 Nov 2025 13:18:46 +1100 Subject: [PATCH 161/162] fix: remove copy account id action in message info screen --- Session/Conversations/Context Menu/ContextMenuVC+Action.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index a424718daa..8611346309 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -251,7 +251,8 @@ extension ContextMenuVC { }() let canCopySessionId: Bool = ( cellViewModel.variant == .standardIncoming && - cellViewModel.threadVariant != .community + cellViewModel.threadVariant != .community && + !forMessageInfoScreen ) let canDelete: Bool = (MessageViewModel.DeletionBehaviours.deletionActions( for: [cellViewModel], From 9f3bebb24553575fa1b1eb0ef31c88a29b6a32f9 Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 6 Nov 2025 14:52:21 +1100 Subject: [PATCH 162/162] fix: Context Menu Screen for RTL --- .../Context Menu/ContextMenuVC+Action.swift | 2 +- .../Context Menu/ContextMenuVC.swift | 46 +++++++++++-------- .../Utilities/UIImage+Utilities.swift | 5 ++ 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 8611346309..c122b2b28c 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -77,7 +77,7 @@ extension ContextMenuVC { static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( - icon: Lucide.image(icon: .reply, size: 24), + icon: Dependencies.isRTL ? Lucide.image(icon: .reply, size: 24)?.flippedHorizontally() : Lucide.image(icon: .reply, size: 24), title: "reply".localized(), shouldDismissInfoScreen: true, accessibilityLabel: "Reply to message" diff --git a/Session/Conversations/Context Menu/ContextMenuVC.swift b/Session/Conversations/Context Menu/ContextMenuVC.swift index 768cbb59c1..1206ba0643 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC.swift @@ -143,7 +143,7 @@ final class ContextMenuVC: UIViewController { emojiBarBackgroundView.pin(to: emojiBar) emojiBar.addSubview(emojiPlusButton) - emojiPlusButton.pin(.right, to: .right, of: emojiBar, withInset: -Values.smallSpacing) + emojiPlusButton.pin(.trailing, to: .trailing, of: emojiBar, withInset: -Values.smallSpacing) emojiPlusButton.center(.vertical, in: emojiBar) let emojiBarStackView = UIStackView( @@ -156,8 +156,8 @@ final class ContextMenuVC: UIViewController { emojiBarStackView.layoutMargins = UIEdgeInsets(top: 0, left: Values.smallSpacing, bottom: 0, right: Values.smallSpacing) emojiBarStackView.isLayoutMarginsRelativeArrangement = true emojiBar.addSubview(emojiBarStackView) - emojiBarStackView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: emojiBar) - emojiBarStackView.pin(.right, to: .left, of: emojiPlusButton) + emojiBarStackView.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: emojiBar) + emojiBarStackView.pin(.trailing, to: .leading, of: emojiPlusButton) // Hide the emoji bar if we have no emoji actions emojiBar.isHidden = emojiBarStackView.arrangedSubviews.isEmpty @@ -188,10 +188,10 @@ final class ContextMenuVC: UIViewController { timestampLabel.center(.vertical, in: snapshot) if cellViewModel.variant == .standardOutgoing { - timestampLabel.pin(.right, to: .left, of: snapshot, withInset: -Values.smallSpacing) + timestampLabel.pin(.trailing, to: .leading, of: snapshot, withInset: -Values.smallSpacing) } else { - timestampLabel.pin(.left, to: .right, of: snapshot, withInset: Values.smallSpacing) + timestampLabel.pin(.leading, to: .trailing, of: snapshot, withInset: Values.smallSpacing) } view.addSubview(fallbackTimestampLabel) @@ -199,14 +199,14 @@ final class ContextMenuVC: UIViewController { fallbackTimestampLabel.set(.height, to: ContextMenuVC.actionViewHeight) if cellViewModel.variant == .standardOutgoing { - fallbackTimestampLabel.textAlignment = .right - fallbackTimestampLabel.pin(.right, to: .left, of: menuView, withInset: -Values.mediumSpacing) - fallbackTimestampLabel.pin(.left, to: .left, of: view, withInset: Values.mediumSpacing) + fallbackTimestampLabel.textAlignment = Dependencies.isRTL ? .left : .right + fallbackTimestampLabel.pin(.trailing, to: .leading, of: menuView, withInset: -Values.mediumSpacing) + fallbackTimestampLabel.pin(.leading, to: .leading, of: view, withInset: Values.mediumSpacing) } else { - fallbackTimestampLabel.textAlignment = .left - fallbackTimestampLabel.pin(.left, to: .right, of: menuView, withInset: Values.mediumSpacing) - fallbackTimestampLabel.pin(.right, to: .right, of: view, withInset: -Values.mediumSpacing) + fallbackTimestampLabel.textAlignment = Dependencies.isRTL ? .right : .left + fallbackTimestampLabel.pin(.leading, to: .trailing, of: menuView, withInset: Values.mediumSpacing) + fallbackTimestampLabel.pin(.trailing, to: .trailing, of: view, withInset: -Values.mediumSpacing) } // Constrains @@ -219,9 +219,15 @@ final class ContextMenuVC: UIViewController { self.timestampLabel.isHidden = { switch cellViewModel.variant { case .standardOutgoing: + if Dependencies.isRTL { + return ((self.targetFrame.maxX + timestampSize.width + Values.mediumSpacing) > UIScreen.main.bounds.width) + } return ((self.targetFrame.minX - timestampSize.width - Values.mediumSpacing) < 0) default: + if Dependencies.isRTL { + return ((self.targetFrame.minX - timestampSize.width - Values.mediumSpacing) < 0) + } return ((self.targetFrame.maxX + timestampSize.width + Values.mediumSpacing) > UIScreen.main.bounds.width) } }() @@ -234,15 +240,18 @@ final class ContextMenuVC: UIViewController { switch cellViewModel.variant { case .standardOutgoing, .standardOutgoingDeleted, .standardOutgoingDeletedLocally: - menuView.pin(.right, to: .right, of: view, withInset: -(UIScreen.main.bounds.width - targetFrame.maxX)) - emojiBar.pin(.right, to: .right, of: view, withInset: -(UIScreen.main.bounds.width - targetFrame.maxX)) + let inset: CGFloat = Dependencies.isRTL ? -targetFrame.minX : -(UIScreen.main.bounds.width - targetFrame.maxX) + menuView.pin(.trailing, to: .trailing, of: view, withInset: inset) + emojiBar.pin(.trailing, to: .trailing, of: view, withInset: inset) case .standardIncoming, .standardIncomingDeleted, .standardIncomingDeletedLocally: - menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX) - emojiBar.pin(.left, to: .left, of: view, withInset: targetFrame.minX) + let inset: CGFloat = Dependencies.isRTL ? (UIScreen.main.bounds.width - targetFrame.maxX) : targetFrame.minX + menuView.pin(.leading, to: .leading, of: view, withInset: inset) + emojiBar.pin(.leading, to: .leading, of: view, withInset: inset) default: // Should generally only be the 'delete' action - menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX) + let inset: CGFloat = Dependencies.isRTL ? (UIScreen.main.bounds.width - targetFrame.maxX) : targetFrame.minX + menuView.pin(.leading, to: .leading, of: view, withInset: inset) } // Tap gesture @@ -281,10 +290,7 @@ final class ContextMenuVC: UIViewController { initialSpringVelocity: 0.6, options: .curveEaseInOut, animations: { [weak self] in - self?.snapshot.pin(.left, to: .left, of: view, withInset: targetFrame.origin.x) - self?.snapshot.pin(.top, to: .top, of: view, withInset: targetFrame.origin.y) - self?.snapshot.set(.width, to: targetFrame.width) - self?.snapshot.set(.height, to: targetFrame.height) + self?.snapshot.frame = targetFrame self?.snapshot.superview?.setNeedsLayout() self?.snapshot.superview?.layoutIfNeeded() }, diff --git a/SessionUIKit/Utilities/UIImage+Utilities.swift b/SessionUIKit/Utilities/UIImage+Utilities.swift index 0c2e894c15..09806d8ad4 100644 --- a/SessionUIKit/Utilities/UIImage+Utilities.swift +++ b/SessionUIKit/Utilities/UIImage+Utilities.swift @@ -87,4 +87,9 @@ public extension UIImage { return renderedImage } + + func flippedHorizontally() -> UIImage? { + guard let cgImage = self.cgImage else { return nil } + return UIImage(cgImage: cgImage, scale: scale, orientation: .upMirrored) + } }

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