fix(android): deliver cleaned files on Android APK (#186) #189
No reviewers
Labels
No labels
bug
documentation
duplicate
e-copy
e-features
e-mobile
enhancement
f-coverage
f-forensic
f-perf
f-privacy
forensic
good first issue
help wanted
infra
invalid
phase-a
phase-b
phase-c
phase-d
phase-e
phase-f
phase-g
phase-h
priority-1
priority-2
priority-3
privacy
question
v5
v6
video-hardening
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: forgejo_admin/exifcleaner-web#189
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/android-downloads"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
On Android Capacitor WebView,
URL.createObjectURL()produces ablob:https://localhost/…URL. Android'sDownloadManager(which Capacitor delegates to) only handleshttp://andhttps://URLs — it cannot resolve a blob URL. The programmatic<a download>+anchor.click()path silently does nothing on Android. Cleaned files were never reaching the device.Affects both single-file output and the batch-zip path.
Solution
On Android native (
Capacitor.isNativePlatform() && platform === "android"),BrowserFileBytes.write()now saves cleaned bytes to the publicDownload/folder via@capacitor/filesysteminstead of the blob-URL anchor trick. A per-row Share button and a batch "Share zip" button callnavigator.share({ files: [...] })from a user-gesture click handler so users can immediately forward the file. Desktop behaviour is unchanged.Changes
src/infrastructure/web/platform.ts(new) —isNativeAndroid()helper using@capacitor/coresrc/infrastructure/web/browser_file_bytes.ts— Android write path viaFilesystem.writeFile(), bytes stash for Share button,toBase64()loop (no spread, avoids stack overflow)src/infrastructure/web/batch_output.ts—triggerDownloadcallback acceptsPromise<void>for async zip savesrc/infrastructure/web/web_api.ts—PlatformApi.isNativeAndroid,FilesApi.shareFile+notifyClearFiles,triggerZipSaveAndroid fork,ZIP_STASH_KEYexportsrc/web/contexts/AppContext.tsx—outputPath: string | nulladded toFileEntry+UPDATE_FILE_METADATAactionsrc/web/hooks/use_process_files.ts— populateoutputPathfromresult.outputPathsrc/web/components/icons/ShareIcon.tsx(new) — inline SVG share iconsrc/web/components/file-list/FileRow.tsx— per-row Share button (Android native only,isNativeAndroid)src/web/components/file-list/FileTable.tsx— batch zip Share button +metascrub:saved-to-downloadstoast listener +metascrub:batch-zip-savedlistenersrc/web/styles/file_table.css—.file-table__shareand.file-table__share-zip-btnstylesandroid/app/src/main/AndroidManifest.xml—WRITE_EXTERNAL_STORAGE maxSdkVersion="28"(only needed on Android ≤9).resources/strings.json—savedToDownloads,shareFile,shareZipkeys (25 locales)package.json—@capacitor/filesystem ^7(devDependencies)Screenshots (simulated Android mode —
isNativeAndroidforced true in browser)Empty state (Android, mobile viewport 390px):
After processing sample.jpg — Share button confirmed in DOM (1 instance), "Saved to Downloads" toast fires on save:
Note: screenshots are taken in a desktop browser with
isNativeAndroidmocked. On a real Android device the reveal icon is hidden (via existingdisplay: nonein the mobile media query) and the share icon is fully visible in the 110px RESULT column.Test plan
yarn lint && yarn typecheck && yarn test && yarn check:deps— all pass (519 tests)yarn build && yarn build:web:standalone— both build cleantests/infrastructure/web/browser_file_bytes.test.ts(7 tests) — stash behaviour, Android write path, event dispatch, no anchor click on Android<a download>behaviour is unchanged; all existing tests still passCloses #186
navigator.share({files:[blobFile]}) is unreliable in the Capacitor WebView — the native share intent needs a real filesystem URI. Switching to @capacitor/share.Share.share({files:[uri]}) which uses Android's native share sheet with the URI returned from Filesystem.writeFile. Also: any unexpected error (other than user cancellation) is now dispatched as a metascrub:download-error event which FileTable surfaces via toast, so failures are no longer silent. - web_api.ts: import Share, use it on Android with the stashed URI; keep navigator.share fallback for desktop - browser_file_bytes.ts: stash the uri returned by Filesystem.writeFile alongside bytes/filename; expose getStashedUri / stashUri - triggerZipSave returns { bytes, uri } so the trigger lambda can stash both - Tests: mock @capacitor/share and updated Filesystem.writeFile to return the WriteFileResult { uri } shape Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>@capacitor/filesystem can't write to public Downloads on Android 10+ because scoped storage blocks raw File writes through getExternalStoragePublicDirectory() regardless of permissions. The documented Android replacement is the MediaStore API, which @capacitor/filesystem does not expose. This adds a small custom Capacitor plugin (SaveToDownloadsPlugin.java, ~80 lines) that: - Android 10+ (API 29+): uses MediaStore.Downloads.EXTERNAL_CONTENT_URI + ContentResolver.insert. No runtime permission needed. - Android ≤9: direct File write to Environment.DIRECTORY_DOWNLOADS via the WRITE_EXTERNAL_STORAGE manifest declaration (maxSdkVersion=28). Result: cleaned files now actually appear in the user's public Downloads folder on all Android versions, visible in the Files app's Downloads view and the share-picker. The Share button still works (the MediaStore content:// URI is shareable via @capacitor/share). Fallback path: if the native plugin call fails (e.g. stale APK before recompile), @capacitor/filesystem app-private write still runs as a safety net. - android/app/.../SaveToDownloadsPlugin.java: new plugin - android/app/.../MainActivity.java: registerPlugin(SaveToDownloadsPlugin.class) - src/infrastructure/web/save_to_downloads.ts: JS binding + MIME-type helper - src/infrastructure/web/browser_file_bytes.ts: try SaveToDownloads, fall back - src/infrastructure/web/web_api.ts: same for the zip path - strings.json: revert savedToDownloads to "Saved to Downloads" (now accurate) - tests: mock SaveToDownloads, update assertions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>Drops the auto-save / MediaStore.Downloads / WRITE_EXTERNAL_STORAGE machinery in favor of user-controlled save and share: Save → Intent.ACTION_CREATE_DOCUMENT (Storage Access Framework). System file picker opens; user chooses destination folder AND filename. Plugin streams the cache file into the user-chosen URI. No permissions, no manifest declarations. Share → Intent.ACTION_SEND. Cache file → FileProvider URI → share sheet. User picks the receiving app. Works for both file:// (FileProvider-wrapped) and content:// URIs with FLAG_GRANT_READ_URI_PERMISSION. Cache → cleaned bytes written to Directory.Cache the moment a file finishes processing. Both Save and Share read from this cache file. Cleared on "Clean more". Why the change: - MediaStore writes worked on Android 10+ but were inflexible (always lands in Downloads, no rename option) and broke share (content:// URIs were rejected by @capacitor/share). - SAF gives the user control over WHERE and WHAT NAME, which matches modern Android UX expectations and avoids the permission/scoped-storage minefield entirely. Changes: - SaveToDownloadsPlugin.java: replace save() with saveAs() that uses startActivityForResult + ActivityCallback for the file picker. share() now uses FileProvider for file:// URIs. - AndroidManifest.xml: add FileProvider entry (authority com.metascrub.app.fileprovider). Drop WRITE_EXTERNAL_STORAGE. - res/xml/file_paths.xml: new — exposes cache + files dirs. - save_to_downloads.ts: saveAs/share TypeScript binding. - browser_file_bytes.ts: Android branch writes to Filesystem.Cache + stashes URI. clearStash() also deletes the cache files (best-effort). - web_api.ts: shareFile → SaveToDownloads.share. New saveFileAs → SaveToDownloads.saveAs. Zip path mirrors single-file. - FileRow.tsx + FileTable.tsx: Save icon (new) next to Share icon; "Save zip" button next to "Share zip". - SaveIcon.tsx: new icon component. - file_table.css: .file-table__save styles; widen mobile RESULT column from 138px to 160px to fit two icons. - strings.json: saveFile + saveZip keys (25 locales); update savedToDownloads to "File saved" (location is user-chosen now). - Tests updated for the new flow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>57500884c5tof52f4bbcee