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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ All notable changes to this project will be documented in this file. Take a look

### Changed

#### LCP

* LCP data models now use `kotlinx.serialization` instead of `org.json`. Some constructor properties have changed or been removed as part of this migration. Deprecated shims have been provided where possible to ease migration.

#### Navigator

* :warning: The PDFium adapter now defaults to a horizontal paginated layout, instead of a vertical continuous scroll. Set `PdfiumDefaults(scroll = true)` to restore the previous behavior. See [the migration guide](docs/migration-guide.md).
Expand Down
8 changes: 8 additions & 0 deletions docs/migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All migration steps necessary in reading apps to upgrade to major versions of th

## Unreleased

### LCP Models Serialization Changes

The LCP models (e.g., `LicenseDocument`, `StatusDocument`, `User`, `Link`) have been refactored to use `kotlinx.serialization` instead of `org.json`.

- The `json: JSONObject` properties in data classes (like `User`, `Rights`, etc.) are now deprecated.
- The `User.encrypted` property type has changed from `MutableList<String>` to `List<String>`.
- The `LicenseDocument` and `StatusDocument` primary constructors now take `jsonString: String` instead of `org.json.JSONObject`. The `json` properties are still available but return a deprecated `JSONObject`.

### Breaking changes with the PDFium adapter

#### Page positions were off by one (bookmarks, reading progression)
Expand Down
2 changes: 2 additions & 0 deletions readium/lcp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
plugins {
id("readium.library-conventions")
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.serialization)
}

android {
Expand All @@ -32,6 +33,7 @@ dependencies {
implementation(libs.timber)
implementation(libs.androidx.browser)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)

implementation(libs.bundles.room)
ksp(libs.androidx.room.compiler)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ internal class LicenseValidation(
val prodLicense = LicenseDocument(data = prodLicenseInput.readBytes())
val passphrase = "7B7602FEF5DEDA10F768818FFACBC60B173DB223B7E66D8B2221EBE2C635EFAD"
try {
LcpClient.findOneValidPassphrase(prodLicense.json.toString(), listOf(passphrase)) == passphrase
LcpClient.findOneValidPassphrase(prodLicense.jsonString, listOf(passphrase)) == passphrase
} catch (e: Exception) {
false
}
Expand Down Expand Up @@ -466,7 +466,7 @@ internal class LicenseValidation(
if (!supportedProfiles.contains(profile)) {
throw LcpException(LcpError.LicenseProfileNotSupported)
}
val context = LcpClient.createContext(license.json.toString(), passphrase, crl.retrieve())
val context = LcpClient.createContext(license.jsonString, passphrase, crl.retrieve())
raise(Event.validatedIntegrity(context))
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* Copyright 2026 Readium Foundation. All rights reserved.
* Use of this source code is governed by a BSD-style license
* available in the top-level LICENSE file of the project.
*/

@file:OptIn(InternalReadiumApi::class)

package org.readium.r2.lcp.license.model

import kotlinx.serialization.json.Json
import org.readium.r2.shared.InternalReadiumApi

internal val LcpJson: Json = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
explicitNulls = false
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
/*
* Module: r2-lcp-kotlin
* Developers: Aferdita Muriqi
*
* Copyright (c) 2019. Readium Foundation. All rights reserved.
* Use of this source code is governed by a BSD-style license which is detailed in the
* LICENSE file present in the project repository where this source code is maintained.
* Copyright 2026 Readium Foundation. All rights reserved.
* Use of this source code is governed by a BSD-style license
* available in the top-level LICENSE file of the project.
*/

@file:OptIn(InternalReadiumApi::class)
Expand All @@ -13,6 +10,17 @@ package org.readium.r2.lcp.license.model

import java.nio.charset.Charset
import kotlin.time.Instant
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
import org.json.JSONObject
import org.readium.r2.lcp.LcpError
import org.readium.r2.lcp.LcpException
Expand All @@ -24,79 +32,44 @@ import org.readium.r2.lcp.license.model.components.lcp.Signature
import org.readium.r2.lcp.license.model.components.lcp.User
import org.readium.r2.lcp.service.URLParameters
import org.readium.r2.shared.InternalReadiumApi
import org.readium.r2.shared.extensions.optNullableString
import org.readium.r2.shared.extensions.toInstant
import org.readium.r2.shared.util.AbsoluteUrl
import org.readium.r2.shared.util.Try
import org.readium.r2.shared.util.Url
import org.readium.r2.shared.util.mediatype.MediaType

public class LicenseDocument internal constructor(public val json: JSONObject) {
import timber.log.Timber

@Serializable(with = LicenseDocumentSerializer::class)
public class LicenseDocument internal constructor(
public val provider: String,
public val id: String,
public val issued: Instant,
public val updated: Instant,
public val encryption: Encryption,
public val links: Links,
public val user: User,
public val rights: Rights,
public val signature: Signature,
public val jsonString: String,
) {

public companion object {

@Deprecated("Use fromBytes instead")
public fun fromJSON(json: JSONObject): Try<LicenseDocument, LcpError.Parsing> {
val document = try {
LicenseDocument(json)
} catch (e: Exception) {
check(e is LcpException)
check(e.error is LcpError.Parsing)
return Try.failure(e.error)
}

return Try.success(document)
return fromBytes(json.toString().toByteArray())
}

public fun fromBytes(data: ByteArray): Try<LicenseDocument, LcpError.Parsing> {
val json = try {
JSONObject(data.decodeToString())
return try {
Try.success(LicenseDocument(data))
} catch (e: Exception) {
return Try.failure(LcpError.Parsing.MalformedJSON)
val error = (e as? LcpException)?.error as? LcpError.Parsing
?: LcpError.Parsing.MalformedJSON
Try.failure(error)
}

return fromJSON(json)
}
}

public val provider: String =
json.optNullableString("provider")
?: throw LcpException(LcpError.Parsing.LicenseDocument)

public val id: String =
json.optNullableString("id")
?: throw LcpException(LcpError.Parsing.LicenseDocument)

public val issued: Instant =
json.optNullableString("issued")
?.toInstant()
?: throw LcpException(LcpError.Parsing.LicenseDocument)

public val updated: Instant =
json.optNullableString("updated")
?.toInstant()
?: issued

public val encryption: Encryption =
json.optJSONObject("encryption")
?.let { Encryption(it) }
?: throw LcpException(LcpError.Parsing.LicenseDocument)

public val links: Links =
json.optJSONArray("links")
?.let { Links(it) }
?: throw LcpException(LcpError.Parsing.LicenseDocument)

public val user: User =
User(json.optJSONObject("user") ?: JSONObject())

public val rights: Rights =
Rights(json.optJSONObject("rights") ?: JSONObject())

public val signature: Signature =
json.optJSONObject("signature")
?.let { Signature(it) }
?: throw LcpException(LcpError.Parsing.LicenseDocument)

init {
if (link(Rel.Hint) == null || link(Rel.Publication) == null) {
throw LcpException(LcpError.Parsing.LicenseDocument)
Expand All @@ -106,14 +79,31 @@ public class LicenseDocument internal constructor(public val json: JSONObject) {
try {
link(Rel.Publication)!!.url() as AbsoluteUrl
} catch (e: Exception) {
throw LcpException(LcpError.Parsing.Url(rel = LicenseDocument.Rel.Publication.value))
Timber.e(e)
throw LcpException(LcpError.Parsing.Url(rel = Rel.Publication.value))
}
}

internal constructor(other: LicenseDocument) : this(
provider = other.provider,
id = other.id,
issued = other.issued,
updated = other.updated,
encryption = other.encryption,
links = other.links,
user = other.user,
rights = other.rights,
signature = other.signature,
jsonString = other.jsonString
)

internal constructor(data: ByteArray) : this(
try {
JSONObject(data.decodeToString())
LcpJson.decodeFromString(LicenseDocumentSerializer, data.decodeToString())
} catch (e: Exception) {
if (e is LcpException) {
throw e
}
throw LcpException(LcpError.Parsing.MalformedJSON)
}
)
Expand All @@ -127,6 +117,11 @@ public class LicenseDocument internal constructor(public val json: JSONObject) {
;

public companion object {
@Deprecated("Use fromBytes instead")
public fun fromJSON(json: JSONObject): Try<LicenseDocument, LcpError.Parsing> {
return fromBytes(json.toString().toByteArray())
}

public operator fun invoke(value: String): Rel? = entries.firstOrNull { it.value == value }
}
}
Expand All @@ -152,9 +147,69 @@ public class LicenseDocument internal constructor(public val json: JSONObject) {
return link.url(parameters = parameters)
}

@Deprecated("Use jsonString instead", ReplaceWith("this.jsonString"))
public val json: JSONObject get() = JSONObject(jsonString)

public val description: String
get() = "License($id)"

public fun toByteArray(): ByteArray =
json.toString().toByteArray(Charset.defaultCharset())
jsonString.toByteArray(Charset.defaultCharset())
}

internal object LicenseDocumentSerializer : KSerializer<LicenseDocument> {
override val descriptor: SerialDescriptor = JsonObject.serializer().descriptor

override fun deserialize(decoder: Decoder): LicenseDocument {
val input = decoder as JsonDecoder
val jsonElement = input.decodeJsonElement() as JsonObject
val rawJson = jsonElement.toString()

val provider = jsonElement["provider"]?.jsonPrimitive?.contentOrNull
?: throw LcpException(LcpError.Parsing.LicenseDocument)
val id = jsonElement["id"]?.jsonPrimitive?.contentOrNull
?: throw LcpException(LcpError.Parsing.LicenseDocument)
val issued = jsonElement["issued"]?.jsonPrimitive?.contentOrNull?.toInstant()
?: throw LcpException(LcpError.Parsing.LicenseDocument)
val updated = jsonElement["updated"]?.jsonPrimitive?.contentOrNull?.toInstant() ?: issued

val encryptionJson = jsonElement["encryption"] as? JsonObject
?: throw LcpException(LcpError.Parsing.LicenseDocument)
val encryption = LcpJson.decodeFromJsonElement(Encryption.serializer(), encryptionJson)

val linksJson = jsonElement["links"] as? JsonArray
?: throw LcpException(LcpError.Parsing.LicenseDocument)
val links = LcpJson.decodeFromJsonElement(Links.serializer(), linksJson)

val user = jsonElement["user"]?.let {
LcpJson.decodeFromJsonElement(User.serializer(), it)
} ?: User()

val rights = jsonElement["rights"]?.let {
LcpJson.decodeFromJsonElement(Rights.serializer(), it)
} ?: Rights()

val signatureJson = jsonElement["signature"] as? JsonObject
?: throw LcpException(LcpError.Parsing.LicenseDocument)
val signature = LcpJson.decodeFromJsonElement(Signature.serializer(), signatureJson)

return LicenseDocument(
provider = provider,
id = id,
issued = issued,
updated = updated,
encryption = encryption,
links = links,
user = user,
rights = rights,
signature = signature,
jsonString = rawJson
)
}

override fun serialize(encoder: Encoder, value: LicenseDocument) {
val output = encoder as JsonEncoder
val jsonObject = LcpJson.parseToJsonElement(value.jsonString) as JsonObject
output.encodeJsonElement(jsonObject)
}
}
Loading
Loading