Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/Photos/PhotoLibrary.swift
1 views
//
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import CoreServices
import Foundation
import Photos
import SignalServiceKit
import SignalUI

protocol PhotoLibraryDelegate: AnyObject {
    func photoLibraryDidChange(_ photoLibrary: PhotoLibrary)
}

class PhotoPickerAssetItem {
    private let asset: PHAsset
    private let photoCollectionContents: PhotoAlbumContents
    private let thumbnailSize: CGSize

    let type: PhotoGridItemType

    init(asset: PHAsset, photoCollectionContents: PhotoAlbumContents, thumbnailSize: CGSize) {
        self.asset = asset
        self.photoCollectionContents = photoCollectionContents
        self.thumbnailSize = thumbnailSize

        self.type = if asset.mediaType == .video {
            .video(Promise.value(asset.duration))
        } else if asset.playbackStyle == .imageAnimated {
            .animated
        } else {
            .photo
        }
    }

    func asyncThumbnail(completion: @escaping (UIImage?) -> Void) {
        var hasLoadedImage = false

        // Surprisingly, iOS will opportunistically run the completion block sync if the image is
        // already available.
        photoCollectionContents.requestThumbnail(for: self.asset, thumbnailSize: self.thumbnailSize) { image, _ in
            DispatchMainThreadSafe({
                // Once we've _successfully_ completed (e.g. invoked the completion with
                // a non-nil image), don't invoke the completion again with a nil argument.
                if !hasLoadedImage || image != nil {
                    completion(image)

                    if image != nil {
                        hasLoadedImage = true
                    }
                }
            })
        }
    }
}

class PhotoAlbumContents {

    private let fetchResult: PHFetchResult<PHAsset>
    private let limit: Int

    enum PhotoLibraryError: Error {
        case assertionError(description: String)
        case unsupportedMediaType
        case failedToExportAsset(underlyingError: Error?)
    }

    init(fetchResult: PHFetchResult<PHAsset>, limit: Int) {
        self.fetchResult = fetchResult
        self.limit = limit
    }

    private let imageManager = PHCachingImageManager()

    // MARK: - Asset Accessors

    var assetCount: Int {
        return min(fetchResult.count, limit)
    }

    func asset(at index: Int) -> PHAsset {
        return fetchResult.object(at: fetchResult.count - index - 1)
    }

    // MARK: - AssetItem Accessors

    func assetItem(at index: Int, thumbnailSize: CGSize) -> PhotoPickerAssetItem {
        let mediaAsset = asset(at: index)
        return PhotoPickerAssetItem(asset: mediaAsset, photoCollectionContents: self, thumbnailSize: thumbnailSize)
    }

    // MARK: ImageManager

    func requestThumbnail(for asset: PHAsset, thumbnailSize: CGSize, resultHandler: @escaping (UIImage?, [AnyHashable: Any]?) -> Void) {
        _ = imageManager.requestImage(for: asset, targetSize: thumbnailSize, contentMode: .aspectFill, options: nil, resultHandler: resultHandler)
    }

    private func requestImageDataSource(for asset: PHAsset) async throws -> (dataSource: DataSourcePath, dataUTI: String) {
        let options: PHImageRequestOptions = PHImageRequestOptions()
        options.isNetworkAccessAllowed = true
        options.version = .current
        options.deliveryMode = .highQualityFormat
        let (imageData, dataUTI) = await withCheckedContinuation { continuation in
            _ = imageManager.requestImageDataAndOrientation(for: asset, options: options) { imageData, dataUTI, _, _ in
                continuation.resume(returning: (imageData, dataUTI))
            }
        }
        guard let imageData else {
            throw PhotoLibraryError.assertionError(description: "imageData was unexpectedly nil")
        }
        guard let dataUTI else {
            throw PhotoLibraryError.assertionError(description: "dataUTI was unexpectedly nil")
        }
        guard let fileExtension = MimeTypeUtil.fileExtensionForUtiType(dataUTI) else {
            throw PhotoLibraryError.assertionError(description: "fileExtension was unexpectedly nil")
        }
        let dataSource = try DataSourcePath(writingTempFileData: imageData, fileExtension: fileExtension)
        return (dataSource: dataSource, dataUTI: dataUTI)
    }

    private func requestVideoDataSource(for asset: PHAsset) async throws -> AVAsset {
        let options: PHVideoRequestOptions = PHVideoRequestOptions()
        options.isNetworkAccessAllowed = true
        options.version = .current

        return try await withCheckedThrowingContinuation { continuation in
            _ = imageManager.requestAVAsset(forVideo: asset, options: options) { video, _, info in
                guard let video else {
                    let error = info?[PHImageErrorKey] as! Error?
                    continuation.resume(throwing: PhotoLibraryError.failedToExportAsset(underlyingError: error))
                    return
                }
                continuation.resume(returning: video)
            }
        }
    }

    func outgoingAttachment(for asset: PHAsset, attachmentLimits: OutgoingAttachmentLimits) async throws -> PreviewableAttachment {
        switch asset.mediaType {
        case .image:
            let (dataSource, dataUTI) = try await requestImageDataSource(for: asset)
            return try PreviewableAttachment.imageAttachment(dataSource: dataSource, dataUTI: dataUTI)
        case .video:
            let video = try await requestVideoDataSource(for: asset)
            return try await PreviewableAttachment.compressVideoAsMp4(asset: video, baseFilename: nil, attachmentLimits: attachmentLimits)
        case .unknown, .audio:
            fallthrough
        @unknown default:
            throw PhotoLibraryError.unsupportedMediaType
        }
    }
}

class PhotoAlbum {
    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 = PhotoAlbum(collection: PHAssetCollection())

    init(collection: PHAssetCollection) {
        self.collection = collection
    }

    func contents(limit: Int) -> PhotoAlbumContents {
        let fetchResult = PHAsset.fetchAssets(in: collection, options: nil)
        return PhotoAlbumContents(fetchResult: fetchResult, limit: limit)
    }
}

class PhotoLibrary: NSObject, PHPhotoLibraryChangeObserver {
    weak var delegate: PhotoLibraryDelegate?

    func photoLibraryDidChange(_ changeInstance: PHChange) {
        DispatchQueue.main.async {
            self.delegate?.photoLibraryDidChange(self)
        }
    }

    override init() {
        super.init()
        PHPhotoLibrary.shared().register(self)
    }

    deinit {
        PHPhotoLibrary.shared().unregisterChangeObserver(self)
    }

    func defaultPhotoAlbum() -> PhotoAlbum {
        var fetchedCollection: PhotoAlbum?
        PHAssetCollection.fetchAssetCollections(
            with: .smartAlbum,
            subtype: .smartAlbumUserLibrary,
            options: fetchOptions,
        ).enumerateObjects { collection, _, stop in
            fetchedCollection = PhotoAlbum(collection: collection)
            stop.pointee = true
        }

        guard let photoCollection = fetchedCollection else {
            Logger.info("Using empty photo collection.")
            assert(PHPhotoLibrary.authorizationStatus() == .denied)
            return PhotoAlbum.empty
        }

        return photoCollection
    }

    private lazy var fetchOptions: PHFetchOptions = {
        let fetchOptions = PHFetchOptions()
        fetchOptions.sortDescriptors = [NSSortDescriptor(key: "endDate", ascending: true)]
        return fetchOptions
    }()
}