diff --git a/.vscode/settings.json b/.vscode/settings.json index 404de023..19e4ed92 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -118,12 +118,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": true, - "bin/analyze": true + "npm run build": true } } diff --git a/CLAUDE.md b/CLAUDE.md index 7acd4a77..cffc1c86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,8 +48,11 @@ When upgrading a toolkit, move all three platforms together where API surface ov ## Build / toolchain facts -- Dart SDK: `>=3.8.0 <4.0.0`. Flutter version pinned in `.flutter-version` — see the Workflow note above before changing it. -- Per-platform toolchain facts (Android SDK/Kotlin/AGP levels, iOS `Podfile` requirements, the web bundler) live in the nested platform files. +- Dart SDK: `>=3.8.0 <4.0.0`, Flutter version pinned in `.flutter-version`, synced to pubspecs via `bin/update_flutter_version`. +- 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+. +- **Flutter version updates**: always update `.flutter-version`, `.fvmrc`, and both pubspec.yaml files (`flutter_readium/pubspec.yaml`, `flutter_readium_platform_interface/pubspec.yaml`) together via `bin/update_flutter_version `. Never change one without the others — divergence causes build failures. ## Gotchas diff --git a/bin/format b/bin/format index 9e48cc4b..0e94f9a5 100755 --- a/bin/format +++ b/bin/format @@ -1,4 +1,5 @@ #!/usr/bin/env bash +set -e source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" # Format all Dart code across all packages in the repo. @@ -19,3 +20,11 @@ dart format --set-exit-if-changed "$REPO_ROOT/flutter_readium" echo "Formatting example app..." ( cd "$REPO_ROOT/flutter_readium/example" && flutter pub get > /dev/null ) dart format --set-exit-if-changed "$REPO_ROOT/flutter_readium/example" + +echo "Formatting test app..." +( cd "$REPO_ROOT/flutter_readium/test" && flutter pub get > /dev/null ) +dart format --set-exit-if-changed "$REPO_ROOT/flutter_readium/test" + +## Run flutter analyze on all packages in the repo. +flutter analyze --fatal-infos --fatal-warnings $REPO_ROOT/flutter_readium +flutter analyze --fatal-infos --fatal-warnings $REPO_ROOT/flutter_readium_platform_interface diff --git a/flutter_readium/CHANGELOG.md b/flutter_readium/CHANGELOG.md index eb83048c..62429a89 100644 --- a/flutter_readium/CHANGELOG.md +++ b/flutter_readium/CHANGELOG.md @@ -126,6 +126,11 @@ Bundle built javascript helpers, previously accidentally skipped by release pipe ### Added +- **Extra JS/CSS injection** — `FlutterReadium().setJavaScriptInjections(List)` + and `FlutterReadium().setCssInjections(List)` 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 pixel dimensions. Detection runs on diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/PublicationChannel.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/PublicationChannel.kt index bae83a2a..1ecc4255 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/PublicationChannel.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/PublicationChannel.kt @@ -113,9 +113,35 @@ internal class PublicationMethodCallHandler : MethodChannel.MethodCallHandler { return Try.success(null) } - "setAudioRecoveryPolicy" -> { - val args = arguments as? Map<*, *> - ReadiumReader.audioRecoveryPolicy = AudioRecoveryPolicy.fromMap(args) + "setAudioRecoveryPolicy" -> { + val args = arguments as? Map<*, *> + ReadiumReader.audioRecoveryPolicy = AudioRecoveryPolicy.fromMap(args) + return Try.success(null) + } + + "setCssInjections" -> { + @Suppress("UNCHECKED_CAST") + val items = arguments as? List> ?: 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> ?: emptyList() + ReadiumReader.javaScriptInjections = + items.map { map -> + InjectionAsset( + assetPath = map["assetPath"] as String, + packageName = map["package"] as? String, + ) + } return Try.success(null) } diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumExtensions.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumExtensions.kt index 120545f7..89b6697b 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumExtensions.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumExtensions.kt @@ -157,19 +157,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 = "" +private const val INJECT_END_MARKER = "" + +/** 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, epubPreferences: FlutterEpubPreferences?, + extraInjections: List = 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) } @@ -181,37 +203,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) -> { + """""" + } - 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( - "", - "$it", - true, - ).toByteArray(), - ) + injection.assetPath.endsWith(".css", ignoreCase = true) -> { + """""" + } + + 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( - """""", - """""", - """ - $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, "::injectScriptsAndStyles. 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, "::injectScriptsAndStyles. Skip injecting - no changes for: $filename") + return@TransformingResource Try.success(bytes) + } + PluginLog.d(TAG, "::injectScriptsAndStyles. 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()) } diff --git a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumReader.kt b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumReader.kt index da8e9ebb..46b78b44 100644 --- a/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumReader.kt +++ b/flutter_readium/android/src/main/kotlin/dk/nota/flutterreadium/ReadiumReader.kt @@ -89,7 +89,7 @@ private const val TAG = "ReadiumReader" private val HTTP_CONNECT_TIMEOUT = 10.seconds private val HTTP_READ_TIMEOUT = 30.seconds -private val stateKey = "dk.nota.flutterreadium.ReadiumReaderState" +private const val stateKey = "dk.nota.flutterreadium.ReadiumReaderState" private val currentPublicationUrlKey = "currentPublicationUrl" private val ttsEnabledKey = "ttsEnabled" @@ -526,6 +526,12 @@ object ReadiumReader : /** Selection actions configured from Dart. Used by EpubReaderFragment to build ActionMode menu. */ var selectionActions: List = emptyList() + /** Extra CSS assets injected alongside the built-in helpers. */ + var cssInjections: List = emptyList() + + /** Extra JavaScript assets injected alongside the built-in helpers. */ + var javaScriptInjections: List = emptyList() + private val context: Context get() = application.applicationContext @@ -714,7 +720,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 } diff --git a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift index ef74bb25..4ee8098d 100644 --- a/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift +++ b/flutter_readium/ios/flutter_readium/Sources/flutter_readium/FlutterReadiumPlugin.swift @@ -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 + } +} + /// Reports resource read failures during publication open (audio streaming /// errors are otherwise swallowed inside upstream AudioNavigator — no handler /// set means no-op). Module scope: installed in `openPublication` before the @@ -20,6 +36,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. @@ -204,6 +226,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 { diff --git a/flutter_readium/lib/flutter_readium.dart b/flutter_readium/lib/flutter_readium.dart index fa6af692..127904f4 100644 --- a/flutter_readium/lib/flutter_readium.dart +++ b/flutter_readium/lib/flutter_readium.dart @@ -66,6 +66,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 setCssInjections(List 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 setJavaScriptInjections(List 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 loadPublication(String pubUrl) => _readiumCall(() => _platform.loadPublication(pubUrl)); diff --git a/flutter_readium/test/flutter_readium_test.dart b/flutter_readium/test/flutter_readium_test.dart index f7f91438..7f9b63ec 100644 --- a/flutter_readium/test/flutter_readium_test.dart +++ b/flutter_readium/test/flutter_readium_test.dart @@ -160,6 +160,12 @@ class MockFlutterReadiumPlatform with MockPlatformInterfaceMixin implements Flut AudioRecoveryPolicy? lastAudioRecoveryPolicy; + @override + Future setCssInjections(List injections) async {} + + @override + Future setJavaScriptInjections(List injections) async {} + @override Future setAudioRecoveryPolicy(AudioRecoveryPolicy policy) async { lastAudioRecoveryPolicy = policy; diff --git a/flutter_readium_platform_interface/lib/flutter_readium_platform_interface.dart b/flutter_readium_platform_interface/lib/flutter_readium_platform_interface.dart index dc8c771f..3ce04d1f 100644 --- a/flutter_readium_platform_interface/lib/flutter_readium_platform_interface.dart +++ b/flutter_readium_platform_interface/lib/flutter_readium_platform_interface.dart @@ -66,6 +66,18 @@ abstract class FlutterReadiumPlatform extends PlatformInterface { /// Sets the log verbosity of the plugin's internal logging system, for both Dart and native code. Future 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 setCssInjections(List 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 setJavaScriptInjections(List injections) => + throw UnimplementedError('setJavaScriptInjections() has not been implemented.'); + /// Configures the automatic audio-stream error recovery loop (retry attempts, /// backoff, and stall detection). /// diff --git a/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart b/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart index 0a9174bb..12362c66 100644 --- a/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart +++ b/flutter_readium_platform_interface/lib/method_channel_flutter_readium.dart @@ -151,6 +151,22 @@ class MethodChannelFlutterReadium extends FlutterReadiumPlatform { ReadiumLog.setLevel(level); } + @override + Future setCssInjections(List injections) async { + await methodChannel.invokeMethod( + 'setCssInjections', + injections.map((e) => e.toJson()).toList(), + ); + } + + @override + Future setJavaScriptInjections(List injections) async { + await methodChannel.invokeMethod( + 'setJavaScriptInjections', + injections.map((e) => e.toJson()).toList(), + ); + } + @override Future setAudioRecoveryPolicy(AudioRecoveryPolicy policy) async { await methodChannel.invokeMethod('setAudioRecoveryPolicy', policy.toJson()); diff --git a/flutter_readium_platform_interface/lib/src/shared/index.dart b/flutter_readium_platform_interface/lib/src/shared/index.dart index cb46b91f..770e1dab 100644 --- a/flutter_readium_platform_interface/lib/src/shared/index.dart +++ b/flutter_readium_platform_interface/lib/src/shared/index.dart @@ -1,5 +1,6 @@ export 'epub.dart'; export 'guided_navigation.dart'; +export 'injection_asset.dart'; export 'mediatype.dart'; export 'opds.dart'; export 'publication.dart'; diff --git a/flutter_readium_platform_interface/lib/src/shared/injection_asset.dart b/flutter_readium_platform_interface/lib/src/shared/injection_asset.dart new file mode 100644 index 00000000..6dfd4574 --- /dev/null +++ b/flutter_readium_platform_interface/lib/src/shared/injection_asset.dart @@ -0,0 +1,26 @@ +import 'package:meta/meta.dart'; + +import '../utils/jsonable.dart'; + +/// Identifies a Flutter asset (JS or CSS file) to inject into EPUB HTML resources. +/// +/// [assetPath] is the asset path as declared in `pubspec.yaml`, e.g. `assets/custom.js`. +/// [package] is the pub package that owns the asset, or `null` for app-level assets. +/// The file type is inferred from the path extension (`.js` or `.css`). +@immutable +class InjectionAsset implements JSONable { + const InjectionAsset({required this.assetPath, this.package}); + + factory InjectionAsset.fromJson(Map json) => InjectionAsset( + assetPath: json['assetPath'] as String, + package: json['package'] as String?, + ); + + final String assetPath; + final String? package; + + @override + Map toJson() => {} + ..put('assetPath', assetPath) + ..putOpt('package', package); +}