iOS: Add ability to save photos to gallery

This commit is contained in:
Leon Böttger committed 2025-11-16 14:31:17 +01:00
1 parent 5e62f3e107
commit 394aaa8093
11 files changed
+3038 -22

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
//
// PhotoManager.swift
// QuickDrop
//
// Created by Leon Böttger on 16.11.25.
//
import UIKit
import Photos
import UniformTypeIdentifiers
struct PhotoManager {
static func hasPhotosOrVideos(at urls: [URL]) -> Bool {
urls.contains(where: { isImageFile(at: $0) || isVideoFile(at: $0) })
}
/// Saves any image/video URLs to the user's Photos library.
/// - Parameters:
/// - urls: Remote or local URLs to inspect.
/// - openPhotosOnSuccess: If true, will try to open the Photos app after saving.
/// - Throws: `SaveToPhotosError` or underlying URLSession/Photos errors.
static func saveMediaToPhotoLibrary(
from urls: [URL],
openPhotosOnSuccess: Bool = true
) async throws {
let candidates = urls.filter { isImageFile(at: $0) || isVideoFile(at: $0) }
guard !candidates.isEmpty else { throw SaveToPhotosError.noSavableContent }
let authStatus = await requestAddOnlyPhotoAuthorization()
guard authStatus == .authorized || authStatus == .limited else {
throw SaveToPhotosError.permissionDenied
}
struct ImageItem {
let fileURL: URL
let creationDate: Date?
}
struct VideoItem {
let fileURL: URL
let creationDate: Date?
}
var imageItems: [ImageItem] = []
var videoItems: [VideoItem] = []
// 1. Download and write to temp files, extracting EXIF date for images.
for sourceURL in candidates {
if isImageFile(at: sourceURL) {
let exifDate = EXIFUtils.exifOriginalDate(from: sourceURL)
imageItems.append(ImageItem(fileURL: sourceURL, creationDate: exifDate))
} else {
// For videos you may want to parse container metadata; as a fallback use file creation date.
let videoDate = await getVideoCreationDate(from: sourceURL)
videoItems.append(VideoItem(fileURL: sourceURL, creationDate: videoDate))
}
}
guard !imageItems.isEmpty || !videoItems.isEmpty else {
throw SaveToPhotosError.noSavableContent
}
// 2. Import into Photos; explicitly set creationDate so it doesn't default to "now".
try await withCheckedThrowingContinuation { continuation in
PHPhotoLibrary.shared().performChanges({
for item in imageItems {
if let request = PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: item.fileURL) {
if let date = item.creationDate {
request.creationDate = date
}
}
}
for item in videoItems {
if let request = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: item.fileURL) {
if let date = item.creationDate {
request.creationDate = date
}
}
}
}, completionHandler: { success, error in
if success {
continuation.resume()
} else {
continuation.resume(throwing: error ?? SaveToPhotosError.saveFailed)
}
})
}
// 5. Open the Photos app
if openPhotosOnSuccess {
if let url = URL(string: "photos-redirect://") {
DispatchQueue.main.async {
UIApplication.shared.open(url)
}
}
}
}
private static func isImageFile(at url: URL) -> Bool {
// Assumes there *is* an extension, as per your requirement
let ext = url.pathExtension.lowercased()
guard let type = UTType(filenameExtension: ext) else {
return false
}
return type.conforms(to: .image)
}
private static func isVideoFile(at url: URL) -> Bool {
let ext = url.pathExtension.lowercased()
guard let type = UTType(filenameExtension: ext) else {
return false
}
// `.movie` is the canonical file-based video type; `.audiovisualContent`
// catches some broader cases if needed.
return type.conforms(to: .movie) || type.conforms(to: .audiovisualContent)
}
private static func getVideoCreationDate(from url: URL) async -> Date? {
let asset = AVURLAsset(url: url)
do {
// AVMetadataItem that typically contains an ISO‑8601 string
guard let item = try await asset.load(.creationDate),
let dateString = try await item.load(.stringValue) else {
return nil
}
let formatter = ISO8601DateFormatter()
return formatter.date(from: dateString)
}
catch {
log("Failed to load creation date from \(url): \(error)")
return nil
}
}
/// Requests add-only Photos permission
private static func requestAddOnlyPhotoAuthorization() async -> PHAuthorizationStatus {
await withCheckedContinuation { continuation in
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
continuation.resume(returning: status)
}
}
}
enum SaveToPhotosError: Error, LocalizedError {
case noSavableContent
case permissionDenied
case saveFailed
var errorDescription: String? {
switch self {
case .noSavableContent:
return "SaveToPhotosErrorNoSavableContent".localized()
case .permissionDenied:
return "SaveToPhotosErrorPermissionDenied".localized()
case .saveFailed:
return "SaveToPhotosErrorSaveFailed".localized()
}
}
}
}
+36
View File
@@ -42,6 +42,42 @@ final class ProgressAlert {
vc.present(alert, animated: true)
}
func presentIncomingTransferDoneAlert(
title: String,
message: String,
onImportToPhotos: (() -> Void)? = nil,
onDone: (() -> Void)? = nil
) {
guard let vc = topMostViewController() else { return }
let alert = UIAlertController(
title: title,
message: message,
preferredStyle: .alert
)
// "Show in Files"
alert.addAction(UIAlertAction(title: "BrowseDownloadedFiles".localized(), style: .default) { _ in
FileManager.default.openDocumentFolder()
})
// Optional "Import to Photos"
if let importHandler = onImportToPhotos {
alert.addAction(UIAlertAction(title: "ImportToPhotos".localized(), style: .default) { _ in
importHandler()
})
}
// "Done"
alert.addAction(UIAlertAction(title: "Done".localized(), style: .cancel) { _ in
onDone?()
})
vc.present(alert, animated: true)
}
// MARK: - Progress Alert
private func showProgressAlert(onCancel: @escaping () -> Void) {