From 7185dca1a26661947c46e5f7b12d98eaceb0bf8e Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 20 Jun 2026 12:28:08 +0800 Subject: [PATCH] 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) * 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 / 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- apps/readest-app/.claude/memory/MEMORY.md | 2 + .../cover-bg-image-texture-suppression.md | 47 +++++++ .../memory/save-image-to-gallery-android.md | 23 ++++ .../sync-statusless-book-rebump-4677.md | 20 +++ .../public/locales/ar/translation.json | 7 +- .../public/locales/bn/translation.json | 7 +- .../public/locales/bo/translation.json | 7 +- .../public/locales/de/translation.json | 7 +- .../public/locales/el/translation.json | 7 +- .../public/locales/es/translation.json | 7 +- .../public/locales/fa/translation.json | 7 +- .../public/locales/fr/translation.json | 7 +- .../public/locales/he/translation.json | 7 +- .../public/locales/hi/translation.json | 7 +- .../public/locales/hu/translation.json | 7 +- .../public/locales/id/translation.json | 7 +- .../public/locales/it/translation.json | 7 +- .../public/locales/ja/translation.json | 7 +- .../public/locales/ko/translation.json | 7 +- .../public/locales/ms/translation.json | 7 +- .../public/locales/nl/translation.json | 7 +- .../public/locales/pl/translation.json | 7 +- .../public/locales/pt-BR/translation.json | 7 +- .../public/locales/pt/translation.json | 7 +- .../public/locales/ro/translation.json | 7 +- .../public/locales/ru/translation.json | 7 +- .../public/locales/si/translation.json | 7 +- .../public/locales/sl/translation.json | 7 +- .../public/locales/sv/translation.json | 7 +- .../public/locales/ta/translation.json | 7 +- .../public/locales/th/translation.json | 7 +- .../public/locales/tr/translation.json | 7 +- .../public/locales/uk/translation.json | 7 +- .../public/locales/uz/translation.json | 7 +- .../public/locales/vi/translation.json | 7 +- .../public/locales/zh-CN/translation.json | 7 +- .../public/locales/zh-TW/translation.json | 7 +- .../src/main/java/NativeBridgePlugin.kt | 81 ++++++++++++ .../tauri-plugin-native-bridge/build.rs | 1 + .../commands/save_image_to_gallery.toml | 13 ++ .../permissions/autogenerated/reference.md | 27 ++++ .../permissions/default.toml | 1 + .../permissions/schemas/schema.json | 16 ++- .../src/commands.rs | 8 ++ .../tauri-plugin-native-bridge/src/desktop.rs | 7 + .../tauri-plugin-native-bridge/src/lib.rs | 1 + .../tauri-plugin-native-bridge/src/mobile.rs | 11 ++ .../tauri-plugin-native-bridge/src/models.rs | 21 +++ .../__tests__/components/ImageViewer.test.tsx | 5 + .../components/ImageViewerSave.test.tsx | 120 ++++++++++++++++++ .../__tests__/services/app-service.test.ts | 4 + .../services/import-metahash.test.ts | 3 + .../services/native-app-service-share.test.ts | 25 +++- .../__tests__/utils/image-data-url.test.ts | 35 +++++ .../src/app/reader/components/ImageViewer.tsx | 63 ++++++++- .../src/app/reader/components/TableViewer.tsx | 2 +- .../app/reader/components/ZoomControls.tsx | 29 ++++- apps/readest-app/src/services/appService.ts | 5 + .../src/services/nativeAppService.ts | 42 +++++- .../src/services/nodeAppService.ts | 5 + .../readest-app/src/services/webAppService.ts | 5 + apps/readest-app/src/types/system.ts | 3 + apps/readest-app/src/utils/bridge.ts | 21 +++ apps/readest-app/src/utils/image.ts | 37 ++++++ 64 files changed, 873 insertions(+), 41 deletions(-) create mode 100644 apps/readest-app/.claude/memory/cover-bg-image-texture-suppression.md create mode 100644 apps/readest-app/.claude/memory/save-image-to-gallery-android.md create mode 100644 apps/readest-app/.claude/memory/sync-statusless-book-rebump-4677.md create mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/save_image_to_gallery.toml create mode 100644 apps/readest-app/src/__tests__/components/ImageViewerSave.test.tsx create mode 100644 apps/readest-app/src/__tests__/utils/image-data-url.test.ts diff --git a/apps/readest-app/.claude/memory/MEMORY.md b/apps/readest-app/.claude/memory/MEMORY.md index 6fef89de..853a79ae 100644 --- a/apps/readest-app/.claude/memory/MEMORY.md +++ b/apps/readest-app/.claude/memory/MEMORY.md @@ -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` diff --git a/apps/readest-app/.claude/memory/cover-bg-image-texture-suppression.md b/apps/readest-app/.claude/memory/cover-bg-image-texture-suppression.md new file mode 100644 index 00000000..aa9682f7 --- /dev/null +++ b/apps/readest-app/.claude/memory/cover-bg-image-texture-suppression.md @@ -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 `` CSS `background-image` +(EPUB sets `background-color` transparent + `background-size:100% 100%`, no +`` — 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_`), `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. diff --git a/apps/readest-app/.claude/memory/save-image-to-gallery-android.md b/apps/readest-app/.claude/memory/save-image-to-gallery-android.md new file mode 100644 index 00000000..47f226c4 --- /dev/null +++ b/apps/readest-app/.claude/memory/save-image-to-gallery-android.md @@ -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/` 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//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]]. diff --git a/apps/readest-app/.claude/memory/sync-statusless-book-rebump-4677.md b/apps/readest-app/.claude/memory/sync-statusless-book-rebump-4677.md new file mode 100644 index 00000000..2dfdb471 --- /dev/null +++ b/apps/readest-app/.claude/memory/sync-statusless-book-rebump-4677.md @@ -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_` socket. Discover socket from `/proc/net/unix`, `adb forward tcp:PORT localabstract:`, 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 })` 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. diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 9d209d05..3712c4b1 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -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": "تم حفظ الصورة في المعرض" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index 8ce3fe28..a6b67650 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -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": "ছবি গ্যালারিতে সংরক্ষিত হয়েছে" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index 53a5d584..6fb44f00 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -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": "པར་རིས་པར་མཛོད་ནང་ཉར་ཚགས་བྱས་ཟིན" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 7d9b4091..fe8dec53 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index d7b94874..30f856c3 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -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": "Η εικόνα αποθηκεύτηκε στη συλλογή" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 83a8254e..123a0196 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index 44af1480..8e4b44c2 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -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": "تصویر در گالری ذخیره شد" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index eb68e4e5..3a42c945 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index f2beae32..d84068f5 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -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": "התמונה נשמרה בגלריה" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index d5b534cd..dfd0e021 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -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": "छवि गैलरी में सहेजी गई" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index e1b4bcac..af143ab9 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 36ac3b1c..e661d844 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index e2163e4c..733e57dc 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index 5bd5bb2f..cc5e377b 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -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": "画像をギャラリーに保存しました" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 764d88db..8a07f751 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -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": "이미지를 갤러리에 저장했습니다" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index 3e804970..7322cba4 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 8adcc9a0..41d70510 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index a7ffa6bb..6f415a81 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index 7bbc903b..c2e0099a 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index c3a675aa..af410932 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index 85614f50..4267eccd 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 68392e12..4c575fe8 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -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": "Изображение сохранено в галерее" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 01a73d55..568cd4e5 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -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": "රූපය ගැලරියට සුරැකිණි" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index 8b480b1c..63ad38bf 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index 267d1522..a42bb557 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index da2a70ff..a2fa6bad 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -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": "படம் கேலரியில் சேமிக்கப்பட்டது" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 82df7318..2a4c47a0 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -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": "บันทึกรูปภาพไปยังแกลเลอรีแล้ว" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index e0429b07..24ed739a 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 6eeda7af..a5593bef 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -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": "Зображення збережено в галереї" } diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index 4d79a954..51aafffe 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 374780da..692a435b 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index ca64090c..6d750507 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -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": "图片已保存到相册" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index b41a2d31..8cbbf1e3 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -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": "圖片已儲存至相簿" } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt index d591ed9f..7370c2c2 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt @@ -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) diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs index e171378e..eaabfb9a 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs @@ -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", diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/save_image_to_gallery.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/save_image_to_gallery.toml new file mode 100644 index 00000000..19fd7e88 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/save_image_to_gallery.toml @@ -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"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md index f2d46d30..f0b9546a 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md @@ -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. +`native-bridge:allow-save-image-to-gallery` + + + + +Enables the save_image_to_gallery command without any pre-configured scope. + + + + + + + +`native-bridge:deny-save-image-to-gallery` + + + + +Denies the save_image_to_gallery command without any pre-configured scope. + + + + + + + `native-bridge:allow-select-directory` diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml index 6854b50d..7bcb6ff3 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml @@ -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", diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json index a4182537..766396a8 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json @@ -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`" } ] } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs index 4bcc799f..694fad17 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs @@ -30,6 +30,14 @@ pub(crate) async fn copy_uri_to_path( app.native_bridge().copy_uri_to_path(payload) } +#[command] +pub(crate) async fn save_image_to_gallery( + app: AppHandle, + payload: SaveImageToGalleryRequest, +) -> Result { + app.native_bridge().save_image_to_gallery(payload) +} + #[command] pub(crate) async fn use_background_audio( app: AppHandle, diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs index cd888d52..73179788 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs @@ -60,6 +60,13 @@ impl NativeBridge { Err(crate::Error::UnsupportedPlatformError) } + pub fn save_image_to_gallery( + &self, + _payload: SaveImageToGalleryRequest, + ) -> crate::Result { + Err(crate::Error::UnsupportedPlatformError) + } + pub fn use_background_audio(&self, _payload: UseBackgroundAudioRequest) -> crate::Result<()> { Err(crate::Error::UnsupportedPlatformError) } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs index 3c02deb8..368c0055 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs @@ -58,6 +58,7 @@ pub fn init() -> TauriPlugin { 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, diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs index 21c85aa4..d70f1ba3 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs @@ -48,6 +48,17 @@ impl NativeBridge { } } +impl NativeBridge { + pub fn save_image_to_gallery( + &self, + payload: SaveImageToGalleryRequest, + ) -> crate::Result { + self.0 + .run_mobile_plugin("save_image_to_gallery", payload) + .map_err(Into::into) + } +} + impl NativeBridge { pub fn use_background_audio(&self, payload: UseBackgroundAudioRequest) -> crate::Result<()> { self.0 diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs index ed010fc0..50a0a19a 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs @@ -27,6 +27,27 @@ pub struct CopyURIResponse { pub error: Option, } +#[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, +} + +#[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, + pub error: Option, +} + #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct UseBackgroundAudioRequest { diff --git a/apps/readest-app/src/__tests__/components/ImageViewer.test.tsx b/apps/readest-app/src/__tests__/components/ImageViewer.test.tsx index 903b13db..aa80bcb6 100644 --- a/apps/readest-app/src/__tests__/components/ImageViewer.test.tsx +++ b/apps/readest-app/src/__tests__/components/ImageViewer.test.tsx @@ -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, diff --git a/apps/readest-app/src/__tests__/components/ImageViewerSave.test.tsx b/apps/readest-app/src/__tests__/components/ImageViewerSave.test.tsx new file mode 100644 index 00000000..cd805591 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/ImageViewerSave.test.tsx @@ -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; + saveImageToGallery?: ReturnType; + } | 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + // 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' })), + ); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/app-service.test.ts b/apps/readest-app/src/__tests__/services/app-service.test.ts index b06a3100..ae8c799d 100644 --- a/apps/readest-app/src/__tests__/services/app-service.test.ts +++ b/apps/readest-app/src/__tests__/services/app-service.test.ts @@ -102,6 +102,10 @@ class TestAppService extends BaseAppService { return true; } + async saveImageToGallery(): Promise { + return true; + } + async ask(): Promise { return true; } diff --git a/apps/readest-app/src/__tests__/services/import-metahash.test.ts b/apps/readest-app/src/__tests__/services/import-metahash.test.ts index 8ce1f155..673567a5 100644 --- a/apps/readest-app/src/__tests__/services/import-metahash.test.ts +++ b/apps/readest-app/src/__tests__/services/import-metahash.test.ts @@ -69,6 +69,9 @@ class TestAppService extends BaseAppService { async saveFile() { return false; } + async saveImageToGallery() { + return false; + } async ask() { return false; } diff --git a/apps/readest-app/src/__tests__/services/native-app-service-share.test.ts b/apps/readest-app/src/__tests__/services/native-app-service-share.test.ts index 0d503c2f..0b48df5d 100644 --- a/apps/readest-app/src/__tests__/services/native-app-service-share.test.ts +++ b/apps/readest-app/src/__tests__/services/native-app-service-share.test.ts @@ -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 `/` 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 `/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 diff --git a/apps/readest-app/src/__tests__/utils/image-data-url.test.ts b/apps/readest-app/src/__tests__/utils/image-data-url.test.ts new file mode 100644 index 00000000..6fe04dda --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/image-data-url.test.ts @@ -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(''); + }); + + 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'); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/ImageViewer.tsx b/apps/readest-app/src/app/reader/components/ImageViewer.tsx index b2fdbe0c..149c19ff 100644 --- a/apps/readest-app/src/app/reader/components/ImageViewer.tsx +++ b/apps/readest-app/src/app/reader/components/ImageViewer.tsx @@ -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 = ({ 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 = ({ 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) => { + 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 = ({
{ if (e.key === 'Enter' || e.key === ' ') { onClose(); @@ -400,7 +459,9 @@ const ImageViewer: React.FC = ({ /> = ({ gridInsets, html, isDarkMode,
{ if (e.key === 'Enter' || e.key === ' ') { onClose(); diff --git a/apps/readest-app/src/app/reader/components/ZoomControls.tsx b/apps/readest-app/src/app/reader/components/ZoomControls.tsx index a71e3bdb..2a48ebd0 100644 --- a/apps/readest-app/src/app/reader/components/ZoomControls.tsx +++ b/apps/readest-app/src/app/reader/components/ZoomControls.tsx @@ -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) => void; onZoomIn: () => void; onZoomOut: () => void; onReset: () => void; @@ -14,7 +24,9 @@ interface ZoomControlsProps { const ZoomControls: React.FC = ({ gridInsets, + canShare, onClose, + onSave, onZoomIn, onZoomOut, onReset, @@ -39,6 +51,21 @@ const ZoomControls: React.FC = ({ + {onSave && ( + + )} +