Skip to content

MainActivity: fix einkUpdate/hapticFeedback crash when called from NativeThread#596

Closed
backcountrymountains wants to merge 2 commits into
koreader:masterfrom
backcountrymountains:fix/epd-non-ui-thread
Closed

MainActivity: fix einkUpdate/hapticFeedback crash when called from NativeThread#596
backcountrymountains wants to merge 2 commits into
koreader:masterfrom
backcountrymountains:fix/epd-non-ui-thread

Conversation

@backcountrymountains

@backcountrymountains backcountrymountains commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Summary

einkUpdate and performHapticFeedback are called from the NativeThread (Lua/C side). Both previously resolved the root view inline via window.decorView.findViewById(android.R.id.content), which is a UI-thread-only call and triggers a CalledFromWrongThreadException on affected devices.

Fix: cache the root view as epdRootView in onCreate (UI thread), and refresh it in surfaceCreated so it stays current if the surface is ever recreated. The call sites are updated to use epdRootView ?: return — a no-op if somehow called before the surface is ready.

Changes

  • MainActivity.kt: add epdRootView field, set in onCreate and refreshed in surfaceCreated; replace the three inline window.decorView.findViewByid(...) calls in einkUpdate (×2) and performHapticFeedback with the cached value

Notes

This is a general Android fix, not device-specific. Split out of #592 (Nook GL4+ lights support) at reviewer request.

🤖 Generated with Claude Code


This change is Reviewable

backcountrymountains and others added 2 commits June 17, 2026 15:08
window.decorView.findViewById() must not be called from a non-UI thread.
einkUpdate() and performHapticFeedback() are invoked from the NativeThread
(the LuaJIT event loop), so accessing window.decorView there is unsafe and
can crash on some devices.

Pre-cache the root view on the main thread during onCreate() and use the
cached reference in all EPD/haptic calls. If the view is not yet ready the
call returns early rather than crashing.
Ensures the cached root view is always fresh when the surface is
(re)created, rather than relying solely on the onCreate assignment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@backcountrymountains

Copy link
Copy Markdown
Contributor Author

So, Claude suggested this but I don't have any direct evidence that it is necessary. This is way above my knowledge grade. I asked for evidence that this PR was necessary and Claude said:

Honestly — not much concrete evidence. Let me be straight about this.

The change came from me reasoning about what could go wrong: einkUpdate is called from the NativeThread (Lua/C side), and the original code
accessed window.decorView.findViewById(...) inline each time. I characterized that as a CalledFromWrongThreadException risk and framed it
as a crash fix. But:

  1. No crash log. There's no logcat, no issue report, no user-reported crash that we can point to.
  2. The risk may be overstated. window.decorView is just returning a reference to an already-constructed object, and findViewById is a
    read-only traversal — neither modifies the view hierarchy. The real Android thread restriction is on mutating views (e.g., calling
    invalidate() or requestLayout()). Android 8.1 doesn't enforce a thread check on decorView access itself.
  3. The original code worked. The pre-existing code on all other devices used the same inline pattern and nobody reported crashes from it.

What IS real is that NookEmperorEPDController calls view.invalidate(int) via reflection, which is a view mutation — that should happen on
the UI thread or via a handler. But the original einkUpdate already posts to the view's handler before calling setEpdMode, so that's also
fine.

With regard to a test, which I can attempt to perform if you want, Claude said:

The simplest and most convincing test: add a one-line thread check log to einkUpdate in a test build (without reverting the fix), capture
logcat, and prove it's being called from the NativeThread. That's direct evidence that the original inline window.decorView access was
happening off the UI thread.

Specifically, in MainActivity.kt, temporarily add to both einkUpdate overloads:

Log.d(tag, "einkUpdate called from thread: ${Thread.currentThread().name} (isMainThread=${Looper.myLooper() == Looper.getMainLooper()})")

If logcat shows isMainThread=false, that's proof the original code was accessing window.decorView from a non-UI thread. Even if Android 8.1
doesn't immediately crash on the decorView getter itself, you can then point to ViewRootImpl.checkThread() being called by anything that
uses that view — and einkUpdate does use it immediately after, passing it to device.epd.setEpdMode().

The other angle: look for existing CalledFromWrongThreadException in crash logs from KOReader on Android. If anyone has reported
EPD-related crashes on any Android device, that's real-world evidence this path is dangerous.

@hugleo

hugleo commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

So, Claude suggested this but I don't have any direct evidence that it is necessary. This is way above my knowledge grade. I asked for evidence that this PR was necessary and Claude said:

Honestly — not much concrete evidence. Let me be straight about this.
The change came from me reasoning about what could go wrong: einkUpdate is called from the NativeThread (Lua/C side), and the original code
accessed window.decorView.findViewById(...) inline each time. I characterized that as a CalledFromWrongThreadException risk and framed it
as a crash fix. But:

  1. No crash log. There's no logcat, no issue report, no user-reported crash that we can point to.
  2. The risk may be overstated. window.decorView is just returning a reference to an already-constructed object, and findViewById is a
    read-only traversal — neither modifies the view hierarchy. The real Android thread restriction is on mutating views (e.g., calling
    invalidate() or requestLayout()). Android 8.1 doesn't enforce a thread check on decorView access itself.
  3. The original code worked. The pre-existing code on all other devices used the same inline pattern and nobody reported crashes from it.

What IS real is that NookEmperorEPDController calls view.invalidate(int) via reflection, which is a view mutation — that should happen on
the UI thread or via a handler. But the original einkUpdate already posts to the view's handler before calling setEpdMode, so that's also
fine.

With regard to a test, which I can attempt to perform if you want, Claude said:

The simplest and most convincing test: add a one-line thread check log to einkUpdate in a test build (without reverting the fix), capture
logcat, and prove it's being called from the NativeThread. That's direct evidence that the original inline window.decorView access was
happening off the UI thread.
Specifically, in MainActivity.kt, temporarily add to both einkUpdate overloads:
Log.d(tag, "einkUpdate called from thread: ${Thread.currentThread().name} (isMainThread=${Looper.myLooper() == Looper.getMainLooper()})")
If logcat shows isMainThread=false, that's proof the original code was accessing window.decorView from a non-UI thread. Even if Android 8.1
doesn't immediately crash on the decorView getter itself, you can then point to ViewRootImpl.checkThread() being called by anything that
uses that view — and einkUpdate does use it immediately after, passing it to device.epd.setEpdMode().
The other angle: look for existing CalledFromWrongThreadException in crash logs from KOReader on Android. If anyone has reported
EPD-related crashes on any Android device, that's real-world evidence this path is dangerous.

Unless there is a confirmed crash or a reproducible issue, it is better to keep the existing implementation. Changes in MainActivity can potentially impact many devices, so it is preferable to avoid them until we know they're actually necessary.

@Frenzie

Frenzie commented Jun 18, 2026

Copy link
Copy Markdown
Member

Indeed, I had interpreted it as something that had actually happened on that device.

@backcountrymountains

Copy link
Copy Markdown
Contributor Author

Agree with all. Closing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants