diff --git a/mobile/package.json b/mobile/package.json index e4ccc2b..e9a5b67 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "audio-route": "file:plugins/audio-route", + "media-library": "file:plugins/media-library", "@capacitor-community/safe-area": "^7.0.0", "@capacitor/android": "^7.0.0", "@capacitor/app": "^7.0.0", diff --git a/mobile/plugins/media-library/MediaLibrary.podspec b/mobile/plugins/media-library/MediaLibrary.podspec new file mode 100644 index 0000000..c6aebef --- /dev/null +++ b/mobile/plugins/media-library/MediaLibrary.podspec @@ -0,0 +1,22 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) + +Pod::Spec.new do |s| + # NOTE: the pod name MUST be 'MediaLibrary' (PascalCase of the npm package name 'media-library'). + # `cap sync` writes `pod 'MediaLibrary', :path => '../../plugins/media-library'` into the generated + # Podfile, and CocoaPods then looks for a file literally named MediaLibrary.podspec whose s.name is + # 'MediaLibrary'. Any other name → "No podspec found for `MediaLibrary`" and pod install fails. + # (Same trap that broke the AudioRoute build — see mobile/plugins/audio-route/AudioRoute.podspec.) + s.name = 'MediaLibrary' + s.version = package['version'] + s.summary = package['description'] + s.license = package['license'] + s.homepage = 'https://bizgaze.com' + s.author = 'BizGaze' + s.source = { :git => 'https://bizgaze.com/media-library.git', :tag => s.version.to_s } + s.source_files = 'ios/Sources/**/*.{swift,h,m}' + s.ios.deployment_target = '14.0' + s.dependency 'Capacitor' + s.swift_version = '5.1' +end diff --git a/mobile/plugins/media-library/ios/Sources/MediaLibraryPlugin/MediaLibraryPlugin.swift b/mobile/plugins/media-library/ios/Sources/MediaLibraryPlugin/MediaLibraryPlugin.swift new file mode 100644 index 0000000..b20f2bf --- /dev/null +++ b/mobile/plugins/media-library/ios/Sources/MediaLibraryPlugin/MediaLibraryPlugin.swift @@ -0,0 +1,126 @@ +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 + } +} diff --git a/mobile/plugins/media-library/package.json b/mobile/plugins/media-library/package.json new file mode 100644 index 0000000..dd0fc74 --- /dev/null +++ b/mobile/plugins/media-library/package.json @@ -0,0 +1,26 @@ +{ + "name": "media-library", + "version": "1.0.0", + "description": "Save downloaded photos & videos into a named Photos album for Biz Connect", + "main": "dist/plugin.cjs.js", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "author": "BizGaze", + "license": "MIT", + "files": [ + "dist/", + "ios/", + "MediaLibrary.podspec" + ], + "capacitor": { + "ios": { + "src": "ios" + } + }, + "devDependencies": { + "@capacitor/core": "^7.0.0" + }, + "peerDependencies": { + "@capacitor/core": "^7.0.0" + } +} diff --git a/server/public/home.html b/server/public/home.html index 0ef5929..7371318 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -1197,7 +1197,7 @@ -