00ea140280
The Files folder is the app's own copy — it powers offline playback and Manage
storage — but Files is not where anyone looks for photos and videos. The Photos app
is, and only PhotoKit can write there; @capacitor/filesystem cannot, because an
app sandbox and the photo library are separate stores. So this adds a small native
plugin, mirroring the existing audio-route one.
- mobile/plugins/media-library: saveToAlbum({path, album, kind}) finds or creates
the album and adds the asset. Uses addResource(with:fileURL:), which is uniform
for photo and video and non-optional, unlike creationRequestForAssetFrom*, which
can silently no-op.
- Requests .readWrite, NOT .addOnly: addOnly can add an asset but cannot look up or
create an ALBUM, which is the whole point here. Both photo-library usage strings
are already set by ios-patch.sh.
- If the album can't be resolved (e.g. "limited" access), the asset is still saved
to the camera roll — landing somewhere beats failing outright. A racing create
from two simultaneous downloads re-looks-up instead of erroring.
- Podspec named MediaLibrary.podspec with s.name = 'MediaLibrary' to match
PascalCase of the package name — the same trap that broke the AudioRoute build.
Checked by a script: pod name, jsName and declared-vs-implemented methods.
Entirely best-effort from the web side: a denied permission or an older app build
never fails a download that is already safe in the app folder. Added a Settings
toggle since this does keep a second copy of the file.
Needs a new iOS build — new native plugin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
127 lines
6.4 KiB
Swift
127 lines
6.4 KiB
Swift
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<String> = ["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
|
|
}
|
|
}
|