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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-27 17:18:11 +08:00
committed by GitHub
parent f8916e128e
commit 324bb8a366
55 changed files with 470 additions and 41 deletions
@@ -1938,5 +1938,6 @@
"Styles": "الأنماط",
"Word": "كلمة",
"Sentence": "جملة",
"Granularity": "الدقة"
"Granularity": "الدقة",
"Refresh Page": "تحديث الصفحة"
}
@@ -1806,5 +1806,6 @@
"Styles": "শৈলী",
"Word": "শব্দ",
"Sentence": "বাক্য",
"Granularity": "সূক্ষ্মতা"
"Granularity": "সূক্ষ্মতা",
"Refresh Page": "পৃষ্ঠা রিফ্রেশ করুন"
}
@@ -1767,5 +1767,6 @@
"Styles": "རྣམ་པ",
"Word": "ཚིག",
"Sentence": "ཚིག་གྲུབ།",
"Granularity": "ཞིབ་ཕྲའི་ཚད"
"Granularity": "ཞིབ་ཕྲའི་ཚད",
"Refresh Page": "ཤོག་ལྷེ་བསྐྱར་གསོ།"
}
@@ -1806,5 +1806,6 @@
"Styles": "Stile",
"Word": "Wort",
"Sentence": "Satz",
"Granularity": "Granularität"
"Granularity": "Granularität",
"Refresh Page": "Seite aktualisieren"
}
@@ -1806,5 +1806,6 @@
"Styles": "Στυλ",
"Word": "Λέξη",
"Sentence": "Πρόταση",
"Granularity": "Διαβάθμιση"
"Granularity": "Διαβάθμιση",
"Refresh Page": "Ανανέωση σελίδας"
}
@@ -1839,5 +1839,6 @@
"Styles": "Estilos",
"Word": "Palabra",
"Sentence": "Oración",
"Granularity": "Granularidad"
"Granularity": "Granularidad",
"Refresh Page": "Actualizar página"
}
@@ -1806,5 +1806,6 @@
"Styles": "سبک‌ها",
"Word": "کلمه",
"Sentence": "جمله",
"Granularity": "دانه‌بندی"
"Granularity": "دانه‌بندی",
"Refresh Page": "تازه‌سازی صفحه"
}
@@ -1839,5 +1839,6 @@
"Styles": "Styles",
"Word": "Mot",
"Sentence": "Phrase",
"Granularity": "Granularité"
"Granularity": "Granularité",
"Refresh Page": "Actualiser la page"
}
@@ -1839,5 +1839,6 @@
"Styles": "סגנונות",
"Word": "מילה",
"Sentence": "משפט",
"Granularity": "רמת פירוט"
"Granularity": "רמת פירוט",
"Refresh Page": "רענן דף"
}
@@ -1806,5 +1806,6 @@
"Styles": "शैलियाँ",
"Word": "शब्द",
"Sentence": "वाक्य",
"Granularity": "सूक्ष्मता"
"Granularity": "सूक्ष्मता",
"Refresh Page": "पृष्ठ ताज़ा करें"
}
@@ -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"
}
@@ -1773,5 +1773,6 @@
"Styles": "Gaya",
"Word": "Kata",
"Sentence": "Kalimat",
"Granularity": "Granularitas"
"Granularity": "Granularitas",
"Refresh Page": "Segarkan Halaman"
}
@@ -1839,5 +1839,6 @@
"Styles": "Stili",
"Word": "Parola",
"Sentence": "Frase",
"Granularity": "Granularità"
"Granularity": "Granularità",
"Refresh Page": "Aggiorna pagina"
}
@@ -1773,5 +1773,6 @@
"Styles": "スタイル",
"Word": "単語",
"Sentence": "文",
"Granularity": "粒度"
"Granularity": "粒度",
"Refresh Page": "ページを更新"
}
@@ -1773,5 +1773,6 @@
"Styles": "스타일",
"Word": "단어",
"Sentence": "문장",
"Granularity": "단위"
"Granularity": "단위",
"Refresh Page": "페이지 새로고침"
}
@@ -1773,5 +1773,6 @@
"Styles": "Gaya",
"Word": "Perkataan",
"Sentence": "Ayat",
"Granularity": "Granulariti"
"Granularity": "Granulariti",
"Refresh Page": "Segarkan Halaman"
}
@@ -1806,5 +1806,6 @@
"Styles": "Stijlen",
"Word": "Woord",
"Sentence": "Zin",
"Granularity": "Granulariteit"
"Granularity": "Granulariteit",
"Refresh Page": "Pagina vernieuwen"
}
@@ -1872,5 +1872,6 @@
"Styles": "Style",
"Word": "Słowo",
"Sentence": "Zdanie",
"Granularity": "Ziarnistość"
"Granularity": "Ziarnistość",
"Refresh Page": "Odśwież stronę"
}
@@ -1839,5 +1839,6 @@
"Styles": "Estilos",
"Word": "Palavra",
"Sentence": "Frase",
"Granularity": "Granularidade"
"Granularity": "Granularidade",
"Refresh Page": "Atualizar página"
}
@@ -1839,5 +1839,6 @@
"Styles": "Estilos",
"Word": "Palavra",
"Sentence": "Frase",
"Granularity": "Granularidade"
"Granularity": "Granularidade",
"Refresh Page": "Atualizar Página"
}
@@ -1839,5 +1839,6 @@
"Styles": "Stiluri",
"Word": "Cuvânt",
"Sentence": "Propoziție",
"Granularity": "Granularitate"
"Granularity": "Granularitate",
"Refresh Page": "Reîmprospătează pagina"
}
@@ -1872,5 +1872,6 @@
"Styles": "Стили",
"Word": "Слово",
"Sentence": "Предложение",
"Granularity": "Детализация"
"Granularity": "Детализация",
"Refresh Page": "Обновить страницу"
}
@@ -1806,5 +1806,6 @@
"Styles": "මෝස්තර",
"Word": "වචනය",
"Sentence": "වාක්‍යය",
"Granularity": "විස්තර මට්ටම"
"Granularity": "විස්තර මට්ටම",
"Refresh Page": "පිටුව නැවුම් කරන්න"
}
@@ -1872,5 +1872,6 @@
"Styles": "Slogi",
"Word": "Beseda",
"Sentence": "Stavek",
"Granularity": "Granularnost"
"Granularity": "Granularnost",
"Refresh Page": "Osveži stran"
}
@@ -1806,5 +1806,6 @@
"Styles": "Stilar",
"Word": "Ord",
"Sentence": "Mening",
"Granularity": "Detaljnivå"
"Granularity": "Detaljnivå",
"Refresh Page": "Uppdatera sidan"
}
@@ -1806,5 +1806,6 @@
"Styles": "பாணிகள்",
"Word": "சொல்",
"Sentence": "வாக்கியம்",
"Granularity": "நுண்மை"
"Granularity": "நுண்மை",
"Refresh Page": "பக்கத்தைப் புதுப்பி"
}
@@ -1773,5 +1773,6 @@
"Styles": "สไตล์",
"Word": "คำ",
"Sentence": "ประโยค",
"Granularity": "ระดับความละเอียด"
"Granularity": "ระดับความละเอียด",
"Refresh Page": "รีเฟรชหน้า"
}
@@ -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"
}
@@ -1872,5 +1872,6 @@
"Styles": "Стилі",
"Word": "Слово",
"Sentence": "Речення",
"Granularity": "Деталізація"
"Granularity": "Деталізація",
"Refresh Page": "Оновити сторінку"
}
@@ -1806,5 +1806,6 @@
"Styles": "Uslublar",
"Word": "Soz",
"Sentence": "Gap",
"Granularity": "Tafsilot darajasi"
"Granularity": "Tafsilot darajasi",
"Refresh Page": "Sahifani yangilash"
}
@@ -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"
}
@@ -1773,5 +1773,6 @@
"Styles": "样式",
"Word": "单词",
"Sentence": "句子",
"Granularity": "粒度"
"Granularity": "粒度",
"Refresh Page": "刷新页面"
}
@@ -1773,5 +1773,6 @@
"Styles": "樣式",
"Word": "單字",
"Sentence": "句子",
"Granularity": "粒度"
"Granularity": "粒度",
"Refresh Page": "重新整理頁面"
}
@@ -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<out Enum<*>>
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
}
}
}
@@ -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
@@ -37,6 +37,7 @@ const COMMANDS: &[&str] = &[
"set_secure_item",
"get_secure_item",
"clear_secure_item",
"refresh_eink_screen",
];
fn main() {
@@ -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.
@@ -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"]
@@ -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.
<tr>
<td>
`native-bridge:allow-refresh-eink-screen`
</td>
<td>
Enables the refresh_eink_screen command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-refresh-eink-screen`
</td>
<td>
Denies the refresh_eink_screen command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-register-listener`
</td>
@@ -42,4 +42,5 @@ permissions = [
"allow-set-secure-item",
"allow-get-secure-item",
"allow-clear-secure-item",
"allow-refresh-eink-screen",
]
@@ -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`"
}
]
}
@@ -273,3 +273,10 @@ pub(crate) async fn clear_secure_item<R: Runtime>(
) -> Result<SecureItemResponse> {
app.native_bridge().clear_secure_item(payload)
}
#[command]
pub(crate) async fn refresh_eink_screen<R: Runtime>(
app: AppHandle<R>,
) -> Result<RefreshEinkScreenResponse> {
app.native_bridge().refresh_eink_screen()
}
@@ -345,6 +345,12 @@ impl<R: Runtime> NativeBridge<R> {
}),
}
}
/// 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<RefreshEinkScreenResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
}
const KEYRING_SERVICE: &str = "Readest Safe Storage";
@@ -88,6 +88,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::set_secure_item,
commands::get_secure_item,
commands::clear_secure_item,
commands::refresh_eink_screen,
])
.setup(|app, api| {
#[cfg(mobile)]
@@ -332,6 +332,14 @@ impl<R: Runtime> NativeBridge<R> {
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn refresh_eink_screen(&self) -> crate::Result<RefreshEinkScreenResponse> {
self.0
.run_mobile_plugin("refresh_eink_screen", ())
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
/// Open a full-screen `WKWebView` / `WebView` over the main app,
/// navigate to `payload.url` with a real Chrome UA, wait for load
@@ -395,3 +395,13 @@ pub struct GetSecureItemResponse {
pub value: Option<String>,
pub error: Option<String>,
}
/// 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<String>,
}
@@ -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<string, unknown> | null,
viewState: { inited: true } as Record<string, unknown> | 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();
});
});
@@ -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();
});
});
@@ -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', () => {
@@ -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
@@ -193,6 +193,11 @@ const PageTurnerSettings: React.FC<PageTurnerSettingsProps> = ({ 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'))}
</BoxedList>
</div>
);
+7 -1
View File
@@ -128,7 +128,13 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
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: [],
+2
View File
@@ -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;
};
}
+15
View File
@@ -113,6 +113,11 @@ export interface GetStorefrontRegionCodeResponse {
error?: string;
}
export interface RefreshEinkScreenResponse {
success: boolean;
error?: string;
}
export async function copyURIToPath(request: CopyURIRequest): Promise<CopyURIResponse> {
const result = await invoke<CopyURIResponse>('plugin:native-bridge|copy_uri_to_path', {
payload: request,
@@ -240,6 +245,16 @@ export async function getStorefrontRegionCode(): Promise<GetStorefrontRegionCode
return result;
}
/**
* Trigger a deep e-ink full screen refresh (GC / GC16 waveform) to clear
* ghosting. Android-only; the native side probes several vendor mechanisms
* via reflection and returns `success: false` on devices with no e-ink
* controller. Other platforms reject with an unsupported-platform error.
*/
export async function refreshEinkScreen(): Promise<RefreshEinkScreenResponse> {
return await invoke<RefreshEinkScreenResponse>('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
+8 -3
View File
@@ -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<string, string> = {
@@ -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