Skip to content
Open
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
2 changes: 2 additions & 0 deletions flutter_readium/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Fixed

- **URI-templated manifest links** are now expanded for guided-navigation and
sync-narration sidecar resources on web, iOS, and Android.
- **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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package dk.nota.flutterreadium

import org.readium.r2.shared.publication.Href
import org.readium.r2.shared.publication.Link
import java.util.concurrent.ConcurrentHashMap

internal sealed interface LinkTemplateResolution {
data class Resolved(
val link: Link,
) : LinkTemplateResolution

data class Unresolved(
val reason: Reason,
val missingVariables: List<String> = emptyList(),
) : LinkTemplateResolution {
enum class Reason {
MISSING_VARIABLE,
INVALID_TEMPLATE,
INVALID_HREF,
}
}
}

internal object LinkTemplateResolver {
private val reportedFailures = ConcurrentHashMap.newKeySet<String>()

fun resolve(
link: Link,
parameters: Map<String, String> = emptyMap(),
): LinkTemplateResolution {
if (!link.href.isTemplated) {
return LinkTemplateResolution.Resolved(link)
}

if (!isValidTemplate(link.href.toString())) {
return LinkTemplateResolution.Unresolved(
LinkTemplateResolution.Unresolved.Reason.INVALID_TEMPLATE,
)
}

val missing =
link.href.parameters
.orEmpty()
.filter { it !in parameters }
.distinct()
.sorted()
if (missing.isNotEmpty()) {
return LinkTemplateResolution.Unresolved(
LinkTemplateResolution.Unresolved.Reason.MISSING_VARIABLE,
missing,
)
}

val expanded = link.url(parameters = parameters)
val resolvedHref = Href(expanded)
return if (resolvedHref == null || resolvedHref.isTemplated) {
LinkTemplateResolution.Unresolved(
LinkTemplateResolution.Unresolved.Reason.INVALID_HREF,
)
} else {
LinkTemplateResolution.Resolved(link.copy(href = resolvedHref))
}
}

fun shouldReport(
link: Link,
resolution: LinkTemplateResolution.Unresolved,
): Boolean = reportedFailures.add("${link.href}|${resolution.reason}|${resolution.missingVariables}")

fun parameters(
resourceLink: Link?,
sidecarLink: Link? = null,
): Map<String, String> {
if (resourceLink == null) return emptyMap()

val href = resourceLink.href.toString()
val parameters =
mutableMapOf(
"ref" to href,
"resource" to href,
)
val fragment = href.substringAfter('#', "")
if (fragment.isNotEmpty()) {
parameters["id"] = fragment
}
if (sidecarLink != null) {
parameters["mediaOverlay"] = sidecarLink.href.toString()
parameters["media-overlay"] = sidecarLink.href.toString()
}
return parameters
}

private fun isValidTemplate(href: String): Boolean {
val expression = Regex("""\{([^{}]*)\}""")
var cursor = 0
while (cursor < href.length) {
val open = href.indexOf('{', cursor)
val close = href.indexOf('}', cursor)
if (open == -1) return close == -1
if (close == -1 || close < open) return false

val body = href.substring(open + 1, close)
val variables = if (body.startsWith('?')) body.substring(1) else body
if (variables.isEmpty() || (!body.startsWith("?") && body.startsWith("#"))) {
return false
}
if (
variables.split(',').any {
!it.matches(Regex("""[A-Za-z][A-Za-z0-9._-]*"""))
}
) {
return false
}

cursor = close + 1
}
return expression.findAll(href).count() > 0 || href.none { it == '}' }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,32 @@ import org.readium.r2.navigator.preferences.Color as ReadiumColor

private const val TAG = "ReadiumExtensions"

private fun resolvedTemplateLink(
link: Link,
parameters: Map<String, String>,
): Link? =
when (val resolution = LinkTemplateResolver.resolve(link, parameters)) {
is LinkTemplateResolution.Resolved -> {
resolution.link
}

is LinkTemplateResolution.Unresolved -> {
if (LinkTemplateResolver.shouldReport(link, resolution)) {
PluginLog.w(
TAG,
"::resolvedTemplateLink. URI template could not be resolved for " +
"${link.href}: ${resolution.reason}" +
resolution.missingVariables
.takeIf { it.isNotEmpty() }
?.let {
" (${it.joinToString()})"
}.orEmpty(),
)
}
null
}
}

/**
* The [HttpError] found by unwrapping this error's cause chain, if any. Readium/ExoPlayer
* errors often wrap the real [HttpError] at some depth (e.g. `ReadError.Access(HttpError)`),
Expand Down Expand Up @@ -283,10 +309,11 @@ suspend fun Publication.getMediaOverlays(): List<FlutterMediaOverlay?>? {
if (!hasMediaOverlays()) return null

val overlayLinks =
this.readingOrder.mapNotNull { r ->
r.alternates
.find { a -> a.mediaType == syncNarrationsMediaType }
?.copy(title = r.title)
this.readingOrder.withIndex().mapNotNull { (position, resourceLink) ->
resourceLink.alternates
.find { alternate -> alternate.mediaType == syncNarrationsMediaType }
?.copy(title = resourceLink.title)
?.let { overlayLink -> Triple(position, resourceLink, overlayLink) }
}

// Fetch+parse every overlay JSON in parallel on IO. Cap is configurable so we don't open
Expand All @@ -295,10 +322,14 @@ suspend fun Publication.getMediaOverlays(): List<FlutterMediaOverlay?>? {
coroutineScope {
val gate = Semaphore(permits = mediaOverlayFetchConcurrency)
overlayLinks
.mapIndexed { index, link ->
.map { (position, resourceLink, link) ->
async(Dispatchers.IO) {
gate.withPermit {
val resource = get(link)
val parameters = LinkTemplateResolver.parameters(resourceLink, link)
val resolvedLink =
resolvedTemplateLink(link, parameters)
?: return@withPermit null
val resource = get(resolvedLink)
if (resource == null) {
PluginLog.w(TAG, "::getMediaOverlays() - no resource for ${link.href}")
return@withPermit null
Expand All @@ -318,7 +349,7 @@ suspend fun Publication.getMediaOverlays(): List<FlutterMediaOverlay?>? {

return@withPermit FlutterMediaOverlay.fromJson(
JSONObject(jsonString),
index + 1,
position + 1,
null,
link.title ?: "",
duration,
Expand All @@ -345,8 +376,9 @@ suspend fun Publication.getGuidedNavigationMediaOverlays(): List<FlutterMediaOve
// Strategy 1: single guided navigation document in publication links (preferred).
val singleDocLink = links.find { it.mediaType == guidedNavigationMediaType }
if (singleDocLink != null) {
val resolvedLink = resolvedTemplateLink(singleDocLink, emptyMap()) ?: return null
val jsonString =
get(singleDocLink)?.read()?.getOrNull()?.let { String(it) } ?: run {
get(resolvedLink)?.read()?.getOrNull()?.let { String(it) } ?: run {
PluginLog.w(
TAG,
"::getGuidedNavigationMediaOverlays - unable to load ${singleDocLink.href}",
Expand Down Expand Up @@ -384,8 +416,15 @@ suspend fun Publication.getGuidedNavigationMediaOverlays(): List<FlutterMediaOve
// Deduplicate: several readingOrder items may reference the same guided-navigation document.
val guidedLinks =
readingOrder
.mapNotNull { roLink -> roLink.alternates.find { it.mediaType == guidedNavigationMediaType } }
.distinctBy { it.href }
.mapNotNull { roLink ->
val guidedLink =
roLink.alternates.find { it.mediaType == guidedNavigationMediaType }
?: return@mapNotNull null
resolvedTemplateLink(
guidedLink,
LinkTemplateResolver.parameters(roLink, guidedLink),
)
}.distinctBy { it.href.toString() }
if (guidedLinks.isEmpty()) return null

val parsed: List<List<FlutterMediaOverlay>?> =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import Foundation
import ReadiumShared

enum LinkTemplateResolutionError: Error, Equatable, CustomStringConvertible {
case missingVariables([String])
case invalidTemplate
case invalidHref

var description: String {
switch self {
case .missingVariables(let variables):
return "missing variables: \(variables.joined(separator: ", "))"
case .invalidTemplate:
return "invalid URI template syntax"
case .invalidHref:
return "expanded href is not fetchable"
}
}
}

enum LinkTemplateResolver {
private static let failureReporter = FailureReporter()

static func resolve(
_ link: Link,
parameters: [String: String] = [:]
) -> Result<Link, LinkTemplateResolutionError> {
guard link.templated else {
return .success(link)
}

guard isValidTemplate(link.href) else {
return .failure(.invalidTemplate)
}

let missing = link.templateParameters.filter { parameters[$0] == nil }.sorted()
guard missing.isEmpty else {
return .failure(.missingVariables(missing))
}

var resolved = link
resolved.expandTemplate(with: parameters)
guard !resolved.href.contains("{"), !resolved.href.contains("}") else {
return .failure(.invalidTemplate)
}
guard !resolved.href.isEmpty else {
return .failure(.invalidHref)
}
return .success(resolved)
}

static func shouldReport(
_ link: Link,
error: LinkTemplateResolutionError
) -> Bool {
failureReporter.shouldReport("\(link.href)|\(error)")
}

static func parameters(
for resourceLink: Link?,
sidecarLink: Link? = nil
) -> [String: String] {
guard let resourceLink else {
return [:]
}

var parameters = [
"ref": resourceLink.href,
"resource": resourceLink.href,
]
if let fragmentStart = resourceLink.href.firstIndex(of: "#") {
let idStart = resourceLink.href.index(after: fragmentStart)
if idStart < resourceLink.href.endIndex {
parameters["id"] = String(resourceLink.href[idStart...])
}
}
if let sidecarLink {
parameters["mediaOverlay"] = sidecarLink.href
parameters["media-overlay"] = sidecarLink.href
}
return parameters
}

private static func isValidTemplate(_ href: String) -> Bool {
var cursor = href.startIndex

while let open = href[cursor...].firstIndex(of: "{") {
guard let close = href[open...].firstIndex(of: "}") else {
return false
}
if href[open...].dropFirst().firstIndex(of: "{") != nil,
href[open...].dropFirst().firstIndex(of: "{")! < close {
return false
}

let bodyStart = href.index(after: open)
let body = String(href[bodyStart..<close])
let variables = body.hasPrefix("?") ? String(body.dropFirst()) : body
guard !variables.isEmpty else {
return false
}
guard body.hasPrefix("?") || !body.hasPrefix("#") else {
return false
}
guard variables.split(separator: ",").allSatisfy({
$0.range(of: #"^[A-Za-z][A-Za-z0-9._-]*$"#, options: .regularExpression) != nil
}) else {
return false
}

cursor = href.index(after: close)
if cursor == href.endIndex {
break
}
}

return !href[cursor...].contains("}")
}
}

private final class FailureReporter: @unchecked Sendable {
private let lock = NSLock()
private var reportedKeys = Set<String>()

func shouldReport(_ key: String) -> Bool {
lock.lock()
defer { lock.unlock() }
return reportedKeys.insert(key).inserted
}
}
Loading
Loading