diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index cba721af..0103da39 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -289,6 +289,7 @@ D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; }; D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; }; D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */; }; + D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */; }; D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; }; D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */; }; D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */; }; @@ -674,6 +675,7 @@ D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = ""; }; D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = ""; }; D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUniqueTests.swift; sourceTree = ""; }; + D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveToPhotosActivity.swift; sourceTree = ""; }; D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = ""; }; D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCollectionViewCell.swift; sourceTree = ""; }; D6D12B57292D5B2C00D528E1 /* StatusActionAccountListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListViewController.swift; sourceTree = ""; }; @@ -1299,6 +1301,7 @@ D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */, D6AEBB3D2321638100E5038B /* UIActivity+Types.swift */, D6AEBB422321685E00E5038B /* OpenInSafariActivity.swift */, + D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */, ); path = Activities; sourceTree = ""; @@ -1974,6 +1977,7 @@ D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */, D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */, D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */, + D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */, D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */, D68A76EE295369C7001DA1B3 /* AcknowledgementsView.swift in Sources */, D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */, diff --git a/Tusker/Activities/SaveToPhotosActivity.swift b/Tusker/Activities/SaveToPhotosActivity.swift new file mode 100644 index 00000000..6bc802da --- /dev/null +++ b/Tusker/Activities/SaveToPhotosActivity.swift @@ -0,0 +1,71 @@ +// +// SaveToPhotosActivity.swift +// Tusker +// +// Created by Shadowfacts on 1/2/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import UniformTypeIdentifiers +import Photos + +class SaveToPhotosActivity: UIActivity { + + override class var activityCategory: UIActivity.Category { + return .action + } + override var activityType: UIActivity.ActivityType? { + return .saveToPhotos + } + + override var activityTitle: String? { + return "Save to Photos" + } + override var activityImage: UIImage? { + UIImage(systemName: "square.and.arrow.down") + } + + override func canPerform(withActivityItems activityItems: [Any]) -> Bool { + return activityItems.contains(where: { + if let url = $0 as? URL, + let type = UTType(filenameExtension: url.pathExtension){ + return type.conforms(to: .movie) || type.conforms(to: .video) || type.conforms(to: .image) + } else { + return false + } + }) + } + + private var url: URL? + private var type: UTType? + + override func prepare(withActivityItems activityItems: [Any]) { + for case let url as URL in activityItems { + self.url = url + type = UTType(filenameExtension: url.pathExtension)! + } + } + + override func perform() { + guard let url, + let type else { + return + } + PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in + guard case .authorized = status else { + return + } + PHPhotoLibrary.shared().performChanges { + if type.conforms(to: .movie) || type.conforms(to: .video) { + _ = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url) + } else { + _ = PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: url) + } + } completionHandler: { _, _ in + self.activityDidFinish(true) + } + } + } + +} diff --git a/Tusker/Activities/UIActivity+Types.swift b/Tusker/Activities/UIActivity+Types.swift index fae4b8d6..c9a9687b 100644 --- a/Tusker/Activities/UIActivity+Types.swift +++ b/Tusker/Activities/UIActivity+Types.swift @@ -11,5 +11,6 @@ import UIKit extension UIActivity.ActivityType { static let openInSafari = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).open_in_safari") + static let saveToPhotos = UIActivity.ActivityType("\(Bundle.main.bundleIdentifier!).save_to_photos") } diff --git a/Tusker/Screens/Account List/AccountListViewController.swift b/Tusker/Screens/Account List/AccountListViewController.swift index 6bf8dd3a..5e3747a3 100644 --- a/Tusker/Screens/Account List/AccountListViewController.swift +++ b/Tusker/Screens/Account List/AccountListViewController.swift @@ -15,7 +15,7 @@ class AccountListViewController: UIViewController, CollectionViewController { private let accountIDs: [String] var collectionView: UICollectionView! { - view as! UICollectionView + view as? UICollectionView } private var dataSource: UICollectionViewDiffableDataSource! diff --git a/Tusker/Screens/Large Image/LargeImageContentView.swift b/Tusker/Screens/Large Image/LargeImageContentView.swift index dc05fc9b..5568e0a6 100644 --- a/Tusker/Screens/Large Image/LargeImageContentView.swift +++ b/Tusker/Screens/Large Image/LargeImageContentView.swift @@ -31,13 +31,20 @@ class LargeImageImageContentView: UIImageView, LargeImageContentView { var animationImage: UIImage? { image! } var activityItemsForSharing: [Any] { - [image!] + guard let data else { + return [] + } + return [ImageActivityItemSource(data: data, url: url, image: image)] } weak var owner: LargeImageViewController? - private var sourceData: Data? + private let url: URL + private let data: Data? - init(image: UIImage) { + init(url: URL, data: Data?, image: UIImage) { + self.url = url + self.data = data + super.init(image: image) contentMode = .scaleAspectFit @@ -79,7 +86,7 @@ class LargeImageImageContentView: UIImageView, LargeImageContentView { } func grayscaleStateChanged() { - guard let data = sourceData else { + guard let data else { return } @@ -108,12 +115,15 @@ extension LargeImageImageContentView: ImageAnalysisInteractionDelegate { class LargeImageGifContentView: GIFImageView, LargeImageContentView { var animationImage: UIImage? { image } var activityItemsForSharing: [Any] { - // todo: should gifs share the data? - [image].compactMap { $0 } + [ImageActivityItemSource(data: gifController!.gifData, url: url, image: image)] } weak var owner: LargeImageViewController? - init(gifController: GIFController) { + private let url: URL + + init(url: URL, gifController: GIFController) { + self.url = url + super.init(image: gifController.lastFrame?.image) contentMode = .scaleAspectFit @@ -138,22 +148,22 @@ class LargeImageGifContentView: GIFImageView, LargeImageContentView { class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView { private(set) var animationImage: UIImage? var activityItemsForSharing: [Any] { - // todo: what should we share for gifvs? - // some SO posts indicate that just sharing a URL to the video should work, but that may need to be a local URL? - [] + [GifvActivityItemSource(asset: asset, attachment: attachment)] } weak var owner: LargeImageViewController? + private let attachment: Attachment private let asset: AVURLAsset private var videoSize: CGSize? override var intrinsicContentSize: CGSize { videoSize ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric) } - + init(attachment: Attachment, source: UIImageView) { precondition(attachment.kind == .gifv) + self.attachment = attachment self.asset = AVURLAsset(url: attachment.url) super.init(asset: asset, gravity: .resizeAspect) @@ -184,4 +194,78 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView { func grayscaleStateChanged() { // no-op, GifvAttachmentView observes the grayscale state itself } + +} + +fileprivate class ImageActivityItemSource: NSObject, UIActivityItemSource { + let data: Data + let url: URL + let image: UIImage? + + init(data: Data, url: URL, image: UIImage?) { + self.data = data + self.url = url + self.image = image + } + + func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any { + return url + } + + func activityViewController(_ activityViewController: UIActivityViewController, thumbnailImageForActivityType activityType: UIActivity.ActivityType?, suggestedSize size: CGSize) -> UIImage? { + return image + } + + func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? { + do { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(url.lastPathComponent) + try data.write(to: tempURL) + return tempURL + } catch { + return nil + } + } + + func activityViewController(_ activityViewController: UIActivityViewController, dataTypeIdentifierForActivityType activityType: UIActivity.ActivityType?) -> String { + return (UTType(filenameExtension: url.pathExtension) ?? .image).identifier + } +} + +fileprivate class GifvActivityItemSource: NSObject, UIActivityItemSource { + let asset: AVAsset + let attachment: Attachment + + init(asset: AVAsset, attachment: Attachment) { + self.asset = asset + self.attachment = attachment + } + + func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any { + return attachment.url + } + + func activityViewController(_ activityViewController: UIActivityViewController, thumbnailImageForActivityType activityType: UIActivity.ActivityType?, suggestedSize size: CGSize) -> UIImage? { + let generator = AVAssetImageGenerator(asset: self.asset) + generator.appliesPreferredTrackTransform = true + if let image = try? generator.copyCGImage(at: .zero, actualTime: nil) { + return UIImage(cgImage: image) + } else { + return nil + } + } + + func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? { + do { + let data = try Data(contentsOf: attachment.url) + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(attachment.url.lastPathComponent) + try data.write(to: tempURL) + return tempURL + } catch { + return nil + } + } + + func activityViewController(_ activityViewController: UIActivityViewController, dataTypeIdentifierForActivityType activityType: UIActivity.ActivityType?) -> String { + return (UTType(filenameExtension: attachment.url.pathExtension) ?? .video).identifier + } } diff --git a/Tusker/Screens/Large Image/LargeImageViewController.swift b/Tusker/Screens/Large Image/LargeImageViewController.swift index fd22ca0f..c9c08e77 100644 --- a/Tusker/Screens/Large Image/LargeImageViewController.swift +++ b/Tusker/Screens/Large Image/LargeImageViewController.swift @@ -381,7 +381,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma } @IBAction func sharePressed(_ sender: Any) { - let activityVC = UIActivityViewController(activityItems: contentView.activityItemsForSharing, applicationActivities: nil) + let activityVC = UIActivityViewController(activityItems: contentView.activityItemsForSharing, applicationActivities: [SaveToPhotosActivity()]) activityVC.popoverPresentationController?.sourceView = shareImage present(activityVC, animated: true) } diff --git a/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift b/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift index 487889b6..0aa8397a 100644 --- a/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift +++ b/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift @@ -135,12 +135,12 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie // is it possible for the source view's gif controller to have different data than we just got? // should this be a property set by the animation controller instead? let gifController = (animationSourceView as? GIFImageView)?.gifController ?? GIFController(gifData: data) - content = LargeImageGifContentView(gifController: gifController) + content = LargeImageGifContentView(url: url, gifController: gifController) } else { if let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) { - content = LargeImageImageContentView(image: transformedImage) + content = LargeImageImageContentView(url: url, data: data, image: transformedImage) } else { - content = LargeImageImageContentView(image: image) + content = LargeImageImageContentView(url: url, data: data, image: image) } } @@ -167,7 +167,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: source) { image = grayscale } - setContent(LargeImageImageContentView(image: image)) + setContent(LargeImageImageContentView(url: url, data: nil, image: image)) } }