mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
576 lines
23 KiB
Swift
576 lines
23 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import Combine
|
|
import Photos
|
|
import CoreServices
|
|
import UniformTypeIdentifiers
|
|
import SignalUtilitiesKit
|
|
import SessionUIKit
|
|
import SessionMessagingKit
|
|
import SessionUtilitiesKit
|
|
|
|
protocol PhotoLibraryDelegate: AnyObject {
|
|
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary)
|
|
}
|
|
|
|
class PhotoMediaSize {
|
|
var thumbnailSize: CGSize
|
|
|
|
init() {
|
|
self.thumbnailSize = .zero
|
|
}
|
|
|
|
init(thumbnailSize: CGSize) {
|
|
self.thumbnailSize = thumbnailSize
|
|
}
|
|
}
|
|
|
|
class PhotoPickerAssetItem: PhotoGridItem {
|
|
let dependencies: Dependencies
|
|
let asset: PHAsset
|
|
let photoCollectionContents: PhotoCollectionContents
|
|
let size: ImageDataManager.ThumbnailSize
|
|
let pixelDimension: CGFloat
|
|
|
|
init(
|
|
asset: PHAsset,
|
|
photoCollectionContents: PhotoCollectionContents,
|
|
size: ImageDataManager.ThumbnailSize,
|
|
pixelDimension: CGFloat,
|
|
using dependencies: Dependencies
|
|
) {
|
|
self.dependencies = dependencies
|
|
self.asset = asset
|
|
self.photoCollectionContents = photoCollectionContents
|
|
self.size = size
|
|
self.pixelDimension = pixelDimension
|
|
}
|
|
|
|
// MARK: PhotoGridItem
|
|
|
|
var isVideo: Bool { asset.mediaType == .video }
|
|
var source: ImageDataManager.DataSource {
|
|
return .asyncSource(self.asset.localIdentifier) { [photoCollectionContents, asset, size, pixelDimension, dependencies] in
|
|
await photoCollectionContents.requestThumbnail(
|
|
for: asset,
|
|
size: size,
|
|
pixelDimension: pixelDimension,
|
|
using: dependencies
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
class PhotoCollectionContents {
|
|
private let dependencies: Dependencies
|
|
let fetchResult: PHFetchResult<PHAsset>
|
|
let localizedTitle: String?
|
|
|
|
enum PhotoLibraryError: Error {
|
|
case assertionError(description: String)
|
|
case unsupportedMediaType
|
|
}
|
|
|
|
init(fetchResult: PHFetchResult<PHAsset>, localizedTitle: String?, using dependencies: Dependencies) {
|
|
self.dependencies = dependencies
|
|
self.fetchResult = fetchResult
|
|
self.localizedTitle = localizedTitle
|
|
}
|
|
|
|
private let imageManager = PHCachingImageManager()
|
|
|
|
// MARK: - Asset Accessors
|
|
|
|
var assetCount: Int {
|
|
return fetchResult.count
|
|
}
|
|
|
|
var lastAsset: PHAsset? {
|
|
guard assetCount > 0 else {
|
|
return nil
|
|
}
|
|
return asset(at: assetCount - 1)
|
|
}
|
|
|
|
var firstAsset: PHAsset? {
|
|
guard assetCount > 0 else {
|
|
return nil
|
|
}
|
|
return asset(at: 0)
|
|
}
|
|
|
|
func asset(at index: Int) -> PHAsset? {
|
|
guard index >= 0 && index < fetchResult.count else { return nil }
|
|
|
|
return fetchResult.object(at: index)
|
|
}
|
|
|
|
// MARK: - AssetItem Accessors
|
|
|
|
func assetItem(at index: Int, size: ImageDataManager.ThumbnailSize, pixelDimension: CGFloat) -> PhotoPickerAssetItem? {
|
|
guard let mediaAsset: PHAsset = asset(at: index) else { return nil }
|
|
|
|
return PhotoPickerAssetItem(
|
|
asset: mediaAsset,
|
|
photoCollectionContents: self,
|
|
size: size,
|
|
pixelDimension: pixelDimension,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
func firstAssetItem(size: ImageDataManager.ThumbnailSize, pixelDimension: CGFloat) -> PhotoPickerAssetItem? {
|
|
guard let mediaAsset = firstAsset else { return nil }
|
|
|
|
return PhotoPickerAssetItem(
|
|
asset: mediaAsset,
|
|
photoCollectionContents: self,
|
|
size: size,
|
|
pixelDimension: pixelDimension,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
func lastAssetItem(size: ImageDataManager.ThumbnailSize, pixelDimension: CGFloat) -> PhotoPickerAssetItem? {
|
|
guard let mediaAsset = lastAsset else { return nil }
|
|
|
|
return PhotoPickerAssetItem(
|
|
asset: mediaAsset,
|
|
photoCollectionContents: self,
|
|
size: size,
|
|
pixelDimension: pixelDimension,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
// MARK: ImageManager
|
|
|
|
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 do it ourselves)
|
|
switch asset.utType?.isAnimated {
|
|
case .some(true):
|
|
let maybeData: Data? = await withCheckedContinuation { [imageManager] continuation in
|
|
let options = PHImageRequestOptions()
|
|
options.deliveryMode = .highQualityFormat
|
|
options.isNetworkAccessAllowed = true
|
|
options.isSynchronous = false
|
|
options.progressHandler = { progress, error, stop, info in
|
|
if let error = error {
|
|
Log.warn("[PhotoLibrary] iCloud download progress error: \(error)")
|
|
}
|
|
}
|
|
|
|
imageManager.requestImageDataAndOrientation(for: asset, options: options) { data, uti, orientation, info in
|
|
guard !hasResumed else { return }
|
|
|
|
guard let data = data, info?[PHImageErrorKey] == nil else {
|
|
Log.error("[PhotoLibrary] Failed to retrieve animated image due to error: \(info?[PHImageErrorKey] ?? "Unknown error")")
|
|
hasResumed = true
|
|
continuation.resume(returning: nil)
|
|
return
|
|
}
|
|
|
|
// Successfully fetched the data
|
|
hasResumed = true
|
|
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()
|
|
options.isNetworkAccessAllowed = true
|
|
options.isSynchronous = false
|
|
options.progressHandler = { progress, error, stop, info in
|
|
if let error = error {
|
|
Log.warn("[PhotoLibrary] iCloud download progress error: \(error)")
|
|
}
|
|
}
|
|
|
|
switch size {
|
|
case .small: options.deliveryMode = .opportunistic
|
|
case .medium, .large: options.deliveryMode = .highQualityFormat
|
|
}
|
|
|
|
imageManager.requestImage(
|
|
for: asset,
|
|
targetSize: CGSize(width: pixelDimension, height: pixelDimension),
|
|
contentMode: .aspectFill,
|
|
options: options
|
|
) { image, info in
|
|
guard !hasResumed else { return }
|
|
guard
|
|
info?[PHImageErrorKey] == nil,
|
|
(info?[PHImageCancelledKey] as? Bool) != true
|
|
else {
|
|
Log.error("[PhotoLibrary] Failed to retrieve image thumbnail due to error: \(info?[PHImageErrorKey] ?? "Unknown error")")
|
|
hasResumed = true
|
|
return continuation.resume(returning: nil)
|
|
}
|
|
|
|
switch size {
|
|
case .small: break // We want the first image, whether it is degraded or not
|
|
case .medium, .large:
|
|
// For medium and large thumbnails we want the full image so ignore any
|
|
// degraded images
|
|
guard (info?[PHImageResultIsDegradedKey] as? Bool) != true else { return }
|
|
|
|
}
|
|
|
|
continuation.resume(returning: .image("\(asset.localIdentifier)-\(size)", image))
|
|
hasResumed = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func requestImageDataSource(
|
|
for asset: PHAsset,
|
|
using dependencies: Dependencies
|
|
) async throws -> PendingAttachment {
|
|
let options: PHImageRequestOptions = PHImageRequestOptions()
|
|
options.isSynchronous = false
|
|
options.isNetworkAccessAllowed = true
|
|
options.isSynchronous = false
|
|
options.deliveryMode = .highQualityFormat
|
|
|
|
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 {
|
|
Log.error("[PhotoLibrary] Failed to retrieve image due to error: \(error)")
|
|
return continuation.resume(throwing: error)
|
|
}
|
|
|
|
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
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
/// 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 targetFormat: PendingAttachment.ConversionFormat = (dependencies[feature: .usePngInsteadOfWebPForFallbackImageType] ?
|
|
.png : .webPLossy
|
|
)
|
|
let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare(
|
|
operations: [.convert(to: targetFormat)],
|
|
using: dependencies
|
|
)
|
|
|
|
return PendingAttachment(
|
|
source: .media(.url(URL(fileURLWithPath: preparedAttachment.filePath))),
|
|
utType: .webP,
|
|
sourceFilename: pendingAttachment.sourceFilename,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
return pendingAttachment
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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"))
|
|
}
|
|
|
|
let compatiblePresets = AVAssetExportSession.exportPresets(compatibleWith: avAsset)
|
|
let bestExportPreset: String = (compatiblePresets.contains(AVAssetExportPresetPassthrough) ?
|
|
AVAssetExportPresetPassthrough :
|
|
AVAssetExportPresetHighestQuality
|
|
)
|
|
let exportPath: String = dependencies[singleton: .fileManager].temporaryFilePath()
|
|
|
|
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) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func pendingAttachment(for asset: PHAsset, using dependencies: Dependencies) async throws -> PendingAttachment {
|
|
switch asset.mediaType {
|
|
case .image: return try await requestImageDataSource(for: asset, using: dependencies)
|
|
case .video: return try await requestVideoDataSource(for: asset, using: dependencies)
|
|
default: throw PhotoLibraryError.unsupportedMediaType
|
|
}
|
|
}
|
|
}
|
|
|
|
class PhotoCollection {
|
|
public let id: String
|
|
private let collection: PHAssetCollection
|
|
|
|
// The user never sees this collection, but we use it for a null object pattern
|
|
// when the user has denied photos access.
|
|
static let empty = PhotoCollection(id: "", collection: PHAssetCollection())
|
|
|
|
init(id: String, collection: PHAssetCollection) {
|
|
self.id = id
|
|
self.collection = collection
|
|
}
|
|
|
|
func localizedTitle() -> String {
|
|
guard let localizedTitle = collection.localizedTitle?.stripped,
|
|
localizedTitle.count > 0 else {
|
|
return "attachmentsAlbumUnnamed".localized()
|
|
}
|
|
return localizedTitle
|
|
}
|
|
|
|
// stringlint:ignore_contents
|
|
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(),
|
|
using: dependencies
|
|
)
|
|
}
|
|
}
|
|
|
|
extension PhotoCollection: Equatable {
|
|
static func == (lhs: PhotoCollection, rhs: PhotoCollection) -> Bool {
|
|
return lhs.collection == rhs.collection
|
|
}
|
|
}
|
|
|
|
class PhotoLibrary: NSObject, PHPhotoLibraryChangeObserver {
|
|
typealias WeakDelegate = Weak<PhotoLibraryDelegate>
|
|
var delegates = [WeakDelegate]()
|
|
|
|
public func add(delegate: PhotoLibraryDelegate) {
|
|
delegates.append(WeakDelegate(value: delegate))
|
|
}
|
|
|
|
var assetCollection: PHAssetCollection!
|
|
|
|
func photoLibraryDidChange(_ changeInstance: PHChange) {
|
|
DispatchQueue.main.async {
|
|
for weakDelegate in self.delegates {
|
|
weakDelegate.value?.photoLibraryDidChange(self)
|
|
}
|
|
}
|
|
}
|
|
|
|
override init() {
|
|
super.init()
|
|
PHPhotoLibrary.shared().register(self)
|
|
}
|
|
|
|
deinit {
|
|
PHPhotoLibrary.shared().unregisterChangeObserver(self)
|
|
}
|
|
|
|
// stringlint:ignore_contents
|
|
private lazy var fetchOptions: PHFetchOptions = {
|
|
let fetchOptions = PHFetchOptions()
|
|
fetchOptions.sortDescriptors = [NSSortDescriptor(key: "endDate", ascending: true)]
|
|
return fetchOptions
|
|
}()
|
|
|
|
func defaultPhotoCollection() -> PhotoCollection {
|
|
var fetchedCollection: PhotoCollection?
|
|
PHAssetCollection.fetchAssetCollections(
|
|
with: .smartAlbum,
|
|
subtype: .smartAlbumUserLibrary,
|
|
options: fetchOptions
|
|
).enumerateObjects { collection, _, stop in
|
|
fetchedCollection = PhotoCollection(id: collection.localIdentifier, collection: collection)
|
|
stop.pointee = true
|
|
}
|
|
|
|
guard let photoCollection = fetchedCollection else {
|
|
Log.debug("[PhotoLibrary] Using empty photo collection.")
|
|
Log.assert(PHPhotoLibrary.authorizationStatus() == .denied)
|
|
return PhotoCollection.empty
|
|
}
|
|
|
|
return photoCollection
|
|
}
|
|
|
|
func allPhotoCollections(using dependencies: Dependencies) -> [PhotoCollection] {
|
|
var collections = [PhotoCollection]()
|
|
var collectionIds = Set<String>()
|
|
|
|
let processPHCollection: ((collection: PHCollection, hideIfEmpty: Bool)) -> Void = { arg in
|
|
let (collection, hideIfEmpty) = arg
|
|
|
|
// De-duplicate by id.
|
|
let collectionId: String = collection.localIdentifier
|
|
|
|
guard !collectionIds.contains(collectionId) else { return }
|
|
collectionIds.insert(collectionId)
|
|
|
|
guard let assetCollection = collection as? PHAssetCollection else {
|
|
Log.error("[PhotoLibrary] Asset collection has unexpected type: \(type(of: collection))")
|
|
return
|
|
}
|
|
let photoCollection = PhotoCollection(id: collectionId, collection: assetCollection)
|
|
guard !hideIfEmpty || photoCollection.contents(using: dependencies).assetCount > 0 else {
|
|
return
|
|
}
|
|
|
|
collections.append(photoCollection)
|
|
}
|
|
let processPHAssetCollections: ((fetchResult: PHFetchResult<PHAssetCollection>, hideIfEmpty: Bool)) -> Void = { arg in
|
|
let (fetchResult, hideIfEmpty) = arg
|
|
|
|
fetchResult.enumerateObjects { (assetCollection, _, _) in
|
|
// We're already sorting albums by last-updated. "Recently Added" is mostly redundant
|
|
guard assetCollection.assetCollectionSubtype != .smartAlbumRecentlyAdded else {
|
|
return
|
|
}
|
|
|
|
// undocumented constant
|
|
let kRecentlyDeletedAlbumSubtype = PHAssetCollectionSubtype(rawValue: 1000000201)
|
|
guard assetCollection.assetCollectionSubtype != kRecentlyDeletedAlbumSubtype else {
|
|
return
|
|
}
|
|
|
|
processPHCollection((collection: assetCollection, hideIfEmpty: hideIfEmpty))
|
|
}
|
|
}
|
|
let processPHCollections: ((fetchResult: PHFetchResult<PHCollection>, hideIfEmpty: Bool)) -> Void = { arg in
|
|
let (fetchResult, hideIfEmpty) = arg
|
|
|
|
for index in 0..<fetchResult.count {
|
|
processPHCollection((collection: fetchResult.object(at: index), hideIfEmpty: hideIfEmpty))
|
|
}
|
|
}
|
|
let fetchOptions = PHFetchOptions()
|
|
fetchOptions.sortDescriptors = [NSSortDescriptor(key: "endDate", ascending: true)] // stringlint:ignore
|
|
|
|
// Try to add "Camera Roll" first.
|
|
processPHAssetCollections((fetchResult: PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .smartAlbumUserLibrary, options: fetchOptions),
|
|
hideIfEmpty: false))
|
|
|
|
// Favorites
|
|
processPHAssetCollections((fetchResult: PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .smartAlbumFavorites, options: fetchOptions),
|
|
hideIfEmpty: true))
|
|
|
|
// Smart albums.
|
|
processPHAssetCollections((fetchResult: PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .albumRegular, options: fetchOptions),
|
|
hideIfEmpty: true))
|
|
|
|
// User-created albums.
|
|
processPHCollections((fetchResult: PHAssetCollection.fetchTopLevelUserCollections(with: fetchOptions),
|
|
hideIfEmpty: true))
|
|
|
|
return collections
|
|
}
|
|
}
|
|
|
|
private extension PHAsset {
|
|
var utType: UTType? {
|
|
return (value(forKey: "uniformTypeIdentifier") as? String) // stringlint:ignore
|
|
.map { UTType($0) }
|
|
}
|
|
}
|