diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..3a1bcdd --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,56 @@ +name: Documentation + +on: + push: + branches: + - main + - docs + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build-docs: + name: Build Documentation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: "17" + distribution: "temurin" + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Build Documentation + run: make docs + + - name: Upload Documentation Artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./build/docs + + deploy: + name: Deploy to GitHub Pages + needs: build-docs + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/Makefile b/Makefile index 13e7648..12fb2ea 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: all clean setup library publish download-binaries tests coverage help test-app example-app \ run-test-app run-example-app signing-server-start signing-server-stop signing-server-status \ - signing-server-build tests-with-server lint format + signing-server-build tests-with-server lint format docs docs-clean # Default target all: library @@ -84,6 +84,22 @@ format: exit 1; \ fi +# Generate API documentation using Dokka +docs: + @echo "Generating API documentation..." + @./gradlew generateDocs + @echo "" + @echo "Documentation generation complete!" + @echo "Output: build/docs/index.html" + @echo "To view: open build/docs/index.html" + +# Clean generated documentation +docs-clean: + @echo "Cleaning generated documentation..." + @rm -rf build/docs + @rm -f library/build/libs/c2pa-release-javadoc.jar + @echo "Documentation cleaned" + # File to store the server PID SIGNING_SERVER_PID_FILE := .signing-server.pid @@ -198,6 +214,10 @@ help: @echo " lint - Run Android lint checks" @echo " format - Format all Kotlin files with ktlint" @echo "" + @echo "Documentation:" + @echo " docs - Generate API documentation with Dokka" + @echo " docs-clean - Clean generated documentation" + @echo "" @echo "Signing Server (for hardware signing tests):" @echo " signing-server-build - Build the signing server" @echo " signing-server-run - Run the signing server in foreground" diff --git a/build.gradle.kts b/build.gradle.kts index b3a1e28..129599f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,6 +9,63 @@ plugins { id("jacoco") } -tasks.register("clean", Delete::class) { - delete(rootProject.layout.buildDirectory) +tasks.register("clean", Delete::class) { delete(rootProject.layout.buildDirectory) } +val dokkaRuntime by configurations.creating +// Configuration for Dokka plugins (separate from CLI) +val dokkaPlugins by configurations.creating + +dependencies { + dokkaRuntime("org.jetbrains.dokka:dokka-cli:2.0.0") + dokkaPlugins("org.jetbrains.dokka:dokka-base:2.0.0") + dokkaPlugins("org.jetbrains.dokka:analysis-kotlin-descriptors:2.0.0") +} + +// Task to generate documentation using Dokka CLI +tasks.register("generateDocs") { + group = "documentation" + description = "Generate API documentation using Dokka CLI" + + classpath = dokkaRuntime + mainClass.set("org.jetbrains.dokka.MainKt") + + val outputDir = file("$rootDir/build/docs") + val sourceDir = file("$rootDir/library/src/main/kotlin") + val moduleDoc = file("$rootDir/library/MODULE.md") + + doFirst { + outputDir.deleteRecursively() + outputDir.mkdirs() + + println("Generating documentation...") + println(" Source: $sourceDir") + println(" Module doc: $moduleDoc") + println(" Output: $outputDir") + } + + // Build plugin classpath from dokkaPlugins configuration only + val pluginsClasspath = dokkaPlugins.files.joinToString(";") { it.absolutePath } + + // Build sourceSet argument with all parameters together + val sourceSetParams = buildList { + add("-src") + add(sourceDir.absolutePath) + if (moduleDoc.exists()) { + add("-includes") + add(moduleDoc.absolutePath) + } + add("-analysisPlatform") + add("jvm") + add("-documentedVisibilities") + add("PUBLIC;PROTECTED;INTERNAL") + add("-sourceSetName") + add("main") + }.joinToString(" ") + + args( + "-pluginsClasspath", pluginsClasspath, + "-outputDir", outputDir.absolutePath, + "-moduleName", "c2pa-android", + "-loggingLevel", "INFO", + "-sourceSet", sourceSetParams, + ) } diff --git a/gradle.properties b/gradle.properties index 0c44ef9..c589438 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,3 +23,5 @@ kotlin.code.style=official android.nonTransitiveRClass=true # Suppress SDK version compatibility warnings android.suppressUnsupportedCompileSdk=35 +# Dokka V1 mode (V2 has compatibility issues with Android Gradle Plugin 8.x) +# org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled diff --git a/library/MODULE.md b/library/MODULE.md new file mode 100644 index 0000000..d59c96a --- /dev/null +++ b/library/MODULE.md @@ -0,0 +1,36 @@ +# Module c2pa-android + +C2PA Android is a Kotlin wrapper around the C2PA C API, providing content authenticity and provenance functionality for Android applications. + +## Overview + +This library enables Android applications to create, read, and validate C2PA manifests, which provide cryptographic proof of content origin and history. The library uses JNI to bridge native C2PA libraries with Android's Kotlin/Java ecosystem. + +## Core Components + +### Content Authenticity + +- [Reader] - Read and validate C2PA manifests from media files +- [Builder] - Create new C2PA manifests with claims, assertions, and ingredients + +### Signing Methods + +The library supports multiple signing approaches: + +- **Direct signing** - Sign with in-memory private keys using [SignerInfo] +- **Callback signing** - Implement custom signing logic with [Signer] +- **Web service signing** - Delegate signing to remote servers with [WebServiceSigner] +- **Hardware security** - Use device hardware security modules with [StrongBoxSigner] or [KeyStoreSigner] + +### Hardware Security Integration + +- [StrongBoxSigner] - Hardware-backed signing using Android StrongBox +- [KeyStoreSigner] - Android Keystore signing with optional biometric authentication +- [CertificateManager] - Certificate generation and management for Android Keystore + +## Platform Requirements + +- **Minimum Android SDK**: 28 (Android 9.0 Pie) +- **Target Android SDK**: 35 +- **Kotlin**: 1.9+ +- **Hardware security** (optional): Devices with StrongBox or TEE support diff --git a/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt b/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt index d9a6053..588c9e7 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/Builder.kt @@ -3,12 +3,99 @@ package org.contentauth.c2pa import java.io.Closeable /** - * C2PA Builder for creating manifest stores + * C2PA Builder for creating and signing manifest stores. + * + * The Builder class provides an API for constructing C2PA manifests with claims, assertions, + * ingredients, and resources. It supports multiple signing methods and uses stream-based operations + * for memory efficiency. + * + * ## Usage + * + * ### Creating a basic signed manifest + * + * ```kotlin + * val manifestJson = """ + * { + * "claim_generator": "MyApp/1.0", + * "title": "Signed Photo", + * "assertions": [ + * { + * "label": "c2pa.actions", + * "data": { + * "actions": [{"action": "c2pa.created"}] + * } + * } + * ] + * } + * """.trimIndent() + * + * val builder = Builder.fromJson(manifestJson) + * + * val sourceStream = DataStream(imageBytes) + * val destStream = ByteArrayStream() + * + * builder.sign( + * format = "image/jpeg", + * source = sourceStream, + * dest = destStream, + * signer = signer + * ) + * + * val signedBytes = destStream.getData() + * ``` + * + * ### Adding ingredients (parent images) + * + * ```kotlin + * val builder = Builder.fromJson(manifestJson) + * + * val ingredientStream = DataStream(originalImageBytes) + * builder.addIngredient( + * ingredientJSON = """{"title": "Original Photo"}""", + * format = "image/jpeg", + * source = ingredientStream + * ) + * ``` + * + * ### Advanced: Data hash signing + * + * ```kotlin + * // Create placeholder for later signing + * val placeholder = builder.dataHashedPlaceholder( + * reservedSize = 4096, + * format = "image/jpeg" + * ) + * + * // Later, sign with the hash + * val signedManifest = builder.signDataHashedEmbeddable( + * signer = signer, + * dataHash = computeHash(placeholder), + * format = "image/jpeg" + * ) + * ``` + * + * ## Thread Safety + * + * Builder instances are not thread-safe. Each thread should use its own Builder instance. + * + * ## Resource Management + * + * Builder implements [Closeable] and must be closed when done to free native resources. Use `use { + * }` or explicitly call `close()`. + * + * @property ptr Internal pointer to the native C2PA builder instance + * @see Reader + * @see Signer + * @see Stream + * @since 1.0.0 */ class Builder internal constructor(private var ptr: Long) : Closeable { /** - * Sign result containing size and optional manifest bytes + * Result of a signing operation containing the manifest size and optional manifest bytes. + * + * @property size The size of the signed manifest in bytes (negative values indicate errors) + * @property manifestBytes Optional manifest data (null for embedded manifests) */ data class SignResult(val size: Long, val manifestBytes: ByteArray?) @@ -18,7 +105,32 @@ class Builder internal constructor(private var ptr: Long) : Closeable { } /** - * Create a builder from JSON + * Creates a builder from a manifest definition in JSON format. + * + * The JSON should contain the manifest structure including claims, assertions, and metadata + * according to the C2PA specification. This is useful for programmatically constructing + * manifests or loading manifest templates. + * + * @param manifestJSON The manifest definition as a JSON string + * @return A Builder instance configured with the provided manifest + * @throws C2PAError.Api if the JSON is invalid or doesn't conform to the C2PA manifest + * schema + * + * @sample + * ```kotlin + * val manifestJson = """ + * { + * "claim_generator": "MyApp/1.0", + * "assertions": [ + * { + * "label": "c2pa.actions", + * "data": {"actions": [{"action": "c2pa.edited"}]} + * } + * ] + * } + * """ + * val builder = Builder.fromJson(manifestJson) + * ``` */ @JvmStatic @Throws(C2PAError::class) @@ -28,7 +140,15 @@ class Builder internal constructor(private var ptr: Long) : Closeable { } /** - * Create a builder from an archive stream + * Creates a builder from a C2PA archive stream. + * + * A C2PA archive is a portable format containing a manifest and its associated resources. + * This method is useful for importing manifests that were previously exported or created by + * other tools. + * + * @param archive The input stream containing the C2PA archive + * @return A Builder instance loaded from the archive + * @throws C2PAError.Api if the archive is invalid or corrupted */ @JvmStatic @Throws(C2PAError::class) @@ -37,21 +157,15 @@ class Builder internal constructor(private var ptr: Long) : Closeable { if (handle == 0L) null else Builder(handle) } - @JvmStatic - private external fun nativeFromJson(manifestJson: String): Long + @JvmStatic private external fun nativeFromJson(manifestJson: String): Long - @JvmStatic - private external fun nativeFromArchive(streamHandle: Long): Long + @JvmStatic private external fun nativeFromArchive(streamHandle: Long): Long } - /** - * Set the no-embed flag - */ + /** Set the no-embed flag */ fun setNoEmbed() = setNoEmbedNative(ptr) - /** - * Set the remote URL - */ + /** Set the remote URL */ @Throws(C2PAError::class) fun setRemoteURL(url: String) { val result = setRemoteUrlNative(ptr, url) @@ -60,9 +174,7 @@ class Builder internal constructor(private var ptr: Long) : Closeable { } } - /** - * Add a resource to the builder - */ + /** Add a resource to the builder */ @Throws(C2PAError::class) fun addResource(uri: String, stream: Stream) { val result = addResourceNative(ptr, uri, stream.rawPtr) @@ -71,9 +183,7 @@ class Builder internal constructor(private var ptr: Long) : Closeable { } } - /** - * Add an ingredient from a stream - */ + /** Add an ingredient from a stream */ @Throws(C2PAError::class) fun addIngredient(ingredientJSON: String, format: String, source: Stream) { val result = addIngredientFromStreamNative(ptr, ingredientJSON, format, source.rawPtr) @@ -82,9 +192,7 @@ class Builder internal constructor(private var ptr: Long) : Closeable { } } - /** - * Write the builder to an archive - */ + /** Write the builder to an archive */ @Throws(C2PAError::class) fun toArchive(dest: Stream) { val result = toArchiveNative(ptr, dest.rawPtr) @@ -93,9 +201,7 @@ class Builder internal constructor(private var ptr: Long) : Closeable { } } - /** - * Sign and write the manifest - */ + /** Sign and write the manifest */ @Throws(C2PAError::class) fun sign(format: String, source: Stream, dest: Stream, signer: Signer): SignResult { val result = signNative(ptr, format, source.rawPtr, dest.rawPtr, signer.ptr) @@ -105,9 +211,7 @@ class Builder internal constructor(private var ptr: Long) : Closeable { return result } - /** - * Create a hashed placeholder for later signing - */ + /** Create a hashed placeholder for later signing */ @Throws(C2PAError::class) fun dataHashedPlaceholder(reservedSize: Long, format: String): ByteArray { val result = dataHashedPlaceholderNative(ptr, reservedSize, format) @@ -117,18 +221,17 @@ class Builder internal constructor(private var ptr: Long) : Closeable { return result } - /** - * Sign using data hash (advanced use) - */ + /** Sign using data hash (advanced use) */ @Throws(C2PAError::class) fun signDataHashedEmbeddable(signer: Signer, dataHash: String, format: String, asset: Stream? = null): ByteArray { - val result = signDataHashedEmbeddableNative( - ptr, - signer.ptr, - dataHash, - format, - asset?.rawPtr ?: 0L, - ) + val result = + signDataHashedEmbeddableNative( + ptr, + signer.ptr, + dataHash, + format, + asset?.rawPtr ?: 0L, + ) if (result == null) { throw C2PAError.Api(C2PA.getError() ?: "Failed to sign with data hash") } diff --git a/library/src/main/kotlin/org/contentauth/c2pa/KeyStoreSigner.kt b/library/src/main/kotlin/org/contentauth/c2pa/KeyStoreSigner.kt index 879f7aa..fd0b411 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/KeyStoreSigner.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/KeyStoreSigner.kt @@ -26,11 +26,13 @@ import kotlin.coroutines.suspendCoroutine * certificateChainPEM = certificateChain, * keyAlias = "my-signing-key" * ) + * ``` * - * // With biometric authentication: + * With biometric authentication: + * ```kotlin * val signer = KeyStoreSigner.createBiometricSigner( * activity = activity, - * keyAlias = "my-key", + * keyAlias = "my-biometric-key", * algorithm = SigningAlgorithm.ES256, * certificateChainPEM = certificateChain * ) diff --git a/library/src/main/kotlin/org/contentauth/c2pa/Reader.kt b/library/src/main/kotlin/org/contentauth/c2pa/Reader.kt index 34fb2dc..6b35042 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/Reader.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/Reader.kt @@ -3,7 +3,56 @@ package org.contentauth.c2pa import java.io.Closeable /** - * C2PA Reader for reading manifest stores + * C2PA Reader for reading and validating manifest stores from media files. + * + * The Reader class provides functionality to extract C2PA manifests from media files and access + * embedded resources. It uses stream-based operations for memory efficiency. + * + * ## Usage + * + * ### Reading a manifest from a file + * + * ```kotlin + * val stream = DataStream(imageBytes) + * val reader = Reader.fromStream("image/jpeg", stream) + * val manifestJson = reader.json() + * ``` + * + * ### Parsing manifest data + * + * ```kotlin + * val manifestJson = reader.json() + * val manifest = JSONObject(manifestJson) + * + * manifest.optJSONObject("active_manifest")?.let { activeManifest -> + * val title = activeManifest.optString("title") + * val claimGenerator = activeManifest.optString("claim_generator") + * } + * ``` + * + * ### Extracting embedded resources + * + * ```kotlin + * val thumbnailUri = "self#jumbf=/c2pa/urn:uuid:12345/c2pa.thumbnail.claim.jpeg" + * val outputStream = ByteArrayStream() + * + * reader.resource(thumbnailUri, outputStream) + * val thumbnailBytes = outputStream.getData() + * ``` + * + * ## Thread Safety + * + * Reader instances are not thread-safe. Each thread should use its own Reader instance. + * + * ## Resource Management + * + * Reader implements [Closeable] and must be closed when done to free native resources. Use `use { + * }` or explicitly call `close()`. + * + * @property ptr Internal pointer to the native C2PA reader instance + * @see Builder + * @see Stream + * @since 1.0.0 */ class Reader internal constructor(private var ptr: Long) : Closeable { @@ -13,7 +62,25 @@ class Reader internal constructor(private var ptr: Long) : Closeable { } /** - * Create a reader from a stream + * Creates a reader from a stream containing media with an embedded C2PA manifest. + * + * This is the primary method for reading C2PA manifests from media files. The stream should + * contain the complete media file (e.g., JPEG, PNG, MP4) with an embedded manifest. + * + * @param format The MIME type of the media (e.g., "image/jpeg", "image/png", "video/mp4") + * @param stream The input stream containing the media file + * @return A Reader instance for accessing the manifest + * @throws C2PAError.Api if the stream doesn't contain a valid C2PA manifest or the format + * is unsupported + * + * @sample + * ```kotlin + * val inputStream = FileInputStream("signed_photo.jpg") + * val stream = Stream.fromInputStream(inputStream) + * val reader = Reader.fromStream("image/jpeg", stream).use { reader -> + * reader.json() + * } + * ``` */ @JvmStatic @Throws(C2PAError::class) @@ -24,7 +91,24 @@ class Reader internal constructor(private var ptr: Long) : Closeable { } /** - * Create a reader from manifest data and stream + * Creates a reader from manifest data and an associated media stream. + * + * This method is used when the manifest is stored separately from the media file, such as + * with sidecar manifests or remote manifests. The manifest data should be in C2PA binary + * format. + * + * @param format The MIME type of the media (e.g., "image/jpeg", "image/png") + * @param stream The input stream containing the media file + * @param manifest The manifest data as a byte array + * @return A Reader instance for accessing the manifest + * @throws C2PAError.Api if the manifest data is invalid or incompatible with the media + * + * @sample + * ```kotlin + * val mediaStream = Stream.fromInputStream(FileInputStream("photo.jpg")) + * val manifestBytes = File("photo.c2pa").readBytes() + * val reader = Reader.fromManifestAndStream("image/jpeg", mediaStream, manifestBytes) + * ``` */ @JvmStatic @Throws(C2PAError::class) @@ -34,8 +118,7 @@ class Reader internal constructor(private var ptr: Long) : Closeable { if (handle == 0L) null else Reader(handle) } - @JvmStatic - private external fun fromStreamNative(format: String, streamHandle: Long): Long + @JvmStatic private external fun fromStreamNative(format: String, streamHandle: Long): Long @JvmStatic private external fun fromManifestDataAndStreamNative( @@ -46,7 +129,25 @@ class Reader internal constructor(private var ptr: Long) : Closeable { } /** - * Convert the reader to JSON + * Converts the C2PA manifest to a JSON string representation. + * + * The returned JSON contains the complete manifest store including all claims, assertions, + * signatures, and validation results. The structure follows the C2PA specification's JSON + * format. + * + * @return The manifest as a JSON string + * @throws C2PAError.Api if the manifest cannot be serialized to JSON + * + * @sample + * ```kotlin + * val reader = Reader.fromStream("image/jpeg", stream) + * val json = reader.json() + * val manifest = JSONObject(json) + * val author = manifest.getJSONObject("active_manifest") + * .getJSONArray("assertions") + * .getJSONObject(0) + * .getString("author") + * ``` */ @Throws(C2PAError::class) fun json(): String { @@ -58,7 +159,30 @@ class Reader internal constructor(private var ptr: Long) : Closeable { } /** - * Write a resource to a stream + * Extracts an embedded resource from the manifest and writes it to a stream. + * + * C2PA manifests can contain embedded resources such as thumbnails, ingredient images, or other + * assets. This method allows you to extract these resources by their URI. + * + * Resource URIs typically follow the pattern: + * `self#jumbf=/c2pa/urn:uuid:/` + * + * @param uri The URI of the resource to extract (found in the manifest JSON) + * @param to The output stream to write the resource data to + * @throws C2PAError.Api if the resource URI is not found or cannot be extracted + * + * @sample + * ```kotlin + * val reader = Reader.fromStream("image/jpeg", stream) + * val manifestJson = reader.json() + * + * // Parse JSON to find thumbnail URI + * val thumbnailUri = "self#jumbf=/c2pa/urn:uuid:12345/c2pa.thumbnail.claim.jpeg" + * + * val outputStream = FileOutputStream("thumbnail.jpg") + * val outputStreamWrapper = Stream.fromOutputStream(outputStream) + * reader.resource(thumbnailUri, outputStreamWrapper) + * ``` */ @Throws(C2PAError::class) fun resource(uri: String, to: Stream) { @@ -68,6 +192,12 @@ class Reader internal constructor(private var ptr: Long) : Closeable { } } + /** + * Closes the reader and releases native resources. + * + * This method must be called when the reader is no longer needed to prevent native memory + * leaks. It's safe to call this method multiple times. + */ override fun close() { if (ptr != 0L) { free(ptr) diff --git a/library/src/main/kotlin/org/contentauth/c2pa/StrongBoxSigner.kt b/library/src/main/kotlin/org/contentauth/c2pa/StrongBoxSigner.kt index 87a664c..c7b6ac4 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/StrongBoxSigner.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/StrongBoxSigner.kt @@ -26,9 +26,14 @@ import javax.security.auth.x500.X500Principal * Example usage: * ```kotlin * val config = StrongBoxSigner.Config( - * keyTag = "strongbox-key", - * requireUserAuthentication = true + * keyTag = "my-strongbox-key", + * requireUserAuthentication = false * ) + * + * if (!StrongBoxSigner.keyExists(config.keyTag)) { + * StrongBoxSigner.createKey(config) + * } + * * val signer = StrongBoxSigner.createSigner( * algorithm = SigningAlgorithm.ES256, * certificateChainPEM = certificateChain, diff --git a/library/src/main/kotlin/org/contentauth/c2pa/WebServiceSigner.kt b/library/src/main/kotlin/org/contentauth/c2pa/WebServiceSigner.kt index 08046eb..360e7bd 100644 --- a/library/src/main/kotlin/org/contentauth/c2pa/WebServiceSigner.kt +++ b/library/src/main/kotlin/org/contentauth/c2pa/WebServiceSigner.kt @@ -18,6 +18,7 @@ import java.util.concurrent.TimeUnit * configurationURL = "http://10.0.2.2:8080/api/v1/c2pa/configuration", * bearerToken = "your-token-here" * ) + * * val signer = webServiceSigner.createSigner() * ``` */