Skip to content
Draft
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
8 changes: 4 additions & 4 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,12 @@
"java.configuration.updateBuildConfiguration": "interactive",
"dart.mcpServer": true,
"chat.tools.terminal.autoApprove": {
"flutter": true,
"dart": true,
"./gradlew": true,
"bin/analyze": true,
"command": true,
"dart": true,
"flutter": true,
"ktlint": true,
"npm run build:flutter": true,
"bin/analyze": true
"npm run build:flutter": true
}
}
20 changes: 17 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,23 @@ When upgrading a toolkit, move all three platforms together where API surface ov

### Android

- After editing any Kotlin file: `ktlint --format`; resolve all violations before committing.
- Every `PluginLog.*` message starts with `::functionName` (exact enclosing named function).
- Navigator-dependent `suspend` funcs: capture `navigator` as a local with a `?: run { … return }` guard, then wrap calls in `return withContext(coroutineContext) { }`. Funcs that only delegate to other wrappers skip the guard.
- **Kotlin formatting**: after writing or editing any Kotlin file, run `ktlint --format` on it. All violations must be resolved before committing.
- **Android log messages**: every `PluginLog.*` call in Kotlin must start with `::functionName` (double colon, then the exact name of the enclosing function). For lambdas, use the name of the enclosing named function. Example: `Log.d(TAG, "::goBackward. Navigator not ready.")`. Single-colon or missing prefixes are bugs; wrong function names from copy-paste are also bugs.
- **Android navigator null guard**: every `suspend` function that needs the navigator must capture it as a local variable with a `?: run { }` early-return guard, then wrap direct navigator calls in `return withContext(coroutineContext) { }`. Functions that only call other wrapper functions (e.g. `evaluateJavascript`) do not need their own guard or `withContext` — delegate instead. Example:
```kotlin
val navigator = epubNavigator ?: run {
PluginLog.w(TAG, "::myFunction. Navigator not ready.")
return
}
return withContext(coroutineContext) { navigator.someCall() }
```

## Build / toolchain facts

- Dart SDK: `>=3.8.0 <4.0.0`, Flutter `>=3.32.0`.
- Android: `minSdkVersion 24`, `compileSdk 36`, Kotlin 2.3.21, AGP 8.13.2, Java 18 source/target.
- iOS: requires `use_frameworks!` and `use_modular_headers!` in consuming `Podfile` (see top-level `README.md`).
- Web: webpack 5, TypeScript 5.7+.

## Gotchas

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

### Added

- **Extra JS/CSS injection** — `FlutterReadium().setJavaScriptInjections(List<InjectionAsset>)`
and `FlutterReadium().setCssInjections(List<InjectionAsset>)` register additional JavaScript
and CSS assets to inject into every EPUB HTML resource alongside the
built-in `flutterReadiumTools.js` / `flutterReadiumTools.css`. Supported on iOS and Android.
Call before opening a publication so the injections are active when the reader view is created.
- **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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,32 @@ internal class PublicationMethodCallHandler : MethodChannel.MethodCallHandler {
return Try.success(null)
}

"setCssInjections" -> {
@Suppress("UNCHECKED_CAST")
val items = arguments as? List<Map<String, Any?>> ?: emptyList()
ReadiumReader.cssInjections =
items.map { map ->
InjectionAsset(
assetPath = map["assetPath"] as String,
packageName = map["package"] as? String,
)
}
return Try.success(null)
}

"setJavaScriptInjections" -> {
@Suppress("UNCHECKED_CAST")
val items = arguments as? List<Map<String, Any?>> ?: emptyList()
ReadiumReader.javaScriptInjections =
items.map { map ->
InjectionAsset(
assetPath = map["assetPath"] as String,
packageName = map["package"] as? String,
)
}
return Try.success(null)
}

"loadPublication" -> {
val args = arguments as List<Any?>
val pubUrlStr = args[0] as String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,19 +117,41 @@ fun decorationStyleFromMap(decoMap: Map<*, *>?): Decoration.Style? {
}
}

private const val READIUM_FLUTTER_PATH_PREFIX =
"https://readium_assets/flutter_assets/packages/flutter_readium"
private const val FLUTTER_ASSETS_BASE = "https://readium_assets/flutter_assets"

private const val INJECT_START_MARKER = "<!-- flutter_readium:start -->"
private const val INJECT_END_MARKER = "<!-- flutter_readium:end -->"

/** A Flutter asset (JS or CSS) to inject into every EPUB HTML resource. */
data class InjectionAsset(
val assetPath: String,
val packageName: String?,
) {
val assetUrl: String
get() =
if (packageName != null) {
"$FLUTTER_ASSETS_BASE/packages/$packageName/$assetPath"
} else {
"$FLUTTER_ASSETS_BASE/$assetPath"
}
}

private val BUILT_IN_INJECTIONS =
listOf(
InjectionAsset("assets/helpers/flutterReadiumTools.js", "flutter_readium"),
InjectionAsset("assets/helpers/flutterReadiumTools.css", "flutter_readium"),
)

// Helper for injecting extra files into an epub.
fun Resource.injectScriptsAndStyles(
tocIds: List<String>,
epubPreferences: FlutterEpubPreferences?,
extraInjections: List<InjectionAsset> = emptyList(),
): Resource =
TransformingResource(this) { bytes ->
val props = this.properties().getOrNull()
val filename = props?.filename ?: return@TransformingResource Try.success(bytes)

// Skip all non-html files
if (!filename.endsWith("html", ignoreCase = true)) {
return@TransformingResource Try.success(bytes)
}
Expand All @@ -141,37 +163,26 @@ fun Resource.injectScriptsAndStyles(
return@TransformingResource Try.success(bytes)
}

val injectStyle = epubPreferences?.toInjectableStyleSheet()
val assetLines =
(BUILT_IN_INJECTIONS + extraInjections).mapNotNull { injection ->
when {
injection.assetPath.endsWith(".js", ignoreCase = true) -> {
"""<script type="text/javascript" src="${injection.assetUrl}"></script>"""
}

if (content.take(headEndIndex).contains(READIUM_FLUTTER_PATH_PREFIX)) {
injectStyle?.let {
if (!content.contains(it)) {
PluginLog.d(
TAG,
"Scripts already loaded for $filename, but custom css needs to be updated.",
)
return@TransformingResource Try.success(
content
.replace(
"</head>",
"$it</head>",
true,
).toByteArray(),
)
injection.assetPath.endsWith(".css", ignoreCase = true) -> {
"""<link rel="stylesheet" type="text/css" href="${injection.assetUrl}"></link>"""
}

else -> {
null
}
}
}

PluginLog.d(TAG, "Skip injecting - already done for: $filename")
return@TransformingResource Try.success(bytes)
}

PluginLog.d(TAG, "Injecting files into: $filename")

val injectLines =
listOf(
"""<script type="text/javascript" src="$READIUM_FLUTTER_PATH_PREFIX/assets/helpers/flutterReadiumTools.js"></script>""",
"""<link rel="stylesheet" type="text/css" href="$READIUM_FLUTTER_PATH_PREFIX/assets/helpers/flutterReadiumTools.css"></link>""",
"""<script type="text/javascript">
val injectStyle = epubPreferences?.toInjectableStyleSheet()
val platformScript =
"""<script type="text/javascript">
const isAndroid = true;
const isIos = false;
window.readiumTocIDs = ${jsonEncode(tocIds)};
Expand All @@ -182,13 +193,36 @@ fun Resource.injectScriptsAndStyles(
};
</script>
$injectStyle
""",
)
"""

val allLines = assetLines + listOf(platformScript) + listOfNotNull(injectStyle)
val newBlock = "$INJECT_START_MARKER\n${allLines.joinToString("\n")}\n$INJECT_END_MARKER"

val startIdx = content.indexOf(INJECT_START_MARKER)
if (startIdx != -1) {
val endIdx = content.indexOf(INJECT_END_MARKER, startIdx)
if (endIdx == -1) {
PluginLog.w(TAG, "Injection start marker found without end marker in: $filename")
} else {
val existingBlock = content.substring(startIdx, endIdx + INJECT_END_MARKER.length)
if (existingBlock == newBlock) {
PluginLog.d(TAG, "Skip injecting - no changes for: $filename")
return@TransformingResource Try.success(bytes)
}
PluginLog.d(TAG, "Replacing injection block for: $filename")
val newContent =
content.substring(0, startIdx) +
newBlock +
content.substring(endIdx + INJECT_END_MARKER.length)
return@TransformingResource Try.success(newContent.toByteArray())
}
}

PluginLog.d(TAG, "Injecting files into: $filename")
val newContent =
StringBuilder(content)
.insert(headEndIndex, "\n" + injectLines.joinToString("\n") + "\n")
.insert(headEndIndex, "\n$newBlock\n")
.toString()

Try.success(newContent.toByteArray())
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ import kotlin.time.Duration

private const val TAG = "ReadiumReader"

private val stateKey = "dk.nota.flutterreadium.ReadiumReaderState"
private const val stateKey = "dk.nota.flutterreadium.ReadiumReaderState"

private val currentPublicationUrlKey = "currentPublicationUrl"
private val ttsEnabledKey = "ttsEnabled"
Expand Down Expand Up @@ -498,6 +498,12 @@ object ReadiumReader :
/** Selection actions configured from Dart. Used by EpubReaderFragment to build ActionMode menu. */
var selectionActions: List<SelectionActionConfig> = emptyList()

/** Extra CSS assets injected alongside the built-in helpers. */
var cssInjections: List<InjectionAsset> = emptyList()

/** Extra JavaScript assets injected alongside the built-in helpers. */
var javaScriptInjections: List<InjectionAsset> = emptyList()

private val context: Context
get() = application.applicationContext

Expand Down Expand Up @@ -678,7 +684,11 @@ object ReadiumReader :
val epubPreferences =
navigator.preferences?.effectiveForLayout(publication.metadata.layout)
if (url.extension?.value?.endsWith("html", ignoreCase = true) == true) {
resource.injectScriptsAndStyles(tocIds, epubPreferences)
resource.injectScriptsAndStyles(
tocIds,
epubPreferences,
javaScriptInjections + cssInjections,
)
} else {
resource
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -898,7 +898,7 @@ void main() {

await _waitUntil(
() => states.last.currentOffset != null && states.last.currentOffset! >= expectedMinOffset,
timeout: const Duration(seconds: 10),
timeout: const Duration(seconds: 110),
reason: 'currentOffset did not advance after audioSeekBy($seekDuration)',
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ import MediaPlayer
import ReadiumNavigator
import ReadiumShared

/// A Flutter asset (JS or CSS) to inject into every EPUB HTML resource.
struct InjectionAsset {
let assetPath: String
let packageName: String?

init(assetPath: String, packageName: String? = nil) {
self.assetPath = assetPath
self.packageName = packageName
}

init(from map: [String: Any?]) {
assetPath = map["assetPath"] as! String
packageName = map["package"] as? String
}
}

public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.WarningLogger, TimebasedListener {

static var registrar: FlutterPluginRegistrar? = nil
Expand All @@ -14,6 +30,12 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin
public var currentPublication: Publication?
public var currentReaderView: (any ReadiumReaderView)?

/// Extra CSS assets injected alongside the built-in helpers.
var cssInjections: [InjectionAsset] = []

/// Extra JavaScript assets injected alongside the built-in helpers.
var javaScriptInjections: [InjectionAsset] = []

/// Incremented each time a new publication is successfully opened.
/// Used to guard against stale `closePublication` calls from a previous
/// Dart session (hot restart) clobbering a freshly opened publication.
Expand Down Expand Up @@ -194,6 +216,14 @@ public class FlutterReadiumPlugin: NSObject, FlutterPlugin, ReadiumShared.Warnin
}
}
}
case "setCssInjections":
let items = call.arguments as? [[String: Any?]] ?? []
self.cssInjections = items.map { InjectionAsset(from: $0) }
result(nil)
case "setJavaScriptInjections":
let items = call.arguments as? [[String: Any?]] ?? []
self.javaScriptInjections = items.map { InjectionAsset(from: $0) }
result(nil)
case "setCustomHeaders":
guard let args = call.arguments as? [String: Any],
let httpHeaders = args["httpHeaders"] as? [String: String] else {
Expand Down
11 changes: 11 additions & 0 deletions flutter_readium/lib/flutter_readium.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ class FlutterReadium {
_platform.setDefaultPreferences(preferences);
}

/// Registers extra CSS assets to inject into every EPUB HTML resource,
/// in addition to the built-in `flutterReadiumTools.css`.
/// Call before opening a publication so the injections are active when the reader view is created.
Future<void> setCssInjections(List<InjectionAsset> injections) => _platform.setCssInjections(injections);

/// Registers extra JavaScript assets to inject into every EPUB HTML resource,
/// in addition to the built-in `flutterReadiumTools.js`.
/// Call before opening a publication so the injections are active when the reader view is created.
Future<void> setJavaScriptInjections(List<InjectionAsset> injections) =>
_platform.setJavaScriptInjections(injections);

/// Loads a publication from the given URL and returns a [Publication] object representing its metadata and structure. This does not open the publication for reading.
Future<Publication> loadPublication(String pubUrl) => _platform.loadPublication(pubUrl);

Expand Down
6 changes: 6 additions & 0 deletions flutter_readium/test/flutter_readium_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,12 @@ class MockFlutterReadiumPlatform with MockPlatformInterfaceMixin implements Flut
@override
Future<void> setLogLevel(LogLevel level) async {}

@override
Future<void> setCssInjections(List<InjectionAsset> injections) async {}

@override
Future<void> setJavaScriptInjections(List<InjectionAsset> injections) async {}

@override
Future<Uint8List> getResourceBytes(String href) async => Uint8List(0);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ abstract class FlutterReadiumPlatform extends PlatformInterface {
/// Sets the log verbosity of the plugin's internal logging system, for both Dart and native code.
Future<void> setLogLevel(LogLevel level) => throw UnimplementedError('setLogLevel() has not been implemented.');

/// Registers extra CSS assets to inject into every EPUB HTML resource,
/// in addition to the built-in `flutterReadiumTools.css`.
/// Call before opening a publication so the injections are in effect when the reader view is created.
Future<void> setCssInjections(List<InjectionAsset> injections) =>
throw UnimplementedError('setCssInjections() has not been implemented.');

/// Registers extra JavaScript assets to inject into every EPUB HTML resource,
/// in addition to the built-in `flutterReadiumTools.js`.
/// Call before opening a publication so the injections are in effect when the reader view is created.
Future<void> setJavaScriptInjections(List<InjectionAsset> injections) =>
throw UnimplementedError('setJavaScriptInjections() has not been implemented.');

/// Stores [preferences] as the default EPUB preferences applied to future publications.
void setDefaultPreferences(EPUBPreferences preferences) {
defaultPreferences = preferences;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,22 @@ class MethodChannelFlutterReadium extends FlutterReadiumPlatform {
ReadiumLog.setLevel(level);
}

@override
Future<void> setCssInjections(List<InjectionAsset> injections) async {
await methodChannel.invokeMethod<void>(
'setCssInjections',
injections.map((e) => e.toJson()).toList(),
);
}

@override
Future<void> setJavaScriptInjections(List<InjectionAsset> injections) async {
await methodChannel.invokeMethod<void>(
'setJavaScriptInjections',
injections.map((e) => e.toJson()).toList(),
);
}

@override
Future<Publication> openPublication(String pubUrl) async {
final publicationString = await methodChannel
Expand Down
Loading
Loading