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
@@ -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