import Foundation import Capacitor import Photos // Copies a downloaded photo/video into the user's Photos library, inside a named album ("Connect"), the // way WhatsApp puts saved media in a WhatsApp album. Registered by cap sync as a real Capacitor plugin // package, so it appears as window.Capacitor.Plugins.MediaLibrary (an app-embedded class would be // stripped in release builds). // // WHY A PLUGIN AT ALL: the app already saves downloads into its own Documents folder (visible in Files), // but Files is not where people look for photos and videos — the Photos app is, and only PhotoKit can put // something there. @capacitor/filesystem cannot: an app's sandbox and the photo library are separate stores. // // PERMISSION NOTE: .addOnly is enough to add an asset, but NOT to look up or create an ALBUM — that needs // .readWrite. So we request .readWrite, and both NSPhotoLibraryUsageDescription and // NSPhotoLibraryAddUsageDescription must be present (ios-patch.sh sets them). @objc(MediaLibraryPlugin) public class MediaLibraryPlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "MediaLibraryPlugin" public let jsName = "MediaLibrary" public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "saveToAlbum", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "checkPermission", returnType: CAPPluginReturnPromise) ] // MARK: - API @objc func checkPermission(_ call: CAPPluginCall) { call.resolve(["status": MediaLibraryPlugin.label(PHPhotoLibrary.authorizationStatus(for: .readWrite))]) } @objc func saveToAlbum(_ call: CAPPluginCall) { guard let raw = call.getString("path"), !raw.isEmpty else { call.reject("path is required"); return } let album = (call.getString("album") ?? "Connect").trimmingCharacters(in: .whitespacesAndNewlines) let url = MediaLibraryPlugin.fileURL(from: raw) guard FileManager.default.fileExists(atPath: url.path) else { call.reject("file not found: \(url.path)"); return } // Trust an explicit kind if the web layer passed one (it knows the MIME); otherwise fall back to // the file extension. let isVideo: Bool = { if let kind = call.getString("kind") { return kind == "video" } return MediaLibraryPlugin.videoExtensions.contains(url.pathExtension.lowercased()) }() PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in guard status == .authorized || status == .limited else { call.reject("permission denied", MediaLibraryPlugin.label(status)); return } self.album(named: album) { collection in PHPhotoLibrary.shared().performChanges({ // addResource(with:fileURL:) works uniformly for photo and video and, unlike the // creationRequestForAssetFrom* helpers, is non-optional — no silent no-op path. let request = PHAssetCreationRequest.forAsset() let options = PHAssetResourceCreationOptions() options.shouldMoveFile = false // keep our own copy in the app folder options.originalFilename = url.lastPathComponent request.addResource(with: isVideo ? .video : .photo, fileURL: url, options: options) // If the album couldn't be resolved (e.g. "limited" access), still save the asset — // landing in the camera roll beats failing outright. if let collection = collection, let placeholder = request.placeholderForCreatedAsset, let add = PHAssetCollectionChangeRequest(for: collection) { add.addAssets([placeholder] as NSArray) } }) { ok, err in if ok { call.resolve(["saved": true, "album": album, "inAlbum": collection != nil]) } else { call.reject(err?.localizedDescription ?? "could not save to Photos") } } } } } // MARK: - Helpers private static let videoExtensions: Set = ["mp4", "mov", "m4v", "3gp", "avi", "mkv", "webm"] private static func label(_ s: PHAuthorizationStatus) -> String { switch s { case .authorized: return "granted" case .limited: return "limited" case .denied: return "denied" case .restricted: return "restricted" case .notDetermined: return "prompt" @unknown default: return "unknown" } } // Accepts either a file:// URI (what Filesystem.writeFile returns) or a bare absolute path. private static func fileURL(from raw: String) -> URL { if raw.hasPrefix("file://") { if let u = URL(string: raw) { return u } // Un-encoded spaces make URL(string:) fail — percent-encode and retry before giving up. let encoded = raw.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? raw if let u = URL(string: encoded) { return u } } return URL(fileURLWithPath: raw) } /// Find the album by title, creating it the first time. Returns nil if it can't be resolved. private func album(named name: String, completion: @escaping (PHAssetCollection?) -> Void) { if let existing = MediaLibraryPlugin.findAlbum(name) { completion(existing); return } var placeholder: PHObjectPlaceholder? PHPhotoLibrary.shared().performChanges({ let req = PHAssetCollectionChangeRequest.creationRequestForAssetCollection(withTitle: name) placeholder = req.placeholderForCreatedAssetCollection }) { ok, _ in guard ok, let id = placeholder?.localIdentifier else { // A racing create (two downloads at once) means it exists now — look again before failing. completion(MediaLibraryPlugin.findAlbum(name)); return } completion(PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [id], options: nil).firstObject) } } private static func findAlbum(_ name: String) -> PHAssetCollection? { let opts = PHFetchOptions() opts.predicate = NSPredicate(format: "localizedTitle = %@", name) return PHAssetCollection.fetchAssetCollections(with: .album, subtype: .albumRegular, options: opts).firstObject } }