Compare commits

..

2 commits

Author SHA1 Message Date
Daniel
bee9361c1d Fix authenticated mobile image downloads
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m39s
2026-06-09 16:02:11 +02:00
Daniel
2ca969e099 Fix generated image mobile downloads
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m21s
2026-06-09 15:20:06 +02:00
8 changed files with 117 additions and 15 deletions

View file

@ -9,8 +9,8 @@ android {
targetSdkVersion rootProject.ext.targetSdkVersion
// Version values below are overwritten by scripts/release.sh from
// the root package.json. versionCode auto-increments per release.
versionCode 714012
versionName "7.14.12"
versionCode 714014
versionName "7.14.14"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -1,12 +1,12 @@
{
"name": "pedscribe-mobile",
"version": "7.14.12",
"version": "7.14.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pedscribe-mobile",
"version": "7.14.12",
"version": "7.14.14",
"dependencies": {
"@aparajita/capacitor-biometric-auth": "^8.0.0",
"@capacitor/android": "^6.0.0",

View file

@ -1,6 +1,6 @@
{
"name": "pedscribe-mobile",
"version": "7.14.12",
"version": "7.14.14",
"description": "PedScribe native mobile app — Capacitor wrapper for Pediatric AI Scribe",
"private": true,
"scripts": {

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "pediatric-ai-scribe",
"version": "7.14.12",
"version": "7.14.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pediatric-ai-scribe",
"version": "7.14.12",
"version": "7.14.14",
"dependencies": {
"@marp-team/marp-cli": "^4.3.1",
"@marp-team/marp-core": "^4.3.0",

View file

@ -1,6 +1,6 @@
{
"name": "pediatric-ai-scribe",
"version": "7.14.12",
"version": "7.14.14",
"description": "AI-powered pediatric clinical documentation platform",
"main": "server.js",
"scripts": {

View file

@ -5,9 +5,9 @@ export function createAssistantImageStore() {
var generatedImageSeq = 0;
var previewKeyHandler = null;
function renderGeneratedImage(src, alt) {
function renderGeneratedImage(src, alt, downloadUrl) {
var id = 'img-' + (++generatedImageSeq);
generatedImages[id] = src;
generatedImages[id] = { src: src, downloadUrl: downloadUrl || '' };
return '<div class="assistant-generated-image"><img src="' + escapeAttr(src) + '" alt="' + escapeAttr(alt || 'Generated image') + '">' +
'<div class="assistant-image-actions">' +
'<button type="button" class="btn-sm btn-ghost" data-assistant-open-image="' + escapeAttr(id) + '"><i class="fas fa-expand"></i> Preview</button>' +
@ -16,7 +16,8 @@ export function createAssistantImageStore() {
}
function openImagePreview(id) {
var src = generatedImages[id];
var item = generatedImages[id];
var src = item && (item.src || item);
if (!src) return;
closeImagePreview();
var modal = document.createElement('div');
@ -41,9 +42,15 @@ export function createAssistantImageStore() {
}
async function downloadImage(id) {
var src = generatedImages[id];
var item = generatedImages[id];
var src = item && (item.src || item);
var downloadUrl = item && item.downloadUrl;
if (!src) return;
try {
if (downloadUrl) {
await downloadFromServer(downloadUrl);
return;
}
if (await saveWithNativeShare(src)) return;
if (isNativeApp()) {
if (typeof window.showToast === 'function') window.showToast('Image saving is not available in this app build yet. Update the app and try again.', 'error');
@ -140,7 +147,7 @@ async function shareWithWebFile(src) {
if (!navigator.canShare({ files: [file] })) return 'unavailable';
await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' });
return 'shared';
} catch (e) { return isShareCancel(e) ? 'cancelled' : 'unavailable'; }
} catch (e) { return isShareCancel(e) ? 'unavailable' : 'unavailable'; }
}
async function downloadWithBrowser(src) {
@ -160,6 +167,79 @@ async function downloadWithBrowser(src) {
setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000);
}
async function downloadFromServer(url) {
var response = await fetch(url, { headers: authHeadersForDownload(), credentials: 'same-origin' });
if (!response.ok) throw new Error('Image download failed');
var blob = await response.blob();
var name = 'clinical-visual-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.png';
if (await saveBlobWithNativeImageBridge(blob, name)) return;
if (await saveBlobWithCapacitorShare(blob, name)) return;
if (await shareBlobWithWebFile(blob, name)) return;
if (isMobileBrowser()) {
if (typeof window.showToast === 'function') window.showToast('Could not start download here. Use Preview and long-press the image to save it.', 'error');
return;
}
downloadBlobWithAnchor(blob, name);
}
function authHeadersForDownload() {
var headers = window.getAuthHeaders ? window.getAuthHeaders() : {};
var clean = {};
Object.keys(headers || {}).forEach(function(key) {
if (key.toLowerCase() !== 'content-type') clean[key] = headers[key];
});
return clean;
}
async function saveBlobWithNativeImageBridge(blob, name) {
if (!window.NativeFiles || typeof window.NativeFiles.saveImage !== 'function') return false;
try {
var base64 = await blobToBase64(blob);
var result = String(window.NativeFiles.saveImage(name, base64) || '');
if (result.indexOf('saved:') === 0) {
if (typeof window.showToast === 'function') window.showToast('Image saved to Photos', 'success');
return true;
}
} catch (e) {}
return false;
}
async function saveBlobWithCapacitorShare(blob, name) {
var plugins = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins : null;
if (!plugins || !plugins.Filesystem) return false;
try {
var base64 = await blobToBase64(blob);
var saved = await plugins.Filesystem.writeFile({ path: name, data: base64, directory: 'CACHE' });
if (plugins.Share && saved && saved.uri) {
await plugins.Share.share({ title: 'Clinical visual', text: 'Clinical Assistant generated visual', url: saved.uri, dialogTitle: 'Save or share clinical visual' });
}
if (typeof window.showToast === 'function') window.showToast('Image prepared', 'success');
return true;
} catch (e) { return false; }
}
async function shareBlobWithWebFile(blob, name) {
if (!navigator.share || !navigator.canShare || typeof File === 'undefined') return false;
try {
var file = new File([blob], name, { type: blob.type || 'image/png' });
if (!navigator.canShare({ files: [file] })) return false;
await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' });
return true;
} catch (e) { return false; }
}
function downloadBlobWithAnchor(blob, name) {
var objectUrl = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = objectUrl;
a.download = name;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000);
}
function isMobileBrowser() {
return !isNativeApp() && /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || '');
}
@ -176,6 +256,10 @@ function isShareCancel(error) {
async function imageSourceToBase64(src) {
if (/^data:image\//i.test(src)) return src.split(',')[1] || '';
var blob = await imageSourceToBlob(src);
return blobToBase64(blob);
}
async function blobToBase64(blob) {
return new Promise(function(resolve, reject) {
var reader = new FileReader();
reader.onload = function() { resolve(String(reader.result || '').split(',')[1] || ''); };

View file

@ -492,10 +492,11 @@ import {
if (!src) throw new Error('No image returned');
lastGeneratedImageSrc = src;
exporter.invalidate();
if (out) out.innerHTML = imageStore.renderGeneratedImage(src, 'Generated clinical visual');
var downloadUrl = data.downloadUrl || '';
if (out) out.innerHTML = imageStore.renderGeneratedImage(src, 'Generated clinical visual', downloadUrl);
if (fromChat) {
setBusy(false, 'Ready');
var html = imageStore.renderGeneratedImage(src, 'Generated clinical visual');
var html = imageStore.renderGeneratedImage(src, 'Generated clinical visual', downloadUrl);
replaceLoadingMessage(loading, html, [], [], true);
}
})

View file

@ -325,6 +325,7 @@ router.get('/clinical-assistant/image/jobs/:id', async function(req, res) {
imageUrl: job.imageUrl || null,
url: job.url || null,
base64: job.base64 || null,
downloadUrl: job.status === 'done' && job.base64 ? '/api/clinical-assistant/image/jobs/' + encodeURIComponent(job.id) + '/download' : null,
error: job.error || null
});
} catch (e) {
@ -332,6 +333,22 @@ router.get('/clinical-assistant/image/jobs/:id', async function(req, res) {
}
});
router.get('/clinical-assistant/image/jobs/:id/download', async function(req, res) {
try {
var job = await getImageJob(req.params.id);
if (!job || String(job.userId) !== String(req.user.id) || job.status !== 'done' || !job.base64) {
return res.status(404).json({ error: 'Image job not found' });
}
var buffer = Buffer.from(String(job.base64), 'base64');
res.setHeader('Content-Type', 'image/png');
res.setHeader('Content-Disposition', 'attachment; filename="clinical-visual.png"');
res.setHeader('Cache-Control', 'private, max-age=900');
res.send(buffer);
} catch (e) {
res.status(500).json({ error: 'Image download failed' });
}
});
async function prepareAssistantChat(body) {
body = body || {};
var message = String(body.message || '').trim();