feat(photos): downloaded media also lands in a "Connect" album in Photos

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>
This commit is contained in:
2026-07-23 22:39:27 +05:30
parent 82fa8e04eb
commit 00ea140280
5 changed files with 196 additions and 2 deletions
+1
View File
@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"audio-route": "file:plugins/audio-route", "audio-route": "file:plugins/audio-route",
"media-library": "file:plugins/media-library",
"@capacitor-community/safe-area": "^7.0.0", "@capacitor-community/safe-area": "^7.0.0",
"@capacitor/android": "^7.0.0", "@capacitor/android": "^7.0.0",
"@capacitor/app": "^7.0.0", "@capacitor/app": "^7.0.0",
@@ -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
@@ -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<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
}
}
+26
View File
@@ -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"
}
}
+21 -2
View File
@@ -1197,7 +1197,7 @@
</head> </head>
<body> <body>
<script src="/icons.js?v=6"></script> <script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-23-batch164';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-23-batch165';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network. // Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
// //
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from // We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
@@ -1479,6 +1479,7 @@ function openSettings(){
// NOTE: deliberately NO "web build" row here — users must not have to reason about an app version vs a // NOTE: deliberately NO "web build" row here — users must not have to reason about an app version vs a
// web build. New code applies itself (see checkWebBuild/scheduleAutoRefresh); the only version shown is // web build. New code applies itself (see checkWebBuild/scheduleAutoRefresh); the only version shown is
// the desktop app's, which is what the auto-updater manages. // the desktop app's, which is what the auto-updater manages.
+(bzLibOn()?sw('setPhotos','Save photos &amp; videos to the Photos app', bzPhotosOn()):'')
+(bzLibOn()?('<label class="gi-setting"><span>Storage<div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem">Photos, videos &amp; files you downloaded to this device</div></span><button class="btn sm" id="setStor">Manage</button></label>'):'') +(bzLibOn()?('<label class="gi-setting"><span>Storage<div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem">Photos, videos &amp; files you downloaded to this device</div></span><button class="btn sm" id="setStor">Manage</button></label>'):'')
+'<div class="hint" style="margin-top:.4rem">These preferences are saved on this device.</div></div>'; +'<div class="hint" style="margin-top:.4rem">These preferences are saved on this device.</div></div>';
document.body.appendChild(ov); document.body.appendChild(ov);
@@ -1505,6 +1506,8 @@ function openSettings(){
ov.querySelector('#setDm').onchange=e=>setPref('dm', e.target.checked); ov.querySelector('#setDm').onchange=e=>setPref('dm', e.target.checked);
const permBtn=ov.querySelector('#setPerm'); if(permBtn) permBtn.onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ try{ await subscribePush(); }catch(_){} } }catch(_){} ov.remove(); openSettings(); }; // just trigger the browser prompt + refresh the state (no toast) const permBtn=ov.querySelector('#setPerm'); if(permBtn) permBtn.onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ try{ await subscribePush(); }catch(_){} } }catch(_){} ov.remove(); openSettings(); }; // just trigger the browser prompt + refresh the state (no toast)
const storBtn=ov.querySelector('#setStor'); if(storBtn) storBtn.onclick=()=>{ ov.remove(); openStorage(); }; const storBtn=ov.querySelector('#setStor'); if(storBtn) storBtn.onclick=()=>{ ov.remove(); openStorage(); };
const phBox=ov.querySelector('#setPhotos'); // second copy in the Photos library — worth being able to turn off
if(phBox) phBox.onchange=e=>{ try{ localStorage.setItem('bzc.photos', e.target.checked?'on':'off'); }catch(_){} };
} }
// Manage storage: everything this device has downloaded, with sizes, and a way to remove it. Deleting // Manage storage: everything this device has downloaded, with sizes, and a way to remove it. Deleting
// here only removes the LOCAL copy — the message and its attachment stay on the server, so anything // here only removes the LOCAL copy — the message and its attachment stay on the server, so anything
@@ -2788,6 +2791,21 @@ function bzMimeFromName(n){
return ''; return '';
} }
function bzLibSrc(r){ try{ return r&&r.uri ? window.Capacitor.convertFileSrc(r.uri) : null; }catch(_){ return null; } } function bzLibSrc(r){ try{ return r&&r.uri ? window.Capacitor.convertFileSrc(r.uri) : null; }catch(_){ return null; } }
function bzPhotosOn(){ try{ return localStorage.getItem('bzc.photos')!=='off'; }catch(_){ return true; } }
// Also put saved photos/videos in the Photos app, in a "Connect" album — the way WhatsApp does. The Files
// folder is the app's own copy (it powers offline playback and Manage storage), but Files is not where
// people look for media. Only PhotoKit can write to the photo library, hence the media-library plugin.
// Best-effort throughout: a denied permission or an older app build must never fail a download that has
// already landed safely in the app folder.
async function bzSaveToPhotos(uri, mime){
if(!uri || !bzPhotosOn()) return false;
const isVid=/^video\//.test(mime||'');
if(!isVid && !/^image\//.test(mime||'')) return false; // documents don't belong in Photos
const ML=window.Capacitor&&window.Capacitor.Plugins&&window.Capacitor.Plugins.MediaLibrary;
if(!ML||!ML.saveToAlbum) return false; // app build predates the plugin
try{ await ML.saveToAlbum({ path:uri, album:'Connect', kind:isVid?'video':'photo' }); return true; }
catch(e){ return false; }
}
// Confirm the file is still on disk. Returns null (and forgets it) if the user deleted it in Files. // Confirm the file is still on disk. Returns null (and forgets it) if the user deleted it in Files.
async function bzLibVerify(id){ async function bzLibVerify(id){
const r=bzLibGet(id); if(!r) return null; const r=bzLibGet(id); if(!r) return null;
@@ -2889,7 +2907,8 @@ async function nativeSaveFile(url, name, meta){
bzDlSet(box,100); if(onPct) onPct(100); bzDlSet(box,100); if(onPct) onPct(100);
if(id) bzLibPut(id, { path:lpath, uri:uri||'', name:fname, mime, size:wrote||total||0, at:Date.now() }); if(id) bzLibPut(id, { path:lpath, uri:uri||'', name:fname, mime, size:wrote||total||0, at:Date.now() });
if(id) bzSyncVideoTiles(id); if(id) bzSyncVideoTiles(id);
toast('Saved to Biz Connect · '+bzLibFolder(mime)); const inPhotos=await bzSaveToPhotos(uri, mime);
toast(inPhotos ? 'Saved to Photos · Connect album' : ('Saved to Biz Connect · '+bzLibFolder(mime)));
}catch(e){ toast("Couldn't save the file"); } }catch(e){ toast("Couldn't save the file"); }
finally{ bzDlHide(box); } finally{ bzDlHide(box); }
return true; // handled either way — never fall through to navigation return true; // handled either way — never fall through to navigation