Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions docs/parity/native-reader-interaction-navigation-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Native Reader Interaction vs Navigation Plan

## Goal

Separate generic reader interaction from page navigation so manual panning inside
Nota comics does not accidentally trigger native page turns or narration manual
mode.

This should be a separate PR from EPUB image-tap detection. The current PR found
the issue while testing Android MO Nota comics, but the behavior touches shared
reader input semantics across Dart, Android, and iOS.

## Current symptoms

- On Android, manual panning inside a Nota MO comic page can still trigger
`goForward` / `goBackward` between pages.
- The trigger was isolated to `_onInteraction()` being called from
`ReadiumReaderWidget`'s `Listener.onPointerMove` path.
- Removing the native notification from pointer-move interaction reduces the
immediate problem, but it also changes how swipe gestures enter narration
manual mode.

## Relevant code

- `flutter_readium/lib/reader_widget.dart`
- `Listener.onPointerMove` classifies small movement as `userSwipe`.
- `_onInteraction()` hides controls and calls `_channel?.notifyUserNavigation()`.
- Semantic edge regions call `_channel?.goForward()` / `goBackward()` directly.
- `flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/fragments/EpubReaderFragment.kt`
- Installs `DirectionalNavigationAdapter` on the `OverflowableNavigator`.
- Readium owns edge-tap / keyboard navigation at the native navigator layer.
- `flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumReader.kt`
- Explicit `epubGoForward` / `epubGoBackward` already call
`enterManualModeIfNarrating(...)` before navigating.
- `flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumReaderWidget.kt`
- Method-channel `notifyUserNavigation` only enters narration manual mode; it
should not itself page-turn.
- `flutter_readium/assets/_helper_scripts/src/NotaComicBookPage.ts`
- Nota comic manual pan/zoom uses the cloned image overlay.
- Touch listeners are passive today, so native navigator input may still see
the same gesture.

## Working hypothesis

`ReadiumReaderWidget` currently conflates two concepts:

1. Generic interaction: wake lock, hide controls, center/edge tap UI handling.
2. Navigation intent: user intentionally moved to another page and narration
should enter manual mode.

Now that Android and iOS use native `DirectionalNavigationAdapter`, the Flutter
wrapper should not infer navigation from `onPointerMove`. Native page-turn paths
should own navigation/manual-mode transitions.

The remaining accidental page turn may come from native navigator input seeing
the same touch gesture used by the Nota comic overlay. If so, fixing only Dart is
insufficient; the comic overlay must consume or isolate manual pan gestures.

## Debug checklist

1. Add temporary logs in `reader_widget.dart`:
- pointer down/move/up
- whether movement crossed the `userSwipe` threshold
- whether `_channel.notifyUserNavigation()` was called
2. Add temporary logs in Android `ReadiumReaderWidget`:
- method-channel `goForward`, `goBackward`, `notifyUserNavigation`
3. Add temporary logs in `ReadiumReader.epubGoForward` / `epubGoBackward` and
`EpubReaderFragment.goForward` / `goBackward`.
4. Add temporary JS logs in `NotaComicBookPage.ts`:
- `touchstart`, `touchmove`, `touchend`
- whether `manualOverrideActive` is true
- whether the event target is inside `.nota-comicbook-page-container`
5. Reproduce on Android MO Nota comic:
- pan while zoomed/manual
- edge tap intentionally
- swipe intentionally
- compare event ordering in logs

Expected evidence:

- If `goForward` / `goBackward` method-channel logs fire, Dart/UI code is still
requesting navigation.
- If native `onPageChanged` happens without method-channel `goForward` /
`goBackward`, the page turn comes from native navigator input.
- If the native turn disappears when `DirectionalNavigationAdapter` is
temporarily disabled, Readium input handling is the source.
- If the native turn disappears when the comic overlay consumes manual pan
events, the source is gesture propagation from the overlay to Readium.

## Proposed implementation direction

### Phase 1: Split Dart interaction semantics

- Rename/split `_onInteraction()` into two explicit concepts:
- hide controls / refresh wake lock
- notify navigation/manual-mode intent
- Do not call `notifyUserNavigation` from `onPointerMove`.
- Keep center tap and edge tap behavior explicit.
- Consider deleting `notifyUserNavigation` calls from Flutter entirely once
native swipe/edge/key navigation is verified to enter manual mode on both iOS
and Android.

### Phase 2: Make native navigation the authority

- Treat native `goForward` / `goBackward` paths as the authoritative signal for
manual navigation during narration.
- Verify Android `DirectionalNavigationAdapter` page turns pass through code that
enters manual mode. If they do not, add a native input listener or navigator
callback at the Android layer instead of using Flutter pointer movement.
- Do the same verification on iOS.

### Phase 3: Isolate Nota comic manual pan

- When `.nota-comicbook-page-container` is in manual zoom/pan mode, test a
non-passive touch/pointer listener that calls `preventDefault()` and/or
`stopPropagation()` for single-finger pan inside the active overlay.
- Keep normal page swipe available when the comic overlay is not in manual mode.
- Avoid blocking pinch gestures needed by the comic helper.

## Acceptance criteria

- Panning inside a zoomed Nota MO comic page does not change EPUB spine/page.
- Intentional native page navigation still works.
- During active narration, intentional page navigation enters manual mode and
emits `onNarrationSyncChanged(false)`.
- Center tap still toggles controls.
- Edge tap behavior remains intentional and documented.
- Behavior is checked on Android first; iOS parity is verified or explicitly
documented.

## Suggested validation

- Android manual smoke test with `50272-nota-comics.webpub`:
- start narration
- enter manual zoom/pan
- pan horizontally and vertically inside the page
- confirm no page turn
- use intentional edge/swipe page turn
- confirm page turn and manual-mode event semantics
- Run `bin/format && bin/analyze` before PR.
- If touching Kotlin: run
`cd flutter_readium/example/android && ./gradlew :flutter_readium:compileDebugKotlin`.
- If touching helper scripts: run `bin/build_helper_scripts` or the equivalent
npm helper build used by the repo.

## Open questions

- Does Android `DirectionalNavigationAdapter` page navigation pass through
`ReadiumReader.epubGoForward` / `epubGoBackward`, or does it navigate entirely
inside the navigator fragment?
- Should Flutter keep semantic edge regions for accessibility only, or should
native expose accessible page-turn actions directly?
- Is `notifyUserNavigation` still needed after native input paths are verified?
9 changes: 9 additions & 0 deletions flutter_readium/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Added

- **EPUB image tap** — tapping an image in an EPUB now fires `onImageTapped`
with an `ImageTapEvent` carrying the publication-relative `href`, optional
`alt` / `caption`, on-screen `rect`, and a `srcUrl` on Web. Detection runs on
iOS, Android, and Web. Android and Web suppress image-tap events for DiViNa publications
and Nota comic page images so narrated comics keep their panel navigation behavior.
- **`getResourceBytes(href)`** — new API on `FlutterReadium` and the underlying
platform interface that returns the raw bytes of any manifest resource, plus a
companion `FlutterReadium.imageProvider(href)` that plugs into Flutter's image
pipeline for lazy display. Implemented on iOS, Android, and Web.
- **Narration sync state & manual mode (iOS, Android, Web)** — a new
`FlutterReadium.onNarrationSyncChanged` stream (`Stream<bool>`: `true` = following narration,
`false` = manual mode) and `FlutterReadium.setNarrationSyncEnabled(bool)`. While Media Overlay or
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package dk.nota.flutterreadium

import android.webkit.JavascriptInterface
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.json.JSONObject
import org.readium.r2.shared.publication.Publication
import java.net.URI

private const val TAG = "EpubImageTapBridge"

/**
* JavaScript interface injected into each EPUB resource WebView via
* [org.readium.r2.navigator.epub.EpubNavigatorFragment.Configuration.registerJavascriptInterface].
*
* Exposes a single entry point callable from content JS:
* `FlutterImageBridge.onImageTapped(JSON.stringify(payload))`
*
* The bridge resolves the publication-relative href from the absolute `img.src`
* URL by suffix-matching the path component against all known publication links.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class EpubImageTapBridge {
@JavascriptInterface
fun onImageTapped(json: String) {
val publication =
ReadiumReader.currentPublication ?: run {
PluginLog.w(TAG, "::onImageTapped. No publication available.")
return
}
val channel =
ReadiumReader.currentReaderWidget?.channel ?: run {
PluginLog.w(TAG, "::onImageTapped. No reader channel available.")
return
}
try {
val data = JSONObject(json)
if (publication.suppressesImageTapEvents(data)) {
PluginLog.d(TAG, "::onImageTapped. Suppressed for publication/content type.")
return
}
val srcUrl = data.optString("srcUrl")
val href =
resolvePublicationHref(srcUrl) ?: run {
PluginLog.w(TAG, "::onImageTapped. Could not resolve href for srcUrl=$srcUrl")
return
}

val result =
JSONObject().apply {
put("href", href)
val alt = data.optString("alt").ifEmpty { null }
if (alt != null) put("alt", alt)
val rectData = data.optJSONObject("rect")
if (rectData != null) put("rect", rectData)
val nw = data.optInt("nw").takeIf { it > 0 }
val nh = data.optInt("nh").takeIf { it > 0 }
if (nw != null) put("pixelWidth", nw)
if (nh != null) put("pixelHeight", nh)
}

PluginLog.d(TAG, "::onImageTapped href=$href")
channel.onImageTapped(result.toString())
} catch (e: Exception) {
PluginLog.w(TAG, "::onImageTapped. Failed to process event: $e")
}
}

private fun resolvePublicationHref(srcUrl: String): String? {
if (srcUrl.isEmpty()) return null
val publication = ReadiumReader.currentPublication ?: return null
return try {
val imgPath = URI(srcUrl).path
val allLinks = publication.readingOrder + publication.resources
allLinks
.find { link ->
val linkHref = link.href.toString()
imgPath.endsWith("/$linkHref") || imgPath == linkHref
}?.href
?.toString()
?: imgPath.trimStart('/')
} catch (e: Exception) {
PluginLog.w(TAG, "::resolvePublicationHref. URI parse failed for srcUrl=$srcUrl: $e")
null
}
}

private fun Publication.suppressesImageTapEvents(data: JSONObject) =
conformsTo(Publication.Profile.DIVINA) || data.optBoolean("notaComicPageImage", false)
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ data class FlutterEpubPreferences(
val wordSpacing: Double? = null,
val blackAndWhiteComicMode: Boolean? = false,
val disableSynchronization: Boolean? = false,
val syncPolicy: String? = null,
val firstElementTopMargin: Int? = null,
val preventMOColumnBreaks: Boolean? = true,
) : Configurable.Preferences<FlutterEpubPreferences> {
Expand Down Expand Up @@ -78,6 +79,7 @@ data class FlutterEpubPreferences(
wordSpacing = other.wordSpacing ?: wordSpacing,
blackAndWhiteComicMode = other.blackAndWhiteComicMode ?: blackAndWhiteComicMode,
disableSynchronization = other.disableSynchronization ?: disableSynchronization,
syncPolicy = other.syncPolicy ?: syncPolicy,
firstElementTopMargin = other.firstElementTopMargin ?: firstElementTopMargin,
preventMOColumnBreaks = other.preventMOColumnBreaks ?: preventMOColumnBreaks,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import org.readium.r2.shared.InternalReadiumApi
import org.readium.r2.shared.publication.Locator
import org.readium.r2.shared.publication.Publication
import org.readium.r2.shared.util.Try
import org.readium.r2.shared.util.Url
import org.readium.r2.shared.util.getOrElse

private const val TAG = "PublicationChannel"
Expand Down Expand Up @@ -281,6 +282,38 @@ internal class PublicationMethodCallHandler : MethodChannel.MethodCallHandler {
return Try.success(null)
}

"getResourceBytes" -> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We used to have this feature but removed it because it worked really bad with large files over the bridge.

Could we save to a tmp file and return the file path instead?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getLinkContent and getString removed back in May

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, maybe we should scope it to smaller ressources or just images 🤔

Will investigate effects of storing in local app storage and loading that.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's do it afterwards

@Suppress("UNCHECKED_CAST")
val args = arguments as? Map<String, Any?>
val href =
args?.get("href") as? String
?: return Try.failure(
PublicationError.Unknown("::getResourceBytes requires a 'href' string argument"),
)
val publication =
ReadiumReader.currentPublication
?: return Try.failure(
PublicationError.Unavailable("::getResourceBytes No publication open"),
)
val url =
Url(href)
?: return Try.failure(
PublicationError.Unknown("::getResourceBytes Invalid href: $href"),
)
val resource =
publication.get(url)
?: return Try.failure(
PublicationError.InvalidPublicationUrl("::getResourceBytes No resource for href: $href"),
)
val bytes =
resource.read().getOrElse { readError ->
PluginLog.w(TAG, "::getResourceBytes. Read failed for href: $href. ${readError.message}")
return Try.failure(PublicationError.Reading(readError))
}
PluginLog.d(TAG, "::getResourceBytes. href=$href bytes=${bytes.size}")
return Try.success(bytes)
}

else -> {
throw NotImplementedError()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ internal class ReadiumReaderChannel(
invokeMethod("onSelectionAction", json.toString())
}

fun onImageTapped(json: String) = launch { invokeMethod("onImageTapped", json) }

fun onDecorationInteraction(
decorationId: String,
group: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,12 @@ class ReadiumReaderWidget(
PluginLog.d(TAG, "::setPreferencesFromMap")
val newPreferences = FlutterEpubPreferences.fromMap(prefMap)
updatePreferences(newPreferences)
// Push the comic re-sync policy to the injected helper.
newPreferences.syncPolicy?.let { policy ->
ReadiumReader.epubEvaluateJavascript(
"window.flutterReadium && window.flutterReadium.setComicSyncPolicy(${JSONObject.quote(policy)});",
)
}
}

private suspend fun emitOnPageChanged(
Expand Down
Loading
Loading