From ae1500459bc9e4200017819e42542d364e3bd3fd Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Mon, 29 Jun 2026 21:18:48 +0200 Subject: [PATCH 1/9] Align NeoFountainPenV2 rendering with Onyx firmware behavior ### Drawing & Core - **NeoFountainPenV2Wrapper**: - Replaced manual pen lifecycle and drawing calls with `NeoPenRender` to ensure strokes match the firmware's live rendering. - Switched to `FountainShapes.createNeoPenV2` for pen creation to include necessary width compensation, smoothing, and fast-mode configurations. - Updated `drawStroke` to use the SDK's full render pipeline, fixing an issue where the trailing "prediction" segment of a stroke was omitted. - Introduced `copyAndNormalizePressure` to prevent mutating the caller's `TouchPoint` data, avoiding cumulative pressure degradation on redraws. ### Documentation - **onyx-neo-fountain-pen-v2.md**: Added a detailed technical guide explaining the Onyx pen SDK rendering pipeline, decompiled insights, and the requirements for pixel-identical offline redraws. --- .../editor/drawing/NeoFountainPenV2Wrapper.kt | 93 +++++------ docs/onyx-neo-fountain-pen-v2.md | 151 ++++++++++++++++++ 2 files changed, 189 insertions(+), 55 deletions(-) create mode 100644 docs/onyx-neo-fountain-pen-v2.md diff --git a/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt b/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt index 7a36f731..2fbf9ee9 100644 --- a/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt +++ b/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt @@ -3,10 +3,9 @@ package com.ethran.notable.editor.drawing import android.graphics.Canvas import android.graphics.Paint import com.onyx.android.sdk.data.note.TouchPoint -import com.onyx.android.sdk.pen.NeoFountainPenV2 -import com.onyx.android.sdk.pen.NeoPenConfig -import com.onyx.android.sdk.pen.PenPathResult -import com.onyx.android.sdk.pen.PenResult +import com.onyx.android.sdk.pen.NeoFountainPenWrapper +import com.onyx.android.sdk.pen.NeoPenRender +import com.onyx.android.sdk.pen.utils.FountainShapes import io.shipbook.shipbooksdk.ShipBook private val logger = ShipBook.getLogger("NeoFountainPenV2Wrapper") @@ -21,73 +20,57 @@ object NeoFountainPenV2Wrapper { strokeWidth: Float, maxTouchPressure: Float, ) { - if (points.size < 2) { logger.e("Drawing strokes failed: Not enough points") return } - // Normalize pressure to [0, 1] using provided maxTouchPressure - if (maxTouchPressure > 0f) { - for (i in points.indices) { - points[i].pressure /= maxTouchPressure - } - } + // Fountain V2 produces closed (filled) paths. The pressure must be in [0, 1]. + // Work on a copy so we never mutate the caller's points (otherwise re-rendering + // the same stroke after an unfreeze would normalize the pressures again). + val renderPoints = copyAndNormalizePressure(points, maxTouchPressure) - val neoPenConfig = NeoPenConfig().apply { - setWidth(strokeWidth) - setTiltEnabled(true) - setMaxTouchPressure(maxTouchPressure) - } - val neoPen = NeoFountainPenV2.create(neoPenConfig) + // Mirror the official demo (BrushScribbleShape): build the pen via + // FountainShapes.createNeoPenV2 so the config (width compensation, minWidth, + // smoothLevel, pressureSensitivity, tilt=off, fastMode) matches what the + // firmware uses while drawing live. Any deviation here makes the redraw differ. + val neoPen = FountainShapes.createNeoPenV2( + strokeWidth, // width + NeoFountainPenWrapper.MIN_FOUNTAIN_PEN_WIDTH, // minWidth + 1.0f, // displayScaleX + 1.0f, // displayScaleY + 1.0f, // scalePrecision + 1.0f, // createScale + null, // pressureSensitivity -> default 0.3 + true, // fastMode + null, // smoothLevel -> default 0.6 + ) if (neoPen == null) { logger.e("Drawing strokes failed: Pen creation failed") return } + val penRender = NeoPenRender(neoPen) try { - // Pen down - drawResult( - neoPen.onPenDown(points.first(), repaint = true), - canvas, - paint - ) - - // Moves (exclude first and last) - if (points.size > 2) { - drawResult( - neoPen.onPenMove( - points.subList(1, points.size - 1), - prediction = null, - repaint = true - ), - canvas, - paint - ) - } - - // Pen up - drawResult( - neoPen.onPenUp(points.last(), repaint = true), - canvas, - paint - ) + // render() runs the full onTouchDown/onTouchMove/onTouchDone pipeline and + // draws every accumulated result plus the trailing prediction segment of the + // last result. Doing this by hand and drawing only `.first` (as before) drops + // the tail of the stroke and skips the SDK's batching. + penRender.render(canvas, paint, renderPoints) } finally { - neoPen.destroy() + penRender.destroyPen() } } - - private fun drawResult( - result: Pair?, - canvas: Canvas, - paint: Paint - ) { - val first = result?.first - if (first !is PenPathResult) { - logger.d("Expected PenPathResult but got ${first?.javaClass?.simpleName ?: "null"}") - return + private fun copyAndNormalizePressure( + points: List, + maxTouchPressure: Float, + ): List { + val needNormalize = maxTouchPressure > 0f && points.any { it.pressure > 1.0f } + return points.map { p -> + TouchPoint(p).apply { + if (needNormalize) pressure /= maxTouchPressure + } } - first.draw(canvas, paint = paint) } } diff --git a/docs/onyx-neo-fountain-pen-v2.md b/docs/onyx-neo-fountain-pen-v2.md new file mode 100644 index 00000000..7571a339 --- /dev/null +++ b/docs/onyx-neo-fountain-pen-v2.md @@ -0,0 +1,151 @@ +# Onyx NeoFountainPenV2 — How Offline Rendering Works + +This document explains how the Onyx fountain-pen-V2 stroke rendering works, why a +naive implementation does **not** match the firmware's live rendering, and how to +render strokes so they match exactly. + +## Context + +On Onyx e-ink devices, the firmware draws the stroke *live* with its own renderer +while the pen moves. Notable then has to re-draw that same stroke onto its own +surface so that when the screen is unfrozen there is no flicker. For this to work, +the offline redraw must be **pixel-identical** to what the firmware drew live. + +The firmware draws live using the SDK's own config + render pipeline. Any deviation +from that pipeline (custom config, custom draw loop) produces a stroke that does not +match. + +## Relevant SDK artifacts + +- `onyxsdk-pen` (1.5.4) — contains `NeoPenRender`, `NeoFountainPenWrapper`. +- `onyxsdk-penbrush` (1.1.1) — contains `NeoFountainPenV2`, `NeoPenConfig`, + `PenPathResult`, `PenResult`, `FountainShapes`, `NeoPen`. + +The official demo (`OnyxAndroidDemo`) renders the fountain V2 pen in +`app/OnyxPenDemo/.../shape/BrushScribbleShape.java`. That class is the reference +implementation to mirror. + +## How the SDK pipeline works (decompiled) + +### `FountainShapes.createNeoPenV2(...)` + +This is the factory the demo uses. It builds a `NeoPenConfig` with specific values — +these are what the firmware uses: + +``` +config.width = width + 3.0f / createScale // FOUNTAIN_PEN_V1_COMPENSATION = 3.0f +config.minWidth = minWidth / createScale +config.pressureSensitivity = pressureSensitivity ?: 0.3f +config.smoothLevel = smoothLevel ?: 0.6f +config.scalePrecision = scalePrecision +config.tiltEnabled = false +config.fastMode = fastMode +config.displayScaleX = displayScaleX +config.displayScaleY = displayScaleY +return NeoFountainPenV2.Companion.create(config) +``` + +Signature: +`createNeoPenV2(width, minWidth, displayScaleX, displayScaleY, scalePrecision, createScale, pressureSensitivity: Float?, fastMode: Boolean, smoothLevel: Float?)` + +Demo call: `createNeoPenV2(strokeWidth, MIN_FOUNTAIN_PEN_WIDTH, 1f, 1f, 1f, 1f, null, true, null)`. + +Constants: +- `NeoFountainPenWrapper.MIN_FOUNTAIN_PEN_WIDTH = 1.0f` +- `FountainShapes.FOUNTAIN_PEN_V1_COMPENSATION = 3.0f` + +### `NeoFountainPenV2.Companion.create(config)` + +``` +handle = NeoPenNative.createPen(6, config.toNativeConfig()) // 6 = NEOPEN_PEN_TYPE_FOUNTAIN_V2 +``` + +The pen **type is hardcoded to 6**, so you do not need to set `config.type` yourself. + +`buildPenResult` returns a `Pair`: +- `.first` = the real ink (committed segment) +- `.second` = the prediction ink (trailing segment up to the latest point) + +When `fastMode` is true these are `PenPointResult`; otherwise `PenPathResult`. + +### `NeoPenRender` + +This is the renderer. The important methods: + +- `render(canvas, paint, points)` → calls `onTouchPointList(points)`, then + `render(canvas, paint)`, then `reset()`. +- `onTouchPointList(points)`: + - `onTouchDown(first, repaint=true)` + - splits the middle points into batches of `POINT_LIST_BATCH_LIMIT = 1000` and + calls `onTouchMove(batch, predict=null, repaint=true)` for each + - `onTouchDone(last, repaint=true)` + - accumulates every returned `Pair` into an internal list. +- `render(canvas, paint)`: + - draws `.first` of **every** accumulated result, + - **plus `.second` of the last result** (the trailing prediction segment). + +That last point is critical — the tail of the stroke lives in the `.second` of the +final pair. + +## Why a naive hand-rolled implementation does NOT match + +The original Notable wrapper created the pen with a bare `NeoPenConfig` and drove +`onPenDown/onPenMove/onPenUp` manually, drawing only `.first`. Every divergence below +caused a visible mismatch: + +1. **Wrong config (biggest cause).** Using a bare `NeoPenConfig` and only setting + width/tilt/maxPressure means you miss: + - the **+3px width compensation** (`width + 3.0/createScale`) → stroke too thin. + - `minWidth`, `smoothLevel = 0.6`, `pressureSensitivity = 0.3` → native defaults + used instead; `smoothLevel` in particular reshapes the path geometry. + - `tiltEnabled` — fountain V2 uses **false**. Setting it `true` changes the outline. + - `fastMode` — demo uses **true**; the default `false` yields a different result + type/geometry. + +2. **Double pressure normalization.** Pre-dividing points by `maxTouchPressure` + *and* calling `setMaxTouchPressure(...)` on the config makes the native code + normalize a second time, collapsing the pressures. The demo leaves + `config.maxTouchPressure` at default and only pre-normalizes the points + (and only if any pressure > 1.0). + +3. **Mutating the caller's points in place.** `points[i].pressure /= max` mutates the + shared list. Because the stroke is re-rendered after each unfreeze, the pressures + shrink on every redraw. The demo copies via `new TouchPoint(p)`. + +4. **Dropping the stroke tail.** Drawing only `.first` of each result omits the + `.second` of the last result (the trailing prediction ink), so the end of the + stroke never reaches the final point. It also bypasses the SDK's 1000-point + batching. + +## The correct approach + +Mirror the demo: build the pen via `FountainShapes.createNeoPenV2(...)` and render via +`NeoPenRender(neoPen).render(canvas, paint, points)`. This runs the exact same code +path the firmware uses, so the redraw matches. + +Additional requirements: +- Pass a **FILL** paint (`Paint.Style.FILL`, `strokeWidth = 0`). Fountain V2 produces + closed/filled paths, not stroked ones. +- Feed pressures in `[0, 1]`; normalize on a **copy** of the points, never the + original list. +- `TouchPoint` here is `com.onyx.android.sdk.data.note.TouchPoint`, which is accepted + by `NeoPenRender.render` (the demo passes the same type) and has a copy constructor + `new TouchPoint(p)`. + +See `app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt` +for the implementation. + +## Inspecting the SDK yourself + +The SDK ships as `.aar` files (in `app/libs/` and the Gradle cache). To decompile: + +```bash +# extract classes.jar from the aar +unzip -o onyxsdk-penbrush-1.1.1.aar -d out +# decompile with CFR +java -jar cfr.jar out/classes.jar \ + --jarfilter 'com.onyx.android.sdk.pen.utils.FountainShapes' \ + --outputdir decompiled +# or list signatures +javap -p com/onyx/android/sdk/pen/NeoPenConfig.class +``` From f5ac87acd5d62c5606815fdc852be466d4ab38e4 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Mon, 29 Jun 2026 23:20:41 +0200 Subject: [PATCH 2/9] Align NeoFountainPenV2 rendering with Onyx firmware behavior ### Drawing & Core - **NeoFountainPenV2Wrapper**: - Replaced manual pen lifecycle and drawing calls with `NeoPenRender` to ensure strokes match the firmware's live rendering. - Switched to `FountainShapes.createNeoPenV2` for pen creation to include necessary width compensation, smoothing, and fast-mode configurations. - Updated `drawStroke` to use the SDK's full render pipeline, fixing an issue where the trailing "prediction" segment of a stroke was omitted. - Introduced `copyAndNormalizePressure` to prevent mutating the caller's `TouchPoint` data, avoiding cumulative pressure degradation on redraws. ### Documentation - **onyx-neo-fountain-pen-v2.md**: Added a detailed technical guide explaining the Onyx pen SDK rendering pipeline, decompiled insights, and the requirements for pixel-identical offline redraws. --- .../notable/editor/canvas/DrawCanvas.kt | 11 +- .../notable/editor/canvas/OnyxInputHandler.kt | 95 +++-- .../editor/drawing/NeoFountainPenV2Wrapper.kt | 11 +- .../ethran/notable/editor/utils/einkHelper.kt | 31 ++ .../notable/editor/utils/handleInput.kt | 20 +- docs/onyx-finger-scribble.md | 151 +++++++ docs/onyx-native-eraser-indicator.md | 204 +++++++++ docs/onyx-neo-fountain-pen-v2.md | 99 ++++- docs/onyx-pen-up-refresh-and-screen-freeze.md | 388 ++++++++++++++++++ 9 files changed, 964 insertions(+), 46 deletions(-) create mode 100644 docs/onyx-finger-scribble.md create mode 100644 docs/onyx-native-eraser-indicator.md create mode 100644 docs/onyx-pen-up-refresh-and-screen-freeze.md diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt b/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt index b26b89c7..a4d243a5 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt @@ -53,7 +53,14 @@ class DrawCanvas( parent?.requestDisallowInterceptTouchEvent(true) - if (!DeviceCompat.isOnyxDevice || inputHandler.isErasing) { + // NATIVE ERASER INDICATOR: + // On Onyx devices the eraser stroke is now rendered natively by the firmware + // (see einkHelper.setupSurface -> setEraserRawDrawingEnabled), so we no longer + // route erase touches into the OpenGL front-buffer renderer. Non-Onyx devices + // still use OpenGL as their only renderer. The original condition is kept + // (commented) as a reference. See docs/onyx-native-eraser-indicator.md. + // if (!DeviceCompat.isOnyxDevice || inputHandler.isErasing) { + if (!DeviceCompat.isOnyxDevice) { glRenderer.onTouchListener.onTouch(this, event) } @@ -96,7 +103,7 @@ class DrawCanvas( fun registerObservers() = observers.registerAll() fun init() { - log.i("Initializing Canvas") + log.i("the Initializing Canvas") glRenderer = OpenGLRenderer(this@DrawCanvas) glRenderer.attachSurfaceView(this) diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt b/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt index ba362764..9965b35b 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt @@ -17,6 +17,7 @@ import com.ethran.notable.editor.utils.Pen import com.ethran.notable.editor.utils.calculateBoundingBox import com.ethran.notable.editor.utils.copyInput import com.ethran.notable.editor.utils.copyInputToSimplePointF +import com.ethran.notable.editor.utils.enableNativeEraser import com.ethran.notable.editor.utils.getModifiedStrokeEndpoints import com.ethran.notable.editor.utils.handleDraw import com.ethran.notable.editor.utils.handleErase @@ -95,19 +96,42 @@ class OnyxInputHandler( // Handle button/eraser tip of the pen: override fun onBeginRawErasing(p0: Boolean, p1: TouchPoint?) { if (touchHelper == null) return - if (GlobalAppSettings.current.openGLRendering) { - prepareForPartialUpdate(drawCanvas, touchHelper!!) - log.d("Eraser Mode") - } + // NATIVE ERASER INDICATOR: + // The eraser stroke is rendered natively by the firmware, enabled via + // touchHelper.setEraserRawDrawingEnabled(true, ...). This is RE-ASSERTED here + // because setRawDrawingEnabled(true) (called by updateIsDrawing() on every + // resume) internally calls resetPenDefaultRawDrawing() -> + // setEraserRawDrawingEnabled(false, 5), which would otherwise leave the native + // eraser channel disabled and the indicator blank (verified by decompiling + // onyxsdk-pen TouchHelper). + // The native eraser channel draws using the helper's current stroke + // color/width/style, so we configure a visible indicator that MATCHES the + // active eraser type (marker for the pen eraser, dashed line for the lasso / + // select eraser). It is restored in onEndRawErasing. The indicator itself is + // transient: after pen-up, onRawErasingList() repaints from the bitmap (which + // has no indicator), so it disappears once the erase is committed. + // See docs/onyx-native-eraser-indicator.md. + enableNativeEraser(touchHelper) + applyEraserIndicatorStyle() + + // The OpenGL front-buffer workaround below is disabled, but kept (commented) + // as a reference for non-native erase rendering. + // if (GlobalAppSettings.current.openGLRendering) { + // prepareForPartialUpdate(drawCanvas, touchHelper!!) + // log.d("Eraser Mode") + // } isErasing = true } override fun onEndRawErasing(p0: Boolean, p1: TouchPoint?) { - if (GlobalAppSettings.current.openGLRendering) { - restoreDefaults(drawCanvas) - drawCanvas.glRenderer.clearPointBuffer() - } - drawCanvas.glRenderer.frontBufferRenderer?.cancel() + // NATIVE ERASER INDICATOR: restore the pen's stroke settings after erasing. + updatePenAndStroke() + // OpenGL workaround disabled (see onBeginRawErasing). + // if (GlobalAppSettings.current.openGLRendering) { + // restoreDefaults(drawCanvas) + // drawCanvas.glRenderer.clearPointBuffer() + // } + // drawCanvas.glRenderer.frontBufferRenderer?.cancel() } override fun onRawErasingTouchPointListReceived(plist: TouchPointList?) = @@ -136,32 +160,45 @@ class OnyxInputHandler( ?.setStrokeWidth(toolbarState.penSettings[toolbarState.pen.penName]!!.strokeSize * page.zoomLevel.value) ?.setStrokeColor(toolbarState.penSettings[toolbarState.pen.penName]!!.color) - Mode.Erase -> { - when (toolbarState.eraser) { - Eraser.PEN -> touchHelper!!.setStrokeStyle(penToStroke(Pen.MARKER)) - ?.setStrokeWidth(30f) - ?.setStrokeColor(Color.GRAY) - - Eraser.SELECT -> { - val dashStyleID = penToStroke(Pen.DASHED) - touchHelper!!.setStrokeStyle(dashStyleID) - ?.setStrokeWidth(3f) - ?.setStrokeColor(Color.BLACK) - val params = FloatArray(4) - params[0] = 5f // thickness - params[1] = 9f // no idea - params[2] = 9f // no idea - params[3] = 0f // no idea - Device.currentDevice().setStrokeParameters(dashStyleID, params) - } - } - } + Mode.Erase -> applyEraserIndicatorStyle(penEraserColor = Color.GRAY) Mode.Select -> touchHelper?.setStrokeStyle(penToStroke(Pen.BALLPEN))?.setStrokeWidth(3f) ?.setStrokeColor(Color.GRAY) } } + /** + * Configures the helper's stroke so the eraser feedback matches the active eraser type: + * a marker for the pen eraser, and a dashed line for the lasso / select eraser. Shared + * by the hand eraser (Mode.Erase in [updatePenAndStroke]) and the pen side-button + * eraser ([onBeginRawErasing], native indicator). + * + * @param penEraserColor colour for the [Eraser.PEN] marker. Hand-erase uses grey; the + * native button-erase indicator uses black (matches the user's preference and is more + * visible against ink). + */ + private fun applyEraserIndicatorStyle(penEraserColor: Int = Color.BLACK) { + if (touchHelper == null) return + when (toolbarState.eraser) { + Eraser.PEN -> touchHelper!!.setStrokeStyle(penToStroke(Pen.MARKER)) + ?.setStrokeWidth(30f) + ?.setStrokeColor(penEraserColor) + + Eraser.SELECT -> { + val dashStyleID = penToStroke(Pen.DASHED) + touchHelper!!.setStrokeStyle(dashStyleID) + ?.setStrokeWidth(3f) + ?.setStrokeColor(Color.BLACK) + val params = FloatArray(4) + params[0] = 5f // thickness + params[1] = 9f // no idea + params[2] = 9f // no idea + params[3] = 0f // no idea + Device.currentDevice().setStrokeParameters(dashStyleID, params) + } + } + } + suspend fun updateIsDrawing() { if(touchHelper == null) return log.i("Update is drawing: $toolbarState.isDrawing") diff --git a/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt b/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt index 2fbf9ee9..5cdd2501 100644 --- a/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt +++ b/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt @@ -34,6 +34,15 @@ object NeoFountainPenV2Wrapper { // FountainShapes.createNeoPenV2 so the config (width compensation, minWidth, // smoothLevel, pressureSensitivity, tilt=off, fastMode) matches what the // firmware uses while drawing live. Any deviation here makes the redraw differ. + // fastMode MUST be false for the offline redraw. With fastMode = true the pen + // returns PenPointResult (discrete point/dab stamps) — this is what the firmware + // uses for low-latency LIVE drawing, but when we re-draw the finished stroke onto + // our surface it renders "point by point" and looks faceted. fastMode = false + // returns PenPathResult, a continuous smooth vector path, which is what we want for + // a clean redraw (this is what the old hand-rolled wrapper got for free, since a + // bare NeoPenConfig defaults fastMode to false). The rest of the config still comes + // from createNeoPenV2 so the size/compensation matches the firmware. + // See docs/onyx-neo-fountain-pen-v2.md. val neoPen = FountainShapes.createNeoPenV2( strokeWidth, // width NeoFountainPenWrapper.MIN_FOUNTAIN_PEN_WIDTH, // minWidth @@ -42,7 +51,7 @@ object NeoFountainPenV2Wrapper { 1.0f, // scalePrecision 1.0f, // createScale null, // pressureSensitivity -> default 0.3 - true, // fastMode + false, // fastMode -> false = smooth PenPathResult null, // smoothLevel -> default 0.6 ) if (neoPen == null) { diff --git a/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt b/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt index 3e51ec59..f6e44221 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt @@ -271,10 +271,41 @@ fun setupSurface(view: View, touchHelper: TouchHelper?, toolbarHeight: Int) { .openRawDrawing() touchHelper.setRawDrawingEnabled(true) + + // NATIVE ERASER INDICATOR (pen side-button erasing): + // Ask the firmware to render the eraser path itself while erasing with the pen + // button, the same way a normal stroke is rendered. This replaces the OpenGL + // front-buffer "indicator" workaround (see OnyxInputHandler.onBeginRawErasing and + // DrawCanvas.dispatchTouchEvent, both kept commented as a non-native reference). + // Signature: setEraserRawDrawingEnabled(drawing: Boolean, eraserStyle: Int), + // eraserStyle uses TouchHelper.STROKE_STYLE_* (firmware default is (false, 5=DASH)). + // + // IMPORTANT (verified by decompiling onyxsdk-pen TouchHelper): setRawDrawingEnabled(true) + // internally calls resetPenDefaultRawDrawing() which calls + // setEraserRawDrawingEnabled(false, 5) -- so this MUST be called AFTER + // setRawDrawingEnabled(true), otherwise it is immediately reset to disabled. + // It is also re-asserted in OnyxInputHandler.onBeginRawErasing because + // updateIsDrawing() calls setRawDrawingEnabled(true) on every resume, which would + // otherwise wipe it again. See docs/onyx-native-eraser-indicator.md. + enableNativeEraser(touchHelper) log.i("Setup editable surface completed") } +/** + * Enables the firmware's native eraser-stroke rendering for pen side-button erasing. + * MUST be called after every setRawDrawingEnabled(true) (which resets it to disabled). + * Wrapped in try/catch because the Onyx SDK is unstable across devices/firmware. + */ +fun enableNativeEraser(touchHelper: TouchHelper?) { + if (touchHelper == null) return + try { + touchHelper.setEraserRawDrawingEnabled(true, TouchHelper.STROKE_STYLE_MARKER) + } catch (t: Throwable) { + log.w("setEraserRawDrawingEnabled not supported on this device: ${t.message}") + } +} + fun prepareForPartialUpdate(view: View, touchHelper: TouchHelper?) { if(touchHelper == null) return EpdController.setDisplayScheme(SCHEME_SCRIBBLE) diff --git a/app/src/main/java/com/ethran/notable/editor/utils/handleInput.kt b/app/src/main/java/com/ethran/notable/editor/utils/handleInput.kt index 4be84445..ead9c804 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/handleInput.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/handleInput.kt @@ -6,11 +6,25 @@ import com.ethran.notable.data.model.SimplePointF import com.onyx.android.sdk.data.note.TouchPoint +// Max delta time (ms) that fits in the uint16 dt channel. 0xFFFF (65535) is reserved as a +// null sentinel by the SB1 stroke encoder, so the largest storable delta is 65534 ms +// (~65 s). Strokes longer than that clamp their tail to this value (acceptable: such +// durations only occur for pathological strokes). Keep in sync with +// StrokePointConverter.DT_MAX_VALUE_INT. +private const val DT_MAX_VALUE_MS = 65534L + fun copyInput(touchPoints: List, scroll: Offset, scale: Float): List { - val points = touchPoints.map { - it.toStrokePoint(scroll, scale) + if (touchPoints.isEmpty()) return emptyList() + // Capture per-point delta time (ms relative to the first point) into StrokePoint.dt so + // it can be persisted. The firmware stamps each TouchPoint with an absolute timestamp; + // we store only the delta, which is far cheaper (uint16 vs a per-point absolute long) + // and is all that velocity-dependent renderers need. See StrokePoint.dt and the SB1 + // DT channel in StrokePointConverter. + val baseTime = touchPoints.first().timestamp + return touchPoints.map { + val deltaMs = (it.timestamp - baseTime).coerceIn(0L, DT_MAX_VALUE_MS) + it.toStrokePoint(scroll, scale).copy(dt = deltaMs.toUShort()) } - return points } diff --git a/docs/onyx-finger-scribble.md b/docs/onyx-finger-scribble.md new file mode 100644 index 00000000..6ceeacf1 --- /dev/null +++ b/docs/onyx-finger-scribble.md @@ -0,0 +1,151 @@ +# Onyx "Open Finger Scribble" — drawing with a finger + +This documents how the Onyx demo's **"Open finger scribble"** toggle works, what it +does at the SDK level, and the two *different* finger/touch mechanisms that are easy to +confuse. Grounded in the official `OnyxAndroidDemo` +(`ScribbleFingerTouchDemoActivity`) and the decompiled `onyxsdk-pen` SDK. + +**Source authority** (same tags as the other docs): +- **[demo]** — verified against `OnyxAndroidDemo` source. +- **[sdk]** — verified by decompiling the Onyx `.aar`s. + +--- + +## 1. What "Open finger scribble" is + +By default, Onyx raw-drawing mode (`TouchHelper` + `setRawDrawingEnabled(true)`) only +reacts to the **stylus**. Capacitive **finger** touches are ignored for drawing — they +fall through to the normal Android view system (scroll, buttons, etc.). This is what you +want 99% of the time: you rest your palm on the screen while writing and it doesn't +leave ink. + +**"Open finger scribble"** is the checkbox (`cb_enable_finger`, label literally +"Open finger scribble") that flips this: it makes the **finger also produce raw-drawing +strokes**, so you can scribble with a fingertip, not just the pen. [demo] + +```java +// ScribbleFingerTouchDemoActivity.enableFingerTouch(...) +public void enableFingerTouch(View view, boolean checked) { + if (touchHelper == null) return; + touchHelper.setRawDrawingEnabled(false); + touchHelper.setRawDrawingEnabled(true); // re-arm raw drawing around the change + touchHelper.enableFingerTouch(checked); // <-- the actual feature +} +``` + +The `setRawDrawingEnabled(false)`/`(true)` bracket is just to re-arm the raw-drawing +pipeline cleanly so the flag change takes effect for the next stroke. + +--- + +## 2. How it works inside the SDK [sdk] + +`TouchHelper.enableFingerTouch(enable)` fans the flag out to every `TouchRender`, which +forwards it to the native input reader (`AppTouchInputReader.setEnableFingerTouch`). All +the interesting logic is one filter method in `AppTouchInputReader` that decides whether +to **ignore** an incoming `MotionEvent`: + +```java +// decompiled & de-obfuscated; g = enableFingerTouch, h = onlyEnableFingerTouch +private boolean shouldSkip(MotionEvent event) { + if (g) { // finger drawing ENABLED + return h && TouchUtils.isPenTouchType(event); + // h == false -> skip nothing -> BOTH pen and finger draw + // h == true -> skip the pen -> ONLY finger draws + } + return !TouchUtils.isPenTouchType(event); // finger drawing DISABLED (default): + // skip everything that isn't the pen +} +``` + +`TouchUtils.isPenTouchType(event)` classifies the event by its `MotionEvent` tool type +(stylus/eraser vs finger). So the three reachable states are: + +| State | API call | Filter result | Who can draw | +|---|---|---|---| +| **Default** | (none) — `g=false` | skip non-pen | **stylus only** | +| **Finger enabled** | `enableFingerTouch(true)` → `g=true, h=false` | skip nothing | **stylus + finger** | +| **Finger only** | `onlyEnableFingerTouch(true)` → `h=true` | skip pen | **finger only** | + +So "Open finger scribble" = move from row 1 to row 2: the capacitive finger now feeds +the same raw-drawing path the stylus uses, producing identical `onRawDrawing*` callbacks +and identical ink. + +### Finger has no pressure +A capacitive finger reports no real pen pressure. The SDK exposes: +- `enableFingerTouchPressure(boolean)` / `setFingerTouchPressure(float)` [sdk] + +to assign a **synthetic constant pressure** to finger strokes, so pressure-sensitive pen +styles (fountain, brush) still render a sensible width when drawn with a finger. + +--- + +## 3. The OTHER finger mechanism — palm/finger rejection (don't confuse these) + +The same demo *also* uses a completely separate, lower-level touch API, and the two are +easy to mix up: + +```java +// ScribbleFingerTouchDemoActivity callback +onBeginRawDrawing(...) -> TouchUtils.disableFingerTouch(getApplicationContext()); +onEndRawDrawing(...) -> TouchUtils.enableFingerTouch(getApplicationContext()); +``` + +`TouchUtils` here is a demo helper that calls the **EPD controller**, not `TouchHelper`: + +```java +// disable: block capacitive touch over a screen region (the whole screen here) +EpdController.setAppCTPDisableRegion(context, new Rect[]{ fullScreenRect }); +// enable: clear the block +EpdController.appResetCTPDisableRegion(context); +``` + +CTP = **C**apacitive **T**ouch **P**anel. This is **palm rejection at the hardware +panel level**: while a pen stroke is in progress, it tells the panel to drop *all* +finger/palm touches in the region so your resting hand can't generate spurious input or +fight the stylus. It is re-enabled the instant the stroke ends. + +### The two are different layers, used together + +| | `TouchHelper.enableFingerTouch` | `EpdController.setAppCTPDisableRegion` | +|---|---|---| +| Layer | raw-drawing input reader (`TouchHelper`) | EPD / capacitive panel driver | +| Question it answers | "Should a finger event become **ink**?" | "Should the panel deliver finger touches **at all** right now?" | +| Scope | the drawing surface | a screen region (here, full screen) | +| Lifetime | a persistent mode (the checkbox) | toggled per-stroke (begin/end raw drawing) | +| Purpose | let the user *draw* with a finger | *reject the palm* during a pen stroke | + +They cooperate: even with finger-scribble **on**, the demo still disables the CTP region +during an active *pen* stroke (`onBeginRawDrawing`), so a palm landing mid-pen-stroke +doesn't inject a competing finger stroke; finger input resumes at `onEndRawDrawing`. + +--- + +## 4. The `create(view, stylus, callback)` overload [sdk] + +The finger demo creates its helper with the 3-arg overload: + +```java +touchHelper = TouchHelper.create(getHostView(), false, callback); +``` + +The boolean maps to an internal *feature* code: `stylus ? 2 : 1` +(`create(view, boolean, cb)` → `create(view, stylus?2:1, cb)`). Passing `false` selects +feature `1`. This is the registration-time feature flag; the runtime per-stroke +finger/pen behaviour is still governed by `enableFingerTouch` / `onlyEnableFingerTouch` +as described above. + +--- + +## 5. Practical notes for Notable + +- If Notable ever wants a "draw with finger" option, the lever is + `touchHelper.enableFingerTouch(true)` (optionally `onlyEnableFingerTouch` for a + finger-only mode), plus `enableFingerTouchPressure`/`setFingerTouchPressure` so + pressure pens look right. Re-arm raw drawing around the change as the demo does. +- Keep finger-scribble and palm-rejection conceptually separate. Even with finger + drawing enabled, you almost always still want to disable the CTP region during a pen + stroke (begin/end raw drawing) for palm rejection — otherwise resting your hand while + using the pen will draw with your palm. +- Default behaviour (no calls) is already "stylus only," which is the right default for a + note app; finger-scribble is an opt-in. diff --git a/docs/onyx-native-eraser-indicator.md b/docs/onyx-native-eraser-indicator.md new file mode 100644 index 00000000..62f9ac2c --- /dev/null +++ b/docs/onyx-native-eraser-indicator.md @@ -0,0 +1,204 @@ +# Native eraser indicator (pen side-button erasing) + +How to make the **pen side-button eraser** render a visible stroke ("erasing +indicator") using the firmware's **native** rendering, instead of Notable's OpenGL +front-buffer workaround. Grounded in the decompiled `onyxsdk-pen` / `onyxsdk-device` +SDK and Notable's own code. + +**Source authority:** **[sdk]** = decompiled Onyx `.aar`; **[notable]** = Notable code. + +--- + +## 1. The problem + +Notable supports two ways to erase: + +- **Hand erase** — the user picks the eraser tool in the toolbar (`Mode.Erase`). The + pen *tip* is then interpreted as an eraser. This goes through the normal + raw-**drawing** path (`onRawDrawingTouchPointListReceived` → `onRawDrawingList`, branch + `Mode.Erase`), and the firmware draws a normal stroke as feedback (Notable configures a + grey `MARKER` for this in `updatePenAndStroke`). So hand-erase already has a native + indicator. [notable] +- **Pen side-button erase** — the user holds the stylus button. The firmware fires the + raw-**erasing** callbacks (`onBeginRawErasing` → `onRawErasingTouchPointListReceived` + → `onEndRawErasing`). **By default the firmware draws nothing during button erasing.** + +To give button-erase a visible indicator, Notable previously used an **OpenGL +front-buffer renderer** (`OpenGLRenderer` / `GLFrontBufferedRenderer`): while +`isErasing`, `DrawCanvas.dispatchTouchEvent` routed stylus events into the GL renderer, +which painted the indicator itself. This is the "nasty workaround that does not use +native rendering." It works, but it duplicates rendering the firmware can do natively. + +The official (closed-source) Onyx note app instead makes the side-button produce the +same kind of native stroke as a normal pen action — a clean, low-latency erasing +indicator drawn by the firmware. + +--- + +## 2. The native API [sdk] + +`com.onyx.android.sdk.pen.TouchHelper`: + +```java +public TouchHelper setEraserRawDrawingEnabled(boolean drawing, int eraserStyle) +``` + +- `drawing` — when `true`, the firmware **renders the eraser path natively** while you + erase with the side button (just like a normal stroke). When `false`, button-erasing + draws nothing (the default). +- `eraserStyle` — which stroke style to draw the eraser path with; uses the + `TouchHelper.STROKE_STYLE_*` constants: + + | Constant | Value | + |---|---| + | `STROKE_STYLE_PENCIL` | 0 | + | `STROKE_STYLE_FOUNTAIN` | 1 | + | `STROKE_STYLE_MARKER` | 2 | + | `STROKE_STYLE_NEO_BRUSH` | 3 | + | `STROKE_STYLE_CHARCOAL` | 4 | + | `STROKE_STYLE_DASH` | 5 | + | `STROKE_STYLE_CHARCOAL_V2` | 6 | + | `STROKE_STYLE_SQUARE_PEN` | 7 | + +`TouchHelper` fans the call out to each `TouchRender`, which forwards it to the device +layer (`Device.setEraserRawDrawingEnabled`), which reflects into the system EPD +controller. The SDK's own `resetPenDefaultRawDrawing()` calls +`Device.currentDevice().setEraserRawDrawingEnabled(false, 5)` — confirming the default is +**disabled, DASH style**. [sdk] + +### THE BIG GOTCHA: `setRawDrawingEnabled(true)` resets it [sdk] + +The hardest bug to spot. Decompiling `TouchHelper` shows: + +```java +public TouchHelper setRawDrawingEnabled(boolean enabled) { + ... + setRawDrawingRenderEnabled(enabled); + setRawInputReaderEnable(enabled); + resetPenDefaultRawDrawing(); // <-- this + return this; +} + +public void resetPenDefaultRawDrawing() { + Device.currentDevice().setBrushRawDrawingEnabled(true); + Device.currentDevice().setEraserRawDrawingEnabled(false, 5); // <-- DISABLES it +} +``` + +So **every** `setRawDrawingEnabled(true)` call silently turns the native eraser channel +back **off** (style 5 = DASH). This bit Notable twice: + +1. `setupSurface` originally enabled the eraser *before* its final + `setRawDrawingEnabled(true)` → instantly reset to disabled. +2. `updateIsDrawing()` calls `setRawDrawingEnabled(true)` on every drawing resume → wipes + it again even if (1) were fixed. + +**Fix (two parts):** +- In `setupSurface`, call `setEraserRawDrawingEnabled(true, …)` **after** + `setRawDrawingEnabled(true)`. +- **Re-assert** it in `onBeginRawErasing` (via the shared `enableNativeEraser(touchHelper)` + helper) so it survives later `updateIsDrawing()` resets. This is the call that actually + makes button-erase render on a running session. + +### The eraser channel uses the helper's stroke color/width [sdk] + +Second gotcha: even once enabled, `setEraserRawDrawingEnabled(true, …)` renders +**nothing visible** unless a colour/width is set — the native eraser channel draws using +the `TouchHelper`'s **current `setStrokeColor` / `setStrokeWidth`** state (the +`eraserStyle` arg only selects the *style*, not the colour). If those aren't set to +something visible when erasing begins, you get a blank screen. + +The fix mirrors Notable's already-working **hand-erase** recipe (which renders a grey +`MARKER` at width 30): configure a visible stroke in `onBeginRawErasing`, and restore the +pen's settings in `onEndRawErasing`. Per request, the indicator colour is **black**: + +```kotlin +// onBeginRawErasing +touchHelper!!.setStrokeStyle(penToStroke(Pen.MARKER)) + ?.setStrokeWidth(30f) + ?.setStrokeColor(Color.BLACK) +// onEndRawErasing +updatePenAndStroke() // restore pen style/width/color +``` + +The indicator is **transient**: after pen-up, `onRawErasingList()` repaints the affected +region from the page bitmap (which contains no indicator), so the black track disappears +as soon as the erase is committed — it only exists as live feedback during the gesture. + +> Note: the official `OnyxAndroidDemo` never calls `setEraserRawDrawingEnabled`; its +> erase "track" is app-drawn (`EraseRenderer.drawEraseCircle`). So this native path is +> firmware-dependent — hence the try/catch and the preserved OpenGL fallback. [demo] + +--- + +## 3. What was changed in Notable + +The native path is now enabled, and the OpenGL workaround is **commented out (not +deleted)** so it remains as a reference implementation of non-native erase rendering. + +### 3.1 Enable native rendering — `editor/utils/einkHelper.kt` (`setupSurface`) + +After (not before — see "THE BIG GOTCHA") the final `setRawDrawingEnabled(true)`: + +```kotlin +touchHelper.setRawDrawingEnabled(true) +enableNativeEraser(touchHelper) // shared helper, see below +``` + +```kotlin +fun enableNativeEraser(touchHelper: TouchHelper?) { + if (touchHelper == null) return + try { + touchHelper.setEraserRawDrawingEnabled(true, TouchHelper.STROKE_STYLE_MARKER) + } catch (t: Throwable) { + log.w("setEraserRawDrawingEnabled not supported on this device: ${t.message}") + } +} +``` + +Wrapped in try/catch because the Onyx SDK is unstable across devices/firmware (same +reasoning as `tryToSetRefreshMode`). The same `enableNativeEraser` helper is also called +from `onBeginRawErasing` to re-assert the flag after `updateIsDrawing()` resets it. + +### 3.2 Disable the OpenGL workaround (kept commented) + +- `editor/canvas/OnyxInputHandler.kt` — in `onBeginRawErasing`, the indicator is + configured by the shared `applyEraserIndicatorStyle()` helper so it **matches the active + eraser type**: `Eraser.PEN` → black `MARKER` width 30; `Eraser.SELECT` (lasso) → dashed + `BLACK` line (`Pen.DASHED`, width 3, via `Device.setStrokeParameters`). The same helper + styles the hand eraser in `updatePenAndStroke` (Mode.Erase), the only difference being + the pen-eraser colour (grey for hand-erase, black for the button indicator). This sets a + visible stroke so the native eraser channel actually renders (see "The eraser channel + uses the helper's stroke color/width" above); `onEndRawErasing` restores the pen via + `updatePenAndStroke()`. The + `GlobalAppSettings.current.openGLRendering` blocks and `glRenderer` calls are commented + out. `isErasing` is still set so the rest of the erase logic is unchanged. +- `editor/canvas/DrawCanvas.kt` — in `dispatchTouchEvent`, the routing condition changed + from `if (!DeviceCompat.isOnyxDevice || inputHandler.isErasing)` to + `if (!DeviceCompat.isOnyxDevice)`. **Non-Onyx devices still use OpenGL as their only + renderer**; only the Onyx erase-routing into OpenGL was removed. Original line kept + commented above. + +Each edit is tagged with a `// NATIVE ERASER INDICATOR:` comment pointing back here. + +--- + +## 4. Tuning / reverting + +- **Change the indicator look:** swap the `eraserStyle` argument (e.g. `STROKE_STYLE_DASH` + for a dashed track, `STROKE_STYLE_PENCIL` for a thin line). Stroke width/colour follow + the helper's current `setStrokeWidth` / `setStrokeColor`. +- **Revert to the OpenGL workaround:** uncomment the blocks in `onBeginRawErasing`, + `onEndRawErasing`, and the original condition in `DrawCanvas.dispatchTouchEvent`, and + set `setEraserRawDrawingEnabled(false, …)` (or remove the call). +- **Keep both as a setting:** the cleanest long-term option is to branch on + `GlobalAppSettings.current.openGLRendering` — native when off, OpenGL demo when on — + so the reference path stays exercised. + +--- + +## 5. Related + +- `docs/onyx-pen-up-refresh-and-screen-freeze.md` — raw-drawing/freeze model and the + `setRawDrawingRenderEnabled` toggle the erase path also uses. +- `docs/onyx-finger-scribble.md` — another `TouchHelper` input-routing feature. diff --git a/docs/onyx-neo-fountain-pen-v2.md b/docs/onyx-neo-fountain-pen-v2.md index 7571a339..24e7ad5e 100644 --- a/docs/onyx-neo-fountain-pen-v2.md +++ b/docs/onyx-neo-fountain-pen-v2.md @@ -17,9 +17,11 @@ match. ## Relevant SDK artifacts -- `onyxsdk-pen` (1.5.4) — contains `NeoPenRender`, `NeoFountainPenWrapper`. -- `onyxsdk-penbrush` (1.1.1) — contains `NeoFountainPenV2`, `NeoPenConfig`, - `PenPathResult`, `PenResult`, `FountainShapes`, `NeoPen`. +- `onyxsdk-pen` (1.5.4, Maven) — contains `NeoPenRender`, `NeoFountainPenWrapper`. + Note these live in the **pen** jar, not penbrush. The jar resolves to the Gradle + transform cache (`~/.gradle/caches/.../onyxsdk-pen-1.5.4/jars/classes.jar`). +- `onyxsdk-penbrush` (1.1.0.1, local `app/libs/*.aar`) — contains `NeoFountainPenV2`, + `NeoPenConfig`, `PenPathResult`, `PenResult`, `FountainShapes`, `NeoPen`. The official demo (`OnyxAndroidDemo`) renders the fountain V2 pen in `app/OnyxPenDemo/.../shape/BrushScribbleShape.java`. That class is the reference @@ -99,8 +101,9 @@ caused a visible mismatch: - `minWidth`, `smoothLevel = 0.6`, `pressureSensitivity = 0.3` → native defaults used instead; `smoothLevel` in particular reshapes the path geometry. - `tiltEnabled` — fountain V2 uses **false**. Setting it `true` changes the outline. - - `fastMode` — demo uses **true**; the default `false` yields a different result - type/geometry. + - `fastMode` — see the dedicated section below. For an **offline redraw use `false`** + (smooth `PenPathResult`); `true` gives discrete `PenPointResult` dabs that look + "point by point". 2. **Double pressure normalization.** Pre-dividing points by `maxTouchPressure` *and* calling `setMaxTouchPressure(...)` on the config makes the native code @@ -117,6 +120,79 @@ caused a visible mismatch: stroke never reaches the final point. It also bypasses the SDK's 1000-point batching. +## The "point by point" bug: `fastMode` and result type (most important) + +This is the single biggest cause of the offline redraw not matching the firmware, and it is +**independent** of timestamps/config sizing. + +`NeoFountainPenV2.buildPenResult` returns a different `PenResult` subtype depending on +`config.fastMode`: + +| `fastMode` | result type | what `.draw()` paints | use | +|---|---|---|---| +| `true` | `PenPointResult` | discrete point/dab stamps | firmware **live** low-latency drawing | +| `false` | `PenPathResult` | continuous smooth filled vector path | **offline redraw** | + +The demo's `BrushScribbleShape` passes `fastMode = true` — but that class is for the **brush** +pen, and the demo has **no fountain-V2 shape** at all. Mirroring it for fountain brought the +wrong mode along: the redraw rendered as a string of dabs ("drawn point by point", faceted), +even though the config sizing was correct. + +The old hand-rolled wrapper +(`NeoFountainPenV2.create(NeoPenConfig().apply { setWidth(..); setTiltEnabled(true); setMaxTouchPressure(..) })`) +looked smooth precisely because a bare `NeoPenConfig` **defaults `fastMode` to `false`**, so it +got `PenPathResult`. Its only real defect was sizing (no `+3px` compensation, no `minWidth`, +native default `smoothLevel`/`pressureSensitivity`). + +**Fix:** keep `FountainShapes.createNeoPenV2(...)` for correct sizing, but pass +**`fastMode = false`**. `NeoPenRender` renders either subtype (`renderResult` just calls +`penResult.draw()`), so the render path is unchanged — you simply get the smooth path. + +## The faceted-curve bug: missing per-point timestamps + +Even with the wrapper above (config + render path identical to the demo), the offline +redraw can still look **faceted / segment-by-segment on tight, fast curves** while the +firmware's live stroke is smooth. The cause is **not** in the render pipeline — it is in +the input points. + +- `NeoFountainPenV2` is a `NeoNativePen`; its smoothing/curve-subdivision happens in + native code and uses the **inter-point velocity**, which it derives from each + `TouchPoint`'s **timestamp**. +- The demo feeds the firmware's captured `touchPointList`, whose points carry **real, + increasing per-point timestamps**. +- Notable persists strokes as `StrokePoint`, which has **no timestamp field**. When + rebuilding `TouchPoint`s (`strokeToTouchPoints`), every point was stamped with the same + `stroke.updatedAt.time`. Identical timestamps ⇒ velocity ≈ 0 everywhere ⇒ the native + smoother stops subdividing and connects raw samples with straight segments. This is + worst exactly where the user notices it: tight, fast curves (few raw samples, high real + velocity). + +**Fix (data first):** persist the real per-point timing so the original velocity profile +is available, instead of synthesizing a fake cadence at render time. + +`StrokePoint` already has a `dt: UShort?` field ("delta time in ms, from the first point in +the stroke") and the SB1 binary format already has a DT channel (uint16) — it was just +never populated. `copyInput` now fills it from the firmware `TouchPoint.timestamp`: + +```kotlin +val baseTime = touchPoints.first().timestamp +touchPoints.map { + val deltaMs = (it.timestamp - baseTime).coerceIn(0L, 65534L) // 0xFFFF reserved + it.toStrokePoint(scroll, scale).copy(dt = deltaMs.toUShort()) +} +``` + +Storing only the **delta** (uint16, 2 bytes) rather than an absolute timestamp (long, +8 bytes) is the cheap, lossless-enough representation; it is then LZ4-compressed with the +rest of the stroke body. No DB migration is needed: `points` is a binary blob and the SB1 +v1 format already defined the DT channel, so old strokes (dt absent) and new strokes (dt +present) coexist. + +**Deferred:** actually *consuming* `dt` in `strokeToTouchPoints` (reconstructing +`timestamp = stroke.updatedAt.time + dt`) to feed the native smoother. This is the step +that fixes the faceting on screen, but it is intentionally not wired up yet — the data is +captured first so it exists for strokes drawn from now on. + ## The correct approach Mirror the demo: build the pen via `FountainShapes.createNeoPenV2(...)` and render via @@ -140,12 +216,13 @@ for the implementation. The SDK ships as `.aar` files (in `app/libs/` and the Gradle cache). To decompile: ```bash -# extract classes.jar from the aar -unzip -o onyxsdk-penbrush-1.1.1.aar -d out -# decompile with CFR -java -jar cfr.jar out/classes.jar \ - --jarfilter 'com.onyx.android.sdk.pen.utils.FountainShapes' \ - --outputdir decompiled +# penbrush classes (NeoFountainPenV2, FountainShapes, NeoPenConfig) — local aar: +unzip -o app/libs/onyxsdk-penbrush-1.1.0.1.aar -d out +java -jar cfr.jar out/classes.jar --outputdir decompiled + +# pen classes (NeoPenRender, NeoFountainPenWrapper) — Maven, in the Gradle cache: +J=$(find ~/.gradle/caches -path '*onyxsdk-pen-1.5.4/jars/classes.jar' | head -1) +java -jar cfr.jar "$J" --outputdir decompiled-pen # or list signatures javap -p com/onyx/android/sdk/pen/NeoPenConfig.class ``` diff --git a/docs/onyx-pen-up-refresh-and-screen-freeze.md b/docs/onyx-pen-up-refresh-and-screen-freeze.md new file mode 100644 index 00000000..04aaa807 --- /dev/null +++ b/docs/onyx-pen-up-refresh-and-screen-freeze.md @@ -0,0 +1,388 @@ +# Onyx "Pen Up Refresh", Screen Freeze, and Refresh-Timing Quirks + +This document explains the Onyx "pen up refresh" feature, the screen-freeze / +raw-drawing model it lives inside, recommended timings, the special considerations +when erasing, and a catalogue of the weird timing quirks discovered in the SDK and in +Notable's own code. + +**Source authority.** Facts are tagged so guess-work isn't mistaken for ground truth: +- **[demo]** — verified against the official `OnyxAndroidDemo` source + (`ScribblePenUpRefreshDemoActivity`, `ScribbleMoveEraserDemoActivity`, + `RefreshScreenAction`, `ResumeRawDrawingRequest`, `PartialRefreshRequest`). +- **[sdk]** — verified by decompiling the Onyx `.aar`s (constants, signatures). +- **[notable]** — taken from Notable's own code, which is **reverse-engineered + guess-work** and may be wrong. Where Notable disagrees with the demo/SDK, the + demo/SDK wins. + +Note: the official prose docs in `OnyxAndroidDemo/doc/*.md` do **not** mention pen-up +refresh at all — that feature lives only in the demo *source* and SDK constants. + +--- + +## 1. The drawing model (why any of this exists) + +On an Onyx e-ink device, handwriting uses a two-layer trick: + +1. **Live firmware layer.** While the pen moves, the firmware draws the stroke + directly onto the EPD at very low latency. The host app's `SurfaceView` is + effectively "frozen" — the firmware is painting over it. This is "raw drawing + mode" (`TouchHelper.setRawDrawingEnabled(true)`). +2. **Host bitmap layer.** When a stroke completes, the app receives the points + (`onRawDrawingTouchPointListReceived`) and must redraw that stroke onto its **own** + bitmap/surface (see `onyx-neo-fountain-pen-v2.md` for how to make that redraw match + the firmware). Then it "unfreezes" so the host surface becomes authoritative again. + +The frozen firmware ink and the host's bitmap must agree. The whole class of bugs in +this document comes from the two layers getting **out of sync in time**: the host +unfreezes/refreshes before its bitmap has been updated, or the firmware's internal +buffer hasn't caught up to what the host just drew. + +Key terms in Notable: +- `resetScreenFreeze()` — toggles `touchHelper.isRawDrawingRenderEnabled` false→true. + This is the "unfreeze": it tells the firmware to stop owning the screen so the host + surface shows through. +- `drawCanvasToView()` — blits the host bitmap onto the `SurfaceView`. +- `refreshUi()` — does `drawCanvasToView()` then `resetScreenFreeze()`. + +--- + +## 2. What "Pen Up Refresh" is + +When `setRawDrawingEnabled(true)`, the firmware owns the screen and paints fast, +low-quality ink (A2-ish waveform) for minimal latency. That fast ink **ghosts** — it +leaves residue and looks rough. "Pen up refresh" is the firmware's built-in cleanup: +a short time **after the pen lifts**, the firmware fires a partial high-quality refresh +of the rectangle that was just drawn, replacing the rough low-latency ink with clean +ink. + +### Why this feature exists (the purpose) + +It exists to resolve a hard, unavoidable trade-off specific to e-ink. An EPD can update +a region either **fast or well, never both**: + +- **While the pen is moving, latency is everything.** If ink doesn't appear under the + nib within ~10–20 ms it feels broken. The only way to hit that is a *fast waveform* + (A2/DU-class): 1-bit black/white, no grayscale, no anti-aliasing, and it **leaves + ghosting/residue** because it doesn't fully settle the e-ink particles. +- **A clean stroke needs a slow waveform.** Grayscale edges, anti-aliasing and + ghost-free pixels require a GC/REGAL-class update that takes ~150–300 ms — far too slow + to run *while* writing. + +So the device deliberately writes ugly-but-instant ink live, and then needs a *second +pass* to upgrade that region to clean ink once it's allowed to be slow. **Pen up refresh +is that automatic second pass.** Its whole reason to exist is to answer two questions the +app would otherwise have to answer by hand: + +1. **"When is it safe to do the slow, pretty refresh?"** — i.e. when has the user + actually paused? Doing the slow refresh mid-stroke would stutter and fight the live + ink. The firmware answers this with the **pen-up timer**: only refresh after the pen + has been lifted for *N* ms (so a burst of quick strokes is coalesced into one cleanup, + not one flicker per stroke). +2. **"Which area needs cleaning?"** — it hands you the dirty `RectF` so only the written + region is refreshed, not the whole screen. + +Put differently: **the purpose is to get low-latency writing AND clean final ink without +the app having to detect end-of-writing, debounce it, track the dirty region, and +schedule a high-quality partial update itself.** It's the firmware automating the +"settle the ghosting once the user stops" step that every serious e-ink note app needs. + +Why it's *optional* (a switch): an app may already do its own end-of-stroke refresh +(this is exactly Notable's case — see §2 "Notable status"), in which case the built-in +pass would be redundant or even conflict with the app's own refresh timing. So the SDK +lets you turn it off and take over, or turn it on and let the firmware handle it. + +### API + +On `TouchHelper`: +- `setPenUpRefreshEnabled(boolean)` — turn the feature on/off. +- `setPenUpRefreshTimeMs(int)` — how long after pen-up before the refresh fires. + +Callback on `RawInputCallback`: +- `onPenUpRefresh(RectF refreshRect)` — fired when the timer elapses. The app is + expected to repaint `refreshRect` from its own bitmap in a high-quality update mode. + +### The demo's handler (reference implementation) + +In `ScribblePenUpRefreshDemoActivity`: + +```java +@Override +public void onPenUpRefresh(RectF refreshRect) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return; + getRxManager().enqueue( + new PartialRefreshRequest(this, surfaceview1, refreshRect).setBitmap(bitmap), + ...); +} +``` + +`PartialRefreshRequest` does the canonical partial-refresh dance: + +```java +EpdController.setViewDefaultUpdateMode(surfaceView, UpdateMode.HAND_WRITING_REPAINT_MODE); +Canvas canvas = surfaceView.getHolder().lockCanvas(renderRect); +canvas.clipRect(renderRect); +renderBackground(canvas, viewRect); +canvas.drawBitmap(bitmap, rect, rect, null); // host bitmap -> surface +surfaceView.getHolder().unlockCanvasAndPost(canvas); +EpdController.resetViewUpdateMode(surfaceView); +``` + +i.e. set a high-quality handwriting update mode, blit the host bitmap into the dirty +rect, post, then reset the update mode. + +> **Notable status:** `OnyxInputHandler.onPenUpRefresh()` currently just calls +> `super.onPenUpRefresh()` (a no-op). Notable instead drives refresh itself from +> `onRawDrawingTouchPointListReceived` via `refreshUi()` / `partialRefreshRegionOnce()`. +> The pen-up-refresh callback is an alternative hook we are not using yet. + +### 2a. Why the demo has *two* "Pen Up Refresh" controls — [demo] + +This is a common point of confusion: the demo shows a button labelled **"Scribble Pen +up Refresh"** in one place and a toggle labelled **"Pen Up Refresh"** in another. They +are **not two different features** — they are the *same* SDK feature +(`setPenUpRefreshEnabled` / `onPenUpRefresh`) surfaced in two separate demo screens. + +The `OnyxPenDemo` module actually contains two parallel demo "apps": + +1. **The Scribble demo family** — a plain `TouchHelper`-based set of screens. Its menu + (`ScribbleDemoActivity`, layout `activity_sribble_demo.xml`) lists many demos, one + button per feature. The button **"Scribble Pen up Refresh"** is just a **navigation + entry**: + + ```java + public void button_pen_up_refresh(View view) { + go(ScribblePenUpRefreshDemoActivity.class); // it only opens the dedicated screen + } + ``` + + It toggles nothing. Its subtitle is `desc_pen_up_refresh` ("Triggers a screen refresh + when the pen is lifted. Includes adjustable delay settings to balance performance and + ghosting."). The "Scribble" prefix just means "this belongs to the scribble-demo + family." The screen it opens (`ScribblePenUpRefreshDemoActivity`) then has its **own** + in-screen `enable_pen_up_refresh` **checkbox** + a **delay seekbar**, wired directly + to `touchHelper.setPenUpRefreshEnabled(...)` / `setPenUpRefreshTimeMs(...)`. + +2. **The integrated PenManager demo** — a richer demo (`PenDemoActivity`) with a + floating menu (`layout_float_menu.xml`). There, **"Pen Up Refresh"** + (`@string/pen_up_refresh`, the `penUpCheck` view) is a **live on/off toggle**: + + ```java + private void onPenUpCheckImpl(boolean isChecked) { + getPenBundle().setEnablePenUpRefresh(isChecked); + refreshScreen(); + } + // ...and the callback honours the flag: + public void onPenUpRefresh(RectF refreshRect) { + if (!getPenBundle().isEnablePenUpRefresh()) return; // gated by the toggle + new CommonPenAction<>(new PartialRefreshRequest(getPenManager(), refreshRect)).execute(); + } + ``` + +**Summary of the mapping:** + +| Label seen | Where | What it is | Backing call | +|---|---|---|---| +| "Scribble Pen up Refresh" | `ScribbleDemoActivity` menu list | **navigation button** to the standalone demo | `go(ScribblePenUpRefreshDemoActivity)` | +| enable checkbox + seekbar | inside `ScribblePenUpRefreshDemoActivity` | enable + delay for the standalone demo | `setPenUpRefreshEnabled` / `setPenUpRefreshTimeMs` | +| "Pen Up Refresh" | `PenDemoActivity` floating menu | **live enable/disable toggle** | `setEnablePenUpRefresh` → gates `onPenUpRefresh` | + +So both ultimately drive the single SDK feature. The standalone screen exists to +demonstrate it in isolation **and** to expose the **delay slider** (400–2000 ms); the +PenManager floating-menu toggle exists to show enabling/disabling it inside a fuller +note-taking-style demo. There is no behavioural difference in the feature itself — only +in which demo harness wraps it. + +--- + +## 3. Recommended timing values + +**[sdk]** From `com.onyx.android.sdk.data.PenConstant`: + +| Constant | Value | +|---|---| +| `DEFAULT_PEN_UP_REFRESH_TIME_MS` | **500** | +| `MIN_PEN_UP_REFRESH_TIME_MS` | 400 | +| `MAX_PEN_UP_REFRESH_TIME_MS` | 2000 | +| `PEN_UP_REFRESH_STEP` | 100 | + +So the firmware-sanctioned range is **400–2000 ms**, default **500 ms**, adjustable in +100 ms steps. + +### How to choose + +- **500 ms (default)** is the right starting point. It's long enough that a normal + writer has lifted and paused, so the clean refresh doesn't interrupt an in-progress + word. +- **Lower toward 400 ms** for snappier cleanup if users complain ink stays "rough" + too long. Going below 400 ms is not allowed by the SDK and risks the refresh firing + mid-stroke-sequence (between two quick strokes), causing a flash. +- **Raise toward 700–1000 ms** for fast note-takers who chain many short strokes; a + longer timer avoids a high-quality refresh firing in the middle of rapid writing + (which both flickers and steals CPU/EPD time from the next stroke). +- The timer is "time since pen lifted," so it naturally coalesces a burst of strokes: + each new pen-down before the timer elapses pushes the clean refresh out. + +**Recommendation for Notable:** if/when we adopt pen-up-refresh, default to 500 ms and +expose it as an advanced setting clamped to [400, 2000] in 100 ms steps. + +--- + +## 4. Erasing and resume timing + +Reported symptom: a stroke is erased, the user immediately draws on top of the now-empty +area, and the just-erased stroke "reappears" / the new stroke behaves as if the old +content were still there. There *is* effectively a buffer: the firmware keeps its own +handwriting layer that updates asynchronously, and re-entering raw drawing before it has +absorbed the change composites the next stroke against stale content. + +The important correction from reading the official demo: **the demo does not fix this +with a long timed delay sprinkled around the erase.** It fixes it *structurally* with +the `RawDrawingRenderEnabled` toggle, and only applies a small, device-specific resume +delay at one precise place. Below is what the demo actually does. + +### How the official demo erases — [demo] `ScribbleMoveEraserDemoActivity` + +```java +onBeginRawErasing(...) -> touchHelper.setRawDrawingRenderEnabled(false); // stop firmware owning screen + drawBitmap(); // blit host bitmap to surface NOW +onRawErasingTouchPointMoveReceived(p) -> // accumulate; every 100 points: + eraseBitmap(path); // PorterDuff.CLEAR into host bitmap + drawBitmap(); // re-post host bitmap +onRawErasingTouchPointListReceived(list) -> eraseBitmap(path); // final batch +onEndRawErasing(...) -> touchHelper.setRawDrawingRenderEnabled(true); // hand screen back +``` + +Key points: +- The erase is committed to the **host bitmap** (CLEAR xfermode) and the bitmap is + **synchronously** locked/drawn/posted to the surface on the callback thread — no + background thread, no race. +- `setRawDrawingRenderEnabled(false)` is flipped the instant erasing begins, so the + firmware stops compositing its layer; the host surface is authoritative during the + whole erase. It is re-enabled only at `onEndRawErasing`. +- There is **no `Thread.sleep` / `delay` inside the erase callbacks at all.** + +### Where the demo *does* delay — [demo] `RefreshScreenAction` / `ResumeRawDrawingRequest` + +For the general "refresh the screen, then resume the pen" flow, the demo runs this +exact sequence on its Rx single thread: + +```java +RendererToScreenRequest(...).execute(); // 1. blit host bitmap to screen +ThreadUtils.mySleep(delayResumePenTimeMs); // 2. wait +penManager.setRawDrawingRenderEnabled(true); // 3. re-enable render +penManager.setRawInputReaderEnable(true); // and input +``` + +The delay is **`DELAY_ENABLE_RAW_DRAWING_MILLS`**, defined as: + +```java +DELAY_ENABLE_RAW_DRAWING_MILLS = DeviceInfoUtil.isColorDevice() + ? COLOR_DEVICE_PEN_RESUME_DELAY_TIME_MS + : COMMON_PEN_RESUME_DELAY_TIME_MS; +``` + +**[sdk]** `com.onyx.android.sdk.data.note.NoteConstant`: + +| Constant | Value | Meaning | +|---|---|---| +| `COMMON_PEN_RESUME_DELAY_TIME_MS` | **150** | monochrome resume delay | +| `COLOR_DEVICE_PEN_RESUME_DELAY_TIME_MS` | **500** | Kaleido color resume delay | +| `PAGE_PEN_RESUME_DELAY_TIME` | 600 | page-level (e.g. page turn) resume delay | +| `COMMON_DEVICE_QUIT_FAST_MODE_DELAY_TIME_MS` | 5000 | delay before leaving fast mode | + +So the **authoritative** wait between "screen refreshed" and "raw drawing re-enabled" is +**150 ms on monochrome, 500 ms on color** — applied *after* posting the bitmap and +*before* `setRawDrawingRenderEnabled(true)`. + +> ⚠️ **Correction to Notable [notable].** Notable's `DeviceCompat.delayBeforeResumingDrawing()` +> uses **300 ms** for monochrome / 500 ms for color. The color value matches the SDK, +> but the **300 ms monochrome value is guess-work and is double the official 150 ms** +> (`COMMON_PEN_RESUME_DELAY_TIME_MS`). Consider aligning to the SDK constants. + +### The actual fix for the "erased stroke reappears" bug + +Mirror the demo's *ordering and mechanism*, not a bigger delay: + +1. On erase begin, `setRawDrawingRenderEnabled(false)` so the firmware stops owning the + screen for the duration of the erase. +2. Commit the erase to the host bitmap (`PorterDuff.CLEAR`) and **synchronously** + lock/draw/post it to the surface — on the callback thread, not a racing background + coroutine. +3. Re-enable with the demo's sequence: post bitmap → `mySleep(150 mono / 500 color)` → + `setRawDrawingRenderEnabled(true)` → `setRawInputReaderEnable(true)`. +4. Only then accept the next stroke. + +Notable's current path is more fragile than the demo's: `resetScreenFreeze` launches on +`Dispatchers.Default` and flips render enabled inside `delayBeforeResumingDrawing`, so if +the erase bitmap write hasn't completed before that coroutine re-enables render, the race +window is open. The demo avoids this by doing the bitmap post **synchronously** and +gating resume behind the single-threaded Rx queue. Serializing erase-commit → post → +delay → re-enable (e.g. under `CanvasEventBus.drawingInProgress`/`waitForDrawing()`, on +one thread) is what closes the bug — increasing the delay only masks it. + +> Notable's own comments already smell this race: in +> `einkHelper.partialRefreshRegionOnce` ("onyx library has its own buffer that needs to +> be updated. Otherwise we will refresh to correct, then incorrect and then correct +> state") and in `OnyxInputHandler.onRawDrawingList` ("sometimes UI will get refreshed +> and frozen before we draw all the strokes … because of doing it in separate thread"). +> Both point at the same root cause the demo sidesteps with synchronous ordering. [notable] + +--- + +## 5. Other quirks found during the search + +These are scattered through the SDK and Notable; collected here so they're not lost. + +### Stroke geometry / config +- **Width compensation `+3.0px`** and specific `smoothLevel`/`pressureSensitivity` + defaults are baked into the fountain-V2 pen — see `onyx-neo-fountain-pen-v2.md`. +- `PenConstant` default smooth level is **0.2** (`DEFAULT_SMOOTH_LEVEL`), but the + fountain-V2 factory uses **0.6** — different code paths use different defaults. +- `PenConstant.SHAPE_LIMIT_RENDER_TOUCH_POINT_COUNT = 20000` and + `NeoPenRender.POINT_LIST_BATCH_LIMIT = 1000`: very long strokes are batched/limited; + don't assume one stroke == one render call. +- `FILTER_REPEAT_MOVE_POINT_*`: the firmware filters out near-duplicate move points + (speed < 0.005, pressure delta < 2.0). Don't expect to receive every raw sample. + +### Refresh / update-mode unreliability +- **The Onyx library is unstable.** `tryToSetRefreshMode()` wraps + `setViewDefaultUpdateMode` in try/catch because it throws `NullPointerException` / + `IllegalArgumentException` on devices/modes that don't support a mode. Always guard + EPD calls. +- `onSurfaceInit` tries `HAND_WRITING_REPAINT_MODE` and **falls back to `REGAL`** if it + fails — mode availability is device-dependent. +- `refreshScreen()` using `EpdController.repaintEveryThing(REGAL_PLUS)` is documented in + Notable as **doing nothing** ("TODO: It does nothing, I have no idea why"). +- `PEN_DEACTIVATE_TIME_INTERVAL_MS = 100`: there's a ~100 ms debounce around pen + activation/deactivation events. + +### Threading / freeze ordering (the root of most flicker bugs) +- "sometimes UI will get refreshed and frozen before we draw all the strokes" — doing + the bitmap draw on a separate thread races the freeze. Notable now serializes draw + work under `CanvasEventBus.drawingInProgress` and `waitForDrawing()`; `refreshUi` + even warns `"Drawing is still in progress there might be a bug."` if the lock is held. +- `refreshUi` deliberately **skips unfreezing when not in drawing mode** (Select/menus), + to avoid fighting other refreshers. +- Color Kaleido devices need **longer** settle times everywhere: the SDK uses + **500 ms** resume delay on color vs **150 ms** on monochrome + (`COLOR_DEVICE_PEN_RESUME_DELAY_TIME_MS` / `COMMON_PEN_RESUME_DELAY_TIME_MS`). [sdk] +- Animation mode (`applyTransientUpdate(ANIMATION_X)` / `clearTransientUpdate`) is used + for scrolling; remember to turn it back off (debounced ~500 ms) or you keep ghosting. + +--- + +## 6. TL;DR + +- **Pen up refresh** = firmware replaces fast/rough live ink with a clean partial + refresh a configurable time after pen-up. Default **500 ms**, range **400–2000 ms**, + step **100 ms**. Handle `onPenUpRefresh(rect)` by blitting your bitmap into `rect` + using `HAND_WRITING_REPAINT_MODE`. +- **Erase reappear bug** = host re-enables raw drawing before the firmware buffer + absorbs the erase. The demo's fix is *structural*: `setRawDrawingRenderEnabled(false)` + for the whole erase, commit the erase to the bitmap and **synchronously** post it, then + resume with `post bitmap → mySleep(150 mono / 500 color) → setRawDrawingRenderEnabled(true)`. + Serialize the steps on one thread; a bigger delay only masks a race. +- **Authoritative resume delay** = **150 ms monochrome / 500 ms color** (`NoteConstant`). + Notable's 300 ms mono value is guess-work and twice the official number. +- The Onyx EPD API is flaky — guard every call (try/catch), expect device-specific + behavior, and budget more time on color panels. From 426a5aa51c65fbc49d2a1e6e163ff31dcae68cf1 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Tue, 30 Jun 2026 00:15:16 +0200 Subject: [PATCH 3/9] Document Onyx framework internals for pens, input handling, and rendering logic. ### Documentation - **Pressure & Line Width**: Created `onyx-pressure-sensitivity-and-line-width.md` to map official UI sliders (mm and percentage) to `NeoPenConfig` fields and `EACStrokeStyle` parameters used by the firmware. - **Fountain V2 & Rendering**: - Expanded `onyx-neo-fountain-pen-v2.md` to explain the architectural split between live firmware rendering (via SurfaceFlinger) and offline SDK rendering (in-process). - Documented that velocity-modulated pens can use 0-based monotonic timestamps instead of absolute wall-clock time, as the native layer only processes deltas. - **Finger & Input Handling**: - Updated `onyx-finger-scribble.md` with details on `BaseHandler` behavior, where the framework automatically pauses hardware handwriting and forces a repaint upon finger contact. - Documented `RawInputReader` internals, including hardware-level erasing flags and the interaction between limit rects and the native input mask. - **Eraser & System Logic**: - Confirmed via `framework.jar` that the system handler early-returns for eraser strokes (style 5), explaining why native eraser paths require specific activation to be visible. - Verified the 500ms default repaint latency logic in `BaseHandler` following stylus-up events. --- docs/onyx-finger-scribble.md | 100 ++++++--- docs/onyx-native-eraser-indicator.md | 10 + docs/onyx-neo-fountain-pen-v2.md | 66 +++++- docs/onyx-pen-up-refresh-and-screen-freeze.md | 16 ++ ...nyx-pressure-sensitivity-and-line-width.md | 191 ++++++++++++++++++ 5 files changed, 353 insertions(+), 30 deletions(-) create mode 100644 docs/onyx-pressure-sensitivity-and-line-width.md diff --git a/docs/onyx-finger-scribble.md b/docs/onyx-finger-scribble.md index 6ceeacf1..98f5a3c1 100644 --- a/docs/onyx-finger-scribble.md +++ b/docs/onyx-finger-scribble.md @@ -41,31 +41,31 @@ pipeline cleanly so the flag change takes effect for the next stroke. ## 2. How it works inside the SDK [sdk] `TouchHelper.enableFingerTouch(enable)` fans the flag out to every `TouchRender`, which -forwards it to the native input reader (`AppTouchInputReader.setEnableFingerTouch`). All -the interesting logic is one filter method in `AppTouchInputReader` that decides whether -to **ignore** an incoming `MotionEvent`: - -```java -// decompiled & de-obfuscated; g = enableFingerTouch, h = onlyEnableFingerTouch -private boolean shouldSkip(MotionEvent event) { - if (g) { // finger drawing ENABLED - return h && TouchUtils.isPenTouchType(event); - // h == false -> skip nothing -> BOTH pen and finger draw - // h == true -> skip the pen -> ONLY finger draws - } - return !TouchUtils.isPenTouchType(event); // finger drawing DISABLED (default): - // skip everything that isn't the pen -} -``` - -`TouchUtils.isPenTouchType(event)` classifies the event by its `MotionEvent` tool type -(stylus/eraser vs finger). So the three reachable states are: - -| State | API call | Filter result | Who can draw | -|---|---|---|---| -| **Default** | (none) — `g=false` | skip non-pen | **stylus only** | -| **Finger enabled** | `enableFingerTouch(true)` → `g=true, h=false` | skip nothing | **stylus + finger** | -| **Finger only** | `onlyEnableFingerTouch(true)` → `h=true` | skip pen | **finger only** | +forwards it to the native input reader (`AppTouchInputReader`). What is **verified** from +the decompiled `AppTouchInputReader`: + +- It holds two boolean fields: one set by `setEnableFingerTouch(boolean)` (call it + `enableFinger`) and one set by `setOnlyEnableFingerTouch(boolean)` (`onlyFinger`). [sdk] +- A single private filter method, keyed on `TouchUtils.isPenTouchType(event)` (which + classifies the `MotionEvent` tool type as stylus/eraser vs finger), decides per event + whether to feed it into the raw-drawing pipeline. The down/move/up handlers all gate on + it (`if (filter(event)) { dispatch… }`). [sdk] +- Both `enableFingerTouchPressure(boolean)` and `setFingerTouchPressure(float)` exist for + giving the (pressure-less) finger a synthetic pressure. [sdk] + +> ⚠️ The exact boolean body of that filter is **not** quoted here: CFR decompiles this +> particular method into mangled code (stray `void var1_1`, inverted assignments), so the +> precise skip/keep polarity can't be trusted from the bytecode. The **observable +> behaviour** below is the reliable contract (it matches Onyx's documented semantics), so +> this doc states behaviour, not a reconstructed expression. + +The three reachable states (by behaviour): + +| State | API call | Who can draw | +|---|---|---| +| **Default** | (none) | **stylus only** | +| **Finger enabled** | `enableFingerTouch(true)` | **stylus + finger** | +| **Finger only** | `onlyEnableFingerTouch(true)` | **finger only** | So "Open finger scribble" = move from row 1 to row 2: the capacitive finger now feeds the same raw-drawing path the stylus uses, producing identical `onRawDrawing*` callbacks @@ -76,7 +76,11 @@ A capacitive finger reports no real pen pressure. The SDK exposes: - `enableFingerTouchPressure(boolean)` / `setFingerTouchPressure(float)` [sdk] to assign a **synthetic constant pressure** to finger strokes, so pressure-sensitive pen -styles (fountain, brush) still render a sensible width when drawn with a finger. +styles (fountain, brush) still render a sensible width when drawn with a finger. The SDK's +default for this is **`PenConstant.DEFAULT_FINGER_TOUCH_PRESSURE = 1500.0f`** [sdk] (on a +~4096 max-pressure scale, i.e. roughly mid-range) — and `AppTouchInputReader` applies it in +its `setPressure(this.j)` path only when the finger-pressure flag (`this.i`) is enabled, +confirming it is an opt-in override of the real (zero) finger pressure. [sdk] --- @@ -121,6 +125,50 @@ doesn't inject a competing finger stroke; finger input resumes at `onEndRawDrawi --- +## 3c. A THIRD finger layer — the system handwriting handler [framework] + +Decompiling the device `framework.jar` reveals a third, system-level finger mechanism that +sits *above* both of the above (it runs in the framework, not in the app): + +`android.onyx.optimization.screennote.handler.BaseHandler` watches every note "draw view". +On a **finger down** it does: + +```java +private void onFingerDownImpl(View view, EACNoteConfig cfg) { + pauseEACScreenNote(); // setScreenHandWritingPenState(PEN_PAUSE = 3) + ViewUpdateHelper.repaintEverything(); +} +``` + +i.e. the firmware **pauses hardware handwriting and forces a clean full repaint the moment +a finger lands**, independent of anything the app does. The stylus down path resumes it +(`PEN_DRAWING = 2`). This is why, on stock Onyx behaviour, touching with a finger cleans up +the fast-mode ghosting — and why an app that wants finger-*drawing* has to opt in explicitly +(`enableFingerTouch`), since the default system reflex to a finger is "stop drawing, repaint". + +## 3d. The raw input reader (`libonyx_touch_reader`) [framework] + +`android.onyx.inputreader.RawInputReader` is the framework JNI bridge under the SDK's +`TouchHelper` (loads `libonyx_touch_reader.so`). Useful facts: + +- The raw callback is `onTouchPointReceived(x, y, pressure, erasing, …, action, time)` with + action codes: `PEN_DOWN=0, PEN_MOVE=1, PEN_RELEASE=2, PEN_RELEASE_OUT_LIMIT_REGION=3, + PEN_INVALID=4, PEN_ACTIVE=5` (5 = hover). Note **`PEN_RELEASE_OUT_LIMIT_REGION`**: the + driver distinguishes a normal pen-up from one that happens outside the limit rect. +- **The "erasing" flag is decided in hardware/driver**, not the app: `this.erasing = z` + comes straight off the touch report. So whether a contact is an erase (eraser tip / side + button) is determined below the framework — the app only reacts to it. +- Points are buffered in a `TouchPointList(600)` (initial capacity 600). +- `setLimitRect` / `setExcludeRect` each drive **two** layers at once: the native input mask + (`nativeSetLimitRegion` / `nativeSetExcludeRegion`) **and** the firmware handwriting region + (`ViewUpdateHelper.setScreenHandWritingRegionLimit/Exclude`). So the limit/exclude rects + Notable passes in `setupSurface` clip both *input* and *firmware rendering*. +- Region mode: `nativeSetRegionMode(0)` = multi-region, `(1)` = single-region (each also + mirrored to `ViewUpdateHelper.setScreenHandWritingRegionMode`). +- `nativeSetPenState(4)` (`PEN_INVALID`) is asserted on `pause()` / `resume()` / `quit()`. + +See `docs/investigation.md` for the full framework trace. + ## 4. The `create(view, stylus, callback)` overload [sdk] The finger demo creates its helper with the 3-arg overload: diff --git a/docs/onyx-native-eraser-indicator.md b/docs/onyx-native-eraser-indicator.md index 62f9ac2c..957e4adb 100644 --- a/docs/onyx-native-eraser-indicator.md +++ b/docs/onyx-native-eraser-indicator.md @@ -129,6 +129,16 @@ as soon as the erase is committed — it only exists as live feedback during the > erase "track" is app-drawn (`EraseRenderer.drawEraseCircle`). So this native path is > firmware-dependent — hence the try/catch and the preserved OpenGL fallback. [demo] +> **[framework] Root cause confirmed in `framework.jar`.** The system handwriting handler +> `BaseHandler.applyStrokeParam()` pushes stroke colour/style/width to SurfaceFlinger +> **only for non-eraser strokes** — it early-returns when +> `strokeStyle == 5` (`isEarsingStroke`). Style `5` is the eraser (the same value the SDK's +> `resetPenDefaultRawDrawing()` passes as `setEraserRawDrawingEnabled(false, 5)`). So by +> default the firmware applies *no paint* while erasing, which is exactly why button-erase +> drew nothing until the dedicated native eraser channel was enabled. `setStrokeStyle` / +> `setEraserRawDrawingEnabled` themselves are `ViewUpdateHelper` Binder transactions to +> SurfaceFlinger (codes 16711688 and 1048833). See `docs/investigation.md`. + --- ## 3. What was changed in Notable diff --git a/docs/onyx-neo-fountain-pen-v2.md b/docs/onyx-neo-fountain-pen-v2.md index 24e7ad5e..1f2ac0f7 100644 --- a/docs/onyx-neo-fountain-pen-v2.md +++ b/docs/onyx-neo-fountain-pen-v2.md @@ -56,6 +56,12 @@ Constants: - `NeoFountainPenWrapper.MIN_FOUNTAIN_PEN_WIDTH = 1.0f` - `FountainShapes.FOUNTAIN_PEN_V1_COMPENSATION = 3.0f` +> The `pressureSensitivity` (default 0.3) and `width` arguments are exactly the official +> app's **"Pressure sensitivity" (0–100%)** and **"Line width" (mm)** sliders — Fountain V2 +> is `NEOPEN_PEN_TYPE_FOUNTAIN_V2 = 6`. See +> `docs/onyx-pressure-sensitivity-and-line-width.md` for the full mapping (incl. mm→px and +> the firmware-side `EACStrokeStyle`). + ### `NeoFountainPenV2.Companion.create(config)` ``` @@ -188,10 +194,39 @@ rest of the stroke body. No DB migration is needed: `points` is a binary blob an v1 format already defined the DT channel, so old strokes (dt absent) and new strokes (dt present) coexist. -**Deferred:** actually *consuming* `dt` in `strokeToTouchPoints` (reconstructing -`timestamp = stroke.updatedAt.time + dt`) to feed the native smoother. This is the step -that fixes the faceting on screen, but it is intentionally not wired up yet — the data is -captured first so it exists for strokes drawn from now on. +**Deferred:** actually *consuming* `dt` in `strokeToTouchPoints` to feed the native +smoother. This is intentionally not wired up yet — the data is captured first so it exists +for strokes drawn from now on. + +### Absolute vs delta vs zero-based timestamps — only deltas matter [sdk][framework] + +When you do wire it up, you can use a **0-based timeline** (`timestamp = dt`, i.e. the first +point at 0) — there is **no need** to add `stroke.updatedAt.time` or any wall-clock base. +Evidence from the decompiled stack: + +- **Which pens use the timestamp:** every Neo* pen wrapper packs it into the native point + array — `PenUtils.getPointDoubleArray(List)` lays out 7 doubles per point + `[x, y, pressure, size, tiltX, tiltY, timestamp]` (and the float variant + `[x, y, pressure, size, timestamp]`). It is **stored raw, never delta-converted in Java** — + the native pen does the diffing. The pens that actually *consume* it are the + velocity-modulated ones (Fountain V1/V2, Brush, Charcoal — anything driven by + `NeoPenConfig.velocitySensitivity`, default 0.5). Constant-width pens (ballpoint) and + Notable's custom ball-pen renderer ignore it. +- **It's used only as velocity** (`Δdistance / Δtimestamp`). Nothing compares the timestamp + to the wall clock or `SystemClock`, so a constant offset cancels out: a 0-based timeline + and an absolute one produce identical velocity → identical ink. +- **Smaller is actually safer.** The framework's *live* stroke API ships the time as a + **`float`** (`ViewUpdateHelper.addStrokePoint(…, float time)`), and the raw source is + `MotionEvent.getEventTime()` — absolute uptime in the billions of ms, which a 32-bit float + cannot resolve to the millisecond. Onyx getting away with that only works because the + native side cares about *differences*; small/zero-based values keep full precision. The + NeoPen offline path uses `double` so it's fine either way, but there is no downside to + zero-basing and it keeps the numbers small. + +So: feed `dt` straight in as the timestamp. Just keep it **monotonically non-decreasing** +with the right per-point gaps; equal consecutive values (Δ = 0) give a degenerate velocity, +but the native side guards that (`velocityIgnoreThreshold`) and it's unrelated to the choice +of time base. ## The correct approach @@ -211,6 +246,29 @@ Additional requirements: See `app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt` for the implementation. +## Why the live stroke and the offline redraw are two different renderers [framework] + +Decompiling the device `framework.jar` makes the architecture explicit, and explains why an +offline redraw can ever differ from what you saw while writing: + +- **Live ink is drawn by the firmware, not by `NeoPen`.** While the pen moves, the SDK + streams points straight to SurfaceFlinger via + `android.onyx.ViewUpdateHelper.startStroke / addStrokePoint / finishStroke(baseWidth, x, y, + pressure, size, time)` (Binder transaction codes 16711697/8/9). Each call *returns the + firmware's own updated stroke width* — the firmware owns the live low-latency rendering and + its width model. +- **The offline redraw is drawn by `NeoFountainPenV2`/`NeoPenRender` in-process** onto our + surface (this doc). It is a *re-implementation* of the same stroke, not a replay of the + firmware's pixels. + +So matching is inherently "two renderers agreeing": the only way the redraw lines up with +the live ink is to feed the in-process pen the exact same config the firmware uses (hence +`createNeoPenV2`) and the exact same point stream — and, critically, to pick the result type +that looks like a *finished* stroke (`fastMode = false`, see above) rather than the live dab +stream the firmware emits. The live `startStroke`/`addStrokePoint` path also carries a +per-point `time`, which is the firmware analogue of the `dt` we now persist (see the +timestamp section). + ## Inspecting the SDK yourself The SDK ships as `.aar` files (in `app/libs/` and the Gradle cache). To decompile: diff --git a/docs/onyx-pen-up-refresh-and-screen-freeze.md b/docs/onyx-pen-up-refresh-and-screen-freeze.md index 04aaa807..a22bcfd4 100644 --- a/docs/onyx-pen-up-refresh-and-screen-freeze.md +++ b/docs/onyx-pen-up-refresh-and-screen-freeze.md @@ -91,6 +91,15 @@ Why it's *optional* (a switch): an app may already do its own end-of-stroke refr pass would be redundant or even conflict with the app's own refresh timing. So the SDK lets you turn it off and take over, or turn it on and let the firmware handle it. +> **[framework] Confirmed in `framework.jar`.** The system note handler +> (`android.onyx.optimization.screennote.handler.BaseHandler`) implements exactly this: on +> stylus-up it schedules, after `EACNoteConfig.getRepaintLatency()` ms, a +> `ViewUpdateHelper.handwritingRepaint(view, l,t,r,b, false)` and sets the pen state to +> `PEN_PAUSE`. `repaintLatency` **defaults to 500 ms**, matching the SDK's +> `PenConstant.DEFAULT_PEN_UP_REFRESH_TIME_MS` below. The pen state is a firmware-tracked +> machine: `PEN_STOP=0, PEN_START=1, PEN_DRAWING=2, PEN_PAUSE=3, PEN_ERASING=4`. See +> `docs/investigation.md` for the full framework trace. + ### API On `TouchHelper`: @@ -287,9 +296,16 @@ DELAY_ENABLE_RAW_DRAWING_MILLS = DeviceInfoUtil.isColorDevice() |---|---|---| | `COMMON_PEN_RESUME_DELAY_TIME_MS` | **150** | monochrome resume delay | | `COLOR_DEVICE_PEN_RESUME_DELAY_TIME_MS` | **500** | Kaleido color resume delay | +| `ERASE_DELAY_RESUME_PEN_TIME` | **500** | resume delay specific to *erasing* | +| `DELAY_FLOAT_MOVE_RESUME_PEN_TIME_MS` | 500 | resume delay after a floating-toolbar move | | `PAGE_PEN_RESUME_DELAY_TIME` | 600 | page-level (e.g. page turn) resume delay | | `COMMON_DEVICE_QUIT_FAST_MODE_DELAY_TIME_MS` | 5000 | delay before leaving fast mode | +Note `ERASE_DELAY_RESUME_PEN_TIME = 500`: the SDK uses a **500 ms** resume delay after an +erase (vs 150 ms monochrome for normal pen resume), i.e. the firmware is given longer to +absorb the cleared region before raw drawing is handed back — directly relevant to the +"erased stroke reappears" bug below. + So the **authoritative** wait between "screen refreshed" and "raw drawing re-enabled" is **150 ms on monochrome, 500 ms on color** — applied *after* posting the bitmap and *before* `setRawDrawingRenderEnabled(true)`. diff --git a/docs/onyx-pressure-sensitivity-and-line-width.md b/docs/onyx-pressure-sensitivity-and-line-width.md new file mode 100644 index 00000000..dd2404e6 --- /dev/null +++ b/docs/onyx-pressure-sensitivity-and-line-width.md @@ -0,0 +1,191 @@ +# Onyx pen: pressure sensitivity & line width (the official app's two sliders) + +The official Onyx note app exposes a **Pen** with two settings: + +- **Line width** — `0.10 mm` … `2.00 mm` +- **Pressure sensitivity** — `0% (Off)` … `100%` + +This documents where those two values live in the SDK/firmware, what they actually control, +and how to reproduce them in Notable. Grounded in the decompiled `onyxsdk-pen` / +`onyxsdk-penbrush` and the device `framework.jar`. + +**Source tags:** **[sdk]** decompiled `.aar`; **[framework]** decompiled `framework.jar`. + +--- + +## 1. Which pen is it? — Fountain V2 [sdk] + +The configurable-pressure "Pen" is the **Fountain V2** pen. `NeoPenConfig` enumerates the +native pen types, and Fountain V2 is the one fed by `FountainShapes.createNeoPenV2(...)` +(see `docs/onyx-neo-fountain-pen-v2.md`): + +``` +NEOPEN_PEN_TYPE_BRUSH = 1 +NEOPEN_PEN_TYPE_FOUNTAIN = 2 +NEOPEN_PEN_TYPE_MARKER = 3 +NEOPEN_PEN_TYPE_CHARCOAL = 4 +NEOPEN_PEN_TYPE_CHARCOAL_V2 = 5 +NEOPEN_PEN_TYPE_FOUNTAIN_V2 = 6 // <-- "Pen" with line width + pressure sensitivity +NEOPEN_PEN_TYPE_PENCIL = 7 +NEOPEN_PEN_TYPE_BALLPOINT = 8 +NEOPEN_PEN_TYPE_SQUARE = 9 +NEOPEN_PEN_TYPE_BRUSH_SIGN = 10 +``` + +It is Fountain V2 (not V1) because **only the V2 path carries a `pressureSensitivity` +parameter** that a UI slider can drive; V1 has no such knob. A constant-width pen (ballpoint) +ignores pressure entirely, so a 0–100% sensitivity slider only makes sense on a +pressure-modulated pen like fountain. + +--- + +## 2. The full pen config and its defaults [sdk] + +`com.onyx.android.sdk.pen.NeoPenConfig` (penbrush) — every field the native renderer reads, +with its default: + +| Field | Default | Meaning | +|---|---|---| +| `type` | 1 | pen type (6 = Fountain V2) | +| `width` | 3.0 | **base stroke width, in pixels** (this is the "line width") | +| `minWidth` | 0.001 | floor width (Fountain V2 uses `MIN_FOUNTAIN_PEN_WIDTH = 1.0`) | +| `maxTouchPressure` | 1.0 | pressure normaliser; points must be pre-divided by this | +| `pressureSensitivity` | **0.3** | **how strongly pressure modulates width (the slider)** | +| `velocitySensitivity` | 0.5 | how strongly speed modulates width (not exposed in UI) | +| `smoothLevel` | 0.6 | curve smoothing | +| `dpi` | 320.0 | pixels-per-inch used for any physical sizing | +| `tiltScale` | 3.0 | tilt → width factor (`tiltEnabled = false` for fountain) | +| `velocityAmplifier`, `velocityIgnoreThreshold`, `velocityLowerBound`, `velocityUpperBound`, `startPointLimit`, `startLengthLimit`, `endVelocitySensitivity` | 0.0 | fine velocity-shaping knobs | +| `scalePrecision`, `displayScaleX/Y` | 1.0 | render scaling | + +All of these are copied into the native `PenConfig` in `NeoPenConfig.toNativeConfig()` +(verified): `it.setPressureSensitivity(this.pressureSensitivity)`, +`it.setVelocitySensitivity(...)`, `it.setWidth(...)`, etc. The actual width-from-pressure +math runs in `libpennative` (JNI, not inspectable), but the inputs are exactly these. + +--- + +## 3. Pressure sensitivity (the 0%–100% slider) [sdk] + +- It is **`NeoPenConfig.pressureSensitivity`**, a float in `[0, 1]`. +- Semantics: it scales how much the per-point pressure pushes the stroke width away from the + base `width`. **`0.0` = "Off"** (pressure ignored → constant-width line); higher = pressure + has more effect on width. The app's **`0% … 100%` maps to `0.0 … 1.0`** (the slider value + divided by 100). +- **Two different defaults — mind the gap:** + - `NeoPenConfig.pressureSensitivity` (the raw struct field) defaults to **0.3**. This is + what `FountainShapes.createNeoPenV2(..., pressureSensitivity = null, ...)` falls back to, + so Notable's current strokes (which pass `null`) run at **0.3**. + - **`PenConstant.DEFAULT_PRESSURE_SENSITIVITY = 0.375`** is the SDK/official-app default + (≈37.5% on the slider). There is also an explicit feature flag + **`PenConstant.ENABLE_CONFIG_PEN_PRESSURE_SENSITIVITY = true`** — i.e. the official app + treats pressure sensitivity as a user-configurable setting, defaulting to 0.375. + - So if you want to match the official app exactly, default the slider to **0.375**, not + 0.3. [sdk] +- Sibling knobs (same source) for completeness: `PenConstant.DEFAULT_VELOCITY_SENSITIVITY = + 1.0` (the `NeoPenConfig` field default is 0.5) and `PenConstant.DEFAULT_SMOOTH_LEVEL = 0.2` + (the field/`createNeoPenV2` default is 0.6). The app-level `PenConstant` defaults and the + raw struct field defaults genuinely differ — the app overrides the struct. + +> ### Pressure must be normalised first +> The native pen expects per-point pressure in **`[0, 1]`**. Raw `TouchPoint.pressure` from +> the digitizer is `1 … 4096` (`EpdController.getMaxTouchPressure()` ≈ 4096). The pen +> wrappers divide each point's pressure by `maxTouchPressure` before rendering. If you set +> `pressureSensitivity > 0` but feed un-normalised pressure, every point saturates and the +> width looks constant/maxed. (Notable already normalises on a copy — see +> `NeoFountainPenV2Wrapper.copyAndNormalizePressure`.) + +--- + +## 4. Line width (the 0.10 mm – 2.00 mm slider) [sdk] + +The `0.10 … 2.00 mm` range is **not a guess — it's hard-coded in `PenConstant`**: + +| Constant | Value | Meaning | +|---|---|---| +| `MIN_NORMAL_STROKE_WIDTH` | **0.1** | min line width (mm) — the slider floor | +| `MAX_NORMAL_STROKE_WIDTH` | **2.0** | max line width (mm) — the slider ceiling | +| `DEFAULT_STROKE_WIDTH_MM` | 0.5 | default "Pen" width (mm) | +| `NORMAL_STROKE_WIDTH_GAP` | 0.05 | slider step (mm) | +| `MIN_/MAX_MARKER_STROKE_WIDTH` | 0.5 / 8.0 | the *marker* pen's separate mm range | +| `DEFAULT_DPI` | 320.0 | nominal density used for mm↔px | + +So the official "Pen" slider is literally `MIN_NORMAL_STROKE_WIDTH … MAX_NORMAL_STROKE_WIDTH` +(0.10–2.00 mm) in 0.05 mm steps, default 0.5 mm. + +- The render value is **`NeoPenConfig.width`, in pixels**, converted from mm with the density: + + ``` + widthPx = widthMm / 25.4 * dpi // dpi = PenConstant.DEFAULT_DPI = 320 (nominal) + ``` + + At the nominal 320 dpi: `0.10 mm ≈ 1.26 px`, `0.50 mm ≈ 6.3 px`, `2.00 mm ≈ 25.2 px`. +- Caveat: `PenConstant` also defines `DEFAULT_STROKE_WIDTH = 7.2f` (px) paired with + `DEFAULT_STROKE_WIDTH_MM = 0.5f`, which implies ≈14.4 px/mm (≈366 dpi), **not** 320. So the + *actual* device conversion is denser than the nominal `DEFAULT_DPI`; for an exact match use + the device's real ppi (or reproduce the 7.2 px ⇄ 0.5 mm ratio) rather than 320. Other + per-pen px defaults: `BRUSH 8.4`, `MARKER 48.0`, `CHARCOAL 3.6`, `PENCIL 6.0`, + with `MAX_RENDER_STROKE_WIDTH = 80.0`. + +- Note `FountainShapes.createNeoPenV2` adds a compensation term: the native pen actually gets + `config.width = width + 3.0 / createScale` and `config.minWidth = minWidth / createScale`. + So the value you pass as `width` is the **nominal** nib; the firmware draws it slightly + thicker by design (the +3 px `FOUNTAIN_PEN_V1_COMPENSATION`). Account for this if you want a + mm value to land exactly. + +--- + +## 5. The firmware (live) side of the same two values [framework] + +When the official app draws live, it does **not** use `NeoPenConfig`; it pushes the style to +SurfaceFlinger through the framework (see `docs/investigation.md`). The relevant carrier is +`android.onyx.optimization.data.v2.EACStrokeStyle`: + +```java +int strokeStyle; // pen/eraser style id +float strokeWidth = 3.0f; // base width (px) <-- the line-width slider +int strokeColor = 0xFF000000; +List strokeExtraArgs; // generic float[] of extra brush params +``` + +`BaseHandler.applyStrokeParam()` forwards these to +`ViewUpdateHelper.setStrokeWidth(strokeWidth)` and +`ViewUpdateHelper.setStrokeParameters(strokeStyle, strokeExtraArgs)` (Binder codes 16711687 +and 1049089). So **line width is `strokeWidth`**, and the **pressure-sensitivity value is +carried inside `strokeExtraArgs`** (the firmware's per-style parameter array — the same +mechanism Notable already uses to pass the dash pattern for the lasso eraser). The exact slot +layout of `strokeExtraArgs` for fountain is decided in the native EPD layer and is not +exposed in Java. + +The takeaway: **live (firmware) and offline (NeoPen) are two renderers** that each need the +same two numbers — `strokeWidth`/`width` and the pressure sensitivity — supplied through their +own channel. + +--- + +## 6. How to expose these in Notable + +Notable renders fountain offline via `NeoFountainPenV2Wrapper` → +`FountainShapes.createNeoPenV2(width, minWidth, …, pressureSensitivity, fastMode, smoothLevel)`. +Today it passes `pressureSensitivity = null` (→ 0.3) and a pixel `strokeWidth`. + +To mirror the official app's two sliders: + +- **Line width (mm):** convert to px with the device ppi and pass as `width` + (`widthPx = mm / 25.4 * ppi`). Remember the `+3 px` fountain compensation. +- **Pressure sensitivity (0–100%):** pass `pressureSensitivity = percent / 100f` instead of + `null`. `0f` reproduces the app's **"Off"** (constant-width line); `1f` = full response; + `0.3f` is the SDK default. +- Keep feeding **normalised pressure** (`pressure / maxTouchPressure`) or the slider will look + like it does nothing. + +`velocitySensitivity` (0.5) and the velocity-bound knobs stay at their defaults — the official +app doesn't expose them, and they shape the fast-stroke taper that makes a fountain pen feel +right. + +--- + +## 7. Related + +- `docs/onyx-neo-fountain-pen-v2.md` — the Fountain V2 render pipeline and `createNeoPenV2`. +- `docs/investigation.md` — framework `EACStrokeStyle` / `ViewUpdateHelper` stroke params. From 3adbef5744c0cb3d8fbac11be155291f4fb344ba Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Tue, 30 Jun 2026 11:58:01 +0200 Subject: [PATCH 4/9] =?UTF-8?q?Point=201=20=E2=80=94=20erase-specific=2050?= =?UTF-8?q?0=20ms=20resume=20=20=20DeviceCompat.delayBeforeResumingDrawing?= =?UTF-8?q?(isErasing:=20Boolean=20=3D=20false)=20now=20returns=20500=20ms?= =?UTF-8?q?=20for=20erase=20regardless=20of=20device=20(matching=20NoteCon?= =?UTF-8?q?stant.ERASE=5FDELAY=5FRESUME=5FPEN=5FTIME),=20keeping=20500=20c?= =?UTF-8?q?olor=20/=20300=20mono=20for=20normal=20pen=20resume.=20=20=20No?= =?UTF-8?q?n-erase=20callers=20(resetScreenFreeze,=20etc.)=20are=20unchang?= =?UTF-8?q?ed.=20=20=20Point=202=20=E2=80=94=20serialized=20post=20?= =?UTF-8?q?=E2=86=92=20delay=20=E2=86=92=20re-enable=20(no=20cross-thread?= =?UTF-8?q?=20race)=20=20=20-=20CanvasRefreshManager.refreshAfterErase(dir?= =?UTF-8?q?ty)=20runs=20the=20whole=20sequence=20on=20one=20coroutine:=20i?= =?UTF-8?q?sRawDrawingRenderEnabled=20=3D=20false=20=E2=86=92=20post=20the?= =?UTF-8?q?=20erased=20bitmap=20and=20await=20it=20actually=20landing=20on?= =?UTF-8?q?=20the=20surface=20(via=20a=20CompletableDeferred=20=20=20compl?= =?UTF-8?q?eted=20in=20drawCanvasToView's=20finally)=20=E2=86=92=20500=20m?= =?UTF-8?q?s=20erase=20settle=20=E2=86=92=20isRawDrawingRenderEnabled=20?= =?UTF-8?q?=3D=20true.=20=20=20-=20drawCanvasToView=20gained=20an=20option?= =?UTF-8?q?al=20onPosted=20callback=20to=20make=20the=20post=20awaitable;?= =?UTF-8?q?=20awaitDrawCanvasToView=20wraps=20it.=20=20=20-=20OnyxInputHan?= =?UTF-8?q?dler.onRawErasingList=20now=20posts=20a=20quick=20indicator-cle?= =?UTF-8?q?ar=20(plain=20drawCanvasToView,=20no=20Jump=20to=20bottom=20(ct?= =?UTF-8?q?rl+End)=20=E2=86=93=20=20the=20single=20serialized=20refreshAft?= =?UTF-8?q?erErase=20over=20the=20union=20of=20the=20indicator=20track=20a?= =?UTF-8?q?nd=20the=20erased?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/canvas/CanvasRefreshManager.kt | 35 +++++++++- .../notable/editor/canvas/OnyxInputHandler.kt | 14 +++- .../ethran/notable/editor/utils/einkHelper.kt | 21 ++++-- docs/onyx-pen-up-refresh-and-screen-freeze.md | 68 +++++++++++++++++++ 4 files changed, 129 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt index 6eba5c9b..a0a8411b 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt @@ -10,6 +10,7 @@ import com.ethran.notable.editor.EditorViewModel import com.ethran.notable.editor.PageView import com.ethran.notable.editor.drawing.selectPaint import com.ethran.notable.editor.state.Mode +import com.ethran.notable.editor.utils.DeviceCompat import com.ethran.notable.editor.utils.pointsToPath import com.ethran.notable.editor.utils.refreshScreenRegion import com.ethran.notable.editor.utils.resetScreenFreeze @@ -17,6 +18,7 @@ import com.ethran.notable.utils.logCallStack import com.onyx.android.sdk.pen.TouchHelper import io.shipbook.shipbooksdk.Log import io.shipbook.shipbooksdk.ShipBook +import kotlinx.coroutines.CompletableDeferred class CanvasRefreshManager( private val drawCanvas: DrawCanvas, @@ -66,7 +68,36 @@ class CanvasRefreshManager( resetScreenFreeze(touchHelper) } - fun drawCanvasToView(dirtyRect: Rect?) { + /** + * Serialized erase resume (see docs/onyx-pen-up-refresh-and-screen-freeze.md): + * freeze firmware compositing -> post the erased bitmap and WAIT until it is actually on + * the surface -> let the EPD settle for the erase-specific delay (500 ms) -> hand the + * screen back. Running post + delay + re-enable in order on one coroutine (instead of + * racing [drawCanvasToView]'s async post against [resetScreenFreeze] on another thread) + * guarantees the firmware re-freezes against the clean, erased image, not stale content. + */ + suspend fun refreshAfterErase(dirtyRect: Rect?) { + // 1. stop the firmware compositing its (indicator) layer over our surface + touchHelper?.isRawDrawingRenderEnabled = false + // 2. post the erased page bitmap and block until it has reached the surface + awaitDrawCanvasToView(dirtyRect) + // 3. give the EPD time to absorb the cleared region before re-arming the pen + DeviceCompat.delayBeforeResumingDrawing(isErasing = true) + // 4. hand the screen back to the firmware (only if we're still drawing) + if (viewModel.toolbarState.value.isDrawing) { + touchHelper?.isRawDrawingRenderEnabled = true + } else { + log.w("refreshAfterErase: not in drawing mode, leaving render disabled") + } + } + + private suspend fun awaitDrawCanvasToView(dirtyRect: Rect?) { + val done = CompletableDeferred() + drawCanvasToView(dirtyRect) { done.complete(Unit) } + done.await() + } + + fun drawCanvasToView(dirtyRect: Rect?, onPosted: (() -> Unit)? = null) { drawCanvas.post { val zoneToRedraw = dirtyRect ?: Rect(0, 0, page.viewWidth, page.viewHeight) var canvas: Canvas? = null @@ -104,6 +135,8 @@ class CanvasRefreshManager( log.v("Canvas refreshed") } catch (e: IllegalStateException) { log.w("Surface released during unlock", e) + } finally { + onPosted?.invoke() } } } diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt b/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt index 9965b35b..f5905ba0 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt @@ -376,7 +376,11 @@ class OnyxInputHandler( boundingBox.right + padding, boundingBox.bottom + padding ) - drawCanvas.refreshManager.refreshUi(strokeArea) + // Quickly clear the native eraser indicator track (plain post, no freeze toggle). + // The single freeze -> post -> settle -> resume sequence is done, serialized, in + // refreshAfterErase below, so we don't race two screen-freeze toggles on different + // threads. See docs/onyx-pen-up-refresh-and-screen-freeze.md. + drawCanvas.refreshManager.drawCanvasToView(strokeArea) val zoneEffected = handleErase( drawCanvas.page, @@ -384,8 +388,12 @@ class OnyxInputHandler( points, eraser = toolbarState.eraser ) - if (zoneEffected != null) - drawCanvas.refreshManager.refreshUi(zoneEffected) + + // Repaint the whole touched region (indicator track ∪ erased strokes' bounds) and + // resume the pen with the erase-specific delay, serialized on one coroutine. + val dirty = Rect(strokeArea) + if (zoneEffected != null) dirty.union(zoneEffected) + coroutineScope.launch { drawCanvas.refreshManager.refreshAfterErase(dirty) } } } \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt b/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt index f6e44221..403f7cef 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds private val log = ShipBook.getLogger("einkHelper") @@ -487,12 +488,22 @@ object DeviceCompat { false } } - suspend fun delayBeforeResumingDrawing() { + suspend fun delayBeforeResumingDrawing(isErasing: Boolean = false) { if (!isOnyxDevice) return - // 500ms for Kaleido Color e-ink, 300ms for monochrome - val delayMs = if (isColorDevice()) 500L else 300L - log.d("delayBeforeResumingDrawing: Delaying raw drawing resume for ${delayMs}ms to allow Android UI to settle") - delay(delayMs) + // Resume delays mirror the Onyx SDK (NoteConstant): + // - erasing: 500ms regardless of device (NoteConstant.ERASE_DELAY_RESUME_PEN_TIME). + // The EPD needs longer to absorb a cleared region before the firmware re-freezes + // the screen; resuming too early lets a stroke drawn right after an erase + // composite against stale content ("erased stroke reappears"). + // - normal pen: 500ms for Kaleido color, 300ms for monochrome. + // See docs/onyx-pen-up-refresh-and-screen-freeze.md. + val delay = when { + isErasing -> 500.milliseconds + isColorDevice() -> 500.milliseconds + else -> 300.milliseconds + } + log.d("delayBeforeResumingDrawing(isErasing=$isErasing): Delaying raw drawing resume for ${delay}ms") + delay(delay) log.d("delayBeforeResumingDrawing: Resuming raw drawing") } } \ No newline at end of file diff --git a/docs/onyx-pen-up-refresh-and-screen-freeze.md b/docs/onyx-pen-up-refresh-and-screen-freeze.md index a22bcfd4..4f62bac2 100644 --- a/docs/onyx-pen-up-refresh-and-screen-freeze.md +++ b/docs/onyx-pen-up-refresh-and-screen-freeze.md @@ -315,6 +315,65 @@ So the **authoritative** wait between "screen refreshed" and "raw drawing re-ena > but the **300 ms monochrome value is guess-work and is double the official 150 ms** > (`COMMON_PEN_RESUME_DELAY_TIME_MS`). Consider aligning to the SDK constants. +### The full erase→draw cycle and its two timing windows — [demo] `PenDemoActivity` + +`ScribbleMoveEraserDemoActivity` (above) is the *minimal* erase demo and **omits the +timing**. The full `PenDemoActivity` shows the real, timed pipeline. Tracing one +draw → erase → draw cycle answers the exact questions: + +**Phase 1 — drawing (screen frozen):** raw drawing enabled; the firmware owns the screen +and draws live. App mirrors each finished stroke into its bitmap. + +**Phase 2 — erasing with the pen button (still frozen, until lifted):** +- `onBeginRawErasing` / `onRawErasingPointMove` → `StrokeErasingRequest`, which is created + with **`setPauseRawDraw(false)`** — i.e. **raw drawing is *not* paused during the erase**. + Erasing runs live while the pen is down: hit-test & mark shapes transparent, render to the + bitmap (and optionally show an erase-circle track). The screen stays frozen the whole time. +- **Q: is there a wait to "finish the stroke" before the screen refreshes?** **No.** Nothing + sleeps while erasing or at pen-lift-before-refresh. On pen up + (`onRawErasingTouchPointListReceived`) it goes straight to `StrokesEraseFinishedRequest`: + `BaseRequest.beforeExecute` calls `setRawDrawingEnabled(false)` (pause render **and** + input), the bitmap is re-rendered (white + surviving shapes, erased ones gone), and + `afterExecute` blits it to screen **immediately**. So: erase commit → screen shows result, + with no timed delay in that step. + +**Phase 3 — between "erased result shown" and the next scribble:** +- After the bitmap is on screen, `RefreshScreenAction` posts + `PenEvent.resumeRawDrawing(DELAY_ENABLE_RAW_DRAWING_MILLS)` → `ResumeRawDrawingRequest`, + whose `execute()` is: + + ```java + ThreadUtils.mySleep(delayResumePenTimeMs); // <-- THE wait, before re-enabling + updatePenParam(); // restore strokeStyle/width/color/penUpRefresh + updateDrawExcludeRect(); + setRawDrawingRenderEnabled(true); // hand screen back to firmware + setRawInputReaderEnable(true); // accept input again + ``` + +- **Q: is there a wait before the next scribble can start?** **Yes — this is the one that + matters.** Raw-drawing render *and input* stay disabled for + `DELAY_ENABLE_RAW_DRAWING_MILLS` = **150 ms (monochrome) / 500 ms (color)** + (`PenEvent.DELAY_ENABLE_RAW_DRAWING_MILLS = isColorDevice() ? COLOR_DEVICE_PEN_RESUME_DELAY_TIME_MS : COMMON_PEN_RESUME_DELAY_TIME_MS`). + Until that elapses the firmware is not re-armed, so a new stroke genuinely cannot begin — + giving the EPD time to absorb the refreshed (erased) image before it re-freezes for the + next stroke. + +**What the simple `ScribbleMoveEraserDemoActivity` left out (verified in the full demo + SDK):** +1. **The post-refresh resume delay** (`mySleep(150/500 ms)`) before re-enabling raw drawing — + the minimal demo just flips `setRawDrawingRenderEnabled(true)` in `onEndRawErasing` with no + wait. This is the missing piece behind "draw immediately after erase → stale content". +2. **`updatePenParam()` on resume** — re-applies stroke style/width/color/pen-up-refresh, + because re-enabling raw drawing runs `resetPenDefaultRawDrawing()` and wipes them + (see `docs/onyx-native-eraser-indicator.md` for the same reset gotcha). +3. **Pausing raw *input* too** during the finish/resume window (`setRawInputReaderEnable`), + not just render — so stray points during the refresh can't be injected. +4. A dedicated SDK constant the demos don't even use: **`NoteConstant.ERASE_DELAY_RESUME_PEN_TIME = 500`** — an erase-specific resume delay (the generic path uses 150/500; the production note app appears to use the 500 ms erase value regardless of mono/color). [sdk] +5. **[framework]** Underneath, `BaseHandler` also runs its own post-stroke cleanup: erasing + sets pen state `PEN_ERASING (4)`, and on stylus-up it schedules + `ViewUpdateHelper.handwritingRepaint(...)` after `EACNoteConfig.repaintLatency` (**500 ms** + default). So there are effectively *two* settle timers — the app's resume delay and the + firmware's repaint latency. + ### The actual fix for the "erased stroke reappears" bug Mirror the demo's *ordering and mechanism*, not a bigger delay: @@ -336,6 +395,15 @@ gating resume behind the single-threaded Rx queue. Serializing erase-commit → delay → re-enable (e.g. under `CanvasEventBus.drawingInProgress`/`waitForDrawing()`, on one thread) is what closes the bug — increasing the delay only masks it. +> **Status [notable].** The erase path now does this: `OnyxInputHandler.onRawErasingList` +> posts the indicator-clear, then runs `CanvasRefreshManager.refreshAfterErase(...)`, which +> on a **single coroutine** does `isRawDrawingRenderEnabled=false` → post the erased bitmap +> and **await** it landing on the surface → `delayBeforeResumingDrawing(isErasing=true)` +> (**500 ms**, `ERASE_DELAY_RESUME_PEN_TIME`) → `isRawDrawingRenderEnabled=true`. This +> replaces the old racing pair (`refreshUi`'s async `drawCanvasToView` post on the main +> thread vs `resetScreenFreeze` on `Dispatchers.Default`) and the 300 ms mono erase delay. +> Still outstanding: collapsing the double refresh and pausing raw *input* during the window. + > Notable's own comments already smell this race: in > `einkHelper.partialRefreshRegionOnce` ("onyx library has its own buffer that needs to > be updated. Otherwise we will refresh to correct, then incorrect and then correct From 2732a0f1edb5708128fe0abf680629780972eb01 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Tue, 30 Jun 2026 21:25:24 +0200 Subject: [PATCH 5/9] redraw correct rectangle --- .../editor/canvas/CanvasRefreshManager.kt | 115 ++++++++++++++---- .../notable/editor/canvas/OnyxInputHandler.kt | 51 +++++--- .../ethran/notable/editor/utils/einkHelper.kt | 18 +-- .../com/ethran/notable/editor/utils/eraser.kt | 11 +- 4 files changed, 146 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt index a0a8411b..2777a13b 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt @@ -12,13 +12,15 @@ import com.ethran.notable.editor.drawing.selectPaint import com.ethran.notable.editor.state.Mode import com.ethran.notable.editor.utils.DeviceCompat import com.ethran.notable.editor.utils.pointsToPath +import com.ethran.notable.editor.utils.enableNativeEraser import com.ethran.notable.editor.utils.refreshScreenRegion import com.ethran.notable.editor.utils.resetScreenFreeze import com.ethran.notable.utils.logCallStack +import com.onyx.android.sdk.api.device.epd.EpdController import com.onyx.android.sdk.pen.TouchHelper import io.shipbook.shipbooksdk.Log import io.shipbook.shipbooksdk.ShipBook -import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch class CanvasRefreshManager( private val drawCanvas: DrawCanvas, @@ -69,32 +71,99 @@ class CanvasRefreshManager( } /** - * Serialized erase resume (see docs/onyx-pen-up-refresh-and-screen-freeze.md): - * freeze firmware compositing -> post the erased bitmap and WAIT until it is actually on - * the surface -> let the EPD settle for the erase-specific delay (500 ms) -> hand the - * screen back. Running post + delay + re-enable in order on one coroutine (instead of - * racing [drawCanvasToView]'s async post against [resetScreenFreeze] on another thread) - * guarantees the firmware re-freezes against the clean, erased image, not stale content. + * Atomic erase commit — used by BOTH the eraser (pen button / hand) and scribble-to-erase + * paths so a pen lift removes the eraser indicator and the erased strokes in **one** step, + * with no window the user can draw into. See docs/onyx-pen-up-refresh-and-screen-freeze.md. + * + * The surface work runs **synchronously on the calling (raw-input callback) thread**, not + * via [drawCanvas] `post{}`. That matters: the firmware runs its own pen-up refresh shortly + * after the pen lifts, which would remove the (firmware-drawn) eraser indicator first and + * leave the strokes — then our app would remove the strokes a moment later. That is the + * "double refresh" the user sees. Doing the swap synchronously and immediately, before + * yielding, beats the firmware's pen-up refresh so there is a single transition. + * + * Must be called after the page bitmap has already been repainted to the erased state + * (i.e. after `handleErase` / `handleScribbleToErase`). */ - suspend fun refreshAfterErase(dirtyRect: Rect?) { - // 1. stop the firmware compositing its (indicator) layer over our surface - touchHelper?.isRawDrawingRenderEnabled = false - // 2. post the erased page bitmap and block until it has reached the surface - awaitDrawCanvasToView(dirtyRect) - // 3. give the EPD time to absorb the cleared region before re-arming the pen - DeviceCompat.delayBeforeResumingDrawing(isErasing = true) - // 4. hand the screen back to the firmware (only if we're still drawing) - if (viewModel.toolbarState.value.isDrawing) { - touchHelper?.isRawDrawingRenderEnabled = true - } else { - log.w("refreshAfterErase: not in drawing mode, leaving render disabled") + fun commitErase(dirtyRect: Rect?, areaErase: Boolean = false) { + val dirty = dirtyRect ?: Rect(0, 0, page.viewWidth, page.viewHeight) + // Mechanism copied from the official Onyx note app (com.onyx.kreader + // PenEventHandler.onEraseRefreshResultEvent): commit an erase by FULLY toggling + // setRawDrawingEnabled(false) -> (true), NOT the light isRawDrawingRenderEnabled + // toggle Notable used before. Fully disabling raw drawing hands the screen back to the + // SurfaceView — which we have already painted with the erased bitmap below — so the + // firmware eraser track AND the erased strokes update in ONE atomic transition: no + // stale snapshot, no gap to start drawing into. (The light render toggle only stops + // the firmware compositing its layer and reveals its last internal snapshot, which is + // exactly the "erased info didn't reach the screen controller" bug.) + // The official app re-arms after WaitForUpdateFinishedAction = max(minWait, 150)ms, + // i.e. 150ms for stroke/scribble erase and 500ms for an area (lasso) erase. + // + // ORDER IS CRITICAL — push the clean page to the PANEL first, THEN drop the firmware + // layer. kreader's scratch gesture (scribble-to-erase) removes the scribble strokes AND + // the overlapped strokes in ONE model mutation, re-renders the clean page to screen + // (RenderRequest.renderToScreen, forced out via enablePost while raw drawing is still ON), + // and only AFTER that runs setRawDrawingEnabled(false) (PenEventHandler + // .onShapeRefreshForHWREvent -> A(false) -> B(true,0)). That order is what makes the + // scribble and the strokes vanish in the SAME frame: the panel already shows the clean + // page before the firmware layer is removed, so there is no stale flash. + // The REVERSE order (disable first, then post) clears the big firmware scribble ink + // first and briefly reveals the not-yet-erased surface underneath — the flash the user + // sees, worst on scribble-to-erase. + // 1. Block input immediately so no stroke can start during the swap/settle. + touchHelper?.setRawInputReaderEnable(false) + drawCanvas.coroutineScope.launch { + // 2. Push the erased page bitmap to the EPD *while still frozen* (drawBitmapToSurfaceSync + // forces it through with enablePost(0)+enablePost(1), like kreader's renderToScreen). + drawBitmapToSurfaceSync(dirty) + // 3. Now drop the firmware raw layer (eraser indicator / scribble ink). The panel + // already shows the clean page, so this is a no-flash transition. + touchHelper?.setRawDrawingEnabled(false) + // 4. Settle before re-arming (150ms stroke / 500ms area), mirroring the official app. + DeviceCompat.delayBeforeResumingDrawing(isErasing = true, areaErase = areaErase) + // 5. Re-arm raw drawing. The heavy toggle resets the eraser channel and stroke + // style, so re-assert both (matches the official C(true) path). + if (viewModel.toolbarState.value.isDrawing) { + touchHelper?.setRawDrawingEnabled(true) + enableNativeEraser(touchHelper) + drawCanvas.inputHandler.updatePenAndStroke() + touchHelper?.setRawInputReaderEnable(true) + } else { + log.w("commitErase: not in drawing mode, leaving raw drawing disabled") + } } } - private suspend fun awaitDrawCanvasToView(dirtyRect: Rect?) { - val done = CompletableDeferred() - drawCanvasToView(dirtyRect) { done.complete(Unit) } - done.await() + /** Synchronous variant of [drawCanvasToView] (no `post{}` hop). Locks, draws the page + * bitmap for [dirtyRect], and posts — on the calling thread. */ + private fun drawBitmapToSurfaceSync(dirtyRect: Rect?) { + val zoneToRedraw = dirtyRect ?: Rect(0, 0, page.viewWidth, page.viewHeight) + var canvas: Canvas? = null + try { + canvas = drawCanvas.holder.lockCanvas(zoneToRedraw) + if (canvas == null) { + log.e("commitErase: failed to lock canvas (surface invalid/destroyed)") + return + } + // Force the post through to the EPD even though raw-drawing render is still ON + // (the screen is frozen). Mirrors kreader's RxBaseReaderRequest.unlockCanvas: + // enablePost(0)+enablePost(1) bracketing the post, resetViewUpdateMode afterward. + // A single enablePost — or a bare unlockCanvasAndPost — is swallowed while frozen, + // so the erased page would never reach the panel before we drop the firmware layer. + EpdController.enablePost(0) + EpdController.enablePost(1) + canvas.drawBitmap(page.windowedBitmap, zoneToRedraw, zoneToRedraw, Paint()) + } catch (e: IllegalStateException) { + log.w("Surface released during erase draw", e) + } finally { + try { + if (canvas != null) drawCanvas.holder.unlockCanvasAndPost(canvas) + // kreader's afterUnlockCanvas equivalent — let the EPD apply the pushed region. + EpdController.resetViewUpdateMode(drawCanvas) + } catch (e: IllegalStateException) { + log.w("Surface released during unlock", e) + } + } } fun drawCanvasToView(dirtyRect: Rect?, onPosted: (() -> Unit)? = null) { diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt b/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt index f5905ba0..c54c2dc0 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt @@ -24,7 +24,6 @@ import com.ethran.notable.editor.utils.handleErase import com.ethran.notable.editor.utils.handleScribbleToErase import com.ethran.notable.editor.utils.handleSelect import com.ethran.notable.editor.utils.onSurfaceInit -import com.ethran.notable.editor.utils.partialRefreshRegionOnce import com.ethran.notable.editor.utils.penToStroke import com.ethran.notable.editor.utils.prepareForPartialUpdate import com.ethran.notable.editor.utils.restoreDefaults @@ -342,13 +341,33 @@ class OnyxInputHandler( ) } else { log.d("Erased by scribble, $erasedByScribbleDirtyRect") - drawCanvas.refreshManager.drawCanvasToView(erasedByScribbleDirtyRect) - partialRefreshRegionOnce( - drawCanvas, - erasedByScribbleDirtyRect, - touchHelper!! + // Union the scribble TRACK (what the firmware drew live, in screen + // coords from the raw points) with the erased strokes' bounds, exactly + // like the eraser path (onRawErasingList). commitErase pushes this + // union to the panel WHILE STILL FROZEN, so the post overwrites BOTH + // the firmware scribble ink and the erased writing in one step; only + // then does it drop the firmware layer — which now reveals an already + // clean page, so scribble and writing vanish together with no gap. + // (Previously only the erased-strokes bounds were pushed, leaving the + // rest of the scribble ink on screen until the firmware layer dropped + // a moment later — the visible gap.) The scribble is NOT drawn into + // the page bitmap (unlike kreader, which keeps it as a Shape); we only + // need the pushed region to cover the firmware's track. See + // docs/onyx-scribble-to-erase.md. + val padding = 10 + val trackBox = + calculateBoundingBox(plist.points) { Pair(it.x, it.y) }.toRect() + val dirty = Rect( + trackBox.left - padding, + trackBox.top - padding, + trackBox.right + padding, + trackBox.bottom + padding ) - + erasedByScribbleDirtyRect.let { dirty.union(it) } + // Longer (area) settle: a scribble is a large-area gesture, far heavier + // on the EPD than a thin stroke erase — 150ms was too short and let the + // next stroke composite against not-yet-settled content. + drawCanvas.refreshManager.commitErase(dirty, areaErase = true) } } @@ -376,12 +395,6 @@ class OnyxInputHandler( boundingBox.right + padding, boundingBox.bottom + padding ) - // Quickly clear the native eraser indicator track (plain post, no freeze toggle). - // The single freeze -> post -> settle -> resume sequence is done, serialized, in - // refreshAfterErase below, so we don't race two screen-freeze toggles on different - // threads. See docs/onyx-pen-up-refresh-and-screen-freeze.md. - drawCanvas.refreshManager.drawCanvasToView(strokeArea) - val zoneEffected = handleErase( drawCanvas.page, history, @@ -389,11 +402,17 @@ class OnyxInputHandler( eraser = toolbarState.eraser ) - // Repaint the whole touched region (indicator track ∪ erased strokes' bounds) and - // resume the pen with the erase-specific delay, serialized on one coroutine. + // Single atomic commit of the whole touched region: the native eraser indicator + // track spans strokeArea, the erased strokes' bounds are zoneEffected, so repainting + // their union both wipes the indicator and shows the erased result in one pass. + // commitErase blocks input, draws synchronously, then drops the firmware overlay so + // indicator + strokes disappear together (no double refresh, no gap to draw into). + // See docs/onyx-pen-up-refresh-and-screen-freeze.md. val dirty = Rect(strokeArea) if (zoneEffected != null) dirty.union(zoneEffected) - coroutineScope.launch { drawCanvas.refreshManager.refreshAfterErase(dirty) } + // Area (lasso/select) erase needs the longer 500ms settle the official app uses; the + // pen/marker erase uses the 150ms stroke settle. + drawCanvas.refreshManager.commitErase(dirty, areaErase = toolbarState.eraser == Eraser.SELECT) } } \ No newline at end of file diff --git a/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt b/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt index 403f7cef..611703ed 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt @@ -488,17 +488,21 @@ object DeviceCompat { false } } - suspend fun delayBeforeResumingDrawing(isErasing: Boolean = false) { + suspend fun delayBeforeResumingDrawing(isErasing: Boolean = false, areaErase: Boolean = false) { if (!isOnyxDevice) return - // Resume delays mirror the Onyx SDK (NoteConstant): - // - erasing: 500ms regardless of device (NoteConstant.ERASE_DELAY_RESUME_PEN_TIME). - // The EPD needs longer to absorb a cleared region before the firmware re-freezes - // the screen; resuming too early lets a stroke drawn right after an erase - // composite against stale content ("erased stroke reappears"). + // Resume delays mirror the official Onyx note app (com.onyx.kreader + // WaitForUpdateFinishedAction, driven from PenEventHandler.onEraseRefreshResultEvent): + // - erasing: the official app waits max(minWait, 150)ms before re-arming, where + // minWait = 500 for an AREA (lasso/select) erase and 0 for stroke/move erase. + // So: 150ms for a normal stroke/scribble erase, 500ms for an area erase. This is + // safe at 150ms ONLY because the commit uses the heavy setRawDrawingEnabled(false) + // toggle (see CanvasRefreshManager.commitErase), which hands the screen back to the + // erased SurfaceView atomically — the light isRawDrawingRenderEnabled toggle needed + // a longer, more conservative delay to hide its stale snapshot. // - normal pen: 500ms for Kaleido color, 300ms for monochrome. // See docs/onyx-pen-up-refresh-and-screen-freeze.md. val delay = when { - isErasing -> 500.milliseconds + isErasing -> if (areaErase) 500.milliseconds else 150.milliseconds isColorDevice() -> 500.milliseconds else -> 300.milliseconds } diff --git a/app/src/main/java/com/ethran/notable/editor/utils/eraser.kt b/app/src/main/java/com/ethran/notable/editor/utils/eraser.kt index 6b02b341..b82abe1b 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/eraser.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/eraser.kt @@ -131,9 +131,14 @@ fun handleScribbleToErase( val deletedStrokeIds = deletedStrokes.map { it.id } page.removeStrokes(deletedStrokeIds) history.addOperationsToHistory(listOf(Operation.AddStroke(deletedStrokes))) - val dirtyRect = strokeBounds(deletedStrokes) - page.drawAreaPageCoordinates(dirtyRect) - return dirtyRect + // Return the erased region in SCREEN coordinates (mirrors handleErase). The caller + // pushes this rect to the SurfaceView/EPD via commitErase, and the surface bitmap + // (windowedBitmap) is in screen space — returning page coords here pushed the wrong + // region whenever scrolled/zoomed. The caller unions this with the scribble track so + // the firmware ink is cleared in the same post. See docs/onyx-scribble-to-erase.md. + val effectedArea = page.toScreenCoordinates(strokeBounds(deletedStrokes)) + page.drawAreaScreenCoordinates(screenArea = effectedArea) + return effectedArea } return null } From 401dafacc4e2548267bf171bff200836e29b9f85 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Tue, 30 Jun 2026 21:26:43 +0200 Subject: [PATCH 6/9] remove unchecked doss --- docs/onyx-finger-scribble.md | 199 -------- docs/onyx-native-eraser-indicator.md | 214 -------- docs/onyx-neo-fountain-pen-v2.md | 286 ----------- docs/onyx-pen-up-refresh-and-screen-freeze.md | 472 ------------------ ...nyx-pressure-sensitivity-and-line-width.md | 191 ------- 5 files changed, 1362 deletions(-) delete mode 100644 docs/onyx-finger-scribble.md delete mode 100644 docs/onyx-native-eraser-indicator.md delete mode 100644 docs/onyx-neo-fountain-pen-v2.md delete mode 100644 docs/onyx-pen-up-refresh-and-screen-freeze.md delete mode 100644 docs/onyx-pressure-sensitivity-and-line-width.md diff --git a/docs/onyx-finger-scribble.md b/docs/onyx-finger-scribble.md deleted file mode 100644 index 98f5a3c1..00000000 --- a/docs/onyx-finger-scribble.md +++ /dev/null @@ -1,199 +0,0 @@ -# Onyx "Open Finger Scribble" — drawing with a finger - -This documents how the Onyx demo's **"Open finger scribble"** toggle works, what it -does at the SDK level, and the two *different* finger/touch mechanisms that are easy to -confuse. Grounded in the official `OnyxAndroidDemo` -(`ScribbleFingerTouchDemoActivity`) and the decompiled `onyxsdk-pen` SDK. - -**Source authority** (same tags as the other docs): -- **[demo]** — verified against `OnyxAndroidDemo` source. -- **[sdk]** — verified by decompiling the Onyx `.aar`s. - ---- - -## 1. What "Open finger scribble" is - -By default, Onyx raw-drawing mode (`TouchHelper` + `setRawDrawingEnabled(true)`) only -reacts to the **stylus**. Capacitive **finger** touches are ignored for drawing — they -fall through to the normal Android view system (scroll, buttons, etc.). This is what you -want 99% of the time: you rest your palm on the screen while writing and it doesn't -leave ink. - -**"Open finger scribble"** is the checkbox (`cb_enable_finger`, label literally -"Open finger scribble") that flips this: it makes the **finger also produce raw-drawing -strokes**, so you can scribble with a fingertip, not just the pen. [demo] - -```java -// ScribbleFingerTouchDemoActivity.enableFingerTouch(...) -public void enableFingerTouch(View view, boolean checked) { - if (touchHelper == null) return; - touchHelper.setRawDrawingEnabled(false); - touchHelper.setRawDrawingEnabled(true); // re-arm raw drawing around the change - touchHelper.enableFingerTouch(checked); // <-- the actual feature -} -``` - -The `setRawDrawingEnabled(false)`/`(true)` bracket is just to re-arm the raw-drawing -pipeline cleanly so the flag change takes effect for the next stroke. - ---- - -## 2. How it works inside the SDK [sdk] - -`TouchHelper.enableFingerTouch(enable)` fans the flag out to every `TouchRender`, which -forwards it to the native input reader (`AppTouchInputReader`). What is **verified** from -the decompiled `AppTouchInputReader`: - -- It holds two boolean fields: one set by `setEnableFingerTouch(boolean)` (call it - `enableFinger`) and one set by `setOnlyEnableFingerTouch(boolean)` (`onlyFinger`). [sdk] -- A single private filter method, keyed on `TouchUtils.isPenTouchType(event)` (which - classifies the `MotionEvent` tool type as stylus/eraser vs finger), decides per event - whether to feed it into the raw-drawing pipeline. The down/move/up handlers all gate on - it (`if (filter(event)) { dispatch… }`). [sdk] -- Both `enableFingerTouchPressure(boolean)` and `setFingerTouchPressure(float)` exist for - giving the (pressure-less) finger a synthetic pressure. [sdk] - -> ⚠️ The exact boolean body of that filter is **not** quoted here: CFR decompiles this -> particular method into mangled code (stray `void var1_1`, inverted assignments), so the -> precise skip/keep polarity can't be trusted from the bytecode. The **observable -> behaviour** below is the reliable contract (it matches Onyx's documented semantics), so -> this doc states behaviour, not a reconstructed expression. - -The three reachable states (by behaviour): - -| State | API call | Who can draw | -|---|---|---| -| **Default** | (none) | **stylus only** | -| **Finger enabled** | `enableFingerTouch(true)` | **stylus + finger** | -| **Finger only** | `onlyEnableFingerTouch(true)` | **finger only** | - -So "Open finger scribble" = move from row 1 to row 2: the capacitive finger now feeds -the same raw-drawing path the stylus uses, producing identical `onRawDrawing*` callbacks -and identical ink. - -### Finger has no pressure -A capacitive finger reports no real pen pressure. The SDK exposes: -- `enableFingerTouchPressure(boolean)` / `setFingerTouchPressure(float)` [sdk] - -to assign a **synthetic constant pressure** to finger strokes, so pressure-sensitive pen -styles (fountain, brush) still render a sensible width when drawn with a finger. The SDK's -default for this is **`PenConstant.DEFAULT_FINGER_TOUCH_PRESSURE = 1500.0f`** [sdk] (on a -~4096 max-pressure scale, i.e. roughly mid-range) — and `AppTouchInputReader` applies it in -its `setPressure(this.j)` path only when the finger-pressure flag (`this.i`) is enabled, -confirming it is an opt-in override of the real (zero) finger pressure. [sdk] - ---- - -## 3. The OTHER finger mechanism — palm/finger rejection (don't confuse these) - -The same demo *also* uses a completely separate, lower-level touch API, and the two are -easy to mix up: - -```java -// ScribbleFingerTouchDemoActivity callback -onBeginRawDrawing(...) -> TouchUtils.disableFingerTouch(getApplicationContext()); -onEndRawDrawing(...) -> TouchUtils.enableFingerTouch(getApplicationContext()); -``` - -`TouchUtils` here is a demo helper that calls the **EPD controller**, not `TouchHelper`: - -```java -// disable: block capacitive touch over a screen region (the whole screen here) -EpdController.setAppCTPDisableRegion(context, new Rect[]{ fullScreenRect }); -// enable: clear the block -EpdController.appResetCTPDisableRegion(context); -``` - -CTP = **C**apacitive **T**ouch **P**anel. This is **palm rejection at the hardware -panel level**: while a pen stroke is in progress, it tells the panel to drop *all* -finger/palm touches in the region so your resting hand can't generate spurious input or -fight the stylus. It is re-enabled the instant the stroke ends. - -### The two are different layers, used together - -| | `TouchHelper.enableFingerTouch` | `EpdController.setAppCTPDisableRegion` | -|---|---|---| -| Layer | raw-drawing input reader (`TouchHelper`) | EPD / capacitive panel driver | -| Question it answers | "Should a finger event become **ink**?" | "Should the panel deliver finger touches **at all** right now?" | -| Scope | the drawing surface | a screen region (here, full screen) | -| Lifetime | a persistent mode (the checkbox) | toggled per-stroke (begin/end raw drawing) | -| Purpose | let the user *draw* with a finger | *reject the palm* during a pen stroke | - -They cooperate: even with finger-scribble **on**, the demo still disables the CTP region -during an active *pen* stroke (`onBeginRawDrawing`), so a palm landing mid-pen-stroke -doesn't inject a competing finger stroke; finger input resumes at `onEndRawDrawing`. - ---- - -## 3c. A THIRD finger layer — the system handwriting handler [framework] - -Decompiling the device `framework.jar` reveals a third, system-level finger mechanism that -sits *above* both of the above (it runs in the framework, not in the app): - -`android.onyx.optimization.screennote.handler.BaseHandler` watches every note "draw view". -On a **finger down** it does: - -```java -private void onFingerDownImpl(View view, EACNoteConfig cfg) { - pauseEACScreenNote(); // setScreenHandWritingPenState(PEN_PAUSE = 3) - ViewUpdateHelper.repaintEverything(); -} -``` - -i.e. the firmware **pauses hardware handwriting and forces a clean full repaint the moment -a finger lands**, independent of anything the app does. The stylus down path resumes it -(`PEN_DRAWING = 2`). This is why, on stock Onyx behaviour, touching with a finger cleans up -the fast-mode ghosting — and why an app that wants finger-*drawing* has to opt in explicitly -(`enableFingerTouch`), since the default system reflex to a finger is "stop drawing, repaint". - -## 3d. The raw input reader (`libonyx_touch_reader`) [framework] - -`android.onyx.inputreader.RawInputReader` is the framework JNI bridge under the SDK's -`TouchHelper` (loads `libonyx_touch_reader.so`). Useful facts: - -- The raw callback is `onTouchPointReceived(x, y, pressure, erasing, …, action, time)` with - action codes: `PEN_DOWN=0, PEN_MOVE=1, PEN_RELEASE=2, PEN_RELEASE_OUT_LIMIT_REGION=3, - PEN_INVALID=4, PEN_ACTIVE=5` (5 = hover). Note **`PEN_RELEASE_OUT_LIMIT_REGION`**: the - driver distinguishes a normal pen-up from one that happens outside the limit rect. -- **The "erasing" flag is decided in hardware/driver**, not the app: `this.erasing = z` - comes straight off the touch report. So whether a contact is an erase (eraser tip / side - button) is determined below the framework — the app only reacts to it. -- Points are buffered in a `TouchPointList(600)` (initial capacity 600). -- `setLimitRect` / `setExcludeRect` each drive **two** layers at once: the native input mask - (`nativeSetLimitRegion` / `nativeSetExcludeRegion`) **and** the firmware handwriting region - (`ViewUpdateHelper.setScreenHandWritingRegionLimit/Exclude`). So the limit/exclude rects - Notable passes in `setupSurface` clip both *input* and *firmware rendering*. -- Region mode: `nativeSetRegionMode(0)` = multi-region, `(1)` = single-region (each also - mirrored to `ViewUpdateHelper.setScreenHandWritingRegionMode`). -- `nativeSetPenState(4)` (`PEN_INVALID`) is asserted on `pause()` / `resume()` / `quit()`. - -See `docs/investigation.md` for the full framework trace. - -## 4. The `create(view, stylus, callback)` overload [sdk] - -The finger demo creates its helper with the 3-arg overload: - -```java -touchHelper = TouchHelper.create(getHostView(), false, callback); -``` - -The boolean maps to an internal *feature* code: `stylus ? 2 : 1` -(`create(view, boolean, cb)` → `create(view, stylus?2:1, cb)`). Passing `false` selects -feature `1`. This is the registration-time feature flag; the runtime per-stroke -finger/pen behaviour is still governed by `enableFingerTouch` / `onlyEnableFingerTouch` -as described above. - ---- - -## 5. Practical notes for Notable - -- If Notable ever wants a "draw with finger" option, the lever is - `touchHelper.enableFingerTouch(true)` (optionally `onlyEnableFingerTouch` for a - finger-only mode), plus `enableFingerTouchPressure`/`setFingerTouchPressure` so - pressure pens look right. Re-arm raw drawing around the change as the demo does. -- Keep finger-scribble and palm-rejection conceptually separate. Even with finger - drawing enabled, you almost always still want to disable the CTP region during a pen - stroke (begin/end raw drawing) for palm rejection — otherwise resting your hand while - using the pen will draw with your palm. -- Default behaviour (no calls) is already "stylus only," which is the right default for a - note app; finger-scribble is an opt-in. diff --git a/docs/onyx-native-eraser-indicator.md b/docs/onyx-native-eraser-indicator.md deleted file mode 100644 index 957e4adb..00000000 --- a/docs/onyx-native-eraser-indicator.md +++ /dev/null @@ -1,214 +0,0 @@ -# Native eraser indicator (pen side-button erasing) - -How to make the **pen side-button eraser** render a visible stroke ("erasing -indicator") using the firmware's **native** rendering, instead of Notable's OpenGL -front-buffer workaround. Grounded in the decompiled `onyxsdk-pen` / `onyxsdk-device` -SDK and Notable's own code. - -**Source authority:** **[sdk]** = decompiled Onyx `.aar`; **[notable]** = Notable code. - ---- - -## 1. The problem - -Notable supports two ways to erase: - -- **Hand erase** — the user picks the eraser tool in the toolbar (`Mode.Erase`). The - pen *tip* is then interpreted as an eraser. This goes through the normal - raw-**drawing** path (`onRawDrawingTouchPointListReceived` → `onRawDrawingList`, branch - `Mode.Erase`), and the firmware draws a normal stroke as feedback (Notable configures a - grey `MARKER` for this in `updatePenAndStroke`). So hand-erase already has a native - indicator. [notable] -- **Pen side-button erase** — the user holds the stylus button. The firmware fires the - raw-**erasing** callbacks (`onBeginRawErasing` → `onRawErasingTouchPointListReceived` - → `onEndRawErasing`). **By default the firmware draws nothing during button erasing.** - -To give button-erase a visible indicator, Notable previously used an **OpenGL -front-buffer renderer** (`OpenGLRenderer` / `GLFrontBufferedRenderer`): while -`isErasing`, `DrawCanvas.dispatchTouchEvent` routed stylus events into the GL renderer, -which painted the indicator itself. This is the "nasty workaround that does not use -native rendering." It works, but it duplicates rendering the firmware can do natively. - -The official (closed-source) Onyx note app instead makes the side-button produce the -same kind of native stroke as a normal pen action — a clean, low-latency erasing -indicator drawn by the firmware. - ---- - -## 2. The native API [sdk] - -`com.onyx.android.sdk.pen.TouchHelper`: - -```java -public TouchHelper setEraserRawDrawingEnabled(boolean drawing, int eraserStyle) -``` - -- `drawing` — when `true`, the firmware **renders the eraser path natively** while you - erase with the side button (just like a normal stroke). When `false`, button-erasing - draws nothing (the default). -- `eraserStyle` — which stroke style to draw the eraser path with; uses the - `TouchHelper.STROKE_STYLE_*` constants: - - | Constant | Value | - |---|---| - | `STROKE_STYLE_PENCIL` | 0 | - | `STROKE_STYLE_FOUNTAIN` | 1 | - | `STROKE_STYLE_MARKER` | 2 | - | `STROKE_STYLE_NEO_BRUSH` | 3 | - | `STROKE_STYLE_CHARCOAL` | 4 | - | `STROKE_STYLE_DASH` | 5 | - | `STROKE_STYLE_CHARCOAL_V2` | 6 | - | `STROKE_STYLE_SQUARE_PEN` | 7 | - -`TouchHelper` fans the call out to each `TouchRender`, which forwards it to the device -layer (`Device.setEraserRawDrawingEnabled`), which reflects into the system EPD -controller. The SDK's own `resetPenDefaultRawDrawing()` calls -`Device.currentDevice().setEraserRawDrawingEnabled(false, 5)` — confirming the default is -**disabled, DASH style**. [sdk] - -### THE BIG GOTCHA: `setRawDrawingEnabled(true)` resets it [sdk] - -The hardest bug to spot. Decompiling `TouchHelper` shows: - -```java -public TouchHelper setRawDrawingEnabled(boolean enabled) { - ... - setRawDrawingRenderEnabled(enabled); - setRawInputReaderEnable(enabled); - resetPenDefaultRawDrawing(); // <-- this - return this; -} - -public void resetPenDefaultRawDrawing() { - Device.currentDevice().setBrushRawDrawingEnabled(true); - Device.currentDevice().setEraserRawDrawingEnabled(false, 5); // <-- DISABLES it -} -``` - -So **every** `setRawDrawingEnabled(true)` call silently turns the native eraser channel -back **off** (style 5 = DASH). This bit Notable twice: - -1. `setupSurface` originally enabled the eraser *before* its final - `setRawDrawingEnabled(true)` → instantly reset to disabled. -2. `updateIsDrawing()` calls `setRawDrawingEnabled(true)` on every drawing resume → wipes - it again even if (1) were fixed. - -**Fix (two parts):** -- In `setupSurface`, call `setEraserRawDrawingEnabled(true, …)` **after** - `setRawDrawingEnabled(true)`. -- **Re-assert** it in `onBeginRawErasing` (via the shared `enableNativeEraser(touchHelper)` - helper) so it survives later `updateIsDrawing()` resets. This is the call that actually - makes button-erase render on a running session. - -### The eraser channel uses the helper's stroke color/width [sdk] - -Second gotcha: even once enabled, `setEraserRawDrawingEnabled(true, …)` renders -**nothing visible** unless a colour/width is set — the native eraser channel draws using -the `TouchHelper`'s **current `setStrokeColor` / `setStrokeWidth`** state (the -`eraserStyle` arg only selects the *style*, not the colour). If those aren't set to -something visible when erasing begins, you get a blank screen. - -The fix mirrors Notable's already-working **hand-erase** recipe (which renders a grey -`MARKER` at width 30): configure a visible stroke in `onBeginRawErasing`, and restore the -pen's settings in `onEndRawErasing`. Per request, the indicator colour is **black**: - -```kotlin -// onBeginRawErasing -touchHelper!!.setStrokeStyle(penToStroke(Pen.MARKER)) - ?.setStrokeWidth(30f) - ?.setStrokeColor(Color.BLACK) -// onEndRawErasing -updatePenAndStroke() // restore pen style/width/color -``` - -The indicator is **transient**: after pen-up, `onRawErasingList()` repaints the affected -region from the page bitmap (which contains no indicator), so the black track disappears -as soon as the erase is committed — it only exists as live feedback during the gesture. - -> Note: the official `OnyxAndroidDemo` never calls `setEraserRawDrawingEnabled`; its -> erase "track" is app-drawn (`EraseRenderer.drawEraseCircle`). So this native path is -> firmware-dependent — hence the try/catch and the preserved OpenGL fallback. [demo] - -> **[framework] Root cause confirmed in `framework.jar`.** The system handwriting handler -> `BaseHandler.applyStrokeParam()` pushes stroke colour/style/width to SurfaceFlinger -> **only for non-eraser strokes** — it early-returns when -> `strokeStyle == 5` (`isEarsingStroke`). Style `5` is the eraser (the same value the SDK's -> `resetPenDefaultRawDrawing()` passes as `setEraserRawDrawingEnabled(false, 5)`). So by -> default the firmware applies *no paint* while erasing, which is exactly why button-erase -> drew nothing until the dedicated native eraser channel was enabled. `setStrokeStyle` / -> `setEraserRawDrawingEnabled` themselves are `ViewUpdateHelper` Binder transactions to -> SurfaceFlinger (codes 16711688 and 1048833). See `docs/investigation.md`. - ---- - -## 3. What was changed in Notable - -The native path is now enabled, and the OpenGL workaround is **commented out (not -deleted)** so it remains as a reference implementation of non-native erase rendering. - -### 3.1 Enable native rendering — `editor/utils/einkHelper.kt` (`setupSurface`) - -After (not before — see "THE BIG GOTCHA") the final `setRawDrawingEnabled(true)`: - -```kotlin -touchHelper.setRawDrawingEnabled(true) -enableNativeEraser(touchHelper) // shared helper, see below -``` - -```kotlin -fun enableNativeEraser(touchHelper: TouchHelper?) { - if (touchHelper == null) return - try { - touchHelper.setEraserRawDrawingEnabled(true, TouchHelper.STROKE_STYLE_MARKER) - } catch (t: Throwable) { - log.w("setEraserRawDrawingEnabled not supported on this device: ${t.message}") - } -} -``` - -Wrapped in try/catch because the Onyx SDK is unstable across devices/firmware (same -reasoning as `tryToSetRefreshMode`). The same `enableNativeEraser` helper is also called -from `onBeginRawErasing` to re-assert the flag after `updateIsDrawing()` resets it. - -### 3.2 Disable the OpenGL workaround (kept commented) - -- `editor/canvas/OnyxInputHandler.kt` — in `onBeginRawErasing`, the indicator is - configured by the shared `applyEraserIndicatorStyle()` helper so it **matches the active - eraser type**: `Eraser.PEN` → black `MARKER` width 30; `Eraser.SELECT` (lasso) → dashed - `BLACK` line (`Pen.DASHED`, width 3, via `Device.setStrokeParameters`). The same helper - styles the hand eraser in `updatePenAndStroke` (Mode.Erase), the only difference being - the pen-eraser colour (grey for hand-erase, black for the button indicator). This sets a - visible stroke so the native eraser channel actually renders (see "The eraser channel - uses the helper's stroke color/width" above); `onEndRawErasing` restores the pen via - `updatePenAndStroke()`. The - `GlobalAppSettings.current.openGLRendering` blocks and `glRenderer` calls are commented - out. `isErasing` is still set so the rest of the erase logic is unchanged. -- `editor/canvas/DrawCanvas.kt` — in `dispatchTouchEvent`, the routing condition changed - from `if (!DeviceCompat.isOnyxDevice || inputHandler.isErasing)` to - `if (!DeviceCompat.isOnyxDevice)`. **Non-Onyx devices still use OpenGL as their only - renderer**; only the Onyx erase-routing into OpenGL was removed. Original line kept - commented above. - -Each edit is tagged with a `// NATIVE ERASER INDICATOR:` comment pointing back here. - ---- - -## 4. Tuning / reverting - -- **Change the indicator look:** swap the `eraserStyle` argument (e.g. `STROKE_STYLE_DASH` - for a dashed track, `STROKE_STYLE_PENCIL` for a thin line). Stroke width/colour follow - the helper's current `setStrokeWidth` / `setStrokeColor`. -- **Revert to the OpenGL workaround:** uncomment the blocks in `onBeginRawErasing`, - `onEndRawErasing`, and the original condition in `DrawCanvas.dispatchTouchEvent`, and - set `setEraserRawDrawingEnabled(false, …)` (or remove the call). -- **Keep both as a setting:** the cleanest long-term option is to branch on - `GlobalAppSettings.current.openGLRendering` — native when off, OpenGL demo when on — - so the reference path stays exercised. - ---- - -## 5. Related - -- `docs/onyx-pen-up-refresh-and-screen-freeze.md` — raw-drawing/freeze model and the - `setRawDrawingRenderEnabled` toggle the erase path also uses. -- `docs/onyx-finger-scribble.md` — another `TouchHelper` input-routing feature. diff --git a/docs/onyx-neo-fountain-pen-v2.md b/docs/onyx-neo-fountain-pen-v2.md deleted file mode 100644 index 1f2ac0f7..00000000 --- a/docs/onyx-neo-fountain-pen-v2.md +++ /dev/null @@ -1,286 +0,0 @@ -# Onyx NeoFountainPenV2 — How Offline Rendering Works - -This document explains how the Onyx fountain-pen-V2 stroke rendering works, why a -naive implementation does **not** match the firmware's live rendering, and how to -render strokes so they match exactly. - -## Context - -On Onyx e-ink devices, the firmware draws the stroke *live* with its own renderer -while the pen moves. Notable then has to re-draw that same stroke onto its own -surface so that when the screen is unfrozen there is no flicker. For this to work, -the offline redraw must be **pixel-identical** to what the firmware drew live. - -The firmware draws live using the SDK's own config + render pipeline. Any deviation -from that pipeline (custom config, custom draw loop) produces a stroke that does not -match. - -## Relevant SDK artifacts - -- `onyxsdk-pen` (1.5.4, Maven) — contains `NeoPenRender`, `NeoFountainPenWrapper`. - Note these live in the **pen** jar, not penbrush. The jar resolves to the Gradle - transform cache (`~/.gradle/caches/.../onyxsdk-pen-1.5.4/jars/classes.jar`). -- `onyxsdk-penbrush` (1.1.0.1, local `app/libs/*.aar`) — contains `NeoFountainPenV2`, - `NeoPenConfig`, `PenPathResult`, `PenResult`, `FountainShapes`, `NeoPen`. - -The official demo (`OnyxAndroidDemo`) renders the fountain V2 pen in -`app/OnyxPenDemo/.../shape/BrushScribbleShape.java`. That class is the reference -implementation to mirror. - -## How the SDK pipeline works (decompiled) - -### `FountainShapes.createNeoPenV2(...)` - -This is the factory the demo uses. It builds a `NeoPenConfig` with specific values — -these are what the firmware uses: - -``` -config.width = width + 3.0f / createScale // FOUNTAIN_PEN_V1_COMPENSATION = 3.0f -config.minWidth = minWidth / createScale -config.pressureSensitivity = pressureSensitivity ?: 0.3f -config.smoothLevel = smoothLevel ?: 0.6f -config.scalePrecision = scalePrecision -config.tiltEnabled = false -config.fastMode = fastMode -config.displayScaleX = displayScaleX -config.displayScaleY = displayScaleY -return NeoFountainPenV2.Companion.create(config) -``` - -Signature: -`createNeoPenV2(width, minWidth, displayScaleX, displayScaleY, scalePrecision, createScale, pressureSensitivity: Float?, fastMode: Boolean, smoothLevel: Float?)` - -Demo call: `createNeoPenV2(strokeWidth, MIN_FOUNTAIN_PEN_WIDTH, 1f, 1f, 1f, 1f, null, true, null)`. - -Constants: -- `NeoFountainPenWrapper.MIN_FOUNTAIN_PEN_WIDTH = 1.0f` -- `FountainShapes.FOUNTAIN_PEN_V1_COMPENSATION = 3.0f` - -> The `pressureSensitivity` (default 0.3) and `width` arguments are exactly the official -> app's **"Pressure sensitivity" (0–100%)** and **"Line width" (mm)** sliders — Fountain V2 -> is `NEOPEN_PEN_TYPE_FOUNTAIN_V2 = 6`. See -> `docs/onyx-pressure-sensitivity-and-line-width.md` for the full mapping (incl. mm→px and -> the firmware-side `EACStrokeStyle`). - -### `NeoFountainPenV2.Companion.create(config)` - -``` -handle = NeoPenNative.createPen(6, config.toNativeConfig()) // 6 = NEOPEN_PEN_TYPE_FOUNTAIN_V2 -``` - -The pen **type is hardcoded to 6**, so you do not need to set `config.type` yourself. - -`buildPenResult` returns a `Pair`: -- `.first` = the real ink (committed segment) -- `.second` = the prediction ink (trailing segment up to the latest point) - -When `fastMode` is true these are `PenPointResult`; otherwise `PenPathResult`. - -### `NeoPenRender` - -This is the renderer. The important methods: - -- `render(canvas, paint, points)` → calls `onTouchPointList(points)`, then - `render(canvas, paint)`, then `reset()`. -- `onTouchPointList(points)`: - - `onTouchDown(first, repaint=true)` - - splits the middle points into batches of `POINT_LIST_BATCH_LIMIT = 1000` and - calls `onTouchMove(batch, predict=null, repaint=true)` for each - - `onTouchDone(last, repaint=true)` - - accumulates every returned `Pair` into an internal list. -- `render(canvas, paint)`: - - draws `.first` of **every** accumulated result, - - **plus `.second` of the last result** (the trailing prediction segment). - -That last point is critical — the tail of the stroke lives in the `.second` of the -final pair. - -## Why a naive hand-rolled implementation does NOT match - -The original Notable wrapper created the pen with a bare `NeoPenConfig` and drove -`onPenDown/onPenMove/onPenUp` manually, drawing only `.first`. Every divergence below -caused a visible mismatch: - -1. **Wrong config (biggest cause).** Using a bare `NeoPenConfig` and only setting - width/tilt/maxPressure means you miss: - - the **+3px width compensation** (`width + 3.0/createScale`) → stroke too thin. - - `minWidth`, `smoothLevel = 0.6`, `pressureSensitivity = 0.3` → native defaults - used instead; `smoothLevel` in particular reshapes the path geometry. - - `tiltEnabled` — fountain V2 uses **false**. Setting it `true` changes the outline. - - `fastMode` — see the dedicated section below. For an **offline redraw use `false`** - (smooth `PenPathResult`); `true` gives discrete `PenPointResult` dabs that look - "point by point". - -2. **Double pressure normalization.** Pre-dividing points by `maxTouchPressure` - *and* calling `setMaxTouchPressure(...)` on the config makes the native code - normalize a second time, collapsing the pressures. The demo leaves - `config.maxTouchPressure` at default and only pre-normalizes the points - (and only if any pressure > 1.0). - -3. **Mutating the caller's points in place.** `points[i].pressure /= max` mutates the - shared list. Because the stroke is re-rendered after each unfreeze, the pressures - shrink on every redraw. The demo copies via `new TouchPoint(p)`. - -4. **Dropping the stroke tail.** Drawing only `.first` of each result omits the - `.second` of the last result (the trailing prediction ink), so the end of the - stroke never reaches the final point. It also bypasses the SDK's 1000-point - batching. - -## The "point by point" bug: `fastMode` and result type (most important) - -This is the single biggest cause of the offline redraw not matching the firmware, and it is -**independent** of timestamps/config sizing. - -`NeoFountainPenV2.buildPenResult` returns a different `PenResult` subtype depending on -`config.fastMode`: - -| `fastMode` | result type | what `.draw()` paints | use | -|---|---|---|---| -| `true` | `PenPointResult` | discrete point/dab stamps | firmware **live** low-latency drawing | -| `false` | `PenPathResult` | continuous smooth filled vector path | **offline redraw** | - -The demo's `BrushScribbleShape` passes `fastMode = true` — but that class is for the **brush** -pen, and the demo has **no fountain-V2 shape** at all. Mirroring it for fountain brought the -wrong mode along: the redraw rendered as a string of dabs ("drawn point by point", faceted), -even though the config sizing was correct. - -The old hand-rolled wrapper -(`NeoFountainPenV2.create(NeoPenConfig().apply { setWidth(..); setTiltEnabled(true); setMaxTouchPressure(..) })`) -looked smooth precisely because a bare `NeoPenConfig` **defaults `fastMode` to `false`**, so it -got `PenPathResult`. Its only real defect was sizing (no `+3px` compensation, no `minWidth`, -native default `smoothLevel`/`pressureSensitivity`). - -**Fix:** keep `FountainShapes.createNeoPenV2(...)` for correct sizing, but pass -**`fastMode = false`**. `NeoPenRender` renders either subtype (`renderResult` just calls -`penResult.draw()`), so the render path is unchanged — you simply get the smooth path. - -## The faceted-curve bug: missing per-point timestamps - -Even with the wrapper above (config + render path identical to the demo), the offline -redraw can still look **faceted / segment-by-segment on tight, fast curves** while the -firmware's live stroke is smooth. The cause is **not** in the render pipeline — it is in -the input points. - -- `NeoFountainPenV2` is a `NeoNativePen`; its smoothing/curve-subdivision happens in - native code and uses the **inter-point velocity**, which it derives from each - `TouchPoint`'s **timestamp**. -- The demo feeds the firmware's captured `touchPointList`, whose points carry **real, - increasing per-point timestamps**. -- Notable persists strokes as `StrokePoint`, which has **no timestamp field**. When - rebuilding `TouchPoint`s (`strokeToTouchPoints`), every point was stamped with the same - `stroke.updatedAt.time`. Identical timestamps ⇒ velocity ≈ 0 everywhere ⇒ the native - smoother stops subdividing and connects raw samples with straight segments. This is - worst exactly where the user notices it: tight, fast curves (few raw samples, high real - velocity). - -**Fix (data first):** persist the real per-point timing so the original velocity profile -is available, instead of synthesizing a fake cadence at render time. - -`StrokePoint` already has a `dt: UShort?` field ("delta time in ms, from the first point in -the stroke") and the SB1 binary format already has a DT channel (uint16) — it was just -never populated. `copyInput` now fills it from the firmware `TouchPoint.timestamp`: - -```kotlin -val baseTime = touchPoints.first().timestamp -touchPoints.map { - val deltaMs = (it.timestamp - baseTime).coerceIn(0L, 65534L) // 0xFFFF reserved - it.toStrokePoint(scroll, scale).copy(dt = deltaMs.toUShort()) -} -``` - -Storing only the **delta** (uint16, 2 bytes) rather than an absolute timestamp (long, -8 bytes) is the cheap, lossless-enough representation; it is then LZ4-compressed with the -rest of the stroke body. No DB migration is needed: `points` is a binary blob and the SB1 -v1 format already defined the DT channel, so old strokes (dt absent) and new strokes (dt -present) coexist. - -**Deferred:** actually *consuming* `dt` in `strokeToTouchPoints` to feed the native -smoother. This is intentionally not wired up yet — the data is captured first so it exists -for strokes drawn from now on. - -### Absolute vs delta vs zero-based timestamps — only deltas matter [sdk][framework] - -When you do wire it up, you can use a **0-based timeline** (`timestamp = dt`, i.e. the first -point at 0) — there is **no need** to add `stroke.updatedAt.time` or any wall-clock base. -Evidence from the decompiled stack: - -- **Which pens use the timestamp:** every Neo* pen wrapper packs it into the native point - array — `PenUtils.getPointDoubleArray(List)` lays out 7 doubles per point - `[x, y, pressure, size, tiltX, tiltY, timestamp]` (and the float variant - `[x, y, pressure, size, timestamp]`). It is **stored raw, never delta-converted in Java** — - the native pen does the diffing. The pens that actually *consume* it are the - velocity-modulated ones (Fountain V1/V2, Brush, Charcoal — anything driven by - `NeoPenConfig.velocitySensitivity`, default 0.5). Constant-width pens (ballpoint) and - Notable's custom ball-pen renderer ignore it. -- **It's used only as velocity** (`Δdistance / Δtimestamp`). Nothing compares the timestamp - to the wall clock or `SystemClock`, so a constant offset cancels out: a 0-based timeline - and an absolute one produce identical velocity → identical ink. -- **Smaller is actually safer.** The framework's *live* stroke API ships the time as a - **`float`** (`ViewUpdateHelper.addStrokePoint(…, float time)`), and the raw source is - `MotionEvent.getEventTime()` — absolute uptime in the billions of ms, which a 32-bit float - cannot resolve to the millisecond. Onyx getting away with that only works because the - native side cares about *differences*; small/zero-based values keep full precision. The - NeoPen offline path uses `double` so it's fine either way, but there is no downside to - zero-basing and it keeps the numbers small. - -So: feed `dt` straight in as the timestamp. Just keep it **monotonically non-decreasing** -with the right per-point gaps; equal consecutive values (Δ = 0) give a degenerate velocity, -but the native side guards that (`velocityIgnoreThreshold`) and it's unrelated to the choice -of time base. - -## The correct approach - -Mirror the demo: build the pen via `FountainShapes.createNeoPenV2(...)` and render via -`NeoPenRender(neoPen).render(canvas, paint, points)`. This runs the exact same code -path the firmware uses, so the redraw matches. - -Additional requirements: -- Pass a **FILL** paint (`Paint.Style.FILL`, `strokeWidth = 0`). Fountain V2 produces - closed/filled paths, not stroked ones. -- Feed pressures in `[0, 1]`; normalize on a **copy** of the points, never the - original list. -- `TouchPoint` here is `com.onyx.android.sdk.data.note.TouchPoint`, which is accepted - by `NeoPenRender.render` (the demo passes the same type) and has a copy constructor - `new TouchPoint(p)`. - -See `app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt` -for the implementation. - -## Why the live stroke and the offline redraw are two different renderers [framework] - -Decompiling the device `framework.jar` makes the architecture explicit, and explains why an -offline redraw can ever differ from what you saw while writing: - -- **Live ink is drawn by the firmware, not by `NeoPen`.** While the pen moves, the SDK - streams points straight to SurfaceFlinger via - `android.onyx.ViewUpdateHelper.startStroke / addStrokePoint / finishStroke(baseWidth, x, y, - pressure, size, time)` (Binder transaction codes 16711697/8/9). Each call *returns the - firmware's own updated stroke width* — the firmware owns the live low-latency rendering and - its width model. -- **The offline redraw is drawn by `NeoFountainPenV2`/`NeoPenRender` in-process** onto our - surface (this doc). It is a *re-implementation* of the same stroke, not a replay of the - firmware's pixels. - -So matching is inherently "two renderers agreeing": the only way the redraw lines up with -the live ink is to feed the in-process pen the exact same config the firmware uses (hence -`createNeoPenV2`) and the exact same point stream — and, critically, to pick the result type -that looks like a *finished* stroke (`fastMode = false`, see above) rather than the live dab -stream the firmware emits. The live `startStroke`/`addStrokePoint` path also carries a -per-point `time`, which is the firmware analogue of the `dt` we now persist (see the -timestamp section). - -## Inspecting the SDK yourself - -The SDK ships as `.aar` files (in `app/libs/` and the Gradle cache). To decompile: - -```bash -# penbrush classes (NeoFountainPenV2, FountainShapes, NeoPenConfig) — local aar: -unzip -o app/libs/onyxsdk-penbrush-1.1.0.1.aar -d out -java -jar cfr.jar out/classes.jar --outputdir decompiled - -# pen classes (NeoPenRender, NeoFountainPenWrapper) — Maven, in the Gradle cache: -J=$(find ~/.gradle/caches -path '*onyxsdk-pen-1.5.4/jars/classes.jar' | head -1) -java -jar cfr.jar "$J" --outputdir decompiled-pen -# or list signatures -javap -p com/onyx/android/sdk/pen/NeoPenConfig.class -``` diff --git a/docs/onyx-pen-up-refresh-and-screen-freeze.md b/docs/onyx-pen-up-refresh-and-screen-freeze.md deleted file mode 100644 index 4f62bac2..00000000 --- a/docs/onyx-pen-up-refresh-and-screen-freeze.md +++ /dev/null @@ -1,472 +0,0 @@ -# Onyx "Pen Up Refresh", Screen Freeze, and Refresh-Timing Quirks - -This document explains the Onyx "pen up refresh" feature, the screen-freeze / -raw-drawing model it lives inside, recommended timings, the special considerations -when erasing, and a catalogue of the weird timing quirks discovered in the SDK and in -Notable's own code. - -**Source authority.** Facts are tagged so guess-work isn't mistaken for ground truth: -- **[demo]** — verified against the official `OnyxAndroidDemo` source - (`ScribblePenUpRefreshDemoActivity`, `ScribbleMoveEraserDemoActivity`, - `RefreshScreenAction`, `ResumeRawDrawingRequest`, `PartialRefreshRequest`). -- **[sdk]** — verified by decompiling the Onyx `.aar`s (constants, signatures). -- **[notable]** — taken from Notable's own code, which is **reverse-engineered - guess-work** and may be wrong. Where Notable disagrees with the demo/SDK, the - demo/SDK wins. - -Note: the official prose docs in `OnyxAndroidDemo/doc/*.md` do **not** mention pen-up -refresh at all — that feature lives only in the demo *source* and SDK constants. - ---- - -## 1. The drawing model (why any of this exists) - -On an Onyx e-ink device, handwriting uses a two-layer trick: - -1. **Live firmware layer.** While the pen moves, the firmware draws the stroke - directly onto the EPD at very low latency. The host app's `SurfaceView` is - effectively "frozen" — the firmware is painting over it. This is "raw drawing - mode" (`TouchHelper.setRawDrawingEnabled(true)`). -2. **Host bitmap layer.** When a stroke completes, the app receives the points - (`onRawDrawingTouchPointListReceived`) and must redraw that stroke onto its **own** - bitmap/surface (see `onyx-neo-fountain-pen-v2.md` for how to make that redraw match - the firmware). Then it "unfreezes" so the host surface becomes authoritative again. - -The frozen firmware ink and the host's bitmap must agree. The whole class of bugs in -this document comes from the two layers getting **out of sync in time**: the host -unfreezes/refreshes before its bitmap has been updated, or the firmware's internal -buffer hasn't caught up to what the host just drew. - -Key terms in Notable: -- `resetScreenFreeze()` — toggles `touchHelper.isRawDrawingRenderEnabled` false→true. - This is the "unfreeze": it tells the firmware to stop owning the screen so the host - surface shows through. -- `drawCanvasToView()` — blits the host bitmap onto the `SurfaceView`. -- `refreshUi()` — does `drawCanvasToView()` then `resetScreenFreeze()`. - ---- - -## 2. What "Pen Up Refresh" is - -When `setRawDrawingEnabled(true)`, the firmware owns the screen and paints fast, -low-quality ink (A2-ish waveform) for minimal latency. That fast ink **ghosts** — it -leaves residue and looks rough. "Pen up refresh" is the firmware's built-in cleanup: -a short time **after the pen lifts**, the firmware fires a partial high-quality refresh -of the rectangle that was just drawn, replacing the rough low-latency ink with clean -ink. - -### Why this feature exists (the purpose) - -It exists to resolve a hard, unavoidable trade-off specific to e-ink. An EPD can update -a region either **fast or well, never both**: - -- **While the pen is moving, latency is everything.** If ink doesn't appear under the - nib within ~10–20 ms it feels broken. The only way to hit that is a *fast waveform* - (A2/DU-class): 1-bit black/white, no grayscale, no anti-aliasing, and it **leaves - ghosting/residue** because it doesn't fully settle the e-ink particles. -- **A clean stroke needs a slow waveform.** Grayscale edges, anti-aliasing and - ghost-free pixels require a GC/REGAL-class update that takes ~150–300 ms — far too slow - to run *while* writing. - -So the device deliberately writes ugly-but-instant ink live, and then needs a *second -pass* to upgrade that region to clean ink once it's allowed to be slow. **Pen up refresh -is that automatic second pass.** Its whole reason to exist is to answer two questions the -app would otherwise have to answer by hand: - -1. **"When is it safe to do the slow, pretty refresh?"** — i.e. when has the user - actually paused? Doing the slow refresh mid-stroke would stutter and fight the live - ink. The firmware answers this with the **pen-up timer**: only refresh after the pen - has been lifted for *N* ms (so a burst of quick strokes is coalesced into one cleanup, - not one flicker per stroke). -2. **"Which area needs cleaning?"** — it hands you the dirty `RectF` so only the written - region is refreshed, not the whole screen. - -Put differently: **the purpose is to get low-latency writing AND clean final ink without -the app having to detect end-of-writing, debounce it, track the dirty region, and -schedule a high-quality partial update itself.** It's the firmware automating the -"settle the ghosting once the user stops" step that every serious e-ink note app needs. - -Why it's *optional* (a switch): an app may already do its own end-of-stroke refresh -(this is exactly Notable's case — see §2 "Notable status"), in which case the built-in -pass would be redundant or even conflict with the app's own refresh timing. So the SDK -lets you turn it off and take over, or turn it on and let the firmware handle it. - -> **[framework] Confirmed in `framework.jar`.** The system note handler -> (`android.onyx.optimization.screennote.handler.BaseHandler`) implements exactly this: on -> stylus-up it schedules, after `EACNoteConfig.getRepaintLatency()` ms, a -> `ViewUpdateHelper.handwritingRepaint(view, l,t,r,b, false)` and sets the pen state to -> `PEN_PAUSE`. `repaintLatency` **defaults to 500 ms**, matching the SDK's -> `PenConstant.DEFAULT_PEN_UP_REFRESH_TIME_MS` below. The pen state is a firmware-tracked -> machine: `PEN_STOP=0, PEN_START=1, PEN_DRAWING=2, PEN_PAUSE=3, PEN_ERASING=4`. See -> `docs/investigation.md` for the full framework trace. - -### API - -On `TouchHelper`: -- `setPenUpRefreshEnabled(boolean)` — turn the feature on/off. -- `setPenUpRefreshTimeMs(int)` — how long after pen-up before the refresh fires. - -Callback on `RawInputCallback`: -- `onPenUpRefresh(RectF refreshRect)` — fired when the timer elapses. The app is - expected to repaint `refreshRect` from its own bitmap in a high-quality update mode. - -### The demo's handler (reference implementation) - -In `ScribblePenUpRefreshDemoActivity`: - -```java -@Override -public void onPenUpRefresh(RectF refreshRect) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return; - getRxManager().enqueue( - new PartialRefreshRequest(this, surfaceview1, refreshRect).setBitmap(bitmap), - ...); -} -``` - -`PartialRefreshRequest` does the canonical partial-refresh dance: - -```java -EpdController.setViewDefaultUpdateMode(surfaceView, UpdateMode.HAND_WRITING_REPAINT_MODE); -Canvas canvas = surfaceView.getHolder().lockCanvas(renderRect); -canvas.clipRect(renderRect); -renderBackground(canvas, viewRect); -canvas.drawBitmap(bitmap, rect, rect, null); // host bitmap -> surface -surfaceView.getHolder().unlockCanvasAndPost(canvas); -EpdController.resetViewUpdateMode(surfaceView); -``` - -i.e. set a high-quality handwriting update mode, blit the host bitmap into the dirty -rect, post, then reset the update mode. - -> **Notable status:** `OnyxInputHandler.onPenUpRefresh()` currently just calls -> `super.onPenUpRefresh()` (a no-op). Notable instead drives refresh itself from -> `onRawDrawingTouchPointListReceived` via `refreshUi()` / `partialRefreshRegionOnce()`. -> The pen-up-refresh callback is an alternative hook we are not using yet. - -### 2a. Why the demo has *two* "Pen Up Refresh" controls — [demo] - -This is a common point of confusion: the demo shows a button labelled **"Scribble Pen -up Refresh"** in one place and a toggle labelled **"Pen Up Refresh"** in another. They -are **not two different features** — they are the *same* SDK feature -(`setPenUpRefreshEnabled` / `onPenUpRefresh`) surfaced in two separate demo screens. - -The `OnyxPenDemo` module actually contains two parallel demo "apps": - -1. **The Scribble demo family** — a plain `TouchHelper`-based set of screens. Its menu - (`ScribbleDemoActivity`, layout `activity_sribble_demo.xml`) lists many demos, one - button per feature. The button **"Scribble Pen up Refresh"** is just a **navigation - entry**: - - ```java - public void button_pen_up_refresh(View view) { - go(ScribblePenUpRefreshDemoActivity.class); // it only opens the dedicated screen - } - ``` - - It toggles nothing. Its subtitle is `desc_pen_up_refresh` ("Triggers a screen refresh - when the pen is lifted. Includes adjustable delay settings to balance performance and - ghosting."). The "Scribble" prefix just means "this belongs to the scribble-demo - family." The screen it opens (`ScribblePenUpRefreshDemoActivity`) then has its **own** - in-screen `enable_pen_up_refresh` **checkbox** + a **delay seekbar**, wired directly - to `touchHelper.setPenUpRefreshEnabled(...)` / `setPenUpRefreshTimeMs(...)`. - -2. **The integrated PenManager demo** — a richer demo (`PenDemoActivity`) with a - floating menu (`layout_float_menu.xml`). There, **"Pen Up Refresh"** - (`@string/pen_up_refresh`, the `penUpCheck` view) is a **live on/off toggle**: - - ```java - private void onPenUpCheckImpl(boolean isChecked) { - getPenBundle().setEnablePenUpRefresh(isChecked); - refreshScreen(); - } - // ...and the callback honours the flag: - public void onPenUpRefresh(RectF refreshRect) { - if (!getPenBundle().isEnablePenUpRefresh()) return; // gated by the toggle - new CommonPenAction<>(new PartialRefreshRequest(getPenManager(), refreshRect)).execute(); - } - ``` - -**Summary of the mapping:** - -| Label seen | Where | What it is | Backing call | -|---|---|---|---| -| "Scribble Pen up Refresh" | `ScribbleDemoActivity` menu list | **navigation button** to the standalone demo | `go(ScribblePenUpRefreshDemoActivity)` | -| enable checkbox + seekbar | inside `ScribblePenUpRefreshDemoActivity` | enable + delay for the standalone demo | `setPenUpRefreshEnabled` / `setPenUpRefreshTimeMs` | -| "Pen Up Refresh" | `PenDemoActivity` floating menu | **live enable/disable toggle** | `setEnablePenUpRefresh` → gates `onPenUpRefresh` | - -So both ultimately drive the single SDK feature. The standalone screen exists to -demonstrate it in isolation **and** to expose the **delay slider** (400–2000 ms); the -PenManager floating-menu toggle exists to show enabling/disabling it inside a fuller -note-taking-style demo. There is no behavioural difference in the feature itself — only -in which demo harness wraps it. - ---- - -## 3. Recommended timing values - -**[sdk]** From `com.onyx.android.sdk.data.PenConstant`: - -| Constant | Value | -|---|---| -| `DEFAULT_PEN_UP_REFRESH_TIME_MS` | **500** | -| `MIN_PEN_UP_REFRESH_TIME_MS` | 400 | -| `MAX_PEN_UP_REFRESH_TIME_MS` | 2000 | -| `PEN_UP_REFRESH_STEP` | 100 | - -So the firmware-sanctioned range is **400–2000 ms**, default **500 ms**, adjustable in -100 ms steps. - -### How to choose - -- **500 ms (default)** is the right starting point. It's long enough that a normal - writer has lifted and paused, so the clean refresh doesn't interrupt an in-progress - word. -- **Lower toward 400 ms** for snappier cleanup if users complain ink stays "rough" - too long. Going below 400 ms is not allowed by the SDK and risks the refresh firing - mid-stroke-sequence (between two quick strokes), causing a flash. -- **Raise toward 700–1000 ms** for fast note-takers who chain many short strokes; a - longer timer avoids a high-quality refresh firing in the middle of rapid writing - (which both flickers and steals CPU/EPD time from the next stroke). -- The timer is "time since pen lifted," so it naturally coalesces a burst of strokes: - each new pen-down before the timer elapses pushes the clean refresh out. - -**Recommendation for Notable:** if/when we adopt pen-up-refresh, default to 500 ms and -expose it as an advanced setting clamped to [400, 2000] in 100 ms steps. - ---- - -## 4. Erasing and resume timing - -Reported symptom: a stroke is erased, the user immediately draws on top of the now-empty -area, and the just-erased stroke "reappears" / the new stroke behaves as if the old -content were still there. There *is* effectively a buffer: the firmware keeps its own -handwriting layer that updates asynchronously, and re-entering raw drawing before it has -absorbed the change composites the next stroke against stale content. - -The important correction from reading the official demo: **the demo does not fix this -with a long timed delay sprinkled around the erase.** It fixes it *structurally* with -the `RawDrawingRenderEnabled` toggle, and only applies a small, device-specific resume -delay at one precise place. Below is what the demo actually does. - -### How the official demo erases — [demo] `ScribbleMoveEraserDemoActivity` - -```java -onBeginRawErasing(...) -> touchHelper.setRawDrawingRenderEnabled(false); // stop firmware owning screen - drawBitmap(); // blit host bitmap to surface NOW -onRawErasingTouchPointMoveReceived(p) -> // accumulate; every 100 points: - eraseBitmap(path); // PorterDuff.CLEAR into host bitmap - drawBitmap(); // re-post host bitmap -onRawErasingTouchPointListReceived(list) -> eraseBitmap(path); // final batch -onEndRawErasing(...) -> touchHelper.setRawDrawingRenderEnabled(true); // hand screen back -``` - -Key points: -- The erase is committed to the **host bitmap** (CLEAR xfermode) and the bitmap is - **synchronously** locked/drawn/posted to the surface on the callback thread — no - background thread, no race. -- `setRawDrawingRenderEnabled(false)` is flipped the instant erasing begins, so the - firmware stops compositing its layer; the host surface is authoritative during the - whole erase. It is re-enabled only at `onEndRawErasing`. -- There is **no `Thread.sleep` / `delay` inside the erase callbacks at all.** - -### Where the demo *does* delay — [demo] `RefreshScreenAction` / `ResumeRawDrawingRequest` - -For the general "refresh the screen, then resume the pen" flow, the demo runs this -exact sequence on its Rx single thread: - -```java -RendererToScreenRequest(...).execute(); // 1. blit host bitmap to screen -ThreadUtils.mySleep(delayResumePenTimeMs); // 2. wait -penManager.setRawDrawingRenderEnabled(true); // 3. re-enable render -penManager.setRawInputReaderEnable(true); // and input -``` - -The delay is **`DELAY_ENABLE_RAW_DRAWING_MILLS`**, defined as: - -```java -DELAY_ENABLE_RAW_DRAWING_MILLS = DeviceInfoUtil.isColorDevice() - ? COLOR_DEVICE_PEN_RESUME_DELAY_TIME_MS - : COMMON_PEN_RESUME_DELAY_TIME_MS; -``` - -**[sdk]** `com.onyx.android.sdk.data.note.NoteConstant`: - -| Constant | Value | Meaning | -|---|---|---| -| `COMMON_PEN_RESUME_DELAY_TIME_MS` | **150** | monochrome resume delay | -| `COLOR_DEVICE_PEN_RESUME_DELAY_TIME_MS` | **500** | Kaleido color resume delay | -| `ERASE_DELAY_RESUME_PEN_TIME` | **500** | resume delay specific to *erasing* | -| `DELAY_FLOAT_MOVE_RESUME_PEN_TIME_MS` | 500 | resume delay after a floating-toolbar move | -| `PAGE_PEN_RESUME_DELAY_TIME` | 600 | page-level (e.g. page turn) resume delay | -| `COMMON_DEVICE_QUIT_FAST_MODE_DELAY_TIME_MS` | 5000 | delay before leaving fast mode | - -Note `ERASE_DELAY_RESUME_PEN_TIME = 500`: the SDK uses a **500 ms** resume delay after an -erase (vs 150 ms monochrome for normal pen resume), i.e. the firmware is given longer to -absorb the cleared region before raw drawing is handed back — directly relevant to the -"erased stroke reappears" bug below. - -So the **authoritative** wait between "screen refreshed" and "raw drawing re-enabled" is -**150 ms on monochrome, 500 ms on color** — applied *after* posting the bitmap and -*before* `setRawDrawingRenderEnabled(true)`. - -> ⚠️ **Correction to Notable [notable].** Notable's `DeviceCompat.delayBeforeResumingDrawing()` -> uses **300 ms** for monochrome / 500 ms for color. The color value matches the SDK, -> but the **300 ms monochrome value is guess-work and is double the official 150 ms** -> (`COMMON_PEN_RESUME_DELAY_TIME_MS`). Consider aligning to the SDK constants. - -### The full erase→draw cycle and its two timing windows — [demo] `PenDemoActivity` - -`ScribbleMoveEraserDemoActivity` (above) is the *minimal* erase demo and **omits the -timing**. The full `PenDemoActivity` shows the real, timed pipeline. Tracing one -draw → erase → draw cycle answers the exact questions: - -**Phase 1 — drawing (screen frozen):** raw drawing enabled; the firmware owns the screen -and draws live. App mirrors each finished stroke into its bitmap. - -**Phase 2 — erasing with the pen button (still frozen, until lifted):** -- `onBeginRawErasing` / `onRawErasingPointMove` → `StrokeErasingRequest`, which is created - with **`setPauseRawDraw(false)`** — i.e. **raw drawing is *not* paused during the erase**. - Erasing runs live while the pen is down: hit-test & mark shapes transparent, render to the - bitmap (and optionally show an erase-circle track). The screen stays frozen the whole time. -- **Q: is there a wait to "finish the stroke" before the screen refreshes?** **No.** Nothing - sleeps while erasing or at pen-lift-before-refresh. On pen up - (`onRawErasingTouchPointListReceived`) it goes straight to `StrokesEraseFinishedRequest`: - `BaseRequest.beforeExecute` calls `setRawDrawingEnabled(false)` (pause render **and** - input), the bitmap is re-rendered (white + surviving shapes, erased ones gone), and - `afterExecute` blits it to screen **immediately**. So: erase commit → screen shows result, - with no timed delay in that step. - -**Phase 3 — between "erased result shown" and the next scribble:** -- After the bitmap is on screen, `RefreshScreenAction` posts - `PenEvent.resumeRawDrawing(DELAY_ENABLE_RAW_DRAWING_MILLS)` → `ResumeRawDrawingRequest`, - whose `execute()` is: - - ```java - ThreadUtils.mySleep(delayResumePenTimeMs); // <-- THE wait, before re-enabling - updatePenParam(); // restore strokeStyle/width/color/penUpRefresh - updateDrawExcludeRect(); - setRawDrawingRenderEnabled(true); // hand screen back to firmware - setRawInputReaderEnable(true); // accept input again - ``` - -- **Q: is there a wait before the next scribble can start?** **Yes — this is the one that - matters.** Raw-drawing render *and input* stay disabled for - `DELAY_ENABLE_RAW_DRAWING_MILLS` = **150 ms (monochrome) / 500 ms (color)** - (`PenEvent.DELAY_ENABLE_RAW_DRAWING_MILLS = isColorDevice() ? COLOR_DEVICE_PEN_RESUME_DELAY_TIME_MS : COMMON_PEN_RESUME_DELAY_TIME_MS`). - Until that elapses the firmware is not re-armed, so a new stroke genuinely cannot begin — - giving the EPD time to absorb the refreshed (erased) image before it re-freezes for the - next stroke. - -**What the simple `ScribbleMoveEraserDemoActivity` left out (verified in the full demo + SDK):** -1. **The post-refresh resume delay** (`mySleep(150/500 ms)`) before re-enabling raw drawing — - the minimal demo just flips `setRawDrawingRenderEnabled(true)` in `onEndRawErasing` with no - wait. This is the missing piece behind "draw immediately after erase → stale content". -2. **`updatePenParam()` on resume** — re-applies stroke style/width/color/pen-up-refresh, - because re-enabling raw drawing runs `resetPenDefaultRawDrawing()` and wipes them - (see `docs/onyx-native-eraser-indicator.md` for the same reset gotcha). -3. **Pausing raw *input* too** during the finish/resume window (`setRawInputReaderEnable`), - not just render — so stray points during the refresh can't be injected. -4. A dedicated SDK constant the demos don't even use: **`NoteConstant.ERASE_DELAY_RESUME_PEN_TIME = 500`** — an erase-specific resume delay (the generic path uses 150/500; the production note app appears to use the 500 ms erase value regardless of mono/color). [sdk] -5. **[framework]** Underneath, `BaseHandler` also runs its own post-stroke cleanup: erasing - sets pen state `PEN_ERASING (4)`, and on stylus-up it schedules - `ViewUpdateHelper.handwritingRepaint(...)` after `EACNoteConfig.repaintLatency` (**500 ms** - default). So there are effectively *two* settle timers — the app's resume delay and the - firmware's repaint latency. - -### The actual fix for the "erased stroke reappears" bug - -Mirror the demo's *ordering and mechanism*, not a bigger delay: - -1. On erase begin, `setRawDrawingRenderEnabled(false)` so the firmware stops owning the - screen for the duration of the erase. -2. Commit the erase to the host bitmap (`PorterDuff.CLEAR`) and **synchronously** - lock/draw/post it to the surface — on the callback thread, not a racing background - coroutine. -3. Re-enable with the demo's sequence: post bitmap → `mySleep(150 mono / 500 color)` → - `setRawDrawingRenderEnabled(true)` → `setRawInputReaderEnable(true)`. -4. Only then accept the next stroke. - -Notable's current path is more fragile than the demo's: `resetScreenFreeze` launches on -`Dispatchers.Default` and flips render enabled inside `delayBeforeResumingDrawing`, so if -the erase bitmap write hasn't completed before that coroutine re-enables render, the race -window is open. The demo avoids this by doing the bitmap post **synchronously** and -gating resume behind the single-threaded Rx queue. Serializing erase-commit → post → -delay → re-enable (e.g. under `CanvasEventBus.drawingInProgress`/`waitForDrawing()`, on -one thread) is what closes the bug — increasing the delay only masks it. - -> **Status [notable].** The erase path now does this: `OnyxInputHandler.onRawErasingList` -> posts the indicator-clear, then runs `CanvasRefreshManager.refreshAfterErase(...)`, which -> on a **single coroutine** does `isRawDrawingRenderEnabled=false` → post the erased bitmap -> and **await** it landing on the surface → `delayBeforeResumingDrawing(isErasing=true)` -> (**500 ms**, `ERASE_DELAY_RESUME_PEN_TIME`) → `isRawDrawingRenderEnabled=true`. This -> replaces the old racing pair (`refreshUi`'s async `drawCanvasToView` post on the main -> thread vs `resetScreenFreeze` on `Dispatchers.Default`) and the 300 ms mono erase delay. -> Still outstanding: collapsing the double refresh and pausing raw *input* during the window. - -> Notable's own comments already smell this race: in -> `einkHelper.partialRefreshRegionOnce` ("onyx library has its own buffer that needs to -> be updated. Otherwise we will refresh to correct, then incorrect and then correct -> state") and in `OnyxInputHandler.onRawDrawingList` ("sometimes UI will get refreshed -> and frozen before we draw all the strokes … because of doing it in separate thread"). -> Both point at the same root cause the demo sidesteps with synchronous ordering. [notable] - ---- - -## 5. Other quirks found during the search - -These are scattered through the SDK and Notable; collected here so they're not lost. - -### Stroke geometry / config -- **Width compensation `+3.0px`** and specific `smoothLevel`/`pressureSensitivity` - defaults are baked into the fountain-V2 pen — see `onyx-neo-fountain-pen-v2.md`. -- `PenConstant` default smooth level is **0.2** (`DEFAULT_SMOOTH_LEVEL`), but the - fountain-V2 factory uses **0.6** — different code paths use different defaults. -- `PenConstant.SHAPE_LIMIT_RENDER_TOUCH_POINT_COUNT = 20000` and - `NeoPenRender.POINT_LIST_BATCH_LIMIT = 1000`: very long strokes are batched/limited; - don't assume one stroke == one render call. -- `FILTER_REPEAT_MOVE_POINT_*`: the firmware filters out near-duplicate move points - (speed < 0.005, pressure delta < 2.0). Don't expect to receive every raw sample. - -### Refresh / update-mode unreliability -- **The Onyx library is unstable.** `tryToSetRefreshMode()` wraps - `setViewDefaultUpdateMode` in try/catch because it throws `NullPointerException` / - `IllegalArgumentException` on devices/modes that don't support a mode. Always guard - EPD calls. -- `onSurfaceInit` tries `HAND_WRITING_REPAINT_MODE` and **falls back to `REGAL`** if it - fails — mode availability is device-dependent. -- `refreshScreen()` using `EpdController.repaintEveryThing(REGAL_PLUS)` is documented in - Notable as **doing nothing** ("TODO: It does nothing, I have no idea why"). -- `PEN_DEACTIVATE_TIME_INTERVAL_MS = 100`: there's a ~100 ms debounce around pen - activation/deactivation events. - -### Threading / freeze ordering (the root of most flicker bugs) -- "sometimes UI will get refreshed and frozen before we draw all the strokes" — doing - the bitmap draw on a separate thread races the freeze. Notable now serializes draw - work under `CanvasEventBus.drawingInProgress` and `waitForDrawing()`; `refreshUi` - even warns `"Drawing is still in progress there might be a bug."` if the lock is held. -- `refreshUi` deliberately **skips unfreezing when not in drawing mode** (Select/menus), - to avoid fighting other refreshers. -- Color Kaleido devices need **longer** settle times everywhere: the SDK uses - **500 ms** resume delay on color vs **150 ms** on monochrome - (`COLOR_DEVICE_PEN_RESUME_DELAY_TIME_MS` / `COMMON_PEN_RESUME_DELAY_TIME_MS`). [sdk] -- Animation mode (`applyTransientUpdate(ANIMATION_X)` / `clearTransientUpdate`) is used - for scrolling; remember to turn it back off (debounced ~500 ms) or you keep ghosting. - ---- - -## 6. TL;DR - -- **Pen up refresh** = firmware replaces fast/rough live ink with a clean partial - refresh a configurable time after pen-up. Default **500 ms**, range **400–2000 ms**, - step **100 ms**. Handle `onPenUpRefresh(rect)` by blitting your bitmap into `rect` - using `HAND_WRITING_REPAINT_MODE`. -- **Erase reappear bug** = host re-enables raw drawing before the firmware buffer - absorbs the erase. The demo's fix is *structural*: `setRawDrawingRenderEnabled(false)` - for the whole erase, commit the erase to the bitmap and **synchronously** post it, then - resume with `post bitmap → mySleep(150 mono / 500 color) → setRawDrawingRenderEnabled(true)`. - Serialize the steps on one thread; a bigger delay only masks a race. -- **Authoritative resume delay** = **150 ms monochrome / 500 ms color** (`NoteConstant`). - Notable's 300 ms mono value is guess-work and twice the official number. -- The Onyx EPD API is flaky — guard every call (try/catch), expect device-specific - behavior, and budget more time on color panels. diff --git a/docs/onyx-pressure-sensitivity-and-line-width.md b/docs/onyx-pressure-sensitivity-and-line-width.md deleted file mode 100644 index dd2404e6..00000000 --- a/docs/onyx-pressure-sensitivity-and-line-width.md +++ /dev/null @@ -1,191 +0,0 @@ -# Onyx pen: pressure sensitivity & line width (the official app's two sliders) - -The official Onyx note app exposes a **Pen** with two settings: - -- **Line width** — `0.10 mm` … `2.00 mm` -- **Pressure sensitivity** — `0% (Off)` … `100%` - -This documents where those two values live in the SDK/firmware, what they actually control, -and how to reproduce them in Notable. Grounded in the decompiled `onyxsdk-pen` / -`onyxsdk-penbrush` and the device `framework.jar`. - -**Source tags:** **[sdk]** decompiled `.aar`; **[framework]** decompiled `framework.jar`. - ---- - -## 1. Which pen is it? — Fountain V2 [sdk] - -The configurable-pressure "Pen" is the **Fountain V2** pen. `NeoPenConfig` enumerates the -native pen types, and Fountain V2 is the one fed by `FountainShapes.createNeoPenV2(...)` -(see `docs/onyx-neo-fountain-pen-v2.md`): - -``` -NEOPEN_PEN_TYPE_BRUSH = 1 -NEOPEN_PEN_TYPE_FOUNTAIN = 2 -NEOPEN_PEN_TYPE_MARKER = 3 -NEOPEN_PEN_TYPE_CHARCOAL = 4 -NEOPEN_PEN_TYPE_CHARCOAL_V2 = 5 -NEOPEN_PEN_TYPE_FOUNTAIN_V2 = 6 // <-- "Pen" with line width + pressure sensitivity -NEOPEN_PEN_TYPE_PENCIL = 7 -NEOPEN_PEN_TYPE_BALLPOINT = 8 -NEOPEN_PEN_TYPE_SQUARE = 9 -NEOPEN_PEN_TYPE_BRUSH_SIGN = 10 -``` - -It is Fountain V2 (not V1) because **only the V2 path carries a `pressureSensitivity` -parameter** that a UI slider can drive; V1 has no such knob. A constant-width pen (ballpoint) -ignores pressure entirely, so a 0–100% sensitivity slider only makes sense on a -pressure-modulated pen like fountain. - ---- - -## 2. The full pen config and its defaults [sdk] - -`com.onyx.android.sdk.pen.NeoPenConfig` (penbrush) — every field the native renderer reads, -with its default: - -| Field | Default | Meaning | -|---|---|---| -| `type` | 1 | pen type (6 = Fountain V2) | -| `width` | 3.0 | **base stroke width, in pixels** (this is the "line width") | -| `minWidth` | 0.001 | floor width (Fountain V2 uses `MIN_FOUNTAIN_PEN_WIDTH = 1.0`) | -| `maxTouchPressure` | 1.0 | pressure normaliser; points must be pre-divided by this | -| `pressureSensitivity` | **0.3** | **how strongly pressure modulates width (the slider)** | -| `velocitySensitivity` | 0.5 | how strongly speed modulates width (not exposed in UI) | -| `smoothLevel` | 0.6 | curve smoothing | -| `dpi` | 320.0 | pixels-per-inch used for any physical sizing | -| `tiltScale` | 3.0 | tilt → width factor (`tiltEnabled = false` for fountain) | -| `velocityAmplifier`, `velocityIgnoreThreshold`, `velocityLowerBound`, `velocityUpperBound`, `startPointLimit`, `startLengthLimit`, `endVelocitySensitivity` | 0.0 | fine velocity-shaping knobs | -| `scalePrecision`, `displayScaleX/Y` | 1.0 | render scaling | - -All of these are copied into the native `PenConfig` in `NeoPenConfig.toNativeConfig()` -(verified): `it.setPressureSensitivity(this.pressureSensitivity)`, -`it.setVelocitySensitivity(...)`, `it.setWidth(...)`, etc. The actual width-from-pressure -math runs in `libpennative` (JNI, not inspectable), but the inputs are exactly these. - ---- - -## 3. Pressure sensitivity (the 0%–100% slider) [sdk] - -- It is **`NeoPenConfig.pressureSensitivity`**, a float in `[0, 1]`. -- Semantics: it scales how much the per-point pressure pushes the stroke width away from the - base `width`. **`0.0` = "Off"** (pressure ignored → constant-width line); higher = pressure - has more effect on width. The app's **`0% … 100%` maps to `0.0 … 1.0`** (the slider value - divided by 100). -- **Two different defaults — mind the gap:** - - `NeoPenConfig.pressureSensitivity` (the raw struct field) defaults to **0.3**. This is - what `FountainShapes.createNeoPenV2(..., pressureSensitivity = null, ...)` falls back to, - so Notable's current strokes (which pass `null`) run at **0.3**. - - **`PenConstant.DEFAULT_PRESSURE_SENSITIVITY = 0.375`** is the SDK/official-app default - (≈37.5% on the slider). There is also an explicit feature flag - **`PenConstant.ENABLE_CONFIG_PEN_PRESSURE_SENSITIVITY = true`** — i.e. the official app - treats pressure sensitivity as a user-configurable setting, defaulting to 0.375. - - So if you want to match the official app exactly, default the slider to **0.375**, not - 0.3. [sdk] -- Sibling knobs (same source) for completeness: `PenConstant.DEFAULT_VELOCITY_SENSITIVITY = - 1.0` (the `NeoPenConfig` field default is 0.5) and `PenConstant.DEFAULT_SMOOTH_LEVEL = 0.2` - (the field/`createNeoPenV2` default is 0.6). The app-level `PenConstant` defaults and the - raw struct field defaults genuinely differ — the app overrides the struct. - -> ### Pressure must be normalised first -> The native pen expects per-point pressure in **`[0, 1]`**. Raw `TouchPoint.pressure` from -> the digitizer is `1 … 4096` (`EpdController.getMaxTouchPressure()` ≈ 4096). The pen -> wrappers divide each point's pressure by `maxTouchPressure` before rendering. If you set -> `pressureSensitivity > 0` but feed un-normalised pressure, every point saturates and the -> width looks constant/maxed. (Notable already normalises on a copy — see -> `NeoFountainPenV2Wrapper.copyAndNormalizePressure`.) - ---- - -## 4. Line width (the 0.10 mm – 2.00 mm slider) [sdk] - -The `0.10 … 2.00 mm` range is **not a guess — it's hard-coded in `PenConstant`**: - -| Constant | Value | Meaning | -|---|---|---| -| `MIN_NORMAL_STROKE_WIDTH` | **0.1** | min line width (mm) — the slider floor | -| `MAX_NORMAL_STROKE_WIDTH` | **2.0** | max line width (mm) — the slider ceiling | -| `DEFAULT_STROKE_WIDTH_MM` | 0.5 | default "Pen" width (mm) | -| `NORMAL_STROKE_WIDTH_GAP` | 0.05 | slider step (mm) | -| `MIN_/MAX_MARKER_STROKE_WIDTH` | 0.5 / 8.0 | the *marker* pen's separate mm range | -| `DEFAULT_DPI` | 320.0 | nominal density used for mm↔px | - -So the official "Pen" slider is literally `MIN_NORMAL_STROKE_WIDTH … MAX_NORMAL_STROKE_WIDTH` -(0.10–2.00 mm) in 0.05 mm steps, default 0.5 mm. - -- The render value is **`NeoPenConfig.width`, in pixels**, converted from mm with the density: - - ``` - widthPx = widthMm / 25.4 * dpi // dpi = PenConstant.DEFAULT_DPI = 320 (nominal) - ``` - - At the nominal 320 dpi: `0.10 mm ≈ 1.26 px`, `0.50 mm ≈ 6.3 px`, `2.00 mm ≈ 25.2 px`. -- Caveat: `PenConstant` also defines `DEFAULT_STROKE_WIDTH = 7.2f` (px) paired with - `DEFAULT_STROKE_WIDTH_MM = 0.5f`, which implies ≈14.4 px/mm (≈366 dpi), **not** 320. So the - *actual* device conversion is denser than the nominal `DEFAULT_DPI`; for an exact match use - the device's real ppi (or reproduce the 7.2 px ⇄ 0.5 mm ratio) rather than 320. Other - per-pen px defaults: `BRUSH 8.4`, `MARKER 48.0`, `CHARCOAL 3.6`, `PENCIL 6.0`, - with `MAX_RENDER_STROKE_WIDTH = 80.0`. - -- Note `FountainShapes.createNeoPenV2` adds a compensation term: the native pen actually gets - `config.width = width + 3.0 / createScale` and `config.minWidth = minWidth / createScale`. - So the value you pass as `width` is the **nominal** nib; the firmware draws it slightly - thicker by design (the +3 px `FOUNTAIN_PEN_V1_COMPENSATION`). Account for this if you want a - mm value to land exactly. - ---- - -## 5. The firmware (live) side of the same two values [framework] - -When the official app draws live, it does **not** use `NeoPenConfig`; it pushes the style to -SurfaceFlinger through the framework (see `docs/investigation.md`). The relevant carrier is -`android.onyx.optimization.data.v2.EACStrokeStyle`: - -```java -int strokeStyle; // pen/eraser style id -float strokeWidth = 3.0f; // base width (px) <-- the line-width slider -int strokeColor = 0xFF000000; -List strokeExtraArgs; // generic float[] of extra brush params -``` - -`BaseHandler.applyStrokeParam()` forwards these to -`ViewUpdateHelper.setStrokeWidth(strokeWidth)` and -`ViewUpdateHelper.setStrokeParameters(strokeStyle, strokeExtraArgs)` (Binder codes 16711687 -and 1049089). So **line width is `strokeWidth`**, and the **pressure-sensitivity value is -carried inside `strokeExtraArgs`** (the firmware's per-style parameter array — the same -mechanism Notable already uses to pass the dash pattern for the lasso eraser). The exact slot -layout of `strokeExtraArgs` for fountain is decided in the native EPD layer and is not -exposed in Java. - -The takeaway: **live (firmware) and offline (NeoPen) are two renderers** that each need the -same two numbers — `strokeWidth`/`width` and the pressure sensitivity — supplied through their -own channel. - ---- - -## 6. How to expose these in Notable - -Notable renders fountain offline via `NeoFountainPenV2Wrapper` → -`FountainShapes.createNeoPenV2(width, minWidth, …, pressureSensitivity, fastMode, smoothLevel)`. -Today it passes `pressureSensitivity = null` (→ 0.3) and a pixel `strokeWidth`. - -To mirror the official app's two sliders: - -- **Line width (mm):** convert to px with the device ppi and pass as `width` - (`widthPx = mm / 25.4 * ppi`). Remember the `+3 px` fountain compensation. -- **Pressure sensitivity (0–100%):** pass `pressureSensitivity = percent / 100f` instead of - `null`. `0f` reproduces the app's **"Off"** (constant-width line); `1f` = full response; - `0.3f` is the SDK default. -- Keep feeding **normalised pressure** (`pressure / maxTouchPressure`) or the slider will look - like it does nothing. - -`velocitySensitivity` (0.5) and the velocity-bound knobs stay at their defaults — the official -app doesn't expose them, and they shape the fast-stroke taper that makes a fountain pen feel -right. - ---- - -## 7. Related - -- `docs/onyx-neo-fountain-pen-v2.md` — the Fountain V2 render pipeline and `createNeoPenV2`. -- `docs/investigation.md` — framework `EACStrokeStyle` / `ViewUpdateHelper` stroke params. From 77d18209436a276d1b0be21270fc4c58ae168b04 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Tue, 30 Jun 2026 21:40:02 +0200 Subject: [PATCH 7/9] clean up code --- .../editor/canvas/CanvasRefreshManager.kt | 53 ++---- .../notable/editor/canvas/OnyxInputHandler.kt | 77 ++------- .../editor/drawing/NeoFountainPenV2Wrapper.kt | 21 +-- .../ethran/notable/editor/utils/einkHelper.kt | 153 +----------------- 4 files changed, 33 insertions(+), 271 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt index 2777a13b..c877b9db 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt @@ -71,45 +71,16 @@ class CanvasRefreshManager( } /** - * Atomic erase commit — used by BOTH the eraser (pen button / hand) and scribble-to-erase - * paths so a pen lift removes the eraser indicator and the erased strokes in **one** step, - * with no window the user can draw into. See docs/onyx-pen-up-refresh-and-screen-freeze.md. - * - * The surface work runs **synchronously on the calling (raw-input callback) thread**, not - * via [drawCanvas] `post{}`. That matters: the firmware runs its own pen-up refresh shortly - * after the pen lifts, which would remove the (firmware-drawn) eraser indicator first and - * leave the strokes — then our app would remove the strokes a moment later. That is the - * "double refresh" the user sees. Doing the swap synchronously and immediately, before - * yielding, beats the firmware's pen-up refresh so there is a single transition. - * - * Must be called after the page bitmap has already been repainted to the erased state - * (i.e. after `handleErase` / `handleScribbleToErase`). + * Atomic erase commit for both the pen-button eraser and scribble-to-erase. Pushes the + * already-repainted page bitmap to the panel while the screen is still frozen, then fully + * toggles setRawDrawingEnabled(false→true) to drop the firmware layer atomically — eraser + * indicator and erased strokes disappear in one transition with no gap to draw into. + * ORDER IS CRITICAL: push first, then drop. See docs/onyx-pen-up-refresh-and-screen-freeze.md. + * Must be called after the page bitmap has already been repainted (after handleErase / + * handleScribbleToErase). */ fun commitErase(dirtyRect: Rect?, areaErase: Boolean = false) { val dirty = dirtyRect ?: Rect(0, 0, page.viewWidth, page.viewHeight) - // Mechanism copied from the official Onyx note app (com.onyx.kreader - // PenEventHandler.onEraseRefreshResultEvent): commit an erase by FULLY toggling - // setRawDrawingEnabled(false) -> (true), NOT the light isRawDrawingRenderEnabled - // toggle Notable used before. Fully disabling raw drawing hands the screen back to the - // SurfaceView — which we have already painted with the erased bitmap below — so the - // firmware eraser track AND the erased strokes update in ONE atomic transition: no - // stale snapshot, no gap to start drawing into. (The light render toggle only stops - // the firmware compositing its layer and reveals its last internal snapshot, which is - // exactly the "erased info didn't reach the screen controller" bug.) - // The official app re-arms after WaitForUpdateFinishedAction = max(minWait, 150)ms, - // i.e. 150ms for stroke/scribble erase and 500ms for an area (lasso) erase. - // - // ORDER IS CRITICAL — push the clean page to the PANEL first, THEN drop the firmware - // layer. kreader's scratch gesture (scribble-to-erase) removes the scribble strokes AND - // the overlapped strokes in ONE model mutation, re-renders the clean page to screen - // (RenderRequest.renderToScreen, forced out via enablePost while raw drawing is still ON), - // and only AFTER that runs setRawDrawingEnabled(false) (PenEventHandler - // .onShapeRefreshForHWREvent -> A(false) -> B(true,0)). That order is what makes the - // scribble and the strokes vanish in the SAME frame: the panel already shows the clean - // page before the firmware layer is removed, so there is no stale flash. - // The REVERSE order (disable first, then post) clears the big firmware scribble ink - // first and briefly reveals the not-yet-erased surface underneath — the flash the user - // sees, worst on scribble-to-erase. // 1. Block input immediately so no stroke can start during the swap/settle. touchHelper?.setRawInputReaderEnable(false) drawCanvas.coroutineScope.launch { @@ -145,11 +116,9 @@ class CanvasRefreshManager( log.e("commitErase: failed to lock canvas (surface invalid/destroyed)") return } - // Force the post through to the EPD even though raw-drawing render is still ON - // (the screen is frozen). Mirrors kreader's RxBaseReaderRequest.unlockCanvas: - // enablePost(0)+enablePost(1) bracketing the post, resetViewUpdateMode afterward. - // A single enablePost — or a bare unlockCanvasAndPost — is swallowed while frozen, - // so the erased page would never reach the panel before we drop the firmware layer. + // enablePost(0)+enablePost(1) forces the bitmap through to the EPD even while the + // screen is frozen (a bare unlockCanvasAndPost is swallowed). Mirrors kreader's + // RxBaseReaderRequest.unlockCanvas. EpdController.enablePost(0) EpdController.enablePost(1) canvas.drawBitmap(page.windowedBitmap, zoneToRedraw, zoneToRedraw, Paint()) @@ -158,7 +127,7 @@ class CanvasRefreshManager( } finally { try { if (canvas != null) drawCanvas.holder.unlockCanvasAndPost(canvas) - // kreader's afterUnlockCanvas equivalent — let the EPD apply the pushed region. + // Let the EPD apply the pushed region (kreader's afterUnlockCanvas equivalent). EpdController.resetViewUpdateMode(drawCanvas) } catch (e: IllegalStateException) { log.w("Surface released during unlock", e) diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt b/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt index c54c2dc0..7add4ae9 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt @@ -6,7 +6,6 @@ import android.graphics.RectF import android.util.Log import androidx.compose.ui.unit.dp import androidx.core.graphics.toRect -import com.ethran.notable.data.datastore.GlobalAppSettings import com.ethran.notable.editor.EditorViewModel import com.ethran.notable.editor.state.Mode import com.ethran.notable.editor.PageView @@ -25,8 +24,6 @@ import com.ethran.notable.editor.utils.handleScribbleToErase import com.ethran.notable.editor.utils.handleSelect import com.ethran.notable.editor.utils.onSurfaceInit import com.ethran.notable.editor.utils.penToStroke -import com.ethran.notable.editor.utils.prepareForPartialUpdate -import com.ethran.notable.editor.utils.restoreDefaults import com.ethran.notable.editor.utils.setupSurface import com.ethran.notable.editor.utils.transformToLine import com.ethran.notable.ui.convertDpToPixel @@ -95,49 +92,21 @@ class OnyxInputHandler( // Handle button/eraser tip of the pen: override fun onBeginRawErasing(p0: Boolean, p1: TouchPoint?) { if (touchHelper == null) return - // NATIVE ERASER INDICATOR: - // The eraser stroke is rendered natively by the firmware, enabled via - // touchHelper.setEraserRawDrawingEnabled(true, ...). This is RE-ASSERTED here - // because setRawDrawingEnabled(true) (called by updateIsDrawing() on every - // resume) internally calls resetPenDefaultRawDrawing() -> - // setEraserRawDrawingEnabled(false, 5), which would otherwise leave the native - // eraser channel disabled and the indicator blank (verified by decompiling - // onyxsdk-pen TouchHelper). - // The native eraser channel draws using the helper's current stroke - // color/width/style, so we configure a visible indicator that MATCHES the - // active eraser type (marker for the pen eraser, dashed line for the lasso / - // select eraser). It is restored in onEndRawErasing. The indicator itself is - // transient: after pen-up, onRawErasingList() repaints from the bitmap (which - // has no indicator), so it disappears once the erase is committed. - // See docs/onyx-native-eraser-indicator.md. + // Re-assert the native eraser indicator because setRawDrawingEnabled(true) (called + // on every resume) resets it to disabled internally. See docs/onyx-native-eraser-indicator.md. enableNativeEraser(touchHelper) applyEraserIndicatorStyle() - - // The OpenGL front-buffer workaround below is disabled, but kept (commented) - // as a reference for non-native erase rendering. - // if (GlobalAppSettings.current.openGLRendering) { - // prepareForPartialUpdate(drawCanvas, touchHelper!!) - // log.d("Eraser Mode") - // } isErasing = true } override fun onEndRawErasing(p0: Boolean, p1: TouchPoint?) { - // NATIVE ERASER INDICATOR: restore the pen's stroke settings after erasing. updatePenAndStroke() - // OpenGL workaround disabled (see onBeginRawErasing). - // if (GlobalAppSettings.current.openGLRendering) { - // restoreDefaults(drawCanvas) - // drawCanvas.glRenderer.clearPointBuffer() - // } - // drawCanvas.glRenderer.frontBufferRenderer?.cancel() } override fun onRawErasingTouchPointListReceived(plist: TouchPointList?) = onRawErasingList(plist) override fun onRawErasingTouchPointMoveReceived(p0: TouchPoint?) { -// if (p0 == null) return } override fun onPenUpRefresh(refreshRect: RectF?) { @@ -202,7 +171,6 @@ class OnyxInputHandler( if(touchHelper == null) return log.i("Update is drawing: $toolbarState.isDrawing") if (toolbarState.isDrawing) { -// DeviceCompat.delayBeforeResumingDrawing() touchHelper!!.setRawDrawingEnabled(true) } else { // Check if drawing is completed @@ -228,17 +196,11 @@ class OnyxInputHandler( ) } } - private fun onRawDrawingList(plist: TouchPointList) { + private fun onRawDrawingList(plist: TouchPointList) {s if (touchHelper == null) return val currentLastStrokeEndTime = lastStrokeEndTime lastStrokeEndTime = System.currentTimeMillis() val startTime = System.currentTimeMillis() - // sometimes UI will get refreshed and frozen before we draw all the strokes. - // I think, its because of doing it in separate thread. Commented it for now, to - // observe app behavior, and determine if it fixed this bug, - // as I do not know reliable way to reproduce it - // Need testing if it will be better to do in main thread on, in separate. - // thread(start = true, isDaemon = false, priority = Thread.MAX_PRIORITY) { when (toolbarState.mode) { Mode.Erase -> onRawErasingList(plist) @@ -264,12 +226,6 @@ class OnyxInputHandler( } } - // After each stroke ends, we draw it on our canvas. - // This way, when screen unfreezes the strokes are shown. - // When in scribble mode, ui want be refreshed. - // If we UI will be refreshed and frozen before we manage to draw - // strokes want be visible, so we need to ensure that it will be done - // before anything else happens. Mode.Line -> { coroutineScope.launch(Dispatchers.Main.immediate) { CanvasEventBus.drawingInProgress.withLock { @@ -300,7 +256,6 @@ class OnyxInputHandler( max(startPoint.x, endPoint.x).toInt(), max(startPoint.y, endPoint.y).toInt() ) -// partialRefreshRegionOnce(this@DrawCanvas, dirtyRect) drawCanvas.refreshManager.refreshUi(dirtyRect) CanvasEventBus.commitHistorySignal.emit(Unit) } @@ -315,8 +270,6 @@ class OnyxInputHandler( val lock = System.currentTimeMillis() log.d("lock obtained in ${lock - startTime} ms") - // Thread.sleep(1000) - // transform points to page space val scaledPoints = copyInput(plist.points, page.scroll, page.zoomLevel.value) val firstPointTime = plist.points.first().timestamp @@ -341,19 +294,11 @@ class OnyxInputHandler( ) } else { log.d("Erased by scribble, $erasedByScribbleDirtyRect") - // Union the scribble TRACK (what the firmware drew live, in screen - // coords from the raw points) with the erased strokes' bounds, exactly - // like the eraser path (onRawErasingList). commitErase pushes this - // union to the panel WHILE STILL FROZEN, so the post overwrites BOTH - // the firmware scribble ink and the erased writing in one step; only - // then does it drop the firmware layer — which now reveals an already - // clean page, so scribble and writing vanish together with no gap. - // (Previously only the erased-strokes bounds were pushed, leaving the - // rest of the scribble ink on screen until the firmware layer dropped - // a moment later — the visible gap.) The scribble is NOT drawn into - // the page bitmap (unlike kreader, which keeps it as a Shape); we only - // need the pushed region to cover the firmware's track. See - // docs/onyx-scribble-to-erase.md. + // Union the scribble track (firmware screen coords) with the erased + // strokes' bounds so commitErase overwrites both in one pass while + // still frozen. Scribble is not drawn into the page bitmap — we only + // need the region to cover the firmware's live track. + // See docs/onyx-scribble-to-erase.md. val padding = 10 val trackBox = calculateBoundingBox(plist.points) { Pair(it.x, it.y) }.toRect() @@ -364,9 +309,7 @@ class OnyxInputHandler( trackBox.bottom + padding ) erasedByScribbleDirtyRect.let { dirty.union(it) } - // Longer (area) settle: a scribble is a large-area gesture, far heavier - // on the EPD than a thin stroke erase — 150ms was too short and let the - // next stroke composite against not-yet-settled content. + // Use areaErase=true for the longer 500ms settle (scribble is a large gesture). drawCanvas.refreshManager.commitErase(dirty, areaErase = true) } @@ -383,8 +326,6 @@ class OnyxInputHandler( isErasing = false if (plist == null) return - plist.points - val points = copyInputToSimplePointF(plist.points, page.scroll, page.zoomLevel.value) val padding = 10 diff --git a/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt b/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt index 5cdd2501..c8084bca 100644 --- a/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt +++ b/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt @@ -30,18 +30,9 @@ object NeoFountainPenV2Wrapper { // the same stroke after an unfreeze would normalize the pressures again). val renderPoints = copyAndNormalizePressure(points, maxTouchPressure) - // Mirror the official demo (BrushScribbleShape): build the pen via - // FountainShapes.createNeoPenV2 so the config (width compensation, minWidth, - // smoothLevel, pressureSensitivity, tilt=off, fastMode) matches what the - // firmware uses while drawing live. Any deviation here makes the redraw differ. - // fastMode MUST be false for the offline redraw. With fastMode = true the pen - // returns PenPointResult (discrete point/dab stamps) — this is what the firmware - // uses for low-latency LIVE drawing, but when we re-draw the finished stroke onto - // our surface it renders "point by point" and looks faceted. fastMode = false - // returns PenPathResult, a continuous smooth vector path, which is what we want for - // a clean redraw (this is what the old hand-rolled wrapper got for free, since a - // bare NeoPenConfig defaults fastMode to false). The rest of the config still comes - // from createNeoPenV2 so the size/compensation matches the firmware. + // Mirror the official demo (BrushScribbleShape) via FountainShapes.createNeoPenV2 so + // config matches the firmware's live rendering. fastMode=false yields PenPathResult + // (smooth vector path); true would give discrete dab stamps and look faceted on redraw. // See docs/onyx-neo-fountain-pen-v2.md. val neoPen = FountainShapes.createNeoPenV2( strokeWidth, // width @@ -61,10 +52,8 @@ object NeoFountainPenV2Wrapper { val penRender = NeoPenRender(neoPen) try { - // render() runs the full onTouchDown/onTouchMove/onTouchDone pipeline and - // draws every accumulated result plus the trailing prediction segment of the - // last result. Doing this by hand and drawing only `.first` (as before) drops - // the tail of the stroke and skips the SDK's batching. + // render() runs the full onTouchDown/onTouchMove/onTouchDone pipeline including + // the trailing prediction segment; this is required for complete stroke coverage. penRender.render(canvas, paint, renderPoints) } finally { penRender.destroyPen() diff --git a/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt b/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt index 611703ed..9863eedf 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt @@ -1,6 +1,5 @@ package com.ethran.notable.editor.utils -import android.content.Context import android.graphics.Rect import android.os.Build import android.view.View @@ -38,97 +37,6 @@ import kotlin.time.Duration.Companion.milliseconds private val log = ShipBook.getLogger("einkHelper") -/** - * ONYX EPD REFRESH MODES GUIDE (Generated by AI, better then nothing) - * ============================================ - * - * ### 1. Update Modes (Quality ↔ Speed Tradeoff) - * These control the balance between refresh speed and display quality: - * - * | Mode | Description | - * |--------------|---------------------------------------------------| - * | `GC` | Full refresh (Best quality, slowest, no ghosting) | - * | `GU` | Grayscale update (Partial refresh, preserves tone)| - * | `DU` | Direct update (Faster, partial, moderate quality) | - * | `ANIMATION` | Smooth animation updates (some ghosting possible) | - * | `A2` | Fastest update (Lowest quality, minimal latency) | - * | `DEEP_GC` | Deep clean refresh (Best for text rendering) | - * - * ### 2. Display Schemes (System-Wide Presets) - * Affect the global system behavior: - * - * | Scheme | Description | - * |-------------------------------|--------------------------------------| - * | `SCHEME_NORMAL` | Default balanced e-ink mode | - * | `SCHEME_KEYBOARD` | Optimized for keyboard input | - * | `SCHEME_SCRIBBLE` | Best for handwriting/stylus input | - * | `SCHEME_APPLICATION_ANIMATION` | App-specific animation optimization | - * | `SCHEME_SYSTEM_ANIMATION` | System-wide animation optimization | - * - * ### 3. Special Functions (System-Level Tools) - * - * | Function | Description | - * |---------------------------|--------------------------------------------------| - * | `applyGCOnce()` | Force one full-screen GC refresh | - * | `repaintEveryThing()` | Repaints the entire screen with current mode | - * | `fillWhiteOnWakeup()` | Clears residual ghosting on wake | - * | `useGCForNewSurface()` | Forces GC when new surfaces appear | - * | `setEpdTurbo(int)` | Adjusts refresh speed (0 = slow, 100 = fastest) | - * - * ### 4. View-Specific Controls - * Fine-grained control per view: - * - * | Function | Description | - * |----------------------------------|--------------------------------------------| - * | `setViewDefaultUpdateMode()` | Sets default update mode for a specific view | - * | `resetViewUpdateMode()` | Resets view update mode to system default | - * | `disableA2ForSpecificView()` | Disables A2 mode for that view | - * | `handwritingRepaint()` | Optimized refresh for handwriting regions | - * - * ### 5. Waveform Controls (Advanced Tuning) - * - * | Function | Description | - * |-------------------|----------------------------------------| - * | `enableRegal()` | Enables Regal waveform for less ghosting | - * | `disableRegal()` | Disables Regal waveform | - * | `setTrigger()` | Sets waveform trigger count | - * | `byPass()` | Skips scheduled waveform updates | - * - * ### 6. Visual Enhancements - * Adjust display contrast and rendering: - * - * | Function | Description | - * |----------------------------------|----------------------------------------------| - * | `applyGammaCorrection()` | Adjusts gamma curve for contrast | - * | `applyMonoLevel()` | Controls monochrome intensity | - * | `applyColorFilter()` | Adjusts color tones (for color e-ink only) | - * | `setWebViewContrastOptimize()` | Enhances contrast in WebViews | - * - * ### Common Usage Patterns: - * - * // For animations or video playback: - * EpdController.setDisplayScheme(SCHEME_APPLICATION_ANIMATION) - * EpdController.setViewDefaultUpdateMode(view, ANIMATION) - * - * // For standard reading mode: - * EpdController.setDisplayScheme(SCHEME_NORMAL) - * EpdController.applyGCOnce() // To remove ghosting - * - * // For handwriting or drawing apps: - * EpdController.setDisplayScheme(SCHEME_SCRIBBLE) - * EpdController.handwritingRepaint(inkView, dirtyRect) - * - * // To reset view to default behavior: - * EpdController.resetViewUpdateMode(view) - * EpdController.setDisplayScheme(SCHEME_NORMAL) - */ - - -/** - * Toggles animation-optimized mode for smoother UI interactions. - * - * @param isAnimationMode true to enable fast/animation mode, false for normal refresh - */ fun setAnimationMode(isAnimationMode: Boolean) { // reference: // https://github.com/onyx-intl/OnyxAndroidDemo/blob/d3a1ffd3af231fe4de60a2a0da692c17cb35ce31/app/OnyxPenDemo/src/main/java/com/onyx/android/eink/pen/demo/ui/PenDemoActivity.java#L500 @@ -273,21 +181,9 @@ fun setupSurface(view: View, touchHelper: TouchHelper?, toolbarHeight: Int) { touchHelper.setRawDrawingEnabled(true) - // NATIVE ERASER INDICATOR (pen side-button erasing): - // Ask the firmware to render the eraser path itself while erasing with the pen - // button, the same way a normal stroke is rendered. This replaces the OpenGL - // front-buffer "indicator" workaround (see OnyxInputHandler.onBeginRawErasing and - // DrawCanvas.dispatchTouchEvent, both kept commented as a non-native reference). - // Signature: setEraserRawDrawingEnabled(drawing: Boolean, eraserStyle: Int), - // eraserStyle uses TouchHelper.STROKE_STYLE_* (firmware default is (false, 5=DASH)). - // - // IMPORTANT (verified by decompiling onyxsdk-pen TouchHelper): setRawDrawingEnabled(true) - // internally calls resetPenDefaultRawDrawing() which calls - // setEraserRawDrawingEnabled(false, 5) -- so this MUST be called AFTER - // setRawDrawingEnabled(true), otherwise it is immediately reset to disabled. - // It is also re-asserted in OnyxInputHandler.onBeginRawErasing because - // updateIsDrawing() calls setRawDrawingEnabled(true) on every resume, which would - // otherwise wipe it again. See docs/onyx-native-eraser-indicator.md. + // Enable the firmware's native eraser indicator. MUST be called after setRawDrawingEnabled(true) + // because that call internally resets it to disabled. Also re-asserted in onBeginRawErasing. + // See docs/onyx-native-eraser-indicator.md. enableNativeEraser(touchHelper) log.i("Setup editable surface completed") @@ -310,11 +206,8 @@ fun enableNativeEraser(touchHelper: TouchHelper?) { fun prepareForPartialUpdate(view: View, touchHelper: TouchHelper?) { if(touchHelper == null) return EpdController.setDisplayScheme(SCHEME_SCRIBBLE) -// EpdController.useFastScheme() // the same as above EpdController.enableA2ForSpecificView(view) -// EpdController.enablePost(view, 1) EpdController.setEpdTurbo(100) - //exit of drawing mode touchHelper.isRawDrawingRenderEnabled = false touchHelper.isRawDrawingRenderEnabled = true } @@ -332,38 +225,24 @@ fun refreshScreenRegion(view: View, dirtyRect: Rect) { dirtyRect.height(), UpdateMode.ANIMATION_MONO ) -// EpdController.handwritingRepaint(view, dirtyRect) } -//https://github.com/onyx-intl/OnyxAndroidDemo/blob/d3a1ffd3af231fe4de60a2a0da692c17cb35ce31/doc/EPD-Screen-Update.md fun refreshScreen() { - // TODO: It does nothing, I have no idea why. EpdController.repaintEveryThing(UpdateMode.REGAL_PLUS) -// EpdController.refreshScreen(view, UpdateMode.ANIMATION_MONO) } - - fun restoreDefaults(view: View) { -// EpdController.resetViewUpdateMode(view) EpdController.setDisplayScheme(SCHEME_NORMAL) - } - fun partialRefreshRegionOnce(view: View, dirtyRect: Rect, touchHelper: TouchHelper?) { if(touchHelper == null) return -// touchHelper.isRawDrawingRenderEnabled = false refreshScreenRegion(view, dirtyRect) resetScreenFreeze(touchHelper) - // we need to wait before refreshing, as onyx library has its own buffer that needs to be updated. Otherwise we will refresh to correct, then incorrect and then correct state. -// delay(100) -// resetScreenFreeze(touchHelper) } fun resetScreenFreeze(touchHelper: TouchHelper?, view: View? = null) { - if(touchHelper == null) - { + if(touchHelper == null) { log.w("touchHelper is null") return } @@ -372,18 +251,8 @@ fun resetScreenFreeze(touchHelper: TouchHelper?, view: View? = null) { DeviceCompat.delayBeforeResumingDrawing() touchHelper.isRawDrawingRenderEnabled = true } -// setRawDrawingEnabled(false) -// setRawDrawingEnabled(true) } -// Device.currentDevice().invalidate(this@DrawCanvas, UpdateMode.ANIMATION_MONO); -// EpdController.refreshScreen(view, UpdateMode.ANIMATION_MONO) -// EpdController.setEpdTurbo(100) -// EpdController.clearTransientUpdate(true) -// EpdController.repaintEveryThing() -// EpdController.setScreenHandWritingPenState(view, EpdPenManager.PEN_PAUSE) -// EpdController.setScreenHandWritingPenState(view, EpdPenManager.PEN_DRAWING) - /** * Automatically toggles e‑ink animation mode when the attached subtree scrolls. @@ -490,16 +359,10 @@ object DeviceCompat { } suspend fun delayBeforeResumingDrawing(isErasing: Boolean = false, areaErase: Boolean = false) { if (!isOnyxDevice) return - // Resume delays mirror the official Onyx note app (com.onyx.kreader - // WaitForUpdateFinishedAction, driven from PenEventHandler.onEraseRefreshResultEvent): - // - erasing: the official app waits max(minWait, 150)ms before re-arming, where - // minWait = 500 for an AREA (lasso/select) erase and 0 for stroke/move erase. - // So: 150ms for a normal stroke/scribble erase, 500ms for an area erase. This is - // safe at 150ms ONLY because the commit uses the heavy setRawDrawingEnabled(false) - // toggle (see CanvasRefreshManager.commitErase), which hands the screen back to the - // erased SurfaceView atomically — the light isRawDrawingRenderEnabled toggle needed - // a longer, more conservative delay to hide its stale snapshot. - // - normal pen: 500ms for Kaleido color, 300ms for monochrome. + // Delays mirror kreader's WaitForUpdateFinishedAction: + // - erase: 150ms stroke, 500ms area (lasso). Safe at 150ms because commitErase uses the + // heavy setRawDrawingEnabled toggle which hands the screen back atomically. + // - normal pen: 500ms color, 300ms monochrome. // See docs/onyx-pen-up-refresh-and-screen-freeze.md. val delay = when { isErasing -> if (areaErase) 500.milliseconds else 150.milliseconds From 50cbe849c26926e043573c1f28071e5e62fae380 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Tue, 30 Jun 2026 21:42:41 +0200 Subject: [PATCH 8/9] correct location of file --- .../ethran/notable/editor/canvas/CanvasRefreshManager.kt | 2 +- .../java/com/ethran/notable/editor/canvas/DrawCanvas.kt | 2 +- .../com/ethran/notable/editor/canvas/OnyxInputHandler.kt | 8 ++++---- .../notable/editor/drawing/NeoFountainPenV2Wrapper.kt | 2 +- .../java/com/ethran/notable/editor/utils/einkHelper.kt | 4 ++-- .../main/java/com/ethran/notable/editor/utils/eraser.kt | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt index c877b9db..42a3e232 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/CanvasRefreshManager.kt @@ -75,7 +75,7 @@ class CanvasRefreshManager( * already-repainted page bitmap to the panel while the screen is still frozen, then fully * toggles setRawDrawingEnabled(false→true) to drop the firmware layer atomically — eraser * indicator and erased strokes disappear in one transition with no gap to draw into. - * ORDER IS CRITICAL: push first, then drop. See docs/onyx-pen-up-refresh-and-screen-freeze.md. + * ORDER IS CRITICAL: push first, then drop. See docs/onyx-sdk/onyx-pen-up-refresh-and-screen-freeze.md. * Must be called after the page bitmap has already been repainted (after handleErase / * handleScribbleToErase). */ diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt b/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt index a4d243a5..07e49d5e 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt @@ -58,7 +58,7 @@ class DrawCanvas( // (see einkHelper.setupSurface -> setEraserRawDrawingEnabled), so we no longer // route erase touches into the OpenGL front-buffer renderer. Non-Onyx devices // still use OpenGL as their only renderer. The original condition is kept - // (commented) as a reference. See docs/onyx-native-eraser-indicator.md. + // (commented) as a reference. See docs/onyx-sdk/onyx-native-eraser-indicator.md. // if (!DeviceCompat.isOnyxDevice || inputHandler.isErasing) { if (!DeviceCompat.isOnyxDevice) { glRenderer.onTouchListener.onTouch(this, event) diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt b/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt index 7add4ae9..b30e368a 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/OnyxInputHandler.kt @@ -93,7 +93,7 @@ class OnyxInputHandler( override fun onBeginRawErasing(p0: Boolean, p1: TouchPoint?) { if (touchHelper == null) return // Re-assert the native eraser indicator because setRawDrawingEnabled(true) (called - // on every resume) resets it to disabled internally. See docs/onyx-native-eraser-indicator.md. + // on every resume) resets it to disabled internally. See docs/onyx-sdk/onyx-native-eraser-indicator.md. enableNativeEraser(touchHelper) applyEraserIndicatorStyle() isErasing = true @@ -196,7 +196,7 @@ class OnyxInputHandler( ) } } - private fun onRawDrawingList(plist: TouchPointList) {s + private fun onRawDrawingList(plist: TouchPointList) { if (touchHelper == null) return val currentLastStrokeEndTime = lastStrokeEndTime lastStrokeEndTime = System.currentTimeMillis() @@ -298,7 +298,7 @@ class OnyxInputHandler( // strokes' bounds so commitErase overwrites both in one pass while // still frozen. Scribble is not drawn into the page bitmap — we only // need the region to cover the firmware's live track. - // See docs/onyx-scribble-to-erase.md. + // See docs/onyx-sdk/onyx-scribble-to-erase.md. val padding = 10 val trackBox = calculateBoundingBox(plist.points) { Pair(it.x, it.y) }.toRect() @@ -348,7 +348,7 @@ class OnyxInputHandler( // their union both wipes the indicator and shows the erased result in one pass. // commitErase blocks input, draws synchronously, then drops the firmware overlay so // indicator + strokes disappear together (no double refresh, no gap to draw into). - // See docs/onyx-pen-up-refresh-and-screen-freeze.md. + // See docs/onyx-sdk/onyx-pen-up-refresh-and-screen-freeze.md. val dirty = Rect(strokeArea) if (zoneEffected != null) dirty.union(zoneEffected) // Area (lasso/select) erase needs the longer 500ms settle the official app uses; the diff --git a/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt b/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt index c8084bca..24a4b4f4 100644 --- a/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt +++ b/app/src/main/java/com/ethran/notable/editor/drawing/NeoFountainPenV2Wrapper.kt @@ -33,7 +33,7 @@ object NeoFountainPenV2Wrapper { // Mirror the official demo (BrushScribbleShape) via FountainShapes.createNeoPenV2 so // config matches the firmware's live rendering. fastMode=false yields PenPathResult // (smooth vector path); true would give discrete dab stamps and look faceted on redraw. - // See docs/onyx-neo-fountain-pen-v2.md. + // See docs/onyx-sdk/onyx-neo-fountain-pen-v2.md. val neoPen = FountainShapes.createNeoPenV2( strokeWidth, // width NeoFountainPenWrapper.MIN_FOUNTAIN_PEN_WIDTH, // minWidth diff --git a/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt b/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt index 9863eedf..28468118 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/einkHelper.kt @@ -183,7 +183,7 @@ fun setupSurface(view: View, touchHelper: TouchHelper?, toolbarHeight: Int) { // Enable the firmware's native eraser indicator. MUST be called after setRawDrawingEnabled(true) // because that call internally resets it to disabled. Also re-asserted in onBeginRawErasing. - // See docs/onyx-native-eraser-indicator.md. + // See docs/onyx-sdk/onyx-native-eraser-indicator.md. enableNativeEraser(touchHelper) log.i("Setup editable surface completed") @@ -363,7 +363,7 @@ object DeviceCompat { // - erase: 150ms stroke, 500ms area (lasso). Safe at 150ms because commitErase uses the // heavy setRawDrawingEnabled toggle which hands the screen back atomically. // - normal pen: 500ms color, 300ms monochrome. - // See docs/onyx-pen-up-refresh-and-screen-freeze.md. + // See docs/onyx-sdk/onyx-pen-up-refresh-and-screen-freeze.md. val delay = when { isErasing -> if (areaErase) 500.milliseconds else 150.milliseconds isColorDevice() -> 500.milliseconds diff --git a/app/src/main/java/com/ethran/notable/editor/utils/eraser.kt b/app/src/main/java/com/ethran/notable/editor/utils/eraser.kt index b82abe1b..24484717 100644 --- a/app/src/main/java/com/ethran/notable/editor/utils/eraser.kt +++ b/app/src/main/java/com/ethran/notable/editor/utils/eraser.kt @@ -135,7 +135,7 @@ fun handleScribbleToErase( // pushes this rect to the SurfaceView/EPD via commitErase, and the surface bitmap // (windowedBitmap) is in screen space — returning page coords here pushed the wrong // region whenever scrolled/zoomed. The caller unions this with the scribble track so - // the firmware ink is cleared in the same post. See docs/onyx-scribble-to-erase.md. + // the firmware ink is cleared in the same post. See docs/onyx-sdk/onyx-scribble-to-erase.md. val effectedArea = page.toScreenCoordinates(strokeBounds(deletedStrokes)) page.drawAreaScreenCoordinates(screenArea = effectedArea) return effectedArea From 0d65e402c80489e612bb2c68ae830461e5977575 Mon Sep 17 00:00:00 2001 From: Wiktor Wichrowski Date: Tue, 30 Jun 2026 21:45:45 +0200 Subject: [PATCH 9/9] correct location of file --- .../main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt b/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt index 07e49d5e..b93c297d 100644 --- a/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt +++ b/app/src/main/java/com/ethran/notable/editor/canvas/DrawCanvas.kt @@ -103,7 +103,7 @@ class DrawCanvas( fun registerObservers() = observers.registerAll() fun init() { - log.i("the Initializing Canvas") + log.i("Initializing Canvas") glRenderer = OpenGLRenderer(this@DrawCanvas) glRenderer.attachSurfaceView(this)