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:
@@ -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=required∪optional, `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 so‘zlar ustida ona tilingizda qisqa maslahat ko‘rsatadi. Darajangizdan yuqori so‘zlarga 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": "圖片已儲存至相簿"
|
||||
}
|
||||
|
||||
+81
@@ -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",
|
||||
|
||||
+13
@@ -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"]
|
||||
+27
@@ -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",
|
||||
|
||||
+14
-2
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user