Fix Photos save for images + add native Quick Look file preview
Photos/Files bug: the lightbox download handed nativeSaveFile a bare "/files/<id>" with no name, extension, or mime. Empty mime made bzSaveToPhotos short-circuit as "notmedia" — so saveToAlbum (and its permission prompt) never ran, and the image landed in the generic Files folder as an extension-less blob. Now nativeSaveFile trusts the server's Content-Type when the mime is unknown and appends a real extension (bzExtForMime/bzEnsureExt), so images reach the Images folder AND Photos. File preview: replace the @capacitor/share "share sheet" open with a new native FileOpener plugin (QLPreviewController). bzOpenFile now prefers a real Quick Look preview and only falls back to the share sheet if the plugin isn't in the build. Wired file-opener into mobile/package.json and the codemagic SPM diagnostics loop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -54,7 +54,7 @@ workflows:
|
|||||||
echo "iOS project layout:"; ls -la ios/App
|
echo "iOS project layout:"; ls -la ios/App
|
||||||
# ── Diagnose local-plugin SPM wiring (the regression: our file: plugins didn't function at runtime) ──
|
# ── Diagnose local-plugin SPM wiring (the regression: our file: plugins didn't function at runtime) ──
|
||||||
echo "=== local plugins in node_modules — symlink vs copy, and is Package.swift present? ==="
|
echo "=== local plugins in node_modules — symlink vs copy, and is Package.swift present? ==="
|
||||||
for p in native-call media-library audio-route share-inbox; do
|
for p in native-call media-library audio-route share-inbox file-opener; do
|
||||||
echo "-- $p --"; ls -ld "node_modules/$p" 2>/dev/null || echo " (dir missing)"
|
echo "-- $p --"; ls -ld "node_modules/$p" 2>/dev/null || echo " (dir missing)"
|
||||||
( ls "node_modules/$p/Package.swift" >/dev/null 2>&1 && echo " Package.swift PRESENT" ) || echo " Package.swift MISSING"
|
( ls "node_modules/$p/Package.swift" >/dev/null 2>&1 && echo " Package.swift PRESENT" ) || echo " Package.swift MISSING"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"audio-route": "file:plugins/audio-route",
|
"audio-route": "file:plugins/audio-route",
|
||||||
|
"file-opener": "file:plugins/file-opener",
|
||||||
"media-library": "file:plugins/media-library",
|
"media-library": "file:plugins/media-library",
|
||||||
"native-call": "file:plugins/native-call",
|
"native-call": "file:plugins/native-call",
|
||||||
"share-inbox": "file:plugins/share-inbox",
|
"share-inbox": "file:plugins/share-inbox",
|
||||||
|
|||||||
@@ -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 'FileOpener' (PascalCase of the npm package name 'file-opener').
|
||||||
|
# `cap sync` writes `pod 'FileOpener', :path => '../../plugins/file-opener'` into the generated Podfile,
|
||||||
|
# and CocoaPods then looks for a file literally named FileOpener.podspec whose s.name is 'FileOpener'.
|
||||||
|
# Any other name → "No podspec found for `FileOpener`" and pod install fails. (Same trap that broke the
|
||||||
|
# AudioRoute build — see mobile/plugins/audio-route/AudioRoute.podspec.)
|
||||||
|
s.name = 'FileOpener'
|
||||||
|
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/file-opener.git', :tag => s.version.to_s }
|
||||||
|
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
|
||||||
|
s.ios.deployment_target = '15.0'
|
||||||
|
s.dependency 'Capacitor'
|
||||||
|
s.swift_version = '5.1'
|
||||||
|
end
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// swift-tools-version: 5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "FileOpener",
|
||||||
|
platforms: [.iOS(.v15)],
|
||||||
|
products: [
|
||||||
|
.library(name: "FileOpener", targets: ["FileOpenerPlugin"])
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "FileOpenerPlugin",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "Capacitor", package: "capacitor-swift-pm"),
|
||||||
|
.product(name: "Cordova", package: "capacitor-swift-pm")
|
||||||
|
],
|
||||||
|
path: "ios/Sources/FileOpenerPlugin")
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import Foundation
|
||||||
|
import Capacitor
|
||||||
|
import QuickLook
|
||||||
|
|
||||||
|
// Previews an already-downloaded file with iOS Quick Look — the native "look at this file" surface: swipe,
|
||||||
|
// pinch-zoom, print, and its own share button. Registered by cap sync as window.Capacitor.Plugins.FileOpener.
|
||||||
|
//
|
||||||
|
// WHY A PLUGIN: the app loads its UI from a REMOTE origin (remote.bizgaze.com), so it cannot open a local
|
||||||
|
// file:// URL in the WebView (cross-origin / capacitor local-serving isn't on this origin). @capacitor/share
|
||||||
|
// only offers the share SHEET ("open in another app"), not a preview. Only QLPreviewController, presented
|
||||||
|
// from native code, gives a real in-app preview. Quick Look picks the renderer from the file extension, which
|
||||||
|
// is why the web layer now saves downloads with a correct extension (see bzEnsureExt in home.html).
|
||||||
|
@objc(FileOpenerPlugin)
|
||||||
|
public class FileOpenerPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
|
public let identifier = "FileOpenerPlugin"
|
||||||
|
public let jsName = "FileOpener"
|
||||||
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
|
CAPPluginMethod(name: "preview", returnType: CAPPluginReturnPromise)
|
||||||
|
]
|
||||||
|
|
||||||
|
// QLPreviewController holds its dataSource weakly, so we must keep a strong reference alive for the
|
||||||
|
// lifetime of the presented preview — otherwise it deallocates and the preview shows blank.
|
||||||
|
private var dataSource: QLDataSource?
|
||||||
|
|
||||||
|
@objc func preview(_ call: CAPPluginCall) {
|
||||||
|
guard let raw = call.getString("path"), !raw.isEmpty else {
|
||||||
|
call.reject("path is required"); return
|
||||||
|
}
|
||||||
|
let url = FileOpenerPlugin.fileURL(from: raw)
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
call.reject("file not found: \(url.path)"); return
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let ds = QLDataSource(url: url)
|
||||||
|
self.dataSource = ds
|
||||||
|
let controller = QLPreviewController()
|
||||||
|
controller.dataSource = ds
|
||||||
|
controller.modalPresentationStyle = .fullScreen
|
||||||
|
guard let base = self.bridge?.viewController else {
|
||||||
|
call.reject("no view controller to present from"); return
|
||||||
|
}
|
||||||
|
// Present on top of whatever is already showing (a modal, another sheet) so it never fails silently.
|
||||||
|
var presenter = base
|
||||||
|
while let top = presenter.presentedViewController { presenter = top }
|
||||||
|
presenter.present(controller, animated: true) {
|
||||||
|
call.resolve(["ok": true])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-item data source. NSURL already conforms to QLPreviewItem.
|
||||||
|
final class QLDataSource: NSObject, QLPreviewControllerDataSource {
|
||||||
|
private let url: URL
|
||||||
|
init(url: URL) { self.url = url }
|
||||||
|
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
|
||||||
|
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
|
||||||
|
return url as NSURL
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "file-opener",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Preview a downloaded file with native iOS Quick Look 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/",
|
||||||
|
"FileOpener.podspec",
|
||||||
|
"Package.swift"
|
||||||
|
],
|
||||||
|
"capacitor": {
|
||||||
|
"ios": {
|
||||||
|
"src": "ios"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@capacitor/core": "^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-5
@@ -2809,6 +2809,23 @@ function bzMimeFromName(n){
|
|||||||
if(/^(mp3|wav|m4a|aac|ogg|opus)$/.test(e)) return 'audio/'+e;
|
if(/^(mp3|wav|m4a|aac|ogg|opus)$/.test(e)) return 'audio/'+e;
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
// Map a MIME type to a file extension. Needed because some downloads arrive without one — the lightbox
|
||||||
|
// hands us a bare "/files/<id>" (no name, no extension) — and a file saved with no extension is one that
|
||||||
|
// neither the Photos app will accept nor the Files app / Quick Look can open.
|
||||||
|
function bzExtForMime(mime){
|
||||||
|
const m=String(mime||'').toLowerCase().split(';')[0].trim();
|
||||||
|
const map={'image/jpeg':'jpg','image/jpg':'jpg','image/png':'png','image/gif':'gif','image/webp':'webp','image/heic':'heic','image/heif':'heif','image/bmp':'bmp','image/svg+xml':'svg',
|
||||||
|
'video/mp4':'mp4','video/quicktime':'mov','video/webm':'webm','video/3gpp':'3gp','video/x-matroska':'mkv','video/x-msvideo':'avi',
|
||||||
|
'audio/mpeg':'mp3','audio/mp4':'m4a','audio/aac':'aac','audio/wav':'wav','audio/x-wav':'wav','audio/ogg':'ogg','audio/opus':'opus',
|
||||||
|
'application/pdf':'pdf','application/zip':'zip','text/plain':'txt'};
|
||||||
|
if(map[m]) return map[m];
|
||||||
|
return (m.split('/')[1]||'').replace(/\+.*$/,'').replace(/[^a-z0-9]+/g,'').slice(0,5); // e.g. "svg+xml"→"svg"
|
||||||
|
}
|
||||||
|
function bzEnsureExt(name, mime){
|
||||||
|
const n=String(name||'file');
|
||||||
|
if(/\.[a-z0-9]{1,6}$/i.test(n)) return n; // already carries an extension
|
||||||
|
const ext=bzExtForMime(mime); return ext ? n+'.'+ext : n;
|
||||||
|
}
|
||||||
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; } }
|
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
|
// Also put saved photos/videos in the Photos app, in a "Connect" album — the way WhatsApp does. The Files
|
||||||
@@ -2889,11 +2906,14 @@ function bzSyncFileTiles(only){
|
|||||||
el.title = saved ? 'Open' : 'Download to this device';
|
el.title = saved ? 'Open' : 'Download to this device';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Open an already-downloaded file via the iOS share/preview sheet (Quick Look / open in another app). We have
|
// Open an already-downloaded file. Prefer a native Quick Look PREVIEW (the FileOpener plugin) — that's the iOS
|
||||||
// no dedicated file-opener plugin, so @capacitor/share on the local file URI is the reliable "open".
|
// "look at this file" surface (swipe, pinch, print, its own share button), which is what people expect when
|
||||||
|
// they tap a file. Only if the preview plugin isn't in this build do we fall back to the share sheet (which
|
||||||
|
// offers "open in another app"), then to a new browser tab.
|
||||||
async function bzOpenFile(rec, name){
|
async function bzOpenFile(rec, name){
|
||||||
const uri=rec&&rec.uri; if(!uri) return;
|
const uri=rec&&rec.uri; if(!uri) return;
|
||||||
const P=window.Capacitor&&window.Capacitor.Plugins;
|
const P=window.Capacitor&&window.Capacitor.Plugins;
|
||||||
|
if(P&&P.FileOpener&&P.FileOpener.preview){ try{ await P.FileOpener.preview({ path:uri }); return; }catch(_){ /* fall through to the share sheet */ } }
|
||||||
if(P&&P.Share){ try{ await P.Share.share({ title:name||'File', url:uri }); }catch(_){} return; } // catch = user cancelled
|
if(P&&P.Share){ try{ await P.Share.share({ title:name||'File', url:uri }); }catch(_){} return; } // catch = user cancelled
|
||||||
try{ const src=bzLibSrc(rec); if(src) window.open(src, '_blank'); }catch(_){}
|
try{ const src=bzLibSrc(rec); if(src) window.open(src, '_blank'); }catch(_){}
|
||||||
}
|
}
|
||||||
@@ -2901,13 +2921,19 @@ async function nativeSaveFile(url, name, meta){
|
|||||||
const C=window.Capacitor, P=C&&C.Plugins; const Fs=P&&P.Filesystem;
|
const C=window.Capacitor, P=C&&C.Plugins; const Fs=P&&P.Filesystem;
|
||||||
if(!Fs) return false;
|
if(!Fs) return false;
|
||||||
const id=(meta&&meta.id) || (String(url).split('?')[0].split('/').pop()||'');
|
const id=(meta&&meta.id) || (String(url).split('?')[0].split('/').pop()||'');
|
||||||
const fname=(String(name||url.split('/').pop()||'file').replace(/[\/\\:*?"<>|\r\n]+/g,'_')).slice(0,120)||'file';
|
let fname=(String(name||url.split('/').pop()||'file').replace(/[\/\\:*?"<>|\r\n]+/g,'_')).slice(0,120)||'file';
|
||||||
const mime=(meta&&meta.mime)||bzMimeFromName(fname);
|
let mime=(meta&&meta.mime)||bzMimeFromName(fname);
|
||||||
const onPct=(meta&&meta.onPct)||null;
|
const onPct=(meta&&meta.onPct)||null;
|
||||||
const box=onPct?null:bzDlShow(fname); // a video reports % inside its own tile, so no floating chip
|
const box=onPct?null:bzDlShow(fname); // a video reports % inside its own tile, so no floating chip
|
||||||
const lpath=bzLibPath(fname, mime, id);
|
|
||||||
try{
|
try{
|
||||||
const res=await fetch(url, {credentials:'include'}); if(!res.ok) throw new Error('http '+res.status);
|
const res=await fetch(url, {credentials:'include'}); if(!res.ok) throw new Error('http '+res.status);
|
||||||
|
// The lightbox / avatar downloads hand us a bare "/files/<id>" — no name, no extension, no mime — so an
|
||||||
|
// image was landing in the generic "Files" folder as an extension-less blob and never reaching Photos.
|
||||||
|
// Trust the server's Content-Type here, then give the file a real extension, so it goes to the right
|
||||||
|
// folder, opens in Quick Look, AND qualifies for the Photos save below.
|
||||||
|
if(!mime){ mime=((res.headers.get('Content-Type')||'').split(';')[0]||'').trim().toLowerCase(); }
|
||||||
|
fname=bzEnsureExt(fname, mime);
|
||||||
|
const lpath=bzLibPath(fname, mime, id);
|
||||||
const total=+(res.headers.get('Content-Length')||0);
|
const total=+(res.headers.get('Content-Length')||0);
|
||||||
let uri=null, wrote=0;
|
let uri=null, wrote=0;
|
||||||
if(res.body && res.body.getReader){
|
if(res.body && res.body.getReader){
|
||||||
|
|||||||
Reference in New Issue
Block a user