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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ When upgrading a toolkit, move all three platforms together where API surface ov
- **Before declaring any web TS changes done:** run `bin/typecheck`, then `bin/update_web_example`. Never hand-edit built JS.
- **Before declaring any `bin/` script changes done:** run `bash -n <script>` for each edited script and fix any syntax errors.
- **Code research**: prefer `tokensave_*` MCP tools over grep/Explore (`.tokensave/`, gitignored; `tokensave sync` after pulling).
- **Tokensave freshness (repo-local only):** run `bin/tokensave_sync_if_needed` after pulls/rebases/branch switches and before large code exploration sessions. If a stale index is detected, agents should run this script once and retry `tokensave_*` before falling back to grep/read tools.
- **Branching workflow** — never commit to `main`, and never let a branch track `Notalib/flutter_readium`:
- Worktree branches created by agents often track upstream `main` — rename and re-track before committing: `git branch -m fix/short-slug && git push -u <fork> HEAD`.
- Branch names must use a CC prefix: `fix/`, `feat/`, `chore/`, `docs/`, `refactor/`, `test/`.
Expand All @@ -40,7 +41,8 @@ When upgrading a toolkit, move all three platforms together where API surface ov

- **Commits / PR titles**: Conventional Commits with scopes (see `git log`). Include fixed issues in commit desc, e.g. "Fixes #123"
- **Branching**: GitHub flow off `main`; `main` is the only relevant branch.
- **Changelog**: update `CHANGELOG.md` under Unreleased for consumer-visible changes only — exclude intra-PR fixes and example-app changes ("would someone upgrading notice this?").
- **Changelog**: update `CHANGELOG.md` under Unreleased for consumer-visible changes only — exclude intra-PR fixes and example-app changes ("would someone upgrading notice this?"). Keep each entry to a bold one-line lead plus 2–4 lines: symptom, cause in a clause, fix. Cut internal mechanism, field-level detail, and anything restating the lead — a reader upgrading needs to recognise the symptom, not understand the internals. Link to `docs/` when the detail genuinely matters.
- **Comments**: 1–3 lines. Don't narrate the code, and don't inline a rationale essay — that belongs in `docs/`. Brevity means cutting content, not compressing prose: write plain sentences, never telegraphese. Never prefix a comment with the tool, skill, or agent that produced it — that's noise. Say only what the code can't: a non-obvious constraint, why the simpler thing doesn't work, or a cross-reference.
- **Verification honesty**: don't claim verification you didn't do. If a change can't be exercised in the example app (native-only, behind a flag, platform edge case), say so explicitly.
- **Method-channel contract**: keep Dart (`flutter_readium_platform_interface`) in sync with all native sides. Every call needs a Swift, Kotlin, and web handler — or an explicit `UnimplementedError` if intentionally unsupported.
- **Bridge serialization**: Readium-owned objects (`Locator`, `Decoration`, …) → JSON strings via `json.encode`; plugin-owned flat structures (preferences, action configs) → Maps. Rationale + Web-TS `.serialize()` rules: `docs/architecture.md#bridge-serialization`.
Expand Down
25 changes: 25 additions & 0 deletions bin/tokensave_sync_if_needed
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env bash

source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"

# Keep tokensave fully optional for contributors who do not use it.
if ! command -v tokensave >/dev/null 2>&1; then
echo "tokensave not found; skipping index refresh"
exit 0
fi

cd "$REPO_ROOT"

# Ensure tokensave is initialized before syncing.
if [ ! -f ".tokensave/tokensave.db" ]; then
echo "tokensave index not found; initializing"
tokensave init </dev/null
fi

# Keep the repo's curated excludes in place when available.
if [ -f ".githooks/tokensave.config.json" ]; then
cp ".githooks/tokensave.config.json" ".tokensave/config.json"
fi

echo "refreshing tokensave index"
tokensave sync
7 changes: 7 additions & 0 deletions flutter_readium/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## Unreleased

### Fixed

- **Reader could report `ready` and then never emit a text locator (iOS, Android).**
Locator enrichment (a JavaScript page-info call plus a ToC lookup) was unbounded, so a
stalled platform webview silently froze `onTextLocatorChanged` for good. Enrichment now
times out after 5 seconds and the un-enriched locator is emitted instead.

## [0.3.3] - 2026-08-04

## [0.3.2] - 2026-08-03
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONObject
import org.readium.r2.shared.ExperimentalReadiumApi
import org.readium.r2.shared.publication.Locator
Expand All @@ -34,6 +35,10 @@ import org.readium.r2.shared.util.AbsoluteUrl
private const val TAG = "ReadiumReaderView"
internal const val VIEW_TYPE_CHANNEL_NAME = "dk.nota.flutter_readium/ReadiumReaderWidget"

// How long locator enrichment (JS page-info + ToC lookup) may take before the raw locator
// is emitted instead. Mirrors `locatorEnrichmentTimeoutSeconds` on iOS.
private const val LOCATOR_ENRICHMENT_TIMEOUT_MS = 5000L

@ExperimentalCoroutinesApi
@OptIn(ExperimentalReadiumApi::class)
class ReadiumReaderWidget(
Expand Down Expand Up @@ -270,44 +275,60 @@ class ReadiumReaderWidget(
totalPages: Int,
locator: Locator,
) {
var emittingLocator = locator
try {
when {
ReadiumReader.isPdf -> {
// Enrich PDF locator with the current TOC chapter title/href by
// matching "#page=N" fragments from the publication's table of contents.
emittingLocator = ReadiumReader.pdfEnrichLocatorWithTocHref(emittingLocator)
}
// Bounded: the EPUB branch evaluates JS, which a stalled webview can leave pending
// forever — freezing this stream after Ready was already reported.
val enriched =
withTimeoutOrNull(LOCATOR_ENRICHMENT_TIMEOUT_MS) {
var emittingLocator = locator
when {
ReadiumReader.isPdf -> {
// Enrich PDF locator with the current TOC chapter title/href by
// matching "#page=N" fragments from the publication's table of contents.
emittingLocator = ReadiumReader.pdfEnrichLocatorWithTocHref(emittingLocator)
}

ReadiumReader.isComic -> {
// Comic (CBZ/DiViNa): no JS webview — just emit the locator as-is.
// ImageNavigatorFragment already produces a correct position-bearing locator.
}
ReadiumReader.isComic -> {
// Comic (CBZ/DiViNa): no JS webview — just emit the locator as-is.
// ImageNavigatorFragment already produces a correct position-bearing locator.
}

else -> {
// EPUB: JS page-info eval + TOC href enrichment.
try {
evaluateJavascript("window.flutterReadium.getPageInformation()")
?.let {
PageInformation.fromJson(
it,
locator.href,
)
}?.let { pageInfo ->
emittingLocator =
emittingLocator.copyWithAdditionalLocations(pageInfo.otherLocations)
} ?: {
PluginLog.d(TAG, "::emitOnPageChanged - no page information")
else -> {
// EPUB: JS page-info eval + TOC href enrichment.
try {
evaluateJavascript("window.flutterReadium.getPageInformation()")
?.let {
PageInformation.fromJson(
it,
locator.href,
)
}?.let { pageInfo ->
emittingLocator =
emittingLocator.copyWithAdditionalLocations(pageInfo.otherLocations)
} ?: run {
PluginLog.d(TAG, "::emitOnPageChanged - no page information")
}
} catch (e: Error) {
PluginLog.d(TAG, "::emitOnPageChanged - pageInformation error: $e")
}

emittingLocator = emittingLocator.addPageNumber(pageIndex, totalPages)
emittingLocator = ReadiumReader.epubEnrichLocatorWithTocHref(emittingLocator)
}
} catch (e: Error) {
PluginLog.d(TAG, "::emitOnPageChanged - pageInformation error: $e")
}

emittingLocator = emittingLocator.addPageNumber(pageIndex, totalPages)
emittingLocator = ReadiumReader.epubEnrichLocatorWithTocHref(emittingLocator)
emittingLocator
}

if (enriched == null) {
PluginLog.w(
TAG,
"::emitOnPageChanged - enrichment timed out after ${LOCATOR_ENRICHMENT_TIMEOUT_MS}ms; " +
"emitting un-enriched locator",
)
}

val emittingLocator = enriched ?: locator

channel.onPageChanged(emittingLocator)
ReadiumReader.emitTextLocatorUpdate(emittingLocator)
PluginLog.d(TAG, "::emitOnPageChanged: emitted $emittingLocator")
Expand Down
6 changes: 5 additions & 1 deletion flutter_readium/example/integration_test/groups/warm_up.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import 'package:flutter_test/flutter_test.dart';
import '../readium_integration_harness.dart';
import '../test_fixtures.dart';

/// Forces the first platform-view / webview launch so the real tests don't pay that cost.
/// Best-effort: never fails on timeout — a cold CI simulator can thrash WebKit for minutes,
/// and first-locator emission is asserted for real by the reader-lifecycle suites.
void defineWarmUpTests(ReadiumIntegrationHarness harness) {
testWidgets(
'Warm-up the platform reader view',
Expand All @@ -28,9 +31,10 @@ void defineWarmUpTests(ReadiumIntegrationHarness harness) {
await waitWithPump(
tester,
() => locators.isNotEmpty,
timeout: const Duration(seconds: 120),
timeout: const Duration(seconds: 45),
reason: 'Reader never emitted an initial textLocator during warm-up',
diagnostics: () => 'readerStatus=$readerStatus, locators=${locators.length}',
failOnTimeout: false,
);

await tester.pumpWidget(const SizedBox());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,16 @@ Future<void> waitUntil(
}
}

/// Pumps until [predicate] holds. On timeout it fails the test, or just returns if
/// [failOnTimeout] is false — either way it logs `waitWithPump TIMEOUT`. Pass `false` only
/// where the wait is best-effort and a real test asserts the condition elsewhere.
Future<void> waitWithPump(
WidgetTester tester,
bool Function() predicate, {
required Duration timeout,
String? reason,
String Function()? diagnostics,
bool failOnTimeout = true,
Duration pollInterval = const Duration(milliseconds: 100),
}) async {
final start = DateTime.now();
Expand All @@ -168,7 +172,10 @@ Future<void> waitWithPump(
final diag = diagnostics != null ? ' | ${diagnostics()}' : '';
final base = reason ?? 'Condition did not become true within $timeout';
debugPrint('waitWithPump TIMEOUT after ${elapsedMs}ms: $base$diag');
fail('$base (waited ${elapsedMs}ms)$diag');
if (failOnTimeout) {
fail('$base (waited ${elapsedMs}ms)$diag');
}
return;
}
await tester.pump(pollInterval);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ struct GuidedNavigationDocument: Equatable {
) -> [FlutterMediaOverlay] {
func flatten(_ obj: GuidedNavigationObject) -> [FlutterMediaOverlayItem] {
var items: [FlutterMediaOverlayItem] = []
if let audio = obj.audioref, let text = obj.textref {
// Accept textref (EPUB/read-aloud) or imgref (Divina panel audio) as the text anchor.
if let audio = obj.audioref, let text = obj.textref ?? obj.imgref {
items.append(FlutterMediaOverlayItem(
audio: audio,
text: text,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public class FlutterMediaOverlayNavigator : FlutterAudioNavigator
// Map the initial Text-based locator to Audio-based MediaOverlay Locator.
self._initialLocator = self.mapTextLocatorToMediaOverlayAudioLocator(initialLocator)
}

public override func initNavigator() async throws -> Void {
Log.navigator.info("Initializing MediaOverlayNavigator")

Expand All @@ -56,6 +56,11 @@ public class FlutterMediaOverlayNavigator : FlutterAudioNavigator
)
}

guard !audioReadingOrder.isEmpty else {
Log.navigator.error("initNavigator() — audio readingOrder is empty; cannot initialize AudioNavigator.")
return
}

// Copy the manifest and set its readingOrder to audioReadingOrder.
var audioPubManifest = publication.manifest // var of struct == implicit copy
audioPubManifest.readingOrder = audioReadingOrder
Expand Down Expand Up @@ -150,7 +155,7 @@ public class FlutterMediaOverlayNavigator : FlutterAudioNavigator
}

internal override func submitTimebasedPlayerStateToListener(info: MediaPlaybackInfo, location: Locator?, bufferedInterval: TimeInterval? = nil) {

/// Create TimebasedState and send it over the timebased-state stream.
let timebasedState = mapToTimebasedState(info: info, location: location, bufferedInterval: bufferedInterval)

Expand All @@ -160,7 +165,7 @@ public class FlutterMediaOverlayNavigator : FlutterAudioNavigator
let combinedLocator = mediaOverlayItem.toCombinedLocator(fromAudioLocator: locator) {
timebasedState.currentLocator = combinedLocator
}

/// If state has changed, submit it to listener.
if (timebasedState != self._lastTimebasedPlayerState) {
self._lastTimebasedPlayerState = timebasedState
Expand Down Expand Up @@ -193,12 +198,12 @@ public class FlutterMediaOverlayNavigator : FlutterAudioNavigator
// If the input Text Locator, is a combined locator with a time fragment
// we use this, as it can be more precise than the MediaOverlayItem fragment.
if let textLocatorTime = textLocator.locations.time,
let textLocatorTimeBegin = textLocatorTime.begin {
let textLocatorTimeBegin = textLocatorTime.begin {
Log.navigator.debug("TextLocator had more precise time offset: \(textLocatorTimeBegin)")
let timeOffset = textLocatorTimeBegin
audioLocator = audioLocator.copyWithOffset(timeOffset)
}

Log.navigator.debug("mapTextLocatorToMediaOverlayAudioLocator - mapped text href=\(textLocator.href.string) " +
"-> audio href=\(audioLocator.href.string) fragments=\(audioLocator.locations.fragments)")
return audioLocator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,33 @@ import ReadiumShared
import Flutter
import UIKit

/// How long locator enrichment (JS page-info + ToC lookup) may take before the raw
/// locator is emitted instead. Generous for a healthy webview, short enough that a
/// stalled one doesn't silently freeze the text-locator stream.
private let locatorEnrichmentTimeoutSeconds: UInt64 = 5

/// Runs `operation`, returning nil if it doesn't finish within `seconds`.
///
/// The timed-out task is not actually cancelled: upstream's `spreadLoaded()` continuation
/// ignores cancellation, so a stalled JS eval keeps running until the spread loads or the
/// reader view is disposed.
private func withTimeout<T: Sendable>(
seconds: UInt64,
_ operation: @escaping @Sendable () async -> T
) async -> T? {
await withTaskGroup(of: T?.self) { group in
group.addTask { await operation() }
group.addTask {
// Task.sleep(for: .seconds(_:)) would read better but is iOS 16+; the podspec targets 15.0.
try? await Task.sleep(nanoseconds: seconds * NSEC_PER_SEC)
return nil
}
let first = await group.next() ?? nil
group.cancelAll()
return first
}
}

/// Core class declaration, stored state, lifecycle, and the base `Navigator`/
/// `EPUBNavigatorDelegate` callbacks that don't have a more specific home.
/// Related behaviour lives in the `EPUBReaderView+*.swift` extensions in this
Expand Down Expand Up @@ -363,18 +390,27 @@ public class EPUBReaderView: NSObject, FlutterPlatformView, ReadiumReaderView, E
Log.reader.debug("emitOnPageChanged, locator: \(locator)")

Task.detached(priority: .high) { [locator] in
/// Enrich Locator with PageInformation and ToC.
var resultLocator = locator
if let pageInfo = await self.getPageInformation() {
resultLocator.locations.otherLocations.merge(pageInfo.otherLocations, uniquingKeysWith: { lhs, rhs in lhs })
/// Enrich Locator with PageInformation and ToC — bounded, because upstream's
/// `EPUBSpreadView.evaluateScript` awaits `spreadLoaded()` with no timeout, so a stalled
/// webview would freeze this stream forever after `ready` was already reported.
let enriched = await withTimeout(seconds: locatorEnrichmentTimeoutSeconds) { [locator] in
var resultLocator = locator
if let pageInfo = await self.getPageInformation() {
resultLocator.locations.otherLocations.merge(pageInfo.otherLocations, uniquingKeysWith: { lhs, rhs in lhs })
}
if let tocLink = try? await FlutterReadiumPlugin.instance?.currentTocLinkFromLocator(resultLocator) {
resultLocator.title = tocLink.title
resultLocator.locations.otherLocations["tocHref"] = .string(tocLink.href)
}
return resultLocator
}
if let tocLink = try? await FlutterReadiumPlugin.instance?.currentTocLinkFromLocator(resultLocator) {
resultLocator.title = tocLink.title
resultLocator.locations.otherLocations["tocHref"] = .string(tocLink.href)

if enriched == nil {
Log.reader.warn("emitOnPageChanged: enrichment timed out after \(locatorEnrichmentTimeoutSeconds)s; emitting un-enriched locator")
}

/// Immutable ref, so that we can use it on the main thread
let finalLocator = resultLocator
let finalLocator = enriched ?? locator
await MainActor.run() {
self.channel.onPageChanged(locator: finalLocator)
FlutterReadiumPlugin.instance?.textLocatorStreamHandler?.sendEvent(try? finalLocator.jsonString())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ extension Publication {
}
return FlutterMediaOverlay(items: items, readingOrderDuration: duration)
}
guard !positionedOverlays.isEmpty else { return nil }
return enrichOverlaysWithToc(positionedOverlays)
}

Expand Down Expand Up @@ -205,7 +206,7 @@ extension Publication {
}
allOverlays += positionedOverlays
}
guard hasAny else { return nil }
guard hasAny, !allOverlays.isEmpty else { return nil }
return enrichOverlaysWithToc(allOverlays)
}

Expand Down