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

+900
View File
@@ -5,11 +5,233 @@
"comment" : "Bundle display name",
"extractionState" : "extracted_with_value",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"ca" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"cs" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"da" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"de" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"el" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "QuickDrop"
}
},
"en-AU" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"en-CA" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"en-GB" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"es" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"es-419" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"fi" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"fr" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"he" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"hi" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"hr" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"hu" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"id" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"it" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"ja" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"ko" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"ms" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"nb" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"nl" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"pl" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"pt-PT" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"ro" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"ru" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"sv" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"th" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"tr" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"uk" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"vi" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
}
}
},
@@ -17,11 +239,233 @@
"comment" : "Bundle name",
"extractionState" : "extracted_with_value",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"ca" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"cs" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"da" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"de" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"el" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "QuickDropiOS"
}
},
"en-AU" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"en-CA" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"en-GB" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"es" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"es-419" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"fi" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"fr" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"he" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"hi" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"hr" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"hu" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"id" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"it" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"ja" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"ko" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"ms" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"nb" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"nl" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"pl" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"pt-PT" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"ro" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"ru" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"sv" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"th" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"tr" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"uk" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"vi" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
}
}
},
@@ -29,11 +473,233 @@
"comment" : "Copyright (human-readable)",
"extractionState" : "extracted_with_value",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"ca" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"cs" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"da" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"de" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"el" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"en" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"en-AU" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"en-CA" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"en-GB" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"es" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"es-419" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"fi" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"fr" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"he" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"hi" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"hr" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"hu" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"id" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"it" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"ja" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"ko" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"ms" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"nb" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"nl" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"pl" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"pt-PT" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"ro" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"ru" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"sv" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"th" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"tr" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"uk" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"vi" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "new",
"value" : ""
}
}
}
},
@@ -269,6 +935,240 @@
}
}
}
},
"NSPhotoLibraryAddUsageDescription" : {
"comment" : "Privacy - Photo Library Additions Usage Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "يرغب QuickDrop في إضافة الصور ومقاطع الفيديو المستلمة إلى مكتبة صورك."
}
},
"ca" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop vol afegir les fotos i vídeos rebuts a la teva biblioteca de fotos."
}
},
"cs" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop si přeje přidat přijaté fotky a videa do vaší knihovny fotografií."
}
},
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop vil gerne tilføje modtagne billeder og videoer til dit fotobibliotek."
}
},
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop möchte empfangene Fotos und Videos deiner Mediathek hinzufügen."
}
},
"el" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Το QuickDrop θέλει να προσθέσει τις ληφθείσες φωτογραφίες και βίντεο στη βιβλιοθήκη φωτογραφιών σας."
}
},
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "The add photos permission is required to save received photos and videos to your photo library."
}
},
"en-AU" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop would like to add received photos and videos to your photo library."
}
},
"en-CA" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop would like to add received photos and videos to your photo library."
}
},
"en-GB" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop would like to add received photos and videos to your photo library."
}
},
"es" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop quiere añadir las fotos y videos recibidos a tu galería."
}
},
"es-419" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop quiere agregar las fotos y videos recibidos a tu galería."
}
},
"fi" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop haluaa lisätä vastaanotetut kuvat ja videot kuvakirjastoosi."
}
},
"fr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop souhaite ajouter les photos et vidéos reçues à votre bibliothèque."
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop souhaite ajouter les photos et vidéos reçues à votre bibliothèque de photos."
}
},
"he" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop מבקשת להוסיף תמונות וסרטונים שהתקבלו לספריית התמונות שלך."
}
},
"hi" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop प्राप्त तस्वीरें और वीडियो आपकी फोटो लाइब्रेरी में जोड़ना चाहता है।"
}
},
"hr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop želi dodati primljene fotografije i videozapise u vašu galeriju."
}
},
"hu" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "A QuickDrop hozzá szeretné adni a fogadott fotókat és videókat a fotókönyvtáradhoz."
}
},
"id" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop ingin menambahkan foto dan video yang diterima ke perpustakaan foto Anda."
}
},
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop desidera aggiungere le foto e i video ricevuti alla tua libreria fotografica."
}
},
"ja" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDropが受信した写真とビデオをフォトライブラリに追加しようとしています。"
}
},
"ko" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop이(가) 받은 사진과 동영상을 사진 보관함에 추가하려고 합니다."
}
},
"ms" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop ingin menambah foto dan video yang diterima ke pustaka foto anda."
}
},
"nb" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop vil legge til mottatte bilder og videoer i bildebiblioteket ditt."
}
},
"nl" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop wil ontvangen foto's en video's toevoegen aan je fotobibliotheek."
}
},
"pl" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop chce dodać odebrane zdjęcia i filmy do Twojej biblioteki zdjęć."
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "O QuickDrop gostaria de adicionar fotos e vídeos recebidos à sua biblioteca de fotos."
}
},
"pt-PT" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "O QuickDrop gostaria de adicionar as fotos e vídeos recebidos à sua biblioteca de fotografias."
}
},
"ro" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop dorește să adauge fotografiile și videoclipurile primite în biblioteca ta foto."
}
},
"ru" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop хочет добавить полученные фото и видео в вашу медиатеку."
}
},
"sv" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop vill lägga till mottagna foton och videor i ditt bildbibliotek."
}
},
"th" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop ต้องการเพิ่มรูปภาพและวิดีโอที่ได้รับไปยังคลังรูปภาพของคุณ"
}
},
"tr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop, alınan fotoğraf ve videoları fotoğraf kitaplığınıza eklemek istiyor."
}
},
"uk" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop хоче додати отримані фото та відео до вашої фототеки."
}
},
"vi" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop muốn thêm ảnh và video nhận được vào thư viện ảnh của bạn."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop 希望将接收的照片和视频添加到您的照片库。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "QuickDrop 想將收到的照片和影片加入你的相簿。"
}
}
}
}
},
"version" : "1.0"
+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) {