Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ All notable changes to this project will be documented in this file. Take a look
#### LCP

* The CRL used to validate LCP licenses is now checked to be a genuine X.509 CRL before being cached. Networks with a captive portal (e.g. on a plane) could return their login page with a `200 OK` status, which was then cached for seven days and prevented opening LCP publications. An invalid CRL cached by a previous version is now ignored instead of waiting for its expiration.
* [#579](https://github.com/readium/swift-toolkit/issues/579) Streamed LCP audiobooks now start playing almost immediately. Resources encrypted with AES-CBC are decrypted and served in chunks, instead of being fully downloaded and decrypted upfront.


## [4.0.0-alpha.1] - 2026-08-14
Expand Down
147 changes: 105 additions & 42 deletions Sources/LCP/Content Protection/LCPDecryptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,63 +120,126 @@ final class LCPDecryptor: Sendable {
await plainTextSize()
}

/// Number of plaintext bytes decrypted and delivered per `consume`
/// call when streaming.
private static let chunkSize: UInt64 = 256 * 1024

@concurrent func stream(range: Range<UInt64>?, consume: @escaping @Sendable (Data) -> Void) async -> ReadResult<Void> {
guard let range = range else {
var plainTextSize: UInt64?
switch await self.plainTextSize() {
case let .success(size):
plainTextSize = size
case let .failure(error):
guard range == nil else {
return .failure(error)
}
}

guard let plainTextSize else {
// Without the plaintext size, we can't compute the chunks to
// decrypt; fall back on reading and decrypting the whole
// resource in one shot.
guard range == nil else {
return failure(.noPlainTextSize)
}
return await license.decryptFully(data: resource.read(), isDeflated: encryption.isDeflated)
.map {
consume($0)
return ()
}
}

return await resource.estimatedLength().asyncFlatMap { encryptedLength in
let requestedRange = range ?? 0 ..< plainTextSize
let clampedRange = min(requestedRange.lowerBound, plainTextSize) ..< min(requestedRange.upperBound, plainTextSize)
guard !clampedRange.isEmpty else {
return .success(())
}

return await resource.estimatedLength().asyncFlatMap { [self] encryptedLength in
guard let encryptedLength = encryptedLength else {
return .failure(.decoding(LCPDecryptor.Error.requiredEstimatedLength))
}
guard let rangeFirst = range.first, let rangeLast = range.last else {
return .failure(.decoding(LCPDecryptor.Error.invalidRange(range)))

// Decrypting in chunks lets the caller process the beginning
// of the resource without waiting for the whole range, which
// matters when streaming a large track from the network.
var chunkStart = clampedRange.lowerBound
while chunkStart < clampedRange.upperBound {
guard !Task.isCancelled else {
return .failure(.cancelled)
}

let chunkEnd = min(chunkStart + Self.chunkSize, clampedRange.upperBound)
let result = await decrypt(
range: chunkStart ..< chunkEnd,
encryptedLength: encryptedLength,
plainTextSize: plainTextSize
)
switch result {
case let .success(chunk):
consume(chunk)
chunkStart = chunkEnd
case let .failure(error):
return .failure(error)
}
}

// Encrypted data is shifted by AESBlockSize, because of IV and because the
// previous block must be provided to perform XOR on intermediate blocks.
let encryptedStart = rangeFirst.floorMultiple(of: AESBlockSize)
let encryptedEndExclusive = min(
(rangeLast + 1).ceilMultiple(of: AESBlockSize) + AESBlockSize,
encryptedLength
)

return await resource.read(range: encryptedStart ..< encryptedEndExclusive)
.combine(plainTextSize())
.flatMap { encryptedData, plainTextSize in
do {
guard let plainTextSize = plainTextSize else {
return .failure(.decoding(LCPDecryptor.Error.noPlainTextSize))
}
guard let bytes = try license.decipher(encryptedData) else {
return .failure(.decoding(LCPDecryptor.Error.emptyDecryptedData))
}

// Exclude the bytes added to match a multiple of AESBlockSize.
let sliceStart = (rangeFirst - encryptedStart)

let isLastBlockRead = encryptedLength - encryptedEndExclusive <= AESBlockSize
let rangeLength = isLastBlockRead
// Use decrypted length to ensure `rangeLast` doesn't exceed decrypted length - 1.
? min(rangeLast, plainTextSize - 1) - rangeFirst + 1
// The last block won't be read, so there's no need to compute the length
: rangeLast - rangeFirst + 1

// Keep only enough bytes to fit the length-corrected request in order to never
// include padding.
let sliceEnd = sliceStart + rangeLength

consume(bytes[sliceStart ..< sliceEnd])
return .success(())
} catch {
return .failure(.decoding(error))
return .success(())
}
}

/// Decrypts a single chunk of plaintext located at `range`.
private func decrypt(
range: Range<UInt64>,
encryptedLength: UInt64,
plainTextSize: UInt64
) async -> ReadResult<Data> {
guard let rangeFirst = range.first, let rangeLast = range.last else {
return failure(.invalidRange(range))
}

// Encrypted data is shifted by AESBlockSize, because of IV and
// because the previous block must be provided to perform XOR on
// intermediate blocks.
let encryptedStart = rangeFirst.floorMultiple(of: AESBlockSize)
let encryptedEndExclusive = min(
(rangeLast + 1).ceilMultiple(of: AESBlockSize) + AESBlockSize,
encryptedLength
)

return await resource.read(range: encryptedStart ..< encryptedEndExclusive)
.flatMap { [self] encryptedData in
do {
guard let bytes = try license.decipher(encryptedData) else {
return failure(.emptyDecryptedData)
}

// Exclude the bytes added to match a multiple of
// AESBlockSize.
let sliceStart = (rangeFirst - encryptedStart)

let isLastBlockRead = encryptedLength - encryptedEndExclusive <= AESBlockSize
let rangeLength = isLastBlockRead
// Use decrypted length to ensure `rangeLast`
// doesn't exceed decrypted length - 1.
? min(rangeLast, plainTextSize - 1) - rangeFirst + 1
// The last block won't be read, so there's no need
// to compute the length
: rangeLast - rangeFirst + 1

// Keep only enough bytes to fit the length-corrected
// request in order to never include padding.
let sliceEnd = sliceStart + rangeLength

return .success(bytes[sliceStart ..< sliceEnd])
} catch {
return .failure(.decoding(error))
}
}
}
}

private func failure<T>(_ error: LCPDecryptor.Error) -> ReadResult<T> {
.failure(.decoding(error))
}
}
}
Expand Down
72 changes: 67 additions & 5 deletions Sources/Navigator/Audiobook/AudioNavigator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,27 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo

/// Returns whether the resource is currently playing or not.
public var state: MediaPlaybackState {
MediaPlaybackState(player.timeControlStatus)
let state = MediaPlaybackState(player.timeControlStatus)
if
state == .playing,
let item = player.currentItem,
item.isPlaybackBufferEmpty, !item.isPlaybackLikelyToKeepUp
{
// As `automaticallyWaitsToMinimizeStalling` is disabled, the
// player reports `.playing` even when it is stalled on an empty
// buffer waiting for data.
return .loading
Comment thread
mickael-menu marked this conversation as resolved.
}
return state
}

/// Indicates whether the player is meant to be playing, even if it is
/// currently stalled waiting for data.
///
/// Unlike `state`, this reflects the playback intent, which is what we
/// need to know when temporarily pausing the player to seek.
private var isPlaybackRequested: Bool {
player.timeControlStatus != .paused
}

/// Current playback info.
Expand Down Expand Up @@ -243,7 +263,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo

/// Seeks to the given time in the current resource.
public func seek(to time: Double) async {
let wasPlaying = (state == .playing)
let wasPlaying = isPlaybackRequested
pause()

await player.seek(to: CMTime(seconds: time, preferredTimescale: 1000))
Expand All @@ -261,10 +281,23 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo
private var rateObserver: NSKeyValueObservation?
private var timeControlStatusObserver: NSKeyValueObservation?
private var currentItemObserver: NSKeyValueObservation?
private var itemStatusObserver: NSKeyValueObservation?
private var itemLikelyToKeepUpObserver: NSKeyValueObservation?
private var timeObserverToken: TimeObserverToken?
private var notificationTask: Task<Void, Never>?

private lazy var mediaLoader = PublicationMediaLoader(publication: publication)
private lazy var mediaLoader: PublicationMediaLoader = {
let loader = PublicationMediaLoader(publication: publication)
loader.onLoadingError = { [weak self] href, error in
Task { @MainActor in
guard let self = self, let href = href.relativeURL else {
return
}
self.delegate?.navigator(self, didFailToLoadResourceAt: href, withError: error)
}
}
return loader
}()

private lazy var player: AVPlayer = makePlayer()

Expand Down Expand Up @@ -312,8 +345,10 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo
}
}

currentItemObserver = player.observe(\.currentItem, options: [.new, .old]) { [weak self] _, _ in
currentItemObserver = player.observe(\.currentItem, options: [.new, .old]) { [weak self] player, _ in
let item = player.currentItem
Task { @MainActor [weak self] in
self?.observe(currentItem: item)
self?.playbackDidChange()
}
}
Expand All @@ -339,6 +374,33 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo
return player
}

private func observe(currentItem item: AVPlayerItem?) {
itemLikelyToKeepUpObserver = item?.observe(\.isPlaybackLikelyToKeepUp) { [weak self] _, _ in
Task { @MainActor [weak self] in
self?.playbackDidChange()
}
}

itemStatusObserver = item?.observe(\.status) { [weak self] item, _ in
guard item.status == .failed else {
return
}

let itemError = item.error

Task { @MainActor [weak self] in
guard let self = self else { return }
log(.error, "Failed to load the player item: \(String(describing: itemError))")
guard let href = self.publication.readingOrder[self.resourceIndex].url().relativeURL else {
return
}
let error: ReadError = itemError.flatMap { .wrap($0) }
?? .decoding("The AVPlayerItem failed to load", cause: itemError)
self.delegate?.navigator(self, didFailToLoadResourceAt: href, withError: error)
}
}
}

private func shouldPlayNextResource() -> Bool {
guard let delegate = delegate else {
return true
Expand Down Expand Up @@ -459,7 +521,7 @@ public final class AudioNavigator: Navigator, Configurable, AudioSessionUser, Lo
public private(set) var currentLocation: Locator?

public func go(to locator: Locator, options: NavigatorGoOptions) async -> Bool {
let wasPlaying = (state == .playing)
let wasPlaying = isPlaybackRequested
pause()

guard let newResourceIndex = publication.readingOrder.firstIndexWithHREF(locator.href) else {
Expand Down
35 changes: 32 additions & 3 deletions Sources/Navigator/Audiobook/PublicationMediaLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log

private let publication: Publication

/// Called when a resource failed to be served to the player, e.g. to
/// forward the error to the `NavigatorDelegate`.
var onLoadingError: ((AnyURL, ReadError) -> Void)?

private let tasks = CancellableTasks()

init(publication: Publication) {
Expand Down Expand Up @@ -63,6 +67,13 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log
else {
return nil
}

// Only the resources of other entries are evicted, as the player
// routinely abandons its requests to issue new ones for the same
// entry. Dropping the current resource would throw away its buffered
// data and force re-downloading the beginning of the entry.
resources = resources.filter { requests[$0.key] != nil }

resources[href] = (link, resource)
return (link, resource)
}
Expand Down Expand Up @@ -99,8 +110,9 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log
let req = reqs.remove(at: index)
req.task.cancel()

// The resource is intentionally kept in `resources`, to reuse its
// buffered data with the next loading requests for the same entry.
if reqs.isEmpty {
resources.removeValue(forKey: href)
requests.removeValue(forKey: href)
} else {
requests[href] = reqs
Expand Down Expand Up @@ -138,7 +150,7 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log
using resource: Resource,
link: Link
) {
tasks.add {
tasks.add { [self] in
infoRequest.isByteRangeAccessSupported = true
infoRequest.contentType = link.mediaType?.uti

Expand All @@ -149,6 +161,7 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log

case let .failure(error):
log(.error, error)
report(error, forHREF: link.url())
request.finishLoading(with: error)
}
}
Expand All @@ -162,17 +175,24 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log
range = UInt64(dataRequest.currentOffset) ..< (UInt64(dataRequest.currentOffset) + UInt64(dataRequest.requestedLength))
}

let task = Task {
let task = Task { [self] in
let result = await resource.stream(
range: range,
consume: { dataRequest.respond(with: $0) }
)

// The player abandons data requests regularly, e.g. when seeking.
// There's nothing to report or finish in this case.
guard !Task.isCancelled else {
return
}

queue.async { [weak self] in
switch result {
case .success:
request.finishLoading()
case let .failure(error):
self?.report(error, forHREF: link.url())
request.finishLoading(with: error)
}

Expand All @@ -183,6 +203,15 @@ final class PublicationMediaLoader: NSObject, AVAssetResourceLoaderDelegate, Log
registerRequest(request, task: task, for: link.url())
}

private func report(_ error: ReadError, forHREF href: AnyURL) {
// Cancellation is not an error worth reporting, it occurs whenever
// the player abandons a data request, e.g. when seeking.
if case .cancelled = error {
return
}
onLoadingError?(href, error)
}

func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
finishRequest(loadingRequest)
}
Expand Down
Loading
Loading