feat(reader): add save/share button to image gallery toolbar (#4680)

* feat(reader): add save/share button to image gallery toolbar

Add a button to the top-right toolbar of the fullscreen image viewer
that saves the currently viewed image to the device. It uses the native
or web Share flow where available (iOS/Android/macOS, navigator.share)
and falls back to a save dialog or browser download otherwise, reusing
the existing export path via appService.saveFile.

The button icon and label reflect the active flow (share vs save).
Adds dataUrlToBytes/imageExtensionFromMime helpers, unit and component
tests, and translations for the new strings across all locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(share): write shareable file to a Temp subdirectory to avoid 0-byte share

On Android, Tauri's Temp dir is the app cache dir, and the sharekit plugin
copies the shared file to <cacheDir>/<name> before firing the share intent.
When saveFile wrote the shareable file to the Temp root, that copy became a
copy onto itself whose output stream truncated the source to 0 bytes, so the
shared image (and any shared export) arrived as a 0 KB file. Write the file
to a Temp subdirectory instead so the plugin's copy has a distinct source.

Verified on a Xiaomi device: sharing a file in the Temp root truncated it to
0 bytes, while sharing from the subdirectory produced a real, non-empty copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): save image to system gallery on Android

The Android share sheet cannot save an image to a file (no file manager
registers as an ACTION_SEND target), so the Save Image button now writes
the image straight into the system photo gallery via MediaStore. It lands
in Pictures/Readest, visible in Gallery and the Files app, with no picker
and no storage permission on Android 10+.

Adds a save_image_to_gallery command to the native-bridge plugin (Rust +
Kotlin MediaStore insert) and an appService.saveImageToGallery method. On
Android the Save button uses it; iOS/macOS/desktop/web keep the existing
share/export flow, and the button label/icon reflect the actual action.

Also includes local agent memory notes that were staged alongside.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-20 12:28:08 +08:00
committed by GitHub
parent a9526377a2
commit 7185dca1a2
64 changed files with 873 additions and 41 deletions
@@ -39,6 +39,7 @@
- [Custom fonts disappear on cloud sync (#4410)](custom-fonts-reincarnation-4410.md) — CRDT remove-wins: re-import-after-delete needs a `reincarnation` token or the pull re-applies the tombstone; `addFont`/`addTexture` minted none; fix mirrors dictionary (both cases) + OPDS token style; coverage matrix per kind
- [koplugin note deletion sync](koplugin-note-deletion-sync.md) — koplugin push only walked LIVE annotations so deletions never reached the server; fix = `recordDeletion` persists a `deletedAt` tombstone to `doc_settings.readest_sync.deleted_notes`, `push` folds+clears them; deletion signal in `onAnnotationsModified` is `items.index_modified < 0`
- [koplugin stats sync (#4666)](koplugin-stats-sync.md) — reading-stats sync (pull on open / push on close, whole statistics.sqlite3 delta, cursor-based); 3-bug chain: plain-table-not-LuaSettings `settings:readSetting` crash; missing required books/notes/configs; statBooks/statPages need `optional_params` (Spore expected=requiredoptional, `payload`≠accepted); large-backlog UI-stall + silent-retry risk unfixed
- [Statusless books re-pinned to top (#4677)](sync-statusless-book-rebump-4677.md) — never-statused (locally-imported, never-pulled) books send `reading_status:undefined` vs server `null`; server POST `statusChanged` (`undefined!==null`) rewrites `updated_at=now()` every push → batch-identical ts pins them top of date-sort; 1-day re-sync window amplifies; fix = `(a??null)!==(b??null)`. On-device CDP PUSH_SENT-vs-RETURNED proof recipe
## Testing
- [Nightly updater Android E2E](nightly-updater-android-e2e.md) — real Xiaomi/HyperOS test of #4577 self-updater; `pnpm dev-android` (--features devtools) for CDP, raw-socket CDP discovery, nightly>stable comparator, MIUI 单次安装授权 install gates
@@ -67,6 +68,7 @@
- [Large-PDF OOM range flood (#3470)](pdf-oom-range-flood-3470.md) — 50MB+ PDF import/open crash = foliate makePDF firing ALL pdf.js range reads un-awaited (753 concurrent fetch→shouldInterceptRequest→Java byte[] → 512MB heap OOM), NOT whole-file load; official viewer survives via browser ~6-conn/host cap; fix = MAX_CONCURRENT_RANGES=6 queue in makePDF; on-device CDP recipe; Xiaomi13/WV147 won't OOM but flood 753→6 verified
## Feature Notes
- [Save image to gallery (Android, #4680)](save-image-to-gallery-android.md) — image-viewer Save button → MediaStore on Android (share sheet can't save-to-file: ACTION_SEND has no file-manager target); sharekit 0-byte self-copy bug (Temp==cacheDir); tsgo misses abstract-class conformance (real tsc catches); on-device CDP verify recipe
- [Webtoon Mode (#3647)](webtoon-mode-3647.md) — seamless no-gap scrolled reading for image books (PRs #4662 + foliate-js#30); fixed-layout scroll mode is fit-width by construction (ignores `zoom`, only `scale-factor`); `scroll-gap` attr→`--scroll-page-gap` var; clear-on-leave in BOTH ViewMenu effect AND Shift+J; worktree submodule has local-path origin (push SHA direct to fork)
- [Biometric app-lock (#4645)](biometric-app-lock-4645.md) — fingerprint/Face ID startup unlock layered over PIN (mobile); gate must read flag from `appLockStore` not un-seeded `settingsStore` (race); `tauri-plugin-biometric` is `#![cfg(mobile)]` (desktop clippy skips it; pin in root Cargo.lock); scope i18n manually (en unscanned, full extract churns drift)
- [Tap to open image/table (#4600)](tap-to-open-image-table-4600.md) — single-tap opens gallery/table-zoom in **reflowable** EPUBs (long-press unchanged); `iframe-long-press` message renamed to `iframe-open-media`, hook `useLongPressEvent``useOpenMediaEvent`; shared `detectMediaTarget`; `handleClick` got `isFixedLayout`
@@ -0,0 +1,47 @@
---
name: cover-bg-image-texture-suppression
description: Cover painted via body background-image vanished under an active bg texture (parchment) because textureAwareBackground misclassified it as transparent
metadata:
node_type: memory
type: project
originSessionId: 9d32520c-53be-4871-9104-d93617736e30
---
EPUB cover pages that paint the cover via a `<body>` CSS `background-image`
(EPUB sets `background-color` transparent + `background-size:100% 100%`, no
`<img>` — e.g. Sigil/duokan样书《商梯》) showed the **background texture instead
of the cover** on the first page. Reported "Xiaomi only" but it's
texture-only, not Android-only.
Root cause (verified on-device via adb+CDP, Xiaomi 13 WV147): foliate
`packages/foliate-js/paginator.js` `textureAwareBackground(resolved, hasTexture)`.
foliate captures the body bg into `view.docBackground` via
`getComputedStyle(body).background` (the SHORTHAND), which always serializes the
transparent background-*color* first: `rgba(0, 0, 0, 0) url("blob:…") no-repeat
fixed 50% 50% / 100% 100% …`. The old `isTransparent` regex
`/^\s*(transparent|rgba\(0,\s*0,\s*0,\s*0\))/` matched that prefix → under an
active texture (`--bg-texture-id` != none) it returned `''` → no bg segment in
the host `#background` → texture (`.foliate-viewer::before`) showed through. With
no texture it worked (returns the cover bg unchanged), which is why desktop/
default looked fine.
Fix: a bg that carries an image is NOT transparent. Add `hasImage =
/\burl\(/i.test(resolved)` and gate `isTransparent` on `!hasImage`. A full-page
cover should occlude the texture; plain `none` transparent pages still drop so
the texture shows through. Helps scrolled (line ~1464) and paginated (~1482)
callers alike. Test: `paginator-background-segments.test.ts` (added the
url()-keeps case; kept the existing `none`-drops case).
NOT the bug (ruled out on-device): Rust `parse_epub_metadata` cover EXTRACTION
(library thumbnail was correct), shorthand serialization (WV147 emits the url
fine), the cover blob URL (loads 1200x1800 fine), `background-attachment:fixed`
(Android falls back to scroll but the segment sets `background-attachment:
initial` anyway). Related: [[paginated-texture-occlusion-4399]],
[[dark-mode-texture-body-bg-4446]], [[paginator-swipe-bg-flash]].
CDP verify recipe: pid changes per app restart — re-derive socket from
`/proc/net/unix` (`webview_devtools_remote_<pid>`), `adb forward tcp:9333
localabstract:…`; curl mishandles WV HTTP framing → raw-socket fetch `/json`;
pure-python WS client (omit Origin for M111+); paint a 50%-width test segment
with the cover blob bg into `#background` + `Page.captureScreenshot` to see
cover-vs-texture side by side.
@@ -0,0 +1,23 @@
---
name: save-image-to-gallery-android
description: Image-viewer Save button → Android MediaStore (not share); sharekit 0-byte self-copy bug; tsgo misses abstract conformance
metadata:
node_type: memory
type: project
originSessionId: d72184f1-0e4c-412b-9dc9-fb384e189427
---
PR #4680 — image gallery "Save Image" button (`ImageViewer.tsx` + `ZoomControls.tsx`).
**Routing (the button reflects the actual action):** `canShare = !isAndroidApp && canShareText(appService)`.
- Android → `appService.saveImageToGallery(filename, bytes, mimeType)` = new native-bridge command `save_image_to_gallery` (Kotlin `MediaStore.Images` insert into `Pictures/Readest`, scoped-storage = NO permission on API 29+; pre-29 best-effort). Writes a Temp `shared/<name>` staging file, passes its path, removes it after.
- iOS/macOS / web-with-`navigator.share``saveFile({share:true})`.
- desktop / web-no-share → saveDialog / download.
**WHY Android does NOT use the share sheet to "save to file":** Android `ACTION_SEND` only lists apps that *consume* content; NO file manager registers for it. Verified on device: `adb shell cmd package query-activities -a android.intent.action.SEND -t image/png` → 34 apps (Bluetooth/Gmail/WPS/Telegram/Xiaomi-Drive…), zero file managers. "Save to a folder" is `ACTION_CREATE_DOCUMENT` (system `com.google.android.documentsui`), which never appears in a share sheet. So on MIUI the share flow genuinely can't save-to-file.
**sharekit 0-byte self-copy bug (separate fix commit on #4680):** `@choochmeque/tauri-plugin-sharekit` (rust `tauri-plugin-sharekit 0.3`) `shareFile` copies src → `File(activity.cacheDir, sourceFile.name)` BEFORE `ACTION_SEND`. Tauri `Temp` dir IS `activity.cacheDir` = `/data/user/0/<pkg>/cache` (verified `invoke('plugin:path|resolve_directory',{directory:12})`). Writing the shared file to the Temp ROOT makes that a copy onto itself → `FileOutputStream` truncates the source to 0 before `copyTo` reads it → **0 KB shared file**. Fix = write to a Temp `shared/` SUBDIR in `nativeAppService.saveFile`. Also fixed the same latent 0-byte bug in annotation/markdown export.
**tsgo gap (bit me):** `pnpm lint` (tsgo) does NOT flag abstract-class interface conformance — adding a method to the `AppService` interface compiled clean under tsgo but the production Next `tsc` failed (`BaseAppService` missing abstract member). When extending `AppService`: add `abstract` decl in `BaseAppService` (appService.ts) + impls in native/web/**node**AppService + the 2 test stub classes (`app-service.test.ts`, `import-metahash.test.ts`). Run real `npx tsc --noEmit -p tsconfig.json` to catch.
**On-device verify recipe (no run-as on release APK):** `pnpm dev-android` (devtools APK) → CDP invoke `plugin:native-bridge|save_image_to_gallery` with a PNG staged via `plugin:fs|write_file` (body = Uint8Array 2nd arg, `headers:{path:encodeURIComponent(p),options:'{}'}`) → confirm with `adb shell content query --uri content://media/external/images/media --projection _display_name:relative_path:_size --where "relative_path='Pictures/Readest/'"`. Real 252 KB JPEGs from the live UI landed correctly. See [[android-cdp-e2e-lane]], [[cdp-android-webview-profiling]].
@@ -0,0 +1,20 @@
---
name: sync-statusless-book-rebump-4677
description: Books with no reading status get re-pinned to top of library after every sync (updated_at rebump); PR
metadata:
node_type: memory
type: project
originSessionId: f943703d-f8c5-4ad9-9c2c-fc2c02d8b62c
---
# Statusless books re-pinned to top of library after every sync (PR #4677)
**Symptom:** a fixed set of books stayed pinned at the top of a `updatedAt`-desc ("date read") library. Reading/closing another book moved it to front, but the next cloud sync floated those books back above it. Gone after logout → caused by the sync round-trip.
**Root cause** (`src/pages/api/sync.ts` POST handler, books branch ~line 422): when a pushed book is NOT newer than the server (`clientIsNewer` false), it rewrites `updated_at = new Date().toISOString()` if `statusChanged`. The check was `status.reading_status !== serverBook.reading_status`. A locally-imported book that never got a status sends `reading_status: undefined` (dropped by `JSON.stringify`); the server stores `null`. `undefined !== null` ⇒ true ⇒ spurious rewrite. The rewrite re-writes `undefined` (→ stays `null`), so it NEVER converges. Discriminator is purely client-side: books that round-tripped through a PULL have `readingStatus: null` (set by `transformBookFromDB`) and don't trigger it; never-pulled imports keep `undefined`.
**Amplifiers:** (1) the 1-day re-sync window (`useSync.ts:98` `lastSyncedAtBooks = stored - ONE_DAY_IN_MS`) re-pushes every recently-touched book each sync. (2) the rewrite runs in one batch `upsert` transaction → all affected rows get the SAME `now()` ⇒ identical-to-the-ms timestamps (the tell-tale signature).
**Fix:** `readingStatusChanged(a,b) = (a ?? null) !== (b ?? null)` — treat undefined/null both as "no status". Existing inflated timestamps age out naturally; no migration. NOT a DB trigger (Alice kept her config time, proving the app writes the value).
**CDP verification recipe (Xiaomi, on-device):** the `pnpm dev-android` build = release APK with `--features devtools` → WebView debugging on → `tauri.localhost` + `webview_devtools_remote_<pid>` socket. Discover socket from `/proc/net/unix`, `adb forward tcp:PORT localabstract:<sock>`, drive via Node 24 native `WebSocket` to `/json/list` page target. The page can call `fetch('https://web.readest.com/api/sync?since=0&type=books', {Authorization: Bearer <localStorage token>})` directly to read cloud `updated_at` per book. Decisive evidence = compare in-app `PUSH_SENT` (client sends old ts) vs `PUSH_RETURNED` (server returns fresh identical `now()`) for the statusless books only. Console object args replay as `Array(N)` previews with stale objectIds — log pre-stringified JSON and/or stash into a `window.__SYNCDBG` ring buffer read via `Runtime.evaluate(returnByValue)`. Related: [[android-cdp-e2e-lane]], [[cdp-android-webview-profiling]]. Touches #4634 reading-status field-level merge.
@@ -1907,5 +1907,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "أظهر تلميحًا قصيرًا بلغتك الأم فوق الكلمات الصعبة. الكلمات التي تفوق مستواك تحصل على تلميح.",
"Level": "المستوى",
"CEFR level": "مستوى CEFR",
"Webtoon Mode": "وضع الويبتون"
"Webtoon Mode": "وضع الويبتون",
"Image saved successfully": "تم حفظ الصورة بنجاح",
"Failed to save the image": "فشل حفظ الصورة",
"Share Image": "مشاركة الصورة",
"Save Image": "حفظ الصورة",
"Image saved to gallery": "تم حفظ الصورة في المعرض"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "কঠিন শব্দের উপরে আপনার মাতৃভাষায় একটি সংক্ষিপ্ত ইঙ্গিত দেখান। আপনার স্তরের উপরের শব্দগুলো একটি ইঙ্গিত পায়।",
"Level": "স্তর",
"CEFR level": "CEFR স্তর",
"Webtoon Mode": "ওয়েবটুন মোড"
"Webtoon Mode": "ওয়েবটুন মোড",
"Image saved successfully": "ছবি সফলভাবে সংরক্ষিত হয়েছে",
"Failed to save the image": "ছবি সংরক্ষণ করা যায়নি",
"Share Image": "ছবি শেয়ার করুন",
"Save Image": "ছবি সংরক্ষণ করুন",
"Image saved to gallery": "ছবি গ্যালারিতে সংরক্ষিত হয়েছে"
}
@@ -1742,5 +1742,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "ཚིག་དཀའ་མོའི་སྟེང་དུ་རང་གི་སྐད་ཡིག་ཐོག་ནས་བརྡ་སྟོན་ཐུང་ངུ་ཞིག་སྟོན། ཁྱེད་ཀྱི་རིམ་པ་ལས་མཐོ་བའི་ཚིག་ལ་བརྡ་སྟོན་ཐོབ།",
"Level": "རིམ་པ།",
"CEFR level": "CEFR རིམ་པ།",
"Webtoon Mode": "Webtoon སྤྱོད་ཚུལ།"
"Webtoon Mode": "Webtoon སྤྱོད་ཚུལ།",
"Image saved successfully": "པར་རིས་ཉར་ཚགས་ལེགས་གྲུབ་བྱུང་",
"Failed to save the image": "པར་རིས་ཉར་ཚགས་བྱས་མ་ཐུབ",
"Share Image": "པར་རིས་མཉམ་སྤྱོད།",
"Save Image": "པར་རིས་ཉར་ཚགས།",
"Image saved to gallery": "པར་རིས་པར་མཛོད་ནང་ཉར་ཚགས་བྱས་ཟིན"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Zeigt einen kurzen Hinweis in deiner Muttersprache über schwierigen Wörtern an. Wörter über deinem Niveau erhalten einen Hinweis.",
"Level": "Niveau",
"CEFR level": "CEFR-Niveau",
"Webtoon Mode": "Webtoon-Modus"
"Webtoon Mode": "Webtoon-Modus",
"Image saved successfully": "Bild erfolgreich gespeichert",
"Failed to save the image": "Bild konnte nicht gespeichert werden",
"Share Image": "Bild teilen",
"Save Image": "Bild speichern",
"Image saved to gallery": "Bild in der Galerie gespeichert"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Εμφανίζει μια σύντομη υπόδειξη στη μητρική σου γλώσσα πάνω από δύσκολες λέξεις. Οι λέξεις πάνω από το επίπεδό σου λαμβάνουν υπόδειξη.",
"Level": "Επίπεδο",
"CEFR level": "Επίπεδο CEFR",
"Webtoon Mode": "Λειτουργία webtoon"
"Webtoon Mode": "Λειτουργία webtoon",
"Image saved successfully": "Η εικόνα αποθηκεύτηκε με επιτυχία",
"Failed to save the image": "Αποτυχία αποθήκευσης της εικόνας",
"Share Image": "Κοινή χρήση εικόνας",
"Save Image": "Αποθήκευση εικόνας",
"Image saved to gallery": "Η εικόνα αποθηκεύτηκε στη συλλογή"
}
@@ -1808,5 +1808,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Muestra una breve pista en tu idioma nativo encima de las palabras difíciles. Las palabras por encima de tu nivel reciben una pista.",
"Level": "Nivel",
"CEFR level": "Nivel CEFR",
"Webtoon Mode": "Modo webtoon"
"Webtoon Mode": "Modo webtoon",
"Image saved successfully": "Imagen guardada correctamente",
"Failed to save the image": "No se pudo guardar la imagen",
"Share Image": "Compartir imagen",
"Save Image": "Guardar imagen",
"Image saved to gallery": "Imagen guardada en la galería"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "یک راهنمای کوتاه به زبان مادری‌تان بالای واژه‌های دشوار نشان می‌دهد. واژه‌های بالاتر از سطح شما راهنما می‌گیرند.",
"Level": "سطح",
"CEFR level": "سطح CEFR",
"Webtoon Mode": "حالت وب‌تون"
"Webtoon Mode": "حالت وب‌تون",
"Image saved successfully": "تصویر با موفقیت ذخیره شد",
"Failed to save the image": "ذخیره تصویر ناموفق بود",
"Share Image": "اشتراک‌گذاری تصویر",
"Save Image": "ذخیره تصویر",
"Image saved to gallery": "تصویر در گالری ذخیره شد"
}
@@ -1808,5 +1808,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Affiche une courte indication dans votre langue maternelle au-dessus des mots difficiles. Les mots au-dessus de votre niveau reçoivent une indication.",
"Level": "Niveau",
"CEFR level": "Niveau CEFR",
"Webtoon Mode": "Mode webtoon"
"Webtoon Mode": "Mode webtoon",
"Image saved successfully": "Image enregistrée avec succès",
"Failed to save the image": "Échec de l'enregistrement de l'image",
"Share Image": "Partager l'image",
"Save Image": "Enregistrer l'image",
"Image saved to gallery": "Image enregistrée dans la galerie"
}
@@ -1808,5 +1808,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "מציג רמז קצר בשפת האם שלך מעל מילים קשות. מילים מעל הרמה שלך מקבלות רמז.",
"Level": "רמה",
"CEFR level": "רמת CEFR",
"Webtoon Mode": "מצב ובטון"
"Webtoon Mode": "מצב ובטון",
"Image saved successfully": "התמונה נשמרה בהצלחה",
"Failed to save the image": "שמירת התמונה נכשלה",
"Share Image": "שיתוף התמונה",
"Save Image": "שמירת התמונה",
"Image saved to gallery": "התמונה נשמרה בגלריה"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "कठिन शब्दों के ऊपर आपकी मातृभाषा में एक छोटा संकेत दिखाता है। आपके स्तर से ऊपर के शब्दों को संकेत मिलता है।",
"Level": "स्तर",
"CEFR level": "CEFR स्तर",
"Webtoon Mode": "वेबटून मोड"
"Webtoon Mode": "वेबटून मोड",
"Image saved successfully": "छवि सफलतापूर्वक सहेजी गई",
"Failed to save the image": "छवि सहेजने में विफल",
"Share Image": "छवि साझा करें",
"Save Image": "छवि सहेजें",
"Image saved to gallery": "छवि गैलरी में सहेजी गई"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Rövid anyanyelvi tippet jelenít meg a nehéz szavak fölött. A szintednél nehezebb szavak kapnak tippet.",
"Level": "Szint",
"CEFR level": "CEFR-szint",
"Webtoon Mode": "Webtoon mód"
"Webtoon Mode": "Webtoon mód",
"Image saved successfully": "A kép sikeresen mentve",
"Failed to save the image": "Nem sikerült menteni a képet",
"Share Image": "Kép megosztása",
"Save Image": "Kép mentése",
"Image saved to gallery": "A kép elmentve a galériába"
}
@@ -1742,5 +1742,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Menampilkan petunjuk singkat dalam bahasa ibu Anda di atas kata-kata sulit. Kata-kata di atas level Anda mendapat petunjuk.",
"Level": "Level",
"CEFR level": "Level CEFR",
"Webtoon Mode": "Mode webtoon"
"Webtoon Mode": "Mode webtoon",
"Image saved successfully": "Gambar berhasil disimpan",
"Failed to save the image": "Gagal menyimpan gambar",
"Share Image": "Bagikan Gambar",
"Save Image": "Simpan Gambar",
"Image saved to gallery": "Gambar disimpan ke galeri"
}
@@ -1808,5 +1808,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Mostra un breve suggerimento nella tua lingua madre sopra le parole difficili. Le parole sopra il tuo livello ricevono un suggerimento.",
"Level": "Livello",
"CEFR level": "Livello CEFR",
"Webtoon Mode": "Modalità webtoon"
"Webtoon Mode": "Modalità webtoon",
"Image saved successfully": "Immagine salvata correttamente",
"Failed to save the image": "Impossibile salvare l'immagine",
"Share Image": "Condividi immagine",
"Save Image": "Salva immagine",
"Image saved to gallery": "Immagine salvata nella galleria"
}
@@ -1742,5 +1742,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "難しい単語の上に母語の短いヒントを表示します。あなたのレベルを超える単語にヒントが付きます。",
"Level": "レベル",
"CEFR level": "CEFRレベル",
"Webtoon Mode": "ウェブトゥーンモード"
"Webtoon Mode": "ウェブトゥーンモード",
"Image saved successfully": "画像を保存しました",
"Failed to save the image": "画像の保存に失敗しました",
"Share Image": "画像を共有",
"Save Image": "画像を保存",
"Image saved to gallery": "画像をギャラリーに保存しました"
}
@@ -1742,5 +1742,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "어려운 단어 위에 모국어로 된 짧은 힌트를 표시합니다. 내 수준보다 높은 단어에 힌트가 표시됩니다.",
"Level": "수준",
"CEFR level": "CEFR 수준",
"Webtoon Mode": "웹툰 모드"
"Webtoon Mode": "웹툰 모드",
"Image saved successfully": "이미지를 저장했습니다",
"Failed to save the image": "이미지를 저장하지 못했습니다",
"Share Image": "이미지 공유",
"Save Image": "이미지 저장",
"Image saved to gallery": "이미지를 갤러리에 저장했습니다"
}
@@ -1742,5 +1742,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Menunjukkan petunjuk ringkas dalam bahasa ibunda anda di atas perkataan sukar. Perkataan melebihi tahap anda akan mendapat petunjuk.",
"Level": "Tahap",
"CEFR level": "Tahap CEFR",
"Webtoon Mode": "Mode webtoon"
"Webtoon Mode": "Mode webtoon",
"Image saved successfully": "Imej berjaya disimpan",
"Failed to save the image": "Gagal menyimpan imej",
"Share Image": "Kongsi Imej",
"Save Image": "Simpan Imej",
"Image saved to gallery": "Imej disimpan ke galeri"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Toont een korte hint in je moedertaal boven moeilijke woorden. Woorden boven jouw niveau krijgen een hint.",
"Level": "Niveau",
"CEFR level": "CEFR-niveau",
"Webtoon Mode": "Webtoon-modus"
"Webtoon Mode": "Webtoon-modus",
"Image saved successfully": "Afbeelding succesvol opgeslagen",
"Failed to save the image": "Kan de afbeelding niet opslaan",
"Share Image": "Afbeelding delen",
"Save Image": "Afbeelding opslaan",
"Image saved to gallery": "Afbeelding opgeslagen in de galerij"
}
@@ -1841,5 +1841,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Pokazuje krótką wskazówkę w Twoim języku ojczystym nad trudnymi słowami. Słowa powyżej Twojego poziomu otrzymują wskazówkę.",
"Level": "Poziom",
"CEFR level": "Poziom CEFR",
"Webtoon Mode": "Tryb webtoon"
"Webtoon Mode": "Tryb webtoon",
"Image saved successfully": "Obraz został zapisany pomyślnie",
"Failed to save the image": "Nie udało się zapisać obrazu",
"Share Image": "Udostępnij obraz",
"Save Image": "Zapisz obraz",
"Image saved to gallery": "Obraz zapisano w galerii"
}
@@ -1808,5 +1808,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Mostra uma breve dica no seu idioma nativo acima de palavras difíceis. Palavras acima do seu nível recebem uma dica.",
"Level": "Nível",
"CEFR level": "Nível CEFR",
"Webtoon Mode": "Modo webtoon"
"Webtoon Mode": "Modo webtoon",
"Image saved successfully": "Imagem salva com sucesso",
"Failed to save the image": "Falha ao salvar a imagem",
"Share Image": "Compartilhar imagem",
"Save Image": "Salvar imagem",
"Image saved to gallery": "Imagem salva na galeria"
}
@@ -1808,5 +1808,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Mostra uma breve dica na sua língua materna acima de palavras difíceis. Palavras acima do seu nível recebem uma dica.",
"Level": "Nível",
"CEFR level": "Nível CEFR",
"Webtoon Mode": "Modo webtoon"
"Webtoon Mode": "Modo webtoon",
"Image saved successfully": "Imagem salva com sucesso",
"Failed to save the image": "Falha ao salvar a imagem",
"Share Image": "Partilhar imagem",
"Save Image": "Salvar imagem",
"Image saved to gallery": "Imagem guardada na galeria"
}
@@ -1808,5 +1808,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Afișează un scurt indiciu în limba ta maternă deasupra cuvintelor dificile. Cuvintele peste nivelul tău primesc un indiciu.",
"Level": "Nivel",
"CEFR level": "Nivel CEFR",
"Webtoon Mode": "Mod webtoon"
"Webtoon Mode": "Mod webtoon",
"Image saved successfully": "Imagine salvată cu succes",
"Failed to save the image": "Salvarea imaginii a eșuat",
"Share Image": "Partajează imaginea",
"Save Image": "Salvează imaginea",
"Image saved to gallery": "Imagine salvată în galerie"
}
@@ -1841,5 +1841,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Показывает короткую подсказку на вашем родном языке над сложными словами. Слова выше вашего уровня получают подсказку.",
"Level": "Уровень",
"CEFR level": "Уровень CEFR",
"Webtoon Mode": "Режим вебтуна"
"Webtoon Mode": "Режим вебтуна",
"Image saved successfully": "Изображение успешно сохранено",
"Failed to save the image": "Не удалось сохранить изображение",
"Share Image": "Поделиться изображением",
"Save Image": "Сохранить изображение",
"Image saved to gallery": "Изображение сохранено в галерее"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "අසීරු වචන මත ඔබේ මව් භාෂාවෙන් කෙටි ඉඟියක් පෙන්වයි. ඔබේ මට්ටමට වඩා ඉහළ වචනවලට ඉඟියක් ලැබේ.",
"Level": "මට්ටම",
"CEFR level": "CEFR මට්ටම",
"Webtoon Mode": "Webtoon ආකාරය"
"Webtoon Mode": "Webtoon ආකාරය",
"Image saved successfully": "රූපය සාර්ථකව සුරැකිණි",
"Failed to save the image": "රූපය සුරැකීමට අසමත් විය",
"Share Image": "රූපය බෙදාගන්න",
"Save Image": "රූපය සුරකින්න",
"Image saved to gallery": "රූපය ගැලරියට සුරැකිණි"
}
@@ -1841,5 +1841,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Prikaže kratek namig v tvojem maternem jeziku nad težkimi besedami. Besede nad tvojo ravnjo dobijo namig.",
"Level": "Raven",
"CEFR level": "Raven CEFR",
"Webtoon Mode": "Tryb webtoon"
"Webtoon Mode": "Tryb webtoon",
"Image saved successfully": "Slika je bila uspešno shranjena",
"Failed to save the image": "Slike ni bilo mogoče shraniti",
"Share Image": "Deli sliko",
"Save Image": "Shrani sliko",
"Image saved to gallery": "Slika je shranjena v galerijo"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Visar en kort ledtråd på ditt modersmål ovanför svåra ord. Ord över din nivå får en ledtråd.",
"Level": "Nivå",
"CEFR level": "CEFR-nivå",
"Webtoon Mode": "Webtoon-läge"
"Webtoon Mode": "Webtoon-läge",
"Image saved successfully": "Bilden har sparats",
"Failed to save the image": "Det gick inte att spara bilden",
"Share Image": "Dela bild",
"Save Image": "Spara bild",
"Image saved to gallery": "Bilden har sparats i galleriet"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "கடினமான சொற்களுக்கு மேலே உங்கள் தாய்மொழியில் ஒரு சிறு குறிப்பைக் காட்டும். உங்கள் நிலைக்கு மேலான சொற்களுக்கு குறிப்பு கிடைக்கும்.",
"Level": "நிலை",
"CEFR level": "CEFR நிலை",
"Webtoon Mode": "வெப்டூன் பயன்முறை"
"Webtoon Mode": "வெப்டூன் பயன்முறை",
"Image saved successfully": "படம் வெற்றிகரமாகச் சேமிக்கப்பட்டது",
"Failed to save the image": "படத்தைச் சேமிக்க முடியவில்லை",
"Share Image": "படத்தைப் பகிர்",
"Save Image": "படத்தைச் சேமி",
"Image saved to gallery": "படம் கேலரியில் சேமிக்கப்பட்டது"
}
@@ -1742,5 +1742,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "แสดงคำใบ้สั้น ๆ ในภาษาแม่ของคุณเหนือคำที่ยาก คำที่สูงกว่าระดับของคุณจะมีคำใบ้",
"Level": "ระดับ",
"CEFR level": "ระดับ CEFR",
"Webtoon Mode": "โหมดเว็บตูน"
"Webtoon Mode": "โหมดเว็บตูน",
"Image saved successfully": "บันทึกรูปภาพสำเร็จแล้ว",
"Failed to save the image": "ไม่สามารถบันทึกรูปภาพได้",
"Share Image": "แชร์รูปภาพ",
"Save Image": "บันทึกรูปภาพ",
"Image saved to gallery": "บันทึกรูปภาพไปยังแกลเลอรีแล้ว"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Zor kelimelerin üzerinde ana dilinizde kısa bir ipucu gösterir. Seviyenizin üzerindeki kelimeler ipucu alır.",
"Level": "Seviye",
"CEFR level": "CEFR seviyesi",
"Webtoon Mode": "Webtoon modu"
"Webtoon Mode": "Webtoon modu",
"Image saved successfully": "Görsel başarıyla kaydedildi",
"Failed to save the image": "Görsel kaydedilemedi",
"Share Image": "Görseli Paylaş",
"Save Image": "Görseli Kaydet",
"Image saved to gallery": "Görsel galeriye kaydedildi"
}
@@ -1841,5 +1841,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Показує коротку підказку вашою рідною мовою над складними словами. Слова, вищі за ваш рівень, отримують підказку.",
"Level": "Рівень",
"CEFR level": "Рівень CEFR",
"Webtoon Mode": "Режим вебтуна"
"Webtoon Mode": "Режим вебтуна",
"Image saved successfully": "Зображення успішно збережено",
"Failed to save the image": "Не вдалося зберегти зображення",
"Share Image": "Поділитися зображенням",
"Save Image": "Зберегти зображення",
"Image saved to gallery": "Зображення збережено в галереї"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Qiyin sozlar ustida ona tilingizda qisqa maslahat korsatadi. Darajangizdan yuqori sozlarga maslahat beriladi.",
"Level": "Daraja",
"CEFR level": "CEFR darajasi",
"Webtoon Mode": "Webtoon rejimi"
"Webtoon Mode": "Webtoon rejimi",
"Image saved successfully": "Rasm muvaffaqiyatli saqlandi",
"Failed to save the image": "Rasmni saqlab bo'lmadi",
"Share Image": "Rasmni ulashish",
"Save Image": "Rasmni saqlash",
"Image saved to gallery": "Rasm galereyaga saqlandi"
}
@@ -1742,5 +1742,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Hiển thị gợi ý ngắn bằng tiếng mẹ đẻ của bạn phía trên những từ khó. Những từ trên trình độ của bạn sẽ có gợi ý.",
"Level": "Cấp độ",
"CEFR level": "Cấp độ CEFR",
"Webtoon Mode": "Chế độ webtoon"
"Webtoon Mode": "Chế độ webtoon",
"Image saved successfully": "Đã lưu hình ảnh thành công",
"Failed to save the image": "Không thể lưu hình ảnh",
"Share Image": "Chia sẻ hình ảnh",
"Save Image": "Lưu hình ảnh",
"Image saved to gallery": "Đã lưu hình ảnh vào thư viện"
}
@@ -1742,5 +1742,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "在生词上方显示一条母语简短提示。高于你水平的单词会显示提示。",
"Level": "级别",
"CEFR level": "CEFR 级别",
"Webtoon Mode": "条漫模式"
"Webtoon Mode": "条漫模式",
"Image saved successfully": "图片保存成功",
"Failed to save the image": "图片保存失败",
"Share Image": "分享图片",
"Save Image": "保存图片",
"Image saved to gallery": "图片已保存到相册"
}
@@ -1742,5 +1742,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "在難詞上方顯示一則母語簡短提示。高於你程度的單字會顯示提示。",
"Level": "等級",
"CEFR level": "CEFR 等級",
"Webtoon Mode": "條漫模式"
"Webtoon Mode": "條漫模式",
"Image saved successfully": "圖片儲存成功",
"Failed to save the image": "圖片儲存失敗",
"Share Image": "分享圖片",
"Save Image": "儲存圖片",
"Image saved to gallery": "圖片已儲存至相簿"
}
@@ -4,9 +4,11 @@ import android.Manifest
import android.app.Activity
import android.app.PendingIntent
import android.content.ComponentName
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import android.os.Build
import android.os.Environment
@@ -55,6 +57,14 @@ class CopyURIRequestArgs {
var dst: String? = null
}
@InvokeArg
class SaveImageToGalleryRequestArgs {
var srcPath: String? = null
var fileName: String? = null
var mimeType: String? = null
var albumName: String? = null
}
@InvokeArg
class InstallPackageRequestArgs {
var path: String? = null
@@ -369,6 +379,77 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
}
@Command
fun save_image_to_gallery(invoke: Invoke) {
val args = invoke.parseArgs(SaveImageToGalleryRequestArgs::class.java)
pluginScope.launch {
val ret = withContext(Dispatchers.IO) {
val r = JSObject()
try {
val srcFile = File(args.srcPath ?: "")
if (!srcFile.exists()) {
r.put("success", false)
r.put("error", "Source file does not exist")
return@withContext r
}
val displayName = args.fileName ?: srcFile.name
val mimeType = args.mimeType ?: "image/*"
val album = args.albumName ?: "Readest"
val resolver = activity.contentResolver
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
put(MediaStore.Images.Media.MIME_TYPE, mimeType)
// Scoped storage (Android 10+): place the image under the
// shared Pictures collection without any storage permission,
// and mark it pending until the bytes are fully written.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(
MediaStore.Images.Media.RELATIVE_PATH,
"${Environment.DIRECTORY_PICTURES}/$album"
)
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}
val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
} else {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}
val itemUri = resolver.insert(collection, values)
if (itemUri == null) {
r.put("success", false)
r.put("error", "Failed to create MediaStore entry")
return@withContext r
}
resolver.openOutputStream(itemUri).use { output ->
if (output == null) {
throw IOException("Failed to open output stream")
}
srcFile.inputStream().use { input -> input.copyTo(output) }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val pending = ContentValues().apply {
put(MediaStore.Images.Media.IS_PENDING, 0)
}
resolver.update(itemUri, pending, null, null)
}
r.put("success", true)
r.put("uri", itemUri.toString())
} catch (e: Exception) {
r.put("success", false)
r.put("error", e.message)
}
r
}
if (isActive) invoke.resolve(ret)
}
}
@Command
fun install_package(invoke: Invoke) {
val args = invoke.parseArgs(InstallPackageRequestArgs::class.java)
@@ -2,6 +2,7 @@ const COMMANDS: &[&str] = &[
"auth_with_safari",
"auth_with_custom_tab",
"copy_uri_to_path",
"save_image_to_gallery",
"use_background_audio",
"install_package",
"set_system_ui_visibility",
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-save-image-to-gallery"
description = "Enables the save_image_to_gallery command without any pre-configured scope."
commands.allow = ["save_image_to_gallery"]
[[permission]]
identifier = "deny-save-image-to-gallery"
description = "Denies the save_image_to_gallery command without any pre-configured scope."
commands.deny = ["save_image_to_gallery"]
@@ -7,6 +7,7 @@ Default permissions for the plugin
- `allow-auth-with-safari`
- `allow-auth-with-custom-tab`
- `allow-copy-uri-to-path`
- `allow-save-image-to-gallery`
- `allow-use-background-audio`
- `allow-install-package`
- `allow-set-system-ui-visibility`
@@ -938,6 +939,32 @@ Denies the request_permissions command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-save-image-to-gallery`
</td>
<td>
Enables the save_image_to_gallery command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-save-image-to-gallery`
</td>
<td>
Denies the save_image_to_gallery command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-select-directory`
</td>
@@ -4,6 +4,7 @@ permissions = [
"allow-auth-with-safari",
"allow-auth-with-custom-tab",
"allow-copy-uri-to-path",
"allow-save-image-to-gallery",
"allow-use-background-audio",
"allow-install-package",
"allow-set-system-ui-visibility",
@@ -702,6 +702,18 @@
"const": "deny-request-permissions",
"markdownDescription": "Denies the request_permissions command without any pre-configured scope."
},
{
"description": "Enables the save_image_to_gallery command without any pre-configured scope.",
"type": "string",
"const": "allow-save-image-to-gallery",
"markdownDescription": "Enables the save_image_to_gallery command without any pre-configured scope."
},
{
"description": "Denies the save_image_to_gallery command without any pre-configured scope.",
"type": "string",
"const": "deny-save-image-to-gallery",
"markdownDescription": "Denies the save_image_to_gallery command without any pre-configured scope."
},
{
"description": "Enables the select_directory command without any pre-configured scope.",
"type": "string",
@@ -775,10 +787,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-save-image-to-gallery`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-save-image-to-gallery`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`"
}
]
}
@@ -30,6 +30,14 @@ pub(crate) async fn copy_uri_to_path<R: Runtime>(
app.native_bridge().copy_uri_to_path(payload)
}
#[command]
pub(crate) async fn save_image_to_gallery<R: Runtime>(
app: AppHandle<R>,
payload: SaveImageToGalleryRequest,
) -> Result<SaveImageToGalleryResponse> {
app.native_bridge().save_image_to_gallery(payload)
}
#[command]
pub(crate) async fn use_background_audio<R: Runtime>(
app: AppHandle<R>,
@@ -60,6 +60,13 @@ impl<R: Runtime> NativeBridge<R> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn save_image_to_gallery(
&self,
_payload: SaveImageToGalleryRequest,
) -> crate::Result<SaveImageToGalleryResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn use_background_audio(&self, _payload: UseBackgroundAudioRequest) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
@@ -58,6 +58,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::auth_with_safari,
commands::auth_with_custom_tab,
commands::copy_uri_to_path,
commands::save_image_to_gallery,
commands::use_background_audio,
commands::install_package,
commands::set_system_ui_visibility,
@@ -48,6 +48,17 @@ impl<R: Runtime> NativeBridge<R> {
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn save_image_to_gallery(
&self,
payload: SaveImageToGalleryRequest,
) -> crate::Result<SaveImageToGalleryResponse> {
self.0
.run_mobile_plugin("save_image_to_gallery", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn use_background_audio(&self, payload: UseBackgroundAudioRequest) -> crate::Result<()> {
self.0
@@ -27,6 +27,27 @@ pub struct CopyURIResponse {
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveImageToGalleryRequest {
/// Absolute path of the source image file on disk.
pub src_path: String,
/// Display name for the saved image, e.g. `image.png`.
pub file_name: String,
pub mime_type: String,
/// Subfolder under the system Pictures collection. Defaults to `Readest`.
pub album_name: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveImageToGalleryResponse {
pub success: bool,
/// MediaStore content URI of the saved image on success.
pub uri: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UseBackgroundAudioRequest {
@@ -12,6 +12,11 @@ vi.mock('@/hooks/useKeyDownActions', () => ({
useKeyDownActions: () => {},
}));
// ImageViewer reads appService (for the save button) via useEnv; stub it.
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: null }),
}));
// ZoomControls reaches into the theme store and Tauri window APIs; stub it out.
vi.mock('@/app/reader/components/ZoomControls', () => ({
__esModule: true,
@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, fireEvent, waitFor } from '@testing-library/react';
import ImageViewer from '@/app/reader/components/ImageViewer';
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (s: string) => s,
}));
vi.mock('@/hooks/useKeyDownActions', () => ({
useKeyDownActions: () => {},
}));
vi.mock('@/store/themeStore', () => ({
useThemeStore: () => ({ systemUIVisible: false, statusBarHeight: 0 }),
}));
const h = vi.hoisted(() => ({
appService: null as {
isMobileApp: boolean;
isMacOSApp: boolean;
isAndroidApp?: boolean;
saveFile: ReturnType<typeof vi.fn>;
saveImageToGallery?: ReturnType<typeof vi.fn>;
} | null,
dispatch: vi.fn(),
}));
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: h.appService }),
}));
vi.mock('@/utils/event', () => ({
eventDispatcher: { dispatch: h.dispatch },
}));
// "AAEC" base64 decodes to bytes [0, 1, 2].
const PNG_DATA_URL = 'data:image/png;base64,AAEC';
const gridInsets = { top: 0, right: 0, bottom: 0, left: 0 };
beforeEach(() => {
h.appService = null;
h.dispatch.mockReset();
});
afterEach(cleanup);
describe('ImageViewer save/share button', () => {
it('exports the image and toasts when sharing is unavailable', async () => {
const saveFile = vi.fn().mockResolvedValue(true);
h.appService = { isMobileApp: false, isMacOSApp: false, saveFile };
const { getByLabelText } = render(
<ImageViewer src={PNG_DATA_URL} onClose={vi.fn()} gridInsets={gridInsets} />,
);
// No native/web share → the affordance is the "Save Image" (export) button.
fireEvent.click(getByLabelText('Save Image'));
await waitFor(() => expect(saveFile).toHaveBeenCalledTimes(1));
const [filename, content, options] = saveFile.mock.calls[0]!;
expect(filename).toBe('image.png');
expect(content).toBeInstanceOf(ArrayBuffer);
expect(new Uint8Array(content as ArrayBuffer)).toEqual(new Uint8Array([0, 1, 2]));
expect(options).toMatchObject({ share: true, mimeType: 'image/png' });
expect(options.sharePosition).toMatchObject({ preferredEdge: 'bottom' });
// Export path confirms with a toast.
await waitFor(() =>
expect(h.dispatch).toHaveBeenCalledWith('toast', expect.objectContaining({ type: 'info' })),
);
});
it('shares via saveFile without a toast on a share-capable platform', async () => {
const saveFile = vi.fn().mockResolvedValue(true);
h.appService = { isMobileApp: true, isMacOSApp: false, saveFile };
const { getByLabelText } = render(
<ImageViewer src={PNG_DATA_URL} onClose={vi.fn()} gridInsets={gridInsets} />,
);
// Share-capable → the affordance is the "Share Image" button.
fireEvent.click(getByLabelText('Share Image'));
await waitFor(() => expect(saveFile).toHaveBeenCalledTimes(1));
expect(saveFile.mock.calls[0]![2]).toMatchObject({ share: true });
// The OS share sheet is its own feedback; no toast on the share path.
expect(h.dispatch).not.toHaveBeenCalled();
});
it('saves to the photo gallery on Android instead of sharing', async () => {
const saveImageToGallery = vi.fn().mockResolvedValue(true);
const saveFile = vi.fn().mockResolvedValue(true);
h.appService = {
isMobileApp: true,
isMacOSApp: false,
isAndroidApp: true,
saveFile,
saveImageToGallery,
};
const { getByLabelText } = render(
<ImageViewer src={PNG_DATA_URL} onClose={vi.fn()} gridInsets={gridInsets} />,
);
// Android saves to the gallery, so the affordance is "Save Image", not Share.
fireEvent.click(getByLabelText('Save Image'));
await waitFor(() => expect(saveImageToGallery).toHaveBeenCalledTimes(1));
const [filename, content, mime] = saveImageToGallery.mock.calls[0]!;
expect(filename).toBe('image.png');
expect(content).toBeInstanceOf(ArrayBuffer);
expect(mime).toBe('image/png');
// It must NOT fall through to the share sheet on Android.
expect(saveFile).not.toHaveBeenCalled();
await waitFor(() =>
expect(h.dispatch).toHaveBeenCalledWith('toast', expect.objectContaining({ type: 'info' })),
);
});
});
@@ -102,6 +102,10 @@ class TestAppService extends BaseAppService {
return true;
}
async saveImageToGallery(): Promise<boolean> {
return true;
}
async ask(): Promise<boolean> {
return true;
}
@@ -69,6 +69,9 @@ class TestAppService extends BaseAppService {
async saveFile() {
return false;
}
async saveImageToGallery() {
return false;
}
async ask() {
return false;
}
@@ -3,6 +3,7 @@ import { describe, test, expect, vi, beforeEach } from 'vitest';
const osTypeMock = vi.fn().mockReturnValue('macos');
const writeTextFileMock = vi.fn().mockResolvedValue(undefined);
const writeFileMock = vi.fn().mockResolvedValue(undefined);
const mkdirMock = vi.fn().mockResolvedValue(undefined);
const saveDialogMock = vi.fn().mockResolvedValue('/tmp/exported.md');
const shareFileMock = vi.fn().mockResolvedValue(undefined);
@@ -12,7 +13,7 @@ vi.mock('@tauri-apps/plugin-os', () => ({
vi.mock('@tauri-apps/plugin-fs', () => ({
exists: vi.fn().mockResolvedValue(false),
mkdir: vi.fn().mockResolvedValue(undefined),
mkdir: (...args: unknown[]) => mkdirMock(...args),
readTextFile: vi.fn().mockResolvedValue(''),
readFile: vi.fn().mockResolvedValue(new Uint8Array()),
writeTextFile: (...args: unknown[]) => writeTextFileMock(...args),
@@ -80,6 +81,7 @@ describe('NativeAppService.saveFile share gating', () => {
beforeEach(() => {
writeTextFileMock.mockClear();
writeFileMock.mockClear();
mkdirMock.mockClear();
saveDialogMock.mockClear();
shareFileMock.mockClear();
});
@@ -108,6 +110,27 @@ describe('NativeAppService.saveFile share gating', () => {
expect(saveDialogMock).toHaveBeenCalledTimes(1);
});
// Regression (#4680): Tauri's Temp dir IS the Android cache dir, and the
// sharekit plugin copies the shared file to `<cacheDir>/<name>` before
// sharing. Writing the shareable file to the Temp ROOT makes that copy a
// self-copy whose output stream truncates the source to 0 bytes (the shared
// image came out 0 KB). The shareable file must live in a Temp SUBDIRECTORY.
test('writes the shareable file to a Temp subdirectory to avoid self-copy truncation', async () => {
const service = await loadServiceWithOS('android');
const bytes = new Uint8Array([1, 2, 3]).buffer;
await service.saveFile('image.png', bytes, { share: true, mimeType: 'image/png' });
expect(shareFileMock).toHaveBeenCalledTimes(1);
const sharedPath = shareFileMock.mock.calls[0]![0] as string;
// Must NOT be `<tempDir>/image.png` — that collides with the plugin's
// `File(cacheDir, "image.png")` destination and truncates to 0 bytes.
expect(sharedPath).not.toBe('/tmp/image.png');
expect(sharedPath).toContain('/shared/');
expect(writeFileMock).toHaveBeenCalledWith(sharedPath, expect.any(Uint8Array));
// The subdirectory is created before writing.
expect(mkdirMock).toHaveBeenCalled();
});
// The book "Send" flow hands an already-on-disk file straight to the share
// sheet via `filePath` and passes `null` content so nothing gets re-buffered
// into memory. The file at `filePath` must be shared verbatim without any
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { dataUrlToBytes, imageExtensionFromMime } from '@/utils/image';
describe('dataUrlToBytes', () => {
it('decodes a base64 data URL into bytes and its MIME type', () => {
// base64 "AAEC" -> bytes [0, 1, 2]
const { bytes, mimeType } = dataUrlToBytes('data:image/png;base64,AAEC');
expect(mimeType).toBe('image/png');
expect(Array.from(bytes)).toEqual([0, 1, 2]);
});
it('decodes a non-base64 (percent-encoded) data URL', () => {
const { bytes, mimeType } = dataUrlToBytes('data:image/svg+xml,%3Csvg%2F%3E');
expect(mimeType).toBe('image/svg+xml');
expect(new TextDecoder().decode(bytes)).toBe('<svg/>');
});
it('throws on a value that is not a data URL', () => {
expect(() => dataUrlToBytes('blob:whatever')).toThrow();
});
});
describe('imageExtensionFromMime', () => {
it('maps common image MIME types to file extensions', () => {
expect(imageExtensionFromMime('image/png')).toBe('png');
expect(imageExtensionFromMime('image/jpeg')).toBe('jpg');
expect(imageExtensionFromMime('image/webp')).toBe('webp');
expect(imageExtensionFromMime('image/gif')).toBe('gif');
});
it('strips structured-syntax suffixes (svg+xml -> svg)', () => {
expect(imageExtensionFromMime('image/svg+xml')).toBe('svg');
});
});
@@ -3,7 +3,11 @@ import React, { useState, useRef, useEffect } from 'react';
import { IoChevronBack, IoChevronForward } from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { useEnv } from '@/context/EnvContext';
import { Insets } from '@/types/misc';
import { eventDispatcher } from '@/utils/event';
import { canShareText } from '@/utils/share';
import { dataUrlToBytes, imageExtensionFromMime } from '@/utils/image';
import ZoomControls from './ZoomControls';
interface ImageViewerProps {
@@ -27,6 +31,12 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
gridInsets,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
// On Android the button saves straight to the photo gallery (the share sheet
// can't save to a file there); elsewhere it shares where supported, else
// exports. The affordance reflects the actual action.
const saveToGallery = appService?.isAndroidApp ?? false;
const canShare = !saveToGallery && canShareText(appService);
const [scale, setScale] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
@@ -325,6 +335,55 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
hideZoomLabelAfterDelay();
};
// Save the currently viewed image to the device. `appService.saveFile`
// routes to the native/web Share sheet where available and falls back to a
// save dialog / browser download otherwise.
const handleSaveImage = async (e: React.MouseEvent<HTMLButtonElement>) => {
if (!src || !appService) return;
// Anchor the macOS / iPad share popover to the button rect (mirrors the
// annotation export flow); ignored on platforms that don't use it.
const rect = e.currentTarget.getBoundingClientRect();
const sharePosition = {
x: rect.left + rect.width / 2,
y: rect.top,
preferredEdge: 'bottom' as const,
};
try {
const { bytes, mimeType } = dataUrlToBytes(decodeURIComponent(src));
const filename = `image.${imageExtensionFromMime(mimeType)}`;
if (saveToGallery) {
const saved = await appService.saveImageToGallery(
filename,
bytes.buffer as ArrayBuffer,
mimeType,
);
eventDispatcher.dispatch('toast', {
type: saved ? 'info' : 'error',
message: saved ? _('Image saved to gallery') : _('Failed to save the image'),
});
return;
}
const saved = await appService.saveFile(filename, bytes.buffer as ArrayBuffer, {
share: true,
mimeType,
sharePosition,
});
// The Share sheet provides its own feedback; only confirm the export path.
if (!canShare) {
eventDispatcher.dispatch('toast', {
type: saved ? 'info' : 'error',
message: saved ? _('Image saved successfully') : _('Failed to save the image'),
});
}
} catch (error) {
console.error('Failed to save image:', error);
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to save the image'),
});
}
};
const onDoubleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -391,7 +450,7 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
<div
role='button'
tabIndex={0}
className='not-eink:bg-black/50 eink:bg-base-100 not-eink:backdrop-blur-md absolute inset-0'
className='image-viewer-overlay not-eink:bg-black/50 eink:bg-base-100 not-eink:backdrop-blur-md absolute inset-0'
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onClose();
@@ -400,7 +459,9 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
/>
<ZoomControls
gridInsets={gridInsets}
canShare={canShare}
onClose={onClose}
onSave={handleSaveImage}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onReset={handleReset}
@@ -183,7 +183,7 @@ const TableViewer: React.FC<TableViewerProps> = ({ gridInsets, html, isDarkMode,
<div
role='button'
tabIndex={0}
className='absolute inset-0 bg-black/50 backdrop-blur-md'
className='table-viewer-overlay absolute inset-0 not-eink:bg-black/50 eink:bg-base-100 not-eink:backdrop-blur-md'
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onClose();
@@ -1,12 +1,22 @@
import React from 'react';
import { IoClose, IoExpand, IoAdd, IoRemove } from 'react-icons/io5';
import {
IoClose,
IoExpand,
IoAdd,
IoRemove,
IoShareOutline,
IoDownloadOutline,
} from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
import { useThemeStore } from '@/store/themeStore';
import { Insets } from '@/types/misc';
interface ZoomControlsProps {
gridInsets: Insets;
// Save/Share is image-specific; omit `onSave` (e.g. the table viewer) to hide it.
canShare?: boolean;
onClose: () => void;
onSave?: (e: React.MouseEvent<HTMLButtonElement>) => void;
onZoomIn: () => void;
onZoomOut: () => void;
onReset: () => void;
@@ -14,7 +24,9 @@ interface ZoomControlsProps {
const ZoomControls: React.FC<ZoomControlsProps> = ({
gridInsets,
canShare,
onClose,
onSave,
onZoomIn,
onZoomOut,
onReset,
@@ -39,6 +51,21 @@ const ZoomControls: React.FC<ZoomControlsProps> = ({
<IoClose className='h-6 w-6' />
</button>
{onSave && (
<button
onClick={onSave}
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
aria-label={canShare ? _('Share Image') : _('Save Image')}
title={canShare ? _('Share Image') : _('Save Image')}
>
{canShare ? (
<IoShareOutline className='h-6 w-6' />
) : (
<IoDownloadOutline className='h-6 w-6' />
)}
</button>
)}
<button
onClick={onZoomIn}
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
@@ -86,6 +86,11 @@ export abstract class BaseAppService implements AppService {
sharePosition?: { x: number; y: number; preferredEdge?: 'top' | 'bottom' | 'left' | 'right' };
},
): Promise<boolean>;
abstract saveImageToGallery(
filename: string,
content: ArrayBuffer,
mimeType: string,
): Promise<boolean>;
abstract ask(message: string): Promise<boolean>;
abstract openDatabase(
schema: SchemaType,
@@ -38,7 +38,7 @@ import {
import { getOSPlatform, isContentURI, isFileURI, isValidURL } from '@/utils/misc';
import { getDirPath, getFilename } from '@/utils/path';
import { NativeFile, RemoteFile } from '@/utils/file';
import { copyURIToPath, getStorefrontRegionCode } from '@/utils/bridge';
import { copyURIToPath, getStorefrontRegionCode, saveImageToGallery } from '@/utils/bridge';
import { copyFiles } from '@/utils/files';
import { BaseAppService } from './appService';
@@ -756,7 +756,15 @@ export class NativeAppService extends BaseAppService {
if (wantShare) {
let shareablePath = options?.filePath;
if (!shareablePath) {
shareablePath = await this.resolveFilePath(filename, 'Temp');
// Write into a Temp SUBDIRECTORY, never the Temp root. On Android the
// sharekit plugin copies the shared file to `<cacheDir>/<name>` before
// sharing, and Tauri's Temp dir IS `<cacheDir>` — writing to the root
// makes that a copy onto itself, whose output stream truncates the
// source to 0 bytes (the shared image came out 0 KB). A subdirectory
// gives the plugin's copy a distinct source path. (#4680)
const shareDir = await this.resolveFilePath('shared', 'Temp');
await mkdir(shareDir, { recursive: true });
shareablePath = await this.resolveFilePath(`shared/${filename}`, 'Temp');
if (typeof content === 'string') {
await writeTextFile(shareablePath, content);
} else if (content) {
@@ -802,6 +810,36 @@ export class NativeAppService extends BaseAppService {
}
}
async saveImageToGallery(
filename: string,
content: ArrayBuffer,
mimeType: string,
): Promise<boolean> {
// MediaStore is Android-only; other platforms keep the saveFile/share path.
if (!this.isAndroidApp) return false;
// Write the bytes to a Temp subdirectory (not the Temp root, mirroring the
// share path), then hand the path to the native MediaStore insert.
const shareDir = await this.resolveFilePath('shared', 'Temp');
await mkdir(shareDir, { recursive: true });
const srcPath = await this.resolveFilePath(`shared/${filename}`, 'Temp');
try {
await writeFile(srcPath, new Uint8Array(content));
const res = await saveImageToGallery({
srcPath,
fileName: filename,
mimeType,
albumName: 'Readest',
});
return res.success;
} catch (error) {
console.error('Failed to save image to gallery:', error);
return false;
} finally {
// Best-effort cleanup of the staged file.
await remove(srcPath).catch(() => {});
}
}
async ask(message: string): Promise<boolean> {
return await ask(message);
}
@@ -409,6 +409,11 @@ export class NodeAppService extends BaseAppService {
}
}
// No system photo gallery on Node; callers fall back to the saveFile flow.
async saveImageToGallery(): Promise<boolean> {
return false;
}
async ask(): Promise<boolean> {
return false;
}
@@ -396,6 +396,11 @@ export class WebAppService extends BaseAppService {
}
}
// No system photo gallery on the web; callers fall back to the saveFile flow.
async saveImageToGallery(): Promise<boolean> {
return false;
}
async ask(message: string): Promise<boolean> {
return window.confirm(message);
}
+3
View File
@@ -155,6 +155,9 @@ export interface AppService {
sharePosition?: { x: number; y: number; preferredEdge?: 'top' | 'bottom' | 'left' | 'right' };
},
): Promise<boolean>;
// Save an image into the system photo gallery (Android MediaStore). Returns
// false on platforms without a gallery (web/desktop) or on failure.
saveImageToGallery(filename: string, content: ArrayBuffer, mimeType: string): Promise<boolean>;
getDefaultViewSettings(): ViewSettings;
loadSettings(): Promise<SystemSettings>;
+21
View File
@@ -10,6 +10,19 @@ export interface CopyURIResponse {
error?: string;
}
export interface SaveImageToGalleryRequest {
srcPath: string;
fileName: string;
mimeType: string;
albumName?: string;
}
export interface SaveImageToGalleryResponse {
success: boolean;
uri?: string;
error?: string;
}
export interface UseBackgroundAudioRequest {
enabled: boolean;
}
@@ -108,6 +121,14 @@ export async function copyURIToPath(request: CopyURIRequest): Promise<CopyURIRes
return result;
}
export async function saveImageToGallery(
request: SaveImageToGalleryRequest,
): Promise<SaveImageToGalleryResponse> {
return await invoke<SaveImageToGalleryResponse>('plugin:native-bridge|save_image_to_gallery', {
payload: request,
});
}
export async function invokeUseBackgroundAudio(request: UseBackgroundAudioRequest): Promise<void> {
await invoke('plugin:native-bridge|use_background_audio', {
payload: request,
+37
View File
@@ -1,3 +1,40 @@
export interface ParsedDataUrl {
bytes: Uint8Array;
mimeType: string;
}
/**
* Decode a `data:` URL into raw bytes plus its MIME type. Used to turn the
* in-memory image shown in the gallery viewer back into a file for the
* share / export flow.
*/
export function dataUrlToBytes(dataUrl: string): ParsedDataUrl {
const match = /^data:([^;,]*)(;base64)?,(.*)$/s.exec(dataUrl);
if (!match) {
throw new Error('Not a data URL');
}
const mimeType = match[1] || 'application/octet-stream';
const isBase64 = !!match[2];
const data = match[3] ?? '';
if (isBase64) {
const binary = atob(data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return { bytes, mimeType };
}
// Non-base64 data URLs hold percent-encoded text (e.g. inline SVG).
return { bytes: new TextEncoder().encode(decodeURIComponent(data)), mimeType };
}
/** Derive a file extension from an image MIME type (e.g. image/svg+xml -> svg). */
export function imageExtensionFromMime(mimeType: string): string {
const subtype = (mimeType.split('/')[1] || 'png').toLowerCase();
const base = subtype.split('+')[0]!;
return base === 'jpeg' ? 'jpg' : base;
}
/**
* Process book cover for Discord Rich Presence:
* - Fit to 512x512 with transparent background