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:
@@ -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": "So‘z",
|
||||
"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": "重新整理頁面"
|
||||
}
|
||||
|
||||
+102
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -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() {
|
||||
|
||||
+7
@@ -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.
|
||||
|
||||
+13
@@ -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"]
|
||||
+27
@@ -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",
|
||||
]
|
||||
|
||||
+14
-2
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user