From 324bb8a366d246d30dd0d1e5d96243c62110a966 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 27 Jun 2026 17:18:11 +0800 Subject: [PATCH] feat(reader): add e-ink screen refresh page-turner action (#4687) (#4822) Add a bindable "Refresh Page" action to Settings > Behavior > Page Turner that triggers a deep e-ink full refresh (GC16) to clear screen ghosting, gated to e-ink mode on Android. It reuses the existing hardware page-turner key-binding machinery: a new 'refresh' slot in HardwarePageTurnerSettings, shown only when isAndroidApp and the e-ink view setting is on. Pressing the bound key calls a new native bridge command instead of paginating. The native side is device-agnostic: EinkRefreshController probes each vendor mechanism via reflection and stops at the first that works, covering Onyx BOOX (Qualcomm View.refreshScreen), Tolino/Nook (NTX postInvalidateDelayed) and Boyue-style Rockchip (requestEpdMode) without bundling any vendor SDK. A success:false result is a soft no-op on non-e-ink hardware. iOS gets a stub. Verified on an Onyx BOOX Leaf5: the Onyx path fires and performs a visible full GC16 refresh. Co-authored-by: Claude Opus 4.8 (1M context) --- .../public/locales/ar/translation.json | 3 +- .../public/locales/bn/translation.json | 3 +- .../public/locales/bo/translation.json | 3 +- .../public/locales/de/translation.json | 3 +- .../public/locales/el/translation.json | 3 +- .../public/locales/es/translation.json | 3 +- .../public/locales/fa/translation.json | 3 +- .../public/locales/fr/translation.json | 3 +- .../public/locales/he/translation.json | 3 +- .../public/locales/hi/translation.json | 3 +- .../public/locales/hu/translation.json | 3 +- .../public/locales/id/translation.json | 3 +- .../public/locales/it/translation.json | 3 +- .../public/locales/ja/translation.json | 3 +- .../public/locales/ko/translation.json | 3 +- .../public/locales/ms/translation.json | 3 +- .../public/locales/nl/translation.json | 3 +- .../public/locales/pl/translation.json | 3 +- .../public/locales/pt-BR/translation.json | 3 +- .../public/locales/pt/translation.json | 3 +- .../public/locales/ro/translation.json | 3 +- .../public/locales/ru/translation.json | 3 +- .../public/locales/si/translation.json | 3 +- .../public/locales/sl/translation.json | 3 +- .../public/locales/sv/translation.json | 3 +- .../public/locales/ta/translation.json | 3 +- .../public/locales/th/translation.json | 3 +- .../public/locales/tr/translation.json | 3 +- .../public/locales/uk/translation.json | 3 +- .../public/locales/uz/translation.json | 3 +- .../public/locales/vi/translation.json | 3 +- .../public/locales/zh-CN/translation.json | 3 +- .../public/locales/zh-TW/translation.json | 3 +- .../src/main/java/EinkRefreshController.kt | 102 ++++++++++++++ .../src/main/java/NativeBridgePlugin.kt | 24 ++++ .../tauri-plugin-native-bridge/build.rs | 1 + .../ios/Sources/NativeBridgePlugin.swift | 7 + .../commands/refresh_eink_screen.toml | 13 ++ .../permissions/autogenerated/reference.md | 27 ++++ .../permissions/default.toml | 1 + .../permissions/schemas/schema.json | 16 ++- .../src/commands.rs | 7 + .../tauri-plugin-native-bridge/src/desktop.rs | 6 + .../tauri-plugin-native-bridge/src/lib.rs | 1 + .../tauri-plugin-native-bridge/src/mobile.rs | 8 ++ .../tauri-plugin-native-bridge/src/models.rs | 10 ++ .../hooks/usePagination-refreshEink.test.ts | 126 ++++++++++++++++++ .../src/__tests__/services/constants.test.ts | 1 + .../src/__tests__/utils/hardwareKeys.test.ts | 7 +- .../src/app/reader/hooks/usePagination.ts | 14 +- .../settings/PageTurnerSettings.tsx | 5 + apps/readest-app/src/services/constants.ts | 8 +- apps/readest-app/src/types/settings.ts | 2 + apps/readest-app/src/utils/bridge.ts | 15 +++ apps/readest-app/src/utils/keybinding.ts | 11 +- 55 files changed, 470 insertions(+), 41 deletions(-) create mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/EinkRefreshController.kt create mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/refresh_eink_screen.toml create mode 100644 apps/readest-app/src/__tests__/app/reader/hooks/usePagination-refreshEink.test.ts diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index 034af541..c47d59c8 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -1938,5 +1938,6 @@ "Styles": "الأنماط", "Word": "كلمة", "Sentence": "جملة", - "Granularity": "الدقة" + "Granularity": "الدقة", + "Refresh Page": "تحديث الصفحة" } diff --git a/apps/readest-app/public/locales/bn/translation.json b/apps/readest-app/public/locales/bn/translation.json index 9581a205..aea2dd8b 100644 --- a/apps/readest-app/public/locales/bn/translation.json +++ b/apps/readest-app/public/locales/bn/translation.json @@ -1806,5 +1806,6 @@ "Styles": "শৈলী", "Word": "শব্দ", "Sentence": "বাক্য", - "Granularity": "সূক্ষ্মতা" + "Granularity": "সূক্ষ্মতা", + "Refresh Page": "পৃষ্ঠা রিফ্রেশ করুন" } diff --git a/apps/readest-app/public/locales/bo/translation.json b/apps/readest-app/public/locales/bo/translation.json index b41b94c8..2edbb2b7 100644 --- a/apps/readest-app/public/locales/bo/translation.json +++ b/apps/readest-app/public/locales/bo/translation.json @@ -1767,5 +1767,6 @@ "Styles": "རྣམ་པ", "Word": "ཚིག", "Sentence": "ཚིག་གྲུབ།", - "Granularity": "ཞིབ་ཕྲའི་ཚད" + "Granularity": "ཞིབ་ཕྲའི་ཚད", + "Refresh Page": "ཤོག་ལྷེ་བསྐྱར་གསོ།" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index 2ea4a681..78ac113f 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -1806,5 +1806,6 @@ "Styles": "Stile", "Word": "Wort", "Sentence": "Satz", - "Granularity": "Granularität" + "Granularity": "Granularität", + "Refresh Page": "Seite aktualisieren" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index b6aef4d5..4747a704 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -1806,5 +1806,6 @@ "Styles": "Στυλ", "Word": "Λέξη", "Sentence": "Πρόταση", - "Granularity": "Διαβάθμιση" + "Granularity": "Διαβάθμιση", + "Refresh Page": "Ανανέωση σελίδας" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 768a8f75..85a12e2b 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -1839,5 +1839,6 @@ "Styles": "Estilos", "Word": "Palabra", "Sentence": "Oración", - "Granularity": "Granularidad" + "Granularity": "Granularidad", + "Refresh Page": "Actualizar página" } diff --git a/apps/readest-app/public/locales/fa/translation.json b/apps/readest-app/public/locales/fa/translation.json index ff8eda60..82bee8c6 100644 --- a/apps/readest-app/public/locales/fa/translation.json +++ b/apps/readest-app/public/locales/fa/translation.json @@ -1806,5 +1806,6 @@ "Styles": "سبک‌ها", "Word": "کلمه", "Sentence": "جمله", - "Granularity": "دانه‌بندی" + "Granularity": "دانه‌بندی", + "Refresh Page": "تازه‌سازی صفحه" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index b624da40..5dee79a2 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -1839,5 +1839,6 @@ "Styles": "Styles", "Word": "Mot", "Sentence": "Phrase", - "Granularity": "Granularité" + "Granularity": "Granularité", + "Refresh Page": "Actualiser la page" } diff --git a/apps/readest-app/public/locales/he/translation.json b/apps/readest-app/public/locales/he/translation.json index fc491cc0..1414fb44 100644 --- a/apps/readest-app/public/locales/he/translation.json +++ b/apps/readest-app/public/locales/he/translation.json @@ -1839,5 +1839,6 @@ "Styles": "סגנונות", "Word": "מילה", "Sentence": "משפט", - "Granularity": "רמת פירוט" + "Granularity": "רמת פירוט", + "Refresh Page": "רענן דף" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 7e43b024..c894b73d 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -1806,5 +1806,6 @@ "Styles": "शैलियाँ", "Word": "शब्द", "Sentence": "वाक्य", - "Granularity": "सूक्ष्मता" + "Granularity": "सूक्ष्मता", + "Refresh Page": "पृष्ठ ताज़ा करें" } diff --git a/apps/readest-app/public/locales/hu/translation.json b/apps/readest-app/public/locales/hu/translation.json index c567d0c6..359c9e6e 100644 --- a/apps/readest-app/public/locales/hu/translation.json +++ b/apps/readest-app/public/locales/hu/translation.json @@ -1806,5 +1806,6 @@ "Styles": "Stílusok", "Word": "Szó", "Sentence": "Mondat", - "Granularity": "Részletesség" + "Granularity": "Részletesség", + "Refresh Page": "Oldal frissítése" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 8396f3fb..5ce08e01 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -1773,5 +1773,6 @@ "Styles": "Gaya", "Word": "Kata", "Sentence": "Kalimat", - "Granularity": "Granularitas" + "Granularity": "Granularitas", + "Refresh Page": "Segarkan Halaman" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 6d5099f9..bc3a2e02 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -1839,5 +1839,6 @@ "Styles": "Stili", "Word": "Parola", "Sentence": "Frase", - "Granularity": "Granularità" + "Granularity": "Granularità", + "Refresh Page": "Aggiorna pagina" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index bb5db48c..0717bf53 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -1773,5 +1773,6 @@ "Styles": "スタイル", "Word": "単語", "Sentence": "文", - "Granularity": "粒度" + "Granularity": "粒度", + "Refresh Page": "ページを更新" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index d94477d6..60c3bc98 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -1773,5 +1773,6 @@ "Styles": "스타일", "Word": "단어", "Sentence": "문장", - "Granularity": "단위" + "Granularity": "단위", + "Refresh Page": "페이지 새로고침" } diff --git a/apps/readest-app/public/locales/ms/translation.json b/apps/readest-app/public/locales/ms/translation.json index ffb49ce6..7c7be83c 100644 --- a/apps/readest-app/public/locales/ms/translation.json +++ b/apps/readest-app/public/locales/ms/translation.json @@ -1773,5 +1773,6 @@ "Styles": "Gaya", "Word": "Perkataan", "Sentence": "Ayat", - "Granularity": "Granulariti" + "Granularity": "Granulariti", + "Refresh Page": "Segarkan Halaman" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 78f0d1c9..c462411b 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -1806,5 +1806,6 @@ "Styles": "Stijlen", "Word": "Woord", "Sentence": "Zin", - "Granularity": "Granulariteit" + "Granularity": "Granulariteit", + "Refresh Page": "Pagina vernieuwen" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index b837a9c9..4c753631 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -1872,5 +1872,6 @@ "Styles": "Style", "Word": "Słowo", "Sentence": "Zdanie", - "Granularity": "Ziarnistość" + "Granularity": "Ziarnistość", + "Refresh Page": "Odśwież stronę" } diff --git a/apps/readest-app/public/locales/pt-BR/translation.json b/apps/readest-app/public/locales/pt-BR/translation.json index bec11fb1..da43cdda 100644 --- a/apps/readest-app/public/locales/pt-BR/translation.json +++ b/apps/readest-app/public/locales/pt-BR/translation.json @@ -1839,5 +1839,6 @@ "Styles": "Estilos", "Word": "Palavra", "Sentence": "Frase", - "Granularity": "Granularidade" + "Granularity": "Granularidade", + "Refresh Page": "Atualizar página" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index dba0da68..b856be5a 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -1839,5 +1839,6 @@ "Styles": "Estilos", "Word": "Palavra", "Sentence": "Frase", - "Granularity": "Granularidade" + "Granularity": "Granularidade", + "Refresh Page": "Atualizar Página" } diff --git a/apps/readest-app/public/locales/ro/translation.json b/apps/readest-app/public/locales/ro/translation.json index 2b4645ce..5fab8068 100644 --- a/apps/readest-app/public/locales/ro/translation.json +++ b/apps/readest-app/public/locales/ro/translation.json @@ -1839,5 +1839,6 @@ "Styles": "Stiluri", "Word": "Cuvânt", "Sentence": "Propoziție", - "Granularity": "Granularitate" + "Granularity": "Granularitate", + "Refresh Page": "Reîmprospătează pagina" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 66aa73e7..bc714d0b 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -1872,5 +1872,6 @@ "Styles": "Стили", "Word": "Слово", "Sentence": "Предложение", - "Granularity": "Детализация" + "Granularity": "Детализация", + "Refresh Page": "Обновить страницу" } diff --git a/apps/readest-app/public/locales/si/translation.json b/apps/readest-app/public/locales/si/translation.json index 0b6daca9..f79748fd 100644 --- a/apps/readest-app/public/locales/si/translation.json +++ b/apps/readest-app/public/locales/si/translation.json @@ -1806,5 +1806,6 @@ "Styles": "මෝස්තර", "Word": "වචනය", "Sentence": "වාක්‍යය", - "Granularity": "විස්තර මට්ටම" + "Granularity": "විස්තර මට්ටම", + "Refresh Page": "පිටුව නැවුම් කරන්න" } diff --git a/apps/readest-app/public/locales/sl/translation.json b/apps/readest-app/public/locales/sl/translation.json index 86e2db5b..43ec7ad4 100644 --- a/apps/readest-app/public/locales/sl/translation.json +++ b/apps/readest-app/public/locales/sl/translation.json @@ -1872,5 +1872,6 @@ "Styles": "Slogi", "Word": "Beseda", "Sentence": "Stavek", - "Granularity": "Granularnost" + "Granularity": "Granularnost", + "Refresh Page": "Osveži stran" } diff --git a/apps/readest-app/public/locales/sv/translation.json b/apps/readest-app/public/locales/sv/translation.json index 9d9bab0c..fe0a427d 100644 --- a/apps/readest-app/public/locales/sv/translation.json +++ b/apps/readest-app/public/locales/sv/translation.json @@ -1806,5 +1806,6 @@ "Styles": "Stilar", "Word": "Ord", "Sentence": "Mening", - "Granularity": "Detaljnivå" + "Granularity": "Detaljnivå", + "Refresh Page": "Uppdatera sidan" } diff --git a/apps/readest-app/public/locales/ta/translation.json b/apps/readest-app/public/locales/ta/translation.json index eed79942..f66d6ae0 100644 --- a/apps/readest-app/public/locales/ta/translation.json +++ b/apps/readest-app/public/locales/ta/translation.json @@ -1806,5 +1806,6 @@ "Styles": "பாணிகள்", "Word": "சொல்", "Sentence": "வாக்கியம்", - "Granularity": "நுண்மை" + "Granularity": "நுண்மை", + "Refresh Page": "பக்கத்தைப் புதுப்பி" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 4861a14f..80c2d3e2 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -1773,5 +1773,6 @@ "Styles": "สไตล์", "Word": "คำ", "Sentence": "ประโยค", - "Granularity": "ระดับความละเอียด" + "Granularity": "ระดับความละเอียด", + "Refresh Page": "รีเฟรชหน้า" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 56f2ecd7..4d7ede3e 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -1806,5 +1806,6 @@ "Styles": "Stiller", "Word": "Kelime", "Sentence": "Cümle", - "Granularity": "Ayrıntı düzeyi" + "Granularity": "Ayrıntı düzeyi", + "Refresh Page": "Sayfayı Yenile" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 9af80ed5..56202055 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -1872,5 +1872,6 @@ "Styles": "Стилі", "Word": "Слово", "Sentence": "Речення", - "Granularity": "Деталізація" + "Granularity": "Деталізація", + "Refresh Page": "Оновити сторінку" } diff --git a/apps/readest-app/public/locales/uz/translation.json b/apps/readest-app/public/locales/uz/translation.json index 42161992..de5fee9d 100644 --- a/apps/readest-app/public/locales/uz/translation.json +++ b/apps/readest-app/public/locales/uz/translation.json @@ -1806,5 +1806,6 @@ "Styles": "Uslublar", "Word": "So‘z", "Sentence": "Gap", - "Granularity": "Tafsilot darajasi" + "Granularity": "Tafsilot darajasi", + "Refresh Page": "Sahifani yangilash" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index 46fe64be..73ac9561 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -1773,5 +1773,6 @@ "Styles": "Kiểu", "Word": "Từ", "Sentence": "Câu", - "Granularity": "Mức độ chi tiết" + "Granularity": "Mức độ chi tiết", + "Refresh Page": "Làm mới trang" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 9e5fe65e..10b0650b 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -1773,5 +1773,6 @@ "Styles": "样式", "Word": "单词", "Sentence": "句子", - "Granularity": "粒度" + "Granularity": "粒度", + "Refresh Page": "刷新页面" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index e560eeb5..e6988b7a 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -1773,5 +1773,6 @@ "Styles": "樣式", "Word": "單字", "Sentence": "句子", - "Granularity": "粒度" + "Granularity": "粒度", + "Refresh Page": "重新整理頁面" } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/EinkRefreshController.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/EinkRefreshController.kt new file mode 100644 index 00000000..6611faa6 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/EinkRefreshController.kt @@ -0,0 +1,102 @@ +package com.readest.native_bridge + +import android.util.Log +import android.view.View + +/** + * Best-effort, device-agnostic deep e-ink full refresh (GC / GC16 waveform) + * used to clear screen ghosting on demand. + * + * Android exposes no public e-ink API; every vendor patches its own methods + * into the platform `android.view.View` (or ships a private SDK). We probe + * each known framework mechanism via reflection in turn and stop at the first + * that succeeds, so one call works across Onyx BOOX (Qualcomm), Tolino / Nook + * (NTX / Freescale) and Boyue-style Rockchip devices without compiling against + * any vendor SDK. Reflection targets are adapted from KOReader's EPD + * controllers (koreader/android-luajit-launcher). + * + * Unlike a reader that owns the whole update loop, Readest leaves the device's + * automatic e-ink handling in place, so we deliberately do NOT switch the panel + * into a manual update mode (e.g. Onyx `setWaveformAndScheme`) — that could + * freeze subsequent system updates. We only request a one-shot full update. + */ +object EinkRefreshController { + private const val TAG = "EinkRefresh" + + // android.view.View.refreshScreen(...) waveform mode (Onyx / Qualcomm): + // EINK_WAVEFORM_UPDATE_FULL (32) + EINK_WAVEFORM_MODE_GC16 (2) = 34. + private const val ONYX_FULL_GC16 = 34 + + // android.view.View.postInvalidateDelayed(...) e-ink mode (NTX / Freescale): + // EINK_UPDATE_MODE_FULL (32) + EINK_WAVEFORM_MODE_GC16 (2) = 34. + private const val NTX_FULL_GC16 = 34 + + /** + * Attempt a deep full refresh over [view]'s region. Returns true when a + * vendor mechanism accepted the request, false when none is available + * (e.g. a non-e-ink Android phone). Never throws. + */ + fun refresh(view: View): Boolean { + val width = view.width + val height = view.height + if (width <= 0 || height <= 0) return false + return onyxRefresh(view, width, height) || + ntxRefresh(view, width, height) || + rockchipRefresh(view) + } + + // Onyx BOOX (Qualcomm models): `refreshScreen` is an instance method patched + // onto View that performs an EPD update of the given region. + private fun onyxRefresh(view: View, width: Int, height: Int): Boolean { + return try { + View::class.java + .getMethod( + "refreshScreen", + Integer.TYPE, Integer.TYPE, Integer.TYPE, Integer.TYPE, Integer.TYPE, + ) + .invoke(view, 0, 0, width, height, ONYX_FULL_GC16) + Log.i(TAG, "onyx full refresh requested") + true + } catch (e: Throwable) { + Log.d(TAG, "onyx refresh unavailable: ${e.message}") + false + } + } + + // NTX / Freescale (Tolino, Nook): the thread-safe postInvalidateDelayed + // overload that carries an e-ink waveform mode. + private fun ntxRefresh(view: View, width: Int, height: Int): Boolean { + return try { + View::class.java + .getMethod( + "postInvalidateDelayed", + java.lang.Long.TYPE, + Integer.TYPE, Integer.TYPE, Integer.TYPE, Integer.TYPE, Integer.TYPE, + ) + .invoke(view, 0L, 0, 0, width, height, NTX_FULL_GC16) + Log.i(TAG, "ntx full refresh requested") + true + } catch (e: Throwable) { + Log.d(TAG, "ntx refresh unavailable: ${e.message}") + false + } + } + + // Rockchip (Boyue T61/T62 clones): View.requestEpdMode(View$EINK_MODE, boolean) + // with the EPD_FULL enum constant. + private fun rockchipRefresh(view: View): Boolean { + return try { + @Suppress("UNCHECKED_CAST") + val einkEnum = Class.forName("android.view.View\$EINK_MODE") as Class> + val full = einkEnum.enumConstants?.firstOrNull { it.name == "EPD_FULL" } ?: return false + View::class.java + .getMethod("requestEpdMode", einkEnum, java.lang.Boolean.TYPE) + .invoke(view, full, true) + Log.i(TAG, "rockchip full refresh requested") + true + } catch (e: Throwable) { + Log.d(TAG, "rockchip refresh unavailable: ${e.message}") + false + } + } +} 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 b2e0824d..5ba2c937 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 @@ -1418,6 +1418,30 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) { } controller.show() } + + /** + * Trigger a deep e-ink full screen refresh (GC16 waveform) to clear + * ghosting. Driven by the page-turner "Refresh Page" action on e-ink + * Android devices. Runs on the UI thread against the window's decor view; + * [EinkRefreshController] probes each vendor mechanism and resolves + * `success: false` (not an error) when none is available. + */ + @Command + fun refresh_eink_screen(invoke: Invoke) { + activity.runOnUiThread { + val ret = JSObject() + try { + val view = activity.window?.decorView + val ok = view != null && EinkRefreshController.refresh(view) + ret.put("success", ok) + } catch (e: Exception) { + Log.e("NativeBridgePlugin", "refresh_eink_screen failed", e) + ret.put("success", false) + ret.put("error", e.message ?: "unknown") + } + invoke.resolve(ret) + } + } } @app.tauri.annotation.InvokeArg 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 b820ac74..f5d16e6b 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 @@ -37,6 +37,7 @@ const COMMANDS: &[&str] = &[ "set_secure_item", "get_secure_item", "clear_secure_item", + "refresh_eink_screen", ]; fn main() { diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift index ffb2bed3..5f4f98f6 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift @@ -1490,6 +1490,13 @@ class NativeBridgePlugin: Plugin { presenter.present(picker, animated: true) } } + + // iOS devices have no e-ink panel; the "Refresh Page" page-turner action is + // gated to e-ink Android in the UI, so this is only ever reached defensively. + // Resolve as a soft no-op rather than rejecting. + @objc public func refresh_eink_screen(_ invoke: Invoke) { + invoke.resolve(["success": false]) + } } /// Persistent store for security-scoped folder bookmarks. diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/refresh_eink_screen.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/refresh_eink_screen.toml new file mode 100644 index 00000000..db093861 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/refresh_eink_screen.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-refresh-eink-screen" +description = "Enables the refresh_eink_screen command without any pre-configured scope." +commands.allow = ["refresh_eink_screen"] + +[[permission]] +identifier = "deny-refresh-eink-screen" +description = "Denies the refresh_eink_screen command without any pre-configured scope." +commands.deny = ["refresh_eink_screen"] 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 1e238690..1c1c3eec 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 @@ -45,6 +45,7 @@ Default permissions for the plugin - `allow-set-secure-item` - `allow-get-secure-item` - `allow-clear-secure-item` +- `allow-refresh-eink-screen` ## Permission Table @@ -838,6 +839,32 @@ Denies the open_external_url command without any pre-configured scope. +`native-bridge:allow-refresh-eink-screen` + + + + +Enables the refresh_eink_screen command without any pre-configured scope. + + + + + + + +`native-bridge:deny-refresh-eink-screen` + + + + +Denies the refresh_eink_screen command without any pre-configured scope. + + + + + + + `native-bridge:allow-register-listener` 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 df139dad..1692f67d 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 @@ -42,4 +42,5 @@ permissions = [ "allow-set-secure-item", "allow-get-secure-item", "allow-clear-secure-item", + "allow-refresh-eink-screen", ] 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 20369e6d..4a4460d1 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 @@ -654,6 +654,18 @@ "const": "deny-open-external-url", "markdownDescription": "Denies the open_external_url command without any pre-configured scope." }, + { + "description": "Enables the refresh_eink_screen command without any pre-configured scope.", + "type": "string", + "const": "allow-refresh-eink-screen", + "markdownDescription": "Enables the refresh_eink_screen command without any pre-configured scope." + }, + { + "description": "Denies the refresh_eink_screen command without any pre-configured scope.", + "type": "string", + "const": "deny-refresh-eink-screen", + "markdownDescription": "Denies the refresh_eink_screen command without any pre-configured scope." + }, { "description": "Enables the register_listener command without any pre-configured scope.", "type": "string", @@ -823,10 +835,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-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`\n- `allow-set-secure-item`\n- `allow-get-secure-item`\n- `allow-clear-secure-item`", + "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`\n- `allow-set-secure-item`\n- `allow-get-secure-item`\n- `allow-clear-secure-item`\n- `allow-refresh-eink-screen`", "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-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`\n- `allow-set-secure-item`\n- `allow-get-secure-item`\n- `allow-clear-secure-item`" + "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`\n- `allow-set-secure-item`\n- `allow-get-secure-item`\n- `allow-clear-secure-item`\n- `allow-refresh-eink-screen`" } ] } 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 11e56c84..ca5f5023 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 @@ -273,3 +273,10 @@ pub(crate) async fn clear_secure_item( ) -> Result { app.native_bridge().clear_secure_item(payload) } + +#[command] +pub(crate) async fn refresh_eink_screen( + app: AppHandle, +) -> Result { + app.native_bridge().refresh_eink_screen() +} 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 f6ae658c..42e3f398 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 @@ -345,6 +345,12 @@ impl NativeBridge { }), } } + + /// E-ink panels exist only on the mobile (Android) side. Desktop has no + /// e-ink controller, so this is unsupported here. + pub fn refresh_eink_screen(&self) -> crate::Result { + Err(crate::Error::UnsupportedPlatformError) + } } const KEYRING_SERVICE: &str = "Readest Safe Storage"; 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 17d70d08..37e568e1 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 @@ -88,6 +88,7 @@ pub fn init() -> TauriPlugin { commands::set_secure_item, commands::get_secure_item, commands::clear_secure_item, + commands::refresh_eink_screen, ]) .setup(|app, api| { #[cfg(mobile)] 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 c962e727..25c51e97 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 @@ -332,6 +332,14 @@ impl NativeBridge { } } +impl NativeBridge { + pub fn refresh_eink_screen(&self) -> crate::Result { + self.0 + .run_mobile_plugin("refresh_eink_screen", ()) + .map_err(Into::into) + } +} + impl NativeBridge { /// Open a full-screen `WKWebView` / `WebView` over the main app, /// navigate to `payload.url` with a real Chrome UA, wait for load 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 86e86c10..34362b16 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 @@ -395,3 +395,13 @@ pub struct GetSecureItemResponse { pub value: Option, pub error: Option, } + +/// Result of a deep e-ink full screen refresh. `success: false` means no +/// known e-ink controller responded on this device (e.g. a non-e-ink +/// Android phone) — not a hard error. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RefreshEinkScreenResponse { + pub success: bool, + pub error: Option, +} diff --git a/apps/readest-app/src/__tests__/app/reader/hooks/usePagination-refreshEink.test.ts b/apps/readest-app/src/__tests__/app/reader/hooks/usePagination-refreshEink.test.ts new file mode 100644 index 00000000..f82c5dad --- /dev/null +++ b/apps/readest-app/src/__tests__/app/reader/hooks/usePagination-refreshEink.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import { HardwarePageTurnerSettings } from '@/types/settings'; + +// Real eventDispatcher; only the native bridge boundary is mocked so we can +// observe whether the "Refresh Page" page-turner action drives a native +// e-ink refresh. +const REFRESH_BINDING = { + source: 'native' as const, + id: 'MediaPlayPause', + label: 'Media Play/Pause', +}; + +const hardwarePageTurner: HardwarePageTurnerSettings = { + enabled: true, + bindings: { + pagePrev: null, + pageNext: null, + sectionPrev: null, + sectionNext: null, + refresh: REFRESH_BINDING, + }, +}; + +const h = vi.hoisted(() => ({ + appService: { isMobileApp: true, isAndroidApp: true } as { + isMobileApp: boolean; + isAndroidApp: boolean; + }, + viewSettings: {} as Record | null, + viewState: { inited: true } as Record | null, + settingsState: { settings: { hardwarePageTurner: undefined as unknown } }, +})); + +vi.mock('@/utils/bridge', () => ({ + interceptKeys: vi.fn(), + getScreenBrightness: vi.fn(), + setScreenBrightness: vi.fn(), + refreshEinkScreen: vi.fn(() => Promise.resolve({ success: true })), +})); + +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ appService: h.appService }), +})); +vi.mock('@/store/readerStore', () => ({ + useReaderStore: Object.assign( + () => ({ + getViewSettings: () => h.viewSettings, + getViewState: () => h.viewState, + hoveredBookKey: null, + setHoveredBookKey: vi.fn(), + }), + { getState: () => ({ hoveredBookKey: null }) }, + ), +})); +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: () => ({ getBookData: () => ({}) }), +})); +vi.mock('@/store/settingsStore', () => ({ + useSettingsStore: Object.assign( + (selector?: (s: typeof h.settingsState) => unknown) => + selector ? selector(h.settingsState) : h.settingsState, + { getState: () => h.settingsState }, + ), +})); +vi.mock('@/store/sidebarStore', () => ({ + useSidebarStore: Object.assign(() => ({}), { getState: () => ({ sideBarBookKey: 'book-1' }) }), +})); + +import { refreshEinkScreen } from '@/utils/bridge'; +import { eventDispatcher } from '@/utils/event'; +import { useDeviceControlStore } from '@/store/deviceStore'; +import { usePagination } from '@/app/reader/hooks/usePagination'; + +const BOOK_KEY = 'book-1'; + +const setup = () => { + const viewRef = { current: null }; + const containerRef = { current: null }; + return renderHook(() => usePagination(BOOK_KEY, viewRef, containerRef)); +}; + +const pressNativeKey = async (keyName: string) => { + await act(async () => { + await eventDispatcher.dispatch('native-key-down', { keyName }); + }); +}; + +beforeEach(() => { + useDeviceControlStore.setState({ + volumeKeysIntercepted: false, + volumeKeysInterceptionCount: 0, + pageTurnerKeysIntercepted: false, + pageTurnerKeysInterceptionCount: 0, + }); + vi.clearAllMocks(); + h.appService = { isMobileApp: true, isAndroidApp: true }; + h.viewSettings = {}; + h.viewState = { inited: true }; + h.settingsState = { settings: { hardwarePageTurner } }; +}); + +afterEach(() => { + cleanup(); +}); + +describe('usePagination "Refresh Page" action (#4687)', () => { + test('triggers a native e-ink refresh for the bound key on Android', async () => { + setup(); + await pressNativeKey('MediaPlayPause'); + expect(refreshEinkScreen).toHaveBeenCalledTimes(1); + }); + + test('does not call the bridge for an unrelated key', async () => { + setup(); + await pressNativeKey('MediaFastForward'); + expect(refreshEinkScreen).not.toHaveBeenCalled(); + }); + + test('does not call the bridge off Android even if the key is bound', async () => { + h.appService = { isMobileApp: true, isAndroidApp: false }; + setup(); + await pressNativeKey('MediaPlayPause'); + expect(refreshEinkScreen).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/constants.test.ts b/apps/readest-app/src/__tests__/services/constants.test.ts index fe37f0fb..610b0de2 100644 --- a/apps/readest-app/src/__tests__/services/constants.test.ts +++ b/apps/readest-app/src/__tests__/services/constants.test.ts @@ -304,6 +304,7 @@ describe('services/constants', () => { expect(hw.bindings.pageNext).toBeNull(); expect(hw.bindings.sectionPrev).toBeNull(); expect(hw.bindings.sectionNext).toBeNull(); + expect(hw.bindings.refresh).toBeNull(); }); }); diff --git a/apps/readest-app/src/__tests__/utils/hardwareKeys.test.ts b/apps/readest-app/src/__tests__/utils/hardwareKeys.test.ts index ce069ddc..fbecd445 100644 --- a/apps/readest-app/src/__tests__/utils/hardwareKeys.test.ts +++ b/apps/readest-app/src/__tests__/utils/hardwareKeys.test.ts @@ -73,6 +73,7 @@ describe('resolvePageTurn', () => { pageNext: { source: 'native', id: 'MediaNext', label: 'Media Next' }, sectionPrev: { source: 'dom', id: 'PageUp', label: 'Page Up' }, sectionNext: { source: 'dom', id: 'PageDown', label: 'Page Down' }, + refresh: { source: 'native', id: 'MediaPlayPause', label: 'Media Play/Pause' }, }, }; @@ -92,8 +93,12 @@ describe('resolvePageTurn', () => { expect(resolvePageTurn(settings, { source: 'dom', id: 'PageDown' })).toBe('sectionNext'); }); + test('returns "refresh" for the refresh binding', () => { + expect(resolvePageTurn(settings, { source: 'native', id: 'MediaPlayPause' })).toBe('refresh'); + }); + test('returns null for an unbound key', () => { - expect(resolvePageTurn(settings, { source: 'native', id: 'MediaPlayPause' })).toBeNull(); + expect(resolvePageTurn(settings, { source: 'native', id: 'MediaFastForward' })).toBeNull(); }); test('returns null when the feature is disabled', () => { diff --git a/apps/readest-app/src/app/reader/hooks/usePagination.ts b/apps/readest-app/src/app/reader/hooks/usePagination.ts index 58beded3..4f8ff522 100644 --- a/apps/readest-app/src/app/reader/hooks/usePagination.ts +++ b/apps/readest-app/src/app/reader/hooks/usePagination.ts @@ -9,6 +9,7 @@ import { useSettingsStore } from '@/store/settingsStore'; import { useSidebarStore } from '@/store/sidebarStore'; import { eventDispatcher } from '@/utils/event'; import { resolvePageTurn, normalizeDomKeyEvent, KeyCandidate } from '@/utils/keybinding'; +import { refreshEinkScreen } from '@/utils/bridge'; import { isTauriAppPlatform } from '@/services/environment'; import { tauriGetWindowLogicalPosition } from '@/utils/window'; import { getReadingRulerMoveDirection } from '../utils/readingRuler'; @@ -359,6 +360,15 @@ export const usePagination = ( const action = resolvePageTurn(settings, candidate); if (!action) return false; + // E-ink full screen refresh (Android only) — clears ghosting without + // turning the page. The native bridge no-ops on non-e-ink hardware. + if (action === 'refresh') { + if (appService?.isAndroidApp) { + refreshEinkScreen().catch(() => {}); + } + return true; + } + const viewSettings = getViewSettings(bookKey); const side = action === 'pagePrev' || action === 'sectionPrev' ? 'up' : 'down'; const mode = action === 'sectionPrev' || action === 'sectionNext' ? 'section' : 'page'; @@ -455,7 +465,8 @@ export const usePagination = ( hardwarePageTurner?.bindings.pagePrev?.source === 'native' || hardwarePageTurner?.bindings.pageNext?.source === 'native' || hardwarePageTurner?.bindings.sectionPrev?.source === 'native' || - hardwarePageTurner?.bindings.sectionNext?.source === 'native'; + hardwarePageTurner?.bindings.sectionNext?.source === 'native' || + hardwarePageTurner?.bindings.refresh?.source === 'native'; const needsNativeInterception = !!appService?.isMobileApp && !!hardwarePageTurner?.enabled && hasNativeBinding; @@ -485,6 +496,7 @@ export const usePagination = ( hardwarePageTurner?.bindings.pageNext?.source, hardwarePageTurner?.bindings.sectionPrev?.source, hardwarePageTurner?.bindings.sectionNext?.source, + hardwarePageTurner?.bindings.refresh?.source, ]); // Touch swipe page flip for fixed-layout books — registered as a touch interceptor diff --git a/apps/readest-app/src/components/settings/PageTurnerSettings.tsx b/apps/readest-app/src/components/settings/PageTurnerSettings.tsx index 6dc8de2c..ec57f18b 100644 --- a/apps/readest-app/src/components/settings/PageTurnerSettings.tsx +++ b/apps/readest-app/src/components/settings/PageTurnerSettings.tsx @@ -193,6 +193,11 @@ const PageTurnerSettings: React.FC = ({ bookKey, onRegi {renderSlot('pageNext', _('Next Page'))} {renderSlot('sectionPrev', _('Previous Section'))} {renderSlot('sectionNext', _('Next Section'))} + {/* Deep e-ink refresh clears ghosting; only meaningful in e-ink mode + on Android, where the native bridge can drive the panel. */} + {appService?.isAndroidApp && + viewSettings.isEink && + renderSlot('refresh', _('Refresh Page'))} ); diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index c7423456..3777654e 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -128,7 +128,13 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial = { swipeBrightnessGesture: true, hardwarePageTurner: { enabled: false, - bindings: { pagePrev: null, pageNext: null, sectionPrev: null, sectionNext: null }, + bindings: { + pagePrev: null, + pageNext: null, + sectionPrev: null, + sectionNext: null, + refresh: null, + }, }, openLastBooks: false, lastOpenBooks: [], diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index f77abe6e..5523bb48 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -215,6 +215,8 @@ export interface HardwarePageTurnerSettings { pageNext: KeyBinding | null; sectionPrev: KeyBinding | null; sectionNext: KeyBinding | null; + /** E-ink full screen refresh (clears ghosting). Optional: absent on settings persisted before the feature existed. */ + refresh?: KeyBinding | null; }; } diff --git a/apps/readest-app/src/utils/bridge.ts b/apps/readest-app/src/utils/bridge.ts index 8e05d7b0..90c3840b 100644 --- a/apps/readest-app/src/utils/bridge.ts +++ b/apps/readest-app/src/utils/bridge.ts @@ -113,6 +113,11 @@ export interface GetStorefrontRegionCodeResponse { error?: string; } +export interface RefreshEinkScreenResponse { + success: boolean; + error?: string; +} + export async function copyURIToPath(request: CopyURIRequest): Promise { const result = await invoke('plugin:native-bridge|copy_uri_to_path', { payload: request, @@ -240,6 +245,16 @@ export async function getStorefrontRegionCode(): Promise { + return await invoke('plugin:native-bridge|refresh_eink_screen'); +} + // ── Sync passphrase keychain ──────────────────────────────────────────── // Tauri-only. Wired into the TauriPassphraseStore (src/libs/crypto/ // passphrase.ts) so the user's sync passphrase persists across app diff --git a/apps/readest-app/src/utils/keybinding.ts b/apps/readest-app/src/utils/keybinding.ts index 896e11c0..918485ae 100644 --- a/apps/readest-app/src/utils/keybinding.ts +++ b/apps/readest-app/src/utils/keybinding.ts @@ -2,13 +2,16 @@ import { HardwarePageTurnerSettings, KeyBinding } from '@/types/settings'; import { stubTranslation as _ } from '@/utils/misc'; export type KeyCandidate = { source: 'native' | 'dom'; id: string }; -export type PageTurnAction = 'pagePrev' | 'pageNext' | 'sectionPrev' | 'sectionNext'; +// `refresh` is an e-ink-only action (full screen refresh to clear ghosting); +// the others navigate. All share the same key-binding machinery. +export type PageTurnAction = 'pagePrev' | 'pageNext' | 'sectionPrev' | 'sectionNext' | 'refresh'; export const PAGE_TURN_ACTIONS: PageTurnAction[] = [ 'pagePrev', 'pageNext', 'sectionPrev', 'sectionNext', + 'refresh', ]; const NATIVE_KEY_LABELS: Record = { @@ -53,8 +56,10 @@ export const normalizeDomKeyEvent = (event: KeyboardEvent): KeyBinding => { }; /** True when `candidate` is the key described by `binding`. */ -export const matchesBinding = (binding: KeyBinding | null, candidate: KeyCandidate): boolean => - !!binding && binding.source === candidate.source && binding.id === candidate.id; +export const matchesBinding = ( + binding: KeyBinding | null | undefined, + candidate: KeyCandidate, +): boolean => !!binding && binding.source === candidate.source && binding.id === candidate.id; /** * Decide which page-turn action an incoming key triggers. Returns the