Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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