Merge pull request #365 from oxen-io/deferred-attachment-downloads

Don't Auto-Download Attachments from Untrusted Contacts
This commit is contained in:
Niels Andriesse 2021-04-09 10:36:42 +10:00 committed by GitHub
commit 51576acec1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 332 additions and 119 deletions

View File

@ -294,6 +294,8 @@
B8F5F58325EC94A6003BF8D4 /* Collection+Subscripting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */; };
B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */; };
B8F5F61B25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F61A25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift */; };
B8F5F71A25F1B35C003BF8D4 /* MediaPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */; };
B8F5F72325F1B4CA003BF8D4 /* DownloadAttachmentModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F72225F1B4CA003BF8D4 /* DownloadAttachmentModal.swift */; };
B8FF8DAE25C0D00F004D1F22 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; };
B8FF8DAF25C0D00F004D1F22 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; };
B8FF8E6225C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 in Resources */ = {isa = PBXBuildFile; fileRef = B8FF8E6125C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 */; };
@ -1303,6 +1305,8 @@
B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Subscripting.swift"; sourceTree = "<group>"; };
B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtractionNotification.swift; sourceTree = "<group>"; };
B8F5F61A25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtractionNotificationInfoMessage.swift; sourceTree = "<group>"; };
B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlaceholderView.swift; sourceTree = "<group>"; };
B8F5F72225F1B4CA003BF8D4 /* DownloadAttachmentModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadAttachmentModal.swift; sourceTree = "<group>"; };
B8FF8E6125C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; name = "GeoLite2-Country-Blocks-IPv4"; path = "Countries/GeoLite2-Country-Blocks-IPv4"; sourceTree = "<group>"; };
B8FF8E7325C10FC3004D1F22 /* GeoLite2-Country-Locations-English */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; name = "GeoLite2-Country-Locations-English"; path = "Countries/GeoLite2-Country-Locations-English"; sourceTree = "<group>"; };
B8FF8EA525C11FEF004D1F22 /* IPv4.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPv4.swift; sourceTree = "<group>"; };
@ -2186,6 +2190,7 @@
B8569AD225CBA13D00DBA3DB /* MediaTextOverlayView.swift */,
3488F9352191CC4000E524CC /* MediaView.swift */,
B8041A9425C8FA1D003C2166 /* MediaLoaderView.swift */,
B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */,
C328251E25CA3A900062D0A7 /* QuoteView.swift */,
34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */,
C328250E25CA06020062D0A7 /* VoiceMessageView.swift */,
@ -2213,6 +2218,7 @@
isa = PBXGroup;
children = (
B897621B25D201F7004F83B2 /* ScrollToBottomButton.swift */,
B8F5F72225F1B4CA003BF8D4 /* DownloadAttachmentModal.swift */,
C3A76A8C25DB83F90074CB90 /* PermissionMissingModal.swift */,
B821494525D4D6FF009C0F2A /* URLModal.swift */,
B821494E25D4E163009C0F2A /* BodyTextView.swift */,
@ -4996,6 +5002,7 @@
3496957121A301A100DCFE74 /* OWSBackupImportJob.m in Sources */,
34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */,
C331FFFE2558FF3B00070591 /* ConversationCell.swift in Sources */,
B8F5F72325F1B4CA003BF8D4 /* DownloadAttachmentModal.swift in Sources */,
C3DFFAC623E96F0D0058DAF8 /* Sheet.swift in Sources */,
C31FFE57254A5FFE00F19441 /* KeyPairUtilities.swift in Sources */,
B8D84EA325DF745A005A043E /* LinkPreviewState.swift in Sources */,
@ -5013,6 +5020,7 @@
C328254925CA60E60062D0A7 /* ContextMenuVC+Action.swift in Sources */,
4542DF54208D40AC007B4E76 /* LoadingViewController.swift in Sources */,
34D5CCA91EAE3D30005515DB /* AvatarViewHelper.m in Sources */,
B8F5F71A25F1B35C003BF8D4 /* MediaPlaceholderView.swift in Sources */,
341341EF2187467A00192D59 /* ConversationViewModel.m in Sources */,
4C21D5D8223AC60F00EF8A77 /* PhotoCapture.swift in Sources */,
C331FFF32558FF0300070591 /* PathStatusView.swift in Sources */,
@ -5233,7 +5241,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 211;
CURRENT_PROJECT_VERSION = 214;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -5254,7 +5262,7 @@
INFOPLIST_FILE = SessionShareExtension/Meta/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.9.4;
MARKETING_VERSION = 1.9.5;
MTL_ENABLE_DEBUG_INFO = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -5302,7 +5310,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 211;
CURRENT_PROJECT_VERSION = 214;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO;
@ -5328,7 +5336,7 @@
INFOPLIST_FILE = SessionShareExtension/Meta/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.9.4;
MARKETING_VERSION = 1.9.5;
MTL_ENABLE_DEBUG_INFO = NO;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -5363,7 +5371,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 211;
CURRENT_PROJECT_VERSION = 214;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = "$(inherited)";
@ -5382,7 +5390,7 @@
INFOPLIST_FILE = SessionNotificationServiceExtension/Meta/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.9.4;
MARKETING_VERSION = 1.9.5;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension";
@ -5433,7 +5441,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 211;
CURRENT_PROJECT_VERSION = 214;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = SUQ8J2PCT7;
ENABLE_NS_ASSERTIONS = NO;
@ -5457,7 +5465,7 @@
INFOPLIST_FILE = SessionNotificationServiceExtension/Meta/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.9.4;
MARKETING_VERSION = 1.9.5;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension";
@ -6318,7 +6326,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 211;
CURRENT_PROJECT_VERSION = 214;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
@ -6354,7 +6362,7 @@
"$(SRCROOT)",
);
LLVM_LTO = NO;
MARKETING_VERSION = 1.9.4;
MARKETING_VERSION = 1.9.5;
OTHER_LDFLAGS = "$(inherited)";
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
@ -6386,7 +6394,7 @@
CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 211;
CURRENT_PROJECT_VERSION = 214;
DEVELOPMENT_TEAM = SUQ8J2PCT7;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
@ -6422,7 +6430,7 @@
"$(SRCROOT)",
);
LLVM_LTO = NO;
MARKETING_VERSION = 1.9.4;
MARKETING_VERSION = 1.9.5;
OTHER_LDFLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger";
PRODUCT_NAME = Session;

View File

@ -376,43 +376,69 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc
}
func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer) {
func confirmDownload() {
let modal = DownloadAttachmentModal(viewItem: viewItem)
modal.modalPresentationStyle = .overFullScreen
modal.modalTransitionStyle = .crossDissolve
present(modal, animated: true, completion: nil)
}
if let message = viewItem.interaction as? TSOutgoingMessage, message.messageState == .failed {
// Show the failed message sheet
showFailedMessageSheet(for: message)
} else {
switch viewItem.messageCellType {
case .audio: playOrPauseAudio(for: viewItem)
case .audio:
if viewItem.interaction is TSIncomingMessage,
let thread = self.thread as? TSContactThread,
Storage.shared.getContact(with: thread.contactIdentifier())?.isTrusted != true {
confirmDownload()
} else {
playOrPauseAudio(for: viewItem)
}
case .mediaMessage:
guard let index = viewItems.firstIndex(where: { $0 === viewItem }),
let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell, let albumView = cell.albumView else { return }
let locationInCell = gestureRecognizer.location(in: cell)
// Figure out whether the "read more" button was tapped
if let overlayView = cell.mediaTextOverlayView {
let locationInOverlayView = cell.convert(locationInCell, to: overlayView)
if let readMoreButton = overlayView.readMoreButton, readMoreButton.frame.contains(locationInOverlayView) {
return showFullText(viewItem) // HACK: This is a dirty way to do this
let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell else { return }
if viewItem.interaction is TSIncomingMessage,
let thread = self.thread as? TSContactThread,
Storage.shared.getContact(with: thread.contactIdentifier())?.isTrusted != true {
confirmDownload()
} else {
guard let albumView = cell.albumView else { return }
let locationInCell = gestureRecognizer.location(in: cell)
// Figure out whether the "read more" button was tapped
if let overlayView = cell.mediaTextOverlayView {
let locationInOverlayView = cell.convert(locationInCell, to: overlayView)
if let readMoreButton = overlayView.readMoreButton, readMoreButton.frame.contains(locationInOverlayView) {
return showFullText(viewItem) // HACK: This is a dirty way to do this
}
}
}
// Otherwise, figure out which of the media views was tapped
let locationInAlbumView = cell.convert(locationInCell, to: albumView)
guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return }
if albumView.isMoreItemsView(mediaView: mediaView) && viewItem.mediaAlbumHasFailedAttachment() {
// TODO: Tapped a failed incoming attachment
}
let attachment = mediaView.attachment
if let pointer = attachment as? TSAttachmentPointer {
if pointer.state == .failed {
// Otherwise, figure out which of the media views was tapped
let locationInAlbumView = cell.convert(locationInCell, to: albumView)
guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return }
if albumView.isMoreItemsView(mediaView: mediaView) && viewItem.mediaAlbumHasFailedAttachment() {
// TODO: Tapped a failed incoming attachment
}
let attachment = mediaView.attachment
if let pointer = attachment as? TSAttachmentPointer {
if pointer.state == .failed {
// TODO: Tapped a failed incoming attachment
}
}
guard let stream = attachment as? TSAttachmentStream else { return }
let gallery = MediaGallery(thread: thread, options: [ .sliderEnabled, .showAllMediaButton ])
gallery.presentDetailView(fromViewController: self, mediaAttachment: stream)
}
guard let stream = attachment as? TSAttachmentStream else { return }
let gallery = MediaGallery(thread: thread, options: [ .sliderEnabled, .showAllMediaButton ])
gallery.presentDetailView(fromViewController: self, mediaAttachment: stream)
case .genericAttachment:
// Open the document if possible
guard let url = viewItem.attachmentStream?.originalMediaURL else { return }
let shareVC = UIActivityViewController(activityItems: [ url ], applicationActivities: nil)
navigationController!.present(shareVC, animated: true, completion: nil)
if viewItem.interaction is TSIncomingMessage,
let thread = self.thread as? TSContactThread,
Storage.shared.getContact(with: thread.contactIdentifier())?.isTrusted != true {
confirmDownload()
} else {
// Open the document if possible
guard let url = viewItem.attachmentStream?.originalMediaURL else { return }
let shareVC = UIActivityViewController(activityItems: [ url ], applicationActivities: nil)
navigationController!.present(shareVC, animated: true, completion: nil)
}
case .textOnlyMessage:
if let preview = viewItem.linkPreview, let urlAsString = preview.urlString, let url = URL(string: urlAsString) {
// Open the link preview URL

View File

@ -0,0 +1,61 @@
final class MediaPlaceholderView : UIView {
private let viewItem: ConversationViewItem
private let textColor: UIColor
// MARK: Settings
private static let iconSize: CGFloat = 24
private static let iconImageViewSize: CGFloat = 40
// MARK: Lifecycle
init(viewItem: ConversationViewItem, textColor: UIColor) {
self.viewItem = viewItem
self.textColor = textColor
super.init(frame: CGRect.zero)
setUpViewHierarchy()
}
override init(frame: CGRect) {
preconditionFailure("Use init(viewItem:textColor:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(viewItem:textColor:) instead.")
}
private func setUpViewHierarchy() {
let (iconName, attachmentDescription): (String, String) = {
guard let message = viewItem.interaction as? TSIncomingMessage else { return ("actionsheet_document_black", "file") } // Should never occur
var attachments: [TSAttachment] = []
Storage.read { transaction in
attachments = message.attachments(with: transaction)
}
guard let contentType = attachments.first?.contentType else { return ("actionsheet_document_black", "file") } // Should never occur
if MIMETypeUtil.isAudio(contentType) { return ("attachment_audio", "audio") }
if MIMETypeUtil.isImage(contentType) || MIMETypeUtil.isVideo(contentType) { return ("actionsheet_camera_roll_black", "media") }
return ("actionsheet_document_black", "file")
}()
// Image view
let iconSize = MediaPlaceholderView.iconSize
let icon = UIImage(named: iconName)?.withTint(textColor)?.resizedImage(to: CGSize(width: iconSize, height: iconSize))
let imageView = UIImageView(image: icon)
imageView.contentMode = .center
let iconImageViewSize = MediaPlaceholderView.iconImageViewSize
imageView.set(.width, to: iconImageViewSize)
imageView.set(.height, to: iconImageViewSize)
// Body label
let titleLabel = UILabel()
titleLabel.lineBreakMode = .byTruncatingTail
titleLabel.text = "Tap to download \(attachmentDescription)"
titleLabel.textColor = textColor
titleLabel.font = .systemFont(ofSize: Values.mediumFontSize)
// Stack view
let stackView = UIStackView(arrangedSubviews: [ imageView, titleLabel ])
stackView.axis = .horizontal
stackView.alignment = .center
stackView.isLayoutMarginsRelativeArrangement = true
stackView.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 12)
addSubview(stackView)
stackView.pin(to: self, withInset: Values.smallSpacing)
}
}

View File

@ -302,6 +302,11 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
private func populateContentView(for viewItem: ConversationViewItem) {
snContentView.subviews.forEach { $0.removeFromSuperview() }
func showMediaPlaceholder() {
let mediaPlaceholderView = MediaPlaceholderView(viewItem: viewItem, textColor: bodyLabelTextColor)
snContentView.addSubview(mediaPlaceholderView)
mediaPlaceholderView.pin(to: snContentView)
}
albumView = nil
bodyTextView = nil
mediaTextOverlayView = nil
@ -337,34 +342,52 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate {
stackView.pin(to: snContentView, withInset: inset)
}
case .mediaMessage:
guard let cache = delegate?.getMediaCache() else { preconditionFailure() }
let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: viewItem)
let albumView = MediaAlbumView(mediaCache: cache, items: viewItem.mediaAlbumItems!, isOutgoing: isOutgoing, maxMessageWidth: maxMessageWidth)
self.albumView = albumView
snContentView.addSubview(albumView)
let size = getSize(for: viewItem)
albumView.set(.width, to: size.width)
albumView.set(.height, to: size.height)
albumView.pin(to: snContentView)
albumView.loadMedia()
albumView.layer.mask = bubbleViewMaskLayer
if let message = viewItem.interaction as? TSMessage, let body = message.body, body.count > 0,
let delegate = delegate { // delegate should always be set at this point
let overlayView = MediaTextOverlayView(viewItem: viewItem, albumViewWidth: size.width, delegate: delegate)
self.mediaTextOverlayView = overlayView
snContentView.addSubview(overlayView)
overlayView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: snContentView)
if viewItem.interaction is TSIncomingMessage,
let thread = viewItem.interaction.thread as? TSContactThread,
Storage.shared.getContact(with: thread.contactIdentifier())?.isTrusted != true {
showMediaPlaceholder()
} else {
guard let cache = delegate?.getMediaCache() else { preconditionFailure() }
let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: viewItem)
let albumView = MediaAlbumView(mediaCache: cache, items: viewItem.mediaAlbumItems!, isOutgoing: isOutgoing, maxMessageWidth: maxMessageWidth)
self.albumView = albumView
snContentView.addSubview(albumView)
let size = getSize(for: viewItem)
albumView.set(.width, to: size.width)
albumView.set(.height, to: size.height)
albumView.pin(to: snContentView)
albumView.loadMedia()
albumView.layer.mask = bubbleViewMaskLayer
if let message = viewItem.interaction as? TSMessage, let body = message.body, body.count > 0,
let delegate = delegate { // delegate should always be set at this point
let overlayView = MediaTextOverlayView(viewItem: viewItem, albumViewWidth: size.width, delegate: delegate)
self.mediaTextOverlayView = overlayView
snContentView.addSubview(overlayView)
overlayView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.bottom, UIView.HorizontalEdge.right ], to: snContentView)
}
unloadContent = { albumView.unloadMedia() }
}
unloadContent = { albumView.unloadMedia() }
case .audio:
let voiceMessageView = VoiceMessageView(viewItem: viewItem)
snContentView.addSubview(voiceMessageView)
voiceMessageView.pin(to: snContentView)
viewItem.lastAudioMessageView = voiceMessageView
if viewItem.interaction is TSIncomingMessage,
let thread = viewItem.interaction.thread as? TSContactThread,
Storage.shared.getContact(with: thread.contactIdentifier())?.isTrusted != true {
showMediaPlaceholder()
} else {
let voiceMessageView = VoiceMessageView(viewItem: viewItem)
snContentView.addSubview(voiceMessageView)
voiceMessageView.pin(to: snContentView)
viewItem.lastAudioMessageView = voiceMessageView
}
case .genericAttachment:
let documentView = DocumentView(viewItem: viewItem, textColor: bodyLabelTextColor)
snContentView.addSubview(documentView)
documentView.pin(to: snContentView)
if viewItem.interaction is TSIncomingMessage,
let thread = viewItem.interaction.thread as? TSContactThread,
Storage.shared.getContact(with: thread.contactIdentifier())?.isTrusted != true {
showMediaPlaceholder()
} else {
let documentView = DocumentView(viewItem: viewItem, textColor: bodyLabelTextColor)
snContentView.addSubview(documentView)
documentView.pin(to: snContentView)
}
default: return
}
}

View File

@ -0,0 +1,79 @@
final class DownloadAttachmentModal : Modal {
private let viewItem: ConversationViewItem
// MARK: Lifecycle
init(viewItem: ConversationViewItem) {
self.viewItem = viewItem
super.init(nibName: nil, bundle: nil)
}
override init(nibName: String?, bundle: Bundle?) {
preconditionFailure("Use init(viewItem:) instead.")
}
required init?(coder: NSCoder) {
preconditionFailure("Use init(viewItem:) instead.")
}
override func populateContentView() {
guard let publicKey = (viewItem.interaction as? TSIncomingMessage)?.authorId else { return }
// Name
let name = Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey
// Title
let titleLabel = UILabel()
titleLabel.textColor = Colors.text
titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
titleLabel.text = "Trust \(name)?"
titleLabel.textAlignment = .center
// Message
let messageLabel = UILabel()
messageLabel.textColor = Colors.text
messageLabel.font = .systemFont(ofSize: Values.smallFontSize)
let message = "Are you sure you want to download media sent by \(name)?"
let attributedMessage = NSMutableAttributedString(string: message)
attributedMessage.addAttributes([ .font : UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], range: (message as NSString).range(of: name))
messageLabel.attributedText = attributedMessage
messageLabel.numberOfLines = 0
messageLabel.lineBreakMode = .byWordWrapping
messageLabel.textAlignment = .center
// Download button
let downloadButton = UIButton()
downloadButton.set(.height, to: Values.mediumButtonHeight)
downloadButton.layer.cornerRadius = Modal.buttonCornerRadius
downloadButton.backgroundColor = Colors.buttonBackground
downloadButton.titleLabel!.font = .systemFont(ofSize: Values.smallFontSize)
downloadButton.setTitleColor(Colors.text, for: UIControl.State.normal)
downloadButton.setTitle("Download", for: UIControl.State.normal)
downloadButton.addTarget(self, action: #selector(trust), for: UIControl.Event.touchUpInside)
// Button stack view
let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, downloadButton ])
buttonStackView.axis = .horizontal
buttonStackView.spacing = Values.mediumSpacing
buttonStackView.distribution = .fillEqually
// Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel, buttonStackView ])
mainStackView.axis = .vertical
mainStackView.spacing = Values.largeSpacing
contentView.addSubview(mainStackView)
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing)
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.largeSpacing)
contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing)
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.largeSpacing)
}
// MARK: Interaction
@objc private func trust() {
guard let message = viewItem.interaction as? TSIncomingMessage else { return }
let publicKey = message.authorId
let contact = Storage.shared.getContact(with: publicKey) ?? Contact(sessionID: publicKey)
contact.isTrusted = true
Storage.write(with: { transaction in
Storage.shared.setContact(contact, using: transaction)
message.touch(with: transaction)
}, completion: {
Storage.shared.resumeAttachmentDownloadJobsIfNeeded(for: message.uniqueThreadId)
})
presentingViewController?.dismiss(animated: true, completion: nil)
}
}

View File

@ -10,6 +10,8 @@ public class Contact : NSObject, NSCoding { // NSObject/NSCoding conformance is
@objc public var profilePictureEncryptionKey: OWSAES256Key?
/// The ID of the thread associated with this contact.
@objc public var threadID: String?
/// This flag is used to determine whether we should auto-download files sent by this contact.
@objc public var isTrusted = false
// MARK: Name
/// The name of the contact. Use this whenever you need the "real", underlying name of a user (e.g. when sending a message).
@ -56,6 +58,7 @@ public class Contact : NSObject, NSCoding { // NSObject/NSCoding conformance is
public required init?(coder: NSCoder) {
guard let sessionID = coder.decodeObject(forKey: "sessionID") as! String? else { return nil }
self.sessionID = sessionID
isTrusted = coder.decodeBool(forKey: "isTrusted")
if let name = coder.decodeObject(forKey: "displayName") as! String? { self.name = name }
if let nickname = coder.decodeObject(forKey: "nickname") as! String? { self.nickname = nickname }
if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL }
@ -72,6 +75,7 @@ public class Contact : NSObject, NSCoding { // NSObject/NSCoding conformance is
coder.encode(profilePictureFileName, forKey: "profilePictureFileName")
coder.encode(profilePictureEncryptionKey, forKey: "profilePictureEncryptionKey")
coder.encode(threadID, forKey: "threadID")
coder.encode(isTrusted, forKey: "isTrusted")
}
// MARK: Equality

View File

@ -9,12 +9,18 @@ extension Storage {
Storage.read { transaction in
result = transaction.object(forKey: sessionID, inCollection: Storage.contactCollection) as? Contact
}
if let result = result, result.sessionID == getUserHexEncodedPublicKey() {
result.isTrusted = true // Always trust ourselves
}
return result
}
@objc(setContact:usingTransaction:)
public func setContact(_ contact: Contact, using transaction: Any) {
let transaction = transaction as! YapDatabaseReadWriteTransaction
if contact.sessionID == getUserHexEncodedPublicKey() {
contact.isTrusted = true // Always trust ourselves
}
transaction.setObject(contact, forKey: contact.sessionID, inCollection: Storage.contactCollection)
transaction.addCompletionQueue(DispatchQueue.main) {
let notificationCenter = NotificationCenter.default

View File

@ -72,6 +72,26 @@ extension Storage {
#endif
return result.first
}
public func getAttachmentDownloadJobs(for threadID: String) -> [AttachmentDownloadJob] {
var result: [AttachmentDownloadJob] = []
Storage.read { transaction in
transaction.enumerateRows(inCollection: AttachmentDownloadJob.collection) { _, object, _, _ in
guard let job = object as? AttachmentDownloadJob, job.threadID == threadID else { return }
result.append(job)
}
}
return result
}
public func resumeAttachmentDownloadJobsIfNeeded(for threadID: String) {
let jobs = getAttachmentDownloadJobs(for: threadID)
jobs.forEach { job in
job.delegate = JobQueue.shared
job.isDeferred = false
job.execute()
}
}
public func getMessageSendJob(for messageSendJobID: String) -> MessageSendJob? {
var result: MessageSendJob?

View File

@ -5,9 +5,11 @@ import SignalCoreKit
public final class AttachmentDownloadJob : NSObject, Job, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility
public let attachmentID: String
public let tsMessageID: String
public let threadID: String
public var delegate: JobDelegate?
public var id: String?
public var failureCount: UInt = 0
public var isDeferred = false
public enum Error : LocalizedError {
case noAttachment
@ -26,31 +28,38 @@ public final class AttachmentDownloadJob : NSObject, Job, NSCoding { // NSObject
public static let maxFailureCount: UInt = 20
// MARK: Initialization
public init(attachmentID: String, tsMessageID: String) {
public init(attachmentID: String, tsMessageID: String, threadID: String) {
self.attachmentID = attachmentID
self.tsMessageID = tsMessageID
self.threadID = threadID
}
// MARK: Coding
public init?(coder: NSCoder) {
guard let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?,
let tsMessageID = coder.decodeObject(forKey: "tsIncomingMessageID") as! String?,
let threadID = coder.decodeObject(forKey: "threadID") as! String?,
let id = coder.decodeObject(forKey: "id") as! String? else { return nil }
self.attachmentID = attachmentID
self.tsMessageID = tsMessageID
self.threadID = threadID
self.id = id
self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0
self.isDeferred = coder.decodeBool(forKey: "isDeferred")
}
public func encode(with coder: NSCoder) {
coder.encode(attachmentID, forKey: "attachmentID")
coder.encode(tsMessageID, forKey: "tsIncomingMessageID")
coder.encode(threadID, forKey: "threadID")
coder.encode(id, forKey: "id")
coder.encode(failureCount, forKey: "failureCount")
coder.encode(isDeferred, forKey: "isDeferred")
}
// MARK: Running
public func execute() {
guard !isDeferred else { return }
if TSAttachment.fetch(uniqueId: attachmentID) is TSAttachmentStream {
// FIXME: It's not clear * how * this happens, but apparently we can get to this point
// from time to time with an already downloaded attachment.
@ -99,9 +108,11 @@ public final class AttachmentDownloadJob : NSObject, Job, NSCoding { // NSObject
return handleFailure(error)
}
OWSFileSystem.deleteFile(temporaryFilePath.absoluteString)
storage.write { transaction in
storage.write(with: { transaction in
storage.persist(stream, associatedWith: self.tsMessageID, using: transaction)
}
}, completion: {
self.handleSuccess()
})
}.catch(on: DispatchQueue.global()) { error in
handleFailure(error)
}
@ -129,9 +140,11 @@ public final class AttachmentDownloadJob : NSObject, Job, NSCoding { // NSObject
return handleFailure(error)
}
OWSFileSystem.deleteFile(temporaryFilePath.absoluteString)
storage.write { transaction in
storage.write(with: { transaction in
storage.persist(stream, associatedWith: self.tsMessageID, using: transaction)
}
}, completion: {
self.handleSuccess()
})
}.catch(on: DispatchQueue.global()) { error in
handleFailure(error)
}

View File

@ -3,6 +3,8 @@ import SessionUtilitiesKit
@objc(SNJobQueue)
public final class JobQueue : NSObject, JobDelegate {
private static var jobIDs: [UInt64:UInt64] = [:]
@objc public static let shared = JobQueue()
@objc public func add(_ job: Job, using transaction: Any) {
@ -14,7 +16,15 @@ public final class JobQueue : NSObject, JobDelegate {
}
@objc public func addWithoutExecuting(_ job: Job, using transaction: Any) {
job.id = String(NSDate.millisecondTimestamp())
let timestamp = NSDate.millisecondTimestamp()
let count = JobQueue.jobIDs[timestamp] ?? 0
// When adding multiple jobs in rapid succession, timestamps might not be good enough as a unique ID. To
// deal with this we keep track of the number of jobs with a given timestamp and that to the end of the
// timestamp to make it a unique ID. We can't use a random number because we do still want to keep track
// of the order in which the jobs were added.
let id = String(timestamp) + String(count)
job.id = id
JobQueue.jobIDs[timestamp] = count + 1
SNMessagingKitConfiguration.shared.storage.persist(job, using: transaction)
job.delegate = self
}

View File

@ -278,8 +278,11 @@ extension MessageReceiver {
groupPublicKey: message.groupPublicKey, openGroupID: openGroupID, using: transaction) else { throw Error.noThread }
message.threadID = threadID
// Start attachment downloads if needed
let isContactTrusted = Storage.shared.getContact(with: message.sender!)?.isTrusted ?? false
let isGroup = message.groupPublicKey != nil || openGroupID != nil
attachmentsToDownload.forEach { attachmentID in
let downloadJob = AttachmentDownloadJob(attachmentID: attachmentID, tsMessageID: tsMessageID)
let downloadJob = AttachmentDownloadJob(attachmentID: attachmentID, tsMessageID: tsMessageID, threadID: threadID)
downloadJob.isDeferred = !isContactTrusted && !isGroup
if isMainAppAndActive {
JobQueue.shared.add(downloadJob, using: transaction)
} else {

View File

@ -4,7 +4,7 @@ public class ContactsMigration : OWSDatabaseMigration {
@objc
class func migrationId() -> String {
return "112"
return "001"
}
override public func runUp(completion: @escaping OWSDatabaseMigrationCompletion) {
@ -12,56 +12,16 @@ public class ContactsMigration : OWSDatabaseMigration {
}
private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) {
var contacts: Set<Contact> = []
var contacts: [Contact] = []
TSContactThread.enumerateCollectionObjects { object, _ in
guard let thread = object as? TSContactThread else { return }
let sessionID = thread.contactIdentifier()
if let contact = Storage.shared.getContact(with: sessionID) {
contact.isTrusted = true
contacts.append(contact)
}
}
Storage.write(with: { transaction in
// Current user
if let profile = OWSUserProfile.fetch(uniqueId: kLocalProfileUniqueId, transaction: transaction),
let sessionID = TSAccountManager.sharedInstance().localNumber() { // Should always exist
let contact = Contact(sessionID: sessionID)
contact.name = profile.profileName
contact.profilePictureURL = profile.avatarUrlPath
contact.profilePictureFileName = profile.avatarFileName
contact.profilePictureEncryptionKey = profile.profileKey
contacts.insert(contact)
}
// One-on-one chats
TSContactThread.enumerateCollectionObjects(with: transaction) { object, _ in
guard let thread = object as? TSContactThread else { return }
let sessionID = thread.contactIdentifier()
let contact = Contact(sessionID: sessionID)
var profileOrNil: OWSUserProfile? = nil
if sessionID == getUserHexEncodedPublicKey() {
profileOrNil = OWSProfileManager.shared().getLocalUserProfile(with: transaction)
} else if let profile = OWSUserProfile.fetch(uniqueId: sessionID, transaction: transaction) {
profileOrNil = profile
}
if let profile = profileOrNil {
contact.name = profile.profileName
contact.profilePictureURL = profile.avatarUrlPath
contact.profilePictureFileName = profile.avatarFileName
contact.profilePictureEncryptionKey = profile.profileKey
}
contact.threadID = thread.uniqueId
contacts.insert(contact)
}
// Closed groups
TSGroupThread.enumerateCollectionObjects(with: transaction) { object, _ in
guard let thread = object as? TSGroupThread, thread.isClosedGroup else { return }
let memberSessionIDs = thread.groupModel.groupMemberIds
memberSessionIDs.forEach { memberSessionID in
guard !contacts.contains(where: { $0.sessionID == memberSessionID }) else { return }
let contact = Contact(sessionID: memberSessionID)
if let profile = OWSUserProfile.fetch(uniqueId: memberSessionID, transaction: transaction) {
contact.name = profile.profileName
contact.profilePictureURL = profile.avatarUrlPath
contact.profilePictureFileName = profile.avatarFileName
contact.profilePictureEncryptionKey = profile.profileKey
}
// At this point we know we don't have a one-on-one thread with this contact
contacts.insert(contact)
}
}
// Save
contacts.forEach { contact in
Storage.shared.setContact(contact, using: transaction)
}